这两周 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 阶段执行
先看导出形态。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 在结构上能看出同一个来源。
协议实现与工具链限制
以上说的协议层是有单测覆盖、能跑通的。但把一个 .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 库作者,稳定入口不导出该 API。Flight 已实现行式流、module reference、segment 挂起重试和每请求 cache 等协议与运行时逻辑,但尚缺少将协议接入应用的构建工具链。两者的实验性边界分别通过 unstable 命名和代码中的 TODO 标注。
useMutableSource 在同期也出现了替代方案,下一篇单独分析。
