React 18 追踪:createRoot 转正,ReactDOM.render 进入废弃警告

3 分钟阅读
·

前四篇都在 reconciler 内部转:Fiber 结构、Lane 模型、调度决策、工作循环。这篇回到应用代码每天要敲的那行入口。6 月下半月 master 上连着三个 commit 把入口 API 的旧格局拆掉了:6 月 9 日 aecb3b6d11 给 ReactDOM.render 和 ReactDOM.hydrate 打上废弃警告,6 月 15 日 7ec4c55971 把水合入口从 createRoot 的选项里拆出来定名 hydrateRoot,6 月 17 日 568dc3532e 把 unstable_createRoot 这个别名从内部构建里删掉。三步走完,createRoot 就算转正了。本篇参考代码是 master 分支 ed6c091f,入口侧的文件是 packages/react-dom/src/client/ReactDOMRoot.jsReactDOMLegacy.js,reconciler 一侧是 packages/react-reconciler/src/ReactRootTags.jsReactFiberReconciler.new.js

系列目录

日期 标题
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 进入废弃警告(本篇)

三个 commit 各自做了什么

先看这半个月的时间线,三步的分工很清楚。

aecb3b6d11「Deprecate ReactDOM.render and ReactDOM.hydrate (#21652)」动的只是 ReactDOMLegacy.js,在 render 和 hydrate 两个函数的 __DEV__ 分支里各加了一段 console.error。commit message 里交代了两件事:警告里带的 reactjs.org/link/switch-to-createroot 短链会跳转到 working group 的说明帖;仓库里大量测试还在用 ReactDOM.render,只能先把这些警告加进内部测试的警告过滤器,再逐步迁移。也就是说连 React 自己的测试套件都没迁完,这个警告会伴随 alpha 期相当长一段时间。

7ec4c55971「createRoot(…, {hydrate:true}) -> hydrateRoot (…) (#21687)」把 ReactDOMRoot.js 拆成 createRoot 和 hydrateRoot 两个函数。之前水合走 createRoot(container, {hydrate: true}),现在有了独立入口 hydrateRoot(container, element)。commit message 解释了为什么不顺手抽公共逻辑:两条路径的选项和容器校验都有微妙差异,先各写一份方便迭代,之后再说。同一个 commit 顺手把 hydrate 的废弃警告文案从「Use createRoot」改成了「Use hydrateRoot」。

568dc3532e「Remove unstable_createRoot from internal builds (#21698)」最小,删的是 index.js、index.classic.fb.js、index.modern.fb.js 三个入口文件里同一行 createRoot as unstable_createRoot 导出别名,外加几处 fixture 的调用点。之前 index.js 里两个名字都对外,现在只留 createRoot。message 很短:「These callsites were already removed as far as I can tell.」别名存在期间的调用点已经清完,可以安全删了。

三步合起来的信号是:入口 API 的命名和分工已经定稿,剩下的工作是让生态迁过去。

补两个旁证。7ec4c55971 的改动列表里有 index.stable.js,hydrateRoot 和 createRoot 一起进了 stable 频道的导出配置,新入口不再只属于 experimental 构建。另一个是 packages/shared/ReactVersion.js,快照时还写着 ‘17.0.3’,文件头挂着一串 TODO,说这个数字是给 devtools 区分 work tag 用的占位,等下一次发布再更新,发布脚本实际用的是另一套版本号机制。所以 alpha 包的版本号以 48a11a3efc 改的发布配置为准,源码里这个字符串暂时不用当真。

RootTag 只剩两个

RootTag 原本的计划是三种对应三种模式。这次核实 packages/react-reconciler/src/ReactRootTags.js,发现文件已经收编成两行:

export const LegacyRoot = 0;
export const ConcurrentRoot = 1;

BlockingRoot 没了。查这个文件的 git log,过程还挺曲折:2 月 28 日 553440bd15「Remove blocking mode and blocking root」第一次删,3 月 2 日 ee43263572 又 revert 回来,3 月 10 日 860f673a7a「Remove Blocking Mode (again)」再删一次,这次站住了。Blocking Mode 的设想是给「还没准备好全面并发、但想要部分并发能力」的应用一个中间档,删之前 RootTag 类型是 0 | 1 | 2。中间档被放弃之后,选择就收敛成非黑即白:要么 LegacyRoot,行为和 17 一致;要么 ConcurrentRoot,整套并发机制全开。

tag 落到 fiber 上的地方在 ReactFiber.new.jscreateHostRootFiber。ConcurrentRoot 的 HostRoot fiber 带 ConcurrentMode,再按选项叠加 StrictLegacyMode 和 StrictEffectsMode;LegacyRoot 的 mode 是 NoMode,什么标志都没有。ReactFiberRoot.new.js 的 FiberRootNode 构造里还有一段 dev 专用的 _debugRootType,ConcurrentRoot 记成 ‘createRoot()‘,LegacyRoot 记成 ‘createLegacyRoot()‘,给 devtools 显示用。

createRoot 的 options 里有两个 unstable 前缀的字段直接通到这里。unstable_strictMode: true 给整棵树叠 StrictLegacyMode,enableStrictEffects 打开时还会再叠 StrictEffectsMode。unstable_concurrentUpdatesByDefault 控制要不要给根 fiber 叠 ConcurrentUpdatesByDefaultMode,这个 mode 影响两处:getNextLanes 里默认更新要不要和连续输入更新合批,shouldTimeSlice 里默认更新要不要时间切片。但它外面还套着一层 allowConcurrentByDefault 的 feature flag,当时是 false,所以这个选项传了也不生效。选项已经留好位置,flag 还没打开,典型的迁移期状态。

createRoot 函数本体

ReactDOMRoot.js 里 createRoot 的流程按顺序是:先用 isValidContainerLegacy 校验容器,调 warnIfReactDOMContainerInDEV 做两个 dev 检查(直接拿 document.body 当容器会收到劝阻,容器已经被某个 root 占用会报错),然后读 options,调 reconciler 的 createContainer 建 FiberRoot,markContainerAsRoot 在容器上打标记,listenToAllSupportedEvents 把所有支持的事件委托挂到容器上,最后返回一个 ReactDOMRoot 实例。这个实例就是 RootType,对外只有 render、unmount 两个方法和 _internalRoot 字段,比 legacy 路径暴露出来的东西少得多。

有一个容易忽略的差异藏在 legacy 一侧。ReactDOMLegacy.js 的 legacyCreateRootFromDOMContainer 在非水合的首次挂载前有一段 while 循环,把容器里已有的子节点逐个 removeChild 清掉。ReactDOM.render 挂载到一个有内容的 div 上,原有内容被静默替换,行为来自这段循环。createRoot 路径没有对应的清理逻辑,容器该是什么就是什么。从「替换内容」到「只管自己的 root」,入口的职责边界收窄了。

事件委托这块两种入口已经统一,legacyCreateRootFromDOMContainer 同样调 listenToAllSupportedEvents,事件挂在 root 容器上。两种 root 真正的分歧点就是 createContainer 传下去的那个 tag,以及 tag 决定的 mode,其余基础设施共用。

警告的实际语义:行为等同 React 17

aecb3b6d11 加的警告文案里有一句关键的话:「Until you switch to the new API, your app will behave as if it’s running React 17.」这句话在源码里有精确对应。

入口在哪分叉看 requestUpdateLaneReactFiberWorkLoop.new.js)的第一个分支:

const mode = fiber.mode;
if ((mode & ConcurrentMode) === NoMode) {
  return (SyncLane: Lane);
}

LegacyRoot 的 HostRoot mode 是 NoMode,整棵树继承下来都不含 ConcurrentMode,所以树上任何更新走到这里直接拿 SyncLane 返回。第 2 篇讲的 31 位车道里,transition、retry、idle 那二十几条车道对 legacy 应用来说全用不上;第 3 篇讲的 getNextLanes 调度决策也退化成「SyncLane 到了就同步做」。警告里「behave as if it’s running React 17」的源码含义就是这一句:入口决定 mode,mode 决定 lane 分配策略,lane 分配策略决定整套并发机制转不转。

反过来说,同一个包里的 createRoot 路径,mode 带 ConcurrentMode,事件外的更新走 getCurrentEventPriority() 拿 DefaultLane,调度、合批、挂起恢复这些机制才真正参与工作。两套行为装在同一份代码里,靠入口分流。

lane 分配只是 mode 影响的第一站。mode 沿树向下继承,reconciler 各阶段到处都在读它:beginWork 的 bailout 条件、Suspense 挂起后的处理方式、effect 的调度时机,分支条件里都有 mode 的影子。StrictMode 在这期间也改了组织方式,原来一个标志拆成 StrictLegacyMode 和 StrictEffectsMode 两个,后者控制 effect 的额外卸载重挂检查,挂在 enableStrictEffects 后面。入口给根 fiber 配好一组 mode 位,整棵树的行为基调就此定下。

createRoot 之后行为差在哪

换了入口不只是控制台少条警告,几个具体行为都变了。下面以当时的代码为准逐条说。

两种入口的路径对比

首挂载不再同步完成。 legacy 路径的 legacyRenderSubtreeIntoContainerReactDOMLegacy.js)在首次挂载时把 updateContainer 包在 flushSyncWithoutWarningIfAlreadyRendering 里,render 返回时 DOM 已经上屏,这也是渲染完成回调能成立的前提。createRoot 的 ReactDOMRoot.prototype.render 直接调 updateContainer,没有任何 flush 包装。首挂载的更新在 requestUpdateLane 里走完整个分支链:mode 带 ConcurrentMode 跳过第一个分支,不在渲染中跳过 render 阶段分支,没有 transition 上下文,getCurrentUpdatePriority 也没值,最后落到 getCurrentEventPriority() 拿 DefaultLane。DefaultLane 不等于 SyncLane,ensureRootIsScheduled 对它走 Scheduler 分支:scheduleCallback 按 NormalSchedulerPriority 安排 performConcurrentWorkOnRoot,进去以后 shouldTimeSlice 对 DefaultLane 返回 false,实际跑的是 renderRootSync。渲染本身是同步做的,但被推迟到 Scheduler 的任务里,root.render(<App />) 返回后立刻读 container.innerHTML,读到的可能是空。依赖「render 调用完 DOM 就在」的代码会踩到这个差异。

render 的第二个 callback 参数没了。 ReactDOMRoot.prototype.render 在 dev 下检测 arguments[1],是函数就报错:「render(…): does not support the second callback argument」,建议改用 useEffect。unmount 同理,root.unmount() 也不收回调。既然首挂载是异步的,回调语义本身就不成立了,参数干脆删掉。

批处理的范围变了。 legacy root 里只有事件处理器内的更新被 batchedUpdates 合批,setTimeout、Promise 回调里的每个 setState 都各自同步 flush 一次。concurrent root 里这些场景拿到的都是 DefaultLane,更新先入队,flush 推迟到 Scheduler 安排的回调,同一轮里的多个 setState 自然合并成一次渲染。packages/react-dom/src/events/ReactDOMUpdateBatching.js 文件头有一段注释把方向写明了:batchedUpdates 这个 API 终将移除,「when everything is batched by default」,之后要的是一个反向的、用来退出批处理做同步工作的 API。要说明的是,当时 enableSyncDefaultUpdates 还是 true,默认更新虽然合批但不做时间切片(shouldTimeSlice 把 DefaultLane 这组排除在外),时间切片目前只留给 transition 这类并发 lane;allowConcurrentByDefault 也还是 false。批处理先行,全面并发调度还没放开。

有一处行为特意没变。concurrent root 下点击、键盘这类离散事件仍按第 2 篇讲的 ReactEventPriorities 映射拿 SyncLane,ensureRootIsScheduled 把它推进内部同步队列,由事件回调之后的微任务 flush,浏览器绘制前完成提交。交互的即时反馈保持原样,推迟和合并只发生在默认优先级的更新上。换入口之后如果发现某个点击的响应变慢,那多半是代码里绕开了事件系统直接触发更新,拿了 DefaultLane。

unmountComponentAtNode 换成 root.unmount()。 root 对象由 createRoot 返回时挂在应用手里,卸载走自己的方法。ReactDOMLegacy.js 里 unmountComponentAtNode 检测到容器是 createRoot 挂的(isContainerMarkedAsRoot 为真且没有 _reactRootContainer 字段),会报「Did you mean to call root.unmount()?」。

同一个容器不能两套 API 混用。 两套入口各留各的标记:legacy 写在 container._reactRootContainer,createRoot 走 markContainerAsRoot。对已经被 ReactDOM.render 用过的容器调 createRoot,或者反过来,ReactDOMRoot.js 的 warnIfReactDOMContainerInDEV 都会报错,ReactDOMLegacy 一侧也有对应的反向检查。对同一个容器重复调 createRoot 同样会拦,提示改用已有 root 的 root.render()。迁移必须整棵树下换掉,不能半新半旧。

hydrateRoot 单独成函数

水合入口的变化值得单独看,因为它的签名设计和 createRoot 不一样。当时 hydrateRoot(container, initialChildren, options) 接收三个参数,函数尾部直接调 updateContainer(initialChildren, root, null, null) 完成首次渲染,返回的 root 对象用于后续更新。也就是说 hydrateRoot 一个调用把「建 root」和「首渲染」合在一起,而 createRoot 分两步。原因不难想:水合场景下服务端已经吐好了 HTML,初始 children 在建 root 的那一刻必然是已知的,没有「先建空 root 再决定渲染什么」的用法。旧的 ReactDOM.hydrate 第三个参数是完成回调,legacyRenderSubtreeIntoContainer 会把它包装成拿根组件实例再调用;hydrateRoot 不再收这个回调,和 root.render 一样,完成时机要用 effect 表达。

createRoot 的 options 里还留着 hydrate: truehydrationOptions 两个字段,代码里用 // TODO: Delete these options 和成对的 // END TODO 注释圈了起来,是 7ec4c55971 拆分后没清干净的旧路径。容器校验也跟着拆成两份:isValidContainer 只认元素、文档、文档片段三种节点,hydrateRoot 用它;isValidContainerLegacy 额外放行一种特定内容的注释节点(nodeValue 是 ’ react-mount-point-unstable ’ 的那种),留给 createRoot 和 legacy 入口。commit message 里提到水合在注释节点上本来就跑不通,所以新入口直接收紧。

hydrateRoot 的 options 类型 HydrateRootOptions 里还有一组回调值得记:onHydrated 和 onDeleted,参数类型是 Comment,对应水合完成的 Suspense 边界的注释锚点。水合和 Suspense 的配合这时已经在 API 层面露了头,应用可以拿到每个边界水合完成的时机。这两个回调经 createContainer 存到 root.hydrationCallbacks 上,挂在 enableSuspenseCallback 这个 feature flag 后面。options 里另有 hydratedSources 数组,逐个调 registerMutableSourceForHydration 注册,是 useMutableSource 在水合场景的配套。这批字段当时都带着实验性质,但能看出入口 API 在为流式服务端渲染留接口。

升级 alpha 要改什么

把上面这些落成一份清单。装包用 npm install react@alpha react-dom@alpha,6 月 8 日起 @next 频道的版本号已经是 18.0.0-alpha-

  1. ReactDOM.render(<App />, el) 换成 createRoot(el).render(<App />)。createRoot 返回的 root 对象要留好,后续更新和卸载都用它。
  2. render 的完成回调删掉,副作用挪进组件的 useEffect。
  3. ReactDOM.hydrate(<App />, el) 换成 hydrateRoot(el, <App />),注意第二个参数是初始 children。
  4. unmountComponentAtNode(el) 换成 root.unmount()
  5. 如果之前实验性的代码里 import 的是 unstable_createRoot,改成 createRoot,别名已经没了。
  6. 换完入口后过一遍依赖「render 返回即上屏」和「事件外 setState 立即生效」的代码,这两处假设在 concurrent root 下都不成立。

不改也能跑,ReactDOM.render 还在,只是行为锁定在 17 那一套,并发特性一个都用不上,这和警告文案说的一致。另外这些警告都包在 __DEV__ 分支里,生产构建不会打,判断迁移进度得在开发环境看控制台。

下一篇

写完这篇去看当天的 master,7 月 1 日刚好有两个 flushSync 相关的重构合入:ed6c091f「Replace unbatchedUpdates with flushSync」和 32eefcb3「Replace flushDiscreteUpdates with flushSync」,本篇的快照 ed6c091f 就是其中之一。前面提到的 ReactDOMUpdateBatching 注释里那个「反向的同步 API」就是 flushSync,它开始收编 reconciler 里散落的旧入口,unbatchedUpdates 和 flushDiscreteUpdates 这两个名字要退出去了。下一篇拆这两个 commit,看 flushSync 怎么统一同步刷新的路径,以及它和 SyncLane 是什么关系。


1046 字 · 49 段落
xi ming

Written by xi mingFollow onGitHub