一、入口 API 的变化不是语法变化,而是模型变化
在 React 18 之前,应用入口通常这样写:
import ReactDOM from 'react-dom'
ReactDOM.render(<App />, document.getElementById('root'))
从 React 18 开始,推荐写法变成:
import { createRoot } from 'react-dom/client'
const root = createRoot(document.getElementById('root'))
root.render(<App />)
表面上看,只是把一次调用拆成了两步。
旧入口是:
ReactDOM.render(element, container)
新入口是:
const root = createRoot(container)
root.render(element)
但是从源码设计上看,这不是简单的 API 调整,而是 React 把"Root"这个运行时概念显式暴露了出来。
旧 API 的表达重点是:
把 element 渲染到 container
新 API 的表达重点是:
先为 container 创建一个 Root,再向这个 Root 提交更新
这两个模型的差异很大。
因为 React 18 之后,React 不再把渲染理解成一次孤立的同步过程,而是把它理解成:
某个 Root 上发生了一次带优先级的更新,React 需要根据调度策略决定什么时候处理它
所以 createRoot 的出现,本质上是为了把 React 内部早就存在的 Root 模型暴露到用户 API 层。
二、ReactDOM.render 的语义为什么不够准确
旧 API:
ReactDOM.render(<App />, container)
这个 API 容易给人一个直觉:
调用 render,React 就开始把 <App /> 渲染成 DOM。
这在早期同步渲染模型下问题不明显,因为 React 的更新基本就是同步递归执行。
但是到了 React 18 的并发模型,这个语义就不够准确了。
因为一次更新不一定马上完成。
它可能会:
被赋予不同优先级
被批量合并
被中断
被恢复
被更高优先级任务插队
因为 Suspense 挂起
在之后某个时间继续执行
这些能力都要求 React 必须长期维护一个 root 级别的运行时对象。
这个 root 对象不能只是一个 DOM container。
它需要保存很多和更新系统相关的信息。
比如:
当前已经提交的 Fiber 树
正在构建中的 Fiber 树
当前 root 上待处理的 lanes
当前 root 是否已经安排调度任务
当前 root 的调度回调是什么
render 完成后等待 commit 的 finishedWork
Suspense 挂起相关状态
hydration 相关状态
错误恢复状态
这些信息都属于"Root 级别状态"。
所以旧 API 最大的问题不是功能不足,而是语义上把 Root 隐藏了。
它看起来像:
ReactDOM.render 是一次性动作
但实际内部需要的是:
Root 是一个长期存在的运行时容器
React 18 之后把 API 改成 createRoot().render(),就是让外部使用方式更接近内部模型。
三、createRoot 显式表达了一个核心事实:Root 会长期存在
新写法:
const root = createRoot(container)
root.render(<App />)
这里的 root 不是一次性的临时对象。
它表示这个 container 对应的 React 根。
后续所有对这个根的操作,都应该通过这个 root 对象完成。
例如:
root.render(<App />)
root.render(<OtherApp />)
root.unmount()
也就是说,root 是一个长期对象。
它背后关联着 React 内部的根容器。
你可以把这个模型理解成:
DOM container 是宿主环境的根
React Root 是 React 更新系统的根
它们不是同一个东西。
DOM container 只是告诉 React:
最终 DOM 要挂载到哪里
React Root 负责管理:
这个应用的更新、调度、Fiber 树和提交结果
所以 createRoot(container) 的真正意义不是"准备渲染",而是:
把一个 DOM 容器注册成 React 可以管理的 Root
四、旧入口把两个阶段合在了一起
旧写法:
ReactDOM.render(<App />, container)
把两个概念混在了一起:
创建 Root
提交更新
新写法把它们拆开:
const root = createRoot(container)
root.render(<App />)
第一步:
createRoot(container)
负责创建 Root。
第二步:
root.render(<App />)
负责向 Root 提交一次更新。
这个拆分非常关键。
因为从 React 内部看,这两件事本来就不是一件事。
创建 Root 时,React 要初始化 root 级别的数据结构。
提交更新时,React 要创建 update、分配 lane、进入调度流程。
这两个阶段职责完全不同。
如果 API 继续使用 ReactDOM.render(element, container),外部看起来就像"传入 element 就马上渲染"。
但真实内部并不是这样。
真实内部更接近:
container 先被创建成 React Root
element 再作为一次更新提交到这个 Root 上
五、createRoot 背后真正创建的是 React 的 Root 容器
用户代码里:
const root = createRoot(container)
拿到的是一个用户层对象。
在源码里,它通常对应一个 ReactDOMRoot 实例。
简化结构可以理解成:
function ReactDOMRoot(internalRoot) {
this._internalRoot = internalRoot
}
也就是说,用户拿到的 root 只是一个外壳。
真正保存 React 内部状态的是:
root._internalRoot
这个内部 root 就是 React 的 FiberRootNode。
可以这样分层:
ReactDOMRoot:暴露给用户的 API 对象
FiberRootNode:React 内部管理更新的根容器
DOM container:真实宿主环境容器
这三个东西要分清楚。
container 是 DOM 节点。
ReactDOMRoot 是用户操作入口。
FiberRootNode 是 React 更新系统真正的根。
六、为什么 Root 必须独立于 DOM container
有一个容易误解的点:
既然 React 最终是把内容渲染到 container,为什么不直接把所有状态挂到 container 上?
原因是 DOM container 只属于宿主环境,它不是 React 的运行时对象。
React 需要保存的很多东西和 DOM 本身无关。
例如:
pendingLanes
suspendedLanes
pingedLanes
expiredLanes
callbackNode
callbackPriority
finishedWork
current
这些都是 React 调度和 Fiber 架构的内部状态。
它们不应该直接挂在 DOM 节点上。
更重要的是,React 不只支持 DOM。
React reconciler 的设计本身是跨宿主环境的。
React DOM 有 container。
React Native 有自己的宿主容器。
其他 renderer 也可以有不同的宿主环境。
所以 React 需要一个抽象的 Root 对象来承载框架内部状态,而不是把 Root 和某个具体 DOM 节点绑死。
这就是 FiberRootNode 的意义。
七、createRoot 对应的是 Concurrent Root
React 18 的 createRoot 创建的是 Concurrent Root。
旧的 ReactDOM.render 对应的是 Legacy Root。
这不是命名差异,而是运行模式差异。
Legacy Root 更接近旧的同步渲染模型。
Concurrent Root 支持 React 18 的并发能力。
例如:
自动批处理
startTransition
Suspense 并发行为
可中断渲染
更细粒度的优先级调度
所以 React 18 里推荐使用:
createRoot(container)
不是因为新 API 更好看,而是因为它让应用进入新的 Root 模式。
可以理解为:
ReactDOM.render 创建的是旧模式 Root
createRoot 创建的是并发模式 Root
Root 的 tag 不同,后续调度和 render 行为就会不同。
也就是说,入口 API 决定了这个应用根节点使用哪套运行模式。
这也是为什么 React 18 升级时,很多新能力要求你从 ReactDOM.render 迁移到 createRoot。
八、为什么 root.render 不是 createRoot 的一部分
有人可能会问:
既然 createRoot 之后基本都会 render,为什么不直接:
createRoot(container, <App />)
React 没这样设计,是因为创建 Root 和提交更新是两个不同阶段。
Root 可以先创建,但不一定马上渲染。
例如:
const root = createRoot(container)
此时 React 只初始化内部根结构。
什么时候提交 UI,由你调用:
root.render(<App />)
而且同一个 root 可以多次 render:
root.render(<App />)
root.render(<App theme="dark" />)
root.render(<OtherApp />)
这些都是对同一个 Root 提交不同更新。
如果把创建 Root 和首次 render 强绑在一起,就无法清晰表达:
Root 是长期存在的
render 是发生在 Root 上的一次更新
React 现在的 API 正是在强调这个边界。
九、root.render 的语义也不是"执行渲染"
虽然名字叫 render,但从内部模型看,root.render(element) 更准确的语义是:
向 Root 提交一个 element 更新
它不是立即执行组件函数。
不是立即创建 Fiber 子树。
不是立即操作 DOM。
它只是把你传入的 element 作为更新内容交给 React。
也就是说:
root.render(<App />)
更接近:
root.update({
element: <App />
})
当然,React API 不会这么命名,因为从用户视角看确实是在"渲染应用"。
但是写源码专栏时,要把语义分清楚。
用户语义:
render App
源码语义:
submit root update
这两个不是一个层级。
十、从 API 变化看 React 的架构变化
从:
ReactDOM.render(element, container)
到:
const root = createRoot(container)
root.render(element)
React 实际上传达了几个架构变化。
第一,Root 是显式对象。
这说明 React 需要长期维护 root 级状态。
第二,render 是 Root 上的一次更新。
这说明 React 把首次渲染和后续更新统一进 update 模型。
第三,Root 决定运行模式。
createRoot 创建 Concurrent Root,旧 API 创建 Legacy Root。
第四,调度以 Root 为单位。
React 不是孤立处理某个组件,而是从 root 级别选择 lanes 并安排任务。
第五,Fiber 树挂在 Root 下面。
Root 持有当前 Fiber 树,render 阶段构建 workInProgress 树,commit 后切换 current。