【React 源码系列】React Hydrate 原理及源码剖析
深入概述 React 初次渲染及状态更新主流程一文中介绍过 React 渲染过程,即
ReactDOM.render
执行过程分为两个大的阶段:render
阶段以及commit
阶段。React.hydrate
渲染过程和ReactDOM.render
差不多,两者之间最大的区别就是,ReactDOM.hydrate
在render
阶段,会尝试复用(hydrate)浏览器现有的 dom 节点,并相互关联 dom 实例和 fiber,以及找出 dom 属性和 fiber 属性之间的差异。欢迎关注mini-react一起学习 react 源码
Demo
这里,我们在 index.html
中直接返回一段 html,以模拟服务端渲染生成的 html
注意,root
里面的内容不能换行,不然客户端hydrate
的时候会提示服务端和客户端的模版不一致。
新建 index.jsx:
对比服务端和客户端的内容可知,服务端h1#A
和客户端的div#A
不同,同时服务端比客户端多了一个span#C
在客户端开始执行之前,即 ReactDOM.hydrate
开始执行前,由于服务端已经返回了 html 内容,浏览器会立马显示内容。对应的真实 DOM 树如下:
注意,这不是 fiber 树!!
ReactDOM.render
先来回顾一下 React 渲染更新过程,分为两大阶段,五小阶段:
render 阶段
beginWork
completeUnitOfWork
commit 阶段。
commitBeforeMutationEffects
commitMutationEffects
commitLayoutEffects
React 在 render 阶段会根据新的 element tree 构建 workInProgress 树,收集具有副作用的 fiber 节点,构建副作用链表。
特别是,当我们调用ReactDOM.render
函数在客户端进行第一次渲染时,render
阶段的completeUnitOfWork
函数针对HostComponent
以及HostText
类型的 fiber 执行以下 dom 相关的操作:
调用
document.createElement
为HostComponent
类型的 fiber 节点创建真实的 DOM 实例。或者调用document.createTextNode
为HostText
类型的 fiber 节点创建真实的 DOM 实例将 fiber 节点关联到真实 dom 的
__reactFiber$rsdw3t27flk
(后面是随机数)属性上。将 fiber 节点的
pendingProps
属性关联到真实 dom 的__reactProps$rsdw3t27flk
(后面是随机数)属性上将真实的 dom 实例关联到
fiber.stateNode
属性上:fiber.stateNode = dom
。遍历
pendingProps
,给真实的dom
设置属性,比如设置 id、textContent 等
React 渲染更新完成后,React 会为每个真实的 dom 实例挂载两个私有的属性:__reactFiber$
和__reactProps$
,以div#container
为例:
ReactDOM.hydrate
hydrate
中文意思是水合物
,这样理解有点抽象。根据源码,我更乐意将hydrate
的过程描述为:React 在 render 阶段,构造 workInProgress 树时,同时按相同的顺序遍历真实的 DOM 树,判断当前的 workInProgress fiber 节点和同一位置的 dom 实例是否满足hydrate
的条件,如果满足,则直接复用当前位置的 DOM 实例,并相互关联 workInProgress fiber 节点和真实的 dom 实例,比如:
如果 fiber 和 dom 满足hydrate
的条件,则还需要找出dom.attributes
和fiber.pendingProps
之间的属性差异。
遍历真实 DOM 树的顺序和构建 workInProgress 树的顺序是一致的。都是深度优先遍历,先遍历当前节点的子节点,子节点都遍历完了以后,再遍历当前节点的兄弟节点。因为只有按相同的顺序,fiber 树同一位置的 fiber 节点和 dom 树同一位置的 dom 节点才能保持一致
只有类型为HostComponent
或者HostText
类型的 fiber 节点才能hydrate
。这一点也很好理解,React 在 commit 阶段,也就只有这两个类型的 fiber 节点才需要执行 dom 操作。
fiber 节点和 dom 实例是否满足hydrate
的条件:
对于类型为
HostComponent
的 fiber 节点,如果当前位置对应的 DOM 实例nodeType
为ELEMENT_NODE
,并且fiber.type === dom.nodeName
,那么当前的 fiber 可以混合(hydrate)对于类型为
HostText
的 fiber 节点,如果当前位置对应的 DOM 实例nodeType
为TEXT_NODE
,同时fiber.pendingProps
不为空,那么当前的 fiber 可以混合(hydrate)
hydrate
的终极目标就是,在构造 workInProgress 树的过程中,尽可能的复用当前浏览器已经存在的 DOM 实例以及 DOM 上的属性,这样就无需再为 fiber 节点创建 DOM 实例,同时对比现有的 DOM 的attribute
以及 fiber 的pendingProps
,找出差异的属性。然后将 dom 实例和 fiber 节点相互关联(通过 dom 实例的__reactFiber$
以及__reactProps$
,fiber 的 stateNode 相互关联)
hydrate 过程
React 在 render 阶段构造HostComponent
或者HostText
类型的 fiber 节点时,会首先调用 tryToClaimNextHydratableInstance(workInProgress)
方法尝试给当前 fiber 混合(hydrate)DOM 实例。如果当前 fiber 不能被混合,那当前节点的所有子节点在后续的 render 过程中都不再进行hydrate
,而是直接创建 dom 实例。等到当前节点所有子节点都调用completeUnitOfWork
完成工作后,又会从当前节点的兄弟节点开始尝试混合。
以下面的 demo 为例
render 阶段,按以下顺序:
div#container
满足hydrate
的条件,因此关联 dom,fiber.stateNode = div#container
。然后使用hydrationParentFiber
记录当前混合的 fiber 节点:hydrationParentFiber = fiber
。获取下一个 DOM 实例,这里是h1#A
,保存在变量nextHydratableInstance
中,nextHydratableInstance = h1#A
。
这里,hydrationParentFiber
和 nextHydratableInstance
都是全局变量。
div#A
和h1#A
不能混合,这时并不会立即结束混合的过程,React 继续对比h1#A
的兄弟节点,即p#B
,发现div#A
还是不能和p#B
混合,经过最多两次对比,React 认为 dom 树中已经没有 dom 实例满足和div#A
这个 fiber 混合的条件,于是div#A
节点及其所有子孙节点都不再进行混合的过程,此时将isHydrating
设置为 false 表明div#A
这棵子树都不再走混合的过程,直接走创建 dom 实例。同时控制台提示:Expected server HTML to contain a matching..
之类的错误。beginWork 执行到文本节点
1
时,发现isHydrating = false
,因此直接跳过混合的过程,在completeUnitOfWork
阶段直接调用document.createTextNode
直接为其创建文本节点同样的,beginWork 执行到节点
div#A2
时,发现isHydrating = false
,因此直接跳过混合的过程,在completeUnitOfWork
阶段直接调用document.createElement
直接为其创建真实 dom 实例,并设置属性由于
div#A
的子节点都已经completeUnitWork
了,轮到div#A
调用completeUnitWork
完成工作,将hydrationParentFiber
指向其父节点,即div#container
这个 dom 实例。设置isHydrating = true
表明可以为当前节点的兄弟节点继续混合的过程了。div#A
没有混合的 dom 实例,因此调用document.createElement
为其创建真实的 dom 实例。为
p#B
执行 beginWork。由于nextHydratableInstance
保存的还是h1#A
dom 实例,因此p#B
和h1#A
对比发现不能复用,React 尝试和h1#A
的兄弟节点p#B
对比,发现 fiberp#B
和 domp#B
能混,因此将h1#A
标记为删除,同时关联 dom 实例:fiber.stateNode = p#B
,保存hydrationParentFiber = fiber
,nextHydratableInstance
指向p#B
的第一个子节点,即span#B1
...省略了后续的过程。
从上面的执行过程可以看出,hydrate 的过程如下:
调用
tryToClaimNextHydratableInstance
开始混合判断当前 fiber 节点和同一位置的 dom 实例是否满足混合的条件。
如果当前位置的 dom 实例不满足混合条件,则继续比较当前 dom 的兄弟元素,如果兄弟元素和当前的 fiber 也不能混合,则当前 fiber 及其所有子孙节点都不能混合,后续 render 过程将会跳过混合。直到当前 fiber 节点的兄弟节点 render,才会继续混合的过程。
事件绑定
React 在初次渲染时,不论是ReactDOM.render
还是ReactDOM.hydrate
,会调用createRootImpl
函数创建 fiber 的容器,在这个函数中调用listenToAllSupportedEvents
注册所有原生的事件。
这里container
就是div#root
节点。listenToAllSupportedEvents
会给div#root
节点注册浏览器支持的所有原生事件,比如onclick
等。React合成事件一文介绍过,React 采用的是事件委托的机制,将所有事件代理到div#root
节点上。以下面的为例:
我们知道 React 在渲染时,会将 fiber 的 props 关联到真实的 dom 的__reactProps$
属性上,此时
当我们点击按钮时,会触发div#root
上的事件监听器:
这样我们就可以实现事件的委托。这其中最重要的就是将 fiber 的 props 挂载到真实的 dom 实例的__reactProps$属性上。因此,只要我们在hydrate
阶段能够成功关联 dom 和 fiber,就自然也实现了事件的“绑定”
hydrate 源码剖析
hydrate 的过程发生在 render 阶段,commit 阶段几乎没有和 hydrate 相关的逻辑。render 阶段又分为两个小阶段:beginWork
和 completeUnitOfWork
。只有HostRoot
、HostComponent
、HostText
三种类型的 fiber 节点才需要 hydrate,因此源码只针对这三种类型的 fiber 节点剖析
beginWork
beginWork 阶段判断 fiber 和 dom 实例是否满足混合的条件,如果满足,则为 fiber 关联 dom 实例:fiber.stateNode = dom
HostRoot Fiber
HostRoot
fiber 是容器root
的 fiber 节点。
这里主要是判断当前 render 是ReactDOM.render
还是ReactDOM.hydrate
,我们调用ReactDOM.hydrate
渲染时,root.hydrate
为 true。
如果是调用的ReactDOM.hydrate
,则调用enterHydrationState
函数进入hydrate
的过程。这个函数主要是初始化几个全局变量:
isHydrating。表示当前正处于 hydrate 的过程。如果当前节点及其所有子孙节点都不满足 hydrate 的条件时,这个变量为 false
hydrationParentFiber。当前混合的 fiber。正常情况下,该变量和
HostComponent
或者HostText
类型的 workInProgress 一致。nextHydratableInstance。下一个可以混合的 dom 实例。当前 dom 实例的第一个子元素或者兄弟元素。
注意getNextHydratable
会判断 dom 实例是否是ELEMENT_NODE
类型(对应的 fiber 类型是HostComponent
)或者TEXT_NODE
类型(对应的 fiber 类型是HostText
)。只有ELEMENT_NODE
或者HostText
类型的 dom 实例才是可以 hydrate 的
HostComponent
HostText Fiber
tryToClaimNextHydratableInstance
假设当前 fiberA 对应位置的 dom 为 domA,tryToClaimNextHydratableInstance
会首先调用tryHydrate
判断 fiberA 和 domA 是否满足混合的条件:
如果 fiberA 和 domA 满足混合的条件,则将
hydrationParentFiber = fiberA;
。并且获取 domA 的第一个子元素赋值给nextHydratableInstance
如果 fiberA 和 domA 不满足混合的条件,则获取 domA 的兄弟节点,即 domB,调用
tryHydrate
判断 fiberA 和 domB 是否满足混合条件:如果 domB 满足和 fiberA 混合的条件,则将 domA 标记为删除,并获取 domB 的第一个子元素赋值给
nextHydratableInstance
如果 domB 不满足和 fiberA 混合的条件,则调用
insertNonHydratedInstance
提示错误:"Warning: Expected server HTML to contain a matching",同时将isHydrating
标记为 false 退出。
这里可以看出,tryToClaimNextHydratableInstance
最多比较两个 dom 节点,如果两个 dom 节点都无法满足和 fiberA 混合的条件,则说明当前 fiberA 及其所有的子孙节点都无需再进行混合的过程,因此将isHydrating
标记为 false。等到当前 fiberA 节点及其子节点都完成了工作,即都执行了completeWork
,isHydrating
才会被设置为 true,以便继续比较 fiberA 的兄弟节点
这里还需要注意一点,如果两个 dom 都无法满足和 fiberA 混合,那么nextHydratableInstance
依然保存的是 domA,domA 会继续和 fiberA 的兄弟节点比对。
completeUnitOfWork
completeUnitOfWork 阶段主要是给 dom 关联 fiber 以及 props:dom.__reactProps$ = fiber.pendingProps;dom.__reactFiber$ = fiber;
同时对比fiber.pendingProps
和dom.attributes
的差异
popHydrationState
以下图为例:
在 beginWork 阶段对 p#B
fiber 工作时,发现 dom 树中同一位置的h1#B
不满足混合的条件,于是继续对比h1#B
的兄弟节点,即div#C
,仍然无法混合,经过最多两轮对比后发现p#B
这个 fiber 没有可以混合的 dom 节点,于是将 isHydrating
标记为 false,hydrationParentFiber = fiberP#B
。p#B
的子孙节点都不再进行混合的过程。
div#B1
fiber 没有子节点,因此它可以调用completeUnitOfWork
完成工作,completeUnitOfWork
阶段调用 popHydrationState
方法,在popHydrationState
方法内部,首先判断 fiber !== hydrationParentFiber
,由于此时的hydrationParentFiber
等于p#B
,因此条件成立,不用往下执行。
由于p#B
fiber 的子节点都已经完成了工作,因此它也可以调用completeUnitOfWork
完成工作。同样的,在popHydrationState
函数内部,第一个判断fiber !== hydrationParentFiber
不成立,两者是相等的。第二个条件!isHydrating
成立,进入条件语句,首先调用popToNextHostParent
将hydrationParentFiber
设置为p#B
的第一个类型为HostComponent
的祖先元素,这里是div#A
fiber,然后将isHydrating
设置为 true,指示可以为p#B
的兄弟节点进行混合。
如果服务端返回的 DOM 有多余的情况,则调用deleteHydratableInstance
将其删除,比如下图中div#D
节点将会在div#A
fiber 的completeUnitOfWork
阶段删除
prepareToHydrateHostInstance
对于HostComponent
类型的 fiber 会调用这个方法,这里只要是关联 dom 和 fiber:
设置
domInstance.__reactFiber$w63z5ormsqk = fiber
设置
domInstance.__reactProps$w63z5ormsqk = props
对比服务端和客户端的属性
这里重点讲下diffHydratedProperties
,以下面的 demo 为例:
在diffHydratedProperties
的过程中发现,服务端返回的 id 和客户端的 id 不同,控制台提示 id 不匹配,但是客户端并不会纠正这个,可以看到浏览器的 id 依然是server
。
同时,服务端多返回了一个extra
属性,因此需要控制台提示,但由于已经提示了 id 不同的错误,这个错误就不会提示。
最后,客户端的文本和服务端的 children 不同,即文本内容不同,也需要提示错误,同时,客户端会纠正这个文本,以客户端的为主。
prepareToHydrateHostTextInstance
对于HostText
类型的 fiber 会调用这个方法,这个方法逻辑比较简单,就不详细介绍了
版权声明: 本文为 InfoQ 作者【爱切图的木子老师】的原创文章。
原文链接:【http://xie.infoq.cn/article/c501696fcf2abd75defd0dce4】。文章转载请联系作者。
评论