Snabbdom
一个精简化、模块化、功能强大、性能卓越的虚拟 DOM 库
安装: npm i snabbdom
问题一
Q:虚拟DOM如何被渲染函数产生
A:手写一个h函数
1.一个虚拟节点有哪些属性?
{
children: undefined
data: {}
elm: undefined // 表示虚拟DOM还未上树
key: undefined // 虚拟DOM节点的唯一标识符
sel: "div"
text: "我是一个盒子"
}
2.虚拟DOM的h函数
// 1.创建出patch函数
const patch = init([classModule, propsModule, styleModule, eventListenersModule])
// 2.1创建一个虚拟节点
const myVnode1 = h('a', { props: { href: 'http://www.baidu.com', target: '_blank' } }, '百度')
// 2.2第二个虚拟节点
const myVnode2 = h('div', '这是一个盒子')
// 2.3嵌套h函数
const myVnode3 = h('ul', [
h('li',{}, '西瓜'),
h('li', '苹果'),
h('li', '香蕉'),
h('li', h('p', '葡萄'))
])
// 3.让虚拟节点上树
const container = document.querySelector('#container')
patch(container, myVnode3)
3.简易版h函数实现创建虚拟DOM
3.1利用简易版h函数创建虚拟DOM节点
// >>> ./src/helper/vnode.js
// vnode 函数的目的就是将传递的参数整理成一个对象格式
export default function (sel, data, children, text, elm) {
/* 节点名称, 节点属性数据, 子节点信息, 节点文本, 判断节点状态 */
return { sel, data, children, text, elm }
}
// ------------------------------分割线------------------------------
// >>> ./src/helper/h.js
import vnode from './vnode'
// h('div', {}, 'text')
// h('div', {}, [])
// h('div', {}, h()) <- 返回值类型为 object 即虚拟DOM
// 简易生成虚拟 DOM 的 h函数, 只能传递三个参数, 第三个参数可选为 string | number, array, h
export default function (sel, data, c) {
// 节点参数必须为三个
if (arguments.length !== 3) {
throw new Error("简易版 - h函数 - 所需参数必须为三个!")
}
// 检查第三个传递的参数类型
if (typeof c === 'string' || typeof c === 'number') {
// 没有子节点
return vnode(sel, data, undefined, c, undefined)
} else if (Array.isArray(c)) {
// 对数组里的元素进行检查, 必须为 objec, 传递 sel 参数
for (let i = 0; i < c.length; i++) {
if (!(typeof c[i] === 'object' && c[i].hasOwnProperty('sel')))
throw new Error("第三项数组中数据不合法!")
}
return vnode(sel, data, c, undefined, undefined)
} else if (typeof c === 'object' && c.hasOwnProperty('sel')) {
// children 必须为数组
const children = [c]
return vnode(sel, data, children, undefined, undefined)
} else {
throw new Error("简易版 - h函数 - 第三个参数不正确!")
}
}
3.2让简易版h函数创建的虚拟DOM上树
// >>> ./src/index.js
// 引入自己写的简易版 h函数
import h from './helper/h'
// 1.创建出patch函数 ↓ 这是引入的库
const patch = init([classModule, propsModule, styleModule, eventListenersModule]);
const myVnode4 = h('div', {}, h('ul', {}, [
h('li', {}, '西瓜'),
h('li', {}, '苹果'),
h('li', {}, '香蕉'),
h('li', {}, '葡萄')
]))
// 3.让虚拟节点上树
const container = document.querySelector('#container')
patch(container, myVnode4)
问题二
Q:diff算法原理
A:手写diff算法
1.patch函数工作原理
// init.ts 源码中的 patch 函数被调用时
return function patch(
oldVnode: VNode | Element | DocumentFragment,
vnode: VNode
): VNode {
let i: number, elm: Node, parent: Node;
const insertedVnodeQueue: VNodeQueue = [];
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
// ① 先判断 oldValue是虚拟节点还是DOM节点
// 若是虚拟节点则继续往下; 若是DOM节点则将其包装为虚拟节点
if (isElement(api, oldVnode)) {
oldVnode = emptyNodeAt(oldVnode);
} else if (isDocumentFragment(api, oldVnode)) {
oldVnode = emptyDocumentFragmentAt(oldVnode);
}
// ② 再判断 oldValue 和 vnode 是否为同一个节点
// 若是同一个则进行精细化比较; 若不是同一个则进行暴力删除旧节点、插入新节点
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;
};
2.如何定义oldValue和vnode是否为同一节点
// 源码中的 sameVnode 函数
function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
// 判断节点的 Key 和 sel
const isSameKey = vnode1.key === vnode2.key;
const isSameIs = vnode1.data?.is === vnode2.data?.is;
const isSameSel = vnode1.sel === vnode2.sel;
// 返回值必须同时满足 true 方为同一节点
return isSameSel && isSameKey && isSameIs;
}
diff处理新旧节点不是同一节点时
需要掌握的基础知识:
- var insertedNode = parentNode.insertBefore(newNode, referenceNode)
insertedNode被插入节点(newNode).parentNode新插入节点的父节点.newNode用于插入的节点.referenceNodenewNode将要插在这个节点之前.- 如果
referenceNode为null则newNode将被插入到子节点的末尾。
- element.appendChild(aChild)
aChild要追加给父节点(通常为一个元素)的节点。
- elementName = element.tagName
elementName是一个字符串,包含了element元素的标签名.
- 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")
- tagName
指定要创建元素类型的字符串,创建元素时的
3.实现一个非嵌套、简单的虚拟节点上树
将 const myVnode = h('h1', {}, 'h1标签内容') 虚拟节点上树 3.1实现一个非嵌套虚拟节点上树函数 - patch
// >>> ./src/helper/patch.js
// vnode 用来创建虚拟节点、createElement用来创建真实DOM节点
import vnode from "./vnode";
import createElement from "./createElement";
export default function (oldVnode, newVnode) {
// ① 先判断 oldValue 节点是虚拟节点还是DOM节点
if (oldVnode.sel === '' || oldVnode.sel === undefined) {
// 将 oldValue 包装成虚拟节点
oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode)
}
// ② 再判断 oldValue 和 newValue 是否为同一个节点
if (oldVnode.Key === newVnode.Key && oldVnode.sel === newVnode.sel) {
console.log('同一个节点');
} else {
console.log('不是同一个节点');
// 函数返回一个根据虚拟节点创建的真实DOM节点
const newTnodeElm = createElement(newVnode)
// 实现虚拟节点上树, 插入到老节点之前
if(newTnodeElm) {
oldVnode.elm.parentNode.insertBefore(newTnodeElm, oldVnode.elm)
}
// 删除老节点
oldVnode.elm.parentNode.removeChild(oldVnode.elm)
}
}
// ------------------------------分割线------------------------------
// >>> ./src/helper/createElement.js
// 真正创建一个DOM节点 -- vnode 虚拟节点
export default function (vnode) {
// 根据虚拟节点的 sel 创建一个DOM节点, 此时还是孤儿节点
let domNode = document.createElement(vnode.sel)
// 虚拟节点没有子节点, 文本内容不为空
if (vnode.text !== '' && vnode.children === undefined || vnode.children.length === 0) {
// 内部文字
domNode.innerText = vnode.text
// 将创建的孤儿节点保存到 vnode.elm 中
vnode.elm = domNode
} else if (Array.isArray(vnode.children) && vnode.children.length > 0) {
// 有子节点, 就要递归创建子节点
console.log('有子节点');
}
// 返回真实DOM
return vnode.elm
}
3.2简易版h函数 + patch函数实现虚拟节点上树
// >>> ./src/index.js
import h from './helper/h'
import patch from './helper/patch'
// 获取 DOM 节点
const container = document.querySelector('#container')
const myVnode = h('h1', {}, 'h1标签内容')
patch(container, myVnode)
4.实现一个嵌套虚拟节点上树
将 const myVnode = h('ul', {}, [ h('li', {}, '111'), h('li', {}, '222'), h('li', {}, '333'), h('li', {}, '444'), ]) 虚拟节点上树
4.1通过递归实现嵌套虚拟DOM转化为真实DOM
// >>> ./src/helper/patch.js 同上 内容不变
// >>> ./src/helper/createElement.js
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) {
// 有子节点, 就要递归创建子节点
console.log('有子节点');
for (let i = 0; i < vnode.children.length; i++) {
// 给 孤儿节点添加子节点, 递归最终会走到第一条判断语句
domNode.appendChild(createElement(vnode.children[i]))
}
}
// *将创建的孤儿节点保存到 vnode.elm 中
vnode.elm = domNode
// 返回真实DOM 返回vnode.elm也行、domNode也可以
return vnode.elm
}
diff处理新旧节点是同一节点时
5.❌实现新旧节点text不同的情况
- 无论老节点内容为文本还是有子节点,只要新节点是文本,直接用innerText进行覆盖(是否覆盖需要判断新旧节点的text内容是否相同)。
- 新节点有子节点,则需要判断老节点的情况:
- 老节点为text -> 先清空老节点的text,再对子节点数组进行遍历,通过 createElement 函数转化为真实DOM节点添加到老节点中。
- 老节点有子节点 -> 最小精细化比较
// >>> ./src/helper/patch.js
// ... 省略代码
// ② 再判断 oldValue 和 newValue 是否为同一个节点
if (oldVnode.Key === newVnode.Key && oldVnode.sel === newVnode.sel) {
console.log('同一个节点');
// 如果新老节点在内存中相等
if (oldVnode === newVnode) return
// 判断新节点是否有text, 否则就是有子节点
if (newVnode.text !== undefined && (newVnode.children === undefined || newVnode.children.length === 0)) {
console.log('新节点有text');
// 如果新节点的text和老节点的text属性相等, 则什么也不做
// 如果新节点的text和老节点的text属性不相等, 则直接把老节点的内容(无论老节点是否有子节点)改为新节点的text
if (newVnode.text !== oldVnode.text) {
oldVnode.elm.innerText = newVnode.text
}
} else {
console.log('新节点没有text');
// 需要判断老节点有没有children 如果有则是最复杂的最小精细化计算
if (oldVnode.children !== undefined && oldVnode.children.length > 0) {
console.log('最小精细化计算');
// 增加一个对老节点记录的一个指针
let un = 0
// 对新老节点的子节点进行比较
for (let i = 0; i < newVnode.children.length; i++) {
let isExist = false
for (let j = 0; j < oldVnode.children.length; j++) {
if (oldVnode.children[j].sel == newVnode.children[i].sel && oldVnode.children[j].key == newVnode.children[i].key) {
isExist = true
}
}
// 如果找到了相同的子节点 则让指针 un 指向下一个 否则就是新增一个子节点
if (!isExist) {
if(un < oldVnode.children.length) {
// 在 un 指向的位置插入子节点
oldVnode.elm.insertBefore(createElement(newVnode.children[i]), oldVnode.children[un].elm)
} else {
oldVnode.elm.appendChild(createElement(newVnode.children[i]))
}
} else {
un ++
// 如果新旧节点顺序乱序 则需要重新排序
// TODO...
}
}
} else {
console.log('老节点为text');
// 清空老节点的文本内容
oldVnode.elm.innerText = ''
// 此时老节点是文本, 新节点有子节点, 需要通过 createElement 生成真实 DOM 添加到老节点的子节点中
// 因为新节点的子节点是一个数组, 而 createElement 函数只能接受一个虚拟DOM, 所以需要对其遍历
for (let i = 0; i < newVnode.children.length; i++) {
// 给老节点添加 - 通过 createElement 生成的真实DOM, 函数接受新节点的子节点的虚拟DOM
oldVnode.elm.appendChild(createElement(newVnode.children[i]))
}
}
}
}
// ... 省略代码
6.diff算法的子节点更新策略
四种命中查找:
- 新前与旧前
- 新后与旧后
- 新后与旧前 (当此种情况命中时,此时要移动节点,移动新后指向的节点 到 旧后之后)
- 新前与旧后 (此种情况命中时,此时要移动节点,移动新前指向的节点 到 旧前之前)
循环四种命中查找: while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx)(源码复制的)
当四种命中查找命中一种 就不再命中查找 转而继续循环。
{
oldStartIdx: "旧前",
oldEndIdx: "旧后",
newStartIdx: "新前",
newEndIdx: "新后",
}
7.实现子节点更新策略(一)
// >>> ./src/helper/updateChildren.js
// 此时还会有四种命中查找不命中的情况 , 最后再进行else判断
import patchVnode from "./patchVnode"
// 判断是否是同一个虚拟节点
function checkSameVnode(o, n) {
return n.sel === o.sel && n.key === o.key
}
export default function updateChildren(parentElm, oldCh, newCh) {
// 定义 新前newStartInx 新后newEndInx 旧前oldStartInx 旧后oldEndInx 指针
let newStartInx = 0
let newEndInx = newCh.length - 1
let oldStartInx = 0
let oldEndInx = oldCh.length - 1
// 获取 四个指针指向的节点
let newStartVnode = newCh[0]
let newEndVnode = newCh[newEndInx]
let oldStartVnode = oldCh[0]
let oldEndVnode = oldCh[oldEndInx]
while (newStartInx <= newEndInx && oldStartInx <= oldEndInx) {
if (checkSameVnode(oldStartVnode, newStartVnode)) {
console.log('1.新前和旧前命中!');
// 这里我把参数写反了 debugger 调试了半天
patchVnode(oldStartVnode, newStartVnode);
newStartVnode = newCh[++newStartInx]
oldStartVnode = oldCh[++oldStartInx] // 第一种命中查找让 新前 和 旧前 往下移
} else if (checkSameVnode(oldEndVnode, newEndVnode)) {
console.log('2.新后和旧后命中!');
patchVnode(oldEndVnode, newEndVnode);
newEndVnode = newCh[--newEndInx]
oldEndVnode = oldCh[--oldEndInx]
} else if (checkSameVnode(oldStartVnode, newEndVnode)) {
console.log('3.新后与旧前命中!');
patchVnode(oldStartVnode, newEndVnode);
// 此时要将 新后 节点移动到 旧后之后
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
// ↑ 把 旧前 插入到 旧后之后? 这里 旧前和新后相等嘛?
newEndVnode = newCh[--newEndInx]
oldStartVnode = oldCh[++oldStartInx]
} else if (checkSameVnode(oldEndVnode, newStartVnode)) {
console.log('4.新前与旧后命中!');
patchVnode(oldEndVnode, newStartVnode);
// 此时要将 新后 节点移动到 旧后之后
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
// ↑ 把 旧前 插入到 旧后之后? 这里 旧前和新后相等嘛?
newStartVnode = newCh[++newStartInx]
oldEndVnode = oldCh[--oldEndInx]
}
// ... 代码省略
}
}
8.实现子节点更新策略(二)
// >>> ./src/helper/updateChildren.js
export default function updateChildren(parentElm, oldCh, newCh) {
// ... 代码省略
// 命中查找完之后, newStartInx 还是比 newEndInx 小
if (newStartInx <= newEndInx) {
console.log('新增子节点, newVnode中还有节点没有处理');
// 插入标杆
const before = newCh[newEndInx + 1] ? newCh[newEndInx + 1].elm : null
// 小于等于才会获取到最后一个节点
for (let i = newStartInx; i <= newEndInx; i++) {
// insertBefore 可以识别 null, 自动排队到队尾去
parentElm.insertBefore(createElement(newCh[i]), before)
}
} else if (oldStartInx <= oldEndInx) {
console.log('删除子节点, oldVnode中还有节点没有删除');
// 批量删除
for (let i = oldStartInx; i <= oldEndInx; i++) {
parentElm.removeChild(oldCh[i].elm)
}
}
}
9.实现子节点更新策略(三)
import createElement from "./createElement"
import patchVnode from "./patchVnode"
// 判断是否是同一个虚拟节点
function checkSameVnode(o, n) {
return n.sel === o.sel && n.key === o.key
}
/**
*
* @param {Object} parentElm 真实DOM节点
* @param {Array} oldCh 老节点的子节点数组
* @param {Array} newCh 新节点的子节点数组
*/
export default function updateChildren(parentElm, oldCh, newCh) {
// 定义 新前newStartInx 新后newEndInx 旧前oldStartInx 旧后oldEndInx 指针
let newStartInx = 0
let newEndInx = newCh.length - 1
let oldStartInx = 0
let oldEndInx = oldCh.length - 1
// 获取 四个指针指向的节点
let newStartVnode = newCh[0]
let newEndVnode = newCh[newEndInx]
let oldStartVnode = oldCh[0]
let oldEndVnode = oldCh[oldEndInx]
// 用于缓存key
let keyMap = {}
while (newStartInx <= newEndInx && oldStartInx <= oldEndInx) {
// 先略过已经标记为undefined的节点
if (oldStartVnode === undefined || oldCh[oldStartInx] === undefined) {
oldStartVnode = oldCh[++oldStartInx]
} else if (oldEndVnode === undefined || oldCh[oldEndInx] === undefined) {
oldEndVnode = oldCh[--oldEndInx]
} else if (newStartVnode === undefined || newCh[newStartInx] === undefined) {
newStartVnode = oldCh[++newStartInx]
} else if (newEndVnode === undefined || newCh[newEndInx] === undefined) {
newEndVnode = oldCh[--newEndInx]
} else if (checkSameVnode(oldStartVnode, newStartVnode)) {
console.log('1.新前和旧前命中!');
// 这里我把参数写反了 debugger 调试了半天
patchVnode(oldStartVnode, newStartVnode);
newStartVnode = newCh[++newStartInx]
oldStartVnode = oldCh[++oldStartInx] // 第一种命中查找让 新前 和 旧前 往下移
} else if (checkSameVnode(oldEndVnode, newEndVnode)) {
console.log('2.新后和旧后命中!');
patchVnode(oldEndVnode, newEndVnode);
newEndVnode = newCh[--newEndInx]
oldEndVnode = oldCh[--oldEndInx]
} else if (checkSameVnode(oldStartVnode, newEndVnode)) {
console.log('3.新后与旧前命中!');
patchVnode(oldStartVnode, newEndVnode);
// 此时要将 新后 节点移动到 旧后之后
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
// ↑ 把 旧前 插入到 旧后之后? 这里 旧前和新后相等嘛?
newEndVnode = newCh[--newEndInx]
oldStartVnode = oldCh[++oldStartInx]
} else if (checkSameVnode(oldEndVnode, newStartVnode)) {
console.log('4.新前与旧后命中!');
patchVnode(oldEndVnode, newStartVnode);
// 此时要将 新后 节点移动到 旧后之后
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
// ↑ 把 旧前 插入到 旧后之后? 这里 旧前和新后相等嘛?
newStartVnode = newCh[++newStartInx]
oldEndVnode = oldCh[--oldEndInx]
} else {
console.log('四种命中方式都没命中!');
// 缓存 key
if (Object.keys(keyMap).length === 0) {
for (let i = oldStartInx; i < oldEndInx; i++) {
const key = oldCh[i].key;
if (key !== undefined) {
keyMap[key] = i
}
}
}
// 根据新节点的子节点的key判断是否为新的节点
const isOldKey = keyMap[newStartVnode.key]
if (isOldKey === undefined) {
// 说明是新的子节点
parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm)
} else {
// 说明不是新的子节点
// 获取到该节点 - 需要进行移动
const elmToMove = oldCh[isOldKey]
// 先把text保存下来
patchVnode(elmToMove, newStartVnode)
// 把这一项设置为 undefined 表示处理完成
oldCh[isOldKey] = undefined
// 移动
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)
}
newStartVnode = newCh[++newStartInx]
}
}
// 命中查找完之后, newStartInx 还是比 newEndInx 小
if (newStartInx <= newEndInx) {
console.log('新增子节点, newVnode中还有节点没有处理');
// 小于等于才会获取到最后一个节点
for (let i = newStartInx; i <= newEndInx; i++) {
// insertBefore 可以识别 null, 自动排队到队尾去
parentElm.insertBefore(createElement(newCh[i]), oldCh[oldStartInx].elm)
}
} else if (oldStartInx <= oldEndInx) {
console.log('删除子节点, oldVnode中还有节点没有删除');
for (let i = oldStartInx; i <= oldEndInx; i++) {
if (oldCh[i]) {
parentElm.removeChild(oldCh[i].elm)
}
}
}
}
问题三
Q:虚拟DOM -> diff算法 -> 真实DOM
A:即 通过diff算法转化