- Published on
React 架构演变
vdom
为什么 react 和 vue 都要基于 vdom 呢?直接操作真实 dom 不行么?
- 原生 DOM, 更新成本太大, 不会复用
- vdom 低成本来更新, 尽量复用
一个描述了 DOM 节点的 js 对象
// 没有和原生dom强绑定,可以渲染到别的平台
{
type: 'div',
props: {
id: 'root',
className: ['flex', 'flex-1'],
onClick: function() {}
},
children: []
}
vdom 的编译
直接写 vdom 影响开发体验,所以框架都会简化操作,比如 vue 的 .vue 文件的 template react 的 jsx
而 react 的 jsx 的编译器是 babel 实现的
调试技巧
在开发调试插件的过程,在本地装上 vite-plugin-inspect 插件,并在 Vite 中使用它
// vite.config.ts
import inspect from 'vite-plugin-inspect'
// 返回的配置
{
plugins: [
// ...
inspect(),
]
}
当你再次启动项目时,会发现多出一个调试地址

渲染 vdom 也就是通过 dom api 增删改 dom,通过 vdom 上的一些属性描述来对应的真实 dom 不管 vue 还是 react,渲染器会通过一些判断来区分 vdom 的类型,来做不同的处理
// react 里是通过 tag 来区分 vdom 类型的
// 比如 HostComponent - 元素
// HostText - 文本
// FunctionComponent、ClassComponent - 函数组件和类组件
switch (vdom.tag) {
case HostComponent:
// 创建或更新 dom
case HostText:
// 创建或更新 dom
case FunctionComponent:
// 创建或更新 dom
case ClassComponent:
// 创建或更新 dom
}
组件
如果是函数组件,那就传入 props 执行它,拿到 vdom 之后再递归渲染。
如果是 class 组件,那就创建它的实例对象,调用 render 方法拿到 vdom,然后递归渲染。
switch (vdom.tag) {
case FunctionComponent:
const childVdom = vdom.type(props)
render(childVdom)
//...
case ClassComponent:
const instance = new vdom.type(props)
const childVdom = instance.render()
render(childVdom)
//...
}
基于 vdom 的前端框架渲染流程都差不多,vue 和 react 很多方面是一样的。 但是管理状态的方式不一样,vue 有响应式,而 react 则是 setState 的 api 的方式。 vue 和 react 最大的区别就是状态管理方式的区别,因为这个区别导致了后面架构演变方向的不同。
状态管理
react 是通过 setState 的 api 触发状态更新的,更新以后就重新渲染整个 vdom。
而 vue 是通过对状态做代理,get 的时候收集依赖,然后修改状态的时候就可以触发对应组件的 render 了。
为什么 react 不直接渲染对应组件呢?
想象一下这个场景:
// 父组件
const [a, setA] = useState(1)
return (
<div>
<h1>{a}</h1>
<Child a={a} setA={setA} />
</div>
)
// 子组件
cosnt { a, setA } = props
return (
<div onClick={setA}>{a}</div>
)
- 父组件把它的 setA 函数传递给子组件,子组件调用
- 这时候 setState 是子组件触发的,但是要渲染的却不只有那个组件
- 这个场景中,父组件也会更新,实际上可能触发任意位置的其他组件更新
- 所以必须重新渲染整个 vdom
vue 可以做到精准的更新变化的组件 因为Object.defineProperty
、Proxy
的响应式代理, 不管是子组件、父组件、还是其他位置的组件,只要用到了对应的状态,那就会被作为依赖收集起来,状态变化的时候就可以触发它们的 render。
react 架构的演变
react 的 setState 会渲染整个 vdom,计算量会很大 gui 渲染线程
和 js 线程
不能同时进行。 浏览器的刷屏频率为 60HZ
, 一帧 = 1000ms / 60 ~= 16.6666ms
浏览器里 js 计算时间太长是会阻塞渲染的,会占用每一帧的动画、重绘重排的时间,这样动画就会卡顿。 动画卡顿肯定是不行的。但是因为 setState 的方式只能渲染整个 vdom,所以计算量大是不可避免的。
通过把计算量拆分一下,每一帧预留一些时间给 JS 线程,不阻塞动画的渲染
这个预留的初始时间是 5ms,源码中有写到
let yieldInterval = 5;
当预留的时间不够用时,React 将线程控制权交还给浏览器渲染 UI,等待下一帧时间到来继续被中断的工作
解决 vdom 计算长时间占用 js 进程导致的阻塞渲染 是通过 时间分片
但是如何将 计算 vdom, 同步的更新变成可中断的更新呢, react 启用了 fiber
架构
fiber 架构
刚刚我们知道了 要把每帧进行拆分,预留一些时间给 js 线程。 实际上浏览器已经有相关的 api: window.requestIdleCallback()
[https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestIdleCallback]
这个函数将在浏览器空闲时期被调用。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件
但是 处于某种原因 react 没有使用这个 api, 自己实现了一个更完善的方法
优化的目标是打断计算,分多次进行,但现在递归的渲染是不能打断的,有两个方面的原因导致的:
如果单次计算完直接就操作 dom,这时打断,会频繁重新渲染真实 dom,开销较大
打断后,无法定位到具体位置,缺少、父元素 兄弟元素等信息
fiber 的 结构
将递归的无法中断的更新重构为异步的可中断更新,由于曾经用于递归的 vdom 数据结构已经无法满足需要。 于是,全新的 fiber 架构应运而生
- 每个 fiber 节点对应一个 React element,保存了该组件的类型(函数组件/类组件/原生组件...)、对应的 DOM 节点等信息。
- 每个 fiber 节点保存了本次更新中该组件改变的状态、要执行的工作(需要被删除/被插入页面中/被更新...)
function FiberNode(tag: WorkTag, pendingProps: mixed, key: null | string, mode: TypeOfMode) {
// 作为静态数据结构的属性
this.tag = tag // Fiber对应组件的类型 Function/Class...
this.key = key
this.elementType = null // 大部分情况同type,某些情况不同,比如FunctionComponent使用React.memo包裹
this.type = null // 对于 FunctionComponent,指函数本身,对于ClassComponent,指class,对于HostComponent,指DOM节点tagName
this.stateNode = null // Fiber对应的真实DOM节点
// 用于连接其他Fiber节点形成Fiber树
this.return = null // 指向父级Fiber节点
this.child = null // 指向子Fiber节点
this.sibling = null // 指向右边第一个兄弟Fiber节点
this.index = 0
this.ref = null
// 作为动态的工作单元的属性
this.pendingProps = pendingProps
this.memoizedProps = null
this.updateQueue = null
this.memoizedState = null
this.dependencies = null
this.mode = mode
// 保存本次更新会造成的DOM操作
this.effectTag = NoEffect
this.nextEffect = null
this.firstEffect = null
this.lastEffect = null
// 调度优先级相关
this.lanes = NoLanes
this.childLanes = NoLanes
// 指向该fiber在另一次更新时对应的fiber
this.alternate = null
}
第一个问题的解决还是容易想到的:
渲染的时候不要直接更新到 真实 dom,找到变化的部分,打个增删改的标记,全部计算完了一次性更新到真实 dom 上
所以 react 把渲染流程分为了两部分: render
和 commit
render
阶段会找到 vdom 中变化的部分,创建 dom,打上增删改的标记,这个叫做 reconcile
调和。 reconcile
是可以打断的,全部计算完了,就一次性更新到 真实 dom,叫做 commit
这样,react 就把之前的和 vue 很像的递归渲染,改造成了 render
+ commit
两个阶段的渲染。
从此以后,react 和 vue 架构上的差异才大了起来。
第二个问题,如何打断以后还能找到父节点、其他兄弟节点呢?
现有的 vdom 是不行的,需要再记录下 parent、silbing 的信息。所以 react 创造了 fiber 的数据结构。
除了 children 信息外,额外多了 sibling、return,分别记录着兄弟节点、父节点的信息。 这个数据结构也叫做 fiber。(fiber 既是一种数据结构,也代表 render + commit 的渲染流程) react 会先把 vdom 转换成 fiber,再去进行 reconcile,这样就是可打断的了。
因为 vdom 里每个节点只记录了子节点(children),没有记录兄弟节点,所以必须一次性渲染完,不能打断。
而转成 fiber 的链表结构就会记录父节点(return)、子节点(child)、兄弟节点(sibling),就变成了可打断的。
双缓存策略
React 使用双缓存
来完成 Fiber 树的构建与替换——对应着 DOM 树的创建与更新 当前屏幕上显示内容对应的 Fiber 树称为 current Fiber
树,正在内存中构建的 Fiber 树称为 workInProgress Fiber
树
构建、替换流程
currentFiber.alternate === workInProgressFiber
workInProgressFiber.alternate === currentFiber
每次状态更新都会产生新的 workInProgress Fiber 树,通过 current 与 workInProgress 的替换,完成 DOM 更新。
ReactDOM.render(<App />, document.getElementById('root'))
- 首次执行 ReactDOM.render 会创建
fiberRoot
和rootFiber。其中
fiberRoot
是整个应用的根节点,rootFiber
是 App 所在组件树的根节点。 - 接下来进入
render
阶段,根据组件返回的 JSX 在内存中依次创建 Fiber 节点并连接在一起构建 Fiber 树,被称为workInProgress Fiber
树 workInProgress Fiber
树在commit
阶段渲染到页面。fiberRoot
的 current 指针指向workInProgress Fiber
树使其变为current Fiber
树。
render 阶段
render 阶段开始于 performSyncWorkOnRoot
或 performConcurrentWorkOnRoot
方法的调用。这取决于本次更新是同步更新还是异步更新。
// performSyncWorkOnRoot
function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress)
}
}
// performConcurrentWorkOnRoot
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress)
}
}
workInProgress
代表当前已创建的workInProgress fiber
可以看到,他们唯一的区别是是否调用 shouldYield
。 如果当前浏览器帧没有剩余时间,shouldYield
会中止循环,直到浏览器有空闲时间后再继续遍历。
performUnitOfWork
方法会创建下一个 Fiber 节点并赋值给workInProgress
,并将workInProgress
与已创建的 Fiber 节点连接起来构成 Fiber 树。
前面说过,为了变为可打断的,reconcile 阶段并不会真正操作 dom,只会创建 dom 然后打个 effectTag 的增删改标记。
commit 阶段就根据标记来更新 dom 就可以了。 但是 commit 阶段要再遍历一次 fiber 来查找有 effectTag 的节点,更新 dom 么?
这样当然没问题,但没必要。完全可以在 reconcile 的时候把有 effectTag 的节点收集到一个队列里,然后 commit 阶段直接遍历这个队列就行了。 这个队列叫做 effectList。
react 会在 commit 阶段遍历 effectList,根据 effectTag 来增删改 dom。 dom 创建前后就是 useEffect、useLayoutEffect 还有一些函数组件的生命周期函数执行的时候。
useEffect 被设计成了在 dom 操作前异步调用,useLayoutEffect 是在 dom 操作后同步调用。
为什么这样呢? 因为都要操作 dom 了,这时候如果来了个 effect 同步执行,计算量很大,那不是把 fiber 架构带来的优势有毁了么? 所以 effect 是异步的,不会阻塞渲染。 而 useLayoutEffect,顾名思义是想在这个阶段拿到一些布局信息的,dom 操作完以后就可以了,而且都渲染完了,自然也就可以同步调用了。
实际上 react 把 commit 阶段也分成了 3 个小阶段。 before mutation、mutation、layout。 mutation 就是遍历 effectList 来更新 dom 的。 它的之前就是 before mutation,会异步调度 useEffect 的回调函数。 它之后就是 layout 阶段了,因为这个阶段已经可以拿到布局信息了,会同步调用 useLayoutEffect 的回调函数。而且这个阶段可以拿到新的 dom 节点,还会更新下 ref。
至此,我们对 react 的新架构,render、commit 两大阶段都干了什么就理清了。
总结
react 和 vue 都是基于 vdom 的前端框架,之所以用 vdom 是因为可以精准的对比关心的属性,而且还可以跨平台渲染。但是开发不会直接写 vdom,而是通过 jsx 这种接近 html 语法的 DSL,编译产生 render function,执行后产生 vdom。 vdom 的渲染就是根据不同的类型来用不同的 dom api 来操作 dom。
渲染组件的时候,如果是函数组件,就执行它拿到 vdom。class 组件就创建实例然后调用 render 方法拿到 vdom。vue 的那种 option 对象的话,就调用 render 方法拿到 vdom。组件本质上就是对一段 vdom 产生逻辑的封装,函数、class、option 对象甚至其他形式都可以。
react 和 vue 最大的区别在状态管理方式上,vue 是通过响应式,react 是通过 setState 的 api。我觉得这个是最大的区别,因为它导致了后面 react 架构的变更。 react 的 setState 的方式,导致它并不知道哪些组件变了,需要渲染整个 vdom 才行。但是这样计算量又会比较大,会阻塞渲染,导致动画卡顿。所以 react 后来改造成了 fiber 架构,目标是可打断的计算。
为了这个目标,不能变对比变更新 dom 了,所以把渲染分为了 render 和 commit 两个阶段,render 阶段通过 schedule 调度来进行 reconcile,也就是找到变化的部分,创建 dom,打上增删改的 tag,等全部计算完之后,commit 阶段一次性更新到 dom。打断之后要找到父节点、兄弟节点,所以 vdom 也被改造成了 fiber 的数据结构,有了 parent、sibling 的信息。
所以 fiber 既指这种链表的数据结构,又指这个 render、commit 的流程。 reconcile 阶段每次处理一个 fiber 节点,处理前会判断下 shouldYield,如果有更高优先级的任务,那就先执行别的。 commit 阶段不用再次遍历 fiber 树,为了优化,react 把有 effectTag 的 fiber 都放到了 effectList 队列中,遍历更新即可。
在 dom 操作前,会异步调用 useEffect 的回调函数,异步是因为不能阻塞渲染。 在 dom 操作之后,会同步调用 useLayoutEffect 的回调函数,并且更新 ref。 所以,commit 阶段又分成了 before mutation、mutation、layout 这三个小阶段,就对应上面说的那三部分。
我觉得理解了 vdom、jsx、组件本质、fiber、render(reconcile + schedule) + commit(before mutation、mutation、layout)的渲染流程,就算是对 react 原理有一个比较深的理解了。