写可访问性标记时经常要生成 DOM id:<label for> 和 <input id> 配对,aria-labelledby 指向另一个元素的 id。没有框架支持时,一般手写一个计数器:
let count = 0;
function useUniqueId() {
const [id] = useState(() => 'field-' + count++);
return id;
}这套计数器在纯客户端渲染中可用,但 SSR 会产生不一致。例如服务器渲染时计数器从 0 到 5,HTML 包含 field-0 至 field-5;客户端 hydration 也从 0 开始,但此前客户端渲染的头部组件已使用两个 id,表单第一个字段会重算为 field-2,与 HTML 的 field-0 不同。开发环境会报告 hydration 属性不匹配。更直接的后果是 hydration 期间 label 的 for 与 input 的 id 可能取不同值,点击 label 无法聚焦输入框,屏幕阅读器无法读出字段名,直至重渲染改写属性。列表中的首次错位还会影响后续项目。
React 为这个场景准备的 Hook 是 useOpaqueIdentifier,目前以 unstable_useOpaqueIdentifier 导出(packages/react/src/React.js)。用法上没有参数,返回一个可以直出给 DOM 属性的值:
import {unstable_useOpaqueIdentifier as useOpaqueIdentifier} from 'react';
function Field() {
const id = useOpaqueIdentifier();
return (
<>
<label htmlFor={id}>邮箱</label>
<input id={id} type="email" />
</>
);
}这篇看它在 master 分支 e4e8226c 快照(8 月 12 日)下的实现:服务器和客户端各怎么发号,hydration 时两端怎么对齐,以及为什么实现里看不到「客户端按同样顺序重放服务器计数器」这种直觉做法。
系列目录
| 日期 | 标题 |
|---|---|
| 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 怎么生成(本篇) |
Hook 的提交记录
先交代来历,它不是这个月的新东西。git log 里直接相关的 commit 有四个。
3278d24218(2020-04-06)Add useOpaqueIdentifier Hook (#17322),首次加入。commit message 列了三个要解决的问题:SSR 之后 hydration 的 id 不匹配;页面一部分 SSR、另一部分 CSR 时两边的 id 对不上;条件渲染导致 id 漂移。message 正文里把新 Hook 叫 useUniqueId,落盘的代码已经定名 useOpaqueIdentifier,名字是 PR 讨论过程中改的。回头对比这个 commit 和现在的代码,设计骨架从那时起没变过:服务器发一套 id,客户端发另一套,hydration 时返回不透明对象延后物化。后面三个 commit 都在补工程细节,没有动这个骨架。
df14b5bcc1(2020-05-07),把发号计数器从模块级挪到每个 server renderer 实例上,同时给 renderToString、renderToStaticMarkup、renderToNodeStream、renderToStaticNodeStream 加上 identifierPrefix 选项。message 里写了两个理由:模块级计数器长时间运行有耗尽的担心;一个页面渲染多个子树时,需要一种手工区分前缀的手段。
6c3202b1e1(2021-03-22),Fizz 侧支持 identifierPrefix,解决同一个 HTML 响应里多次渲染的结果互相撞号。
f4d7a0f1ea(2021-04-14)Implement useOpaqueIdentifier (#21260),把服务器侧的 id 生成挪进各 renderer 自己的 format config,message 只有一句:id 的格式由输出格式决定。这条之后,DOM、Native、Noop 各自的 server 渲染器自己定义 id 长什么样。
服务器侧:每个渲染实例一个计数器
legacy 的 renderToString 走 ReactPartialRenderer,它的 hooks dispatcher 在 packages/react-dom/src/server/ReactPartialRendererHooks.js:
function useOpaqueIdentifier(): OpaqueIDType {
return (
(currentPartialRenderer.identifierPrefix || '') +
'R:' +
(currentPartialRenderer.uniqueID++).toString(36)
);
}uniqueID 是 ReactDOMServerRenderer 构造时初始化为 0 的实例字段,递增后转 36 进制。一次 renderToString 产出的 id 是 R:0、R:1 这样排下去。同一个页面里多次调用 renderToString 渲染不同子树时,靠 options 里的 identifierPrefix 把命名空间隔开。
不传前缀的后果很具体:两次 renderToString 各自从 R:0 发起号,两段 HTML 拼进同一页就有两对元素共用同一个 id。DOM 本身要求 id 唯一,getElementById 和 label 的 for 关联只会命中文档里先出现的那个,后插入的子树关联静默失效,没有任何报错。实例之间的隔离没有自动机制,全靠调用方约定不同的 identifierPrefix。
Fizz 侧(packages/react-server/src/ReactFizzHooks.js)的 useOpaqueIdentifier 只有一行,调 makeServerID(currentResponseState),定义在 packages/react-dom/src/server/ReactDOMServerFormatConfig.js:responseState 上存了 opaqueIdentifierPrefix(identifierPrefix 加 ‘R:‘)和 nextOpaqueID 计数器,每次调用拼前缀加计数。函数体里有一行 TODO 注释:「This is not deterministic since it’s created during render.」团队自己知道流式渲染下这套计数器不保证确定顺序,这一点后面还会回来。
identifierPrefix 在 Fizz 里是一套统一的命名空间方案,不只服务于这个 Hook。createResponseState 里能看到同一前缀被拼进四种标识:placeholderPrefix(P:)、segmentPrefix(S:)、boundaryPrefix(B:)、opaqueIdentifierPrefix(R:),分别给占位注释、segment、Suspense 边界和 useOpaqueIdentifier 用。也就是说同一个响应里所有需要全局唯一的字符串都从这一个前缀派生,多次渲染的响应嵌在同一页面时,传不同的 identifierPrefix 就能全部隔开。测试文件里有一个用例专门验证流式渲染器多次读取时 prefix 不撞(identifierPrefix works for multiple reads on a streaming server renderer)。
客户端侧:另一个计数器
客户端的 id 生成在 host config 里,packages/react-dom/src/client/ReactDOMHostConfig.js:
let clientId: number = 0;
export function makeClientId(): OpaqueIDType {
return 'r:' + (clientId++).toString(36);
}模块级计数器,前缀用小写 r:,和服务器的大写 R: 区分开。非 hydration 的首次渲染走 mountOpaqueIdentifier(packages/react-reconciler/src/ReactFiberHooks.new.js,old fork 里内容相同)的普通分支:makeClientId 拿一个字符串,mountState 存进 hook state,之后 updateOpaqueIdentifier 原样返回 state 里的值,id 稳定不变。
dispatcher 表上还有第三个入口 rerenderOpaqueIdentifier,处理渲染中途被更新打断、当前组件重来一遍的情形,它和 update 一样只从 state 回读,不取新号。三个入口里只有 mount 真正发号,这是 id 在组件存活期内保持不变的直接来源。
hydration 跳过比较并延迟物化
关键分支在 getIsHydrating() 为真的时候。这时 mountOpaqueIdentifier 不调 makeClientId,而是调 makeOpaqueHydratingObject 造一个对象,用 mountState 存进 hook:
{
$$typeof: REACT_OPAQUE_ID_TYPE,
toString: attemptToReadValue,
valueOf: attemptToReadValue,
}attemptToReadValue 做两件事:第一次被读时 setId(makeId()),给这个 hook 排入一个更新,把 state 升级成真正的客户端 id;然后 invariant 抛错,错误信息是「useOpaqueIdentifier 返回的对象只能透传给属性,不要直接读值」。这个对象在语义上拒绝被读取,应用代码拿不到字符串,也就没法拿它去和 HTML 里的服务器 id 比较。
attemptToReadValue 里有一个 didUpgrade 标记保证只升级一次,上方注释解释了为什么这样安全:即使读取发生在渲染进行中,更新也会被加进一个共享队列,这个队列比当前这次渲染活得久。这就是第 7 篇讲 interleaved 更新队列时说的那套机制,渲染中途收到的 setState 不会丢,会跟着下一次渲染一起处理。开发环境下还会把 isUpdatingOpaqueValueInRenderPhase 置位,让 work loop 里「渲染期间更新另一个组件」的警告不误报这次合法的升级。
配合的机制有两处。
第一处在 hydration 的属性比对。packages/react-dom/src/client/DOMPropertyOperations.js 的 getValueForAttribute(开发环境用来核对 hydration 属性是否一致)开头检查 isOpaqueHydratingObject,是就直接返回 expected,跳过比对,不报 mismatch 警告。hydration 完成后,DOM 里保留的是服务器写下的 R:0,hook state 里是那个读不得的对象,两边相安无事。
第二处在更新路径。packages/react-dom/src/client/ReactDOMComponent.js 的 diffProperties 在 nextProps 循环里先跳过引用不变的 prop,当这个不透明对象作为新值出现时(比如 id 属性从 null 变成它),不把它推进 updatePayload,而是直接调 nextProp.toString()。这一调触发了上面的 attemptToReadValue:setId 排入更新,然后抛错。work loop 按渲染错误处理,走第 2 篇讲过的 getLanesToRetrySynchronouslyOnError 路径同步重试,重试把刚排入的更新一起带上,第二遍渲染时 hook state 已经是 r:N 字符串,diff 正常走完。源码注释把意图写明了:生成一个新的客户端 id,抛错重渲染。所有从同一个 hook 拿 id 的元素在这次重渲染里一起换成新值。
两种 root 模式下物化时机不同。legacy 模式(ReactDOM.hydrate)在挂载时就挂一个 passive effect,commit 之后立即 setId(makeId()),主动把 R:0 升级成 r:0,属性同步改写。concurrent 模式不挂 effect,完全惰性:只要组件不更新、id 不再作为新值写进属性,DOM 里可以一直留着服务器的 R:0,直到第一次有更新碰到它才升级。仓库里的集成测试(ReactDOMServerIntegrationHooks-test.js)覆盖了「hydration 时没用这个 id、更新时才写进属性」的场景:concurrent 下那次更新渲染了两遍,第一遍在属性 diff 处抛错,同步重试的第二遍带着新 id 完成。同一份测试还覆盖了 flushSync 里触发物化、id 参与 Suspense 边界内的子树、以及 hydration 之后新挂载的组件复用同一个 id 等场景,结论一致:物化发生的那次提交里,所有引用点一起换成同一个客户端 id。
开发环境还有一层 makeClientIdInDEV:返回一个 toString 和 valueOf 被包装过的对象,读取时先经 warnOnOpaqueIdentifierAccessInDEV 报一次警告(按组件名去重)再给出值。渲染期间直接把 id 当字符串用,console 里就会收到提示。
还有一条兜底的收敛路径值得知道。hydration 渲染本身出错时(不限于 id,任何渲染错误都算),work loop 在 RootErrored 分支里把 root.hydrate 置回 false,清空容器,整棵树退回到纯客户端渲染。这时 mountOpaqueIdentifier 走普通分支,所有组件直接拿客户端的 r:N,HTML 里的服务器 id 随容器一起被清掉,不一致问题连同服务器输出一起消失。代价是这一次 hydration 白做了。
为什么不做一个两端共享的计数器
回头看开头的手写方案,要让它在 SSR 下不出错,思路是让客户端按同样的规则重放出和服务器一样的序列。React 自己掌握着服务器渲染器和 hydration 的完整实现,理论上最有条件做这件事,但代码里没有走这条路。按当时的实现和 commit message,至少有三个过不去的地方。
多 root。一个页面上可以同时存在多棵 React 树:微前端、嵌套的独立应用、服务器渲染的主体加上客户端渲染的岛。模块级计数器全局共享时,两棵树各渲染各的,发号互相干扰;每棵树各自用独立计数器又会撞号。df14b5bcc1 的做法是把计数器收窄到每个渲染实例,再把区分责任交给使用者传 identifierPrefix。这等于承认计数器方案给不了自动的全局唯一性,前缀是人肉协商出来的。
流式分段。Fizz 按 Suspense 边界把页面切成 segment,数据晚到的边界后渲染,各段 flush 的顺序由数据到达时间决定,和组件在树里的位置没有关系。makeServerID 上方的 TODO 注释写明 created during render, not deterministic。顺序本身不确定,客户端想按同一顺序重放计数器就没有基准。
hydration 顺序和条件渲染。要让客户端算出和服务器一样的号,客户端必须以完全相同的顺序、相同的分支执行每一次 useOpaqueIdentifier 调用。部分注水、懒加载的边界、两端任何一处条件渲染的差异,都会让序列从某个点开始整体错位。错位的 id 比完全随机的 id 更难排查,因为大部分位置看起来是对的。
该设计只要求同一个 hook 的所有引用同时变化。服务器的 R:0 仅存在于 HTML 中,客户端不读取它,hydration 也不比较它;需要物化时统一升级为客户端的 r:N,label 和 input 在同一次提交中同时更新。这样无需跨进程以相同顺序重放计数器,只依赖单次提交内的原子替换。
页面一部分 SSR、另一部分 CSR 的混合场景也被这套划分自然覆盖。客户端渲染的岛走 makeClientId 拿 r:N,服务器渲染的主体里是 R:N,两套字符串不相等,但两侧之间没有 hydration 比对发生,各自内部引用一致就够了。测试文件里专门有一个用例验证这种混合页面(IDs match when part of the DOM tree is server rendered and part is client rendered)。条件渲染导致的漂移同样被化解:id 只在 hook state 里记一份,组件挂载时取回,和它前后渲染过多少个兄弟组件无关。
代价和边界
限制如下。第一,hydration 后 DOM 属性会从 R:0 改写为 r:0;若 React 外部脚本在升级前读取 id 并建立关联,或服务器 HTML 中的锚点、内联 SVG 引用该值,关联会失效。因此该方案假定 id 仅由 React 管理的属性引用。第二,返回值不能读取:将 id 拼接为字符串、传入数据层或参与 key 计算,都会触发 invariant 或开发环境警告,适用范围仅为透传给 DOM 属性。第三,Fizz 侧仍保留非确定性的 TODO:流式渲染中,同一响应的不同 segment 只保证 id 不冲突,不保证与任何重算结果一致。
下一篇看事件系统:18 把事件委托挂到了 root 容器上,一次点击的事件优先级怎么决定后续 setState 落到哪条 Lane。
