Vue的diff算法到底优化在了哪里

前言

我们都知道,虚拟 dom 是 Vue 和 React 页面渲染性能优化的大杀器,可是在更新时,他们是如何高效地计算出更改的地方的呢?

diff 算法的真面目

传统 diff 算法(计算一颗树映射到例外一颗树所需的最小操作数)的步骤说起来非常简单:

    1. 将两颗树中所有的节点一一对比,循环递归;
    1. 更新树,即删除未找到的旧节点,更新未找到的新节点,更新节点位置。

但是该算法经历了太多次进化,时间复杂度从1979年的 O((m3)(n3)) ,发展到2011年的 O(n^3) ,哪怕是这样,传统的 diff 算法仍然不足以为前端所用,毕竟这种复杂度意味着假如虚拟 dom 有1000个节点就需要进行10^9次比较。

// 传统diff算法处理dom更新 简单实现
let result = [];
// 比较叶子节点
const diffLeafs = function (beforeLeaf, afterLeaf) {
    // 获取较大节点树的长度
    let count = Math.max(beforeLeaf.children.length, afterLeaf.children.length);
    // 循环遍历
    for (let i = 0; i < count; i++) {
        const beforeTag = beforeLeaf.children[i];
        const afterTag = afterLeaf.children[i];
        // 添加 afterTag 节点
        if (beforeTag === undefined) {
            result.push({ type: "add", element: afterTag });
            // 删除 beforeTag 节点
        } else if (afterTag === undefined) {
            result.push({ type: "remove", element: beforeTag });
            // 节点名改变时,删除 beforeTag 节点,添加 afterTag 节点
        } else if (beforeTag.tagName !== afterTag.tagName) {
            result.push({ type: "remove", element: beforeTag });
            result.push({ type: "add", element: afterTag });
            // 节点不变而内容改变时,改变节点
        } else if (beforeTag.innerHTML !== afterTag.innerHTML) {
            if (beforeTag.children.length === 0) {
                result.push({
                    type: "changed",
                    beforeElement: beforeTag,
                    afterElement: afterTag,
                    html: afterTag.innerHTML
                });
            } else {
                // 递归比较
                diffLeafs(beforeTag, afterTag);
            }
        }
    }
    return result;
}
// 拿到处理队列后再进行处理...

根据前端真实场景优化算法

Vue 和 React 是怎么将 diff 算法的时间复杂度从 O(n^3) 降至 O(n) 的呢?这来自对于前端真实场景的深刻理解。

首先,虚拟 dom 虽然是树状结构,但是在前端开发中,需要节点跨层级移动的 ui 非常少,与其耗费算力在这个上面,不如直接默认为删除或新增,将比对停留在同级之间;

function patch (oldVnode, vnode, parentElm) {
    if (!oldVnode) {
        addVnodes(parentElm, null, vnode, 0, vnode.length - 1);
    } else if (!vnode) {
        removeVnodes(parentElm, oldVnode, 0, oldVnode.length - 1);
    } else {
        if (sameVnode(oldVNode, vnode)) {
            patchVnode(oldVNode, vnode);
        } else {
            removeVnodes(parentElm, oldVnode, 0, oldVnode.length - 1);
            addVnodes(parentElm, null, vnode, 0, vnode.length - 1);
        }
    }
}

其次,如果两个节点的类型不一样,默认就不可能是同一节点,自然不用再往下比对了,直接替换就可以了;

function sameVnode (a, b) {
    return (
        a.key === b.key &&
        a.tag === b.tag &&
        a.isComment === b.isComment &&
        (!!a.data) === (!!b.data) &&
        sameInputType(a, b)
    )
}

最后,同一级的大量同类型子节点,是前端开发中常见的( v-for ),可通过开发者提供唯一的 id 来判断是否为同一节点,以空间换时间。

自此,框架开发者通过对前端的理解,完成了看似粗暴,实则与实际场景贴切的优化。

Vue 和 React 的 diff 算法差别在哪

虽然两者的优化思想是一致的,但是在细节上, Vue 和 React 对于 diff 算法的实现还是有一些区别,除了对于节点是否相同的判断差异外(比如 className 不同的同类型节点, Vue 会认为不是相同的但是 React 会),主要差异在于同级多个同类型子节点——即列表更新。

React 的列表更新

React 的列表更新

React 首先对新集合的节点( nextChildren )进行遍历, lastIndex 初始为零,通过唯一的 key 可以取得老集合中相同的节点并判断:

    1. 如果不存在,便在 lastIndex 后添加新节点;
    1. 如果存在相同节点,则进行移动操作,但在移动前:
      1. 1 lastIndex 大于等于当前节点在老集合中的位置,执行移动操作;
      1. 2 lastIndex 小于当前节点在老集合中的位置,不执行移动操作;
    1. 更新 lastIndex 为 lastIndex 和老位置中的较大值;
      遍历结束后,将未匹配的节点删除。

其中第二步判断是因为,如果老节点的位置比新节点位置靠后,说明该节点不会影响到其他节点的位置,在他之前多出的节点必然会被移动到后放或者被删除,所以该节点不用进行操作。


React 列表更新例一

但是有一种情形是 React 算法实现中待优化的:


React 列表更新例二

当遍历到 D 时,此时 D 不移动,但它导致 lastIndex 更新为3,从而使得其他元素 A , B , C 的 index < lastIndex ,导致 A ,B , C 都要去移动。

Vue 的列表更新

function updateChildren (parentElm, oldCh, newCh) {
    /* 定义 oldStartIdx、newStartIdx、oldEndIdx 以及 newEndIdx
    分别是新老两个 VNode 的两边的索引
    同时 oldStartVnode、newStartVnode、oldEndVnode 以及 newEndVnode
    分别指向这几个索引对应的 VNode 节点 */
    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, elmToMove, refElm;

    // oldStartIdx、newStartIdx、oldEndIdx 以及 newEndIdx 会逐渐向中间靠拢
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        // 当 oldStartVnode 或者 oldEndVnode 不存在的时候,oldStartIdx 与 oldEndIdx 继续向中间靠拢
        if (!oldStartVnode) {
            oldStartVnode = oldCh[++oldStartIdx];
        } else if (!oldEndVnode) {
            oldEndVnode = oldCh[--oldEndIdx];
        } else if (sameVnode(oldStartVnode, newStartVnode)) {
            // oldStartIdx、newStartIdx、oldEndIdx 以及 newEndIdx 两两比对的过程
            patchVnode(oldStartVnode, newStartVnode);
            oldStartVnode = oldCh[++oldStartIdx];
            newStartVnode = newCh[++newStartIdx];
        } else if (sameVnode(oldEndVnode, newEndVnode)) {
            patchVnode(oldEndVnode, newEndVnode);
            oldEndVnode = oldCh[--oldEndIdx];
            newEndVnode = newCh[--newEndIdx];
        } else if (sameVnode(oldStartVnode, newEndVnode)) {
            // 两种交叉比对的情况,匹配上是会将对应的节点移动到头或尾
            patchVnode(oldStartVnode, newEndVnode);
            nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm));
            oldStartVnode = oldCh[++oldStartIdx];
            newEndVnode = newCh[--newEndIdx];
        } else if (sameVnode(oldEndVnode, newStartVnode)) {
            patchVnode(oldEndVnode, newStartVnode);
            nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
            oldEndVnode = oldCh[--oldEndIdx];
            newStartVnode = newCh[++newStartIdx];
        } else {
            // 4种情况都没匹配上,先创建keyMap,找到相同的节点
            let elmToMove = oldCh[idxInOld];
            if (!oldKeyToIdx) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
            idxInOld = newStartVnode.key ? oldKeyToIdx[newStartVnode.key] : null;
            // 如果没找到就创建一个新的
            if (!idxInOld) {
                createElm(newStartVnode, parentElm);
                newStartVnode = newCh[++newStartIdx];
            } else {
                // 如果找到了,且是同类型节点,更新
                elmToMove = oldCh[idxInOld];
                if (sameVnode(elmToMove, newStartVnode)) {
                    patchVnode(elmToMove, newStartVnode);
                    oldCh[idxInOld] = undefined;
                    nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm);
                    newStartVnode = newCh[++newStartIdx];
                } else {
                    // 否则也创建新的
                    createElm(newStartVnode, parentElm);
                    newStartVnode = newCh[++newStartIdx];
                }
            }
        }
    }
    // 新老集合有其中一个遍历完毕,开始处理该删除或新增的
    if (oldStartIdx > oldEndIdx) {
        refElm = (newCh[newEndIdx + 1]) ? newCh[newEndIdx + 1].elm : null;
        addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx);
    } else if (newStartIdx > newEndIdx) {
        removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
    }
}

Vue 的列表更新与 React 不同,他的比对顺序会相对复杂:
不断循环判断并执行以下步骤:

    1. 先找到老集合中的前后边界的节点;
    1. 将新老集合的前后边界节点两两比对,当比对成功时一起对应指针向中间靠拢(如果是前后交叉匹配成功,进行移动操作);
    1. 通过新节点的 key 找到老集合中的相同节点,若找到,便进行移动;若未找到,创建一个新节点插入。

直到新老边界指针有一组出现交叉,则表示其中一组已经遍历结束,退出循环开始剩余节点的新增或删除。

其中的步骤二,正好解决的就是 React 待优化的场景,同样的场景在 Vue 中执行时,只需要把 D 移动到第一位就行了:


Vue 列表更新例一

但是当 Vue 遇到 React 例一中的场景,却变得无能为力,会直接开始依靠 key 遍历更新。

小结

Vue 和 React 的 diff 算法优化思想是几乎相同的,但是他们在具体实现上的差别是因为处理某些场景的优先级不同导致的, Vue 对于节点列表中的移动场景更新渲染更友好(拖拽,翻转等),而 React 在这方面并没有做太多优化。不过 Vue 和 React 的关系总是这样,一个是开创者,一个是优化者(理性讨论不是引战~)。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,098评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,213评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,960评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,519评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,512评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,533评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,914评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,574评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,804评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,563评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,644评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,350评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,933评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,908评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,146评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,847评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,361评论 2 342

推荐阅读更多精彩内容