【Vue2】 diff算法原理剖析,看完你就会了

1,349 阅读7分钟

附上之前看文章写过的一段话:已经有很多讲解diff算法原理及实现的文章,为什么自己还要写类似的呢,OK,细心读哦,你会发现不同之处。学习就好比是座大山,人们沿着不同的路登山,分享着自己看到的风景。你不一定能看到别人看到的风景,体会到别人的心情。只有自己去登山,才能看到不一样的风景,体会才更加深刻。

Vue 的 diff 算法是基于 snabbdom 改造过来的,下面我们会基于它进行论述。

一、diff算法作用

社会在进步,人类在发展,它出来是要解决问题的,那解决了啥问题嘞,用没有花里胡哨的简洁语言解释道: 使用了虚拟DOM,其实就是把真实DOM数据化,我们操作数据,最终映射成真实DOM,展示在页面上, 提升了性能,哦?怎么提升性能的,下面我们举简单例子来验证下。

Untitled.png

上面代码运行效果如下:由此可见,操作js数据要比频繁操作DOM耗时少很多。

QQ20220510-101420.gif

二、Snabbdom

为什么要学习它呢?开篇我们提到 Vue 的 diff 算法是基于它实现的,而 Snabbdom 极其简单、高效并且可拓展,同时核心代码 ≈ 200 行,所有非必要的功能都将模块化引入。

下面我们使用 webpack 简单的搭了一个环境,然后安装下它的依赖,来学习它的实现原理。

官网给了我们一个例子,我们也是全部复制放到我们的 src/index.js 中,别忘了在 public/index.html 的页面中放置 id=container 的节点元素,然后控制台就嘎嘎报错了,好,不慌哈,我们把 someFnanotherEventHandler这俩 click 事件都改成 () => {}。当我们看到如下效果的话,说明已经没问题了。

image.png

下面我们把代码贴出来,然后我们解释下几个核心API:

import {
  init,
  classModule,
  propsModule,
  styleModule,
  eventListenersModule,
  h,
} from "snabbdom";

const patch = init([
  // Init patch function with chosen modules
  classModule, // makes it easy to toggle classes
  propsModule, // for setting properties on DOM elements
  styleModule, // handles styling on elements with support for animations
  eventListenersModule, // attaches event listeners
]);

const container = document.getElementById("container");

const vnode = h("div#container.two.classes", { on: { click: () => {} } }, [
  h("span", { style: { fontWeight: "bold" } }, "This is bold"),
  " and this is just normal text",
  h("a", { props: { href: "/foo" } }, "I'll take you places!"),
]);
// Patch into empty DOM element – this modifies the DOM as a side effect
patch(container, vnode);

const newVnode = h(
  "div#container.two.classes",
  { on: { click: () =>{} } },
  [
    h(
      "span",
      { style: { fontWeight: "normal", fontStyle: "italic" } },
      "This is now italic type"
    ),
    " and this is still just normal text",
    h("a", { props: { href: "/bar" } }, "I'll take you places!"),
  ]
);
// Second `patch` invocation
patch(vnode, newVnode); // Snabbdom efficiently updates the old view to the new state

  1. init 函数:它接收一个包含模块的数组并返回一个具有指定功能的 patch 函数,patch 函数有啥能力都取决于我们在数组里面传了哪些 Module,它支持哪些 Module 呢,又代表什么意思呢?下面我们列下:

    • classModule:设置 类选择器
      h("a", { class: { active: true, selected: false } }, "Toggle");
      // <a class="active">Toggle</a>
      
    • propsModule:它允许我们可以设置 DOM 元素的属性
      h("a", { props: { href: "/foo" } }, "Go to Foo");
      // <a href="/foo">Go to Foo</a>
      
    • attributesModule:与 props 相同,但是是使用 attr 替代 prop
      h("a", { attrs: { href: "/foo" } }, "Go to Foo");
      // <a href="/foo">Go to Foo</a>
      

      attrsprops 有啥区别呢?

      • 对于HTML元素本身就带有的固有属性,在处理时,使用 prop方法
      • 对于HTML元素我们自己自定义的DOM属性,在处理时,使用 attr 方法
    • datasetModule:允许我们在 DOM 元素上设置自定义 data 属性
      h("button", { dataset: { action: "reset" } }, "Reset");
      // <button data-action="reset">Reset</button>
      
    • styleModule:允许我们在 DOM 元素上设置行内样式
      h(
       "span",
       {
         style: {
           border: "1px solid #bada55",
           color: "#c0ffee",
           fontWeight: "bold",
         },
       },
       "Say my name, and every colour illuminates"
      );
      
    • eventListenersModule:允许我们在 DOM 元素上绑定事件监听器
  2. patch函数:比较新旧 Virtual DOM 树并更新,它接受两个参数,第一个是:一个 DOM 元素或者 一个表示当前视图的 vnode,第二个是:新的、需要更新的 vnode

    如果第一个参数传入一个包含父节点的 DOM 元素,那么新的 vnode 将转换为一个 DOM 节点并替换传入的元素。如果第一个参数传入的是一个 vnode 则根据新的 vnode 相关描述进行修改。

  3. h 函数:它是用来创建 vnodes,接收一个字符串类型的 标签或选择器、一个数据对象(可选)、一个子节点数组或字符串(可选)。在上面例子中已经体现了。

    h("div", { style: { color: "#000" } }, [
     h("h1", "Headline"),
     h("p", "A paragraph"),
    ]);
    // <div style="color: #000">
    //  <h1>Headline</h1>
    //  <p>A paragraph</p>
    // </div>
    

    ok,我们看下 h 函数返回的 vnodes 结构是什么样子的,每个属性代表的含义可以在文档上找的到:

    image.png

三、新老节点替换规则

  1. 如果新老节点不是同一个节点名称,那么就暴力删除旧节点,创建插入新的节点。验证的话也比较简单。

    Untitled (1).png

    上面代码运行效果如下:旧节点是 h1 ,按钮的点击事件把节点变成了 div

    QQ20220513-110221-HD.gif

  2. 不能跨层比较,只能同级比较。比如下面这两个DOM节点,虽然是同一片虚拟节点,但是跨层了,依旧会暴力删除旧的、插入新的。

    const vnode1 = h("ul", {}, [
     h("li", {}, "a"),
     h("li", {}, "b"),
     h("li", {}, "c"),
    ]);
    
    const vnode2 = h("ul", {}, [
     h('div', {}, [
       h("li", {}, "a"),
       h("li", {}, "b"),
       h("li", {}, "c"),
     ])
    ]);
    
  3. 写到这里发现规则比较多啊😭,所以在这里画了一张流程图。

    image.png

    上面流程图对应的代码逻辑如下:init 函数最终的返回的值是 patch 方法,如果是相同节点,在 patch 方法里调用的 patchVnode方法,在它里面,如果新老节点都有 children 且不同,则调用 updateChildren 做 diff 比较。 iShot_2022-05-16_14.03.07.png

四、updateChildren 核心 diff 逻辑

image.png

updateChildren() 是整个 Virtual DOM 的核心,内部使用 diff 算法,对比新旧节点的 children,更新DOM。上面流程图中没画,在这咱们细说下。

对比步骤,五步走

  1. 前前比。首先对比 oldStartVnodenewStartVnodesameVnode(相同节点:key 和 sel和data.is三者相同)
    • 如果相同就调用 patchVnode 对比和更新节点, 将索引后移:oldStartIdx++ / newStartIdx++,继续对比下一组。
    • 不同 则进行步骤二
  2. 后后比。 对比 oldEndVnodenewEndVnodesameVnode
    • 如果相同,就调用 patchVnode 对比和更新节点,将索引前移:oldEndIdx-- / newEndIdx-–,继续对比下一组。
    • 不同 则进行步骤三
  3. 前后比。对比 oldStartVnodenewEndVnodesameVnode
    • 如果相同,先调用 patchVnode 对比和更新节点,然后调用 parentNode.insertBeforeoldStartVnode.elm 移动到 oldEndVnode 表示的 DOM元素(elm) 后面,也就是 oldEndVnode 后面,这里使用了 oldEndVnode.elm.nextSibling 表示它的后面。最后更新索引:oldStartIdx++ / newEndIdx–
    • 不同 则进行步骤四
  4. 后前比。对比 oldEndVnodenewStartVnodesameVnode
    • 如果相同,先调用 patchVnode 对比和更新节点,然后调用 parentNode.insertBeforeoldEndVnode.elm 移动到 oldStartVnode.elm 前面,最后更新索引:oldEndIdx-- / newStartIdx++
  5. 它包含以下几个动作。
  • 获取 oldStartVnodeoldEndVnode 之间 所有包含 key 属性的节点的 key 和索引位置,存储在 oldKeyToIdx 对象中。 结构长这样 {[key]: i(介于beginIdx~endIdx)}
  • 使用 newStartVnodekey 在第一步得到的对象 oldKeyToIdx 中查找相同的 key
    • 如果没找到,说明 newStartVnode 是新节点,调用 parentNode.insertBefore,将新建的DOM,插入到 oldStartVnode.elm 前面
    • 如果找到了,获取这个节点并存储到 elmToMove,然后判断 elmToMovenewStartVnodesel 是否相同
      • 如果不同,说明是不同的节点,按照新增节点处理,会调用 parentNode.insertBefore,将新建的 DOM 插入到 oldStartVnode.elm 前面
      • 如果相同,表示是 sameVnode,会调用 patchVnode 对比和更新节点,然后将原位置的节点设为 undefined(防止被遍历到时再参与对比,也是为了占位,保证索引不会被影响),最后会调用 parentNode.insertBefore,将新建的 DOM 插入到 oldStartVnode.elm 前面
  • 更新索引:newStartIdx++,同时也会更新 newStartVnodenewCh[++newStartIdx]

批量删除或新增剩余节点

遍历对比完 oldVnode,最后批量判断处理

  • newStartIdx <= newEndIdx:旧节点数组先遍历完成,说明 newStartVnode - newEndVnode 是未参与遍历的新节点,调用 addVnodes 将它们插入 索引位置 newEndIdx +1 的 dom 元素前。 image.png

  • oldStartIdx <= oldEndIdx:新节点数组先遍历完成,说明 oldStartVnode - oldEndVnode 是未参与遍历的旧节点,调用 removeVnodes 将它们 删除掉。

    image.png

五、key 为什么可以提升性能

从咱们以上分析知道,给 VNode 设置 key 之后,当操作dom时,会重用上一次对应的 DOM 对象,减少渲染次数,因此会提高性能。