面试中的网红虚拟DOM,你知多少呢?深入解读diff算法
Posted 星期一研究室
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了面试中的网红虚拟DOM,你知多少呢?深入解读diff算法相关的知识,希望对你有一定的参考价值。
深入浅出虚拟DOM和diff算法
众所周知,在前端的面试中,面试官非常爱考vdom和diff算法。比如,可能会出现在以下场景🤏
滴滴滴,面试官发来一个面试邀请。接受邀请📞
🧑面试官:你知道 key
的作用吗?
🙎我:key
的作用是保证数据的唯一性。
🧑面试官:怎么保证数据的唯一性?
🙎我:就…
🧑面试官:你知道虚拟dom吗?
🙎我:虚拟dom就是……balabala
🧑面试官:(好像有点道理)那你知道diff算法吗?
🙎我:(心里:what……diff算法是什么??)
🧑面试官:本次面试结束,回去等面试结果通知。
🙋🙋🙋
我们都知道, key
的作用在前端的面试是一道很普遍的题目,但是呢,很多时候我们都只浮于知识的表面,而没有去深挖其原理所在,这个时候我们的竞争力就在这被拉下了。所以呢,深入学习原理对于提升自身的核心竞争力是一个必不可少的过程。
在接下来的这篇文章中,我们将讲解面试中很爱考的虚拟DOM以及其背后的diff算法。
一、虚拟DOM(Vitual DOM)
1、虚拟DOM(Vitual DOM)和diff的关系
我们都知道 DOM
操作是非常耗费性能的,早期我们用 JQuery
来自行控制 DOM
操作的时机,也就是手动调整,这样子其实也不是特别方便。因此就出现了虚拟 DOM
,即 Vitual DOM
(下文简称为 vdom
),来解决 DOM
操作的问题。 vdom
是现如今的一个热门话题,也是面试中的热门话题,基本上在前端的面试中都会问到 虚拟DOM
的问题。
而为什么会问到 vdom
的问题呢,原因在于现在流行的 vue
和 react
框架,都是数据驱动视图,并且是基于 vdom
实现的,可以说 vdom
是实现 vue
和 react
的重要基石。
谈到 vdom
,我们不明觉厉的还会想到 diff算法 。那 diff算法 和 vdom 是什么关系呢?
其实, vdom
是一个大的概念,而 diff算法
是 vdom
的一部分, vdom
的核心价值在于最大程度的减少DOM的使用范围, vdom
通过把 DOM
用JS的方式进行模拟,之后进行计算和对比,最后找出最小的更新范围去更新。那么这个对比的过程就是 diff 算法 。也就是说他们两者是包含关系,如下图所示:
可以说,diff 算法是 vdom
中最核心、最关键的部分,整个 vdom
的核心包围着大量的 diff算法 。
有了这几个概念的基础铺垫,接下来我们来开始了解 虚拟DOM 是什么。
2、真实DOM的渲染过程
在开始讲解 虚拟DOM
之前,我们先来了解真实的 DOM
在浏览器中是怎么解析的。浏览器渲染引擎工作流程大致分为以下4个步骤:
创建DOM树 → 创建CSSOM树 → 生成render树 → 布局render树 → 绘制render树 。
- 第一步:创建
DOM
树。渲染引擎首先解析html
代码,并生成DOM
树。 - 第二步:创建
CSSOM
树。浏览为获得外部css
文件的数据后,就会像构建DOM
树一样开始构建CSSOM
树,这个过程与第一步没什么差别。 - 第三步:生成
Render
树。将DOM
树和CSSOM
树关联起来,生成一棵Render
(渲染)树。 - 第四步:布局
Render
树。有了Render
树之后,浏览器开始对渲染树的每个节点进行布局处理,确定其在屏幕上的显示位置。 - 第五步:绘制
Render
树。将每个节点绘制到屏幕上。
引用网上的一张图来呈现真实DOM的渲染过程:
3、虚拟DOM是什么?
当用原生 js
或者 jq
去操作真实 DOM
的时候,浏览器会从构建DOM树开始,从头到尾执行一遍流程。那这样的话,就很有可能导致操作次数过多。当操作次数过多时,之前计算的与 DOM
节点相关的坐标值等各种值就…不知不觉的浪费掉了其性能,因此呢,虚拟DOM由此产生。
4、解决方案 - vdom
(1)问题引出
大家都知道, DOM
树是具有一定的复杂度的,所以,在生成 DOM
树的过程中,会不断的进行计算操作,但难就难在,想要减少计算次数其实还是比较难的。
那换个思路考虑,我们都知道,JS
的执行速度很快很快,那能不能尝试着把这个计算,更多的转为JS计算呢?答案是肯定的。
(2)vdom如何解决问题:将真实DOM转为JS对象的计算
假设在一次操作中有1000个节点需要更新 DOM
,那么 虚拟DOM 不会立即去 操作DOM ,而是将这1000次更新的 diff
内容保存到本地的一个 JS
对象当中,之后将这个 JS
对象一次性 attach 到 DOM
树上,最后再进行后续的操作,这样子就避免了大量没有必要的计算。
所以,用JS对象模拟DOM节点的好处是,先将页面的更新全部反映到虚拟 DOM
上,这样子就先**操作内存中的JS对象**。值得注意的是,操作内存中 JS
对象的速度是相当快的。因此,等到全部 DOM节点 更新完成之后,再将 最后的JS对象 映射到 真实的DOM 上,交由 浏览器 去绘制。
这样,就解决了真实 DOM
渲染速度慢,性能消耗大的问题。
5、用JS模拟一个DOM结构
根据下方的 html
代码,用 v-node
模拟出该 html
代码的 DOM
结构。
html代码:
<div id="div1" class="container">
<p>
vdom
</p>
<ul style="font-size:20px;">
<li>a</li>
</ul>
</div>
用JS模拟出以上代码的DOM结构:
{
tag: 'div',
props:{
className: 'container',
id: 'div1'
},
children: [
{
tag: 'p',
chindren: 'vdom'
},
{
tag: 'ul',
props:{ style: 'font-size: 20px' },
children: [
{
tag: 'li',
children: 'a'
}
// ....
]
}
]
}
通过以上代码我们可以分析出,我们用 tag
, props
和 children
来模拟 DOM
树结构。用 JS
模拟 DOM
树的结构,这样做的好处在于,可以计算出最小的变更,操作最少的DOM。
6、通过snabbdom学习vdom
vue
的 vdom 和 diff算法 是参考 github
上的一个开源库 snabbdom 改造过来的,那么我们接下来就用这个库为例,来学习 vdom
的思想。
(1)snabbdom是什么
snabbdom
是一个简洁又强大的vdom
库,易学易用;Vue
参考它实现的vdom
和diff
;Vue3.0
重写了vdom
的代码,优化了性能。
(2)snabbdom浅析
我们先来看 snabbdom
首页上的 example
,先简单了解其思想。下面先贴上代码:
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");
//h函数输入一个标签,之后再输入一个data,最周输入一个子元素
const vnode = h("div#container.two.classes", { on: { click: someFn } }, [
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函数
// 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: anotherEventHandler } },
[
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!"),
]
);
//第二个patch函数
// Second `patch` invocation
patch(vnode, newVnode); // Snabbdom efficiently updates the old view to the new state
通过官方的例子我们可以知道, h
函数输入一个标签,之后输入一个 data
,最后输入一个子元素。并且h函数是一个 vnode
的结构( vnode
结构见上述第5点),层级般的一层一层递进。最后就是 patch
函数,第一个patch
函数用来对元素进行渲染,第二个 patch
函数用来比较新旧节点。
(2)snabbdom演示
接下来我们用 cdn
的方式引入 snabbdom
的库,来演示一遍 snabbdom
是如何操作 vdom
的。附上代码:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Document</title>
</head>
<body>
<div id="container"></div>
<button id="btn-change">change</button>
<script src="https://cdn.bootcss.com/snabbdom/0.7.3/snabbdom.js"></script>
<script src="https://cdn.bootcss.com/snabbdom/0.7.3/snabbdom-class.js"></script>
<script src="https://cdn.bootcss.com/snabbdom/0.7.3/snabbdom-props.js"></script>
<script src="https://cdn.bootcss.com/snabbdom/0.7.3/snabbdom-style.js"></script>
<script src="https://cdn.bootcss.com/snabbdom/0.7.3/snabbdom-eventlisteners.js"></script>
<script src="https://cdn.bootcss.com/snabbdom/0.7.3/h.js"></script>
<script>
const snabbdom = window.snabbdom
// 定义 patch
const patch = snabbdom.init([
snabbdom_class,
snabbdom_props,
snabbdom_style,
snabbdom_eventlisteners
])
// 定义 h
const h = snabbdom.h
const container = document.getElementById('container')
// 生成 vnode
const vnode = h('ul#list', {}, [
h('li.item', {}, 'Item 1'),
h('li.item', {}, 'Item 2')
])
patch(container, vnode)
document.getElementById('btn-change').addEventListener('click', () => {
// 生成 newVnode
const newVnode = h('ul#list', {}, [
h('li.item', {}, 'Item 1'),
h('li.item', {}, 'Item B'),
h('li.item', {}, 'Item 3')
])
patch(vnode, newVnode) // vnode = newVnode → patch 之后,应该用新的覆盖现有的 vnode ,否则每次 change 都是新旧对比
})
</script>
</body>
</html>
此时我们来看浏览器的显示效果:
我们可以看到,最终的效果是当我们点击时, DOM
树不会一整棵树重新渲染,而是只针对改变的值进行重新比较,最终只将改变的节点进行渲染。
通过这样的演示,相信大家对真实 DOM
和虚拟 DOM
的区别有了一定的了解。
7、vdom总结
讲到这里,我们来对vdom做一个总结:
- 可以通过
JS
来模拟DOM
结构(vnode); - 新旧
vnode
对比,得出最小的更新范围,最后更新DOM; - 数据驱动视图的模式下,可以有效地控制DOM操作。
二、diff算法
我们在上述讲 vdom
的时候说过, vdom
的核心价值就在于最大程度的减少DOM的使用范围。那 vdom
是通过什么方式呢,它是通过把 DOM
用 JS
来去模拟,之后进行计算和进行对比,最后找出最小的更新范围去更新。那么这个对比的过程对应的就是我们经常听到的 diff
算法。
接下来就让我们一起来了解 vdom
的另外一个内容, diff
算法。
1、diff算法
-
diff算法是前端的一个热门话题,同时也是
vdom
中最核心、最关键的部分。 -
diff算法在日常使用
vue
和react
中经常出现(如key)。
2、diff算法概述
diff
即对比,是一个广泛的概念,如linux diff命令、git diff命令等。- 两个js对象也可以做
diff
,如github
上的jiff库,这个库可以直接用来给两个js对象做diff。 - 两棵树做
diff
,如上述所说的vdom
和diff
。
我们来看个例子🌰:
看到上面两棵树,我们可以想象下它是如何进行 diff
算法的。我们可以看到,右边这棵树要把左边的 E
改为 X
,同时要新增一个节点 H
。因此如果通过 diff
来实现的话,我们可以对其进行新旧节点的比较,如果比较完一样,则不动它;如果比较完不一样,则对它进行修改。这样处理的话,5个节点只需要修改2次,而不用修改5次,效率很是UpUp。
3、树diff的时间复杂度O(n3)
对于树来说,原始的时间复杂度有O(n3)。那么这个 O(n3) 是怎么来的呢?
首先,遍历tree1;其次,遍历tree2;最后,对树进行排序。这样 n*n*n
,就达到了O(n3)。
假设现在有1000个节点要操作,那1000的3次方就1亿次了,因此,树的这个算法不可用。那我们怎么解决呢?继续看下面。
4、优化时间复杂度到O(n)
因为树的时间复杂度是O(n3),因此,我们就想办法,优化其时间复杂度从O(n3)到O(n),以达到操作 vdom
节点,那这个优化过程其实我们所说的 diff
算法。通过 diff
算法,我们可以将时间复杂度从O(n3)优化到O(n)。diff算法的具体思想如下:
- 只比较同一层级,不跨级比较;
tag
不相同,则直接删掉重建,不再深度比较;tag
和key
,两者都相同,则认为是相同节点,不再深度比较。
三、深入diff算法源码
1、生成vnode
我们先来回顾下上面讲的 snabbdom
, diff
比较先是在 h
函数里面进行,这个 h
函数输入一个标签,之后输入一个 data
,最后输入一个子元素。并且 h
函数是一个 vnode
的结构,层级般的一层一层递进。最后就是 patch
函数, 第一个patch
函数用来对元素进行渲染,第二个 patch
函数用来比较新旧节点。
接下来我们来看下它是如何生成vnode的。
先克隆一份snabbdom的代码下来,打开 src|h.ts
文件,直接来看 h
函数,具体代码如下:
export function h(sel: string): VNode;
export function h(sel: string, data: VNodeData | null): VNode;
export function h(sel: string, children: VNodeChildren): VNode;
export function h(
sel: string,
data: VNodeData | null,
children: VNodeChildren
): VNode;
export function h(sel: any, b?: any, c?: any): VNode {
let data: VNodeData = {};
let children: any;
let text: any;
let i: number;
if (c !== undefined) {
if (b !== null) {
data = b;
}
if (is.array(c)) {
children = c;
} else if (is.primitive(c)) {
text = c;
} else if (c && c.sel) {
children = [c];
}
} else if (b !== undefined && b !== null) {
if (is.array(b)) {
children = b;
} else if (is.primitive(b)) {
text = b;
} else if (b && b.sel) {
children = [b];
} else {
data = b;
}
}
if (children !== undefined) {
for (i = 0; i < children.length; ++i) {
if (is.primitive(children[i]))
children[i] = vnode(
undefined,
undefined,
undefined,
children[i],
undefined
);
}
}
if (
sel[0] === "s" &&
sel[1] === "v" &&
sel[2] === "g" &&
(sel.length === 3 || sel[3] === "." || sel[3] === "#")
) {
addNS(data, children, sel);
}
// 返回vnode,这个vnode对应patch下的vnode
return vnode(sel, data, children, text, undefined);
}
我们看到最后一行, h
函数返回的是一个 vnode
函数。之后我们继续找 vnode
的文件,在 src|vnode.ts
文件中。附上最关键部分代码:
export function vnode(
sel: string | undefined,
data: any | undefined,
children: Array<VNode | string> | undefined,
text: string | undefined,
elm: Element | Text | undefined
): VNode {
const key = data === undefined ? undefined : data.key;
// 返回一个对象
// elm表示vnode结构对应的是哪一个DOM元素
// key可以理解为v-for时我们使用的key
return { sel, data, children, text, elm, key };
}
同样定位到最后一行,大家可以发现, vnode
实际上是返回一个对象。而这个对象里,有6个元素。其中, sel, data, children, text
四个元素对应我们上面讲 vnode
时对应的结构(第一点的第5点)。而 elm
表示 vnode
结构对应的是哪一个 DOM
元素,最后的 key
大家可以理解为是我们使用 v-for
时用的 key
,同时需要注意是, key
不一定只有在 v-for
时可以使用,在定义组件等各种场景时均可使用。
2、patch函数
看完 vnode
,我们来看下如何用patch函数来对比 vnode
。从官方文档中我们可以定位到, patch
函数在 src|init.ts
文件下,我们找到 init.ts
文件。同样,我们定位到 patch
函数部分,具体代码如下:
// 返回一个patch函数
return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
let i: number, elm: Node, parent: Node;
const insertedVnodeQueue: VNodeQueue = [];
// 执行pre hook,hook 即 DOM 节点的生命周期
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
// 第一个参数不是vnode,是一个DOM元素
if (!isVnode(oldVnode)) {
// 创建一个空的 vnode,关联到这个DOM元素
oldVnode = emptyNodeAt(oldVnode);
}
// 相同的vnode(key 和 sel 都相等)
if (sameVnode(oldVnode, vnode)) {
// vnode进行对比
patchVnode(oldVnode, vnode, insertedVnodeQueue);
}
// 不同的 vnode , 直接删掉重建
else {
elm = oldVnode.elm!;
parent = api.parentNode(elm) as Node;
// 重建
createElm(vnode, insertedVnodeQueue);
if (parent !== null) {
api.insertBefore面试中的网红Vue源码解析之虚拟DOM,你知多少呢?深入解读diff算法