React Reconciliation

1,140 阅读8分钟

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

  • 我们通过这个流程图可以看出,这个过程充斥着递归,因此,当节点树异常庞大时会导致长时间占用主线程
  • 此外,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 的过程中找到差异只是去收集,当收集完毕后利用空闲时间去一起更新