虚拟DOM和diff算法

66 阅读6分钟

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处理新旧节点不是同一节点时

需要掌握的基础知识:

  1. var insertedNode = parentNode.insertBefore(newNode, referenceNode)
    • insertedNode 被插入节点(newNode).
    • parentNode 新插入节点的父节点.
    • newNode 用于插入的节点.
    • referenceNode newNode 将要插在这个节点之前.
    • 如果 referenceNodenullnewNode 将被插入到子节点的末尾。
  2. element.appendChild(aChild)
    • aChild 要追加给父节点(通常为一个元素)的节点。
  3. elementName = element.tagName
    • elementName 是一个字符串,包含了element元素的标签名.
  4. let oldChild = node.removeChild(child); //OR element.removeChild(child);
    • child 是要移除的那个子节点.
    • node 是child的父节点.
    • oldChild保存对删除的子节点的引用. oldChild === child.
  5. var element = document.createElement(tagName[, options]);
    • tagName 指定要创建元素类型的字符串,创建元素时的 nodeName 使用 tagName 的值为初始化,该方法不允许使用限定名称(如:"html:a"),在 HTML 文档上调用 createElement() 方法创建元素之前会将tagName 转化成小写,在 Firefox、Opera 和 Chrome 内核中,createElement(null) 等同于 createElement("null")

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不同的情况

  1. 无论老节点内容为文本还是有子节点,只要新节点是文本,直接用innerText进行覆盖(是否覆盖需要判断新旧节点的text内容是否相同)。
  2. 新节点有子节点,则需要判断老节点的情况:
    • 老节点为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算法的子节点更新策略

四种命中查找:

  1. 新前与旧前
  2. 新后与旧后
  3. 新后与旧前 (当此种情况命中时,此时要移动节点,移动新后指向的节点 到 旧后之后)
  4. 新前与旧后 (此种情况命中时,此时要移动节点,移动新前指向的节点 到 旧前之前)

循环四种命中查找: 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算法转化

流程图

1112.png