未设置发布日期1约 2853 字10 分钟阅读

从 setState 到像素:渲染管线与调度器的完整链路

导读里的 shouldYield 存在 vs 不存在,答案在这里。本篇追踪一次状态变更从触发到像素的完整路径——React 的 Fiber+Lanes+commit 三阶段、Vue 的微任务队列+Patch Flags、Solid 的同步 batch+直达 DOM。两条独立因果链:调度器解决 render 阶段不卡主线程,读写分离解决 commit 阶段不触发 forced layout。

前端框架深度解剖

第 4 篇 / 共 4 篇

从 setState 到像素:一次状态变更的完整旅程

用户点了一个按钮,状态变了,界面更新了——这个过程看似瞬间完成,但框架内部经历了完全不同长度的管线。总览篇用一张因果传导图概括了管线长度差异的根源,本篇追踪一次状态变更从触发到像素的完整路径,逐步拆解每个环节做了什么、为什么要做、能不能省。

核心发现来自总览篇讨论中的两个关键修正:

  1. 调度器和读写分离是两个正交的优化——调度器(Fiber/Lanes/时间切片)让 render 阶段不卡主线程,commit 三阶段的读写分离让 DOM 操作不触发 forced layout。两者解决不同问题,不应混为一谈。
  2. 管线长度不只是开销,也是架构空间——长管线有空间做读写分离,短管线没有这个架构空间,读写安全的责任转嫁给开发者。

React:Fiber 架构下的完整链路

React 的更新路径是五大框架里最长的,但每一步都有明确的架构目的。

触发阶段

setState(newVal)enqueueUpdate:把更新对象推入 Fiber 节点的更新队列,分配一个 lane(优先级位掩码)。离散事件(click)拿到 SyncLanestartTransition 包裹的拿到 TransitionLane

调度阶段

scheduleCallback:调度器根据当前所有挂起更新的 lanes,选择最高优先级的子集来处理。关键决策——选同步循环还是并发循环

  • 包含 SyncLaneworkLoopSync:不可中断,一口气跑完
  • 不包含 → workLoopConcurrent:每个 work unit 结束后检查 shouldYield(),超过约 5ms 就让出主线程

这是调度器的最后一个决策。 过了 workLoopSync/workLoopConcurrent 的入口,就全是渲染管线的工作了。

Render 阶段

beginWorkcompleteWork:从触发更新的 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 对浏览器最"友好"的设计。

完整路径

text
事件 → 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。

完整路径

text
事件 → 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 内部的读写交替

js
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 的风险完全由开发者承担。

完整路径

text
事件 → 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 的读操作。 offsetHeightclientWidthscrollTopgetComputedStyle()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 操作
HydrateSSR 后客户端接管复用已有 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 操作是否对浏览器布局引擎友好。这是因果链上又一对"选择即代价"的跷跷板:长管线换来了架构空间和安全保障,短管线换来了极致的更新速度和最小的运行时。