虚拟 dom 和 diff 算法理解,实现简易的 snabbdom

29 阅读5分钟

虚拟 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 函数

image.png

判断 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;
}

同一个节点,精细化比较

image.png

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 尾部;

命中规则:前前 后后 前后 后前(旧新)

  1. 比较旧前 oldStart 与新前 newStart;若命中,patch之后就移动头指针 ++oldStart、++newStart image.png
if (checkSameVnode(oldStartVnode, newStartVnode)) {
    console.log('一 旧前新前');
    // 旧前新前
    patchVnode(oldStartVnode, newStartVnode);
    oldStartVnode = oldCh[++oldStartIdx];
    newStartVnode = newCh[++newStartIdx];
}
  1. 比较旧后 oldEnd 与新后 newEnd;若命中,patch之后就移动头指针 --oldEnd、--newEnd image.png
if (checkSameVnode(oldEndVnode, newEndVnode)) {
    console.log('二 旧后新后');
    // 旧后新后
    patchVnode(oldEndVnode, newEndVnode);
    oldEndVnode = oldCh[--oldEndIdx];
    newEndVnode = newCh[--newEndIdx];
}
  1. 比较旧前 oldStrat 与新后 newEnd;若命中,将新后 newEnd 指向的节点移动到 旧后 oldEnd 指向节点之后,移动指针 --oldStart、++newEnd image.png
if (checkSameVnode(oldStartVnode, newEndVnode)) {
    console.log('三 旧前新后');
    // 旧前新后
    patchVnode(oldStartVnode, newEndVnode);
    // 此种情况命中后,要移动真实 dom,将 新后 指向节点移动到 旧后 指向节点后面
    // 移动节点原理:插入已经在 dom 树上的节点,它就会被移动
    parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling); 

    oldStartVnode = oldCh[++oldStartIdx];
    newEndVnode = newCh[--newEndIdx];
}
  1. 比较旧后 oldEnd 与新前 newStart;若命中,新前 newStart 指向的节点,移动到旧前 oldStart 之前,移动指针 --oldEnd、++newStart image.png
if (checkSameVnode(oldEndVnode, newStartVnode)) {
    console.log('四 旧后新前');
    // 旧后新前
    patchVnode(oldEndVnode, newStartVnode);
    // 此种情况命中后,要移动真实 dom,将 新前(旧后) 指向节点移动到 旧前 指向节点前面
    // 移动节点原理:插入已经在 dom 树上的节点,它就会被移动
    parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);

    oldEndVnode = oldCh[--oldEndIdx];
    newStartVnode = newCh[++newStartIdx];
}
  1. 若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);

处理剩余节点

  1. 新的 newCh 中有剩余: image.png image.png
  2. 旧的 oldCh 中有剩余: image.png image.png

源码

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);
            }
        }
    }
}

image.png

涉及知识

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:要删除节点