这两周 master 上没有调度器侧的大改动,值得记录的是两个实验性质的进展。一个是 9 月 14 日合入的 263cfa6e([Experimental] Add useInsertionEffect,#21913),给 commit 阶段加了一类新的 effect;另一个是 Server Components(内部叫 Flight)的代码在 react-server 和 react-server-dom-webpack 两个包里持续堆积,协议核心已经能跑,但接入工具链基本还是 TODO。两个都明确标着实验性质,离能用还有距离,这篇只做现状记录,不做展望。参考代码是 master 分支 a8cabb5648。
系列目录
| 日期 | 标题 |
|---|---|
| 06-08 | 从 Stack Reconciler 到 Fiber:追踪 React 18 开发,先看数据结构 |
| 06-10 | React 18 追踪:Lane 模型(上),31 个二进制位取代 expirationTime |
| 06-17 | React 18 追踪:Lane 模型(下):调度决策与饥饿保护 |
| 06-24 | React 18 追踪:Concurrent 工作循环与时间切片 |
| 07-01 | React 18 追踪:createRoot 转正,ReactDOM.render 进入废弃警告 |
| 07-08 | React 18 追踪:flushSync 统一同步刷新入口 |
| 07-15 | React 18 追踪:自动批处理与交错更新队列 |
| 07-22 | React 18 追踪:Suspense 的挂起与恢复 |
| 08-05 | React 18 追踪:useTransition 与 useDeferredValue |
| 08-12 | React 18 追踪:useOpaqueIdentifier,SSR 一致的 id 怎么生成 |
| 08-19 | React 18 追踪:事件系统与 Context 传播 |
| 09-02 | React 18 追踪:Fizz,流式 SSR 引擎 |
| 09-09 | React 18 追踪:选择性注水,用户交互如何插队 hydration |
| 09-16 | React 18 追踪:useInsertionEffect 与 Server Components 雏形(本篇) |
useInsertionEffect:commit 阶段多了一类 effect
先看导出形态。packages/react/src/React.js 里以 useInsertionEffect as unstable_useInsertionEffect 导出,packages/react/index.js 和 index.experimental.js 的导出列表里都加了这一项。导出走的是构建通道的区分:index.stable.js 里没有这一项,index.experimental.js(和全量导出的 index.js)才导出,等于只有 experimental 通道的构建拿得到它。实现代码本身没有 feature flag 包裹,.new.js 和 .old.js 两个 fork 各加了同样的 79 行。所以 alpha 构建里现在就能用,只是名字带着 unstable 前缀,PR 标题也标了 [Experimental],作者和 review 讨论里都把它定位为给 CSS-in-JS 库作者用的 API,不是给业务代码的。
实现本身很小。packages/react-reconciler/src/ReactHookEffectTags.js 把 hook effect 的位标记从 3 位扩到 4 位,插进一个 Insertion = 0b0010,Layout 和 Passive 顺次左移。ReactFiberHooks.new.js 里的 mountInsertionEffect / updateInsertionEffect 只有一行实质内容:
function mountInsertionEffect(create, deps) {
return mountEffectImpl(UpdateEffect, HookInsertion, create, deps);
}和 useLayoutEffect、useEffect 共用同一套 effect 链表机制,fiber flag 同样用 UpdateEffect,差异只在 hook tag 上。渲染阶段做的事情完全一样:pushEffect 把 effect 对象推进 fiber 的 updateQueue 链表,打上 HookInsertion 标记。更新路径也一样,updateEffectImpl 里先用 areHookInputsEqual 比较 deps,deps 没变就不打 HookHasEffect,commit 阶段会跳过这个 effect;变了才打上 HookHasEffect 并把上一次的 destroy 带进新 effect 对象。真正不同的是 commit 阶段谁来消费这个标记。
对照 mountLayoutEffect 能看出这个差异有多小:它调的是同一个 mountEffectImpl,fiber flag 同样是 UpdateEffect(enableSuspenseLayoutEffectSemantics 打开时额外带上 LayoutStaticEffect,StrictEffects 的 dev 模式再加 MountLayoutDevEffect),只有 hookFlags 一个参数不同,HookLayout 对 HookInsertion。两个 hook 在渲染阶段积累的信息完全同构,commit 阶段的消费点才是全部区别所在。
执行时机:mutation 阶段、layout 之前
消费点在 ReactFiberCommitWork.new.js 的 commitWork。函数组件分支(FunctionComponent、ForwardRef、MemoComponent、SimpleMemoComponent)的最前面新增了两行:
commitHookEffectListUnmount(HookInsertion | HookHasEffect, finishedWork, finishedWork.return);
commitHookEffectListMount(HookInsertion | HookHasEffect, finishedWork);commitWork 由 mutation 阶段的 commitMutationEffectsOnFiber 调用。所以插入位置很具体:mutation 阶段的遍历轮到某个函数组件时,先跑它所有 insertion effect 的 destroy 和 create,然后才进入这个组件自己的 layout effect destroy。对照 commit 的完整顺序:
- Before Mutation 阶段:先把上一轮遗留的 passive effects 冲刷掉,再跑类组件的 getSnapshotBeforeUpdate。
- Mutation 阶段:深度优先遍历,commitPlacement 插入和移动宿主节点,HostComponent 应用属性更新;函数组件在这里执行 insertion effect 和 layout effect 的 destroy。
- Layout 阶段:ref attach、layout effect 的 create、componentDidMount/Update。
- Passive:useEffect 的 destroy/create 由 Scheduler 异步调度,绘制之后才执行。
新加的测试(ReactHooksWithNoopRenderer-test.js 里的 useInsertionEffect 一组)把这个顺序钉死了几条断言:「fires insertion effects after snapshots on update」,insertion 在 getSnapshotBeforeUpdate 之后;「fires all insertion effects (interleaved) before firing any layout effects」,所有组件的 insertion 全部跑完才轮到任何 layout create。后一条还带出一个实现差异:layout effect 是两段式的,所有 destroy 在 mutation 阶段统一跑完,create 推迟到 layout 阶段;insertion effect 是每个组件自己的 destroy 紧跟 create,组件之间交错进行。PR 的 review 讨论里,destroy 的时机是被特意提出来过的点,最后采用的就是现在这种交错顺序。
有一个细节要说准确。insertion effect 的执行点在 mutation 阶段的遍历内部,遍历是自底向上的,子树的宿主节点变更(插入、属性更新)发生在父组件的 insertion effect 之前。所以这个 hook 拿到的窗口是「DOM 变更已基本就位、ref 尚未 attach、任何 layout effect 尚未执行」,而不是字面意义上的所有 DOM 写之前。PR 说明里也对应写了两条约束:这个 hook 里拿不到 ref(attach 在 layout 阶段),也不应该在里面调度更新。用途被收窄到「插入 style 标签这类只做写入、不做读取的 DOM 操作」。
为什么 CSS-in-JS 需要这个窗口
CSS-in-JS 库在渲染时生成规则,需要把 style 标签插进文档。现有的两个 hook 都有问题。用 useEffect 插入:它在绘制之后异步执行,浏览器已经按无样式的状态画过一帧,会闪。用 useLayoutEffect 插入:执行时机够早,但 layout effect 同时也是其他组件测量布局的地方(读 offsetHeight、getBoundingClientRect),同一次 commit 里不同组件的 layout create 顺序是按 fiber 遍历决定的,样式库没法保证自己的 layout effect 排在所有读取布局的 layout effect 之前。样式没插进去就被测量,结果是错的高度和位置,有些库为此不得不在 effect 里再强制同步重排一次。
insertion effect 给的保证恰好补上这个缺口:它在 mutation 阶段执行,此刻 DOM 结构已经更新,而 layout 阶段还没开始,任何组件都不可能已经在这次 commit 里读过布局。样式在这里插入,等 layout effect 运行时测量到的就是带样式的最终结果。拿一个具体场景过一遍:一个带样式的弹窗组件挂载,库在 insertion effect 里把生成的规则插进 document.head,随后弹窗自己的 layout effect 读内容高度做定位,读到的是样式生效后的值。同一次 commit 里如果有别的组件也插样式、也测量,交错的执行顺序保证每个组件的样式都先于任何一次测量就位。这也解释了为什么它不需要 ref 访问和更新调度:插样式只需要知道规则内容,这两样在渲染阶段就有了。
服务端一侧的处理也一并在这个 commit 里落地。旧的 renderToString(ReactPartialRendererHooks.js)在 dev 下对它打 console.error,明确说 useInsertionEffect 在服务端不执行,效果编不进 SSR 的输出格式,强行用会导致注水前后 UI 不一致;Fizz 的 hooks dispatcher(ReactFizzHooks.js)里它是 noop;Flight 的 dispatcher 里它和其他状态类 hook 一样直接 unsupportedHook 抛错。也就是说 SSR 场景下样式要靠别的途径带下去(比如服务端收集样式表),这个 hook 只管客户端。
Flight:Server Components 的协议雏形
再说另一个实验特性。Server Components 团队在去年 12 月公开过,提案的名字叫 Zero-Bundle-Size Server Components:组件只在服务端执行,产物是序列化后的 UI 描述而不是组件代码,客户端不用下载这部分 JavaScript。当时的演示是一个能跑的原型,代码随后陆续进 master。大半年过去,现在仓库里相关的有三个包:react-server(渲染器无关的核心,ReactFlightServer.js)、react-client(ReactFlightClient.js)、react-server-dom-webpack(DOM 加 webpack 的绑定)。最后一个的 package.json 版本号 0.1.0,description 写得很明白:给元框架集成用的,不打算让应用直接引用。
先看协议本身,这是目前完成度最高的部分。ReactFlightServerConfigStream.js 文件头部有一段 grammar 注释,定义了行式协议:每行一个 tag 字母、一个十六进制行 id、一个冒号,后面跟数据。注释里列了六种行类型,J(JSON model)、M(模块元数据)、H(HTML)、B(二进制 blob)、U(URL)、E(错误),但代码里实现的只有 J、M、S(symbol)、E 四种,H、B、U 只存在于注释里。
数据部分还有两个细节值得记一下。字符串如果以 $ 或 @ 开头,序列化时要在前面再加一个 $ 转义(escapeStringValue),因为这两个前缀被协议占用:$ 后接十六进制是行引用,@ 后接十六进制是模块引用。客户端的 parseModelString 按首字符分流:$$ 或 $@ 开头说明是转义过的普通字符串,剥掉首字符返回;$id 去查对应的 chunk;@id 则包一层 lazy wrapper,模块没加载完时只挂起这个组件。
服务端的渲染入口和 Fizz 类似:createRequest 拿一个 model(React 元素树)和 destination,startWork 开始处理,startFlowing 开始往流里写。核心的序列化函数是 resolveModelToJSON,它作为 JSON.stringify 的 replacer(request.toJSON)挂上去,遍历 model 时按值的类型分别处理:
- 服务器组件(普通函数)在服务端直接执行
type(props),返回的 model 继续递归序列化。函数组件在这里没有 Fiber、没有实例,就是调用一次。 - 宿主元素序列化成
[$, type, key, props]数组,$是 REACT_ELEMENT_TYPE 的转义写法。 - 客户端组件以 module reference 的形式出现(一个带
Symbol.for('react.module.reference')标记的对象,记录 filepath 和 name)。第一次遇到时发一行 M 行,内容是resolveModuleMetaData从 bundlerConfig 里查出的{id, chunks, name};元素的 type 位置写成@id的惰性引用。客户端拿到这个引用后把它还原成 lazy 组件,代码没加载完时只挂起这个组件本身,父级照常渲染。 - 渲染中挂起(throw thenable)时,给这个子树新开一个 segment,记下 id,thenable 落地后 ping 回来重试;原位置先发一个
$id引用。这和 Fizz 的分段思路一致:能先发的先发,挂起的部分后面按段补齐。 - 渲染出错时发 E 行,客户端渲染到这一行时抛出。
这些约束不用翻文档,代码里逐条都能看到。传进客户端组件的 props 必须是可序列化的 plain object,dev 下会逐个检查:带方法的类实例、symbol 属性、自定义 toJSON 都会收到 console.error。函数一律传不过去,事件处理函数(on 开头的 key)会单独报一条 invariant,提示你「需要交互就把这部分改成客户端组件」。ref 既不能用在服务器组件上,也不能传给客户端组件。服务器组件里没有状态:ReactFlightServer.js 的 dispatcher 只放行 useMemo(立即执行)、useCallback(原样返回)和 getCacheForType(每个请求一份的 cache,挂起的数据源靠它在重试时拿到同一份资源),useState、useEffect、useContext 等其余全部指向 unsupportedHook,抛「This Hook is not supported in Server Components.」。
客户端一侧,react-server-dom-webpack 的 reader 端提供 createFromReadableStream、createFromFetch、createFromXHR 三个入口,把流按行解析。ReactFlightClient.js 里每个行 id 对应一个 Chunk 对象,有 PENDING、RESOLVED_MODEL、RESOLVED_MODULE、INITIALIZED、ERRORED 五种状态,Chunk 本身实现了 then 方法。readRoot 读 0 号 chunk,渲染中遇到 $id 或 @id 引用时去查对应的 chunk:已到达就还原出值,没到就把这个 chunk 当 thenable 抛出去,由外层的 Suspense 接住,和客户端组件代码分割的 lazy 是同一套挂起语义。行可以乱序到达,引用一个还没到的行时先建一个 PENDING chunk 占位,行到了再 wake 监听者。resolveModelChunk、resolveModuleChunk 把 J 行、M 行还原成 model 和模块,最终拼回一棵可以交给 ReactDOM 渲染的元素树。服务端写出的 Node 入口是 pipeToNodeWritable(ReactFlightDOMServerNode.js),和 Fizz 当时的 API 形态一致。
和第 12 篇写的 Fizz 对照着看,两者都是流式,但流的内容不同。Fizz 流的是 HTML 字符串,客户端注水把它变成活的界面;Flight 流的是序列化的 model,客户端把它解析回元素树再交给 ReactDOM 渲染,这一层当时不涉及 hydration。挂起和分段补齐的思路是共用的,Fizz 的 Segment/Boundary 和 Flight 的 segment 在结构上能看出同一个来源。
完成度:协议能跑,工具链是 TODO
以上说的协议层是有单测覆盖、能跑通的。但把一个 .client.js 文件变成 module reference、把 module metadata 收集成 bundlerConfig 这一层,也就是真正接进 webpack 构建的部分,现在基本是空的,证据都在代码里:
ReactFlightWebpackPlugin.js的构造函数里,isServer: true直接throw new Error('TODO: Implement the server compiler.')。客户端侧有雏形:clientReferences 配置项按 directory、recursive、include 三个字段描述去哪找客户端组件,默认值就是把项目里所有*.client.(js|ts|jsx|tsx)文件当 client reference,插件挂在 compiler 的 emit 钩子上产出 manifest。服务端编译完全没做。- 文件约定已经定了:
.server.js只能被.server.js引用,ReactFlightWebpackNodeRegister.js里用 require hook 强制这一点,防止服务端代码被打进客户端 bundle;.client.js在服务端被替换成 module reference 占位。但 node loader(ReactFlightWebpackNodeLoader.js)里挂着 TODO。 ReactFlightDOM-test.js里的 webpackModules、webpackMap 和__webpack_require__全是测试手工构造的。测试能跑,是因为它绕过了真实的 loader 和 plugin。emitErrorChunk里有条 TODO:生产环境不应该把错误信息原样发给客户端,应该换成错误码。
我的判断是,现在的状态是「协议和运行时核心能跑通测试,接入路径不存在」。即使装上当前的 alpha,普通应用也没有可用的方式把 Server Components 跑起来:plugin 会 throw,loader 是半成品,唯一行得通的是像测试那样手工构造 module reference。package.json 里「给元框架集成」的定位和这是自洽的,这层东西本来就是给框架作者准备的,但框架作者现在能拿到的也只有协议层。
小结
这篇的两个主题放在一起,是因为它们恰好展示了实验特性的两种形态。useInsertionEffect 是机制完整、场景收敛的实验:实现只有几十行,执行位置钉死在 mutation 阶段,目标用户是 CSS-in-JS 库作者,unstable 前缀提醒你不要用在业务代码里。Flight 是方向明确、完成度有限的实验:协议设计能看出完整的想法(行式流、module reference、segment 挂起重试、每请求 cache),但从协议到可用之间还隔着整个构建工具链。两者共同的地方是边界都写在明处,一个用命名,一个用 TODO。
另外注意到 useMutableSource 那边最近也有替代方案的动静,前后几周的事,值得单独一篇,下次看。

