本篇笔记来自于尚硅谷——Vue源码解析系列课程。
虚拟DOM和diff算法也是vue高效的原因。
DOM如何变为虚拟DOM,属于模板编译原理范畴,本课次不研究。
介绍
diff算法可以进行精细化比对,实现最小量更新,只需要更新修改的DOM,而不是整个DOM,尽可能做到节点的复用,减少开销。
虚拟DOM,将真实的DOM转换为JS对象,因为计算机内部操作JS对象会比操作真实DOM方便得多。
虚拟DOM:用 JavaScript对象描述DOM的层次结构。DOM中的一切属性都在虚拟DOM中有对应的属性。
两者之间的关系:
diff的算法基于虚拟DOM,是两个虚拟DOM之间的精细化比较,算出应该如何最小量更新,最后反映到真正的DOM上。
snabbdom
-
snabbdom
是瑞典单词,原意 “ 速度 ” -
snabbdom
是著名的虚拟DOM库,是diff算法的鼻祖,Vue源码借鉴了snabbdom
-
官方git:github.com/snabbdom/sn…
-
一个专注于简单性、模块化、强大特性和性能的虚拟DOM库
安装
-
在git上的
snabbdom
源码是用 TypeScript写的,git上并不提供编译好的JavaScript版本 -
如果要直接使用编译出来的 JavaScript版的
snabbdom
库,可以从npm上下载npm i -S snabbdom
-
学习库底层时,建议大家阅读原汁原味的代码,最好带有库作者原注释。这样对源码阅读能力会有很大的提升。
安装完毕后,查看目录结构,这里安装的版本为3.0.3。
- 其中src文件夹中存放的是TypeScript源码文件,跟git上面的内容相同
- build文件夹中就是编译好后的JavaScript源码文件
搭建环境
和Mustache不同,snabbdom是一个虚拟DOM库,也就是说不能在Node端运行。需要安装webpack
,webpack-cli
,webpack-dev-server
开发环境运行。
npm i -D webpack webpack-cli@3 webpack-dev-server
通过实验snabbdom可以和webpack-cli@3
版本使用,最新版为4,运行会报错。
-
项目根目录下新建
webpack.config.js
module.exports = { // 打包入口 entry: './src/index.js', output: { //虚拟路径,并不会在真实文件夹中产生,localhost:8080/fake/bundles.js publicPath: 'fake', filename: 'bundle.js' }, devServer:{ // 端口号 port:8080, // 静态资源目录 contentBase:'www' } };
-
新建配置文件中的入口和静态资源目录
index.html
中引用<script src="fake/bundle.js"></script>
-
修改启动命令
... "scripts": { "dev": "webpack-dev-server" }, ...
-
启动
npm run dev # 若没修改启动命令,使用 # npm run webpack-dev-server
PS:视频中老师的snabbdom版本为2.1.0,该版本
package.json
中有exports
属性,功能类似于为路径取别名:在2.1.0版本的github的Example中,这样导入模块,但是真实路径中并不存在,都是通过上述的
exports
别名进行查找的。但是
webpack4
并不支持exports
,所以视频中安装的版本如下:npm i -D webpack@5 webpack-cli@3 webpack-dev-server@3
Example
复制github上的Example到入口/src/index.js
中:
在index.html
中创建div#container
节点,因为下列代码要用到。
另外,其中第20行(vnode
)和第30行(newVnode
),虚拟DOM中的click:someFn
,someFn
需要自己定义一个。
import {
init,
classModule,
propsModule,
styleModule,
eventListenersModule,
h,
} from "snabbdom";
const patch = init([
// Init patch function with chosen modules
classModule,
propsModule,
styleModule,
eventListenersModule,
]);
const container = document.getElementById("container");
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 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!"),
]
);
// Second `patch` invocation
patch(vnode, newVnode); // Snabbdom efficiently updates the old view to the new state
结果:
h函数
-
h函数用来创建虚拟节点(vnode)
-
比如这样调用h函数
h('a',{ props:{ href: 'http://www.atguigu.com' }}, '尚硅谷')
-
将得到以下虚拟节点
{ "sel": "a", "data": { props: { href: 'http://www.atguigu.com'} }, "text": "尚硅谷" }
-
这个虚拟节点表示的真正DOM节点为
<a href="http://www.atguigu.com">尚硅谷</a>
虚拟DOM的属性
- sel:选择器
- children:子元素,也是虚拟DOM,
undefined
表示没有子元素 - data:元素的属性或样式等等,真实DOM的
attribute
信息 - elm:表示该虚拟DOM对应的真实DOM,若为
undefined
则表示该虚拟DOM还未上DOM树(未渲染) - key:唯一标识(vue中的
v-for
使用过) - text:元素文本,innerText
基本使用
import {
init,
classModule,
propsModule,
styleModule,
eventListenersModule,
h,
} from "snabbdom";
const patch = init([
// 用所选择的模块初始化patch函数
classModule, // 使切换类的工作变得简单
propsModule, // 用于设置DOM元素的属性
styleModule, // 处理支持动画的元素的样式
eventListenersModule, // 挂载事件监听器
]);
const container = document.getElementById("container");
const vnode1 = h('a', {
props: {
href: 'https://www.baidu.com/',
target: '_blank'
}
}, '百度一下')
//省略data children
const vnode2 = h('div', '我是一个盒子')
//嵌套使用,子元素用数组表示,若子元素只有一个可以省略数组
const vnode3 = h('ul', [
h('li', '西瓜'),
h('li', '苹果'),
h('li', '梨子'),
h('li', [
h('div', '喜欢西瓜'),
h('div', '讨厌苹果')
])
])
console.log(vnode3)
// 挂载到DOM树
patch(container, vnode3);
原理
查看snabbdom
模块下src/h.ts
import { vnode, VNode, VNodeData } from "./vnode";
import * as is from "./is";
export type VNodes = VNode[];
export type VNodeChildElement = VNode | string | number | undefined | null;
export type ArrayOrElement<T> = T | T[];
export type VNodeChildren = ArrayOrElement<VNodeChildElement>;
// 添加svg namespace(命名空间)
function addNS(
data: any,
children: VNodes | undefined,
sel: string | undefined
): void {
data.ns = "http://www.w3.org/2000/svg";
if (sel !== "foreignObject" && children !== undefined) {
for (let i = 0; i < children.length; ++i) {
const childData = children[i].data;
if (childData !== undefined) {
addNS(childData, children[i].children as VNodes, children[i].sel);
}
}
}
}
// 声明方法重载
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;
// 实现h函数
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
);
}
}
// 为svg元素添加命名空间
if (
sel[0] === "s" &&
sel[1] === "v" &&
sel[2] === "g" &&
(sel.length === 3 || sel[3] === "." || sel[3] === "#")
) {
addNS(data, children, sel);
}
// 返回虚拟节点
return vnode(sel, data, children, text, undefined);
}
h函数的功能其实非常简单,本质只是将传入的几个参数进行适当加工,并组成一个vnode对象返回。
patch函数
diff算法会在patch函数种进行体现,h函数创建虚拟节点,而patch函数将虚拟节点通过diff算法渲染为真实节点。patch函数是通过init函数生成的。
基本使用
修改index.html
<body>
<div id="container">我是container</div>
<button id="toUl2">ul1-->ul2</button>
<button id="toUl3">ul1-->ul3</button>
<button id="toUl4">ul1-->ul4</button>
<script src="fake/bundle.js"></script>
</body>
修改index.js
import {
init,
classModule,
propsModule,
styleModule,
eventListenersModule,
h,
} from "snabbdom";
const patch = init([
// 用所选择的模块初始化patch函数
classModule, // 使切换类的工作变得简单
propsModule, // 用于设置DOM元素的属性
styleModule, // 处理支持动画的元素的样式
eventListenersModule, // 挂载事件监听器
]);
const container = document.getElementById("container");
const ul1 = h('ul', [
h('li', '1'),
h('li', '2'),
h('li', '3'),
h('li', '4'),
])
//先将 ul1 更新到 container
patch(container, ul1)
// ul2 只比 ul1从最后多一个子元素
const ul2 = h('ul', [
h('li', '1'),
h('li', '2'),
h('li', '3'),
h('li', '4'),
h('li', '5')
])
// 比较ul1 和 ul2 更新
document.getElementById("toUl2").onclick = function () {
patch(ul1, ul2)
}
// ul3 只比 ul1从最前面多一个子元素
const ul3 = h('ul', [
h('li', 'E'),
h('li', '1'),
h('li', '2'),
h('li', '3'),
h('li', '4')
])
// 比较ul1 和 ul3 更新
document.getElementById("toUl3").onclick = function () {
patch(ul1, ul3)
}
// ul4 和 ul1 的sel参数不同,子元素相同
const ul4 = h('ol', [
h('li', '1'),
h('li', '2'),
h('li', '3'),
h('li', '4')
])
// 比较ul1 和 ul4 更新
document.getElementById("toUl4").onclick = function () {
patch(ul1, ul4)
}
-
初始界面
-
进入浏览器开发者模式修改元素内容
-
点击第一个按钮,更新为
ul2
结论,对于后面追加的内容,diff算法是可以感知到的,并进行了最小化更新。
-
刷新回到初始界面,并进入浏览器开发者模式修改元素内容
-
点击第二个按钮,更新为
ul3
,整个ul
都被更新了 -
如果将
index.js
中的ul1
和ul2
代码修改const ul1 = h('ul', [ h('li', { key: '1' }, '1'), h('li', { key: '2' }, '2'), h('li', { key: '3' }, '3'), h('li', { key: '4' }, '4'), ]) const ul3 = h('ul', [ h('li', { key: 'E' }, 'E'), h('li', { key: '1' }, '1'), h('li', { key: '2' }, '2'), h('li', { key: '3' }, '3'), h('li', { key: '4' }, '4') ])
-
再进行相同操作
结论:对于从前插入的数据,diff算法无法感知,会更新掉整个DOM,如果给元素添加key属性,那么key属性相同的元素将不会被更新。
-
撤回对
ul1
和ul2
对象的修改,回归原始界面和数据,并再次修改元素内容 -
点击第三个按钮,更新为
ul4
,整个ul
被替换为ol
结论:同一个虚拟DOM(key相同且sel相同),才会使用diff算法,否则会更新整个DOM。
另外,diff算法只进行同层比较,即便是同一个虚拟DOM,但是跨层了,diff算法依旧不起作用,会直接更新整个DOM。如下:
const div = h('div', [
h('p', { key: '1' }, '1'),
h('p', { key: '2' }, '2'),
h('p', { key: '3' }, '3'),
h('p', { key: '4' }, '4')
])
// 虽然子元素都相同,但是多了一层section,diff算法不起作用
const div2 = h('div', h('section', [
h('p', { key: '1' }, '1'),
h('p', { key: '2' }, '2'),
h('p', { key: '3' }, '3'),
h('p', { key: '4' }, '4')
]))
虽然这样看起来,diff算法并不是那么智能且高效,但是实际上在vue的开发中,这些情况基本不会遇见,这是合理的优化机制。
原理
流程图如下:
查看snabbdom
模块下src/init.ts
,可以直接从最后function patch(...)
开始看起,前面的函数都是为了组成patch
函数。
// 省略一大堆
export function init(modules: Array<Partial<Module>>, domApi?: DOMAPI) {
// 省略一大堆
return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
let i: number, elm: Node, parent: Node;
const insertedVnodeQueue: VNodeQueue = [];
// 处理钩子,生命周期,不用管
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
// 判断节点是否是虚拟节点,不是则转化为虚拟节点
if (!isVnode(oldVnode)) {
oldVnode = emptyNodeAt(oldVnode);
}
// 判断新旧虚拟阶段是否为同一个节点:判断sel和key
if (sameVnode(oldVnode, vnode)) {
// 相同节点才会精细化比较
patchVnode(oldVnode, vnode, insertedVnodeQueue);
} else {
// 不是相同节点,则直接删除原来的元素,追加新元素(整个更新)
elm = oldVnode.elm!;
parent = api.parentNode(elm) as Node;
createElm(vnode, insertedVnodeQueue);
if (parent !== null) {
api.insertBefore(parent, vnode.elm!, api.nextSibling(elm));
removeVnodes(parent, [oldVnode], 0, 0);
}
}
for (i = 0; i < insertedVnodeQueue.length; ++i) {
insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i]);
}
for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
return vnode;
};
}
前面说过,diff算法生效的前提是新旧节点是同一个节点。由源码可知,实现diff算法的是patchVnode()
函数。
function patchVnode(
oldVnode: VNode,
vnode: VNode,
insertedVnodeQueue: VNodeQueue
) {
const hook = vnode.data?.hook;
hook?.prepatch?.(oldVnode, vnode);
const elm = (vnode.elm = oldVnode.elm)!;
const oldCh = oldVnode.children as VNode[];
const ch = vnode.children as VNode[];
// 如果 新旧节点在内存中就是同一个对象,什么都不用做
if (oldVnode === vnode) return;
// 更新节点的data
if (vnode.data !== undefined) {
for (let i = 0; i < cbs.update.length; ++i)
cbs.update[i](oldVnode, vnode);
vnode.data.hook?.update?.(oldVnode, vnode);
}
// diff算法的核心
// 若新节点没有文本
if (isUndef(vnode.text)) {
// 并且两个节点都有子元素
if (isDef(oldCh) && isDef(ch)) {
//两者的子元素不相同,直接更新子元素
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
} // 若只有新节点有子元素
else if (isDef(ch)) {
// 若老元素有text则清空,
if (isDef(oldVnode.text)) api.setTextContent(elm, "");
// 给真实DOM添加新虚拟节点的子元素
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
} // 若只有旧节点有子元素
else if (isDef(oldCh)) {
// 把真实元素存在的的旧子元素删除
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
} // 若只有旧节点有text
else if (isDef(oldVnode.text)) {
// 把真实DOM的innerText(这里可以这么理解,Node.textContent与innerText类似,区别可自行搜索)
api.setTextContent(elm, "");
}
} // 旧节点的文本不同于新节点的文本
else if (oldVnode.text !== vnode.text) {
// 且旧节点有子元素
if (isDef(oldCh)) {
// 删除真实DOM存在的旧节点子元素
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
}
// 将新节点的文本给真实DOM
api.setTextContent(elm, vnode.text!);
}
hook?.postpatch?.(oldVnode, vnode);
}