ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ReactDOMRoot源码解析:从createRoot到render的完整链路

ReactDOMRoot源码解析:从createRoot到render的完整链路

做React源码分析,很多人一上来就啃scheduler调度器或者fiber架构,结果被lane、workLoop这些概念劝退。我自己的经验是,不如先从ReactDOMRoot入手——这是每个React应用真正的起点,你天天写的createRoot(...).render(<App/>),本质上就是在操作这个类的实例。搞清楚ReactDOMRoot怎么实现,后面再看update、reconcile、commit这条主链路,会顺畅非常多。

这篇源码分析我会直接对照React 18.x的react-dom源码,把createRoot入口、ReactDOMRoot构造函数、render/unmount方法、FiberRootNode和HostRoot fiber的关系一条线捋清楚。内容适合两类人看:一类是准备React面试,想搞懂createRoot返回的到底是什么、root.render背后发生了什么的;另一类是刚开始做React源码分析,需要一个低门槛切入点的。读完你能回答几个硬核问题:ReactDOMRoot和FiberRootNode到底什么关系,root.render为什么叫"入队一个更新",unmount之后为什么不能再render。

1. ReactDOMRoot在React体系中的定位

1.1 为什么会有createRoot这个API

React 18之前,大家启动应用只有一个选择:ReactDOM.render(<App/>, document.getElementById('root'))。那时候React的根节点概念是有的,但API层面没有单独暴露出来,ReactDOM.render内部会自己搞一套legacy root的逻辑。到了React 18,官方把并发渲染正式推到台前,createRoot成了标配入口,ReactDOM.render被标记为legacy,还带着你需要主动绕过的警告。

这个变化背后不只是API搬家。createRoot对应的root类型是ConcurrentRoot,而ReactDOM.render内部创建的是LegacyRoot,两者的fiber tag不一样,调度行为和优先级处理也有区别。并发特性如startTransition、可中断渲染、自动批处理,本质上都需要一个支持并发模式的root来承载。所以React 18把client API拆分到react-dom/client,并强调要用createRoot,是在源码层面做了一场结构性区分:老的同步渲染路径保留兼容,新的并发渲染路径正式转正。

我实际看源码的时候发现一个有意思的点:即使是React 18里你强行用ReactDOM.render,它内部也会创建一个ReactDOMRoot实例,只不过tag是LegacyRoot。所以ReactDOMRoot并不是只有createRoot在用,它是所有render路径共同的门面对象。理解了这一层,再看整个react-dom的入口代码,就能明白为什么源码里大量逻辑都在区分root.tag === ConcurrentRoot还是LegacyRoot。

1.2 ReactDOMRoot实际负责什么

ReactDOMRoot这个类从代码量看极其短小,React 18源码里构造函数加两个原型方法,总共不超过四十行。但它的职责非常关键,概括起来是三件事:

第一,持有根节点数据。构造函数把传入的FiberRootNode存到this._internalRoot,这里的FiberRootNode是整个fiber树挂在容器上的"根锚点",ReactDOMRoot是暴露给用户的门面对象,内部数据全靠_internalRoot间接访问。

第二,接收React元素入队。root.render(children)做的事情不是直接操作DOM,而是把传入的React元素包装成一个update,压入HostRoot fiber的更新队列,再触发一次调度,让React自己决定什么时候去渲染。

第三,提供卸载入口。root.unmount()会入队一个空更新,通知React把整棵fiber树销毁,并清理root容器上的内部标记。

一句话总结:ReactDOMRoot是用户侧操作React根节点的遥控器,真正干活的引擎在FiberRootNode和fiber架构里。很多面试者会在这两个概念上卡壳,后面第四章我再仔细拆。

2. createRoot入口:从DOM容器到ReactDOMRoot

2.1 createRoot函数逐行拆解

createRoot定义在react-dom/src/client/ReactDOMRoot.js里,源码不长,但是信息密度很高。核心流程是:校验容器、解析options、创建fiber root、给容器打标记、绑定事件、返回ReactDOMRoot实例。

我按源码顺序带你走一遍:

export function createRoot(container, options) { if (!isValidContainer(container)) { throw new Error('createRoot(...): Target container is not a DOM element.'); } warnIfReactDOMDirIncreateRoot(container); let isStrictMode = false; let concurrentUpdatesByDefault = false; let identifierPrefix = ''; let onUncaughtError = noop; let onCaughtError = noop; let onRecoverableError = noop; let transitionCallbacks = null; if (options !== null && options !== undefined) { // 逐项读取配置 if (options.unstable_strictMode === true) { isStrictMode = true; } // ... } const root = createContainer( container, ConcurrentRoot, null, isStrictMode, concurrentUpdatesByDefault, identifierPrefix, onUncaughtError, onCaughtError, onRecoverableError, transitionCallbacks, ); markContainerAsRoot(root.current, container); const rootContainerElement = container.nodeType === COMMENT_NODE ? container.parentNode : container; listenToAllSupportedEvents(rootContainerElement); return new ReactDOMRoot(root); }

可以看到,createRoot本身并没有做太多"渲染"相关的事情。它先校验容器,然后读取options参数,接着调用createContainer创建真正的root数据结构,然后两个关键动作:markContainerAsRoot给DOM容器打上React内部标记,listenToAllSupportedEvents在容器上绑定事件委托。最后return new ReactDOMRoot(root)把内部root包装成对外实例。

一个经常被忽视的细节:markContainerAsRoot和listenToAllSupportedEvents都在new ReactDOMRoot之前执行,说明DOM容器在用户拿到root之前就已经"被React接管"了。

2.2 参数校验和options里值得注意的细节

isValidContainer这段,很多人都只是扫一眼就过了,其实它透露了React对容器的兼容边界:

export function isValidContainer(node) { return !!( node && (node.nodeType === ELEMENT_NODE || node.nodeType === DOCUMENT_NODE || node.nodeType === DOCUMENT_FRAGMENT_NODE || (node.nodeType === COMMENT_NODE && node.nodeType === COMMENT_NODE && (node: any).text === ' react-mount-point-unstable ')) ); }

普通开发者只需要知道传入一个div就行,但源码里允许了四种情况:元素节点、document节点、fragment节点、特定注释节点。最后那个comment节点是为了兼容老版本ReactDOM.render曾经支持的特殊挂载方式,现在的createRoot很少会用到,但校验逻辑还是保留了。

options参数在React 18里还有个名字很劝退的unstable_strictMode,官方从React 18.3开始才正式把StrictMode在根节点上启用的方式稳定下来。如果你想在根上开严格模式,可以传<React.StrictMode>包裹组件,也可以在createRoot时传unstable_strictMode: true。源码里还会处理identifierPrefix、onUncaughtError、onCaughtError、onRecoverableError这些配置,它们会一路传给FiberRootNode,作为运行时的错误处理回调。

还有一个我翻源码时觉得挺有意思的冷知识:markContainerAsRoot在DOM节点上挂的属性key不是固定的__reactContainer,而是带一段随机字符串:

const randomKey = Math.random().toString(36).slice(2); export const internalContainerInstanceKey = '__reactContainer$' + randomKey;

每个应用加载时,这个randomKey都不一样。这样设计是为了避免多个React副本同时存在时互相污染标记。React DevTools能快速定位root节点,靠的也是这个属性。你在浏览器里选中#root,在Elements面板的属性栏能看到一个__reactContainer$xxx字段,那就是React给你的容器盖的章。

2.3 createContainer到createFiberRoot的建根流程

createContainer定义在ReactFiberReconciler.js里,它内部只是转调createFiberRoot。createFiberRoot的流程可以概括为三步:创建FiberRootNode、创建HostRoot fiber、初始化更新队列。

export function createFiberRoot( containerInfo, tag, hydrate, initialChildren, hydrationCallbacks, isStrictMode, concurrentUpdatesByDefault, identifierPrefix, onUncaughtError, onCaughtError, onRecoverableError, transitionCallbacks ) { const root = new FiberRootNode( containerInfo, tag, hydrate, identifierPrefix, onUncaughtError, onCaughtError, onRecoverableError, transitionCallbacks ); const uninitializedFiber = createHostRootFiber(tag, isStrictMode, concurrentUpdatesByDefault); root.current = uninitializedFiber; uninitializedFiber.stateNode = root; initializeUpdateQueue(uninitializedFiber); return root; }

这段代码揭示了一个关键结构:FiberRootNode和HostRoot fiber互相引用。FiberRootNode的current指向当前的HostRoot fiber,而HostRoot fiber的stateNode指回FiberRootNode。整个fiber树由此有了一个稳定的"根对"。

createHostRootFiber还会初始化memoizedState,初始值大概是这样的结构:

const initialState = { element: initialChildren, // createRoot时是null isDehydrated: hydrate, // createRoot时是false cache: null, transitions: null, };

initializeUpdateQueue则给HostRoot fiber挂上更新队列,初始baseState就是上面那个initialState。这为后面root.render入队第一个更新做好了准备。

3. ReactDOMRoot类内部:render与unmount的实现

3.1 构造函数:内部root的存放与null判空

React 18里ReactDOMRoot的定义是这样的:

function ReactDOMRoot(internalRoot) { this._internalRoot = internalRoot; }

构造函数极简,就是把createRoot创建的FiberRootNode存到实例上,赋值给this._internalRoot。很多人会困惑为什么叫_internalRoot:因为对外这是一个"私有字段",虽然JS没有真正私有,但下划线前缀是告诉调用者:别动它,这是React内部引擎要用的。你在控制台打印root对象,能看到_internalRoot下面挂着一大堆字段,那就是FiberRootNode的完整数据。

为什么不用普通对象而用构造函数加原型?核心原因是结构清晰加方法共享。render和unmount这两个方法定义在prototype上,所有ReactDOMRoot实例共享同一份实现,避免每个root都复制一份方法。同时构造函数的形式也方便做instanceof判断。

3.2 render方法:从root.render到scheduleUpdateOnFiber的完整链路

ReactDOMRoot.prototype.render源码非常短:

ReactDOMRoot.prototype.render = function (children) { const root = this._internalRoot; if (root === null) { throw new Error('Cannot update an unmounted root.'); } updateContainer(children, root, null, null); };

关键的判断是root === null。什么时候_internalRoot会是null?只有调用了unmount之后。所以"unmount之后不能再render"不是约定俗成,而是源码层面直接抛错。

真正干活的是updateContainer,这个函数在ReactFiberReconciler.js里,逻辑链路很长,我拆成几步:

export function updateContainer(element, container, parentComponent, callback) { const current = container.current; const lane = requestUpdateLane(current); const context = getContextForSubtree(parentComponent); if (container.context === null) { container.context = context; } else { container.pendingContext = context; } const update = createUpdate(lane); update.payload = { element }; callback = callback === undefined ? null : callback; if (callback !== null) { update.callback = callback; } const root = enqueueUpdate(current, update, lane); if (root !== null) { scheduleUpdateOnFiber(root, current, lane); entangleTransitions(root, current, lane); } return lane; }

第一步,requestUpdateLane根据当前调度环境申请一条优先级lane。React并发模式里,每次更新都要有一个优先级等级,紧急更新拿同步lane,过渡更新拿transition lane。

第二步,createUpdate(lane)创建一个update对象,把root.render(children)传入的React元素塞进update.payload = { element }。这里有个很多人忽略的重点:root.render的参数被包成{ element: children },而不是直接用children作为payload。因为React元素本身只是描述,真正渲染时需要把它当成fiber节点的"element"属性去消费。

第三步,enqueueUpdate(current, update, lane)把update插入到HostRoot fiber的更新队列。这个队列不是普通数组,而是一个环形链表结构。每次插入都放在shared.pending上,pending永远指向最后一个update,而pending.next指向第一个。这种环形链表的好处是O(1)插入,并且处理时能快速拿到第一批更新。

第四步,scheduleUpdateOnFiber(root, current, lane)触发调度。从这里开始,React会安排一次渲染任务,可能同步执行,也可能被调度器延后到合适时机,然后经过render阶段、commit阶段,最终把DOM变化同步到页面上。

你可以看到,root.render本身没有任何DOM操作。它只是把一个更新塞进队列,再通知调度器"有事做了"。这也是理解React并发特性的关键:从root.render到DOM更新,中间是异步可中断的。

3.3 unmount方法:清引用、入队空更新、清事件标记

unmount源码同样精简:

ReactDOMRoot.prototype.unmount = function () { const root = this._internalRoot; if (root !== null) { this._internalRoot = null; const container = root.containerInfo; updateContainer(null, root, null, null); flushSyncCallbacks(); markContainerAsRoot(undefined, container); } };

拆开看,unmount做了四件事:

第一,判空。如果_internalRoot已经为null,说明已经卸载过,直接什么都不做。这保证了unmount的幂等性。

第二,把this._internalRoot = null。这一步非常关键,它让后续任何尝试调用render的操作都会走到前面说的"Cannot update an unmounted root"分支,从实例层面杜绝了卸载后继续渲染的可能。

第三,调用updateContainer(null, root, null, null)。注意这里传的是null而不是undefined,语义是"把root里的元素更新为null",React在render阶段发现element为null,就会把整棵树清空卸载。这一步是异步调度的,所以紧跟后面调用了flushSyncCallbacks(),强制把同步回调清掉,确保DOM清理尽快完成。

第四,markContainerAsRoot(undefined, container),把容器上那个__reactContainer$xxx标记清除,通知外界"这个容器不再是React的root了"。

一个值得注意的地方:unmount并不会立刻同步清空DOM。它入队的更新需要调度器处理,flushSyncCallbacks只是让已经排队的同步任务尽快执行。如果在一个复杂的调度环境下,真正DOM被拿掉可能会有短暂延迟。但实践里感知不明显,我们通常认为unmount之后页面内容就被移除了。

3.4 React 18与React 19在实现上的差异

React 18里ReactDOMRoot是典型的构造函数加原型方法。但到了React 19,源码里做了一次重构,把render和unmount的逻辑收进了内部工厂函数createRootInternals,再挂到原型上。简单说,方法本体变成了工厂函数返回的一部分:

function createRootInternals() { function render(children) { const root = this._internalRoot; if (root === null) { throw new Error('Cannot update an unmounted root.'); } updateContainer(children, root, null, null); } function unmount() { const root = this._internalRoot; if (root !== null) { this._internalRoot = null; const container = root.containerInfo; updateContainer(null, root, null, null); flushSyncCallbacks(); markContainerAsRoot(undefined, container); } } return { render, unmount }; }

这样调整背后的动机有两个:一是后续有其他内部API需要复用同一套render/unmount逻辑,二是让ReactDOMRoot的构造更灵活,不再绑定prototype的写法。你要是看React 19的hydrateRoot实现,会发现它也复用了这套内部方法。所以源码分析别只看一个版本的实现,版本差异往往暴露了设计意图的演进。

4. ReactDOMRoot的地基:FiberRootNode与HostRoot fiber

4.1 FiberRootNode关键字段

FiberRootNode是react-reconciler层的数据结构,整个应用的fiber树、更新优先级、渲染进度都记录在它身上。我挑几个最重要的字段给你梳理一遍。

第一个是tag,标记root类型。createRoot创建出来的是ConcurrentRoot,ReactDOM.render创建的是LegacyRoot。很多渲染逻辑分支都靠这个tag判断,比如是否允许并发更新、是否走hydration逻辑。

第二个是containerInfo,存的是createRoot传入的DOM容器。ReactDOMRoot的unmount方法里拿root.containerInfo就是这个字段。

第三个是current,指向当前HostRoot fiber。React的双缓冲机制依赖这个指针:渲染完成前,新树挂在workInProgress上,提交时切换current。

第四个是pendingLanes和pendingUpdateLanes,记录等待处理的更新优先级。lane模型是并发调度的核心,React用31位的位向量表示优先级,多个lane可以同时存在。root.render触发更新时会让某些lane置位。

第五个是finishedWork,指向已经完成render阶段、等待提交的fiber树。从render阶段到commit阶段交接,靠的就是这个字段。

第六个是context和pendingContext,保存root级别的React context默认值,通常是null。当有新的context提供者出现在根节点上层时,React会把新context暂存在pendingContext,在渲染开始时切换。

这些字段在new FiberRootNode构造函数里有一个超长的初始化列表,第一次看源码很容易被吓到。但理解核心几个就够了:current、containerInfo、pendingLanes、finishedWork。

4.2 HostRoot fiber与初始化状态

HostRoot fiber是整棵fiber树的根节点,它的tag是HostRoot,数字标识是3。这个fiber比较特殊,它没有对应的DOM元素,stateNode指向FiberRootNode,作用就是作为fiber树的起点节点。

初始化时,HostRoot fiber的memoizedState是一个对象:

{ element: null, // 等待渲染的React元素,root.render后这里会被更新 isDehydrated: false, // 是否处于服务端渲染水合状态 cache: null, // 缓存相关数据 transitions: null, // 过渡更新状态 }

updateQueue由initializeUpdateQueue创建,初始baseState就是上面那个对象。每次root.render,React都会在这个队列里追加一个update,然后由render阶段把队列里的element取出来,生成或更新子fiber。

如果你在控制台手动修改过root._internalRoot.current.memoizedState,会发现页面渲染结果并不会立刻变化,因为fiber数据不是直接驱动DOM的。真正驱动DOM的是render阶段从updateQueue里计算出的新memoizedState,再经过commit阶段提交。这也是很多人调试React内部状态时容易误解的地方。

4.3 两个root的关系与常见理解误区

ReactDOMRoot和FiberRootNode之间的关系,可以类比成"遥控器"和"电视机"。ReactDOMRoot是用户手里的遥控器,小巧直观,你只需要按render、unmount两个按钮;FiberRootNode是电视机内部的电路板,复杂但承担所有实际工作。两者的连接线就是_internalRoot字段。

常见误区有三个:

第一个误区:认为ReactDOMRoot就是fiber树根。实际上fiber树根是HostRoot fiber,FiberRootNode只是持有它并记录调度状态的容器。ReactDOMRoot更外层,它连fiber树根都不是,只是一个客户端API层面的门面。

第二个误区:认为root.render会立刻触发DOM操作。前面已经拆解过,root.render只是入队更新并触发调度,DOM变化发生在commit阶段,且并发模式下中间可能被中断、重来。

第三个误区:认为unmount会销毁整个root数据结构。源码里unmount只是把_internalRoot置null、入队空更新、清除DOM标记,FiberRootNode对象本身并没有被delete,只是脱离了ReactDOMRoot的引用。真正的内存回收交给GC处理。

理清这三个误区,React的启动流程就清晰了一大半。

5. 面试与实战:ReactDOMRoot相关考点和排坑记录

5.1 高频面试题与答题要点

React源码相关的面试题,ReactDOMRoot这个概念出现的频率很高。我整理几个常见问题,配了答题思路,你可以在面试前快速过一遍。

面试题核心答题思路
createRoot返回的是什么对象?ReactDOMRoot实例,内部持有_internalRoot指向FiberRootNode
FiberRootNode和HostRoot fiber是什么关系?互相引用:FiberRootNode.current指向HostRoot fiber,HostRoot fiber.stateNode指回FiberRootNode
root.render到底做了什么?创建update、挂到HostRoot更新队列、调用scheduleUpdateOnFiber触发调度,不直接操作DOM
为什么React 18要用createRoot替代ReactDOM.render?createRoot创建ConcurrentRoot,支持并发特性和可中断渲染,ReactDOM.render是LegacyRoot语义
unmount后再调用render会怎样?抛错:"Cannot update an unmounted root.",因为_internalRoot已被置为null
React事件为什么会代理到root容器上?createRoot里调用了listenToAllSupportedEvents,把事件委托绑定到root容器而不是document

回答问题的时候,不要只背结论。面试官更想听到的是你对"入队更新"、"调度"这些词的理解,能说出updateContainer -> scheduleUpdateOnFiber这条链路,基本就证明你不是死记硬背。

5.2 业务中常见的五个坑

我在实际项目里见过不少和根节点相关的线上问题,列出来给大家避坑。

第一个坑:对同一个DOM元素重复调用createRoot。React会警告"Attempted to recreate root",而且行为不可控。正确的做法是模块顶层只创建一次root,后续更新都通过root.render。

第二个坑:在createRoot之后再用innerHTML或replaceChildren清空容器。React对DOM的掌控是基于它自己挂载的标记和fiber树,如果你手动把容器内容换掉,React提交阶段可能把DOM状态搞乱。清空内容请用root.render(null),销毁请用root.unmount。

第三个坑:unmount之后又调用render。前面源码已经说了,这个直接抛异常。常见场景是热更新、组件卸载后又尝试重新挂载,需要判断root是否已被unmount。

第四个坑:StrictMode下副作用执行两次。React 18的StrictMode会让组件mount时多执行一次初始化流程,开发模式下正常,许多新手以为代码写错了。根节点用<React.StrictMode>包裹后,render阶段函数会被调用两次,这是故意的,目的是暴露副作用问题。

第五个坑:依赖document级事件委托。React 17之前合成事件挂在document上,React 17开始挂在root容器上。如果你在document上监听React相关的原生事件,可能会漏掉冒泡到root但没继续往上冒泡的情况。这就是为什么根容器不要嵌套在事件监听的中间层。

5.3 源码调试技巧

看源码不能光靠脑子过,断点和打印才是效率工具。

第一个调试点:在ReactDOMRoot.prototype.render里打断点,可以看到每一次root.render调用的入参,再单步进updateContainer,就能观察update对象的创建和入队。我是用Chrome DevTools直接引源码map文件来断的,搜ReactDOMRoot.js就能定位。

第二个调试点:在scheduleUpdateOnFiber打断点,能看到调度器如何接手更新。这里你可以观察lane的值和root.pendingLanes的变化,对理解优先级有帮助。

第三个打印技巧:浏览器控制台里选中root容器,查看它的__reactContainer$xxx属性。这个属性指向HostRoot fiber,等于在真实DOM上直接嗅探React内部结构。你还可以通过这个属性反向验证两个root之间的连接关系。

第四个技巧:用React DevTools看root节点。Components面板最顶层的那个节点就是root对应的fiber,点开能看到它的props和hooks,但对理解数据结构帮助有限,不如直接控制台打印直观。

6. 手写一个简化版ReactDOMRoot

6.1 最小可用实现

理解源码的最好方式是自己写一遍。我在这里放一个简化版ReactDOMRoot,去掉了lane调度、diff、commit这些复杂部分,只保留核心链路:createRoot -> 创建fiber root -> ReactDOMRoot实例 -> render入队 -> 触发调度。

const randomKey = Math.random().toString(36).slice(2); const containerKey = '__reactContainer$' + randomKey; function createRoot(container) { if (!container || (container.nodeType !== 1 && container.nodeType !== 9 && container.nodeType !== 11)) { throw new Error('createRoot(...): Target container is not a DOM element.'); } const fiberRoot = createFiberRoot(container); container[containerKey] = fiberRoot.current; return new ReactDOMRoot(fiberRoot); } function createFiberRoot(container) { const fiberRoot = { containerInfo: container, current: null, pendingLanes: 0, finishedWork: null, }; const hostRootFiber = { tag: 3, // HostRoot stateNode: fiberRoot, memoizedState: { element: null, isDehydrated: false }, updateQueue: null, child: null, sibling: null, return: null, }; hostRootFiber.updateQueue = createUpdateQueue(hostRootFiber.memoizedState); fiberRoot.current = hostRootFiber; return fiberRoot; } function createUpdateQueue(baseState) { return { baseState, firstBaseUpdate: null, lastBaseUpdate: null, shared: { pending: null }, callbacks: null, }; } class ReactDOMRoot { constructor(internalRoot) { this._internalRoot = internalRoot; } render(children) { const root = this._internalRoot; if (root === null) { throw new Error('Cannot update an unmounted root.'); } updateContainer(children, root); } unmount() { const root = this._internalRoot; if (root !== null) { this._internalRoot = null; root.containerInfo[containerKey] = undefined; updateContainer(null, root); } } } function updateContainer(element, container) { const current = container.current; const update = { payload: { element }, next: null }; enqueueUpdate(current, update); scheduleUpdateOnFiber(container, current); } function enqueueUpdate(fiber, update) { const queue = fiber.updateQueue; const pending = queue.shared.pending; if (pending === null) { update.next = update; // 环形链表,自己指向自己 } else { update.next = pending.next; pending.next = update; } queue.shared.pending = update; } function scheduleUpdateOnFiber(fiberRoot, currentFiber) { // 简化版:直接把当前fiber标记为需要更新,并触发一次同步渲染 // 真实实现会走lane分配、调度器、render阶段、commit阶段 console.log('schedule update on fiber:', currentFiber.tag); // 这里只模拟入队后的调度信号,真正的渲染循环省略 const queue = currentFiber.updateQueue; let pending = queue.shared.pending; if (pending) { const first = pending.next; queue.baseState = { ...queue.baseState, element: first.payload.element }; queue.shared.pending = null; } }

这段代码可以跑通基本流程:createRoot往容器上挂标记、ReactDOMRoot持有内部root、render创建update并入队、schedule里简单消费update。你可以在浏览器里跑一下,查看容器的__reactContainer$xxx属性,对比真实React的行为。但对真实的DOM渲染,这个版本完全没实现,它只是一个理解源码结构的骨架。

6.2 与真实实现的核心差距

我写这个简化版的过程里,反复对照真实源码,发现差距主要集中在六个方面。

第一,lane优先级模型。真实源码里每次更新都要通过requestUpdateLane分配具体优先级,整个调度系统围绕lane展开。我的简化版把所有更新都当成同等优先级,无法实现可中断和并发。

第二,调度器。真实实现里scheduleUpdateOnFiber会触发scheduler调度,任务可能被安排到宏任务执行,可中断可恢复。我的版本直接同步消费update,完全没有"安排到什么时候执行"的概念。

第三,render阶段。真实源码从HostRoot出发,递归构建整棵fiber树,执行render函数、diff子节点。我的版本只更新了根节点的element,压根没有子fiber的构建。

第四,commit阶段。真实源码会先收集副作用,再统一提交DOM变更,包括DOM插入、删除、更新、ref绑定等。我的版本没有任何DOM操作。

第五,hydration。服务端渲染的hydrateRoot会走完全不同的初始化路径,HostRoot的isDehydrated会变成true,render阶段要复用已有DOM节点。我的版本完全没处理。

第六,事件系统。真实源码在createRoot时会调用listenToAllSupportedEvents,把一大堆原生事件委托到root容器。我的版本没有任何事件绑定。

手写这个简化版,最大的价值不在于实现功能,而在于让你在动手过程中建立"根节点数据流"的心智模型。等你带着这个模型再回去读真实源码,那些散布在ReactFiberReconciler、ReactFiberWorkLoop里的函数,就不再是零散的碎片了。

我个人这些年看React源码的体会是:ReactDOMRoot这个入口,是最适合普通开发者切入的点。它不像scheduler那样纯粹抽象,也不像reconciler那样函数庞大。它处在一个用户侧和引擎侧的接缝位置,看得见摸得着。我第一次把断点打在ReactDOMRoot.prototype.render,顺藤摸瓜看到updateContainer里的createUpdate和enqueueUpdate,对React的"更新"概念一下子就从"重新渲染组件"升级成了"入队一个带优先级的任务"。最后再分享一个实用小技巧:分析这类源码时,直接把源码文件下载下来配合断点调试,比自己盲读效率高很多,你甚至可以在关键函数里临时加console.log,观察真实应用的调度轨迹。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进