Vue 虚拟DOM的diff算法

802 阅读4分钟

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

TIP 👉 人无远虑,必有近忧。《论语·卫灵公》

VNode

- 一个 VNode 就是一个虚拟节点用来描述一个 DOM 元素,如果这个 VNode 有 children 就是 Virtual DOM - 源码位置:src/package/vnode.ts
export interface VNode {
    // 选择器
    sel: string | undefined;
    // 节点数据:属性/样式/事件等
    data: VNodeData | undefined;
    // 子节点,和 text 只能互斥
    children: Array<VNode | string> | undefined;
    // 记录 vnode 对应的真实 DOM
    elm: Node | undefined;
    // 节点中的内容,和 children 只能互斥
    text: string | undefined;
    // 优化用
    key: Key | undefined;
}
export function vnode (sel: string | undefined,
                        data: any | undefined,
                        children: Array<VNode | string> | undefined,
                        text: string | undefined,
                        elm: Element | Text | undefined): VNode {
    const key = data === undefined ? undefined : data.key
    return { sel, data, children, text, elm, key }
}

patch

  • 功能:

    • 传入新旧 VNode,对比差异,把差异渲染到 DOM

    • 返回新的 VNode,作为下一次 patch() 的 oldVnode

  • 执行过程:

    • 首先执行模块中的钩子函数 pre

    • 如果 oldVnode 和 vnode 相同(key 和 sel 相同)

      • 调用 patchVnode(),找节点的差异并更新 DOM
    • 如果 oldVnode 是 DOM 元素

      • 把 DOM 元素转换成 oldVnode

      • 调用 createElm() 把 vnode 转换为真实 DOM,记录到 vnode.elm

      • 把刚创建的 DOM 元素插入到 parent 中

      • 移除老节点

      • 触发用户设置的 create 钩子函数

return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
    let i: number, elm: Node, parent: Node
    // 保存新插入节点的队列,为了触发钩子函数
    const insertedVnodeQueue: VNodeQueue = []
    // 执行模块的 pre 钩子函数
    for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()
      // 如果 oldVnode 不是 VNode,创建 VNode 并设置 elm
    if (!isVnode(oldVnode)) {
        // 把 DOM 元素转换成空的 VNode
        oldVnode = emptyNodeAt(oldVnode)
    }
    // 如果新旧节点是相同节点(key 和 sel 相同)
    if (sameVnode(oldVnode, vnode)) {
        // 找节点的差异并更新 DOM
        patchVnode(oldVnode, vnode, insertedVnodeQueue)
    } else {
        // 如果新旧节点不同,vnode 创建对应的 DOM
        // 获取当前的 DOM 元素
        elm = oldVnode.elm!
        parent = api.parentNode(elm) as Node
              // 触发 init/create 钩子函数,创建 DOM
        createElm(vnode, insertedVnodeQueue)
        
        if (parent !== null) {
            // 如果父节点不为空,把 vnode 对应的 DOM 插入到文档中
            api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))
            // 移除老节点
            removeVnodes(parent, [oldVnode], 0, 0)
        }
    }
      // 执行用户设置的 insert 钩子函数
    for (i = 0; i < insertedVnodeQueue.length; ++i) {
        insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i])
    }
    // 执行模块的 post 钩子函数
    for (i = 0; i < cbs.post.length; ++i) cbs.post[i]()
    return vnode
}

patchVnode

  • 功能:

    • patchVnode(oldVnode, vnode, insertedVnodeQueue)

    • 对比 oldVnode 和 vnode 的差异,把差异渲染到 DOM

  • 执行过程:

    • 首先执行用户设置的 prepatch 钩子函数

    • 执行 create 钩子函数

      • 首先执行模块create 钩子函数

      • 然后执行用户设置的 create 钩子函数

    • 如果 vnode.text 未定义

      • 如果 oldVnode.childrenvnode.children 都有值

        • 调用 updateChildren()

        • 使用 diff 算法对比子节点,更新子节点

      • 如果 vnode.children 有值,oldVnode.children 无值

        • 清空 DOM 元素

        • 调用 addVnodes(),批量添加子节点

      • 如果 oldVnode.children 有值,vnode.children 无值

        • 调用 removeVnodes(),批量移除子节点
      • 如果 oldVnode.text 有值

        • 清空 DOM 元素的内容
      • 如果设置了 vnode.text 并且和和 oldVnode.text 不等

        • 如果老节点有子节点,全部移除

        • 设置 DOM 元素的 textContentvnode.text

      • 最后执行用户设置的 postpatch 钩子函数

function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
    const hook = vnode.data?.hook
    // 首先执行用户设置的 prepatch 钩子函数
    hook?.prepatch?.(oldVnode, vnode)
    const elm = vnode.elm = oldVnode.elm!
    const oldCh = oldVnode.children as VNode[]
    const ch = vnode.children as VNode[]
        // 如果新老 vnode 相同返回
    if (oldVnode === vnode) return
    if (vnode.data !== undefined) {
        // 执行模块的 update 钩子函数
        for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
        // 执行用户设置的 update 钩子函数
        vnode.data.hook?.update?.(oldVnode, vnode)
    }
        // 如果 vnode.text 未定义
    if (isUndef(vnode.text)) {
        // 如果新老节点都有 children
        if (isDef(oldCh) && isDef(ch)) {
            // 调用 updateChildren 对比子节点,更新子节点
            if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)
        } else if (isDef(ch)) {
            // 如果新节点有 children,老节点没有 children
            // 如果老节点有text,清空dom 元素的内容
            if (isDef(oldVnode.text)) api.setTextContent(elm, '')
            // 批量添加子节点
            addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
        } else if (isDef(oldCh)) {
            // 如果老节点有children,新节点没有children
            // 批量移除子节点
            removeVnodes(elm, oldCh, 0, oldCh.length - 1)
        } else if (isDef(oldVnode.text)) {
                // 如果老节点有 text,清空 DOM 元素
                api.setTextContent(elm, '')
            }
        } else if (oldVnode.text !== vnode.text) {
            // 如果没有设置 vnode.text
            if (isDef(oldCh)) {
                // 如果老节点有 children,移除
                removeVnodes(elm, oldCh, 0, oldCh.length - 1)
            }
            // 设置 DOM 元素的 textContent 为 vnode.text
            api.setTextContent(elm, vnode.text!)
    }
    // 最后执行用户设置的 postpatch 钩子函数
    hook?.postpatch?.(oldVnode, vnode)
}

updateChildren

  • 功能:

    • diff 算法的核心,对比新旧节点的 children,更新 DOM
  • 执行过程:

    • 要对比两棵树的差异,我们可以取第一棵树的每一个节点依次和第二课树的每一个节点比较,但是这样的时间复杂度为 O(n^3)

    • 在DOM 操作的时候我们很少很少会把一个父节点移动/更新到某一个子节点

    • 因此只需要找同级别的子节点依次比较,然后再找下一级别的节点比较,这样算法的时间复杂度为 O(n)

image.png

  • 在进行同级别节点比较的时候,首先会对新老节点数组的开始和结尾节点设置标记索引,遍历的过程中移动索引

  • 在对开始和结束节点比较的时候,总共有四种情况

    • oldStartVnode / newStartVnode (旧开始节点 / 新开始节点)

    • oldEndVnode / newEndVnode (旧结束节点 / 新结束节点)

    • oldStartVnode / oldEndVnode (旧开始节点 / 新结束节点)

    • oldEndVnode / newStartVnode (旧结束节点 / 新开始节点)

image.png

  • 开始节点和结束节点比较,这两种情况类似

    • oldStartVnode / newStartVnode (旧开始节点 / 新开始节点)

    • oldEndVnode / newEndVnode (旧结束节点 / 新结束节点)

  • 如果 oldStartVnode 和 newStartVnode 是 sameVnode (key 和 sel 相同)

    • 调用 patchVnode() 对比和更新节点

    • 把旧开始和新开始索引往后移动 oldStartIdx++ / oldEndIdx++

image.png

  • oldStartVnode / newEndVnode (旧开始节点 / 新结束节点) 相同

   - 调用 patchVnode() 对比和更新节点

  • 把 oldStartVnode 对应的 DOM 元素,移动到右边

    - 更新索引

image.png

  • oldEndVnode / newStartVnode (旧结束节点 / 新开始节点) 相同

  • 调用 patchVnode() 对比和更新节点

  • 把 oldEndVnode 对应的 DOM 元素,移动到左边

  • 更新索引

image.png

  • 如果不是以上四种情况

    • 遍历新节点,使用 newStartNode 的 key 在老节点数组中找相同节点

    • 如果没有找到,说明 newStartNode 是新节点

    • 创建新节点对应的 DOM 元素,插入到 DOM 树中

    • 如果找到了

    • 判断新节点和找到的老节点的 sel 选择器是否相同

    • 如果不相同,说明节点被修改了

    • 重新创建对应的 DOM 元素,插入到 DOM 树中

    • 如果相同,把 elmToMove 对应的 DOM 元素,移动到左边

image.png

  • 循环结束

  • 当老节点的所有子节点先遍历完 (oldStartIdx > oldEndIdx),循环结束

  • 新节点的所有子节点先遍历完 (newStartIdx > newEndIdx),循环结束

  • 如果老节点的数组先遍历完(oldStartIdx > oldEndIdx),说明新节点有剩余,把剩余节点批量插入到右边

image.png

  • 如果新节点的数组先遍历完(newStartIdx > newEndIdx),说明老节点有剩余,把剩余节点批量删除

image.png