vue 源码分析:diff 算法

553 阅读12分钟

前言

在解读 vue 的 diff 算法时,我将 diff 功能集成到了以前写的 demo 即 vue-mini-demo 项目之中,这个项目已实现的功能还有数据劫持、模版编译和数据响应以及指令绑定(例如,v-for),感兴趣的同学们可以下载下来进行调试。

虚拟 DOM(Virtual DOM)

diff 算法是用来处理虚拟 DOM 的,在了解它之前,我们需要先了解虚拟 DOM。那么什么是虚拟 DOM呢?

一句话概括:虚拟 DOM 就是一个用来描述真实 DOM 的 JavaScript 对象。这样说,可能也就只是让同学们了解到它是个对象。因此,我们需要举个例子,以便让大家能够清楚地理解。

  • 真实 DOM
<div>
    <span>dom</span>
</div>
  • 对应的虚拟 DOM
const Vnode = {
    tag: 'ul',
    children: [
        { tag: 'span', text: 'dom' }
    ]
};

通过上述的简易例子,我们不难看出,Virtual DOM 其实就是将真实的 DOM 的数据抽象出来,然后以对象的形式模拟树形结构。

diff 算法

众所周知,渲染真实 DOM 的开销是很大的(尤其是复杂视图的情况下),例如:当我们修改了某个数据,然后直接渲染到真实 DOM 上时,会引起整个 dom 树的重绘和重排。那么有没有办法只更新我们修改的 dom 而不是所有的呢?

答案是:有!那就是 diff 算法。通过它,我们可以将两个虚拟 dom 进行比较,从而找出两者间的差异,之后在利用其它方法(例如,patch 函数)进行 dom 的更新渲染。

下面我们来看一个例子(以 snabbdom 为例):

案例

snabbdom 是一个专注于简单性、模块化、强大功能和性能的虚拟 DOM 库。Vue 2.x 内部使用的虚拟 DOM 就是参考它进行改造的。

网上有很多有关 snabbdom 的使用教程,这里我是直接通过 BootCDN 引入链接的方式使用的。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>snabbdom</title>
    <script src="https://cdn.bootcdn.net/ajax/libs/snabbdom/0.7.4/snabbdom.js"></script>
    <script src="https://cdn.bootcdn.net/ajax/libs/snabbdom/0.7.4/snabbdom-attributes.js"></script>
    <script src="https://cdn.bootcdn.net/ajax/libs/snabbdom/0.7.4/snabbdom-dataset.js"></script>
    <script src="https://cdn.bootcdn.net/ajax/libs/snabbdom/0.7.4/snabbdom-eventlisteners.js"></script>
    <script src="https://cdn.bootcdn.net/ajax/libs/snabbdom/0.7.4/snabbdom-props.js"></script>
    <script src="https://cdn.bootcdn.net/ajax/libs/snabbdom/0.7.4/snabbdom-style.js"></script>
    <script src="https://cdn.bootcdn.net/ajax/libs/snabbdom/0.7.4/snabbdom-class.js"></script>
    <script src="https://cdn.bootcdn.net/ajax/libs/snabbdom/0.7.4/h.js"></script>
</head>

<body>
    <div id="app"></div>
    <button id="btn">新增</button>
    <script>
        const app = document.getElementById('app')
        const { h, init } = window.snabbdom;
        
        // 创建patch函数,用于更新dom
        var patch = snabbdom.init([
            // 初始化 patch 功能与选择的模块
            snabbdom_class,
            snabbdom_props,
            snabbdom_style,
            snabbdom_eventlisteners
        ]);
        
        // 第一次渲染
        let vnode = h('div#app', [h('p', '1')]);
        patch(app, vnode);
        
        // 点击按钮:进行第二次渲染,依据新的虚拟 dom 创建出的 dom 元素将替换之前的,
        // 只会修改发生变化的 dom
        document.getElementById('btn').addEventListener('click', function () {
            let newNode = h('div#app', [h('p', '1'), h('p', '2')]);
            patch(vnode, newNode);
        });
    </script>
</body>
</html>

结果展示

虚拟 dom 的变化,多了一个节点对象。

截屏2021-10-11 17.35.16.png

渲染真实 dom 时的变化,点击新增按钮时,仅渲染了新增的 dom 即前后两个虚拟 dom 的差异之处,其它的都没有变动(有闪动的节点,就是新渲染的)。

Oct-11-2021 17-59-21.gif

通过阅读上面的图文,我们可以知道,第二次 patch 时的 newNode(虚拟 dom) 比第一次 patch 时的 vnode(虚拟 dom),多了一行代码 h('p', '2'),反映到虚拟 dom 中,就是多了一个节点对象。且在渲染时,也仅是对差异之处(新增的节点)进行渲染。

diff 算法比较方式

举 snabbdom 这个例子,主要是想大家进一步了解 diff 算法。下面,我们将开始分析 vue 中的 diff 算法实现。

vue 中的 diff 算法在比较新旧虚拟 dom 时,只会对同层级进行比较, 不会跨层级比较。光说肯定是说不清的,还是要举个例子来看:

  • 假如这是我们的真实dom结构
// dom节点1——before
<div>
    <p>
        <span>1</span>
        <span>2</span>
    </p>
    <p>
        <span>3</span>
        <span>4</span>
    </p>
</div>

// dom节点2——after
<div>
    <p>
        <span>1</span>
        <span>2</span>
    </p>
    <p>
        <span>3</span>
        <span>4</span>
    </p>
</div>

  • 与真实dom相对应的虚拟dom(偷点懒,从网上找了一张别人画好的图)。

998023-20180519212338609-1617459354.png

将上面的图文对比着来看,想必同学们已了解何为同级比较:就是同一层的 div 与 div 比较或 p 与 p 比较,而不是 div 与 p 或 span 比较。那么,接下来,我们就说一说同级别节点在代码中是如何实现比较的。

具体实现

patch 函数

将 vnode 虚拟节点生成相应 HTML(俗称打补丁),就是从调用此函数开始的。patch 函数接收的oldVnodevnode 参数分别代表旧节点和新节点,它主要处理以下情况(这里只说核心实现部分):

  1. oldVnode 旧节点不存在或者是个真实元素(首次渲染时,接受的基本是html),则创建一个新节点。
  2. 不是真实元素且新旧虚拟节点是同一个对象,则修补更新现有的根节点。
// 为方便阅读与理解,这里仅列出代码的核心功能
function patch(oldVnode, vnode) {
    // ...省略
    
    // 老节点不存在
    if (isUndef(oldVnode)) {
        // empty mount (likely as component), create new root element
        isInitialPatch = true;
        createElm(vnode, insertedVnodeQueue);
    } else {
        // 是否为真实元素
        const isRealElement = isDef(oldVnode.nodeType);

        // 不是真实元素且新旧虚拟节点是同一个对象,则进行修补更新现有的根节点
        if (!isRealElement && sameVnode(oldVnode, vnode)) {
            patchVnode(oldVnode, vnode); // 修补现有的根节点
        } else {
            if (isRealElement) {
                // 创建一个空节点替换 oldVnode
                oldVnode = emptyNodeAt(oldVnode);
            }
            // 替换现有的 element
            const oldElm = oldVnode.elm;
            const parentElm = nodeOps.parentNode(oldElm); // 获取 oldElm 父元素

            // 创建新节点
            createElm(
                vnode,
                insertedVnodeQueue,
                parentElm,
                // 返回紧跟 oldElm 之后的元素
                nodeOps.nextSibling(oldElm)
            );

            // ...省略
        }
    }

    return vnode.elm;
};

patchVnode 函数

当确定新旧两个虚拟节点需要比较之后,就需调用 patchVnode 函数。它主要负责以下工作:

  1. 判断 vnode 和 oldVnode 是否为同一个节点对象,如果是,则无需比较,直接 return。
  2. 若 vnode.text 不存在,分以下情况处理:
    • 若 vnode 有子节点而 oldVnode 没有,则将 vnode 的子节点添加到 elm。
    • 若 vnode 没有子节点而 oldVnode 有,则删除 elm 的子节点。
    • 若两者都有子节点,则执行 updateChildren 函数比较并找出这两个子节点的差异,从而进行更新(这是 diff 的核心之处)。
  3. 若 vnode.text 和 oldVnode.text 存在且不相等,则将 vnode.text 赋值给 elm。
// 为方便阅读与理解,这里仅列出代码的核心功能
function patchVnode(
    oldVnode,
    vnode,
    insertedVnodeQueue,
    ownerArray,
    index,
    removeOnly
) {
    // 新旧节点相同即同一个对象,则停止比较
    if (oldVnode === vnode) {
        return;
    }
    
    if (isDef(vnode.elm) && isDef(ownerArray)) {
        // 克隆重用 vnode。这是 diff 中使用的一种叫就地复用的策略。
        // 它指的是尽可能复用之前的 dom,以便在渲染真实 dom 时,减少对 dom 的操作。
        vnode = ownerArray[index] = cloneVNode(vnode);
    }
        
    const elm = (vnode.elm = oldVnode.elm);

    let i;
    const data = vnode.data;
    const oldCh = oldVnode.children;
    const ch = vnode.children;

    // vnode 中的 data 和 tag 属性同时为真,则对其属性(class、style等)进行更新
    if (isDef(data) && isPatchable(vnode)) {
        for (i = 0; i < cbs.update.length; ++i) {
            cbs.update[i](oldVnode, vnode);
        }
    }

    // vnode.text 为 undefined 或 null
    if (isUndef(vnode.text)) {
        // oldVnode 和 vnode 都有子节点且不相等,则更新子节点
        if (isDef(oldCh) && isDef(ch)) {
            if (oldCh !== ch) {
                updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly);
            }
        } else if (isDef(ch)) { // 只有 vnode 有子节点,则将其子节点添加到elm

            checkDuplicateKeys(ch); // 检测 key 是否重复

            if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, ''); // 将elm置空

            addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);

        } else if (isDef(oldCh)) { // 只有 oldVnode 有子节点,则删除其子节点

            removeVnodes(oldCh, 0, oldCh.length - 1);

        } else if (isDef(oldVnode.text)) {

            nodeOps.setTextContent(elm, '');  // 将elm置空
        }

    } else if (oldVnode.text !== vnode.text) {
        // 若是 vnode 和 oldVnode 都有属性 text(文本节点)并且不相等,则将 vnode.text 赋值给elm
        nodeOps.setTextContent(elm, vnode.text);
    }
}

updateChildren 函数

updateChildren 函数,可以说是 diff 的核心,它主要是比较并找出新旧虚拟节点的子节点差异,以便实现最小化更新。

新旧子节点比较概括

updateChildren 函数接收的 oldCh 和 newCh 参数分别代表旧子节点和新子节点,它们各有两个头尾节点和两个头尾节点索引:

  • 节点

    • oldStartVnode —— 旧子节点的开始节点
    • oldEndVnode —— 旧子节点的结束节点
    • newStartVnode —— 新子节点的开始节点
    • newEndVnode —— 新子节点的结束节点
  • 节点索引

    • oldStartIdx —— 旧子节点的开始节点索引
    • oldEndIdx —— 旧子节点的结束节点索引
    • newStartIdx —— 新子节点的开始节点索引
    • newEndIdx —— 新子节点的结束节点索引

这些变量组合成四种比较方式。假若这四种方式都没匹配到,但节点设置了 key,那就会用 key 进行比较。而在比较的过程中,这些变量会逐渐向中间移动。当 oldStartIdx > oldEndIdxnewStartIdx > newEndIdx,则表明 oldCh 和 newCh 至少有一个已完成遍历,就会结束比较。

新旧子节点比较方式

  1. oldStartVnode 和 newStartVnode 比较。
  2. oldEndVnode 和 newEndVnode 比较。
  3. oldStartVnode 和 newEndVnode 比较。
  4. oldEndVnode 和 newStartVnode 比较。
  5. 以上4种比较都没匹配到,若是设置了key,就用key进行比较。
function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    let oldStartIdx = 0; // 旧节点的开始节点索引
    let newStartIdx = 0; // 新子节点的开始节点索引
    let oldEndIdx = oldCh.length - 1; // 旧子节点的结束节点索引
    let oldStartVnode = oldCh[0]; // 旧子节点的开始节点
    let oldEndVnode = oldCh[oldEndIdx]; // 旧子节点的结束节点
    let newEndIdx = newCh.length - 1; // 新子节点的结束节点索引
    let newStartVnode = newCh[0]; // 新子节点的开始节点
    let newEndVnode = newCh[newEndIdx]; // 新子节点的结束节点
    let oldKeyToIdx, idxInOld, vnodeToMove, refElm;

    // removeOnly 是仅使用 <transition-group> 时的一个特殊标志,
    // 以确保在离开转换期间被移除的元素保持在正确的相对位置
    const canMove = !removeOnly;

    checkDuplicateKeys(newCh); // 检查 key 值是否重复

    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (isUndef(oldStartVnode)) {
            // oldStartVnode 向右移动
            oldStartVnode = oldCh[++oldStartIdx];
        } else if (isUndef(oldEndVnode)) {
            // oldEndVnode 向左移动
            oldEndVnode = oldCh[--oldEndIdx];
        } else if (sameVnode(oldStartVnode, newStartVnode)) {
            // oldStartVnode 和 newStartVnode 是同一个节点,则进行比较
            patchVnode(
                oldStartVnode,
                newStartVnode,
                insertedVnodeQueue,
                newCh,
                newStartIdx
            );
            // oldStartVnode 和 newStartVnode 向右移动
            oldStartVnode = oldCh[++oldStartIdx];
            newStartVnode = newCh[++newStartIdx];
        } else if (sameVnode(oldEndVnode, newEndVnode)) {
            // oldEndVnode 和 newEndVnode 是同一个节点,则进行比较
            patchVnode(
                oldEndVnode,
                newEndVnode,
                insertedVnodeQueue,
                newCh,
                newEndIdx
            );
            // oldEndVnode 和 newEndVnode 向左移动
            oldEndVnode = oldCh[--oldEndIdx];
            newEndVnode = newCh[--newEndIdx];
        } else if (sameVnode(oldStartVnode, newEndVnode)) {
            // oldStartVnode 和 newEndVnode 是同一个节点,则进行比较
            patchVnode(
                oldStartVnode,
                newEndVnode,
                insertedVnodeQueue,
                newCh,
                newEndIdx
            );

            canMove &&
                // 插入元素
                nodeOps.insertBefore(
                    parentElm,
                    oldStartVnode.elm,
                    nodeOps.nextSibling(oldEndVnode.elm)
                );

            // oldStartVnode 向右移动,newEndVnode 向左移动
            oldStartVnode = oldCh[++oldStartIdx];
            newEndVnode = newCh[--newEndIdx];
        } else if (sameVnode(oldEndVnode, newStartVnode)) {
            // oldEndVnode 和 newStartVnode 是同一个节点,则进行比较
            patchVnode(
                oldEndVnode,
                newStartVnode,
                insertedVnodeQueue,
                newCh,
                newStartIdx
            );
            canMove &&
                // 插入元素
                nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
            // oldEndVnode 向左移动,newStartVnode 向右移动
            oldEndVnode = oldCh[--oldEndIdx];
            newStartVnode = newCh[++newStartIdx];
            
        } else { // 第五种比较: 通过 key 来进行比较
            
            if (isUndef(oldKeyToIdx)) {
                // 创建一个以旧子节点的 key 和索引做为键与值的对象并返回
                oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
            }
            // 获取当前的旧子节点索引
            idxInOld = isDef(newStartVnode.key)
                ? oldKeyToIdx[newStartVnode.key]
                // 找出 oldCh(旧节点数组)中和 newStartVnode 是同一个节点的元素,然后返回其索引
                : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);

            // 判断 idxInOld 是否存在
            if (isUndef(idxInOld)) {
                // 创建新节点
                createElm(
                    newStartVnode,
                    insertedVnodeQueue,
                    parentElm,
                    oldStartVnode.elm,
                    false,
                    newCh,
                    newStartIdx
                );
            } else {
                // 取出当前需要比较的旧子节点
                vnodeToMove = oldCh[idxInOld];
                // 是否为相同节点
                if (sameVnode(vnodeToMove, newStartVnode)) {
                    patchVnode(
                        vnodeToMove,
                        newStartVnode,
                        insertedVnodeQueue,
                        newCh,
                        newStartIdx
                    );
                    // 更新比较后,排除当前旧子节点即设置为 undefined,不在比较它。
                    oldCh[idxInOld] = undefined;
                    canMove &&
                        // 插入元素
                        nodeOps.insertBefore(
                            parentElm,
                            vnodeToMove.elm,
                            oldStartVnode.elm
                        );
                } else {
                    // 相同的 key,但却是不同的元素。视为新元素。
                    createElm(
                        newStartVnode,
                        insertedVnodeQueue,
                        parentElm,
                        oldStartVnode.elm,
                        false,
                        newCh,
                        newStartIdx
                    );
                }
            }
            // newStartVnode 向右移动
            newStartVnode = newCh[++newStartIdx];
        }
    }
    
    // 退出判断循环后,说明新或旧节点数组有一个已被查找完,这时有两种情况要处理:
    // 第一种:oldStartIdx > oldEndIdx,则说明新节点数组中有剩余节点,需要新增。
    // 第二种:newStartIdx > newEndIdx,则说明旧节点数组中有剩余节点,需要删除。
    if (oldStartIdx > oldEndIdx) {
        refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm;
        addVnodes(
            parentElm,
            refElm,
            newCh,
            newStartIdx,
            newEndIdx,
            insertedVnodeQueue
        );
    } else if (newStartIdx > newEndIdx) {
        removeVnodes(oldCh, oldStartIdx, oldEndIdx);
    }
}

图文解析

为了更好的理解 updateChildren 的代码逻辑(diff 过程),下面我将用图文结合的方式对它进行一次解析。

第一种比较方式

oldStartVnode 和 newStartVnode 进行比较:

  1. 执行sameVnode判断是否为相同节点。

  2. 若是相同节点,则执行patchVnode找出两者间的差异,有差异就更新视图,没有差异则什么都不做。在本例的真实 dom 中,文本内容 A 会更新为 a。

  3. oldStartVnode 和 newStartVnode 向右移动。

oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];

截屏2021-10-17 19.09.33.png

第二种比较方式

oldEndVnode 和 newEndVnode 进行比较:

  1. 执行sameVnode判断是否为相同节点。

  2. 若是相同节点,则执行patchVnode找出两者间的差异,有差异就更新视图,没有差异则什么都不做。在本例的真实 dom 中,文本内容 D 会更新为 d。

  3. oldEndVnode 和 newEndVnode 向左移动

oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];

截屏2021-10-17 19.07.00.png

第三种比较方式

oldStartVnode 和 newEndVnode 比较:

  1. 执行sameVnode判断是否为相同节点。

  2. 若是相同节点,则执行patchVnode找出两者间的差异,有差异就更新视图,没有差异则什么都不做。在本例的真实 dom 中,文本内容 A 会更新为 d。

  3. oldStartVnode 对应的真实dom 位移到 oldEndVnode 对应的真实 dom 之后。

// 插入元素
nodeOps.insertBefore(
    parentElm,
    oldStartVnode.elm,
    nodeOps.nextSibling(oldEndVnode.elm)
);
  1. oldStartVnode 向右移动,newEndVnode 向左移动。
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];

截屏2021-10-17 19.03.16.png

第四种比较方式

oldEndVnode 和 newStartVnode 比较:

  1. 执行sameVnode判断是否为相同节点。

  2. 若是相同节点,则执行patchVnode找出两者间的差异,有差异就更新视图,没有差异则什么都不做。在本例的真实 dom 中,文本内容 D 会更新为 a。

  3. oldEndVnode 对应的真实 dom 位移到 oldStartVnode 对应的真实 dom 之前。

// 插入元素
nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
  1. oldEndVnode 向左移动,newStartVnode 向右移动。
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];

截屏2021-10-17 18.54.22.png

第五种比较方式

若是以上4种比较都没匹配到,但设置了key,那就用key进行比较。下面有关大概思路和处理过程的叙述,建议结合 updateChildren 中关于用 key 进行比较的代码来看。

大概思路:

先通过 key 找出 oldCh(旧子节点数组)中与 newStartVnode(新子节点的开始节点)相同的节点的索引。若是没找到,就让 newStartVnode 同 oldCh 中的节点逐个比较,找出相同节点的索引。最后,根据这个索引是否存在,判断是进行节点比较还是创建新节点。

处理过程:

  1. 索引(idxInOld)不存在,执行createElm创建新节点。
  2. 索引(idxInOld)存在,则根据索引从 oldCh 中取出这个节点(vnodeToMove)进行比较。
    1. 执行sameVnode判断是否为相同节点。
    2. 是相同节点,则执行patchVnode找出两者间的差异,有差异就更新视图,没有差异则什么都不做。然后,将此节点对应的真实 dom 移动到 oldStartVnode 对应的真实 dom 之前。
    3. 不是相同节点(相同的 key,但却是不同的元素,则视为新元素),执行createElm创建新节点。
  • newStartVnode 向右移动

上述处理过程,在整体上分为两种情况:有相同节点和没有相同节点(需调用 createElm 函数)。 注意,实线尖头代表找到了相同节点,虚线尖头代表没找到相同节点。

  • 有相同节点的示意图

截屏2021-10-17 21.44.29.png

  • 没有相同节点的示意图

截屏2021-10-17 22.08.39.png

退出循环后的处理

代码展示:

if (oldStartIdx > oldEndIdx) {
    refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm;
    addVnodes(
        parentElm,
        refElm,
        newCh,
        newStartIdx,
        newEndIdx,
        insertedVnodeQueue
    );
} else if (newStartIdx > newEndIdx) {
    removeVnodes(oldCh, oldStartIdx, oldEndIdx);
}

退出循环,意味着在新子节点数组和旧子节点数组中,至少有一个已被查找完。从代码可以看出,这分为两种处理情况:

  1. oldStartIdx > oldEndIdx(旧子节点数组先遍历完),则说明 newCh(新子节点数组)中的节点比 oldCh(旧子节点数组)中的节点要多,也就是 newCh 中有剩余节点,这些节点需要新增。总结成一句话就是:oldCh 中没有的子节点,而 newCh 中有,就新增

新增节点是通过调用 addVnodes 函数。但实际上,它在执行时会去调用 createElm 函数创建节点元素,然后通过 insert 函数,将节点元素插入到相应位置。

// 为便于了解代码逻辑,所以省略了一些代码
function createElm(
    vnode, // 虚拟节点对象
    insertedVnodeQueue, // 存储已插入的 vnode 的队列
    parentElm, // vnode.elm 父元素
    refElm, // 紧跟在 vnode.elm 之后的元素
    nested,
    ownerArray,
    index
) {
    if (isDef(vnode.elm) && isDef(ownerArray)) {
        // 这个 vnode 在之前的渲染中使用过!
        // 现在它被用作一个新节点,当它被用作插入参考节点时,覆盖它的 elm 会导致潜在的补丁错误。
        // 相反,我们在为节点创建相关的 DOM 元素之前按需克隆节点。
        vnode = ownerArray[index] = cloneVNode(vnode);
    }

    const data = vnode.data; // 获取元素属性
    const children = vnode.children; // 获取子元素
    const tag = vnode.tag; // 获取标签

    // 元素节点
    if (isDef(tag)) {
        // 创建元素
        vnode.elm = nodeOps.createElement(tag, vnode);
        // 创建子元素
        createChildren(vnode, children, insertedVnodeQueue);
        if (isDef(data)) {
            // 处理元素上的各种属性
            invokeCreateHooks(vnode, insertedVnodeQueue);
        }
        // 插入元素
        insert(parentElm, vnode.elm, refElm);
    } else {
        // 纯文本节点
        vnode.elm = nodeOps.createTextNode(vnode.text);
        insert(parentElm, vnode.elm, refElm);
    }
}
// 为便于了解代码逻辑,所以省略了一些代码
function insert(parent, elm, ref) {
    // 存在父级
    if (isDef(parent)) {
        // elm 之后存在元素(有同级的兄弟元素)
        if (isDef(ref)) {
            // elm 和 ref 元素的父级元素是同一个(elm 和 ref是同级兄弟元素)
            if (nodeOps.parentNode(ref) === parent) {
                // 将 elm 插入到 ref 之前
                nodeOps.insertBefore(parent, elm, ref);
            }
        } else {
            // elm 之后不存在元素,也就是 ref 不存在,则将 elm 插入到 parent 节点
            // 元素列表(childNodes[] 数组)的末尾
            nodeOps.appendChild(parent, elm);
        }
    }
}

新增的元素节点的位置示意图:

  • 新增的元素节点后不存在元素节点

截屏2021-10-18 17.55.53.png

  • 新增的元素节点后存在元素节点

截屏2021-10-18 18.05.25.png

  1. newStartIdx > newEndIdx(新子节点数组先遍历完),则说明 oldCh(旧子节点数组)中的节点比 newCh(新子节点数组)中的节点要多,也就是 oldCh 中有剩余节点,这些节点需要删除。总结成一句话就是:newCh 中没有的子节点,而 oldCh 中有,就删除

删除节点主要通过调用 removeVnodes 函数。

function removeVnodes(vnodes, startIdx, endIdx) {
    for (; startIdx <= endIdx; ++startIdx) {
        const ch = vnodes[startIdx];
        
        if (isDef(ch)) { // ch 是否存在
            removeNode(ch.elm);
        }
    }
}

function removeNode(el) {
    const parent = nodeOps.parentNode(el);
    
    if (isDef(parent)) { // parent 是否存在
        nodeOps.removeChild(parent, el); // 删除
    }
}

删除元素节点的示意图:

截屏2021-10-18 18.31.28.png

结束

在阅读的过程中,如果同学们发现了说的不对的地方,还请不吝赐教。当然,若是你觉得有所收获,还请为我点个赞👍,谢谢!