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 既是一种数据结构,也是一个工作单元:
{
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)
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 | 场景 | 特点 |
|---|---|---|
SyncLane | flushSync、受控输入 | 同步,最高 |
InputContinuousLane | 输入、拖拽 | 高 |
DefaultLane | setState | 默认 |
TransitionLane | startTransition | 低,可被打断 |
IdleLane | 离屏/兜底 | 最低 |
startTransition 的本质:把更新标记为低优先级, 让用户输入这类高优先级更新能插队,从而避免卡顿。
// 输入立刻响应,昂贵的列表过滤可被打断
setInputValue(e.target.value) // 高优先级
startTransition(() => setQuery(e.target.value)) // 低优先级,可中断并发特性(Concurrent)一句话总结:
不是"更快",而是"能分清轻重缓急"——先响应用户,再算重活。
三、Diff 算法:为什么 key 不能用 index
3.1 三个启发式假设(把 O(n³) 降到 O(n))
- 只做同层比较,跨层级的移动视为"删除 + 新建"(不做跨层复用)
- 类型不同 → 直接销毁重建(
<div>变<span>,整棵子树重来) key相同 → 认为是同一个节点,只更新属性
3.2 单节点 vs 多节点
- 单节点:比对
key和type,不同就删旧建新 - 多节点(数组):分两轮遍历
- 第一轮:从头开始,逐个比对
key,key不同就跳出 - 第二轮:剩下的节点放进
Map(key → fiber),乱序复用
- 第一轮:从头开始,逐个比对
3.3 key 为什么不能用 index
// 列表: [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)
Object.defineProperty(obj, 'name', { get(){}, set(){} })| 问题 | 表现 |
|---|---|
| 不能感知新增 / 删除属性 | 要 Vue.set / Vue.delete |
| 不能感知数组下标与 length | 要重写 7 个数组方法(push/pop/splice…) |
| 初始化就要遍历所有 key | 对象大时启动慢 |
4.2 Vue3 的 Proxy
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 次数据,只触发一次渲染。
count.value++ // 多次修改
count.value++
count.value++
// → 只渲染一次(放进微任务队列,见 A09 的事件循环)
await nextTick() // DOM 已更新对比 React 18 的自动批处理(Automatic Batching):两者思路一致—— 攒一批再渲染,避免中间态的无效渲染。
五、两者设计取向对比(理解差异的根源)
| 维度 | React | Vue |
|---|---|---|
| 更新触发 | 手动 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() |
动手练习
- 写一个 1000 节点的列表,分别用
key=index和key=id,在头部插入一项,用 React DevTools 的 "Highlight updates" 对比重渲染范围。 - 在 Vue3 里给响应式对象新增一个属性,确认不需要
Vue.set;再试试整体替换reactive对象,观察响应式是否丢失(体会ref的必要性)。 - 用
startTransition改造一个输入过滤长列表的页面,在 CPU 4× 降速下对比输入流畅度。
相关文档
- 横向选型对比 → A05 · React 与 Vue 核心机制对比
- 事件循环与时间片 → A09 · JavaScript 运行时
- 状态管理(Signals 是响应式的收敛方向)→ A07 · 状态管理演进与选型
- 掉帧怎么定位 → A25 · 前端性能度量与诊断