附上之前看文章写过的一段话:已经有很多讲解
diff算法原理及实现的文章,为什么自己还要写类似的呢,OK,细心读哦,你会发现不同之处。学习就好比是座大山,人们沿着不同的路登山,分享着自己看到的风景。你不一定能看到别人看到的风景,体会到别人的心情。只有自己去登山,才能看到不一样的风景,体会才更加深刻。
Vue 的 diff 算法是基于 snabbdom 改造过来的,下面我们会基于它进行论述。
一、diff算法作用
社会在进步,人类在发展,它出来是要解决问题的,那解决了啥问题嘞,用没有花里胡哨的简洁语言解释道: 使用了虚拟DOM,其实就是把真实DOM数据化,我们操作数据,最终映射成真实DOM,展示在页面上, 提升了性能,哦?怎么提升性能的,下面我们举简单例子来验证下。
上面代码运行效果如下:由此可见,操作js数据要比频繁操作DOM耗时少很多。
二、Snabbdom
为什么要学习它呢?开篇我们提到 Vue 的 diff 算法是基于它实现的,而 Snabbdom 极其简单、高效并且可拓展,同时核心代码 ≈ 200 行,所有非必要的功能都将模块化引入。
下面我们使用 webpack 简单的搭了一个环境,然后安装下它的依赖,来学习它的实现原理。
官网给了我们一个例子,我们也是全部复制放到我们的 src/index.js 中,别忘了在 public/index.html 的页面中放置 id=container 的节点元素,然后控制台就嘎嘎报错了,好,不慌哈,我们把 someFn 和 anotherEventHandler这俩 click 事件都改成 () => {}。当我们看到如下效果的话,说明已经没问题了。
下面我们把代码贴出来,然后我们解释下几个核心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
-
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 替代 proph("a", { attrs: { href: "/foo" } }, "Go to Foo"); // <a href="/foo">Go to Foo</a>attrs和props有啥区别呢?- 对于HTML元素本身就带有的固有属性,在处理时,使用
prop方法 - 对于HTML元素我们自己自定义的DOM属性,在处理时,使用
attr方法
- 对于HTML元素本身就带有的固有属性,在处理时,使用
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 元素上绑定事件监听器
-
patch函数:比较新旧 Virtual DOM 树并更新,它接受两个参数,第一个是:一个 DOM 元素或者 一个表示当前视图的vnode,第二个是:新的、需要更新的vnode。如果第一个参数传入一个包含父节点的 DOM 元素,那么新的 vnode 将转换为一个 DOM 节点并替换传入的元素。如果第一个参数传入的是一个
vnode则根据新的vnode相关描述进行修改。 -
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结构是什么样子的,每个属性代表的含义可以在文档上找的到:
三、新老节点替换规则
-
如果新老节点不是同一个节点名称,那么就暴力删除旧节点,创建插入新的节点。验证的话也比较简单。
上面代码运行效果如下:旧节点是
h1,按钮的点击事件把节点变成了div -
不能跨层比较,只能同级比较。比如下面这两个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"), ]) ]); -
写到这里发现规则比较多啊😭,所以在这里画了一张流程图。
上面流程图对应的代码逻辑如下:
init函数最终的返回的值是patch方法,如果是相同节点,在patch方法里调用的patchVnode方法,在它里面,如果新老节点都有children且不同,则调用updateChildren做 diff 比较。
四、updateChildren 核心 diff 逻辑
updateChildren() 是整个 Virtual DOM 的核心,内部使用 diff 算法,对比新旧节点的 children,更新DOM。上面流程图中没画,在这咱们细说下。
对比步骤,五步走
- 前前比。首先对比
oldStartVnode和newStartVnode是sameVnode(相同节点:key 和 sel和data.is三者相同)- 如果相同就调用
patchVnode对比和更新节点, 将索引后移:oldStartIdx++ / newStartIdx++,继续对比下一组。 - 不同 则进行步骤二
- 如果相同就调用
- 后后比。 对比
oldEndVnode和newEndVnode是sameVnode- 如果相同,就调用
patchVnode对比和更新节点,将索引前移:oldEndIdx-- / newEndIdx-–,继续对比下一组。 - 不同 则进行步骤三
- 如果相同,就调用
- 前后比。对比
oldStartVnode和newEndVnode是sameVnode- 如果相同,先调用
patchVnode对比和更新节点,然后调用parentNode.insertBefore将oldStartVnode.elm移动到oldEndVnode表示的 DOM元素(elm) 后面,也就是oldEndVnode后面,这里使用了oldEndVnode.elm.nextSibling表示它的后面。最后更新索引:oldStartIdx++ / newEndIdx– - 不同 则进行步骤四
- 如果相同,先调用
- 后前比。对比
oldEndVnode和newStartVnode是sameVnode- 如果相同,先调用
patchVnode对比和更新节点,然后调用parentNode.insertBefore将oldEndVnode.elm移动到oldStartVnode.elm前面,最后更新索引:oldEndIdx-- / newStartIdx++
- 如果相同,先调用
- 它包含以下几个动作。
- 获取
oldStartVnode和oldEndVnode之间 所有包含key属性的节点的key和索引位置,存储在oldKeyToIdx对象中。 结构长这样{[key]: i(介于beginIdx~endIdx)}。 - 使用
newStartVnode的key在第一步得到的对象oldKeyToIdx中查找相同的key- 如果没找到,说明
newStartVnode是新节点,调用parentNode.insertBefore,将新建的DOM,插入到oldStartVnode.elm前面 - 如果找到了,获取这个节点并存储到
elmToMove,然后判断elmToMove和newStartVnode的sel是否相同- 如果不同,说明是不同的节点,按照新增节点处理,会调用
parentNode.insertBefore,将新建的 DOM 插入到oldStartVnode.elm前面 - 如果相同,表示是
sameVnode,会调用patchVnode对比和更新节点,然后将原位置的节点设为undefined(防止被遍历到时再参与对比,也是为了占位,保证索引不会被影响),最后会调用parentNode.insertBefore,将新建的 DOM 插入到oldStartVnode.elm前面
- 如果不同,说明是不同的节点,按照新增节点处理,会调用
- 如果没找到,说明
- 更新索引:newStartIdx++,同时也会更新
newStartVnode为newCh[++newStartIdx]
批量删除或新增剩余节点
遍历对比完 oldVnode,最后批量判断处理
-
newStartIdx <= newEndIdx:旧节点数组先遍历完成,说明newStartVnode - newEndVnode是未参与遍历的新节点,调用addVnodes将它们插入 索引位置newEndIdx +1的 dom 元素前。 -
oldStartIdx <= oldEndIdx:新节点数组先遍历完成,说明oldStartVnode - oldEndVnode是未参与遍历的旧节点,调用removeVnodes将它们 删除掉。
五、key 为什么可以提升性能
从咱们以上分析知道,给 VNode 设置 key 之后,当操作dom时,会重用上一次对应的 DOM 对象,减少渲染次数,因此会提高性能。