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 前 useLayoutEffectcomponentDidMount/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 其实很薄——useStateuseEffectuseMemouseCallback,每一个都能直接对应到上面某个机制。

框架 API 会过时,架构思维不会。Fiber 的可中断设计、Lane 的位掩码优先级、Scheduler 的时间切片——这些思想换个语言、换个框架依然成立。

搞明白了 Runtime 在做什么,你写的每一行代码都有了来处。