React 不是框架,是 UI Runtime:从 Fiber 到调度器的完整心智模型
从 beginWork / completeWork 到 Lane 模型,从 bailout 机制到 useEffect 的真正含义——搞懂这些,React 的 API 就不重要了
React 团队自己说过:React is a UI runtime, not a library。
一个 library 是你调用它。Runtime 是它调用你——决定什么时候执行你的组件函数,决定中断、恢复、丢弃渲染结果,管理状态生命周期,调度副作用执行时机。你写的组件只是 Runtime 的插件。
普通 library 的交互模式:
你的代码 → 调用 lodash.groupBy() → 拿结果
React Runtime 的交互模式:
React Runtime
├── 决定什么时候调用你的组件函数
├── 决定调用几次(StrictMode 故意调两次)
├── 决定中断、恢复、丢弃你的渲染结果
├── 管理你的状态生命周期
└── 调度副作用的执行时机
理解了这一点,很多“奇怪的行为”就不奇怪了。
一、为什么需要 Fiber
React 15 的 Reconciler 是同步递归的。一旦开始 diff,就必须一口气跑完整棵树,主线程被占满,浏览器没有机会响应用户输入或绘制帧,卡顿随之而来。
Fiber 要解决的本质问题:把不可中断的递归,变成可中断、可恢复、可优先级排序的迭代。
实现方式很直接——用链表替换调用栈。每个 React 元素对应一个 Fiber 节点,是一个普通 JS 对象:
{
tag, // 节点类型(FunctionComponent / HostComponent / ...)
type, // 对应的函数 / 类 / 字符串
stateNode, // 宿主环境的真实节点(DOM / RN View)
return, // 父 Fiber
child, // 第一个子 Fiber
sibling, // 下一个兄弟 Fiber
pendingProps, // 本次要处理的新 props
memoizedProps, // 上一次渲染完成后固化的 props
memoizedState, // hooks 链表挂在这里
lanes, // 这个节点上待处理的优先级集合
flags, // 副作用标记(Placement / Update / Deletion)
subtreeFlags, // 子树的副作用汇总(React 18 优化)
alternate, // 指向另一棵树的对应节点(双缓冲)
}
child / sibling / return 三个指针构成链表,取代了递归调用栈。链表随时能“存档”当前位置,下次从这里继续。递归没有这个能力。
二、JSX、ReactElement、Fiber 是三个不同层次
这三个概念经常被混在一起,实际上层次完全不同:
JSX
↓ babel/swc 编译
React.createElement() / jsx()
↓ 执行
ReactElement ← 轻量的 plain object,只描述"长什么样",无状态,每次渲染重新生成
↓ reconcile
Fiber Node ← 有状态、有指针、可调度的工作单元,跨渲染复用
↓ commit
真实 DOM / RN View
ReactElement 是纯描述。Fiber 是有记忆的——状态、副作用、优先级都挂在上面。这也是 React 和 Vue vnode 的一个本质差异:Vue 的 vnode 承担了更多职责,React 把“描述”和“工作单元”拆成了两层。
三、双缓冲树
React 同时维护两棵 Fiber 树:
- current tree — 当前屏幕上渲染的内容
- workInProgress tree — 正在后台构建的新树
每个节点通过 alternate 互相指向对方。构建完成后,fiberRoot.current 指针切换,原来的 current 变成下一次的 workInProgress 复用,避免重复分配对象。
四、工作循环:beginWork 和 completeWork
Fiber 的遍历是深度优先,由工作循环驱动:
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
function performUnitOfWork(unitOfWork) {
const next = beginWork(unitOfWork); // 处理当前节点,返回第一个 child
if (next === null) {
completeUnitOfWork(unitOfWork); // 没有子节点,开始回溯
} else {
workInProgress = next; // 继续向下
}
}
beginWork — 向下展开,负责协调。根据节点类型处理当前节点,对 FunctionComponent 就是执行函数体、处理 hooks;对 HostComponent 就是 diff props、准备子节点的 Fiber。产出是 child Fiber。
completeWork — 向上收束,负责构建与收集。对 HostComponent 创建真实 DOM 节点,把子节点拼成离屏 DOM 子树;同时把 flags 往父节点冒泡:
node.return.subtreeFlags |= node.subtreeFlags | node.flags;
commit 阶段遍历时,subtreeFlags === 0 的子树可以整个跳过,不用深入——这是 React 18 的优化。
两者的分工:
beginWork → 我该渲染什么子节点?(向下,生产 child Fiber)
completeWork → 我自己的真实产物是什么?(向上,构建 DOM / 收集 flags)
五、mount 和 update 是两条路径
beginWork 里有一个关键判断:
if (current === null) {
// 首次挂载(mount)
mountChildFibers(...)
} else {
// 更新(update)
reconcileChildFibers(...)
}
current === null 意味着这个节点是第一次渲染。mount 和 update 走完全不同的逻辑——几乎所有 hook 内部也是靠这个判断走 mount 还是 update 路径。
首次挂载时有一个重要优化:React 故意不给子节点打 Placement flag,因为整棵树都是新的,如果每个节点都打,commit 阶段就要插入 DOM 无数次。实际做法是只在根节点打一个 Placement,commit 时把整棵离屏 DOM 子树一次性插入文档。这棵离屏子树正是 completeWork 在回溯时逐级拼好的。
六、diff 发生在哪里
diff 不是两棵 Fiber 树之间的比较,而是:
current Fiber vs 新的 ReactElement(JSX 产出)→ 生成 wip Fiber
发生在 beginWork → reconcileChildren 内部。wip Fiber 是 diff 的产物,不是输入。
React diff 三条核心策略:
- 只比同层,不跨层级,复杂度从 O(n³) 降到 O(n)
- type 不同直接废弃,旧子树整个删掉重建
- key 用来识别身份,判断节点是移动了还是新增/删除了
多节点 diff 两轮遍历:第一轮按顺序比,遇到 key 或 type 不同停下;第二轮把剩余旧节点放进 Map,新节点来查 key 能不能复用。
完整调用链:
workLoop → beginWork → reconcileChildren(diff 在这里)→ 生成/复用 Fiber + 打 flags
→ completeWork → 构建离屏 DOM + 冒泡 subtreeFlags
→ commit
七、pendingProps 和 memoizedProps
这两个字段贯穿整个 render 阶段:
pendingProps ← beginWork 开始时,本次要处理的新 props
memoizedProps ← 上一次渲染完成后"固化"的 props
时机:
beginWork 开始 → pendingProps = 本次 ReactElement 带来的新 props(还没处理完)
completeWork 结束 → memoizedProps = pendingProps(处理完了,固化)
render 阶段可以被打断。中断时 pendingProps 还在,memoizedProps 还是上一次的值——恢复时 React 知道“这个节点还没处理完”,可以从 pendingProps 重新开始。
八、bailout:为什么引用稳定这么重要
beginWork 一进来会做这个判断:
if (current.memoizedProps === workInProgress.pendingProps) {
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
用的是 === 引用比较,不是深比较。props 没变 → bailout → 整棵子树跳过,不进入 reconcile。
这就是为什么:
// 每次渲染都是新对象,引用变了 → bailout 失效
<Child style={{ color: 'red' }} />
// useMemo 保持引用 → === 成立 → bailout,子树跳过
const style = useMemo(() => ({ color: 'red' }), []);
<Child style={style} />
// 函数作为 props 同理
<Child onClick={() => doSth()} /> // 每次新函数 → 永远无法 bailout
<Child onClick={useCallback(() => doSth(), [])} /> // 引用稳定 → 可以 bailout
React 的性能优化本质上只有一件事:让 memoizedProps === pendingProps 尽可能成立。
memo / useMemo / useCallback 都服务于这一个目标。React Compiler 做的事,就是在编译阶段静态分析你用了哪些变量,自动插入这些优化——用编译时手段,模拟 Vue 运行时自动 track 的效果。
九、hooks 链表
memoizedState 上挂着一条单向链表,每个 hook 调用对应链表里的一个节点:
memoizedState → { state: 0, next } → { state: fn, next } → { state: [], next } → null
useState useEffect useMemo
mount 时链表按 hook 调用顺序构建,update 时按同样的顺序逐个取出复用。
这就是为什么 hook 不能写在条件语句里:
// ❌ 条件里调用 hook
if (condition) {
useState(0); // 某次渲染 condition 为 false,这个节点消失
} // 后续所有 hook 的链表位置全部错位
链表顺序是 React 识别“这次的 useState 对应上次哪个状态”的唯一依据,顺序一旦乱,状态就乱。
十、Lane 模型:优先级是位掩码
React 18 用 Lane 模型替代了之前的 expirationTime。每个 Lane 是一个二进制位:
SyncLane = 0b0000000000000000000000000000001 // 同步,不可中断
InputContinuousLane= 0b0000000000000000000000000000100 // 连续输入(拖拽)
DefaultLane = 0b0000000000000000000000000010000 // 普通更新
TransitionLane = 0b0000000000000000000001000000000 // startTransition
IdleLane = 0b0100000000000000000000000000000 // 空闲
位掩码的好处是可以用位运算批量判断、合并、拆分优先级,比数字比较高效得多。
每个更新产生时,React 根据触发来源分配对应的 Lane。startTransition 内的更新走 TransitionLane,优先级低,可以被打断;用户输入走 SyncLane,优先级最高,不可中断。
十一、Scheduler:时间切片的实现
Scheduler 是独立包(scheduler),React 只是它的消费者。
核心机制:用 MessageChannel 发宏任务。
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
shouldYield() 就是 Scheduler 暴露的接口。每次宏任务开始记录 startTime,加上时间切片长度(默认 5ms)得到 deadline,超时就返回 true,让出控制权。
为什么用 MessageChannel 而不是 setTimeout(fn, 0)?后者有 4ms 最小延迟。
为什么不用微任务(Promise.then)?浏览器渲染发生在两个宏任务之间。微任务会在同一个任务内连续执行,浏览器没有机会插入渲染帧,达不到让出的效果。
十二、commit 阶段:不可中断
render 阶段可以被打断,commit 阶段不行。分三个子阶段,各自遍历一次 effect list:
| 子阶段 | 时机 | 主要工作 |
|---|---|---|
| before mutation | DOM 变更前 | getSnapshotBeforeUpdate,异步调度 useEffect |
| mutation | DOM 变更 | 插入 / 更新 / 删除真实 DOM |
| layout | DOM 变更后、浏览器 paint 前 | useLayoutEffect、componentDidMount/Update |
时序:
mutation 阶段完成
↓
layout 阶段 → useLayoutEffect 同步执行 ← DOM 已更新,浏览器还没 paint
↓
浏览器 paint
↓
Scheduler 异步 → useEffect 执行
useLayoutEffect 能读取真实布局信息并同步修改,不会引起闪烁,代价是阻塞 paint。原则:需要读写 DOM、避免闪烁用 useLayoutEffect,其他副作用用 useEffect。
十三、useEffect 不是生命周期
Dan Abramov 说过:不要用生命周期的思维理解 useEffect。
生命周期思维是以组件为中心:
组件挂载 → 做什么
组件更新 → 做什么
组件卸载 → 做什么
useEffect 的正确心智模型是以数据流为中心:让这个副作用和外部系统同步。
// 生命周期思维 → 典型 bug
useEffect(() => {
subscribeToChat(roomId);
}, []); // roomId 变了还在订阅旧频道
// 正确思维
useEffect(() => {
const unsub = subscribeToChat(roomId);
return () => unsub();
}, [roomId]); // roomId 变了就重新同步
正确的问法不是“这个 effect 什么时候执行”,而是“这个 effect 在和什么同步”。
十四、Vue 响应式和 React deps 是同一件事
// Vue watchEffect:自动收集依赖
watchEffect(() => {
console.log(count.value); // 访问时自动 track
});
// React useEffect:手动声明依赖
useEffect(() => {
console.log(count);
}, [count]); // 手动告诉 React
本质上是同一个思想:副作用跟着数据走,数据变了重新执行。
差别在实现哲学:Vue 的数据是响应式 Proxy,访问即订阅,自动 track / trigger。React 的数据是普通 JS 值,没有任何拦截,React 根本感知不到你在 effect 里用了什么,只能让你手动声明。
Vue → 数据是响应式的,effect 是普通函数,运行时自动 track
React → 数据是普通的,effect 需要手动声明依赖
React Compiler 要解决的问题,本质上就是在编译阶段静态分析你用了哪些变量,自动填 deps——用编译时手段,模拟 Vue 运行时自动 track 的效果。两条路走向同一个终点。
结语
理解了这些之后,React 的 API 其实很薄——useState、useEffect、useMemo、useCallback,每一个都能直接对应到上面某个机制。
框架 API 会过时,架构思维不会。Fiber 的可中断设计、Lane 的位掩码优先级、Scheduler 的时间切片——这些思想换个语言、换个框架依然成立。
搞明白了 Runtime 在做什么,你写的每一行代码都有了来处。