虚拟 dom
生成虚拟 dom
// vnode.js
/**
* 产生虚拟节点
* 将传入的参数组合成对象返回
* @param {string} sel 选择器
* @param {object} data 属性、样式
* @param {Array} children 子元素
* @param {string|number} text 文本内容
* @param {object} elm 对应的真正的dom节点(对象),undefined表示节点还没有上dom树
* @returns
*/
// 将5个参数组合成对象返回
export default function (sel, data, children, text, elm) {
const key = data.key;
return { sel, data, children, text, elm, key };
}
// h.js
import vnode from "./vnode.js";
// 低配 h 函数,必须接收3个参数
// 类似重载
// h('div', {}, '文字')
// h('div', {}, [])
// h('div', {}, h())
export default function (sel, data, c) {
// 检查参数个数
if (arguments.length != 3) {
throw new Error('必须传入3个参数');
}
// 检查 c 的类型
if (typeof c === 'string' || typeof c === 'number') {
// 说明调用第一种
return vnode(sel, data, undefined, c, undefined);
} else if (Array.isArray(c)) {
// 说明调用第二种
let children = [];
// 遍历 c,判断 c 的项是否合法
for (let i = 0; i < c.length; 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')) {
// 说明调用第三种
//唯一的children
return vnode(sel, data, [c], undefined, undefined);
} else {
throw new Error('传入3个参数类型不对')
}
}
diff 算法
比较规则
- 根据 key 和选择器 sel 唯一标识判断是否同一个节点
- 同一虚拟节点才进行精细比较
- 跨级不比较,直接删除,插入新的
patch 函数
判断 oldVnode 是虚拟节点还是真实的 dom 节点
// 判断第一个参数是虚拟节点还是真实的 dom 节点
if (oldVnode.sel === '' || oldVnode.sel === undefined) {
// 如果是真实的 dom 节点,则包装成虚拟节点
oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode);
}
oldVnode 和 newVnode 不是同一个节点
// 判断 oldVnode 和 newVnode 是否为同一个节点
if (oldVnode.key === newVnode.key && oldVnode.sel === newVnode.sel) {
console.log('同一个节点');
patchVnode(oldVnode, newVnode);
} else {
console.log('不是同一个节点先插入,再删');
let newVnodeElm = createElement(newVnode);
// 插入到老节点之前
if (oldVnode.elm.parentNode && newVnodeElm) {
oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm);
}
// 删除老节点
oldVnode.elm.parentNode.removeChild(oldVnode.elm);
}
createElement 函数
// 真正创建节点,创建真正的 dom,是孤儿节点,暂不进行插入
export default function createElement (vnode) {
// console.log('目的把虚拟节点插入', vnode, '变为真正的 dom 但不插入');
// 创建一个 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
let chDom = createElement(ch);
// 将子节点添加进父节点
domNode.appendChild(chDom);
}
}
// 创建的真实 dom 对象指向新的 vnode 的 elm 属性
vnode.elm = domNode;
// 返回真实 dom 对象
return vnode.elm;
}
同一个节点,精细化比较
patchVnode 函数
import createElement from "./createElement";
import updateChildren from "./updateChildren";
export default function patchVnode (oldVnode, newVnode) {
// 判断新旧 vnode 是否是同一个对象
if (oldVnode === newVnode) {
return;
}
// 判断新 vnode 有没有 text 属性
if (newVnode.text !== undefined && (newVnode.children === undefined || newVnode.children.length === 0)) {
// 新vnode有text属性
console.log('新vnode有text属性');
if (newVnode.text !== oldVnode.text) {
// 如果新旧虚拟节点 text 不同,直接将新的 text 值写入旧的 elm中,即使旧的节点是 children 也会被覆盖
oldVnode.elm.innerText = newVnode.text
}
} else {
// 新vnode没有text属性,有children
console.log('新vnode没有text属性');
// 判断旧的有没有 children
if (oldVnode.children !== undefined && oldVnode.children.length > 0) {
// 旧的有 children,新旧都有 children 情况最复杂
updateChildren(oldVnode.elm, oldVnode.children, newVnode.children);
} else {
// 旧的没有,新的有
// 清空老节点内容
oldVnode.elm.innerHTML = '';
for (let i = 0; i < newVnode.children.length; i++) {
let dom = createElement(newVnode.children[i]);
oldVnode.elm.appendChild(dom);
}
}
}
}
updateChildren() 函数
用于解决当新老虚拟节点都有 children 时的精细化对比,算法原理基于4个指针比较命中,命中一种则结束一次循环的判断,若都没有命中则需要
旧前:指向旧的虚拟节点 children 头部;
旧后:指向旧的虚拟节点 children 尾部;
新前:指向新的虚拟节点 children 头部;
旧前:指向新的虚拟节点 children 尾部;
命中规则:前前 后后 前后 后前(旧新)
- 比较旧前 oldStart 与新前 newStart;若命中,patch之后就移动头指针 ++oldStart、++newStart
if (checkSameVnode(oldStartVnode, newStartVnode)) {
console.log('一 旧前新前');
// 旧前新前
patchVnode(oldStartVnode, newStartVnode);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
}
- 比较旧后 oldEnd 与新后 newEnd;若命中,patch之后就移动头指针 --oldEnd、--newEnd
if (checkSameVnode(oldEndVnode, newEndVnode)) {
console.log('二 旧后新后');
// 旧后新后
patchVnode(oldEndVnode, newEndVnode);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
}
- 比较旧前 oldStrat 与新后 newEnd;若命中,将新后 newEnd 指向的节点移动到 旧后 oldEnd 指向节点之后,移动指针 --oldStart、++newEnd
if (checkSameVnode(oldStartVnode, newEndVnode)) {
console.log('三 旧前新后');
// 旧前新后
patchVnode(oldStartVnode, newEndVnode);
// 此种情况命中后,要移动真实 dom,将 新后 指向节点移动到 旧后 指向节点后面
// 移动节点原理:插入已经在 dom 树上的节点,它就会被移动
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
}
- 比较旧后 oldEnd 与新前 newStart;若命中,新前 newStart 指向的节点,移动到旧前 oldStart 之前,移动指针 --oldEnd、++newStart
if (checkSameVnode(oldEndVnode, newStartVnode)) {
console.log('四 旧后新前');
// 旧后新前
patchVnode(oldEndVnode, newStartVnode);
// 此种情况命中后,要移动真实 dom,将 新前(旧后) 指向节点移动到 旧前 指向节点前面
// 移动节点原理:插入已经在 dom 树上的节点,它就会被移动
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
}
- 若4种都没有命中,则遍历 oldVnode 中的 key,用一个对象保存,key 为键,在 oldCh 中的下标为值,以目前 newStartVnode.key 在保存对象中寻找下标,若找不到则说明此节点上新增的节点,直接变为真实节点,然后插入父节点:
// 寻找 newStartIdx 这项在 keyMap 中的映射位置虚序号
const idxInOld = keyMap[newStartVnode.key];
if (idxInOld === undefined) {
// 则表示是全新的项
parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm);
}
反之,需要移动此节点,最后还需移动指针
// 如果不是undefind,则要移动
const elmToMove = oldCh[idxInOld];
patchVnode(elmToMove, newStartVnode);
// 把这项设置为 undefind,表示已处理
oldCh[idxInOld] = undefined;
// 移动,调用 insertBefore实现移动
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm);
处理剩余节点
- 新的 newCh 中有剩余:
- 旧的 oldCh 中有剩余:
源码
import createElement from "./createElement";
import patchVnode from "./patchVnode";
// 判断是否为同一个虚拟节点
function checkSameVnode (a, b) {
return a.sel === b.sel && a.key === b.key;
}
export default function updateChildren (parentElm, oldCh, newCh) {
console.log(oldCh, newCh)
// 旧前
let oldStartIdx = 0;
// 旧后
let oldEndIdx = oldCh.length - 1;
// 新前
let newStartIdx = 0;
// 新后
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 || 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('一 旧前新前');
// 旧前新前
patchVnode(oldStartVnode, newStartVnode);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (checkSameVnode(oldEndVnode, newEndVnode)) {
console.log('二 旧后新后');
// 旧后新后
patchVnode(oldEndVnode, newEndVnode);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (checkSameVnode(oldStartVnode, newEndVnode)) {
console.log('三 旧前新后');
// 旧前新后
patchVnode(oldStartVnode, newEndVnode);
// 此种情况命中后,要移动真实 dom,将 新后 指向节点移动到 旧后 指向节点后面
// 移动节点原理:插入已经在 dom 树上的节点,它就会被移动
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (checkSameVnode(oldEndVnode, newStartVnode)) {
console.log('四 旧后新前');
// 旧后新前
patchVnode(oldEndVnode, newStartVnode);
// 此种情况命中后,要移动真实 dom,将 新前(旧后) 指向节点移动到 旧前 指向节点前面
// 移动节点原理:插入已经在 dom 树上的节点,它就会被移动
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
// 4种都没有命中
if (!keyMap) {
keyMap = {};
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
const key = oldCh[i].key;
if (key !== undefined) {
keyMap[key] = i
}
}
}
// 寻找 newStartIdx 这项在 keyMap 中的映射位置虚序号
const idxInOld = keyMap[newStartVnode.key];
if (idxInOld === undefined) {
// 则表示是全新的项
parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm);
} else {
// 如果不是undefind,则要移动
const elmToMove = oldCh[idxInOld];
patchVnode(elmToMove, newStartVnode);
// 把这项设置为 undefind,表示已处理
oldCh[idxInOld] = undefined;
// 移动,调用 insertBefore实现移动
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm);
}
// 指针下移,只移动新的头
newStartVnode = newCh[++newStartIdx];
}
}
// 继续处理剩余节点
if (newStartIdx <= newEndIdx) {
console.log('newCh 剩余节点没有处理完');
// 遍历新的newnewCh,添加到老的未处理之前
for (let i = newStartIdx; i <= newEndIdx; i++) {
// insertBefore 方法可以自动识别 null,如果是 null 自动插入队尾
parentElm.insertBefore(createElement(newCh[i]), oldCh[oldStartIdx].elm);
}
} else if (oldStartIdx <= oldEndIdx) {
console.log('oldCh 剩余节点没有处理完');
// 批量删除 oldStart 和 oldEnd 之间的项
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
if (oldCh[i]) {
parentElm.removeChild(oldCh[i].elm);
}
}
}
}
涉及知识
Node.insertBefore()
var insertedNode = parentNode.insertBefore(newNode, referenceNode);
insertNode
:用于插入的节点(newNode)
parentNode
:新插入节点的父节点
newNode
:用于插入的节点
referenceNode
:newNode
将要插在这个节点之前,如果此节点为 null,则直接插入最后
如果插入的节点已存在于父节点,则移动此节点到指定位置
Node.appendChild()
parentNode.appendChild(childNode)
parentNode
:容器节点
childNode
:要插入节点
如果插入的节点已存在于父节点,则移动此节点到最后
Node.removeChild()
let delNode = node.removeChild(child)
delNode
:被删除节点
child
:要删除节点