虚拟 DOM 核心原理
参考:Vue 源码解析系列课程
系列笔记:
大纲:
- snabbdom 简介
- snabbdom 的 h 函数如何工作
- diff 算法原理
- 手写 diff 算法
本章源码:https://gitee.com/szluyu99/vue-source-learn/tree/master/Snabbdom_Study
简单介绍 Dom 和 diff
简单介绍虚拟 DOM 和 diff 算法:
diff 是发生在虚拟 DOM 上的:
snabbdom 简介和环境搭建
snabbdom 是著名的虚拟 DOM 库,是 diff 算法的鼻祖,Vue 源码借鉴了 snabbdom
git 地址:https://github.com/snabbdom/snabbdom
snabbdom 安装注意事项:
- git 上的 snabbdom 源码是用 TypeScript写 的,git 上并不提供编译好的JavaScript版本
- 如果要直接使用 build 出来的 JavaScript 版的 snabbdom 库,可以从 npm 上下载
npm i -D snabbdom
snabbdom 的测试环境搭建:
- snabbdom 库是 DOM 库,当然不能在 nodejs 环境运行,需要搭建 webpack和 webpack-dev-server 开发环境
- 注意:必须安装
webpack@5
以上的版本,因为旧版本 webpack 没有读取身份证中 exports 的能力
npm i -D webpack@5 webpack-cli@3 webpack-dev-server@3
- 参考 webpack 官网,书写好 webpack.config.js 文件
npm init
npm i -D webpack@5 webpack-cli@3 webpack-dev-server@3
npm i -D snabbdom
webpack.config.js:
const path = require('path');
module.exports = {
// 入口
entry: './src/index.js',
// 出口
output: {
// 虚拟打包路径,文件夹不会真正生成,而是在 8080 端口虚拟生成
publicPath: 'xuni',
// 打包出来的文件名
filename: 'bundle.js',
},
devServer: {
// 端口号
port: 8080,
// 静态资源文件夹
contentBase: 'www'
}
};
将 snabbdom 仓库首页的测试代码放到 index.js,看到以下界面则环境搭建成功:
不要忘记在 index.html 中放一个
div#container
源码学习
本章源码:https://gitee.com/szluyu99/vue-source-learn/tree/master/Snabbdom_Study
虚拟 DOM 和 h 函数
diff 是发生在虚拟 DOM 上的:
这门课程并不研究 真实 DOM 如何变成虚拟 DOM。
这门课程研究的内容:
- 虚拟 DOM 是如何被渲染函数(h 函数)产生?
- diff 算法原理
- 虚拟 DOM 如何通过 diff 变成真实 DOM
本课程不研究 真实 DOM => 虚拟 DOM,但是 虚拟 DOM => 真实 DOM 是包含在 diff 算法中
h 函数用来产生 虚拟节点 (vnode):
1、调用 h 函数
var vnode = h('a', {
props: {
href: 'http://www.atguigu.com' }}, '尚硅谷');
2、得到的虚拟节点如下:
{
"sel": "a", "data": {
props: {
href: 'http://www.atguigu.com' } }, "text": "尚硅谷" }
3、它表示的真正的 DOM 节点:
<a href="http://www.atguigu.com">尚硅谷</a>
一个虚拟节点具有以下属性:
- children 子虚拟节点
- data 节点附带的一些数据,比如
props
、class
- elm 对应的真实 DOM 节点
- key 节点的唯一标识
- sel 对应的标签
- text 标签内容
虚拟节点使用实例:
1、创建 patch 函数
2、创建虚拟节点
3、虚拟节点上树
// 创建出 patch 函数
var patch = init([classModule, propsModule, styleModule, eventListenersModule]);
// 创建虚拟节点
var myVnode1 = h(
"a",
{
props: {
href: "https://www.baidu.com ",
target: "_blank",
},
},
"百度"
);
// const myVnode2 = h('div', {}, '我是一个盒子')
const myVnode2 = h('div', '我是一个盒子') // 没有属性可以简写
const myVnode3 = h('ul', [
h('li', '苹果'),
h('li', '香蕉'),
h('li', '西瓜'),
h('li', [h('span', '火龙果'), h('span', '榴莲')])
])
// 让虚拟节点上树
const container = document.getElementById("container");
patch(container, myVnode3);
h 函数的嵌套使用:
h 函数用法很灵活:
手写 h 函数
我们实现的是低配版的 h 函数,不考虑过多的函数重载功能,满足以下三种用法:
h('div', {}, 'hello')
,第三个参数为 字符串 或 数字h('div', {}, []
,第三个参数为 数组(且数组元素都是 h 函数)h('div', {}, h())
,第三个参数就是一个 h 函数
h 函数的用法展示:
var myVnode1 = h('div', {
}, 'hello')
var myVnode2 = h('div', {
}, [
h('p', {
}, 'A'),
h('p', {
}, 'B'),
h('p', {
}, h('span', {
}, 'C')),
])
vnode.js
:
/**
* 该函数的功能非常简单,就是把传入的 5 个参数组合成对象返回
*/
export default function(sel, data, children, text, elm) {
const key = data.key
return {
sel, data, children, text, elm, key
}
}
h.js
:
// 低配版本的 h 函数,必须接受 3 个参数,弱化它的重载功能
// 对于函数 h(sel, data, c) 有三种形态:
// 形态1:h('div', {}, 'hello')
// 形态2:h('div', {}, [])
// 形态3:h('div', {}, h())
export default function h(sel, data, c) {
// 检查参数的个数
if (arguments.length != 3) {
throw new Error("对不起,h 函数必须传入 3 个参数,这是低配版 h 函数");
}
// 检查参数 c 的类型
if (typeof c == "string" || typeof c == "number") {
// 调用 h 函数是形态1 h('div', {}, 'hello')
return vnode(sel, data, undefined, c, undefined);
} else if (Array.isArray(c)) {
// 调用 h 函数是形态2 h('div', {}, [])
let children = []
// 遍历 c,收集 children
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 收集完毕,返回虚拟节点,具有 children 属性
return vnode(sel, data, children, undefined, undefined)
} else if (typeof c == "object" && c.hasOwnProperty("sel")) {
// 调用 h 函数是形态3 h('div', {}, h())
// 即,传入的 c 是唯一的 children
let children = [c]
return vnode(sel, data, children, undefined, undefined)
} else {
throw new Error("传入的第三个参数类型不对");
}
}
diff 算法
diff 算法的心得:
- key 很重要,key 是节点的唯一标识,它会告诉 diff 算法,在更改前后它们是同一个 DOM 节点
- 只有是同一个虚拟节点,才会进行精细化比较,否则就是暴力删除旧的,插入新的
- 只进行同层比较,不会进行跨层比较,否则还是暴力删除旧的,插入新的
后面两个操作在实际的 Vue 开发中,基本不会遇见,这是合理的优化机制
patch 函数的整体逻辑:
如何定义 “相同节点” 这个概念?
/**
* 判断 vnode1 和 vnode2 是不是同一个节点
*/
function sameVnode(vnode1, vnode2) {
// 旧节点的 key === 新节点的 key 且 旧节点的选择器 === 新节点的选择器
return vnode1.key == vnode2.key && vnode1.sel == vnode2.sel
}
patch.js
:
export default function patch(oldVnode, newVnode) {
// 判断传入的第一个参数,是 DOM 节点还是虚拟节点
if (!oldVnode.sel) {
// 如果是 DOM 节点,则包装为虚拟节点
oldVnode = vnode(oldVnode.tagName.toLowerCase(), {
}, [], undefined, oldVnode)
}
// 判断 oldVnode 和 newVnode 是不是同一个节点
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)
}
}
createElement.js
:根据 vnode 创建真实的 DOM
/**
* 根据 vnode 创建 DOM 节点
*/
export default function createElement(vnode){
// 创建一个 DOM 节点,这个节点现在还是孤儿节点
let domNode = document.createElement(vnode.sel);
// 判断内部是文本还是有子节点
if (vnode.text && (!vnode.children|| !vnode.children.length )){
// 内部是文本(让文字上树)
domNode.innerText = vnode.text
} else if (Array.isArray(vnode.children) && vnode.children.length) {
// 内部是子节点,递归创建节点
for (let i = 0; i < vnode.children.length; i++) {
// 得到当前子节点
let ch = vnode.children[i]
// 创建出它的 DOM,一旦调用 createElement 函数,就会创建出一个 DOM 节点,但是还没有上树
let chDOM = createElement(ch)
// 上树
domNode.appendChild(chDOM)
}
}
// 补充 elm 属性,elm 是一个纯 DOM 对象
vnode.elm = domNode
return domNode
}
精细化比较的逻辑:
patchVnode.js
:
diff 的难点与核心抽取到
updateChildren
函数中,在后面介绍
/**
* 对比同一个虚拟节点,精细化比较
*/
export default function patchVnode(oldvnode, newvnode) {
// 判断新旧 vnode 是否是同一个节点
if (oldvnode == newvnode) return
// 判断新 vnode 是否有 text 属性
if (newvnode.text && (!newvnode.children || !newvnode.children.length)) {
// 新 vnode 有 text 属性
console.log('新 vnode 有 text 属性');
if (newvnode.text != oldvnode.text) {
// 新vnode 和 旧vnode 的 text 属性不一样,则更新 text
// 如果 旧node 是 children 属性,会消失掉
oldvnode.elm.innertext = newvnode.text
newVnode.elm = oldVnode.elm // 补充
}
} else {
// 新 vnode 没有 text 属性,有 children
console.log('新 vnode 没有 text 属性');
if (oldvnode.children && oldvnode.children.length) {
// 新老节点都有 children,最复杂的情况
// 这里是 diff 的难点与核心!!!!!!
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)
}
}
}
}
diff 中四种命中查找:
- 新前与旧前,命中则,同时下移
- 新后与旧后,命中则,同时上移
- 新后与旧前,命中则,新前指向的节点,移动到旧后之后
- 新前与旧后,命中则,新前指向的节点,移动到旧前之前
命中一种就不再进行判断,未命中则从上往下依次判断
如果都没有命中,就需要用循环来进行寻找
这个地方还有很多疑问???
export default function updateChildren(parentElm, oldCh, newCh) {
console.log('updateChildren');
console.log(oldCh, newCh);
// 旧前
let oldStartIdx = 0
// 新前
let newStartIdx = 0
// 旧后
let oldEndIdx = oldCh.length - 1
// 新后
let newEndIdx = newCh.length - 1
// 旧前节点
let oldStartVnode = oldCh[oldStartIdx]
// 旧后节点
let oldEndVnode = oldCh[oldEndIdx]
// 新前节点
let newStartVnode = newCh[newStartIdx]
// 新后节点
let newEndVnode = newCh[newEndIdx]
let keyMap = {
}
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 首先不是判断 1234 命中,而是要略过已经加undefined标记的东西
if (!oldStartVnode || !oldCh[oldStartIdx]) {
oldStartVnode = oldCh[++oldStartIdx]
} else if (!oldEndVnode || !oldCh[oldEndIdx]) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (!newStartVnode || !newCh[newStartIdx]) {
newStartVnode = newCh[++newStartIdx]
} else if (!newEndVnode || !newCh[newEndIdx]) {
newEndVnode = newCh[--newEndIdx]
}
// 开始依次判断 1234 命中
else if (sameVnode(oldStartVnode, newStartVnode)) {
// 命中1 新前 和 旧前
console.log('1 新前 和 旧前');
patchVnode(oldStartVnode, newStartVnode)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// 命中2 新后 和 旧后
console.log('2 新后 和 旧后');
patchVnode(oldEndVnode, newEndVnode)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(newEndVnode, oldStartVnode)) {
// 命中3 新后 和 旧前
console.log('3 新后 和 旧前');
patchVnode(oldStartVnode, newEndVnode)
// 移动节点,移动新前指向的节点到旧后的后面
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(newStartVnode, oldEndVnode)) {
// 命中4 新前 和 旧后
console.log('4 新前 和 旧后');
patchVnode(oldEndVnode, newStartVnode)
// 移动节点,移动新前指向的节点到旧前的前面
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
// 四种命中都没有命中
console.log('5 都没有匹配');
// 寻找 key 的 map
if (!keyMap) {
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
const key = oldCh[i].key
if (key) keyMap[key] = i
}
}
console.log(keyMap);
const idxInOld = keyMap[newStartVnode.key];
console.log(idxInOld);
if (idxInOld == undefined) {
// 判断,如果idxInOld是undefined表示它是全新的项
// 被加入的项(就是newStartVnode这项)现不是真正的DOM节点
parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm);
} else {
// 如果不是undefined,不是全新的项,而是要移动
const elmToMove = oldCh[idxInOld];
patchVnode(elmToMove, newStartVnode);
// 把这项设置为undefined,表示我已经处理完这项了
oldCh[idxInOld] = undefined;
// 移动,调用insertBefore也可以实现移动。
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm);
}
// 指针下移,只移动新的头
newStartVnode = newCh[++newStartIdx];
}
}
// 继续看看有没有剩余的节点
if (newStartIdx <= newEndIdx) {
console.log('还有剩余节点没有处理');
// 遍历新的newCh,添加到老的没有处理的之前
for (let i = newStartIdx; i <= newEndIdx; i++) {
// insertBefore方法可以自动识别null,如果是null就会自动排到队尾去。和appendChild是一致了。
// newCh[i]现在还没有真正的DOM,所以要调用createElement()函数变为DOM
parentElm.insertBefore(createElement(newCh[i]), oldCh[oldStartIdx].elm);
}
} else if (oldStartIdx <= oldEndIdx) {
console.log('old还有剩余节点没有处理,要删除项');
// 批量删除oldStart和oldEnd指针之间的项
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
if (oldCh[i]) {
parentElm.removeChild(oldCh[i].elm);
}
}
}
}