虚拟 DOM 和 diff 算法
虚拟 DOM 和 diff 算法其实对于大家并不陌生,不管我们开发中使用的什么框架,其实都离不开虚拟 DOM 和 diff。
当有人问你:你了解虚拟 DOM 和 diff 算法吗? 我相信,大部分人其实都能说出一点相关知识。但是,请各位亲们扪心自问,你真的懂虚拟 DOM 和 diff 算法吗?
本次分享将带大家一起探索虚拟 DOM 和 diff 算法的奥秘。我们将通过代码演示以及手写相关实现代码来一起打开 diff 算法的神秘面纱。核心代码每一行都安排得明明白白,由简入深,循序渐进,让大家都能听懂,干货满满!
一、简单认识虚拟 DOM 和 diff 算法
虚拟 DOM:用 js 对象形式描述真实 DOM
<div class="box">
<h3>我是一个标题</h3>
<ul>
<li>java</li>
<li>php</li>
<li>python</li>
</ul>
</div>
{
"sel": "div",
"data": { "class": { "box": true } },
"children": [
{
"sel": "h3",
"data": {},
"text": "我是一个标题",
},
{
"sel": "ul",
"data": {},
"children": [
{ "sel": "li", "data": {}, "text": "java" },
{ "sel": "li", "data": {}, "text": "php" },
{ "sel": "li", "data": {}, "text": "python" },
],
},
],
};
diff: 最小量更新
变为
<div class="box">
<h3>我是一个标题</h3>
<span>我是一个新的span</span>
<ul>
<li>java</li>
<li>php</li>
<li>python</li>
<li>javascript</li>
</ul>
</div>
这种场景在实际开发中经常出现,那么 diff 算法怎么做处理的呢?有人说,直接拆掉整个 DOM,然后重新上树,计算机是很快的嘛 ~ 但是,涉及到比较庞大的 DOM 结构时,还是会有很多性能问题。就好比你要在家里客厅放一个桌子,你总不可能把家拆了重新装修吧。
我们来看 DOM 结构,其实就是多了一个 span,多了一个 li,其余东西并没有发生任何变化,diff 算法的核心就在于
进行精细化比较,实现最小量更新
。
二、snabbdom
- snabbdom 简介
- snabbdom 的 h 函数如何工作
- 感受diff 算法
2.1 snabbdom 简介
snabbdom 是瑞典语单词,单词原意“速度”
snabbdom 是著名的虚拟 DOM 库,是 diff 算法的鼻祖,Vue 源码借鉴了 snabbdom
2.1.1 搭建 snabbdom 环境
1.用npm init
构建项目即可
2.安装以下几个包 snabbdom
、webpack
、webpack-cli
、webpack-dev-server
"dependencies": {
"snabbdom": "^3.0.3",
"webpack": "^5.48.0",
"webpack-cli": "^3.3.12",
"webpack-dev-server": "^3.11.2"
}
3.新建src/index.js
4.新建www/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div id="container"></div>
<script src="xuni/bundle.js"></script>
</body>
</html>
5.创建webpack.config.js
```javascript
module.exports = {
entry: "./src/index.js",
output: {
publicPath: "xuni",
filename: "bundle.js",
},
devServer: {
port: 9999,
contentBase: "www",
},
};
```
复制github上的demo代码到index.js
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 someFn = () => {
console.log(1)
}
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: someFn } },
[
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
注意,需要把demo中的someFn
定义一下。如果页面显示下面的内容,则环境基本搭建完成:
2.2 虚拟DOM和h函数
-
虚拟DOM
用JavaScript对象描述DOM的层次结构。DOM中的一切属性都在虚拟DOM中有对应的属性。
-
diff是发生在虚拟DOM上的
新虚拟DOM和老虚拟DOM进行diff,算出如何最小化更新,最后反映到真实的DOM上。
-
研究1:虚拟DOM如何通过渲染函数(
h函数
)产生的手写h函数。
-
研究2: diff算法原理
手写diff算法。
-
研究3:虚拟DOM如何通过diff变成真正的DOM
事实上,虚拟DOM变为真正DOM,是涵盖在diff算法里的。
注意: DOM如何变为虚拟DOM,是属于模板编译
原理范畴,我们这里不做讨论。
2.2.1 h函数
h函数用来产生虚拟节点(vnode)
比如这样调用h函数:
h('a', {props: { href: 'https://www.baidu.com' }}, '百度一下,你还是不知道')
得到这样的虚拟节点: 他表示真正的DOM节点:
<a href='https://www.baidu.com'>百度一下,你还是不知道</a>
字段解释:
{
children: undefined, // 子元素
data: {}, // 属性 样式 等
elm: undefined, // 有没有对应的DOM节点,有没有上树
key: undefined, // 唯一标识
sel: 'div', // 选择器
text: '我是一个盒子' // 文字
}
调用patch函数(后面会讲到),可以使虚拟DOM上树。
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('a', { props: { href: 'https://www.baidu.com' } }, '百度一下,你还是不知道')
// 让虚拟节点上树
patch(container, vnode)
console.log(vnode)
h函数可以嵌套,从而得到虚拟DOM树。
const vnode = h('ul', {}, [
h('li', { style: { color: '#f00' } }, 'java'),
h('li', 'php'),
h('li', 'python'),
])
原版h函数TS版本核心代码
h.ts
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);
}
return vnode(sel, data, children, text, undefined);
}
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;
return { sel, data, children, text, elm, key };
}
从h.ts里面的代码来看,h函数有很多种用法(TS的函数重载):
h('div')
h('div', '文字')
h('div', [])
h('div', h())
h('div', {}, '文字')
h('div', {}, [])
h('div', {}, h())
下面到了手写h函数的环节,我们在这里只实现阉割版的h函数,只实现3个参数的情况,因为原版代码里就是给a、b、c三个参数赋值而已。
手写h函数
看原版的TS代码,仿写JS代码。因为我们今天重点不在TS。
只要主干功能,放弃实现一些细节,保证让大家都能理解核心是怎么实现的。
vnode.js
// 非常简单,只是把传入参数合组合成对象返回
export default function (sel, data, children, text, elm) {
const key = data === undefined ? undefined : data.key;
return {
sel, data, children, text, elm, key
}
}
h.js
import vnode from './vnode.js'
export default function (sel, data, c) {
if (arguments.length !== 3) {
throw new Error('对不起,我们是阉割版的h函数,只实现3个参数的情况,QAQ')
}
if (typeof c === 'string' || typeof c === 'number') {
return vnode(sel, data, undefined, c, undefined)
} else if (Array.isArray(c)) {
const children = []
for (let i = 0; i < c.length; i++) {
// 检查c[i]必须是一个对象
if (!(typeof c[i] === 'object' && c[i].hasOwnProperty('sel'))) {
throw new Error('传入的数组参数中有项不是h函数')
}
children.push(c[i])
}
return vnode(sel, data, children, undefined, undefined)
} else if (typeof c === 'object' && c.hasOwnProperty('sel')) {
const children = [c]
return vnode(sel, data, children, undefined, undefined)
}
throw new Error('对不起,参数错误')
}
这样,我们已经实现了一个阉割版的h函数,直接上例子:
import h from "./mysnabbdom/h.js";
const vnode = h('div', {}, [
h('h2', {}, '我是一个标题'),
h('div', {}, h('p', {}, '我是一个p')),
h('ul', {}, [
h('li', {}, 'java'),
h('li', {}, 'php'),
h('li', {}, 'python'),
])
])
console.log(vnode)
当当当当~~~
完美收官!
2.3 感受diff算法
一直都在说diff,那么diff到底干了什么?
通过这一章节的学习(例子以及演示),你将会对diff算法有了进一步了解,知道它干了什么。
看个例子:
const container = document.getElementById("container");
const btn = document.getElementById("btn");
const vnode1 = h('ul', {}, [
h('li', {}, 'A'),
h('li', {}, 'B'),
h('li', {}, 'C'),
h('li', {}, 'D'),
])
patch(container, vnode1)
// 点击按钮时 将 vnode1 换成 vnode2
btn.onclick = function () {
const vnode2 = h('ul', {}, [
h('li', {}, 'A'),
h('li', {}, 'B'),
h('li', {}, 'C'),
h('li', {}, 'D'),
h('li', {}, 'E'),
])
patch(vnode1, vnode2)
}
效果:
我们成功的把vnode1替换成了vnode2。
那diff算法内部到底干了什么?按我们已经知道的知识,diff应该知道ABCD是不会变的,那么真的是这样吗?怎么验证?
看下面的图:
很明显的看到,我们手动把li的文字改掉,点击按钮后,文字并没有发生变化(没有变为ABCD),故diff知道了ABCD没有任何变化,所以复用并保留了它们。说明diff是最小量更新,diff真是太牛啦!
那么,diff真的有这么智能吗?
看下面的例子:
const container = document.getElementById("container");
const btn = document.getElementById("btn");
const vnode1 = h('ul', {}, [
h('li', {}, 'A'),
h('li', {}, 'B'),
h('li', {}, 'C'),
h('li', {}, 'D'),
])
patch(container, vnode1)
// 点击按钮时 将 vnode1 换成 vnode2
btn.onclick = function () {
const vnode2 = h('ul', {}, [
h('li', {}, 'E'),
h('li', {}, 'A'),
h('li', {}, 'B'),
h('li', {}, 'C'),
h('li', {}, 'D'),
])
patch(vnode1, vnode2)
}
我们在前面插入一个E,看看会发生什么。
好像。。。没啥毛病。那么问题来了,diff到底是在前面插入E,还是在后面插入一个节点,依次把文字替换呢?
即A=>E B=>A C=>B D=>C ''=>E
不卖关子了,我们用同样的方式上图:
我们发现,E并不是在前面插入的,所以,并没有那么智能。但是,diff真的这么笨吗?
我们来继续看下面的例子:
const vnode1 = h('ul', {}, [
h('li', { key: 'A' }, 'A'),
h('li', { key: 'B' }, 'B'),
h('li', { key: 'C' }, 'C'),
h('li', { key: 'D' }, 'D'),
])
patch(container, vnode1)
// 点击按钮时 将 vnode1 换成 vnode2
btn.onclick = function () {
const vnode2 = h('ul', {}, [
h('li', { key: 'E' }, 'E'),
h('li', { key: 'A' }, 'A'),
h('li', { key: 'B' }, 'B'),
h('li', { key: 'C' }, 'C'),
h('li', { key: 'D' }, 'D'),
])
patch(vnode1, vnode2)
}
我们加了一个key,看到这,你是不是脑子里灵光一闪,突然好像明白了什么。
上图!
很直观的发现,加上key后,diff就真的在前面插入了E。因为我们告诉了diff,A就是A、B就是B。。。
key的作用就是为了服务于最小量更新,这就知道为什么我们写项目的时候列表为什么必须要加key的原因。
再来一个例子:
const vnode1 = h('ul', {}, [
h('li', { key: 'A' }, 'A'),
h('li', { key: 'B' }, 'B'),
h('li', { key: 'C' }, 'C'),
h('li', { key: 'D' }, 'D'),
])
patch(container, vnode1)
// 点击按钮时 将 vnode1 换成 vnode2
btn.onclick = function () {
const vnode2 = h('ol', {}, [
h('li', { key: 'A' }, 'A'),
h('li', { key: 'B' }, 'B'),
h('li', { key: 'C' }, 'C'),
h('li', { key: 'D' }, 'D'),
])
patch(vnode1, vnode2)
}
我们加了一个key,看到这,你是不是脑子里灵光一闪,突然好像明白了什么。
上图!
我们把ul换成了ol,整个DOM被替换掉了。
最后一个例子:
const vnode1 = h('div', {}, [
h('p', { key: 'A' }, 'A'),
h('p', { key: 'B' }, 'B'),
h('p', { key: 'C' }, 'C'),
h('p', { key: 'D' }, 'D'),
])
patch(container, vnode1)
// 点击按钮时 将 vnode1 换成 vnode2
btn.onclick = function () {
const vnode2 = h('div', {}, h('div', {}, [
h('p', { key: 'A' }, 'A'),
h('p', { key: 'B' }, 'B'),
h('p', { key: 'C' }, 'C'),
h('p', { key: 'D' }, 'D'),
]))
patch(vnode1, vnode2)
}
心得:
1、最小量更新真是太厉害啦,真的是最小量更新!当然,key很重要。key是这个节点的唯一标识,告诉diff算法,在更改前后,它们是用一个DOM节点。
2、只有是同一个虚拟节点,才会进行精细化比较。否则就是暴力删除旧的、插入新的。如何定义是同一个虚拟节点:选择器相同且key相同。
3、只进行同层比较,不会进行跨层比较。即使是同一片虚拟节点,但是跨层了,对不起,精细化比较不diff你,而是暴力删除旧的、插入新的。
可能你会问,diff并不是那么牛逼啊!但真的影响效率吗?
答:相关的操作在实际开发中,基本不会遇到,所以这是合理的优化机制。
就好比你在开发中,并不会写这种代码:
<ul v-if='flag'>
<li>A</li>
<li>B</li>
<li>C</li>
</ul>
<ol v-else>
<li>A</li>
<li>B</li>
<li>C</li>
</ol>
<div>
<div v-if='flag'>
<p></p>
<p></p>
<p></p>
</div>
<p v-if='!flag'></p>
<p v-if='!flag'></p>
<p v-if='!flag'></p>
</div>
虽然这一章节只是感受diff算法,但我相信你肯定会有收获的~~~
三、手写diff
我们手写之前先简单看下源码。
从一开始的demo中,我们可以发现调用了init方法返回了patch函数。
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
]);
init.ts 源码截图
patch方法
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);
}
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;
};
看patch方法源码我们可以知道简单的流程:
3.1 新建patch函数
import vnode from './vnode.js'
import api from './htmldomapi.js'
// 是不是虚拟节点
function isVnode(vnode) {
return vnode.sel === '' || vnode.sel !== undefined;
}
// 把oldVnode包装成虚拟节点
function emptyNodeAt(elm) {
return vnode(
api.tagName(elm).toLowerCase(),
{},
[],
undefined,
elm
);
}
// 是不是同一个虚拟节点
function sameVnode(vnode1, vnode2) {
return vnode1.sel === vnode2.sel && vnode1.key === vnode2.key
}
export default function patch(oldVnode, newVnode) {
// 判断是不是虚拟节点
if (!isVnode(oldVnode)) {
// 不是虚拟节点 则包装成虚拟节点
oldVnode = emptyNodeAt(oldVnode)
}
if (sameVnode(oldVnode, newVnode)) {
// 精细化比较
} else {
// 暴力插入新的,删除旧的
}
}
其中,里面的vnode就是之前定义的vnode函数,api是操作dom的api,这里就不放代码了,大家应该能看懂。
目前已经实现了上面流程图里的部分功能,接下来到了第一个比较难的部分,就是插入新的,删除旧的。
3.2 createElement函数
我们先考虑最简单的,h函数第三个参数是文本的情况。
import h from './mysnabbdom/h'
import patch from './mysnabbdom/patch'
const container = document.getElementById("container");
const vnode1 = h('h1', {}, '你好')
patch(container, vnode1)
// 真正创建节点,将 vnode 创建为 DOM ,插入到 pivot 元素之前
function createElement(vnode, pivot) {
// 创建DOM节点
let domNode = document.createElement(vnode.sel)
// 有子节点还是有文本
if (vnode.text !== '' && (vnode.children === undefined || vnode.children.length === 0)) {
// 他内部是文本
domNode.innerText = vnode.text
// 上树 让是 pivot 的父元素调用 insertBefore 方法 ,将新的节点 domNode 插入到 pivot 之前
pivot.parentNode.insertBefore(domNode, pivot)
}
}
export default function patch(oldVnode, newVnode) {
// 判断是不是虚拟节点
if (!isVnode(oldVnode)) {
// 不是虚拟节点 则包装成虚拟节点
oldVnode = emptyNodeAt(oldVnode)
}
if (sameVnode(oldVnode, newVnode)) {
// 精细化比较
} else {
// 暴力插入新的,删除旧的
createElement(newVnode, oldVnode.elm)
}
}
页面已经成功显示 你好。我们完成了第一次上树。
我们接下来考虑h函数第三个参数是数组的情况。
const vnode1 = h('ul', {}, [
h('li', {}, 'A'),
h('li', {}, 'B'),
h('li', {}, 'C'),
h('li', {}, 'D'),
])
patch(container, vnode1)
由于有很多子节点,我们需要递归创建节点,同时递归需要有个结束条件,也就是h函数第三个参数为文本的时候。
这种情况我们会发现,createElement第二个参数在这里不知道传什么,所以我们先改造一下createElement函数,第二个参数我们不传,我们只用createElement创建节点,不进行插入操作,我们在patch函数里执行插入。
function createElement(vnode) {
// 创建DOM节点
let domNode = document.createElement(vnode.sel)
// 有子节点还是有文本
if (vnode.text !== '' && (vnode.children === undefined || vnode.children.length === 0)) {
// 他内部是文本
domNode.innerText = vnode.text
// 补充elm属性
vnode.elm = domNode
} else if (Array.isArray(vnode.children) && vnode.children.length > 0) {
// 内部是子节点,就要递归创建节点
}
// 返回elm 纯dom对象
return vnode.elm
}
同时,我们要改造上面第一个简单的例子:
function patch(oldVnode, newVnode) {
// 判断是不是虚拟节点
if (!isVnode(oldVnode)) {
// 不是虚拟节点 则包装成虚拟节点
oldVnode = emptyNodeAt(oldVnode)
}
if (sameVnode(oldVnode, newVnode)) {
// 精细化比较
} else {
// 暴力插入新的,删除旧的
let newVnodeElm = createElement(newVnode)
oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm)
}
}
我们在页面上成功显示 你好。
我们这样改造的目的就是为了让createElement只做创建节点的事情,不做其他事情,便于后续递归。
那么,我们便可以开始写递归了。
import vnode from './vnode.js'
import api from './htmldomapi.js'
// 是不是虚拟节点
function isVnode(vnode) {
return vnode.sel === '' || vnode.sel !== undefined;
}
// 把oldVnode包装成虚拟节点
function emptyNodeAt(elm) {
return vnode(
api.tagName(elm).toLowerCase(),
{},
[],
undefined,
elm
);
}
// 是不是同一个虚拟节点
function sameVnode(vnode1, vnode2) {
return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel
}
// 真正创建节点,将 vnode 创建为 DOM 但不进行插入操作
function createElement(vnode) {
// 创建DOM节点
let domNode = document.createElement(vnode.sel)
// 有子节点还是有文本
if (vnode.text !== '' && (vnode.children === undefined || vnode.children.length === 0)) {
// 他内部是文本
domNode.innerText = vnode.text
} else if (Array.isArray(vnode.children) && vnode.children.length > 0) {
// 内部是子节点,就要递归创建节点
for (let i = 0; i < vnode.children.length; i++) {
// 得到当前children
let ch = vnode.children[i]
// 创建出它的dom,一旦调用了createElement意味着:创建出dom了,并且它的elm属性指向了创建出的dom,但是还没有上树
let chDom = createElement(ch)
// 上树
domNode.appendChild(chDom)
}
}
// 补充elm属性
vnode.elm = domNode
// 返回elm 纯dom对象
return vnode.elm
}
export default function patch(oldVnode, newVnode) {
// 判断是不是虚拟节点
if (!isVnode(oldVnode)) {
// 不是虚拟节点 则包装成虚拟节点
oldVnode = emptyNodeAt(oldVnode)
}
if (sameVnode(oldVnode, newVnode)) {
// 精细化比较
} else {
// 暴力插入新的,删除旧的
let newVnodeElm = createElement(newVnode)
if (oldVnode.elm.parentNode && newVnodeElm) {
oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm)
}
}
}
页面成功显示:
例2:
const vnode1 = h('ul', {}, [
h('li', {}, 'A'),
h('li', {}, 'B'),
h('li', {}, 'C'),
h('li', {}, [
h('div',{},'DD1'),
h('div',{},'DD2'),
h('div',{},'DD3'),
]),
])
patch(container, vnode1)
到此,我们的递归写得差不多了。
噢,对了,我们还没有删除老节点,只需要加上一行代码即可。
if (oldVnode.elm.parentNode && newVnodeElm) {
oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm)
// 删除老节点
oldVnode.elm.parentNode.removeChild(oldVnode.elm)
}
我们再把按钮加上,改变DOM:
import h from './mysnabbdom/h'
import patch from './mysnabbdom/patch'
const container = document.getElementById("container");
const btn = document.getElementById("btn");
const vnode1 = h('ul', {}, [
h('li', {}, 'A'),
h('li', {}, 'B'),
h('li', {}, 'C'),
])
patch(container, vnode1)
btn.onclick = function () {
const vnode2 = h('ol', {}, [
h('li', {}, 'AA'),
h('li', {}, 'BB'),
h('li', {}, 'CC'),
])
patch(vnode1, vnode2)
}
over。
3.3 diff处理新旧节点是同一个节点时
先上个流程图:
3.3.1 newVnode有text的情况
if (sameVnode(oldVnode, newVnode)) {
// 在内存中是同一个对象
if (oldVnode === newVnode) return
if (newVnode.text !== undefined && (newVnode.children === undefined || newVnode.children.length === 0)) {
// 新vnode有text属性
if (newVnode.text !== oldVnode.text) {
oldVnode.elm.innerText = newVnode.text
}
} else {
// 新vnode没有text属性
}
}
我们实现了newVnode有text的情况,下面两段代码都能实现效果,比较简单,这里就不上图了。
// 第一段
const vnode1 = h('h2', {}, 'hh')
patch(container, vnode1)
btn.onclick = function () {
const vnode2 = h('h2', {}, 'xx')
patch(vnode1, vnode2)
}
// 第二段
const vnode1 = h('h2', {}, [
h('p', {}, 'A'),
h('p', {}, 'B'),
h('p', {}, 'C'),
])
patch(container, vnode1)
btn.onclick = function () {
const vnode2 = h('h2', {}, 'xx')
patch(vnode1, vnode2)
}
3.3.2 newVnode没有text的情况(有children)
1、oldVnode没有children
if (sameVnode(oldVnode, newVnode)) {
// 在内存中是同一个对象
if (oldVnode === newVnode) return
if (newVnode.text !== undefined && (newVnode.children === undefined || newVnode.children.length === 0)) {
// 新vnode有text属性
if (newVnode.text !== oldVnode.text) {
oldVnode.elm.innerText = newVnode.text
}
} else {
// 新vnode没有text属性
if (oldVnode.children !== undefined && oldVnode.children.length > 0) {
// 老的有children
// 最复杂的情况
} else {
// 老的没有children 新的有children
// 第一步:清空老节点内容
oldVnode.elm.innerText = ''
// 第二步:遍历新的vnode子节点 创建DOM上树
for (let i = 0; i < newVnode.children.length; i++) {
const dom = createElement(newVnode.children[i])
oldVnode.elm.appendChild(dom)
}
}
}
}
实现效果:
const vnode1 = h('h2', {}, 'hh')
patch(container, vnode1)
btn.onclick = function () {
const vnode2 = h('h2', {}, [
h('p', {}, 'A'),
h('p', {}, 'B'),
h('p', {}, 'C')
])
patch(vnode1, vnode2)
}
2、oldVnode有children
这一块是diff算法最核心的部分。
为了便于后续操作,我们先把上面的代码单独抽出来,新增patchVnode方法:
function patchVnode(oldVnode, newVnode) {
// 在内存中是同一个对象
if (oldVnode === newVnode) return
if (newVnode.text !== undefined && (newVnode.children === undefined || newVnode.children.length === 0)) {
// 新vnode有text属性
if (newVnode.text !== oldVnode.text) {
oldVnode.elm.innerText = newVnode.text
}
} else {
// 新vnode没有text属性
if (oldVnode.children !== undefined && oldVnode.children.length > 0) {
// 老的有children
// 最复杂的情况
} else {
// 老的没有children 新的有children
// 第一步:清空老节点内容
oldVnode.elm.innerText = ''
// 第二步:便利新的vnode子节点 创建DOM上树
for (let i = 0; i < newVnode.children.length; i++) {
let dom = createElement(newVnode.children[i])
oldVnode.elm.appendChild(dom)
}
}
}
}
export default function patch(oldVnode, newVnode) {
// 判断是不是虚拟节点
if (!isVnode(oldVnode)) {
// 不是虚拟节点 则包装成虚拟节点
oldVnode = emptyNodeAt(oldVnode)
}
if (sameVnode(oldVnode, newVnode)) {
patchVnode(oldVnode, newVnode)
} else {
// 暴力插入新的,删除旧的
let newVnodeElm = createElement(newVnode)
if (oldVnode.elm.parentNode && newVnodeElm) {
oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm)
// 删除老节点
oldVnode.elm.parentNode.removeChild(oldVnode.elm)
}
}
}
3.3.3 分析diff算法 更新子节点操作(重要)
diff提供了4种命中查找方式(4个指针):
1、新前与旧前
2、新后与旧后
3、新后与旧前(涉及移动节点,新后指向的节点,移动到旧后之后)
4、新前与旧后(涉及移动节点,新前指向的节点,移动到旧前之前)
命中判断由上往下,命中一种就不会再命中判断了。
如果都没有命中,就循环来寻找。
1、新前 newStart 与旧前 oldStart
如果命中 1 了,patch之后就移动指针,newStart++,oldStart++
新增 updateChildren 方法。
function updateChildren(parentElm, oldCh, newCh) {
let oldStartIdx = 0 // 旧前
let newStartIdx = 0 // 新前
let oldEndIdx = oldCh.length - 1 // 旧后
let newEndIdx = newCh.length - 1 // 新后
let oldStartVnode = oldCh[0] // 旧前节点
let oldEndVnode = oldCh[oldEndIdx] // 旧后节点
let newStartVnode = newCh[0] // 新前节点
let newEndVnode = newCh[newEndIdx] // 新后节点
}
function patchVnode(oldVnode, newVnode) {
// 在内存中是同一个对象
if (oldVnode === newVnode) return
if (newVnode.text !== undefined && (newVnode.children === undefined || newVnode.children.length === 0)) {
// 新vnode有text属性
if (newVnode.text !== oldVnode.text) {
oldVnode.elm.innerText = newVnode.text
}
} else {
// 新vnode没有text属性
if (oldVnode.children !== undefined && oldVnode.children.length > 0) {
// 老的有children
// 最复杂的情况
updateChildren(oldVnode.elm, oldVnode.children, newVnode.children)
} else {
// 老的没有children 新的有children
// 第一步:清空老节点内容
oldVnode.elm.innerText = ''
// 第二步:便利新的vnode子节点 创建DOM上树
for (let i = 0; i < newVnode.children.length; i++) {
let dom = createElement(newVnode.children[i])
oldVnode.elm.appendChild(dom)
}
}
}
}
开始循环判断:
function updateChildren(parentElm, oldCh, newCh) {
let oldStartIdx = 0 // 旧前
let newStartIdx = 0 // 新前
let oldEndIdx = oldCh.length - 1 // 旧后
let newEndIdx = newCh.length - 1 // 新后
let oldStartVnode = oldCh[0] // 旧前节点
let oldEndVnode = oldCh[oldEndIdx] // 旧后节点
let newStartVnode = newCh[0] // 新前节点
let newEndVnode = newCh[newEndIdx] // 新后节点
// 开始循环
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode)
// 移动指针
oldStartVnode = oldCh(++oldStartIdx)
newStartVnode = newCh(++newStartIdx)
}
}
}
如果没命中,就接着比较下一种情况。
2、新后 newEnd 和 旧后 oldEnd
如果命中 1 了,patch之后就移动指针,newEnd--,oldEnd--
if (sameVnode(newEndVnode, oldEndVnode)) { // 新后与旧后
patchVnode(oldEndVnode, newEndVnode)
// 移动指针
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
}
如果没命中,就接着比较下一种情况。
3、新后 newEnd 与旧前 oldStart
如果命中 3 了,将 新后newEnd 指向的节点移动到 旧后oldEnd 之后,移动指针oldStart++,newEnd--
if (sameVnode(newEndVnode, oldStartVnode)) { // 新后与旧前
patchVnode(oldStartVnode, newEndVnode);
// 当 新后与旧前 命中的时候,此时要移动节点。移动 新后(旧前)指向的这个节点到老节点的 旧后的后面
// 移动节点:只要插入一个已经在DOM树上 的节点,就会被移动
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
}
如果没命中,就接着比较下一种情况。
4、新前 newStart 与旧后 oldEnd
如果命中 4 了,将 新前newStart 指向的节点移动到 旧前oldStart 之前,oldEnd--,newStart++
if (sameVnode(newStartVnode, oldEndVnode)) { // 新前与旧后
patchVnode(oldEndVnode, newStartVnode);
// 当 新前与旧后 命中的时候,此时要移动节点。移动 新前(旧后)指向的这个节点到老节点的 旧前的前面
// 移动节点:只要插入一个已经在DOM树上 的节点,就会被移动
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
}
5、四种都没命中,遍历oldVnode中的key 找到了就移动位置,移动指针newStart++ 用例子分析如下:
const vnode1 = h('ul', {}, [
h('li', { key: 'A' }, 'A'),
h('li', { key: 'B' }, 'B'),
h('li', { key: 'C' }, 'C'),
h('li', { key: 'D' }, 'D'),
h('li', { key: 'E' }, 'E'),
])
patch(container, vnode1)
btn.onclick = function () {
const vnode2 = h('ul', {}, [
h('li', { key: 'B' }, 'B'),
h('li', { key: 'Q' }, 'Q'),
h('li', { key: 'A' }, 'A'),
h('li', { key: 'D' }, 'D'),
])
patch(vnode1, vnode2)
}
通过这个流程,我们可以写出下面的代码:
let keyMap = null;
// 开始循环
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 首先不是判断四种命中,而是要略过已经加undefined标记的东西
if (oldStartVnode == null) {
oldStartVnode = oldCh[++oldStartIdx];
} else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx];
} else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx];
} else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newStartVnode)) { // 新前与旧前
patchVnode(oldStartVnode, newStartVnode)
// 移动指针
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(newEndVnode, oldEndVnode)) { // 新后与旧后
patchVnode(oldEndVnode, newEndVnode)
// 移动指针
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(newEndVnode, oldStartVnode)) { // 新后与旧前
patchVnode(oldStartVnode, newEndVnode);
// 当 新后与旧前 命中的时候,此时要移动节点。移动 新后(旧前)指向的这个节点到老节点的 旧后的后面
// 移动节点:只要插入一个已经在DOM树上 的节点,就会被移动
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(newStartVnode, oldEndVnode)) { // 新前与旧后
patchVnode(oldEndVnode, newStartVnode);
// 当 新前与旧后 命中的时候,此时要移动节点。移动 新前(旧后)指向的这个节点到老节点的 旧前的前面
// 移动节点:只要插入一个已经在DOM树上 的节点,就会被移动
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
// 用这种方式替换每次遍历操作 很优雅
if (!keyMap) {
keyMap = {}
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
const key = oldCh[i].key
if (key) {
keyMap[key] = i
}
}
}
// 寻找当前项(newStartIdx)在keyMap中映射的序号
const idxInOld = keyMap[newStartVnode.key];
if (!idxInOld) {
// 如果 idxInOld 是 undefined 说明是全新的项,要插入
// 被加入的项(就是newStartVnode这项)现不是真正的DOM节点
const dom = createElement(newStartVnode)
parentElm.insertBefore(dom, oldStartVnode.elm);
} else {
// 说明不是全新的项,要移动
const elmToMove = oldCh[idxInOld];
patchVnode(elmToMove, newStartVnode);
// 把这项设置为undefined,表示我已经处理完这项了
oldCh[idxInOld] = undefined;
// 移动
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm);
}
newStartVnode = newCh[++newStartIdx];
}
}
6、while循环结束后
循环结束后,有两种情况:
- newStartIdx<=newEndIdx
说明newVnode还有剩余节点没处理完成,所以要添加这些节点 - oldStartIdx<=oldEndIdx
说明oldVnode还有剩余节点没处理完成,所以要删除这些节点
// 循环结束
if (newStartIdx <= newEndIdx) {
// 说明newVndoe还有剩余节点没有处理,所以要添加这些节点
const before = oldCh[oldStartIdx] == null ? null : oldCh[oldStartIdx].elm;
for (let i = newStartIdx; i <= newEndIdx; i++) {
const dom = createElement(newCh[i])
parentElm.insertBefore(dom, before);
}
} else if (oldStartIdx <= oldEndIdx) {
// 说明oldVnode还有剩余节点没有处理,所以要删除这些节点
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
if (oldCh[i]) {
parentElm.removeChild(oldCh[i].elm);
}
}
}
至此,diff算法已大工告成!!!
用上面的例子演示:
const vnode1 = h('ul', {}, [
h('li', { key: 'A' }, 'A'),
h('li', { key: 'B' }, 'B'),
h('li', { key: 'C' }, 'C'),
h('li', { key: 'D' }, 'D'),
h('li', { key: 'E' }, 'E'),
])
patch(container, vnode1)
btn.onclick = function () {
const vnode2 = h('ul', {}, [
h('li', { key: 'B' }, 'B'),
h('li', { key: 'Q' }, 'Q'),
h('li', { key: 'A' }, 'A'),
h('li', { key: 'D' }, 'D'),
])
patch(vnode1, vnode2)
}
四、patch函数整体代码
import vnode from './vnode.js'
import api from './htmldomapi.js'
// 是不是虚拟节点
function isVnode(vnode) {
return vnode.sel === '' || vnode.sel !== undefined;
}
// 把oldVnode包装成虚拟节点
function emptyNodeAt(elm) {
return vnode(
api.tagName(elm).toLowerCase(),
{},
[],
undefined,
elm
);
}
// 是不是同一个虚拟节点
function sameVnode(vnode1, vnode2) {
return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel
}
// 真正创建节点,将 vnode 创建为 DOM 但不进行插入操作
function createElement(vnode) {
// 创建DOM节点
let domNode = document.createElement(vnode.sel)
// 有子节点还是有文本
if (vnode.text !== '' && (vnode.children === undefined || vnode.children.length === 0)) {
// 他内部是文本
domNode.innerText = vnode.text
} else if (Array.isArray(vnode.children) && vnode.children.length > 0) {
// 内部是子节点,就要递归创建节点
for (let i = 0; i < vnode.children.length; i++) {
// 得到当前children
let ch = vnode.children[i]
// 创建出它的dom,一旦调用了createElement意味着:创建出dom了,并且它的elm属性指向了创建出的dom,但是还没有上树
let chDom = createElement(ch)
// 上树
domNode.appendChild(chDom)
}
}
// 补充elm属性
vnode.elm = domNode
// 返回elm 纯dom对象
return vnode.elm
}
function patchVnode(oldVnode, newVnode) {
// 在内存中是同一个对象
if (oldVnode === newVnode) return
if (newVnode.text !== undefined && (newVnode.children === undefined || newVnode.children.length === 0)) {
// 新vnode有text属性
if (newVnode.text !== oldVnode.text) {
oldVnode.elm.innerText = newVnode.text
}
} else {
// 新vnode没有text属性
if (oldVnode.children !== undefined && oldVnode.children.length > 0) {
// 老的有children
// 最复杂的情况
updateChildren(oldVnode.elm, oldVnode.children, newVnode.children)
} else {
// 老的没有children 新的有children
// 第一步:清空老节点内容
oldVnode.elm.innerText = ''
// 第二步:便利新的vnode子节点 创建DOM上树
for (let i = 0; i < newVnode.children.length; i++) {
let dom = createElement(newVnode.children[i])
oldVnode.elm.appendChild(dom)
}
}
}
}
function updateChildren(parentElm, oldCh, newCh) {
let oldStartIdx = 0 // 旧前
let newStartIdx = 0 // 新前
let oldEndIdx = oldCh.length - 1 // 旧后
let newEndIdx = newCh.length - 1 // 新后
let oldStartVnode = oldCh[0] // 旧前节点
let oldEndVnode = oldCh[oldEndIdx] // 旧后节点
let newStartVnode = newCh[0] // 新前节点
let newEndVnode = newCh[newEndIdx] // 新后节点
let keyMap = null
// 开始循环
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 首先不是判断四种命中,而是要略过已经加undefined标记的东西
if (oldStartVnode == null) {
oldStartVnode = oldCh[++oldStartIdx];
} else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx];
} else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx];
} else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newStartVnode)) { // 新前与旧前
patchVnode(oldStartVnode, newStartVnode)
// 移动指针
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(newEndVnode, oldEndVnode)) { // 新后与旧后
patchVnode(oldEndVnode, newEndVnode)
// 移动指针
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(newEndVnode, oldStartVnode)) { // 新后与旧前
patchVnode(oldStartVnode, newEndVnode);
// 当 新后与旧前 命中的时候,此时要移动节点。移动 新后(旧前)指向的这个节点到老节点的 旧后的后面
// 移动节点:只要插入一个已经在DOM树上 的节点,就会被移动
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(newStartVnode, oldEndVnode)) { // 新前与旧后
patchVnode(oldEndVnode, newStartVnode);
// 当 新前与旧后 命中的时候,此时要移动节点。移动 新前(旧后)指向的这个节点到老节点的 旧前的前面
// 移动节点:只要插入一个已经在DOM树上 的节点,就会被移动
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
if (!keyMap) {
keyMap = {}
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
const key = oldCh[i].key
if (key) {
keyMap[key] = i
}
}
}
// 寻找当前项(newStartIdx)在keyMap中映射的序号
const idxInOld = keyMap[newStartVnode.key];
if (!idxInOld) {
// 如果 idxInOld 是 undefined 说明是全新的项,要插入
// 被加入的项(就是newStartVnode这项)现不是真正的DOM节点
const dom = createElement(newStartVnode)
parentElm.insertBefore(dom, oldStartVnode.elm);
} else {
// 说明不是全新的项,要移动
const elmToMove = oldCh[idxInOld];
patchVnode(elmToMove, newStartVnode);
// 把这项设置为undefined,表示我已经处理完这项了
oldCh[idxInOld] = undefined;
// 移动,调用insertBefore也可以实现移动。
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm);
}
newStartVnode = newCh[++newStartIdx];
}
}
// 循环结束
if (newStartIdx <= newEndIdx) {
// 说明newVndoe还有剩余节点没有处理,所以要添加这些节点
const before = oldCh[oldStartIdx] == null ? null : oldCh[oldStartIdx].elm;
for (let i = newStartIdx; i <= newEndIdx; i++) {
const dom = createElement(newCh[i])
parentElm.insertBefore(dom, before);
}
} else if (oldStartIdx <= oldEndIdx) {
// 说明oldVnode还有剩余节点没有处理,所以要删除这些节点
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
if (oldCh[i]) {
parentElm.removeChild(oldCh[i].elm);
}
}
}
}
export default function patch(oldVnode, newVnode) {
// 判断是不是虚拟节点
if (!isVnode(oldVnode)) {
// 不是虚拟节点 则包装成虚拟节点
oldVnode = emptyNodeAt(oldVnode)
}
if (sameVnode(oldVnode, newVnode)) {
patchVnode(oldVnode, newVnode)
} else {
// 暴力插入新的,删除旧的
let newVnodeElm = createElement(newVnode)
if (oldVnode.elm.parentNode && newVnodeElm) {
oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm)
// 删除老节点
oldVnode.elm.parentNode.removeChild(oldVnode.elm)
}
}
}
如果这篇文章对您有帮助,请给个赞吧~~~