从 setState 到像素:渲染管线与调度器的完整链路
导读里的 shouldYield 存在 vs 不存在,答案在这里。本篇追踪一次状态变更从触发到像素的完整路径——React 的 Fiber+Lanes+commit 三阶段、Vue 的微任务队列+Patch Flags、Solid 的同步 batch+直达 DOM。两条独立因果链:调度器解决 render 阶段不卡主线程,读写分离解决 commit 阶段不触发 forced layout。
从 setState 到像素:一次状态变更的完整旅程
用户点了一个按钮,状态变了,界面更新了——这个过程看似瞬间完成,但框架内部经历了完全不同长度的管线。总览篇用一张因果传导图概括了管线长度差异的根源,本篇追踪一次状态变更从触发到像素的完整路径,逐步拆解每个环节做了什么、为什么要做、能不能省。
核心发现来自总览篇讨论中的两个关键修正:
- 调度器和读写分离是两个正交的优化——调度器(Fiber/Lanes/时间切片)让 render 阶段不卡主线程,commit 三阶段的读写分离让 DOM 操作不触发 forced layout。两者解决不同问题,不应混为一谈。
- 管线长度不只是开销,也是架构空间——长管线有空间做读写分离,短管线没有这个架构空间,读写安全的责任转嫁给开发者。
React:Fiber 架构下的完整链路
React 的更新路径是五大框架里最长的,但每一步都有明确的架构目的。
触发阶段
setState(newVal) → enqueueUpdate:把更新对象推入 Fiber 节点的更新队列,分配一个 lane(优先级位掩码)。离散事件(click)拿到 SyncLane,startTransition 包裹的拿到 TransitionLane。
调度阶段
scheduleCallback:调度器根据当前所有挂起更新的 lanes,选择最高优先级的子集来处理。关键决策——选同步循环还是并发循环:
- 包含
SyncLane→workLoopSync:不可中断,一口气跑完 - 不包含 →
workLoopConcurrent:每个 work unit 结束后检查shouldYield(),超过约 5ms 就让出主线程
这是调度器的最后一个决策。 过了 workLoopSync/workLoopConcurrent 的入口,就全是渲染管线的工作了。
Render 阶段
beginWork → completeWork:从触发更新的 Fiber 节点开始,向下遍历子树。每个 Fiber 节点执行 beginWork(重新执行组件函数、生成新的子 Fiber 节点),再执行 completeWork(收集 effect 标记)。
时间切片作用于这个阶段——workLoopConcurrent 在每个 work unit 之间检查是否该让出。注意:时间切片让 render 阶段不阻塞用户交互,但不让 React "更快"——总工作量不变,甚至有切换开销。
Commit 阶段
render 阶段完成后,进入不可中断的 commit 阶段——三个子阶段硬编码顺序执行:
1. beforeMutation(读 DOM):执行 getSnapshotBeforeUpdate,获取 DOM 更新前的状态(如滚动位置)。
2. mutation(写 DOM):执行所有 DOM 增/删/改操作。这个阶段是纯写、不可插入读操作的——从架构上消除了 forced synchronous layout 的可能性。
3. layout(受控的读写混合区):执行 useLayoutEffect 回调。此时 DOM 已经更新完毕,开发者可以同步读取布局信息并做调整。最多触发一次额外 layout,不会产生交替 thrashing。
读写分离发生在这里,不在调度器里。 beforeMutation 读、mutation 写、layout 读写——三阶段的硬编码顺序是 React 对浏览器最"友好"的设计。
完整路径
事件 → setState → enqueueUpdate → scheduleCallback →
[调度器选 lane] → [选同步/并发] ──┃── workLoop → beginWork → completeWork
┃ (render 阶段,可中断)
分界线 1
completeWork ──┃── beforeMutation(读) → mutation(写) → layout(读写)
┃ (commit 阶段,不可中断)
分界线 2
两条分界线,两个正交的优化:分界线 1 之前是调度(解决响应性),分界线 2 之后是读写分离(解决浏览器布局友好性)。
Vue 3:从 trigger 到 patch 的微任务管线
Vue 的更新路径比 React 短——没有 Fiber 树的双缓冲,没有 lanes 优先级,没有时间切片。
触发 → 通知 → 入队
ref.value = newVal → Proxy set 触发 trigger() → 沿 Dep 的 Link 链表遍历找到所有 subscriber → 对每个组件的 renderEffect 调用 queueJob()。
queueJob 做了两件事:去重(同一个组件在一个 tick 内多次触发只入队一次)和排序(按组件树深度排序,父先于子)。
微任务 flush
当前同步代码执行完毕后,微任务回调触发 flushJobs——遍历排好序的 job 队列,依次调用 job()。
分界线在 flushJobs 取出 job 开始执行的那一刻——之前是调度(queueJob 入队、去重、排序),之后是渲染(effect.run → 执行渲染函数 → 生成 VNode → patch)。
patch:带 Patch Flags 的快速 diff
patch(oldVNode, newVNode) 递归比对两棵 VNode 树。编译器注入的 Patch Flags 让 diff 跳过已知不变的属性——patchFlag & PatchFlags.CLASS 为真就只检查 class,其他全跳过。Block Tree 让 diff 只比对动态节点的扁平数组,跳过整棵静态子树。
Vue 没有独立的 commit 阶段——DOM 操作在 patch 递归过程中边 diff 边执行。这意味着 Vue 没有 React 那种 beforeMutation/mutation/layout 的读写分离架构。nextTick 提供了"正确的读 DOM 时机",但这是建议不是强制——在 watchEffect 里直接读布局属性一样会触发 forced layout。
完整路径
事件 → ref.value= → trigger → 沿 Link 链表找 subscriber →
queueJob(去重+排序)→ [微任务等待] → flushJobs ──┃── job() → effect.run()
┃ → render → patch → DOM
分界线
一条分界线,一层调度。Vue 没有优先级——所有 job 等价,唯一排序维度是树深度不是紧急程度。
Solid:signal 到 DOM 的直达路径
Solid 的更新路径是最短的——从 signal 变化到 DOM 更新,整条链路是同步的、直达的。
触发 → 通知 → 执行
setSignal(newVal) → 遍历 signal 的 observers 数组 → 按拓扑排序确定执行顺序 → 同步执行每个 computation 的回调 → effect 里的 el.textContent = count() 直接更新 DOM。
没有微任务延迟,没有队列排序,没有优先级调度。调度和渲染是"内联"的——几乎没有一个明确的"调度器把工作移交给渲染管线"的时刻。
batch:合并多个 signal 变更
batch() 把多个 signal 变更的通知延迟到 batch 结束时一次性 flush。这解决了 effect 之间的 DOM 操作合并问题——没有 batch 时,改 signalA 和 signalB 分别触发两个 effect,中间如果有 DOM 读操作会触发两次 forced layout;有了 batch,两个 effect 连续执行,浏览器可以合并。
但 batch() 无法阻止单个 effect 内部的读写交替:
createEffect(() => {
el.style.width = width() + 'px'; // 写
const h = el.offsetHeight; // 读 → 触发 forced layout!
el.style.height = h * 2 + 'px'; // 写
});
这段代码在 React 里写不出来——不是因为 React 不允许你读 DOM,而是因为 commit 架构把 DOM 读和 DOM 写隔离到了不同阶段。 Solid 的 effect 里没有这个隔离,读写交替触发 forced layout 的风险完全由开发者承担。
完整路径
事件 → setSignal → [batch 内同步] → effect() → DOM 操作
(几乎无分界线,调度和渲染内联)
没有分界线,没有独立的 commit 阶段,没有读写分离架构。管线最短,但框架能提供的"安全网"最少。
调度器的正交性:时间切片 vs 读写分离
这两个优化经常被混为一谈,但它们解决的是完全不同的问题:
| 维度 | 时间切片 | 读写分离 |
|---|---|---|
| 解决什么问题 | render 阶段的长任务不卡主线程 | commit 阶段的 DOM 操作不触发 forced layout |
| 作用于哪个阶段 | render(beginWork/completeWork) | commit(beforeMutation/mutation/layout) |
| 机制 | 每 ~5ms 检查 shouldYield,让出主线程 | 三阶段硬编码顺序:读→写→受控读写 |
| 哪些框架有 | React(Fiber 架构) | React(commit 三阶段) |
| 为什么其他框架不需要 | Solid/Svelte 每次更新工作量极小 | Vue 的 patch 边 diff 边写没有独立 commit |
Solid 不需要时间切片是因为每次更新只改一个 DOM 节点——工作量小到不值得中断。React 需要时间切片是因为 setState 一调用就要重跑组件函数 + diff 子树——单次更新工作量大到可能冻结界面。调度器的复杂度是框架为响应式粒度不足买的单。
Solid 没有读写分离是因为管线太短——effect 回调里的 DOM 操作就是整条管线的全部,不存在"先攒一堆写操作再统一执行"的中间层。管线短到没有架构空间切出独立的阶段。
渲染管线与浏览器的跷跷板(深度版)
总览篇第五章用"跷跷板"概括了管线长度和浏览器布局压力的关系,这里用更精确的机制解释。
浏览器什么时候合并 DOM 写操作
浏览器合并连续 DOM 写操作的前提是:写操作之间没有触发 layout 的读操作。 offsetHeight、clientWidth、scrollTop、getComputedStyle()、getBoundingClientRect() 等属性/方法的读取会强制浏览器立即执行挂起的 layout 计算。
React 的两层隔离
React 在两个层面都做了隔离:
- effect 间:commit 阶段所有 mutation 连续执行,中间不夹杂读操作
- effect 内:
beforeMutation(读) /mutation(写) /layout(受控读写) 三阶段分离
Solid 的一层覆盖
Solid 的 batch() 只覆盖了第一层(effect 间合并),第二层(effect 内读写分离)完全裸露。
Vue 的一层半
Vue 的 queueJob + 微任务 flush 覆盖了第一层(effect 间),nextTick 提供了第二层的"软隔离"(建议而非强制)。
跷跷板的本质
管线长度不只是性能开销,也是架构空间。 管线长的框架有空间在管线内部做精细的读写分离;管线短的框架没有这个架构空间,读写安全由开发者承担。
Solid 的短管线在开发者遵守读写纪律的前提下,绝对性能仍然最高。但管线越短,框架能提供的"安全网"越少。
所有框架在批处理这个需求上殊途同归——React 在调度层合并,Vue 在微任务层合并,Solid 在 signal 传播层合并。但只有 React 在第二层(commit 阶段的读写分离)也做了架构级保障。
四条路径的完整图景
本文追踪的是 update 路径——状态变更触发的 DOM 更新。但完整的渲染管线至少有四条路径:
| 路径 | 触发条件 | 和 update 的区别 |
|---|---|---|
| Mount | 组件首次渲染 | 创建真实 DOM 节点而非比对更新 |
| Update | 状态变更 | 本文的主题——diff/patch/直接 DOM 操作 |
| Hydrate | SSR 后客户端接管 | 复用已有 DOM 节点而非创建,详见 SSR 篇 |
| Unmount | 组件销毁 | 清理副作用、移除 DOM、回收订阅 |
React 的 hydrate 路径走的是不同的 reconciler 分支(hydrateRoot vs createRoot),Vue 的 patch 函数内部有 hydrateNode 分支,Solid 的 hydrate 路径跳过 DOM 创建只做"接线"。卸载路径的复杂度也和框架基因高度相关——React 的 commitDeletionEffects 要递归清理 Fiber 子树和 effect 链,Solid 的 onCleanup 跟着 owner 的 dispose 走最简单。
这四条路径在渲染与调度的架构选择上共享同一套基础设施——调度器的 lanes 和时间切片对 mount/update/hydrate 都生效,commit 三阶段的读写分离对所有涉及 DOM 操作的路径都提供保护。
渲染与调度篇总结:管线长度是架构空间
一次状态变更到 DOM 更新的完整路径:
| 框架 | 路径步骤数 | 调度复杂度 | 读写分离 | 时间切片 |
|---|---|---|---|---|
| React | ~7步 | 最高(Fiber+Lanes) | 两层(effect间+effect内) | 有 |
| Vue | ~5步 | 中(微任务队列+排序) | 一层半(effect间+nextTick软隔离) | 无 |
| Solid | ~3步 | 最低(同步batch) | 一层(effect间,batch覆盖) | 无 |
导读里的 shouldYield 存在 vs 不存在,答案就在这里: Solid 的每次更新只改一个 DOM 节点,根本产生不了需要时间切片来拆分的长任务。React 必须有时间切片,因为 setState 一调用就要重跑组件函数 + diff 子树——调度器的复杂度是框架为响应式粒度不足买的单。
管线长度不只是开销——它决定了框架能否在管线内部做读写分离,读写分离决定了 DOM 操作是否对浏览器布局引擎友好。这是因果链上又一对"选择即代价"的跷跷板:长管线换来了架构空间和安全保障,短管线换来了极致的更新速度和最小的运行时。