搭建测试环境snabbdom:
snabbdom 是著名的虚拟 DOM 库,是 diff 算法的鼻祖,Vue 源码就是借鉴了 snabbdom
- 创建一个全英文路径下的空文档,在vscode的命令行先初始化文档:
npm init
- 搭建webpack 和 webpack-dev-server 开发环境,必须安装最新版 webpack@5,不能安装 webpack@4,这是因为 webpack@4 没有读取身份证(package.json)中 exports 的能力,建议大家使用这样的版本:
npm i -D webpack@5 webpack-cli@3 webpack-dev-server@3
- 安装snabbdom,要求本地安装
npm i -S snabbdom
- 相关入口出口文件配置,端口隐藏文件设置,详细见邵山欢老师的视频讲解。
- 何为虚拟DOM
虚拟DOM就是普通的js对象,其可以用来描述DOM对象,但是由于不是真正的DOM对象,因此人们把它叫做虚拟DOM。 - h函数产生虚拟DOM
- diff用在哪
diff是发生在虚拟DOM上的,是新的虚拟DOM和老的虚拟DOM进行diff(精细化比较),最后反映到真实DOM中。
譬如真实DOM和虚拟DOM如下:
真实DOM:
<div class="box">
<h3>我是一个标题</h3>
<ul>
<li>牛奶</li>
<li>咖啡</li>
<li>可乐</li>
</ul>
</div>
虚拟DOM:
{
"sel": "div",
"data": {
"class": { "box": true }
},
"children": [
{
"sel": "h3",
"data": {},
"text": "我是一个标题"
},
{
"sel": "ul",
"data": {},
"children": [
{ "sel": "li", "data": {}, "text": "牛奶" },
{ "sel": "li", "data": {}, "text": "咖啡" },
{ "sel": "li", "data": {}, "text": "可乐" }
]
}
]
}
- 手写h函数,产生虚拟DOM
比如这样调用h函数:
h('a',{props:{href:'http://baidu.com'}},'百度')
将得到下面的虚拟节点:
{"sel":"a","data":{props:{href:'http://baidu.com'}},"text":"百度"}
它其实表示的是真正的DOM:
<a href="http://baidu.com">百度</a>
一个虚拟DOM有哪些属性:
{ children:子元素
data:{} 属性、样式
elm:对应的真正节点,是否上树
key:节点的唯一标识,服务于最小量
sel:选择器
text:内容
- 在index.js中创建一个虚拟节点
var myVnode1 = h('a', { props: { href: 'http://baidu.com' } }, '百度');
console.log(myVnode1);
输出结果:
elm为underfined,说明此时虚拟节点未上树,考虑创建patch函数,渲染页面
import {
init,
classModule,
propsModule,
styleModule,
eventListenersModule,
h,
} from "snabbdom";
const patch = init([
classModule,
propsModule,
styleModule,
eventListenersModule
])
const container = document.getElementById('container');
patch(container, myVnode1);
虚拟节点已经创建:
再写一个h函数:
const myVnode3 = h('ul', [h('li', '香蕉'), h('li', '苹果'),
h('li', [h('ol', [h('li', '背景'), h('li', '荆州')])]), h('li', '橘子')
]);
综上:h函数有多种写法:
h('div')
h('div','文字')
h('div',[])
h('div',h())
h('div',{},[])
h('div',{},h())
我们这里着手写后三种形式的h函数:
src 目录下新建 mysnabbdom 目录
新建2个文件
src/mysnabbdom/h.js
、src/mysnabbdom/vnode.js
h.js
import vnode from "./vnode.js";
// console.log('div', 2, 3, 4, 5);
//编写一个低配版的h函数,必须接受3个参数
//调用的形态必须是三个之一
// h('div','文字')
// h('div',{},[])
// h('div',{},h())
export default function (sel, data, c) {
//检查参数个数
if (arguments.length !== 3) {
throw new Error("对不起,我们是低配版h函数");
}
//检查c的类型
if (typeof c == "string" || typeof c == "number") {
//调用形态为1
return vnode(sel, data, undefined, c, undefined);
} else if (Array.isArray(c)) {
let children = [];
//形态2 遍历c
for (let i = 0; i < c.length; i++) {
//检查c[i]是否为一个对象,
if (!(typeof c[i] == "object" && c[i].hasOwnProperty("sel")))
throw new Error("传入的数组参数中有一项不是h函数");
//这里不用执行c[i],只需要收集
children.push(c[i]);
}
//循环结束,children收集结束
return vnode(sel, data, children, undefined, undefined);
} else if (typeof c == "object" && c.hasOwnProperty("sel")) {
//形态3 说明传入的c是唯一的children
let children = [c];
return vnode(sel, data, children, undefined, undefined);
} else {
throw new Error("传入的第3个参数类型不对");
}
}
vnode.js
export default function(sel, data, children, text, elm) {
return {
sel,
data,
children,
text,
elm
}
}
上述写完后便可以识别低配版的h函数,比如给了这样一个myVnode1(形态二,第二个判断条件)
import h from "./mysnabbdom/h.js";
var myVnode1 = h("div", {}, [
h("div", { class: "box" }, "哈哈哈"),
h("div", {}, "嘻嘻嘻"),
h("div", {}, "嘻嘻嘻"),
]);
console.log(myVnode1);
diff算法:
点击按钮,在后面新增一个节点ooo,其实前面的都没变,这是在已经选然后的html中改变li的值,当点击按钮时,前面的都没变。代码中的 patch 函数所做的工作其实是在原有 vnode1 基础上新增了一个节点。
const vnode1 = h("ul", {}, [
h("li", { key: "a" }, "AA"),
h("li", { key: "b" }, "bb"),
h("li", { key: "c" }, "CC"),
h("li", { key: "d" }, "DD"),
h("li", { key: "d" }, "HH"),
]);
const container = document.getElementById("container");
patch(container, vnode1);
const vnode2 = h("ul", {}, [
h("li", { key: "a" }, "AA"),
h("li", { key: "b" }, "bb"),
h("li", { key: "c" }, "CC"),
h("li", {}, "ooo"),
h("li", { key: "d" }, "DD"),
h("li", { key: "d" }, "HH"),
]);
const btn = document.getElementById("btn");
//点击按钮时,vnode1变为2
btn.onclick = function () {
patch(vnode1, vnode2);
};
注意:这里的key非常关键,key是这里的唯一标识,告诉diff算法,更改前后,他们是同一个DOM节点;
- 只要是同一个虚拟节点,就可进行精细化比较,否则就是暴力删除旧的,添加新的,那么如何定义一个虚拟节点呢,选择器相同且key相同。
- 只进行统计比较,不进行跨层比较,即使是同一虚拟节点,一旦跨层,则不会diff。
- 给节点加了唯一标识 key 之后页面渲染效率会大大提高的原因
如何证明上述是否正确呢?,我们将vnode2改为ol,已经不是一个选择器
const vnode1 = h("ul", {}, [
h("li", { key: "a" }, "AA"),
h("li", { key: "b" }, "bb"),
h("li", { key: "c" }, "CC"),
h("li", { key: "d" }, "DD"),
h("li", { key: "d" }, "HH"),
]);
const container = document.getElementById("container");
patch(container, vnode1);
const vnode2 = h("ol", {}, [
h("li", { key: "a" }, "AA"),
h("li", { key: "b" }, "bb"),
h("li", { key: "c" }, "CC"),
h("li", {}, "ooo"),
h("li", { key: "d" }, "DD"),
h("li", { key: "d" }, "HH"),
]);
const btn = document.getElementById("btn");
//点击按钮时,vnode1变为2
btn.onclick = function () {
patch(vnode1, vnode2);
};
我们仍然在浏览器中改变每个li的内容,点击按钮,都变了。
- 代码示意
diff 处理新旧节点不是同一个节点时:
代码如下:
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);
}
}
可简化为以下流程图:
如何定义同一个节点:源码如下,比较新旧节点的key,且选择器相同;
function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
const isSameKey = vnode1.key === vnode2.key;
const isSameIs = vnode1.data?.is === vnode2.data?.is;
const isSameSel = vnode1.sel === vnode2.sel;
return isSameSel && isSameKey && isSameIs;
}
创建子节点时,递归调用新节点:
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);
}