1. 协调(reconciliation)是什么?
React 是一个用于构建用户界面的库,它的核心是跟踪组件状态变化并将它们更新到页面上。在 React 中我们称这个过程为 reconciliation。当调用 setState 方法的时候,react 会检查 state 和 props 是否发生了变化,并重新渲染组件。React 16 之前的版本中使用了 Stack Reconciliation 的方式进行 diff,但此算法的核心在于递归调用,外加 JavaScript 又是单线程的,因此在 diff 的过程中有可能阻塞我们主线程的任务,从而导致页面卡顿等问题。
Vnode
首先了解一下 React 根据不同方式生成的虚拟节点的内容
// jsx
const Demo = (
<div className="container">Demo</div>
)
/*
{
props: {
className: "container"
},
children: [{
props: {
textContent: 'Demo'
},
children: [],
type: "text"
}]
type: "div"
}
*/
// 函数式组件和类组件
const Demo = () => {
return <div className="container">Demo</div>
}
class Demo extends React.Component {
render() {
return <div className="container">Demo</div>
}
}
/*
{
props: {
className: "container"
},
children: []
type: ƒ Demo()
}
*/
2. Stack Reconciliation vs Fiber Reconciliation
Stack Reconciliation
- 我们通过这个流程图可以看出,这个过程充斥着递归,因此,当节点树异常庞大时会导致长时间占用主线程
- 此外,Stack Reconciliation 的性能低下表现在 diff 到一个节点的差异就直接去更新,这样可能导致丢帧
关于丢帧
- JavaScript 引擎与 GUI 引擎是互斥的,也就是说 GUI 引擎在渲染时会阻塞 JavaScript 引擎计算
- 如果在GUI渲染的时候,JavaScript 改变了 DOM,那么就会造成渲染不同步
- 从这个例子中我们可以看到,如果同步执行会导致第一次的渲染被挂起,然后在浏览器空闲时间一起被渲染,因为第二次覆盖了第一次的渲染,所以导致了丢帧
- 此外我们还需要明确一件事儿,那就是 JavaScript 执行的速度远快于渲染的速度
<body>
<button id='btn'>点击计算</button>
<div id='status'>等待计算</div>
<script>
function long_running(status_div) {
var result = 0;
for (var i = 0; i < 1000; i++) {
for (var j = 0; j < 700; j++) {
for (var k = 0; k < 300; k++) {
result = result + i + j + k
}
}
}
document.querySelector(status_div).innerHTML = '计算完毕'
}
document.querySelector('#btn').onclick = function () {
document.querySelector('#status').innerHTML = '计算中....'
// long_running('#status')
// setTimeout(() => {
// long_running('#status')
// }, 0)
}
</script>
</body>
解决方案
- 利用浏览器的空闲时间执行任务,将主线程让给用户
- 使用循环的方式,可以中断并且可以在中断处继续
- 拆分任务,将组件的 diff 任务拆分成每一个节点的 diff 任务
- 将渲染任务放到一个方法里,一次性更新完所有的页面更新
Fiber Reconciliation
- 根据以上的解决方案,React 提出了一个新的树形结构 —— Fiber,并且在其底层的框架上新增了一个调度层
- React 16 之前的版本分为了两个层面 —— 协调层 / 渲染层,之后的版本分为了三个层面 —— 调度层 / 协调层 / 渲染层
- 因为 requestIdleCallback 有一定的性能限制,而且因浏览器的支持而异,因此 React 开发了一套拥有优先级的调度库 —— schedule -> messageChannel + 模拟实现了一个 Generator
- 调度层控制着任务的添加和弹出
- 协调层 diff 节点差异并将所有的差异以链表的方式储存到每一个 fiber 对象的 nextEffect / firstEffect / lastEffect 中
- 渲染层中将这些需要更新的差异根据 effectTag 进行 commit 进行渲染
- Fiber Reconciliation
算法原理
- 构建 fiber ( 可中断 )
- 提交 Commit ( 不可中断 )
- DOM 初始渲染: virtualDOM -> fiber -> fiber[] -> DOM
- DOM 对比渲染: newVirtualDOM vs oldVirtualDOM -> fiber[] -> DOM
/*
{
type 节点类型
prop 节点属性
stateNode 节点 DOM 对象 / 组件实例对象
tag 节点标记 ( 对具体类型的分类 hostRoot / hostComponent / classComponent / functionComponent )
effects 数组,储存需要更改的 fiber 对象 ( 就不用链表了,改成数组代替吧 )
effectTag 当前 fiber 要被执行的操作 ( 新增 / 删除 / 修改 )
return 当前 fiber 的父级 fiber
child 当前 fiber 的子级 fiber
sibling 当前 fiber 的下一个兄弟 fiber
alternate 备份 fiber,比对时使用
}
*/
调度层
在 React 初始化时定义调度任务,调度任务并不是一个单纯的同步任务,执行完了一次就再也不会执行了的,而是一个时时刻刻都有可能执行的,类似于其它语言中的定时任务的概念
- 当浏览器拥有空闲时间时便会去触发执行这个调度任务中的任务
- 当浏览器拥有用户操作的行为时暂停这个调度任务中的任务
-
- 怎么知道任务中断的点?
-
-
- 这就是为什么 Fiber Reconciliation 采用循环 diff 的原因了
-
-
-
-
- 首先任务队列中的任务主体对象是 fiber 对象
- 每次执行任务的时候都会在全局记录一下当前的任务主体,即 fiber 对象
- 因为 fiber 对象是一个链表结构,所以可以从断点处继续执行
-
-
- 那什么时候会往里面添加任务呢?初始化外层节点 + 构建子节点 + setState
- 构建子节点如何理解呢?
-
- 就需要一个 workLoop 的方法,将最外层的这个任务主体替换成最外层构建完以后的这层 fiber 对象的 child 就行了。
- 但这只是构建了左树的子节点对象,那么右节点呢?例如下面这个 FiberB 的节点
- 如果这层 fiber 对象没有 child 属性,那就返回其 sibling 属性,这样就能将当前任务传到右树上了
- workLoop 中需要循环检查全局任务主体是否还存在,如果存在那就会继续进入协调层
协调层
所谓协调就是依次将任务队列中的任务提取出来进行 diff
- 进入协调层的第一件事儿就是去构建当前 fiber 对象的子节点的 fiber 对象
-
- 假设当前 fiber 对象拥有两个子节点 A 和 B,分别构建出 FiberA 和 FiberB
-
-
- 如果 FiberA 是当前 fiber 的第一个子节点
-
-
-
-
- fiber.child -> FiberA
- FiberA.return -> fiber
- FiberB.sibling -> FiberA
-
-
-
-
- 这三个 fiber 对象各自都只有一个属性,尤其需要注意的是 FiberB 不指向 fiber.child,也没有 return 属性指向 fiber
- 然后给它们的 effectTag 的属性上添加上 'placement' 标记,表示要新增
-
- 如果子节点的 fiber 对象存在,那说明这不是初始化操作,而是 diff 更新。那怎么判断是否存在?而且怎么拿到上一次的 fiber 对象?alternate 属性加持。但是这一个属性的第一次赋值其实是放在了渲染层,所以之后再说~
- 如果子节点的 fiber 对象存在且 alternate 也存在,说明是更新操作
-
- 判断新节点 fiber.type 是否等于 alternate.type
-
-
- 如果是,则说明是同一个节点,那就将直接拿到 alternate 里面储存的真实节点,赋值给新节点的 stateNode 属性
- 如果不是,则说明不是同一个节点,那就直接新创建一个新的节点放到 stateNode 属性中
-
-
- 然后给新节点的 effectTag 的属性上添加上 'update' 标记,表示要更新
- 如果新节点 fiber 对象不存在但是 alternate 存在,说明是删除操作
-
- 那就直接将新节点的 effects 中添加 alternate,并将 alternate.effectTag 改为 'delete'
- 综上三种情况我们可以看出,只有删除操作才会去 effects 中添加需要更改的 fiber 对象,那其他两种怎么做呢?
- 当构建完子节点以后,当前的任务主体肯定是最后一个没有子节点的子节点,那就说明这是一个拥有 return 属性的 fiber 对象
-
- 记录当前这个子节点,循环判断 return 是否存在,这个判断会在 fiber 对象是最外层 fiber 对象时终止
- 在每个循环中往当前子节点的父级 fiber 对象的 effects 中添加当前 fiber 对象,即上面标记了 'placement' / 'update' 的 fiber 对象,这样就可以让最外层的 fiber 对象的 effects 中记录了每一个子节点和每一个子节点的子节点所需要更改的 fiber 对象
- 综合来看,构建节点树的流程是先构建最外层节点的子节点,然后沿着左树依次往下构建,当构建完左树以后再依次退回上一个节点,看看是否存在兄弟节点,如果存在那就构建兄弟节点,收集 effects 也是如此
- 当退回到最外层的 fiber 对象时,说明更新的副作用已经收集完毕了,那就可以将这个最外层的 fiber 对象推入 commit 阶段去进行渲染了
渲染层
- 渲染层要做的事儿就是判断 effectTag 的值,根据值的不同进行不同的 DOM 操作,这个阶段是不可被打断的
- 依次拿出拥有副作用的 fiber 对象
-
- 如果 effectTag 是 'placement',则使用 appendChild 进行追加插入操作
- 如果 effectTag 是 'update',则使用 replaceChild 使用 fiber.stateNode 替换 fiber.alternate.stateNode
- 如果 effectTag 是 'delete',则使用 removeChild 删除该节点
setState
上面在提到调度层时说到有三种方式可以往任务队列中添加任务,只剩下 setState 怎么添加还没说
- 当我们调用 setState 时,React 内部会去调用一个调度方法,将新的 state 和当前实例传过去,然后将这些新数据放进任务队列中
- 当浏览器空闲时,调度层就会去检测任务队列中是否还存在任务,因为通过 setState 添加了一个任务,因此 workLoop 就会工作,拿出该任务并去执行构建子节点
- 在构建子节点之前要去匹配一下是否存在该实例,如果匹配上了,那就更改原来组件 fiber 对象身上的 state,再用新的 state 调用 render 方法构建一颗新的 fiber 树去进行 diff
总结
- Stack Reconciliation 的本质是 diff 的过程中找到差异就去更新,如果一个数据牵扯到了很多节点,那么就会造成页面的卡顿
- Fiber Reconciliation 的本质是 diff 的过程中找到差异只是去收集,当收集完毕后利用空闲时间去一起更新