普通视图

发现新文章,点击刷新页面。
昨天 — 2026年4月30日首页

React 19 源码主线拆解 04:Fiber 到底是什么,React 为什么需要 Fiber?

作者 倾颜
2026年4月30日 19:47

这是我持续更新的一组 React 源码解读文章,也会尽量控制单篇篇幅,按主线一点点往里拆。
这一篇先不急着进入 beginWorkcompleteWork 和完整 render 流程,而是先把 React 运行时里最关键的工作节点:Fiber,单独理清楚。

前言

上一篇里,我们已经把 React 主线从 ReactElement 推进到了根级更新系统。

简单回顾一下:

  • createRoot(container) 初始化了 Root 系统
  • root.render(element) 把 ReactElement 送进了根级更新流程
  • ReactElement 会被包装成一次 Update
  • 这次 Update 会挂到 HostRoot Fiber 的 updateQueue

也就是说,主线已经走到了这里:

ReactElement → Update → HostRoot Fiber.updateQueue

到这一步,Fiber 这个词已经绕不开了。

因为继续往后看,很快就会遇到这些问题:

  • HostRoot Fiber 到底是什么?
  • FiberRoot 和 Fiber 是一个东西吗?
  • Update 为什么要挂到 Fiber 的 updateQueue 上?
  • 后面的 render 阶段,为什么不是直接处理 ReactElement,而是围绕 Fiber 展开?
  • commit 阶段为什么又要看 Fiber 上的 flags?

这些问题继续往下追,都会回到一个更基础的问题上:

Fiber 到底是什么?React 为什么需要 Fiber?

所以这一篇不急着进入 beginWork,也不急着讲完整的 render work loop,而是先把 Fiber 这个运行时工作节点本身讲清楚。

这篇文章主要想回答几个问题:

  • Fiber 到底是什么,为什么不能把它理解成 ReactElement
  • React 为什么需要 Fiber 这样的运行时工作节点
  • FiberRoot、HostRoot Fiber、FiberNode、Fiber 树分别是什么关系
  • Fiber 树是怎么通过 child / sibling / return 组织起来的
  • alternate 和双缓存树到底解决了什么问题

这里也先说明一下版本口径:这篇文章标题写的是 React 19,因为整体讨论的是 React 19 的主线机制;但在具体源码观察上,我会先以 React 19.1.1 作为基线来展开。


一、先说结论:Fiber 是 React 的运行时工作节点

先把这一篇最核心的结论放在前面:

ReactElement 描述“我要渲染什么”,Fiber 承载“React 怎么处理这次工作”。

这句话是理解 Fiber 的第一层边界。

第二篇里,我们已经讲过 ReactElement。它是 JSX 编译并运行之后产出的描述对象,大致描述了:

  • 这是什么节点
  • 节点的 type 是什么
  • 节点上有什么 props
  • 有没有 key
  • 有没有 ref

但 ReactElement 本身很轻。
它只是描述“我要渲染什么”,并不负责记录后续工作过程。

它不会保存:

  • 当前节点的状态
  • 当前节点的 updateQueue
  • 当前节点有没有副作用
  • 当前节点的调度优先级
  • 当前节点和旧节点之间的复用关系

这些运行时信息,真正会落到 Fiber 上。

所以如果把 React 主线从输入到落地简单画一下,大概是这样:

graph TD
    A["JSX"] --> B["ReactElement:描述要渲染什么"]
    B --> C["Update:进入更新系统"]
    C --> D["Fiber:承载运行时工作"]
    D --> E["DOM:宿主环境真实节点"]

这张图里最重要的不是箭头本身,而是分层:

  • ReactElement 是输入描述
  • Fiber 是运行时工作结构
  • DOM 是最终落到宿主环境里的真实节点

所以这一篇最先要立住的边界就是:

Fiber 不是 ReactElement 的别名,而是 React 运行时用来组织更新、保存状态、标记副作用、支持调度和复用的工作节点。

后面再看 updateQueuelanesflagsalternate,这些东西才不会变成一堆散乱字段。


二、为什么 React 需要 Fiber

理解 Fiber,最好先从它解决的问题开始。
如果只盯着字段看,很容易把 Fiber 理解成一个很大的对象。

但 Fiber 真正重要的地方,不是“字段很多”,而是它改变了 React 组织更新工作的方式。

在旧式同步递归模型里,一次更新更像是:

从根节点开始
一路递归往下处理
直到整棵树处理完

这种方式的问题是:一旦开始处理一棵大树,就容易一口气跑到底。

如果组件树很大,中间又有很多节点需要计算,就可能长时间占用主线程。
而主线程一旦被长时间占用,页面响应、用户输入、动画这些事情都会受到影响。

更关键的是,纯同步递归模型不容易回答这些问题:

  • 工作做到哪里了?
  • 能不能先暂停一下?
  • 能不能稍后继续?
  • 能不能让更高优先级的更新先处理?
  • 能不能复用上一次已经创建过的节点信息?

React 需要的不只是“递归渲染一棵树”,而是要把一次更新拆成一批更容易管理的工作单元。

Fiber 就是在这个背景下出现的。

一个 Fiber 可以理解成一个节点级工作单元。
它不仅知道自己代表什么节点,还能记录:

  • 自己在树里的位置
  • 本轮待处理的 props
  • 上一次保存下来的状态
  • 当前节点上有没有更新
  • 当前节点或子树有没有副作用
  • 当前节点和旧节点之间怎么复用
  • 当前节点上有哪些优先级的工作

所以 Fiber 最大的意义,不是把树换一种写法,而是:

让 React 不只是递归渲染一棵树,而是能管理一批可记录、可复用、可调度的工作单元。

Scheduler、时间切片、Concurrent Rendering 的完整细节可以先放一放。
这一篇先抓住一点就够了:

Fiber 是 React 后续更新、render、commit 能够围绕一套工作节点推进的基础。


三、Fiber、FiberNode、Fiber 树、FiberRoot、HostRoot Fiber 是什么关系

Fiber 这个词很容易让人混,因为它在不同上下文里会指向不同层次的东西。

比如我们会看到:

  • Fiber
  • FiberNode
  • Fiber 树
  • FiberRoot
  • HostRoot Fiber
  • ReactDOMRoot

这些词如果不先拆开,后面源码会越看越乱。

这一节先把它们的关系讲清楚。

1. FiberNode:单个工作节点

从源码结构上看,具体的数据结构通常是 FiberNode。

我们平时说“一个 Fiber”,很多时候指的就是一个 FiberNode。

它可以对应不同类型的 React 节点,比如:

  • 函数组件
  • 类组件
  • 原生 DOM 标签
  • Fragment
  • Suspense

这里不用把所有 tag 类型都列出来。
先把它粗略理解成一句话:

一个 FiberNode,就是 React 运行时里的一个节点级工作单元。

2. Fiber 树:由 FiberNode 连接成的工作树

单个 FiberNode 不是孤立存在的。
很多 FiberNode 会通过指针连接起来,形成一棵 Fiber 树。

这棵树不是 DOM 树,也不是 ReactElement 树。
它是 React 运行时真正用来推进工作的树。

后面的 render 阶段,React 会围绕 Fiber 树进行计算。
commit 阶段,也会根据 Fiber 上记录的副作用标记去执行真实更新。

3. HostRoot Fiber:Fiber 树最顶层的根 Fiber

第三篇里,我们已经见过 HostRoot Fiber。

它是 Fiber 树内部最顶层的根 Fiber。

注意这里的关键词是:Fiber 树内部

HostRoot Fiber 本身是一个 FiberNode,只不过它处在整棵 Fiber 树的最顶层。
根级 updateQueue 也会挂在这个 HostRoot Fiber 上。

所以第三篇里看到:

HostRoot Fiber.updateQueue

其实就是在说:

根更新先挂到 Fiber 树最顶层的那个 Fiber 上。

4. FiberRoot:树外侧的根级状态容器

FiberRoot 和 HostRoot Fiber 不是一个东西。

FiberRoot 更像是整棵树外侧的根级状态容器。
它保存根级别的信息,比如:

  • 宿主容器 container
  • 当前 Fiber 树入口
  • pending lanes
  • 根级调度状态

FiberRoot 会通过 current 指向当前 Fiber 树的 HostRoot Fiber:

FiberRoot.current -> HostRoot Fiber

也就是说:

  • FiberRoot 在树外,管理整棵树的根级状态
  • HostRoot Fiber 在树内,是 Fiber 树的根节点

这个边界非常重要。

5. ReactDOMRoot:对外暴露的 root 句柄

业务代码里写的是:

const root = createRoot(container)

这里拿到的 root,不是 FiberRoot,也不是 HostRoot Fiber,而是 ReactDOMRoot。

ReactDOMRoot 是 React DOM 暴露给业务代码使用的 root 句柄。
它内部再通过 _internalRoot 持有真正的 FiberRoot。

把这几层合起来,大概是这样:

graph TD
    A["ReactDOMRoot:业务代码拿到的 root"] --> B["_internalRoot"]
    B --> C["FiberRoot:树外根级状态容器"]
    C --> D["current"]
    D --> E["HostRoot Fiber:Fiber 树根节点"]
    E --> F["child"]
    F --> G["App Fiber"]

所以这一节最重要的结论是:

FiberRoot 是树外的根级状态容器,HostRoot Fiber 才是 Fiber 树内部的根节点。

只要这个边界分清楚,后面再看到 root.currentroot.stateNode、HostRoot Fiber,就不会混成一团。


四、ReactElement 和 Fiber 到底有什么区别

第二篇里,我们已经讲过 ReactElement。
这一篇从 Fiber 的角度,再把两者边界补完整。

很多人第一次看源码时,会把这条链想得过于简单:

JSX → ReactElement → Fiber

好像 ReactElement 只是换个名字就变成了 Fiber。

但实际上,ReactElement 和 Fiber 是两层完全不同的东西。

1. ReactElement:描述“我要渲染什么”

ReactElement 是输入描述对象。

它主要描述:

  • type
  • key
  • ref
  • props

它的任务是表达:

“我要渲染一个什么东西?”

比如:

<App count={1} />

最终会变成一个 ReactElement。
这个 ReactElement 描述了:这里要渲染一个 App,并且带着 count: 1 这样的 props。

但 ReactElement 本身不负责保存运行时工作信息。

它没有:

  • updateQueue
  • lanes
  • flags
  • subtreeFlags
  • alternate
  • memoizedState

所以它不是后续 render / commit 真正围绕处理的工作节点。

2. Fiber:承载“React 怎么处理这次工作”

Fiber 则不一样。

Fiber 要回答的问题不是“我要渲染什么”,而是:

“这次更新里,这个节点要怎么被处理?”

所以 Fiber 上会保存更多运行时信息,比如:

  • 当前节点在 Fiber 树里的位置
  • 当前节点上一次保存下来的 props / state
  • 当前节点本轮待处理的新 props
  • 当前节点上有没有 updateQueue
  • 当前节点或子树有没有副作用标记
  • 当前节点有哪些 lanes
  • 当前节点和另一棵树里的对应 Fiber 是什么关系

如果用表格对比,会更清楚:

对比项 ReactElement Fiber
定位 输入描述对象 运行时工作节点
来源 JSX 编译后运行时创建 render 过程中创建或复用
是否保存状态 不保存 保存 memoizedState
是否有更新队列 没有 可以有 updateQueue
是否记录副作用 不记录 通过 flags 等字段记录
是否参与调度 不直接参与 通过 lanes / childLanes 参与
是否关联旧节点 不关联 通过 alternate 关联

所以这一节最核心的一句话是:

ReactElement 是输入描述,Fiber 是运行时工作结构。

这一篇先把两者边界立住。
后面再进入 render 阶段时,才能更顺地理解 Fiber 子树是怎么被构建出来的。


五、child / sibling / return 如何组织 Fiber 树

既然 Fiber 是一棵运行时工作树,那这棵树是怎么组织起来的?

为了更直观,可以先看一个很简单的 JSX:

function App() {
  return (
    <>
      <Header />
      <Main />
      <Footer />
    </>
  )
}

从 ReactElement 的角度看,这里描述的是 App 下面有三个子节点:HeaderMainFooter

而到了 Fiber 这层,React 不会简单把它们理解成一个普通数组,而是会用 child / sibling / return 把它们串成一棵 Fiber 树。

FiberNode 里有三个非常关键的树结构字段:

  • child
  • sibling
  • return

它们共同把一个个 FiberNode 连接成 Fiber 树。

1. child:第一个子 Fiber

child 指向当前 Fiber 的第一个子 Fiber。

比如 App 下面有 Header、Main、Footer 三个子节点。
那么 App Fiber 的 child 会指向第一个子节点,也就是 Header Fiber。

2. sibling:下一个兄弟 Fiber

如果 Header 后面还有 Main,那么 Header Fiber 的 sibling 会指向 Main Fiber。
Main 后面还有 Footer,那么 Main Fiber 的 sibling 会指向 Footer Fiber。

也就是说,同一层的兄弟节点,不是都放在一个数组里,而是通过 sibling 串起来。

3. return:父 Fiber

return 指向当前 Fiber 的父 Fiber。

这里的 return 不是 JavaScript 里的 return 语句,而是 FiberNode 上的一个字段。
可以先把它理解成:

“处理完当前节点后,应该回到哪里?”

用一张图看会更直观:

graph LR
    A["App Fiber"] --> B["child:Header Fiber"]
    B --> C["sibling:Main Fiber"]
    C --> D["sibling:Footer Fiber"]

这里的 Header Fiber、Main Fiber、Footer Fiber 的 return 都会指回 App Fiber。
这样 React 在处理完某个子节点或兄弟节点之后,就能继续回到父节点,接着推进后续工作。

所以这一节可以先收成一句话:

child / sibling / return 让 Fiber 树可以用链表式结构表示,也为后面的 work loop 遍历打下基础。

这里先不展开 beginWorkcompleteWork 和完整深度优先遍历。
那是后面讲 render 阶段时要重点看的内容。


六、alternate 和双缓存树:current / workInProgress 是什么

讲 Fiber,绕不开 alternate

因为后面进入 render 阶段时,会不断看到这些词:

  • current
  • workInProgress
  • alternate

如果这里不先建立基本认知,后面看 render 会非常容易乱。

1. current 树:当前已经提交的 Fiber 树

React 已经提交到页面上的那棵 Fiber 树,可以先理解成 current 树。

FiberRoot 的 current 会指向当前这棵树的 HostRoot Fiber。

也就是说,当前页面已经对应着一棵 Fiber 树,这棵树就是 current 树。

2. workInProgress 树:本轮更新正在构建的新树

当一次新的更新开始时,React 不会直接在 current 树上乱改。

它会基于 current 树创建或复用一棵新的工作树,也就是 workInProgress 树。

这棵树可以先理解成:

本轮更新正在计算中的下一版 UI 状态。

也就是说:

  • current 树代表当前页面已经确认的状态
  • workInProgress 树代表本轮更新正在计算的新状态

3. alternate:连接 current Fiber 和 workInProgress Fiber

current 树和 workInProgress 树不是完全孤立的。

两个树里对应的 Fiber,会通过 alternate 关联起来。

可以粗略理解成:

current Fiber  <── alternate ──>  workInProgress Fiber

用图表示就是:

graph LR
    A["current Fiber:当前已提交"] <-->|alternate| B["workInProgress Fiber:本轮正在构建"]

有了这个关联之后,React 就能知道:

  • 旧 Fiber 是谁
  • 新 Fiber 是谁
  • 哪些信息可以复用
  • 哪些信息需要更新
  • 本轮工作和上一轮工作之间是什么关系

create-work-in-progress-alternate.png

这张图不用逐行记。这里最关键的是三件事:

  • React 会先从 current.alternate 上尝试取得对应的 workInProgress Fiber
  • 如果不存在,就创建新的 Fiber,并让 currentworkInProgress 通过 alternate 双向连接
  • 如果已经存在,就复用这个 workInProgress,同时重置本轮更新相关的 flags / subtreeFlags / deletions

所以 alternate 不是一个孤立字段,它正是 current 树和 workInProgress 树之间的连接点。

4. 为什么要有双缓存树

双缓存树可以先用一个很直观的比喻理解:

前台展示一棵已经稳定的树,后台准备另一棵新的树。

React 不直接在 current 树上乱改,而是先构建 workInProgress 树。
等这棵新树处理完成,并且进入 commit 阶段后,再把它切换成新的 current 树。

也就是说:

current tree
    ↓  基于它构建
workInProgress tree
    ↓  commit 后
new current tree

更完整一点可以这样理解:

graph TD
    A["FiberRoot.current"] --> B["current tree"]
    B <-->|alternate| C["workInProgress tree"]
    C --> D["commit 后成为新的 current"]

这一节最重要的结论是:

alternate 把旧 Fiber 和新 Fiber 关联起来,是双缓存树、节点复用和后续可中断渲染的重要基础。

createWorkInProgressreconcileChildren、bailout 这些细节可以先放一放。
这一层先把 current / workInProgress / alternate 的关系理清就够了。


七、FiberNode 的关键字段:为什么它不是普通树节点

如果只把 Fiber 看成一个树节点,就会低估它。

Fiber 不只是有 child / sibling / return 这些树结构字段。
它还会把 React 后续工作需要的信息都组织在一个节点上。

先看一张 FiberNode 构造函数里的源码截图:

fiber-node-fields.png

这张图不用逐行记。重点是先看到几个分组:

  • tag / key / type / stateNode:节点身份和实例连接
  • return / child / sibling:Fiber 树结构
  • pendingProps / memoizedProps / memoizedState:输入和状态
  • updateQueue:更新队列
  • flags / subtreeFlags / deletions:副作用标记
  • lanes / childLanes:调度相关信息
  • alternate:连接另一棵树里的对应 Fiber

如果把这些字段再按功能整理一下,大概是这样:

FiberNode
├── 身份:tag / type / key
├── 树结构:return / child / sibling
├── 输入与状态:pendingProps / memoizedProps / memoizedState
├── 更新:updateQueue
├── 副作用标记:flags / subtreeFlags / deletions
├── 调度:lanes / childLanes
├── 复用:alternate
└── 宿主连接:stateNode

这里不需要逐个背字段,更适合按功能看它们分别解决什么问题。

1. 身份信息:tag / type / key

这一组字段回答的是:

这个 Fiber 代表什么类型的节点?

比如:

  • tag 表示 Fiber 类型
  • type 表示具体组件函数、类、原生标签等
  • key 用于同层节点比较和复用

这决定了 React 后面应该用什么方式处理这个 Fiber。

2. 树结构:return / child / sibling

这一组字段回答的是:

这个 Fiber 在树里的位置在哪里?

前面已经讲过:

  • child 指向第一个子 Fiber
  • sibling 指向下一个兄弟 Fiber
  • return 指向父 Fiber

它们让 Fiber 能够连成一棵可遍历的工作树。

3. 输入与状态:pendingProps / memoizedProps / memoizedState

这一组字段回答的是:

当前节点的新输入是什么,已经保存下来的状态是什么?

大致可以先这样理解:

  • pendingProps:本轮待处理的新 props
  • memoizedProps:上一次已经确认下来的 props
  • memoizedState:当前 Fiber 上保存的状态

这里可以先记住一点:Fiber 不只是描述节点,它还保存运行时状态。

函数组件 Hooks 的状态,后面也会和 memoizedState 这条线有关。
但这里先不展开 Hook 链表,后面讲 Hooks 内部实现时再继续看。

4. 更新:updateQueue

这一组字段回答的是:

这个节点上有没有等待处理的更新?

第三篇里,我们已经看到 HostRoot Fiber 上有 updateQueue
root.render(element) 触发的根更新,就会挂到 HostRoot Fiber 的 updateQueue 上。

到了更一般的组件更新里,Fiber 也会成为更新队列挂载和后续消费的工作节点。

这里先不展开 updateQueue 内部结构。
下一篇进入“一次更新怎么进入系统”时,会继续看 Update、Queue、Lane 和 Schedule。

5. 副作用标记:flags / subtreeFlags / deletions

这一组字段回答的是:

这个节点或它的子树,在 commit 阶段有没有事情要做?

比如:

  • 是否需要插入 DOM
  • 是否需要更新 DOM
  • 是否有节点需要删除
  • 子树里是否存在副作用

这三个字段可以先这样理解:

  • flags:当前 Fiber 自身的副作用标记
  • subtreeFlags:当前 Fiber 子树里的副作用汇总标记
  • deletions:本轮需要删除的子 Fiber

这里有一个边界必须说清楚:

Fiber 上记录的是副作用标记,不是立刻执行副作用。

真正执行 DOM 操作和 effect 的地方,是后面的 commit 阶段。
Fiber 在这里做的是记录和标记,让 commit 阶段知道后面要做什么。

6. 调度:lanes / childLanes

这一组字段回答的是:

这个 Fiber 以及它的子树上,有哪些优先级的工作需要处理?

先不用急着进入位运算细节。
可以粗略理解成:

  • lanes 和当前 Fiber 自身的更新有关
  • childLanes 和子树里的更新有关

它们会帮助 React 判断哪些工作需要被处理,哪些子树里还有待完成的更新。

这一部分也会在后面讲 Update 和 Lane 时继续接上。

7. 复用:alternate

alternate 回到前面第六节讲的双缓存树。

它回答的是:

这个 Fiber 和另一棵树里的对应 Fiber 是什么关系?

有了 alternate,React 才能在 current 树和 workInProgress 树之间建立关联,从而为复用和后续工作推进打基础。

8. 宿主连接:stateNode

stateNode 的含义会随着 Fiber 类型不同而不同。

比如:

  • HostComponent 对应的 stateNode 可能是 DOM 节点
  • ClassComponent 对应的 stateNode 可能是类组件实例
  • HostRoot Fiber 的 stateNode 指向 FiberRoot

这里不用展开所有情况。
先知道 stateNode 是 Fiber 和实际实例 / 宿主对象之间的一条连接即可。

所以这一节最后可以收成一句话:

Fiber 不是普通树节点,而是 React 运行时把状态、更新、副作用标记、调度优先级和复用关系组织在一起的工作节点。


八、把 Fiber 接回 React 主线

到这里,Fiber 这层结构就基本立起来了。

如果把前几篇串起来,现在 React 主线已经可以这样理解:

JSX
→ ReactElement
→ Root / update system
→ Fiber 作为运行时工作节点

也就是说:

  • 第二篇讲清楚了 React 的输入对象是 ReactElement
  • 第三篇讲清楚了 ReactElement 会先进入根级更新系统
  • 这一篇则把 Fiber 这个运行时工作节点立起来了

从这里开始,React 主线就真正进入运行时工作阶段。

后面的更新、render、commit,都会继续围绕 Fiber 展开:

  • 更新会挂到 Fiber 的 updateQueue
  • render 阶段会构建 workInProgress Fiber 树
  • commit 阶段会读取 Fiber 上的 flags 去执行 DOM 更新和副作用

用一张图收一下:

graph TD
    A["JSX"] --> B["ReactElement:输入描述"]
    B --> C["Root / update system:进入更新系统"]
    C --> D["Fiber:运行时工作节点"]
    D --> E["render / commit:围绕 Fiber 展开"]

所以这一篇真正要建立的,不是对某个字段的记忆,而是这个判断:

从这里开始,React 主线真正进入运行时工作阶段。后面的更新、render、commit,都要围绕 Fiber 树继续展开。


结语

React 源码最难的地方,从来都不是某一个字段本身。

真正难的是:如果没有主线,ReactElement、FiberRoot、HostRoot Fiber、alternate、lanes、flags 这些词会看起来彼此割裂。

所以这一篇真正想补上的,不是 Fiber 的所有实现细节,而是先把 Fiber 在 React 主线里的位置立住:

ReactElement 是输入描述,Fiber 是运行时工作节点。

当这个边界立住以后,后面再看 Update、Queue、Lane、render、commit,落点就会稳定很多。

Fiber 这层结构立住以后,下一步就可以看一次真正的组件更新:setState / Hook dispatch 触发后,React 是怎么创建 Update、进入 Queue、分配 Lane,并最终走到调度入口的。

如果这篇对你有帮助,欢迎点个赞支持。后面我也会继续把这组 React 源码文章慢慢补完整。

这组源码解读文章也会同步整理到 GitHub 仓库里,方便集中查看和持续更新:

GitHub:github.com/HWYD/source…

如果觉得这组内容对你有帮助,也欢迎顺手点个 Star。

最近在做的一个 AI 项目

最近我也在持续迭代一个 AI 项目:AI Mind
如果你对 AI 应用工程化方向感兴趣,欢迎来看看:

GitHub:github.com/HWYD/ai-min…

如果觉得还不错,也欢迎顺手点个 Star。

昨天以前首页

接入 MCP 之后,我如何让 Skill 稳定消费 Tool / Resource / Prompt

作者 倾颜
2026年4月27日 12:01

本文对应项目版本:v0.0.11

接入 MCP 之后,下一步是不是应该马上做 Agent?

这是我在 AI Mind 本版本里反复提醒自己的问题。

先简单介绍一下项目背景。AI Mind 是一个按版本持续演进的 AI Native Runtime Skeleton,不是一次性做完的 AI 产品。它从本地聊天闭环开始,逐步长出结构化流式协议、Tool Calling、Multi-Tool Runtime、Skill Runtime、MCP 接入,以及后续会继续推进的 Agent / 数据层能力。

到前一版本为止,项目里已经有了一条比较稳定的聊天主链:请求从 /api/chat 进入,经过 chat-service(聊天服务 facade,负责对外暴露稳定入口)和 runtime(聊天主链编排层,负责规划、工具执行和最终回答生成),再通过 @ai-mind/stream-core(流式协议、生命周期和 writer 内核)返回前端可消费的流式 chunk。

前面版本已经完成基础 MCP 接入,天气走 MCP Tool,文件读取升级成 MCP Resource。照这个节奏继续往下,很容易产生一个冲动:既然外部能力已经接进来了,那是不是该让模型自己决定下一步调用什么?

我最后把重心放在了更靠前的一层:先让能力面变得清楚、稳定、可消费。

本版本的主题不是 Agent,也不是 Remote MCP 教程,而是补了一层更基础的东西:Capability Surface(能力表面,用来描述能力、约束 Skill 承接范围,并把能力消费事实带进 Runtime)。

为了让第一次看到这个项目的读者也能顺着看下去,这里先把几个词放在当前项目语境里:MCP 负责把外部能力接进来,Tool 是可执行动作,Resource 是可读取上下文,Prompt 是可注入模型的任务消息,Skill 则负责承接某类稳定任务模式。

Capability Surface 可以先理解成一层“能力名片”:它先说清楚一个能力是谁、来自哪里、在本地还是远端、当前能不能用、哪个 Skill 可以承接。至于这个能力最终怎么执行,仍然交给 Tool / Resource / Prompt 各自的运行链路。

我更想复盘的是:在一个真实 AI Runtime 项目里,接入 MCP 之后、走向更完整的计划与执行之前,为什么需要先把能力表面收清楚。

它解决的问题是:当系统里同时存在 Tool、Resource、Prompt、本地 MCP、远端 MCP、Skill 路由和前端执行事实时,Skill 到底应该用什么方式稳定理解这些能力,并让 Runtime 安全地消费它们?

先看结论

MCP 接入解决的是“能力怎么接进来”,Capability Surface 解决的是“这些能力如何被描述、被 Skill 承接、被 Runtime 消费、被前端承接”。

本版本我先把 Tool / Resource / Prompt 收成统一的能力描述层,再让 reader-skill(文档读取、项目上下文和外部信息查询类 Skill)消费本版本固定的 local / remote MCP capability。

如果把这件事压成一条链路,大概是这样:

用户问题
  -> Skill(reader-skill)
    -> capabilitySelectors(能力选择范围)
      -> Tool / Resource / Prompt(三类能力)
        -> Runtime 消费
          -> 前端消息 part(执行事实卡片)

ai-1.gif


1. 为什么接入 MCP 后,我先补能力表面

接入 MCP 之后继续往上做调度,看起来很自然。

因为这时候系统已经有了外部能力来源:MCP server 可以暴露 Tool,可以暴露 Resource,也可以暴露 Prompt。再往上一层,好像就该让运行时自己规划:先读哪个资源,再拿哪个 Prompt,再调用哪个 Tool。

但在我这个阶段,这一步太早了。

问题不在于更高层调度不重要,而是它之前还有一个更基础的问题没有解决:

  • 系统里的能力是否能被统一描述?
  • Skill 是否知道自己可以承接哪些能力?
  • Prompt 是否已经被当成一等能力,而不是塞进工具链里的附属品?
  • 前端消息模型是否能承接能力执行事实,而不只是最终答案?
  • Runtime 是否真的消费了 capability metadata,而不是只把它当展示文案?

如果这些问题没有先收住,上层调度很容易变成一个过早的总入口:它看起来什么都能管,但底下的能力边界、错误语义、前端表达都还没清楚。

所以本版本我选择先补 Capability Surface。

这个名字听起来有点抽象,但在项目里它很具体:它是一层让 Skill 和 Runtime 能稳定理解能力对象的表面。它不替代 Tool、不替代 Resource、不替代 Prompt,也不替代 MCP 接入层。它只是先把“系统里有什么能力、来自哪里、在本地还是远端、属于哪类能力、当前是否可用”这些信息讲清楚。

对我来说,这比马上做更高层调度更值得先做。

因为后续计划与执行要做得稳,前提是能力面已经稳。


2. Capability Model 统一的是描述层,不是执行链

本版本最重要的一个判断是:Capability Model 只统一能力描述,不统一执行链。

这句话如果没有提前说清楚,很容易把事情做重。

Tool、Resource、Prompt 虽然都可以叫 capability,但它们的运行时语义完全不同:

  • Tool 通常是一次可执行动作,可能由模型 tool call 触发,也可能被 runtime 主动调用。
  • Resource 更像外部上下文读取,重点是把内容拿回来进入后续回答。
  • Prompt 是模板或消息注入,重点是生成一组可进入模型上下文的消息。

所以我没有把它们硬抽成一个统一的 executeCapability() 大协议。

统一的是这层描述对象:

export const capabilityTypes = ['prompt', 'resource', 'tool'] as const
export type CapabilityType = (typeof capabilityTypes)[number]

export const capabilityProviderKinds = ['internal', 'mcp'] as const
export type CapabilityProviderKind = (typeof capabilityProviderKinds)[number]

export const capabilityLocations = ['local', 'remote'] as const
export type CapabilityLocation = (typeof capabilityLocations)[number]

export interface CapabilityIdentity {
    name: string
    capabilityType: CapabilityType
    providerKind: CapabilityProviderKind
    location: CapabilityLocation
    serverId?: string
}

export interface CapabilityDefinition extends CapabilityIdentity {
    capabilityId: string
    title: string
    description: string
    availability: CapabilityAvailability
}

这段代码解决的是“能力身份如何被稳定描述”的问题。

这里有几个字段很关键:

  • capabilityType:能力是 toolresource 还是 prompt
  • providerKind:能力来自 internal 还是 mcp
  • location:能力在 local 还是 remote
  • serverId:如果来自 MCP,它属于哪个 server
  • availability:能力当前是否可用,不只是一句 true / false

capabilityId 也不是直接用 name,而是按 providerKind:location:capabilityType:serverId?:name 组合出来。这样做是为了避免重名。

一个本地 MCP server 里可以有 summary,一个远端 MCP server 里也可以有 summary。如果只靠 name,后面 Skill、Runtime、前端都会开始猜。capabilityId 把能力身份收成稳定规则,后面再扩 remote server 或 discovery,才不会一开始就欠债。

这一层的克制点也很重要:它只描述能力,不接管能力执行。

Tool / Resource / Prompt 仍然保持各自的执行语义。Capability Model 只是让它们能被同一套语言描述出来。


3. Skill Metadata 是 Skill 的表面,不是 Workflow

在之前的版本里,Skill 已经存在,但它更多像一组运行时规则:命中哪个 Skill、允许用哪些 Tool、拼什么 system prompt。

到了本版本,我想把 Skill 的“表面”讲清楚。

这里的表面不是 UI,而是 Skill 对外声明自己的方式:

  • 我是谁?
  • 我主要处理什么任务?
  • 我可以承接哪些能力来源?
  • 我允许消费哪些 capability?
  • 如果能力不可用,我怎么回退?

对应到类型上,就是 SkillDefinition(Skill 的统一定义对象,承载系统提示词、工具范围和 capability 选择范围)。这里最关键的新增字段是 capabilitySelectors,它用结构化条件描述 Skill 可承接的能力范围。

我刻意没有给 Skill 再包一层复杂的 metadata 对象,也没有把它扩成 workflow 定义。因为本版本的 Skill Metadata 只承担几件事:

  • 自描述
  • routing 辅助
  • 前端轻展示
  • capability 承接范围声明
  • fallback 策略声明

它不承担:

  • 多步 workflow
  • 通用 capability 调度
  • 通用 planner
  • 模型自主继续决策

reader-skill(阅读类 Skill,负责文件读取、文档总结、项目上下文和 MCP 文档能力)就是本版本最关键的例子:

export const readerSkillDefinition: SkillDefinition = {
    skillId: 'reader-skill',
    name: '阅读技能',
    allowedTools: ['city-weather', 'local-text-read'],
    sourceKinds: ['mcp'],
    capabilitySelectors: [
        { providerKind: 'mcp', location: 'local', capabilityType: 'tool', names: ['city-weather'] },
        { providerKind: 'mcp', location: 'local', capabilityType: 'resource', names: ['local-text-read'] },
        { providerKind: 'mcp', location: 'local', capabilityType: 'prompt', names: ['local-file-summary'] },
        { providerKind: 'mcp', location: 'remote', serverId: 'project-assistant-service', capabilityType: 'resource' },
        { providerKind: 'mcp', location: 'remote', serverId: 'project-assistant-service', capabilityType: 'prompt' },
        { providerKind: 'mcp', location: 'remote', serverId: 'project-assistant-service', capabilityType: 'tool' },
    ],
    fallbackPolicy: 'direct-answer',
}

这段配置的意义不在于“列了一堆能力”,而在于它把 Skill 的边界声明出来了。

reader-skill 可以承接本地 MCP Tool、Resource、Prompt,也可以承接来自 project-assistant-service(本版本新增的远端 MCP 服务)的三类 remote capability。但这不代表它拥有通用调度权。它只是声明:这些能力属于我的可承接范围。

真正是否执行,仍然由 Runtime 在具体上下文里决定。

这样 Skill 就没有偷偷长成上层调度器。


4. Prompt 为什么要成为一等 Capability

我在本版本很想强调 Prompt。

在很多 AI 应用里,Prompt 容易被当成“内部模板文件”,或者被塞进 Tool 调用前后的某段字符串里。短期看没问题,长期看会让能力面变得不完整。

如果 Tool 是“做一个动作”,Resource 是“取一段上下文”,那 Prompt 就应该是“生成一组可注入模型上下文的任务消息”。

它不是 Tool。

因为 Prompt 本身不执行外部动作,也不应该伪装成一次 tool call。

它也不是 Resource。

因为模板文件只是 Prompt 的存储介质,不等于 Prompt capability 本身。真正被消费的是“带参数注入后的 Prompt 消息”。

所以本版本补了两个 Prompt:

  • local-file-summary(本地文件总结 Prompt,通过 project-files-server 暴露)
  • tasklist-draft(远端 tasklist 草稿 Prompt,通过 project-assistant-service 暴露)

本地 Prompt 的运行时消费落在 prompt-context.ts(本地 Prompt 上下文注入模块,负责判断是否需要读取 prompt、注入参数并转换成模型消息):

export function resolvePromptContextInvocation(
    request: ChatRequest,
    executedToolResults: ExecutedToolResult[]
): PromptContextInvocation | null {
    const userGoal = getLastUserMessageText(request)

    if (!shouldUseLocalFileSummaryPrompt(userGoal)) {
        return null
    }

    const localTextReadResult = getLatestSuccessfulLocalTextReadResult(executedToolResults)

    if (!localTextReadResult) {
        return null
    }

    const filename = getLocalTextReadFilename(localTextReadResult.toolCall)

    if (!filename) {
        return null
    }

    return {
        promptName: LOCAL_FILE_SUMMARY_PROMPT_NAME,
        source: 'mcp',
        location: 'local',
        serverId: LOCAL_FILE_SUMMARY_SERVER_ID,
        input: formatPromptInvocationInput(filename, userGoal),
        execute: () => buildLocalSummaryPromptContextMessages(filename, userGoal),
    }
}

这段代码解决的是“Prompt 如何进入最终回答上下文”的问题。

它的执行链是:

  1. 用户先触发文件读取,例如读取 README.md
  2. Runtime 拿到最近一次成功的 local-text-read 结果
  3. 判断当前用户目标是否需要总结、摘要、提炼
  4. 获取 local-file-summary Prompt
  5. 注入 filename / content / userGoal
  6. 把 Prompt 消息转成模型上下文

这里我只把 Prompt 当成一类执行事实展示出来。前端需要稳定知道的是:

  • 哪个 Prompt 被使用了
  • 来自哪里
  • 属于 local 还是 remote
  • 注入了几条上下文消息
  • 是否失败

至于内部 Prompt 模板正文,则继续留在 Runtime 和模型上下文里,不作为前端展示重点。

ai-2.png


5. Remote MCP 只验证最小闭环,不做远程业务平台

本版本确实新增了一个 remote MCP server。

这里的 remote 很朴素:它不在 apps/webapp 进程内,而是一个独立服务,通过 Streamable HTTP 被 Webapp 消费。

但它的定位非常克制:只验证 remote capability 最小闭环。

新增服务是 apps/project-assistant-service(独立 NestJS 服务,当前只承载 remote MCP mock capability)。它通过官方 MCP SDK 暴露三类能力:

  • Resource:project://latest-context
  • Prompt:tasklist-draft
  • Tool:check_doc_consistency

服务侧注册能力的代码在 mcp-capability.service.ts(远端 MCP 能力注册服务,负责创建 MCP server 并注册 Resource / Prompt / Tool)。这里我只保留了“每类 capability 一个最小 mock”的形态,用来证明远端能力面可以成立。

我没有在这里接数据库,没有接第三方 API,也没有做远程文件系统。三个 capability 都是 mock 数据。这样做不是因为远端能力不重要,而是因为本版本要验证的是另一件事:

Webapp 作为 MCP 消费端,能不能通过 remote Streamable HTTP 稳定消费 Resource / Prompt / Tool 三类能力,并把执行事实并入当前聊天主链?

Webapp 侧的 server definition 也保持很明确:transport=streamable-httplocation=remoteserverId=project-assistant-service,并声明它同时具备 prompts / resources / tools 三类 capability。

这层配置解决的是“远端 MCP server 如何进入 Webapp 能力注册表”的问题。

这里我只做了 mock Bearer Token:

  • 无 token:unauthorized
  • 错 token:forbidden
  • 正确 token:正常连接

没有做用户态登录透传,也没有做 OAuth。

这也是本版本的边界:remote MCP 是为了验证 remote capability surface 和 runtime 消费链路,不是为了提前做一个生产级远程业务平台。


6. Capability Metadata 不能只用于展示,必须进入 Runtime

如果 capability 只停留在 catalog 和前端展示,本版本其实还不完整。

真正让我觉得本版本站住了的,是 Capability Metadata 进入了 Runtime 消费闭环

先用一个真实请求把链路放具体一点。

当我在前端输入:

帮我检查一下当前文档之间有没有明显不一致的地方

我希望它不是直接让模型凭空回答,而是先命中 reader-skill,再确认这个 Skill 是否声明过可承接 check_doc_consistency 这类 remote tool capability。确认通过后,Runtime 才去调用 project-assistant-service 暴露的 remote Tool,并把结果注入最终回答。

也就是说,reader-skill 已经声明的 capabilitySelectors,不能只是文档信息。Runtime 必须真正基于这层声明决定本轮能不能消费某个 capability。

对应模块是 capability-context.ts(最小 remote capability 消费层,只处理 reader-skill 下本版本固定远端能力,不做通用 planner)。

先看入口:

export function resolveCapabilityContextInvocations(request: ChatRequest, skillDefinition?: SkillDefinition): RemoteCapabilityInvocation[] {
    if (skillDefinition?.skillId !== 'reader-skill') {
        return []
    }

    const userGoal = getLastUserMessageText(request)
    const invocations: RemoteCapabilityInvocation[] = []
    const candidates: Array<[RemoteCapabilityName, CapabilityType, boolean]> = [
        [LATEST_CONTEXT_RESOURCE_NAME, 'resource', matchesAny(userGoal, PROJECT_CONTEXT_PATTERNS)],
        [TASKLIST_DRAFT_PROMPT_NAME, 'prompt', matchesAny(userGoal, TASKLIST_DRAFT_PATTERNS)],
        [DOC_CONSISTENCY_TOOL_NAME, 'tool', matchesAny(userGoal, DOC_CONSISTENCY_PATTERNS)],
    ]

    for (const [name, capabilityType, matched] of candidates) {
        const identity = createRemoteCapabilityIdentity(name, capabilityType)

        if (matched && isRemoteCapabilityAllowed(skillDefinition, identity)) {
            invocations.push(createRemoteCapabilityInvocation(name, capabilityType, userGoal))
        }
    }

    return invocations
}

这段代码解决的是“Skill metadata 如何进入 runtime 判断”的问题。

这里有两个约束很重要:

  1. 只有命中 reader-skill 才会进入这层 remote capability 消费。
  2. 即使用户输入命中了高置信规则,也必须通过 isRemoteCapabilityAllowed() 检查 capabilitySelectors

也就是说,Runtime 不会绕过 Skill 声明去随便调远端能力。

执行阶段也没有把三类 capability 强行揉成一种协议,而是保留各自语义:

function createRemoteCapabilityInvocation(
    name: RemoteCapabilityName,
    capabilityType: CapabilityType,
    userGoal: string
): RemoteCapabilityInvocation {
    const invocation: RemoteCapabilityInvocation = {
        capabilityType,
        execute: async options => {
            if (capabilityType === 'resource') {
                return executeRemoteResourceInvocation(invocation, options)
            }

            if (capabilityType === 'prompt') {
                return executeRemotePromptInvocation(invocation, options)
            }

            return executeRemoteToolInvocation(invocation, options)
        },
        input: capabilityType === 'resource' ? LATEST_CONTEXT_RESOURCE_URI : `goal=${userGoal}`,
        location: 'remote',
        name,
        serverId: PROJECT_ASSISTANT_SERVER_ID,
        source: 'mcp',
    }

    return invocation
}

这段代码体现了本版本的核心取舍:

  • invocation 形态是统一的
  • 但 Resource / Prompt / Tool 的执行语义不是统一的

Resource 会 readResource(),并把完整内容作为模型上下文。

Prompt 会 getPrompt(),并把返回 messages 转成模型上下文。

Tool 会 callTool(),并把执行结果作为最终回答依据。

如果某个 capability 失败,也不会直接打断整轮对话。Runtime 会写出统一错误 chunk,再注入一条“能力不可用”的上下文,让最终回答不要编造结果。

这个点很小,但非常关键。

因为这意味着 capability metadata 不再只是“给人看”的资料,而是真的进入了 Runtime 决策。


7. 前端为什么要承接执行事实

AI 应用的前端如果只承接最终答案,很多运行时事实会被藏起来。

这在普通聊天里问题不大,但一旦系统开始接 Tool、Resource、Prompt、MCP、Skill,前端就需要承接更多运行时事实。它不只是服务用户感知,也是在帮整个系统保持技术完整性:Runtime 写出了什么,前端就能稳定接住什么。

本版本扩展了流式协议:

export interface SkillSelectedChunk {
    type: 'skill-selected'
    skillId: string
    name: string
    description?: string
}

export interface PromptStartChunk {
    type: 'prompt-start'
    partId: string
    promptName: string
    source?: 'internal' | 'mcp'
    location?: 'local' | 'remote'
    serverId?: string
    input?: string
}

export interface PromptEndChunk {
    type: 'prompt-end'
    partId: string
    promptName: string
    source?: 'internal' | 'mcp'
    location?: 'local' | 'remote'
    serverId?: string
    status: 'completed' | 'failed'
    messageCount?: number
}

这段协议解决的是“前端消息模型如何稳定承接 Skill 命中和 Prompt 执行事实”的问题。

同时,原来的 Tool / Resource chunk 也补上了 source / location / serverId。这样前端就不只是知道“调用了一个工具”,还知道:

  • capability 类型是什么
  • 来源是 internal 还是 mcp
  • 位置是 local 还是 remote
  • 属于哪个 serverId
  • 当前状态是 called、completed 还是 failed

前端消费逻辑落在 use-chat-stream.ts(聊天流消费 hook,负责把 NDJSON chunk 合并成前端消息 part):

case 'skill-selected': {
    const messageId = activeStreamRef.current.messageId

    if (!messageId) {
        return
    }

    updateMessages(current => appendPart(current, messageId, createSkillPart(chunk.skillId, chunk.name, chunk.description)))
    return
}

case 'prompt-start': {
    const messageId = activeStreamRef.current.messageId

    if (!messageId) {
        return
    }

    updateMessages(current =>
        appendPart(
            current,
            messageId,
            createPromptPart(chunk.partId, chunk.promptName, 'called', chunk.source, chunk.location, chunk.serverId, chunk.input)
        )
    )
    return
}

case 'prompt-end': {
    const messageId = activeStreamRef.current.messageId

    if (!messageId) {
        return
    }

    updateMessages(current =>
        updatePromptPart(current, messageId, chunk.partId, part => ({
            ...part,
            promptName: chunk.promptName,
            source: chunk.source ?? part.source,
            location: chunk.location ?? part.location,
            serverId: chunk.serverId ?? part.serverId,
            status: chunk.status,
            messageCount: chunk.messageCount,
        }))
    )
    return
}

这段代码解决的是“流式协议如何落到前端消息结构”的问题。

最终前端不只是渲染答案,而是会把这些运行时事实沉淀成消息 part:

  • Skill 命中:阅读技能
  • Prompt 注入:tasklist-draft
  • Resource 读取:latest-context
  • Tool 执行:check_doc_consistency
  • 来源:MCP
  • 位置:remote
  • 服务:project-assistant-service

ai-3.png

我很喜欢这一步,因为它让 Runtime 不再像一个黑盒。

在调试、观察或复盘一轮回答时,我们能知道这轮回答依赖了哪个 Skill,消费了哪类 capability,来自本地还是远端。

这不是 UI 小修,而是协议层、运行时和产品表达一起往前走了一步。


8. 本版本刻意没做什么

这篇文章的主角是 Capability Surface,所以边界也要说清楚。

本版本刻意没有做这些事:

  • 不做 Agent
  • 不做 workflow
  • 不做多 remote server 编排
  • 不做模型自由规划任意 capability 调用
  • 不做复杂 OAuth 或账号体系
  • 不接数据库
  • 不接第三方 API
  • 不做 remote 文件系统
  • 不把 Tool / Resource / Prompt 强行抽成同一条执行链

这些不是“以后都不做”,而是本版本先不做。

因为当前更需要验证的是:

  • Capability 能不能先被统一描述?
  • Skill 能不能先声明自己可承接的能力范围?
  • Prompt 能不能成为 Tool / Resource 之外的一等 capability?
  • Remote MCP 能不能用一个 server 跑通最小闭环?
  • Metadata 能不能真正进入 Runtime,而不是停留在展示?
  • 前端消息模型能不能把执行事实承接下来?

这几个问题没有先收住,继续往更高层计划与执行走,复杂度会涨得很快。


9. 回到这次实践,我得到的几个判断

做完这一轮后,我对 AI Runtime 里的能力层有了一个更明确的判断:

做更完整的 Agent Runtime 之前,最好先有 Capability Surface。

更具体一点,我收获了 4 个判断。

第一,Capability Model 应该先是一层描述模型

它不需要一开始就包办执行链。先把 Tool / Resource / Prompt 的身份、来源、位置、可用性描述清楚,就已经很有价值。

第二,Skill Metadata 是 Skill 的表面,不是 planner

capabilitySelectors 用来表达 Skill 可承接什么能力,而不是让 Skill 变成 workflow 引擎。

第三,Prompt 应该是一等 capability

Prompt 不应该长期伪装成 Tool,也不应该只作为 Resource 背后的模板文件存在。它有自己的生命周期、参数注入方式和前端执行事实。

第四,Remote MCP 可以先做最小闭环

一个 remote server,三类 mock capability,一套 Streamable HTTP transport,一个 mock token,已经足够验证 MCP 接入层、Runtime、Skill、Protocol、Frontend 是否能跑通。

对我来说,本版本最有价值的地方,不是项目多了一个 project-assistant-service,也不是前端多了几张卡片。

真正的价值是:Capability 从“能被列出来”走到了“能被 Skill 声明、能被 Runtime 消费、能被前端消息模型承接”。

这才是从接入 MCP 继续往上层运行时走之前,我认为应该补上的一层。


10. 后续我会怎么继续往前推

短期内,我不会急着把这层扩成通用 Agent Runtime。

更合理的节奏是:

  • 先继续观察 Capability Model 是否足够承载更多本地 / 远端能力
  • 再考虑 remote MCP discovery 或 server 配置化
  • 再让更多 Skill 基于 capabilitySelectors 消费稳定能力
  • 最后再谈 Agent Runtime 如何基于这些能力做计划、执行和继续决策

换句话说,这条线更像从接入 MCP 走到 Capability Surface,再让 Skill Runtime 稳定消费更多 MCP 能力来源,最后再进入 Agent Runtime。

我现在更愿意先把中间这层做扎实。


项目地址

GitHub: github.com/HWYD/ai-min…

如果这篇文章刚好对正在做 AI Runtime、MCP 接入、Tool Calling 或 Skill 分层的同路人有一点参考价值,欢迎来仓库里看看。

如果大家也对这种按版本持续演进的 AI Runtime Skeleton 感兴趣,顺手点个 Star,也能让我知道这条路线确实对外部读者有帮助。后面我会继续沿着 Capability Surface、MCP 能力治理、Skill Runtime 和 Agent Runtime,把这套骨架一点点往前推。

React 19 源码主线拆解(3):一篇搞懂createRoot和root.render完整流程

作者 倾颜
2026年4月23日 11:58

这是我持续更新的一组 React 源码解读文章,也会尽量控制单篇篇幅,按主线一点点往里拆。
这一篇先不急着往 Fiber 内部结构和 render 细节里走,而是先把 React 主线里的“系统入口”这一段补上。

前言

上一篇里,我已经把 React 主线最前面那一段补了出来:

JSX → ReactElement

也就是说,React 运行时真正接收到的第一层核心对象,不是 JSX 语法本身,而是 ReactElement。

但如果只停在这里,后面很多问题其实还是悬着的。

比如:

  • React 拿到 ReactElement 之后,下一步先去哪了?
  • createRoot(container) 到底创建了什么?
  • root.render(element) 到底是在“渲染”,还是在“发起一次更新”?
  • ReactElement 是在这里立刻变成 Fiber 了吗?

这些问题继续往下追,主线就会自然推进到 React 的系统入口层。

所以这一篇不急着进入 Fiber 内部结构,也不急着进入 render / commit,而是先把这一段补清楚:

ReactElement 是怎么进入根级更新系统的?

这篇文章主要想回答几个问题:

  • createRoot(container) 返回的 root 到底是什么
  • createRoot 初始化了哪些关键对象
  • ReactDOMRootFiberRootHostRoot Fiber 分别是什么关系
  • root.render(element) 到底做了什么
  • ReactElement 在这里是怎么进入根级更新系统的

这里也先说明一下版本口径:这篇文章标题写的是 React 19,因为整体讨论的是 React 19 的主线机制;但在具体源码观察上,我会先以 React 19.1.1 作为基线来展开。


一、先说结论:createRoot 负责初始化根容器,root.render 负责发起一次根更新

先把这一篇最核心的结论放在前面:

  1. createRoot(container) 不是立刻把组件渲染到页面
  2. 它更像是在初始化一个 React 根容器
  3. 真正把 ReactElement 送进系统的是 root.render(element),而这一步本质上是在发起一次根更新

平时写 React 时,我们很容易把这两步看成一整句:

const root = createRoot(container)
root.render(<App />)

表面上看,这像是一段连续的“开始渲染”代码。
但如果从源码视角往里看,会发现这两步分工非常明确:

  • createRoot(container) 负责把根容器搭起来
  • root.render(element) 负责把新的 UI 描述送进根级更新系统

换句话说,这一篇真正想补上的,不是“页面怎么显示出来”,而是:

React 在真正开始 render 之前,入口层到底先做了什么。

这里我先提前立住一句全篇最重要的边界话:

到这里为止,ReactElement 先进入的是根级更新系统,还没有真正展开成后面 render 阶段里的 Fiber 子树。

flowchart TB
    A["createRoot(container)"] --> B["初始化根容器"]
    B --> C["root.render(element)"]
    C --> D["发起一次根更新"]

二、createRoot(container) 返回的 root 到底是什么

继续往下看时,一个很自然的问题就是:

createRoot(container) 返回的这个 root,到底是什么?

如果只看业务代码,我们拿到的是这样一个对象:

const root = createRoot(container)

但这里的 root,并不是 React 内部真正维护的 FiberRoot
更准确地说,它是一个对外暴露的 API 层 root 句柄,也就是 ReactDOMRoot

这一层先分清很重要,因为它能帮我们把两种 root 区分开。

1. 业务代码拿到的 root

这是 ReactDOMRoot
它更像 React DOM 暴露给外部使用的句柄,后面调用 root.render(...)root.unmount() 也是通过它发起的。

2. React 内部真正维护的 root

这一层才是后面真正贯穿运行时系统的根对象,也就是 FiberRoot

两者之间的连接方式并不复杂:

  • ReactDOMRoot 对外暴露方法
  • 它内部通过 _internalRoot 持有真正的 FiberRoot

所以从这里开始,我们最好先把两件事分开:

  • 我们手里拿着的 root
  • React 内部真正维护的 root
flowchart LR
    A[业务代码拿到的 root] --> B[ReactDOMRoot]
    B --> C[_internalRoot]
    C --> D[FiberRoot]

create-root-return-react-dom-root.png

上面这张源码图里,最值得注意的是最后两步:

  • 先通过 createContainer(...) 拿到内部的 root
  • 最后返回 new ReactDOMRoot(root)

这也就把这一节最关键的判断立住了:createRoot(container) 返回的不是 FiberRoot 本身,而是一个对外暴露的 ReactDOMRoot 句柄。


三、createRoot 到底初始化了什么

既然 createRoot(container) 返回的是一个对外句柄,那它内部到底做了什么?

如果顺着源码往下看,会发现 createRoot 真正做的事情,不是“立刻开始渲染”,而是先把根级运行时入口搭起来。

这一层更适合按系统分层来理解,而不是按源码步骤一项项罗列。

1. 对外先创建了一个 root 句柄

最外层拿到的是 ReactDOMRoot
这一层的作用很直接:业务代码后续通过它来调用 render(...)unmount(...) 这些入口方法。

也就是说,React 先对外准备了一个“可用的 root 句柄”。

2. 内部真正建起了根级状态容器

再往里走,会进入 createContainer(...)
从这里开始,才真正进入 reconciler 内部的 root 初始化流程。

这一层真正创建出来的是 FiberRoot

FiberRoot 可以先粗略理解成:

整棵 React 树外侧的根级状态容器。

后面很多根级信息,都会挂在这一层上,比如:

  • 当前根对应的 container
  • 当前这棵树的 current
  • pending lanes
  • 其他根级调度与状态信息

3. 同时把 Fiber 树入口和更新系统准备好了

Root 初始化并不会只停在 FiberRoot 这一层。
在创建 FiberRoot 的同时,还会继续创建 HostRoot Fiber

HostRoot Fiber 是整棵 Fiber 树最顶层的根 Fiber。
后面真正进入 render 阶段时,工作会从这里开始往下展开。

除此之外,根初始化时还会顺手把一些关键状态准备好,比如:

  • 根 Fiber 的 memoizedState
  • 根 Fiber 的 updateQueue

这也意味着:

Root 从来不是一个空壳。
它在一开始,就已经把根级状态容器、Fiber 树入口和后续更新通道一起准备好了。

flowchart TD
    A[createRoot] --> B[ReactDOMRoot]
    A --> C[FiberRoot]
    A --> D[HostRoot Fiber]
    D --> E[初始化 memoizedState]
    D --> F[初始化 updateQueue]

create-fiber-root-init.png

如果只盯住这张图里最关键的几行,其实就能看到这一节最重要的信息:

  • 先创建 FiberRoot
  • 再创建 HostRoot Fiber
  • root.current = uninitializedFiber
  • uninitializedFiber.stateNode = root
  • 初始化 memoizedState
  • 初始化 updateQueue

也就是说,createRoot 做的不是“准备一个空的 root 变量”,而是在初始化阶段就把根级状态容器、根 Fiber 入口和更新通道一起准备好了。


四、FiberRoot 和 HostRoot Fiber 在这里各自是什么角色

上面虽然已经提到了 FiberRoot 和 HostRoot Fiber,但这两者的角色最好单独拆开看。
因为这会直接关系到后面第 4 篇里“Fiber 到底是什么”的理解。

1. FiberRoot:树外的根级状态容器

FiberRoot 可以先理解成:

React 在根级维护整棵树运行状态的地方。

它不属于 Fiber 树里的某个普通节点,而是在树外侧,集中保存根级信息。

比如后面常见的这些信息,都会和它有关:

  • 宿主容器 container
  • 当前树入口 current
  • pending lanes
  • 其他根级状态和调度信息

所以它更像是在回答:

“这棵树作为一个整体,目前处在什么状态?”

2. HostRoot Fiber:Fiber 树最顶层的根 Fiber

HostRoot Fiber 则不一样。
它已经属于 Fiber 树内部了,而且是最顶层的那个根 Fiber。

后面真正进入 render 阶段时,工作会从这里开始往下展开。
所以它更像是在回答:

“如果要开始处理这棵树,入口 Fiber 在哪里?”

3. 两者的关系

这两层对象不是彼此独立的,而是双向连起来的。

大致可以粗略理解成:

  • FiberRoot.current -> HostRoot Fiber
  • HostRoot Fiber.stateNode -> FiberRoot

也就是说:

  • FiberRoot 通过 current 指向当前这棵树的根 Fiber
  • HostRoot Fiber 再通过 stateNode 回指到 FiberRoot

这两个引用关系非常关键。
因为它把:

  • 根级状态容器
  • Fiber 树入口

真正接成了一套系统。

如果先把这里压成一句话,那就是:

FiberRoot 管的是“根级状态”,HostRoot Fiber 管的是“根 Fiber 入口”,两者通过双向引用连在一起。

flowchart LR
    A[FiberRoot] -->|current| B[HostRoot Fiber]
    B -->|stateNode| A

五、root.render(element) 到底做了什么

到这里,根容器已经搭起来了。
接下来的问题就自然变成:

root.render(element) 到底做了什么?

平时写代码时,我们很容易把它理解成:

“开始渲染”

但如果顺着源码往下看,这里的事情其实更准确一些。

1. render(children) 接收的是已经求值好的输入

这里传进去的 children,在典型场景下,就是上一篇讲过的 ReactElement。

也就是说:

root.render(<App />)

这行代码在真正进入 render(...) 方法之前,<App /> 这段 JSX 已经先求值成了 ReactElement。
所以 render(...) 本身并不负责做 JSX 到 element 的转换。

2. root.render 更像在提交一份新的 UI 描述

这一层最容易产生误解的地方就在于 render 这个名字。

从业务侧看,它当然对应“我要渲染这棵树”。
但从源码这一步真正做的事情来说,它更像是在:

把一份新的 UI 描述提交给根级更新系统。

也就是说,这一步还不是“立刻去改 DOM”,而是先把 element 往根级更新流程里送。

3. 后面会进入 updateContainer(...)

继续往下看,root.render(element) 会进入 updateContainer(...)
从这里开始,这次根更新才真正被组织起来。

所以如果把这一节压成一句话,我会更愿意这样理解:

root.render 更像是在提交一份新的 UI 描述,而不是立刻把 DOM 改掉。

react-dom-root-render-update-container.png

这张图里最关键的两行,就是:

  • const root = this._internalRoot
  • updateContainer(children, root, null, null)

也就是说,root.render(children) 这一层做的核心事情,就是先拿到内部的 _internalRoot,然后继续进入 updateContainer(...),把这份新的 UI 描述送进根级更新系统。


六、ReactElement 是怎么变成一次 update 的

这一节是第三篇真正最值钱的部分。

因为到这里,主线第一次真正从“输入对象”推进到了“标准化更新”。

如果顺着源码往下看,大致会经过这样一条小链路:

1. updateContainer(...)

root.render(element) 继续往下后,会进入 updateContainer(...)

这一层做的事情,先粗略理解成两步就够了:

  • 找到当前根 Fiber
  • 为这次更新分配 lane

也就是说,从这里开始,这份新的 element 已经不再只是“一个输入对象”,而是开始被组织成一次真正的根更新。

2. createUpdate(lane)

接着会调用 createUpdate(lane),创建出一个 Update 对象。

这里的关键不是把 Update 结构细节讲完,而是先知道:

React 不会直接拿着 element 裸奔往下传,而是会先把它包装成一次标准化更新。

create-update-function.png

这一小步单独拿出来看会更清楚。
createUpdate(lane) 做的事情并不复杂:它创建并返回一个 update 对象,先把 lanetagpayloadcallbacknext 这些基础字段准备好。

这里最值得注意的是:刚创建出来时,payload 还是 null
也就是说,这一步只是先把“更新对象的壳”准备好。

3. update.payload = { element }

接下来,React 会把这次新的 UI 描述真正放进 update 里。

这一行是整篇第三篇里最值得盯住的一步。

因为到这里可以非常明确地看到:

ReactElement 被放进了这次 update 的 payload 里。

也就是说,这一步里发生的不是:

  • element 直接变成 Fiber
  • element 直接变成 DOM
  • element 直接进入 work loop

而是:

element 先被放进了一次更新对象的 payload 里。

4. enqueueUpdate(...)

再往后,这次 update 会被挂进 HostRoot Fiber 的 update queue。

这说明根更新不会在这里立刻“一路跑到底”,而是先进入统一的更新队列。

5. scheduleUpdateOnFiber(...)

最后,这次更新会继续推进到 scheduleUpdateOnFiber(...)
从这里开始,调度入口才真正接上。

这一步我先不往后深讲,因为那已经开始进入后面几篇的空间了。
在第三篇这里,看到这里就够了。

update-container-payload-enqueue-schedule.png

如果只看这张图里最关键的几步,主线其实非常清楚:

  • createUpdate(lane) 创建更新对象
  • 再把 update.payload = { element }
  • 然后通过 enqueueUpdate(...) 挂进队列
  • 最后继续推进到 scheduleUpdateOnFiber(...)

所以第三篇真正最值得带走的一句认知就是:

ReactElement 在这里先变成的是一次 Update,而不是直接变成 Fiber。

flowchart TB
    A["ReactElement"] --> B["updateContainer"]
    B --> C["createUpdate"]
    C --> D["payload = { element }"]
    D --> E["enqueueUpdate"]
    E --> F["scheduleUpdateOnFiber"]

七、HostRoot Fiber 的 updateQueue 在这里是干什么的

看到这里时,一个很自然的问题就是:

为什么不是直接把 element 丢给某个 render 函数,而是非要先过 update queue?

这里的关键在于:

1. updateQueue 是挂在 Fiber 上的

更具体一点说,这里的 queue 是挂在 HostRoot Fiber 上的。

也就是说,根更新不是单独漂在外面的,它是和 Fiber 树入口直接关联起来的。

2. 根初始化时就已经把 queue 准备好了

前面第三节已经提过,根初始化时,HostRoot Fiber 的 updateQueue 就已经准备好了。

这也说明一个很重要的点:

React 在 root 初始化时,就已经把“后续如何接收更新”这条通道一起搭好了。

所以 root.render(element) 并不是临时找地方塞进去,而是沿着一条一开始就准备好的根级更新通道进入系统。

3. root.render(element) 本质上是在提交一次根更新

从这个角度回头再看,就会更清楚:

  • root.render(element) 不是“立刻渲染”
  • 它本质上是在往 HostRoot Fiber 的 queue 上挂一次新的根更新

后面到了 render 阶段,再去真正消费它。

所以这一节想说明的,其实就一句话:

先经过 updateQueue,不是绕路,而是 React 把更新纳入统一运行时系统的方式。

下面这张结构图,可以把这一层关系再看得更直观一点:

flowchart TB
    A["root.render(element)"] --> B["createUpdate"]
    B --> C["Update"]
    C --> D["enqueueUpdate"]
    D --> E["HostRoot Fiber.updateQueue"]
    E --> F["render 阶段再消费 queue"]
    F --> G["继续构造后续工作流程"]

如果换成更口语一点的话,就是:

  • root.render(element) 先创建一次 Update
  • 这次 Update 不会直接变成 DOM 更新
  • 它会先被放进 HostRoot Fiber 的 updateQueue
  • 后面 render 阶段再从 queue 里把它取出来继续处理

这样理解以后,updateQueue 的存在就不再像一层多余中转,而更像 React 统一接收更新的标准入口。


八、到这里,React 主线走到了哪里

到这里,第三篇其实已经把 React 主线从“输入对象”推进到了“系统入口”。

如果把当前这段主线压成一条链,大致就是:

JSX → ReactElement → createRoot 初始化根容器 → root.render(element) → createUpdate → enqueueUpdate → scheduleUpdateOnFiber

也就是说,到这里为止:

  • JSX 已经先变成 ReactElement
  • Root 入口已经初始化好了
  • ReactElement 已经进入根级更新系统
  • 更新已经被包装成了标准化 Update,并且挂进了 HostRoot Fiber 的 queue

但还有一件事必须在这里立住:

到这里,ReactElement 已经进入根级更新系统了,但它还没有真正展开成 Fiber 子树。

这句话非常重要。
因为它直接决定了这一篇的边界。

顺着这条链再往后追,问题就会自然切到下一层:

这套系统里真正工作的 Fiber 到底是什么,React 为什么需要 Fiber?

这里把主线再收束成一张更紧凑的图:

flowchart TB
    A[JSX] --> B[ReactElement]
    B --> C[createRoot<br/>初始化根容器]
    C --> D[root.render<br/>提交 element]
    D --> E[createUpdate]
    E --> F[enqueueUpdate]
    F --> G[scheduleUpdateOnFiber]

结语

React 源码最难的地方,从来都不是某一个函数本身。

真正难的是:如果没有地图,很多细节都会看起来彼此割裂。今天看到 ReactElement,明天看到 Root,后天又看到 Fiber,名词越来越多,但主线反而越来越模糊。

所以这一篇真正补上的,不是某个零散知识点,而是 React 主线里的“系统入口”这一段:

React 拿到 ReactElement 之后,并不是立刻把它展开成 Fiber 子树,而是先把它送进根级更新系统。

把这一步看清楚之后,后面再去看 Fiber、Update、Queue、Lane、render、commit,这些东西的落点就会稳很多。

如果这篇对你有帮助,欢迎点个赞支持。后面我也会继续把这组 React 源码文章慢慢补完整。

这组源码解读文章也会同步整理到 GitHub 仓库里,方便集中查看和持续更新:

GitHub:github.com/HWYD/source…

如果觉得这组内容对你有帮助,也欢迎顺手点个 Star。

最近在做的一个 AI 项目

最近我也在持续迭代一个 AI 项目:AI Mind
如果你对 AI 应用工程化方向感兴趣,欢迎来看看:

GitHub:github.com/HWYD/ai-min…

如果觉得还不错,也欢迎顺手点个 Star。

❌
❌