简单易懂的diff算法浅析

378 阅读12分钟

本篇文章将通过通俗易懂的语言,简单介绍流行前端框架 react、vue 的 diff 算法,以及它们之间的区别,相信对前端小伙伴们会有一定的帮助。

虚拟节点是什么

一个简单的 JavaScript 对象,前端框架根据它来渲染一个真实的 dom 元素。

优点:

  1. 避免直接操作 dom 的复杂代价,提高性能。
  2. 可以解决跨平台问题。

vue 的diff 算法做了啥

当视图中的元素需要更新时:

  1. 不使用框架的步骤是:直接操作修改 dom 元素。
  2. 在 vue 中,当视图的数据发生改变时,对应的视图也需要发生改变,没有优化的做法是:第一步,根据数据生成新的虚拟节点,第二步,将旧虚拟节点对应的真实 dom 元素销毁,第三步,根据新的虚拟节点生成真实 dom 放在原来的位置。

实际上,大部分虚拟节点都是这样更新的,而 diff 算法只是优化了其中一个特殊的小小情况:列表的更新。举个例子,列表的顺序是 ABC,现在要变成 CAB,没有 diff 算法,框架会先把 A 销毁,然后创建一个 C 放在 A 的位置上,接着把 B 销毁,并创建 A 放上,最后把 C 销毁,把 B 放上去。有了 diff 算法,框架就会把 C 移动到 A 前面,节省了很多创建销毁的操作。diff 算法提高了节点的复用。

那么,react 也有 diff 算法,它的算法是什么样子的呢?

react 的 diff 算法做了啥

在 react 中,旧版本 react 提供递归创建虚拟节点的方法,比如我们给了某个参数,它就会创建对应的虚拟节点以及虚拟节点的子节点。因为虚拟节点中只包含 children 属性(使用自上而下的递归造成的),所以创建时只能全部创建,不允许被中断(后面会说明)。这意味着可能长时间占用线程,会遇到长任务经常要面对的问题:页面卡顿。

避免长任务的办法就是把长任务分割成多个短任务。新版 react (16+)采用 fiber 架构,简单地给虚拟节点添加了更多的指针,比如 return 、sibing。让虚拟节点可以指向更多的节点。同时,虚拟节点的组织结构也从原来的树状,变成了 fiber 结构的链状(其实树本身就是包含多个 next 节点的链表),但为了方便,在后面我仍称 fiber 为树。

当我们创建节点时,需要执行其它任务,我们可以保存当前正在创建但是还没有完成的节点,退出创建,执行其它任务,回来以后,再从这个节点继续创建。因为我们可以找到节点的父节点、兄弟节点,所以我们知道节点在虚拟节点树的哪个位置,所以能够继续下去。如果只有 children 指针,我们回来后找到节点,但是我们不知道这个节点在节点树的位置,创建就无法继续。或许你会疑惑,为什么不去遍历已完成的虚拟节点树所有节点的 children ,这样就可以找到当前节点的位置了?这是因为,虽然这个没有创建完成的树确实存在内存中,但我们是找不到的,因为只有在创建完成之后,才会有指针指向它。

react 的更新包括 render 阶段和 commit 阶段,render 阶段就是生成 fiber 树,前面我们已经了解过这个过程是可以被中断的,commit 阶段就是将 fiber 转为真实 dom,该阶段被设定成不可中断。

react 的 diff 算法的发生时机:当组件的 props 或者 state 发生改变(或其他条件)导致视图重新渲染时,react 会把 render 函数的返回结果与 fiber 树进行对比,这时 diff 算法会把差异记录在新的 fiber 树上,在 commit 阶段就可以根据这些差异有选择地渲染真实 dom。

vue、react 算法对比

vue 在视图发生改变时,会渲染新的 Vnode tree,新旧 Vnode 在比对(patch)的同时,把差异直接修改在 dom 上,最后用新的 Vnode 覆盖旧的 Vnode,diff 算法只体现在 patch 列表的情况中。

react 在视图发生改变时,使用 diff 算法进行比对,然后生成新的 fiber ,最后 commit 渲染。

vue 和 react 的 diff 算法最大的区别就是,前者只是 patch 阶段的一个优化算法,后者是更新时的主要判断算法,范围广一些。

vue 的 patch 流程浅析

大致的过程在上面已经分析过了,下面是一些细节的补充:

  1. 通知更新:数据更新时,会触发 setter,setter 会调用 Dep. notify ()通知所有的 watcher 进行更新(watcher 是视图与数据之间的桥梁)。
  2. 开始 patch:在过程更新中,render 函数会返回新的 Vnode。然后调用 path(oldVnode, newVnode)进行真实 dom 的更新。
  3. 判断是否有复用的可能:在对比新旧 Vnode 时,通过二者的 key、tagName 等属性判断是否有复用的必要,如果没必要,则直接替换掉。
  4. 复用可能:如果地址相同或者都没有子节点和文本,结束 patch。否则,进行最后的比对更新。

补充理解:

  1. patch 意味补丁,vue 的视图更新就是一个一边对比一边打补丁(更新真实 dom)的过程。
  2. vue 的虚拟 dom 的 el 属性指向的是对应的真实 dom。
  3. render 函数在返回没有改变的 Vnode 时,返回的是 oldVnode 的地址。

比对更新:

  1. 在 vue 中,如果节点没有子节点,那么节点要么还有文本,要么什么都没有。假设新旧节点都没有子节点,那么只剩三种情况,新增文本,删去文本和更新文本,接着操作真实 dom。
  2. 如果旧节点有子节点,新节点没有,找到旧节点的 el 属性,删去这个 dom 元素的 children。
  3. 如果旧节点没有子节点,新节点有,找到旧节点的 el 属性,给这个 dom 添加新节点的 children 虚拟节点对应的 el 属性。
  4. 如果二者都有节点,但没有 key,将两个子数组(假设 oldVnode. childrens 和 newVnode. childrens)相同 index 的元素两两进行 patch。
  5. 如果二者都有子节点,且存在 key,上 diff 算法!

react 的 diff 算法浅析

在了解 diff 算法前,我们不妨先看看 react 是怎么做的,了解之后,你会发现其实 vue 的 diff 算法也是类似的。react 算法分两步走。

key

如何判断两个虚拟节点是不是同一个节点,我们可以用三种方法来判断:

  1. 全量判断:通过判断两个对象的大部分属性来确定。消耗高。
  2. 判断对象的地址。不准确,只有没有变化的节点才会使用同一个地址,而变化了的节点的地址是不一样的。
  3. 给对象增加一个特殊属性,作为这个对象的标识。在 css 中,我们经常给元素新增一个额外的属性 id,不管它的地址怎么变、外观怎么变,我们总是能够通过选择器去找到它。在 JavaScript 框架中,这个属性称为 key。

第一轮遍历:使用首指针

  1. 给定两个数组,一个是 oldVnode 数组,一个是 newVnode 数组,使用一个 index 指针从 0 开始遍历它们。
  2. 对相同索引的节点进行比对,如果 key 相同,则判断两者的变化方式(新增、修改、删除),然后把差异记录在新的 fiber 树中。
  3. 如果两个节点不相同,遍历结束。
  4. 这时有三种情况:
    1. oldVnode 数组结束,newVnode 数组未结束,新增元素即可(把差异记录在新的 fiber 树中)。
    2. oldVnode 数组未结束,newVnode 数组结束,删除元素即可(把差异记录在新的 fiber 树中)。
    3. olcVnode 数组未结束,newVnode 数组也未结束,进行第二轮遍历。

第二轮遍历:使用哈希表

  1. 将 oldVnode 数组中还未被遍历的节点放入哈希表中。
  2. 继续遍历 newVnode 数组,读取 newVnode 的 key 并查表,如果存在,把差异记录在新的 fiber 树中(修改)。如果不存在,也是把差异记录在 fiber 中(新增)。
  3. 直到 newVnode 遍历完毕,如果哈希表中还有未遍历的数据,也把差异记录在新的 fiber 树中(删除)。

lastIndex

在遍历哈希表时,只是说明要标记移动元素,但是,我们如何去移动它,也是要区别的。

假设我们有三个节点 1234,更新后变为 1324,我们只需将 2 往后移。通过 lastIndex 保存的是我们上一个处理的 oldVnode 的 index(数组的 index)比如 3,然后读取当前匹配的 oldVnode 的 index 比较,我们知道,在 oldVnode 中 2 在 3 之前,2 需要后移,才能到达正确位置。

讨论

  1. 为什么要有第一轮遍历,可以直接使用哈希表吗?可以使用直接使用哈希表。第一轮遍历在节点的位置都没有变化的情况下可以更快(不需要使用第二轮遍历,且这种数组遍历比哈希表快),所以加上它并不是什么坏事,算是在经验基础上的优化。
  2. 这种算法存在不足,就是当我们把最后一个元素移动到前面时,第一轮遍历马上结束,而第二轮遍历会给所有节点打上修改的标注,性能不佳。因此,在编写 react 组件时,尽量避免节点前移(比如每次都把新内容添加到最前面)。

vue 的 diff 算法浅析

diff 算法的目的在于遍历含 key 的两个子节点数组。它和 react 一样,包含了两个阶段,但在第一阶段,它新增了一个尾指针,来避免我们在上面讨论中提到的算法的不足。

vue2:双端diff

基本流程:

  1. 读取到两个新旧节点数组,并分别给它们各自分配一对首位指针。
  2. 首尾指针之间相互对比,比较 key 是否相同。哪个和哪个指针比对的顺序并不重要,重要的是存在着四次比对:old 首指针与 new 首指针,old 首指针与 new 尾指针,old 尾指针与 new 首指针,old 尾指针与 new 尾指针。
  3. 指针的移动:当比对成功时,指针会向中间移动,当某对指针交叉而过(在重叠之后),遍历结束。
  4. 如果遍历结束是因为指针导致的,那么可以判断,如果是新节点结束,旧节点指针未结束,说明需要删除多余旧节点,如果是旧节点指针结束,新节点指针未结束,说明需要新增新节点。
  5. 但在一些情况下遍历结束,是因为四次比对都没有找到相同的 key,这时,就会进行类似 react 的第二轮遍历。

下面重点介绍四个节点的比对和移动中要注意的地方:

  1. 如果两个首指针一直匹配成功,那么节点是不需要移动的。(两个尾指针也同理)
  2. 如果两个首指针没有匹配成功,而首指针和尾指针匹配成功,它们的位置是不同的,需要移动元素。例如,new 首指针和 old 尾指针匹配了,说明 old 指针所在元素的 el 指向的真实 dom 应该移动到 new 首指针的位置,具体就是首指针在 newVnode 数组的前一个位置的 sibling 属性上。
  3. 第二步中,首尾指针匹配成功,它们各自向中间靠拢。这就导致了一种情况,两个首指针的位置也是不一样的(在 react 中,两个首指针位置从始至终都是同步的),因此,在两个首指针再次匹配时,仍需要移动元素(与第一步不同)。

vue3:快速diff

vue3 没有采用 vue2 的首尾指针法,而是采用了更快更简单的算法。它只是在 react 的基础上多加了一对同步的尾指针,来解决 react 算法中的不足。在双端 diff 算法中,我们可以明显感觉到,两个匹配的指针,在大多数情况下,往往需要去移动真实 dom。在指针同步后,位置相同,就不需要移动了,接着,在第二阶段中,又因为引入了求解最长递增子序列,也在一定程度上减少 dom 的移动,提高了 diff 的速度。

基本流程:

  1. 首指针共同前进,匹配相同 key。
  2. 如果不匹配,改用尾指针共同后退,匹配相同 key。
  3. 如果都不匹配,进入第二阶段。

第二阶段,如果双方都有余,这时只有求解出剩余两个数组中最长递增子序列,这部分序列不需要移动,然后移动不在序列中的元素即可。这部分求解算法篇幅较长,后续将在新文章上介绍。

总结

在这篇文章中,我们了解了什么是虚拟 dom,以及在虚拟 dom 渲染成真实 dom 的过程中 diff 算法起到的作用。

我们了解到 react 的 diff 算法的执行流程,并学习了在此基础上修改的 vue 的 diff 算法。

参考

聊聊 Vue 的双端 diff 算法 - 掘金

一文吃透 React 和 Vue 的多节点 diff 原理 - 掘金