剖析 React 源码:组件更新流程二(diff 策略)

这是个人剖析 React 源码的第六篇文章。这篇文章链接上篇,将会带着你们学习组件更新过程相关的内容,而且尽量的脱离源码来了解原理,下降你们的学习难度。html

文章相关资料

这篇文章你能学到什么?

文章分为三部分,在这部分的文章中你能够学习到以下内容:react

  • 调和的过程

三篇文章并无强相关性,固然仍是推荐阅读下 前一篇文章git

调和的过程

组件更新归结到底仍是 DOM 的更新。对于 React 来讲,这部分的内容会分为两个阶段:github

  1. 调和阶段,基本上也就是你们熟知的虚拟 DOM 的 diff 算法
  2. 提交阶段,也就是将上一个阶段中 diff 出来的内容体现到 DOM 上

这一小节的内容将会集中在调和阶段,提交阶段这部分的内容将会在下一篇文章中写到。另外你们所熟知的虚拟 DOM 的 diff 算法在新版本中其实已经彻底被重写了。算法

这一小节的内容会有点难度,若是你以为难以读懂个人文章或者是别的问题,欢迎在下方评论区与我互动! 数据结构

有个例子能更好地帮助理解,咱们就经过如下组件的更新来了解整个调和的过程。函数

class Test extends React.Component {
  state = {
    data: [{ key: 1, value: 1 }, { key: 2, value: 2 }]
  };
  componentDidMount() {
    setTimeout(() => {
      const data = [{ key: 0, value: 0 }, { key: 2, value: 2 }]
      this.setState({
        data
      })
    }, 3000);
  }
  render() {
    const { data } = this.state;
    return (
      <> { data.map(item => <p key={item.key}>{item.value}</p>) } </> ) } } 复制代码

在前一篇文章中咱们了解到了整个更新过程(不包括渲染)就是在反复寻找工做单元并运行它们,那么具体体现到代码中是怎么样的呢?学习

while (nextUnitOfWork !== null && !shouldYield()) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
复制代码

上述代码的 while 循环只有当找不到工做单元或者应该打断的时候才会终止。找不到工做单元的状况只有当循环完全部工做单元才会触发,打断的状况是调度器触发的。动画

当更新任务开始时,root 永远是第一个工做单元,不管以前有没有被打断过工做。this

循环寻找工做单元的这个流程其实很简单,就是自顶向下再向上的一个循环。这个循环的规则以下:

  1. root 永远是第一个工做单元,无论以前有没有被打断过任务
  2. 首先判断当前节点是否存在第一个子节点,存在的话它就是下一个工做单元,并让下一个工做节点继续执行该条规则,不存在的话就跳到规则 3
  3. 判断当前节点是否存在兄弟节点。若是存在兄弟节点,就回到规则 2,不然跳到规则 4
  4. 回到父节点并判断父节点是否存在。若是存在则执行规则 3,不然跳到规则 5
  5. 当前工做单元为 null,即为完成整个循环

如下动画是例子代码的工做循环过程的一个示例:

了解了工做循环流程之后,咱们就来深刻学习一下工做单元是如何工做的。为了精简流程,咱们就直接认为当前的工做单元为 Test 组件实例。

在工做单元工做的这一阶段中实际上是分为不少分支的,由于涉及到不一样类型组件及 DOM 的处理。Testclass 组件,另外这也是最经常使用的组件类型,所以接下来的内容会着重介绍 class 组件的调和过程。

class 组件的调和过程大体分为两个部分:

  1. 生命周期函数的处理
  2. 调和子组件,也就是 diff 算法的过程

处理 class 组件生命周期函数

最早被处理的生命周期函数是 componentWillReceiveProps

可是触发这个函数的条件有两个:

  1. props 先后有差异
  2. 没有使用 getDerivedStateFromProps 或者 getSnapshotBeforeUpdate 这两个新的生命周期函数。使用其一则 componentWillReceiveProps 不会被触发

知足以上条件该函数就会被调用。所以该函数在 React 16 中已经不被建议使用。由于调和阶段是有可能会打断的,所以该函数会重复调用。

凡是在调和阶段被调用的函数基本是不被建议使用的。

接下来须要处理 getDerivedStateFromProps 函数来获取最新的 state

而后就是判断是否须要更新组件了,这一块的判断逻辑分为两块:

  1. 判断是否存在 shouldComponentUpdate 函数,存在就调用
  2. 不存在上述函数的话,就判断当前组件是否继承自 PureComponent。若是是的话,就浅比较先后的 propsstate 得出结果

若是得出结论须要更新组件的话,那么就会先调用 componentWillUpdate 函数,而后处理 componentDidUpdategetSnapshotBeforeUpdate 函数。

这里须要注意的是:调和阶段并不会调用以上两个函数,而是打上 tag 以便未来使用位运算知晓是否须要使用它们。effectTag 这个属性在整个更新的流程中都是相当重要的一员,凡是涉及到函数的延迟调用、devTool 的处理、DOM 的更新均可能会使用到它。

if (typeof instance.componentDidUpdate === 'function') {
    workInProgress.effectTag |= Update;
}
if (typeof instance.getSnapshotBeforeUpdate === 'function') {
    workInProgress.effectTag |= Snapshot;
}
复制代码

调和子组件

处理完生命周期后,就会调用 render 函数获取新的 child,用于在以后与老的 child 进行对比。

在继续学习以前咱们先来熟悉三个对象,由于它们在后续的内容中会反复出现:

  • returnFiber:父组件。
  • currentFirstChild:父组件的第一个 child。若是你还记得 fiber 的数据结构的话,应该知道每一个 fiber 都有一个 sibling 属性指向它的兄弟节点。所以知道第一个子节点就能知道全部的同级节点。
  • newChild:也就是咱们刚刚 render 出来的内容。

首先咱们会判断 newChild 的类型,知道类型就能够进行相应的 diff 策略了。它可能会是一个 Fragment 类型,也多是 objectnumber 或者 string 类型。这几个类型都会有相应的处理,但这不是咱们的重点,而且它们的处理也至关简单。

咱们的重点会放在可迭代类型上,也就是 Array 或者 Iterator 类型。这二者的核心逻辑是一致的,所以咱们就只讲对 Array 类型的处理了。

如下内容是对于 diff 算法的详解,虽然有三次 for 循环,可是本质上只是遍历了一次整个 newChild

正餐开始,第一轮遍历

第一轮遍历的核心逻辑是复用和当前节点索引一致的老节点,一旦出现不能复用的状况就跳出遍历。

那么如何复用以前的节点呢?规则以下:

  • 新旧节点都为文本节点,能够直接复用,由于文本节点不须要 key
  • 其余类型节点一概经过判断 key 是否相同来复用或建立节点(可能类型不一样但 key 相同)

如下是我简化后的第一轮遍历代码:

for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
  // 找到下一个老的子节点
  nextOldFiber = oldFiber.sibling;
  // 经过 oldFiber 和 newChildren[newIdx] 判断是否能够复用
  // 并给复用出来的节点的 return 属性赋值 returnFiber
  const newFiber = reuse(
    returnFiber,
    oldFiber,
    newChildren[newIdx]
  );
  // 不能复用,跳出
  if (newFiber === null) {
    break;
  }
}
复制代码

那么回到上文中的例子中,咱们老的第一个节点的 key 为 1,新的节点的 key 为 0。key 不相同不能复用,所以直接跳出循环,此时 newIdx 仍 为 0。

第二轮遍历

当第一轮遍历结束后,会出现两种状况:

  • newChild 已经遍历完
  • 老的节点已经遍历完了

当出现 newChild 已经遍历完的状况时只须要把全部剩余的老节点都删除便可。删除的逻辑也就是设置 effectTagDeletion,另外还有几个 fiber 节点属性须要说起下。

当出现须要在渲染阶段进行处理的节点时,会把这些节点放入父节点的 effect 链表中,好比须要被删除的节点就会把加入进链表。这个链表的做用是能够帮助咱们在渲染阶段迅速找到须要更新的节点。

当出现老的节点已经遍历完了的状况时,就会开始第二轮遍历。这轮遍历的逻辑很简单,只须要把剩余新的节点所有建立完毕便可。

这轮遍历在咱们的例子中是不会执行的,由于咱们以上两种状况都不符合。

第三轮遍历

第三轮遍历的核心逻辑是找出能够复用的老节点并移动位置,不能复用的话就只能建立一个新的了。

那么问题又再次回到了如何复用节点并移动位置上。首先咱们会把全部剩余的老节点都丢到一个 map 中。

咱们例子中的代码剩余的老节点为:

<p key={1}>1</p>
<p key={2}>2</p>
复制代码

那么这个 map 的结构就会是这样的:

// 节点的 key 做为 map 的 key
// 若是节点不存在 key,那么 index 为 key
const map = {
    1: {},
    2: {}
}
复制代码

在遍历的过程当中会寻找新的节点的 key 是否存在于这个 map 中,存在便可复用,不存在就只能建立一个新的了。其实这部分的复用及建立的逻辑和第一轮中的是如出一辙的,因此也就再也不赘述了。

那么若是复用成功,就应该把复用的 keymap 中删掉,而且给复用的节点移动位置。这里的移动依旧不涉及 DOM 操做,而是给 effectTag 赋值为 Placement

此轮遍历结束后,就把还存在于 map 中的全部老节点删除。

小结

以上就是 diff 子节点的所有逻辑,对比 React 15 的 diff 策略而言我的认为代码好懂了许多。

最后

阅读源码是一个很枯燥的过程,可是收益也是巨大的。若是你在阅读的过程当中有任何的问题,都欢迎你在评论区与我交流。

另外写这系列是个很耗时的工程,须要维护代码注释,还得把文章写得尽可能让读者看懂,最后还得配上画图,若是你以为文章看着还行,就请不要吝啬你的点赞。

最后,以为内容有帮助能够加群一同交流与学习。

相关文章
相关标签/搜索