参考:Vue 源码解析系列课程
源码:https://gitee.com/szluyu99/vue-source-learn/tree/master/Snabbdom_Study
大纲:
简单介绍虚拟 DOM 和 diff 算法:


diff 是发生在虚拟 DOM 上的:

snabbdom 是著名的虚拟 DOM 库,是 diff 算法的鼻祖,Vue 源码借鉴了 snabbdom
git 地址:https://github.com/snabbdom/snabbdom
snabbdom 安装注意事项:
npm i -D snabbdom
snabbdom 的测试环境搭建:
webpack@5 以上的版本,因为旧版本 webpack 没有读取身份证中 exports 的能力npm i -D webpack@5 webpack-cli@3 webpack-dev-server@3
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


diff 是发生在虚拟 DOM 上的:

这门课程并不研究 真实 DOM 如何变成虚拟 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>
一个虚拟节点具有以下属性:
props、class虚拟节点使用实例:
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('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 算法的心得:
后面两个操作在实际的 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);
}
}
}
}