来自尚硅谷的课程笔记 课程链接 尚硅谷邵山欢(考拉老师)Vue之虚拟DOM和diff算法
加入大量的注释以及大量改写,新增很多插图解释diff算法
第八章仔细说明了老师说的一些比较含糊的地方
源码链接 https://gitee.com/ykang2020/vue_learn
目标:手写h函数
目标:手写diff算法
本课程不涉及DOM如何变成虚拟DOM
这属于模板编译原理范畴
但是虚拟节点变成DOM节点在diff中可以做到
新虚拟DOM和旧虚拟DOM进行diff(精细化比较),算出应该如何最小量更新,最后反映到真正的DOM上
snabbdom
(瑞典语,“速度”)是著名的虚拟DOM库,是diff算法的鼻祖
Vue源码借鉴了snabbdom
源码使用TypeScript写的https://github.com/snabbdom/snabbdom
从npm下载的是build出来的JavaScript版本
npm install -D snabbdom
cnpm install -S snabbdom
cnpm i -D webpack@5 webpack-cli@3 webpack-dev-server@3
配置webpack5
module.exports = {
// webpack5 不用配置mode
// 入口
entry: "./src/index.js",
// 出口
output: {
// 虚拟打包路径,文件夹不会真正生成,而是在8080端口虚拟生成
publicPath: "xuni",
// 打包出来的文件名
filename: "bundle.js",
},
// 配置webpack-dev-server
devServer: {
// 静态根目录
contentBase: 'www',
// 端口号
port: 8080,
},
};
src/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 vnode = h("div#container.two.classes", {
on: {
click: function () {
} } }, [
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: function () {
} } },
[
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
{
children: undefined// 子元素 数组
data: {
} // 属性、样式、key
elm: undefined // 对应的真正的dom节点(对象),undefined表示节点还没有上dom树
key: // 唯一标识
sel: "" // 选择器
text: "" // 文本内容
}
// 创建虚拟节点
var myVnode1 = h('a', {
props: {
href: 'https://www.baidu.com' } }, 'YK菌')
console.log(myVnode1)
// 创建patch函数
const patch = init([
classModule,
propsModule,
styleModule,
eventListenersModule,
]);
// 创建虚拟节点
var myVnode1 = h(
"a",
{
props: {
href: "https://www.baidu.com", target: "_blank" } },
"YK菌"
);
// 让虚拟节点上树
let container = document.getElementById("container");
patch(container, myVnode1);
const myVnode3 = h('ul', [
h('li', '苹果'),
h('li', '香蕉'),
h('li', '西瓜'),
h('li', '番茄'),
])
// 让虚拟节点上树
let container = document.getElementById("container");
patch(container, myVnode3);
const myVnode3 = h('ul', [
h('li', '苹果'),
h('li', [
h('div', [
h('p', '香蕉'),
h('p', '草莓')
])
]),
h('li', h('span','西瓜')),
h('li', '番茄'),
])
将传入的参数组合成对象返回
/**
* 产生虚拟节点
* 将传入的参数组合成对象返回
* @param {string} sel 选择器
* @param {object} data 属性、样式
* @param {Array} children 子元素
* @param {string|number} text 文本内容
* @param {object} elm 对应的真正的dom节点(对象),undefined表示节点还没有上dom树
* @returns
*/
export default function(sel, data, children, text, elm) {
const key = data.key;
return {
sel, data, children, text, elm, key };
}
产生虚拟DOM树,返回的是一个对象
import vnode from "./vnode";
/**
* 产生虚拟DOM树,返回的一个对象
* 低配版本的h函数,这个函数必须接受三个参数,缺一不可
* @param {*} sel
* @param {*} data
* @param {*} c
* 调用只有三种形态 文字、数组、h函数
* ① h('div', {}, '文字')
* ② h('div', {}, [])
* ③ h('div', {}, h())
*/
export default function (sel, data, c) {
// 检查参数个数
if (arguments.length !== 3) {
throw new Error("请传且只传入三个参数!");
}
// 检查第三个参数 c 的类型
if (typeof c === "string" || typeof c === "number") {
// 说明现在是 ① 文字
return vnode(sel, data, undefined, c, undefined);
} else if (Array.isArray(c)) {
// 说明是 ② 数组
let children = [];
// 遍历 c 数组
for (let item of c) {
if (!(typeof item === "object" && item.hasOwnProperty("sel"))) {
throw new Error("传入的数组有不是h函数的项");
}
// 不用执行item, 只要收集数组中的每一个对象
children.push(item);
}
return vnode(sel, data, children, undefined, undefined);
} else if (typeof c === "object" && c.hasOwnProperty("sel")) {
// 说明是 ③ h函数 是一个对象(h函数返回值是一个对象)放到children数组中就行了
let children = [c];
return vnode(sel, data, children, undefined, undefined);
} else {
throw new Error("传入的参数类型不对!");
}
}
import h from "./my_snabbdom/h";
const myVnode1 = h("div", {
}, [
h("p", {
}, "嘻嘻"),
h("p", {
}, "哈哈"),
h("p", {
}, h('span', {
}, '呵呵')),
]);
console.log(myVnode1);
最小量更新,key
很关键。key
是这个节点的唯一标识,告诉diff算法,在更改前后它们是同一个DOM节点。
只是同一个虚拟节点,才进行精细化比较(往ul中的 li 添加 li),否则就是暴力删除旧的、插入新的(ul中的li 换到在 ol 中去)
问题: 如何定义是同一个虚拟节点
答:选择器相同且key相同
只进行同层比较,不会进行跨层比较。即使是同一片 虚拟节点,但是跨层了,diff就是暴力删除旧的,然后插入新的
patch(container, myVnode1)
var insertedNode = parentNode.insertBefore(newNode, referenceNode);
insertedNode
:被插入节点(newNode
)
parentNode
:新插入节点的父节点
newNode
:用于插入的节点
referenceNode
:newNode
将要插在这个节点之前
在当前节点下增加一个子节点 Node
,并使该子节点位于参考节点的前面。
element.appendChild(aChild)
将一个节点附加到指定父节点的子节点列表的末尾处。
如果将被插入的节点已经存在于当前文档的文档树中,那么 appendChild()
只会将它从原先的位置移动到新的位置(不需要事先移除要移动的节点)。
返回当前元素的标签名
elementName = element.tagName
elementName
是一个字符串,包含了element元素的标签名.
在HTML文档中, tagName
会返回其大写形式
从DOM中删除一个子节点。返回删除的节点
let oldChild = node.removeChild(child);
//OR
element.removeChild(child);
child
是要移除的那个子节点.
node
是child的父节点.
oldChild
保存对删除的子节点的引用. oldChild === child.
var element = document.createElement(tagName[, options]);
tagName
:指定要创建元素类型的字符串, 创建元素时的 nodeName 使用 tagName 的值为初始化,该方法不允许使用限定名称(如:“html:a”),在 HTML 文档上调用 createElement() 方法创建元素之前会将tagName 转化成小写,在 Firefox、Opera 和 Chrome 内核中,createElement(null) 等同于 createElement(“null”)
返回
新建的元素(Element)
import vnode from "./vnode";
import createElement from "./createElement";
export default function (oldVnode, newVnode) {
// 判断传入的第一个参数是 DOM节点 还是 虚拟节点
if (oldVnode.sel == "" || oldVnode.sel === undefined) {
// 说明oldVnode是DOM节点,此时要包装成虚拟节点
oldVnode = vnode(
oldVnode.tagName.toLowerCase(), // sel
{
}, // data
[], // children
undefined, // text
oldVnode // elm
);
}
// 判断 oldVnode 和 newVnode 是不是同一个节点
if (oldVnode.key === newVnode.key && oldVnode.sel === newVnode.sel) {
console.log("是同一个节点,需要精细化比较");
} else {
console.log("不是同一个节点,暴力 插入新节点,删除旧节点");
// 创建 新虚拟节点 为 DOM节点
// 要操作DOM,所以都要转换成 DOM节点
let newVnodeElm = createElement(newVnode);
let oldVnodeElm = oldVnode.elm;
// 插入 新节点 到 旧节点 之前
if (newVnodeElm) {
// 判断newVnodeElm是存在的 在旧节点之前插入新节点
oldVnodeElm.parentNode.insertBefore(newVnodeElm, oldVnodeElm);
}
// 删除旧节点
oldVnodeElm.parentNode.removeChild(oldVnodeElm);
}
}
创建节点。将vnode虚拟节点创建为DOM节点
/**
* 创建节点。将vnode虚拟节点创建为DOM节点
* 是孤儿节点,不进行插入操作
* @param {object} vnode
*/
export default function createElement(vnode) {
// 根据虚拟节点sel选择器属性 创建一个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 ch of vnode.children) {
// 递归调用 创建出它的DOM,一旦调用createElement意味着创建出DOM了。并且它的elm属性指向了创建出的dom,但是没有上树,是一个孤儿节点
let chDOM = createElement(ch); // 得到 子节点 表示的 DOM节点 递归最后返回的一定是文本节点
console.log(ch);
// 文本节点 上domNode树
domNode.appendChild(chDOM);
}
}
// 补充虚拟节点的elm属性
vnode.elm = domNode;
// 返回domNode DOM对象
return domNode;
}
import h from "./my_snabbdom/h";
import patch from "./my_snabbdom/patch";
let container = document.getElementById("container");
let btn = document.getElementById("btn");
// const myVnode1 = h("h1", {}, "你好");
const myVnode1 = h("ul", {
}, [
h("li", {
}, "A"),
h("li", {
}, "B"),
h("li", {
}, "C"),
h("li", {
}, "D"),
]);
// 上树
patch(container, myVnode1);
// 判断 oldVnode 和 newVnode 是不是同一个节点
if (oldVnode.key === newVnode.key && oldVnode.sel === newVnode.sel) {
console.log("是同一个节点,需要精细化比较");
patchVnode(oldVnode, newVnode);
}
export default function patchVnode(oldVnode, newVnode) {
// 1. 判断新旧 vnode 是否是同一个对象
if (oldVnode === newVnode) return;
// 2. 判断 newVndoe 有没有 text 属性
if (
newVnode.text !== undefined &&
(newVnode.children === undefined || newVnode.children.length === 0)
) {
// newVnode 有 text 属性
// 2.1 判断 newVnode 与 oldVnode 的 text 属性是否相同
if (newVnode.text !== oldVnode.text) {
// 如果newVnode中的text和oldVnode的text不同,那么直接让新text写入老elm中即可。
// 如果oldVnode中是children,也会立即消失
oldVnode.elm.innerText = newVnode.text;
}
} else {
// newVnode 没有text属性 有children属性
// 2.2 判断 oldVnode 有没有 children 属性
if (oldVnode.children !== undefined && oldVnode.children.length > 0) {
// oldVnode有children属性 最复杂的情况,新老节点都有children
} else {
// oldVnode没有children属性 说明有text; newVnode有children属性
// 清空oldVnode的内容
oldVnode.elm.innerHTML = "";
// 遍历新的vnode虚拟节点的子节点,创建DOM,上树
for (let ch of newVnode.children) {
let chDOM = createElement(ch);
oldVnode.elm.appendChild(chDOM);
}
}
}
}
import h from "./my_snabbdom/h";
import patch from "./my_snabbdom/patch";
let container = document.getElementById("container");
let btn = document.getElementById("btn");
const myVnode1 = h("h1", {
}, "你好");
// 上树
patch(container, myVnode1);
const myVnode2 = h("ul", {
}, [
h("li", {
}, "A"),
h("li", {
}, "B"),
h("li", {
}, "C"),
h("li", {
}, "D"),
]);
btn.onclick = function () {
patch(myVnode1, myVnode2);
}
如果命中①了,patch之后就移动头指针 newStart++ oldStart++
if (checkSameVnode(oldStartVnode, newStartVnode)) {
// 新前与旧前
console.log(" ①1 新前与旧前 命中");
// 精细化比较两个节点 oldStartVnode现在和newStartVnode一样了
patchVnode(oldStartVnode, newStartVnode);
// 移动指针,改变指针指向的节点,这表示这两个节点都处理(比较)完了
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
}
如果没命中就接着比较下一种情况
如果命中②了,patch后就移动尾指针 newEnd-- oldEnd–
if (checkSameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
}
如果没命中就接着比较下一种情况
如果命中③了,将 新后newEnd 指向的节点移动到 旧后oldEnd 之后
if (checkSameVnode(oldStartVnode, newEndVnode)) {
// 新后与旧前
console.log(" ③3 新后与旧前 命中");
patchVnode(oldStartVnode, newEndVnode);
// 当③新后与旧前命中的时候,此时要移动节点。移动 新后(旧前) 指向的这个节点到老节点的 旧后的后面
// 移动节点:只要插入一个已经在DOM树上 的节点,就会被移动
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
}
如果没命中就接着比较下一种情况
如果命中④了,将 新前newStart 指向的节点,移动到 旧前oldStart 之前
if (checkSameVnode(oldEndVnode, newStartVnode)) {
// 新前与旧后
console.log(" ④4 新前与旧后 命中");
patchVnode(oldEndVnode, newStartVnode);
// 当④新前与旧后命中的时候,此时要移动节点。移动 新前(旧后) 指向的这个节点到老节点的 旧前的前面
// 移动节点:只要插入一个已经在DOM树上的节点,就会被移动
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
}
如果没命中就表示四种情况都没有命中
找到了就 移动位置 移动指针newStart++
没找的就是新节点,直接插入所有未处理旧节点之前
// 四种都没有匹配到,都没有命中
console.log("四种都没有命中");
// 寻找 keyMap 一个映射对象, 就不用每次都遍历old对象了
if (!keyMap) {
keyMap = {
};
// 记录oldVnode中的节点出现的key
// 从oldStartIdx开始到oldEndIdx结束,创建keyMap
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
const key = oldCh[i].key;
if (key !== undefined) {
keyMap[key] = i;
}
}
}
console.log(keyMap);
// 寻找当前项(newStartIdx)在keyMap中映射的序号
const idxInOld = keyMap[newStartVnode.key];
if (idxInOld === undefined) {
// 如果 idxInOld 是 undefined 说明是全新的项,要插入
// 被加入的项(就是newStartVnode这项)现不是真正的DOM节点
parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm);
} else {
// 说明不是全新的项,要移动
const elmToMove = oldCh[idxInOld];
patchVnode(elmToMove, newStartVnode);
// 把这项设置为undefined,表示我已经处理完这项了
oldCh[idxInOld] = undefined;
// 移动,调用insertBefore也可以实现移动。
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm);
}
// newStartIdx++;
newStartVnode = newCh[++newStartIdx];
结束后
// 循环结束
if (newStartIdx <= newEndIdx) {
// 说明newVndoe还有剩余节点没有处理,所以要添加这些节点
for (let i = newStartIdx; i <= newEndIdx; i++) {
// insertBefore方法可以自动识别null,如果是null就会自动排到队尾,和appendChild一致
parentElm.insertBefore(createElement(newCh[i]), oldCh[oldStartIdx].elm);
}
} else if (oldStartIdx <= oldEndIdx) {
// 说明oldVnode还有剩余节点没有处理,所以要删除这些节点
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
if (oldCh[i]) {
parentElm.removeChild(oldCh[i].elm);
}
}
}
// 2.2 判断 oldVnode 有没有 children 属性
if (oldVnode.children !== undefined && oldVnode.children.length > 0) {
// oldVnode有children属性 最复杂的情况,新老节点都有children
updateChildren(oldVnode.elm, oldVnode.children, newVnode.children);
}
import createElement from "./createElement";
import patchVnode from "./patchVnode";
/**
*
* @param {object} parentElm Dom节点
* @param {Array} oldCh oldVnode的子节点数组
* @param {Array} newCh newVnode的子节点数组
*/
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[0];
// 旧后节点
let oldEndVnode = oldCh[oldEndIdx];
// 新前节点
let newStartVnode = newCh[0];
// 新后节点
let newEndVnode = newCh[newEndIdx];
let keyMap = null;
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
console.log("**循环中**");
// 首先应该不是判断四种命中,而是略过已经加了undefined标记的项
if (oldStartVnode === null || oldCh[oldStartIdx] === undefined) {
oldStartVnode = oldCh[++oldStartIdx];
} else if (oldEndVnode === null || oldCh[oldEndIdx] === undefined) {
oldEndVnode = oldCh[--oldEndIdx];
} else if (newStartVnode === null || newCh[newStartIdx] === undefined) {
newStartVnode = newCh[++newStartIdx];
} else if (newEndVnode === null || newCh[newEndIdx] === undefined) {
newEndVnode = newCh[--newEndIdx];
} else if (checkSameVnode(oldStartVnode, newStartVnode)) {
// 新前与旧前
console.log(" ①1 新前与旧前 命中");
// 精细化比较两个节点 oldStartVnode现在和newStartVnode一样了
patchVnode(oldStartVnode, newStartVnode);
// 移动指针,改变指针指向的节点,这表示这两个节点都处理(比较)完了
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (checkSameVnode(oldEndVnode, newEndVnode)) {
// 新后与旧后
console.log(" ②2 新后与旧后 命中");
patchVnode(oldEndVnode, newEndVnode);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (checkSameVnode(oldStartVnode, newEndVnode)) {
// 新后与旧前
console.log(" ③3 新后与旧前 命中");
patchVnode(oldStartVnode, newEndVnode);
// 当③新后与旧前命中的时候,此时要移动节点。移动 新后(旧前) 指向的这个节点到老节点的 旧后的后面
// 移动节点:只要插入一个已经在DOM树上 的节点,就会被移动
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (checkSameVnode(oldEndVnode, newStartVnode)) {
// 新前与旧后
console.log(" ④4 新前与旧后 命中");
patchVnode(oldEndVnode, newStartVnode);
// 当④新前与旧后命中的时候,此时要移动节点。移动 新前(旧后) 指向的这个节点到老节点的 旧前的前面
// 移动节点:只要插入一个已经在DOM树上的节点,就会被移动
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
// 四种都没有匹配到,都没有命中
console.log("四种都没有命中");
// 寻找 keyMap 一个映射对象, 就不用每次都遍历old对象了
if (!keyMap) {
keyMap = {
};
// 记录oldVnode中的节点出现的key
// 从oldStartIdx开始到oldEndIdx结束,创建keyMap
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
const key = oldCh[i].key;
if (key !== undefined) {
keyMap[key] = i;
}
}
}
console.log(keyMap);
// 寻找当前项(newStartIdx)在keyMap中映射的序号
const idxInOld = keyMap[newStartVnode.key];
if (idxInOld === undefined) {
// 如果 idxInOld 是 undefined 说明是全新的项,要插入
// 被加入的项(就是newStartVnode这项)现不是真正的DOM节点
parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm);
} else {
// 说明不是全新的项,要移动
const elmToMove = oldCh[idxInOld];
patchVnode(elmToMove, newStartVnode);
// 把这项设置为undefined,表示我已经处理完这项了
oldCh[idxInOld] = undefined;
// 移动,调用insertBefore也可以实现移动。
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm);
}
// newStartIdx++;
newStartVnode = newCh[++newStartIdx];
}
}
// 循环结束
if (newStartIdx <= newEndIdx) {
// 说明newVndoe还有剩余节点没有处理,所以要添加这些节点
// // 插入的标杆
// const before =
// newCh[newEndIdx + 1] === null ? null : newCh[newEndIdx + 1].elm;
for (let i = newStartIdx; i <= newEndIdx; i++) {
// insertBefore方法可以自动识别null,如果是null就会自动排到队尾,和appendChild一致
parentElm.insertBefore(createElement(newCh[i]), oldCh[oldStartIdx].elm);
}
} else if (oldStartIdx <= oldEndIdx) {
// 说明oldVnode还有剩余节点没有处理,所以要删除这些节点
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
if (oldCh[i]) {
parentElm.removeChild(oldCh[i].elm);
}
}
}
}
// 判断是否是同一个节点
function checkSameVnode(a, b) {
return a.sel === b.sel && a.key === b.key;
}
源码链接 https://gitee.com/ykang2020/vue_learn