Skip to content

20 · 框架底层原理:Fiber 与响应式 ​

本篇解决什么问题: "为什么 React 要重写成 Fiber?""Vue3 为什么用 Proxy?""为什么 key 不能用 index?" 这些问题背答案没用,要理解它为了解决什么矛盾。本篇纵向拆到实现层。

📌 与 A05(React 与 Vue 核心机制对比)的分工:A05 是横向对比选型,本篇是纵向深入实现。


开篇速览 ​

主题核心矛盾方案
React Fiber递归渲染不可中断,大组件树卡死主线程链表化 + 可中断的分片渲染
Scheduler如何决定"谁先渲染"时间切片 + Lane 优先级模型
Diff全量对比太慢同层比较 + key 复用(O(n) 启发式)
Vue3 响应式Object.defineProperty 无法感知新增/删除属性与数组下标Proxy 代理整个对象
依赖收集怎么知道"哪个组件用了这个数据"渲染时收集、修改时触发

一、React Fiber:为什么要重写 ​

1.1 老架构(Stack Reconciler)的问题 ​

React 15 的渲染是递归遍历组件树:

render(<App/>)  →  递归调用每个组件的 render  →  一口气算完  →  提交 DOM

致命问题:递归是同步不可中断的。组件树一大(比如 1000 个节点), 这次计算可能耗时 几百毫秒,期间主线程被独占,用户点了没反应、动画掉帧。

回忆 A09 的 16.67ms 帧预算——一条渲染任务跑了 300ms, 意味着 18 帧被丢掉。

1.2 Fiber 的解法:把递归改成可中断的链表遍历 ​

Fiber 既是一种数据结构,也是一个工作单元:

js
{
  tag, type, key,              // 节点类型信息
  return,    // 父 Fiber
  child,     // 第一个子 Fiber
  sibling,   // 下一个兄弟 Fiber
  // ↓ 双缓冲:current(屏幕上已渲染的)与 workInProgress(正在构建的)
  alternate,
  pendingProps, memoizedProps, memoizedState,
  flags,     // 增/删/改 标记(提交阶段用)
  lanes,     // 优先级
}

关键结构变化:树 → 链表(child / sibling / return), 使遍历可以用一个 while 循环 + 指针完成,随时能停下来保存现场、再恢复。

1.3 两个阶段 ​

阶段做什么能否中断
Render / Reconciliation(可中断)遍历 Fiber,算出变更,打 flags✅ 可分片、可暂停、可重做
Commit(不可中断)按 flags 真正操作 DOM❌ 必须一次完成(否则 UI 半截状态)
render 阶段(可打断,时间切片)        commit 阶段(同步,一口气)
┌──────────────────────────────┐    ┌──────────────────────┐
│ workLoop:                     │    │ mutation: 改 DOM     │
│   while (有剩余时间 && 有任务) │───►│ layout:   useEffect  │
│     performUnitOfWork()       │    │ passive:  useEffect  │
└──────────────────────────────┘    └──────────────────────┘

⚠️ 因为 render 阶段可中断重做,它必须是纯的、无副作用的—— 这就是 React 严格模式下 componentWillMount 等生命周期被废弃的原因(它们可能被重复调用)。


二、Scheduler:谁先渲染 ​

2.1 时间切片(Time Slicing) ​

js
function workLoop(deadline) {
  while (nextUnitOfWork && !shouldYield()) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
  }
  // 时间片用完了 → 让出主线程,下一帧继续
  requestIdleCallback(workLoop)   // 实际 React 自己实现了更精细的调度
}

shouldYield() = "当前时间片用完 || 有更高优先级任务插队"。

💡 React 没有用 requestIdleCallback(它触发频率不稳定), 而是用 MessageChannel + 5ms 时间片自己实现了 scheduler 包。

2.2 Lane 模型(优先级) ​

React 18 用 Lane(车道) 二进制位表示优先级:

Lane场景特点
SyncLaneflushSync、受控输入同步,最高
InputContinuousLane输入、拖拽高
DefaultLanesetState默认
TransitionLanestartTransition低,可被打断
IdleLane离屏/兜底最低

startTransition 的本质:把更新标记为低优先级, 让用户输入这类高优先级更新能插队,从而避免卡顿。

js
// 输入立刻响应,昂贵的列表过滤可被打断
setInputValue(e.target.value)                    // 高优先级
startTransition(() => setQuery(e.target.value))  // 低优先级,可中断

并发特性(Concurrent)一句话总结:

不是"更快",而是"能分清轻重缓急"——先响应用户,再算重活。


三、Diff 算法:为什么 key 不能用 index ​

3.1 三个启发式假设(把 O(n³) 降到 O(n)) ​

  1. 只做同层比较,跨层级的移动视为"删除 + 新建"(不做跨层复用)
  2. 类型不同 → 直接销毁重建(<div> 变 <span>,整棵子树重来)
  3. key 相同 → 认为是同一个节点,只更新属性

3.2 单节点 vs 多节点 ​

  • 单节点:比对 key 和 type,不同就删旧建新
  • 多节点(数组):分两轮遍历
    • 第一轮:从头开始,逐个比对 key,key 不同就跳出
    • 第二轮:剩下的节点放进 Map(key → fiber),乱序复用

3.3 key 为什么不能用 index ​

jsx
// 列表: [A, B, C],key 用 index
// 删除 A 后变成 [B, C],index 重排:
//   旧 key=0 → A      新 key=0 → B   ❌ React 认为"key 0 的内容从 A 变成了 B"
//   旧 key=1 → B      新 key=1 → C   ❌
//   旧 key=2 → C      (无)

后果:

问题原因
性能差本可复用的节点被判定为"内容变了",触发不必要的更新
状态错乱如果节点是有状态的组件或表单输入,state 会跟错对象(B 拿到了 A 的输入值)

✅ 正确做法:用稳定且唯一的业务 ID(item.id)。


四、Vue3 响应式:Proxy 干了什么 ​

4.1 Vue2 的痛点(Object.defineProperty) ​

js
Object.defineProperty(obj, 'name', { get(){}, set(){} })
问题表现
不能感知新增 / 删除属性要 Vue.set / Vue.delete
不能感知数组下标与 length要重写 7 个数组方法(push/pop/splice…)
初始化就要遍历所有 key对象大时启动慢

4.2 Vue3 的 Proxy ​

js
const reactive = obj => new Proxy(obj, {
  get(target, key, receiver) {
    track(target, key)                       // ① 收集依赖
    const res = Reflect.get(target, key, receiver)
    return typeof res === 'object' ? reactive(res) : res   // ② **惰性**递归
  },
  set(target, key, value, receiver) {
    const old = target[key]
    const ok = Reflect.set(target, key, value, receiver)
    if (old !== value) trigger(target, key)  // ③ 触发更新
    return ok
  },
  deleteProperty(target, key) {
    const had = Object.prototype.hasOwnProperty.call(target, key)
    const ok = Reflect.deleteProperty(target, key)
    if (had && ok) trigger(target, key)      // ✅ 能感知删除
    return ok
  },
})

优势:

优势说明
感知新增/删除不需要 Vue.set
感知数组下标 / length不需要重写数组方法
惰性代理只有被访问到的嵌套对象才会被代理(启动更快)

⚠️ 代价:Proxy 是 ES6,无法 polyfill → Vue3 不支持 IE11。

4.3 依赖收集:怎么知道"谁用了我" ​

渲染组件 ──► 访问响应式数据 ──► getter 里 track() ──► 把「当前正在渲染的副作用」存进 deps
数据修改 ──► setter 里 trigger() ──► 取出 deps ──► 重新执行这些副作用(即重新渲染)

关键是一个全局"当前副作用"栈:渲染前把组件的 effect 压栈, getter 里把它和 key 关联起来(存在 WeakMap<target, Map<key, Set<effect>>> 结构里)。

💡 ref 与 reactive 的取舍: reactive 用于对象,不能整体替换(会丢响应式); ref 包一层 .value,可以整体替换,更适合基础类型与需要重新赋值的场景。

4.4 调度:nextTick 与批量更新 ​

Vue 的更新是异步批量的:同一个 tick 内改 100 次数据,只触发一次渲染。

js
count.value++   // 多次修改
count.value++
count.value++
// → 只渲染一次(放进微任务队列,见 A09 的事件循环)
await nextTick()   // DOM 已更新

对比 React 18 的自动批处理(Automatic Batching):两者思路一致—— 攒一批再渲染,避免中间态的无效渲染。


五、两者设计取向对比(理解差异的根源) ​

维度ReactVue
更新触发手动 setState(不知道哪个组件该更新,要自上而下遍历)自动(精确知道哪个 effect 依赖了这个数据)
组件级优化需要 memo / useMemo / useCallback 手动挡依赖收集天然精确,多数情况不需要手动优化
架构重心调度(谁能先跑)——Fiber + Lane精确(谁该重跑)——响应式依赖图
心智负担优化要自己管灵活度与可控性稍弱

🎯 一句话:React 把精力花在"调度"上(Fiber/Lane),Vue 把精力花在"精确依赖"上(Proxy/依赖收集)。 理解这一条,A05 里的所有对比都能串起来。


自检清单 ​

  • [ ] 能说出 React 15 递归渲染的致命问题,以及 Fiber 如何改成可中断
  • [ ] 能区分 render 阶段(可中断)与 commit 阶段(不可中断)
  • [ ] 能解释 startTransition / Lane 优先级解决了什么问题
  • [ ] 能说清 Diff 的三个启发式假设,以及 key 用 index 的两类后果
  • [ ] 能说清 Vue3 Proxy 相对 defineProperty 的三个优势与一个代价
  • [ ] 能描述"渲染时收集依赖、修改时触发更新"的完整链路

常见误区 ​

❌ 误区✅ 事实
"Fiber 让 React 变快了"Fiber 让渲染可中断、可调度,提升的是响应性而非绝对速度
"commit 阶段也能被打断"不能,否则 UI 会出现半截状态
"key 用 index 只是性能差一点"还可能导致状态错乱(表单值跟错对象)
"Vue3 的 Proxy 能 polyfill 到 IE"不能,Proxy 无法 polyfill,Vue3 不支持 IE11
"响应式数据改了 DOM 立刻更新"是异步批量的,要 await nextTick()

动手练习 ​

  1. 写一个 1000 节点的列表,分别用 key=index 和 key=id,在头部插入一项,用 React DevTools 的 "Highlight updates" 对比重渲染范围。
  2. 在 Vue3 里给响应式对象新增一个属性,确认不需要 Vue.set;再试试整体替换 reactive 对象,观察响应式是否丢失(体会 ref 的必要性)。
  3. 用 startTransition 改造一个输入过滤长列表的页面,在 CPU 4× 降速下对比输入流畅度。

相关文档 ​