Vue3 Diff 算法解析

208 阅读5分钟

关于 diff 算法我觉得可以分为两部分讲,一部分是简单情况的比对,一部分是复杂情况的比对

简单情况

其实就是两个节点不相同的情况,如果两个节点不相同就是暴力增删,删除旧节点,增添新节点

image.png tips:processElement 如果传入 n1 为 null 就会直接创建 n2 节点上树

复杂情况

那和简单情况相对的,就是节点相同的情况,这种情况大致可以分为两种:

  • 情况1: 有一方不具有孩子节点的情况
  • 情况2: 都有孩子节点的情况

image.png

情况1

对于情况1的处理方式其实和简单情况的处理方式很相似,这里做的就是暴力的替换,以前面的文本节点比对情况为例,hostSetElementText 函数做的就是直接替换掉旧的文本内容

function hostSetElementText(el, text) {
    el.textContent = text
}

情况2

情况2的处理相对复杂,vue3中用到了两端比对最长递增子序列去优化算法

  • 两端比对

vue3 会先对孩子节点的前半部分和后半部分进行比对,筛选掉前后节点顺序相同的情况,对于这部分节点我们只需要处理他们的属性与内容的变化,无需移动位置,这样可以提高性能,减少对节点的操作

function patchKeyedChildren(c1, c2, container) {
    let i = 0;
    const l2 = c2.length;
    let e1 = c1.length - 1;
    let e2 = l2 - 1;

    // 优化:把前面相同的节点先处理掉
    while (i <= e1 && i <= e2) {
        const prevChild = c1[i];
        const nextChild = c2[i];

        if (!isSameVnode(prevChild, nextChild)) {
            break;
        }

        patch(prevChild, nextChild, container);
        i++;
    }

    // 优化:把后面相同的节点先处理掉
    while (i <= e1 && i <= e2) {
        // 从右向左取值
        const prevChild = c1[e1];
        const- nextChild = c2[e2];

        if (!isSameVnode(prevChild, nextChild)) {
            break;
        }
        patch(prevChild, nextChild, container);
        e1--;
        e2--;
    }

    // 通过 i,e1,e2,确定出现的新节点,或被删除的节点
    // 节点新增
    if (i > e1 && i <= e2) {
        const nextPos = e2 + 1; // e2 有移动说明是往前面插入
        const anchor = nextPos < l2 ? c2[nextPos].el : null;
        while (i <= e2) {
            patch(null, c2[i], container, anchor);
            i++;
        }
    } else if (i > e2 && i <= e1) {
        // 这种情况的话说明新节点的数量是小于旧节点的数量的
        // 那么我们就需要把多余的删除
        while (i <= e1) {
            console.log(`需要删除当前的 vnode: ${c1[i].key}`);
            hostRemove(c1[i].el);
            i++;
        }
    } else { 
        // 左右两边比对完了,中间部分可能发生了乱序或者增删
        // 这里要做的其实是调换位置或者增删节点
        // todo
    }
  }
  • 中间比对

中间部分的比对其实就是遍历老节点把新节点没有的老节点删掉(通过新节点用key和index建立map),把一样的节点进行patch,最后通过求最长递增子序列去移动位置只需要移动递增子序列以外的节点这样可以减少 dom 操作,对于没有位置映射关系的节点说明是中间部分的新增节点直接创建在容器下即可)。

else {
        // 左右两边比对完了,中间部分可能发生了乱序或者增删
        // 这里要做的其实是调换位置或者增删节点

        let s1 = i;
        let s2 = i;
        const keyToNewIndexMap = new Map(); // 建立新节点key与当前位置的映射
        for (let i = s2; i <= e2; i++) {
            const nextChild = c2[i];
            keyToNewIndexMap.set(nextChild.key, i);
        }

        let patched = 0;
        // 需要处理新节点的数量
        const toBePatched = e2 - s2 + 1;

        // 建立新的节点位置和旧节点位置的映射关系
        const newIndexToOldIndexMap = new Array(toBePatched).fill(0);

        // 遍历老节点
        // 1. 老节点没有,而新节点有 -> 增加节点
        // 2. 需要找出老节点有,而新节点没有的 -> 需要把这个节点删除掉
        // 3. 新老节点都有的,—> 需要 patch
        for (i = s1; i <= e1; i++) {
            const prevChild = c1[i];

            // 在比对完新节点后,如果老的节点仍有剩余,那么这里老节点直接删除即可
            if (patched >= toBePatched) {
                hostRemove(prevChild.el);
                continue;
            }

            let newIndex;
            if (prevChild.key != null) {
                // 这里就可以通过key快速的查找了, 看看在新的里面这个节点存在不存在
                newIndex = keyToNewIndexMap.get(prevChild.key);
            } else {
                // 如果没key 的话,那么只能是遍历所有的新节点来确定当前节点存在不存在了
                // 时间复杂度O(n)
                for (let j = s2; j <= e2; j++) {
                    if (isSameVnode(prevChild, c2[j])) {
                        newIndex = j;
                        break;
                    }
                }
            }

            // 因为有可能 nextIndex 的值为0(0也是正常值)
            // 所以需要通过值是不是 undefined 或者 null 来判断
            if (newIndex === undefined) {
                // 当前节点的key 不存在于 newChildren 中,需要把当前节点给删除掉
                hostRemove(prevChild.el);
            } else {
                // 新老节点都存在
                // 把新节点的索引和老的节点的索引建立映射关系
                // i + 1 是因为我们把 0 认为是新节点,在下面判断中值为 0 时会创建新节点
                newIndexToOldIndexMap[newIndex - s2] = i + 1;

                patch(prevChild, c2[newIndex], container, null); // 这里只能修改 props 和值的变化,并不能处理位置的变化
                patched++;
            }
        }

        // 获取最长递增子序列
        let increment = getSequence(newIndexToOldIndexMap)
        let j = increment.length - 1
        console.log('increment---', increment);
        

        // 处理需要移动的位置
        // 从后开始处理,从后往前插入(node.insertBefore)
        for (let i = toBePatched - 1; i >= 0; i--) {
            let index = i + s2
            let current = c2[index]
            let anchor = index + 1 < c2.length ? c2[index + 1].el : null;
            if (newIndexToOldIndexMap[i] === 0) { // 没有映射关系说明是新节点,直接创建
                patch(null, current, container, anchor)
            } else { // 需要移动位置的节点
                if (i != increment[j]) {
                    hostInsert(current.el, container, anchor)
                } else {
                    j--
                }
            }
        }

    }

学习建议

我个人是参考 vuejs/core 还有 mini-vue 这个两个库去进行源码的学习,然后自己再去复现(下面第三个链接,如果对你有帮助记得给个start)总结,不懂的就看看其他技术文章或者是视频。