vue3 Diff 算法

255 阅读4分钟

说明
本文是霍春阳 [vue.js设计与实现] 中第9、10、11章的学习笔记。
文中是用dom和vnode来演示的,为了简化代码直接使用的字符串代替vnode来做对比,且不使用dom。

前言

  • vue3 Diff 算法是对比新虚拟dom和旧虚拟dom的差别,对旧数据进行移动、新增、删除来得到新数据。因为创建dom、删除dom的开销较大,通过diff来改变旧数据,更多地利用已有数据,提高性能。新增和删除比较简单,重点是相同数据顺序不一致时如何移动。
  • 代码中不考虑重复数据,因为v-key不重复。
  • 代码不进行真实的数据操作,数组改变后,判断条件等也会改动。vue中通过对vnode的判断来更新dom,vnode数组不会发生变化

简单 Diff 算法

数据如下,新数组作为目标,顺序处理:

const news = ['a', 'c', 'b'];
const olds = ['a', 'b', 'c'];
  • a 顺序相同,不处理
  • c 在a之后,相对顺序相同,不处理
  • b 新—在c之后,旧—在c之前,移动

实现

双重循环:新数组、旧数组,数组的循环是有序的,当前的索引也是数据在数组中的顺序。在遍历过程中遇到的索引值呈现递增趋势,则说明不需要移动,反之则需要。

function simpleDiff(olds, news) {
    console.log('\n***简单diff***');
    console.log('初始数组:', olds);
    console.log('目标数组:', news, '\n');
    // 上一个未移动的元素索引
    // 用来存储过程中遇到的最大索引值
    let lastIndex = 0;

    for(let i = 0; i < news.length; i++) {
        let find = false;
        const value = news[i];
        for(let j = 0; j < olds.length; j++) {
            // 旧的元素存在则可能移动,也可能保持不变
            if(olds[j] === value) {
                find = true;
                if(j >= lastIndex) {
                     // 新数据是对应的旧数据的相对顺序 相同
                    lastIndex = j;
                    console.log('元素[%s]: 不动', value);
                }else {
                    // 如果i=0,则说明是第一个元素,不移动
                    const prevIndex = i - 1;
                    if(prevIndex > -1) {
                        console.log('元素[%s]: 移动到元素[%s]后', value, news[prevIndex]);
                    }
                }
                break;
            }
        }

        if(!find) {
            const prev = i - 1 > -1 ? news[i-1] : undefined;
            console.log('元素[%s]: 新增在元素[%s]后', value, prev);
        }
    }

    for(let i = olds.length - 1; i > -1; i--) {
        const has = news.find(item => item === olds[i]);
        if(!has) {
            console.log('元素[%s]: 删除', olds[i]);
        }
    }
}

执行

const news = ['a', 'c', 'b', 'd'];
const olds = ['a', 'b', 'c', 'e'];
simpleDiff(olds, news);

***简单diff***
初始数组: [ 'a', 'b', 'c', 'e' ]
目标数组: [ 'a', 'c', 'b', 'd' ]

元素[a]: 不动
元素[c]: 不动
元素[b]: 移动到元素[c]后
元素[d]: 新增在元素[b]后
元素[e]: 删除

双端 Diff 算法

此算法是vue2采用的Diff算法

简单Diff算法的缺点在于,它的移动操作不是最优的

const olds = ['1', '2', '3', '4'];
const news = ['4', '1', '2', '3'];
simpleDiff(olds, news);

***简单diff***
初始数组: [ '1', '2', '3', '4' ]
目标数组: [ '4', '1', '2', '3' ]

元素[4]: 不动
元素[1]: 移动到元素[4]后
元素[2]: 移动到元素[1]后
元素[3]: 移动到元素[2]后

此数据用简单 Diff 移动了3次,但最简单的是移动1次,将4移动到最前面,双端算法可以做到。

实现

双端算法是一种同时对新旧两组数据进行比较的算法,因此此算法需要四个索引值分别指向两组数据的首尾端点。

function doubleEndedDiff(paramOlds, paramNews) {
     // 里边会对原数组有些改动
    const olds = [...paramOlds];
    const news = [...paramNews];
    console.log('\n***双端diff***');
    console.log('初始数组:', olds);
    console.log('目标数组:', news, '\n');
    let oldStartIdx = 0;
    let oldEndIdx = olds.length - 1;
    let newStartIdx = 0;
    let newEndIdx = news.length - 1;

    let oldStartVal = olds[oldStartIdx];
    let oldEndVal = olds[oldEndIdx];
    let newStartVal = news[newStartIdx];
    let newEndVal = news[newEndIdx];

    while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if(oldStartVal === newStartVal) {
            // 数据在新的顺序中仍然处于首部,不移动
            oldStartVal = olds[++oldStartIdx];
            newStartVal = news[++newStartIdx];
        }else if(oldEndVal === newEndVal) {
            // 数据在新的顺序中仍然处于尾部,不移动
            oldEndVal = olds[--oldEndIdx];
            newEndVal = news[--newEndIdx];
        }else if(oldStartVal === newEndVal) {
            // 将oldStart移动到oldEnd的后面
            console.log('oldStart=newEnd)元素[%s]: 移动到元素[%s]后', oldStartVal, oldEndVal);

            oldStartVal = olds[++oldStartIdx];
            newEndVal = news[--newEndIdx];
        }else if(oldEndVal === newStartVal) {
            // 将oldEnd移动到oldStart的后面
            console.log('oldEnd=newStart)元素[%s]: 移动到元素[%s]前', oldEndVal, oldStartVal);

            oldEndVal = olds[--oldEndIdx];
            newStartVal = news[++newStartIdx];
        } else {
            // 如果两端的值没有匹配的
        }
    }

    console.log('结束循环');
    console.log(`olds index: start=${oldStartIdx}, end=${oldEndIdx}`);
    console.log(`news index: start=${newStartIdx}, end=${newEndIdx}`);

}

执行

const olds = ['1', '2', '3', '4'];
const news = ['4', '1', '2', '3'];
doubleEndedDiff(olds, news);

初始数组: [ '1', '2', '3', '4' ]
目标数组: [ '4', '1', '2', '3' ]

oldEnd=newStart)元素[4]: 移动到元素[1]前
结束循环
olds index: start=3, end=2
news index: start=4, end=3

完善

两端不匹配

    while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        // 旧节点可能已经被处理了,跳到下一个位置继续处理
        if(!oldStartVal) {
            oldStartVal = olds[++oldStartIdx];
        }else if(!oldEndVal) {
            oldEndVal = olds[--oldEndIdx];
        }else if(oldStartVal === newStartVal) {
        }else if(oldEndVal === newEndVal) {
        }else if(oldStartVal === newEndVal) {
        }else if(oldEndVal === newStartVal) {
        } else {
            // 如果两端的值没有匹配的,就处理newStartVal
            // newStartVal是否在初始数组中存在,存在则移动,不存在就新增
            const idxInOld = olds.findIndex(val => val === newStartVal);
            if(idxInOld > 0) {
                // 将olds中的这个移动端头部节点oldStart之前
                console.log('两端不匹配)元素[%s]: 移动到元素[%s]前', newStartVal, oldStartVal);
                // 此数据已经移动了,不需要再处理了
                olds[idxInOld] = undefined;
            }else {
                // 需要新增 新增到当前的头部节点位置
                console.log('两端不匹配)元素[%s]: 新增在元素[%s]前', newStartVal, oldStartVal);
            }
            newStartVal = news[++newStartIdx];
        }
    }

执行

const olds = ['1', '2', '3', '4'];
const news =  ['2', '4', '1', '3'];
doubleEndedDiff(olds, news);
***双端diff***
初始数组: [ '1', '2', '3', '4' ]
目标数组: [ '2', '4', '1', '3' ]

两端不匹配)元素[2]: 移动到元素[1]前
oldEnd=newStart)元素[4]: 移动到元素[1]前
结束循环
olds index: start=3, end=2
news index: start=4, end=3

循环后检查

可能存在遗漏的数据

 console.log(`news index: start=${newStartIdx}, end=${newEndIdx}`);
    // 循环结束后检查索引的值
    if(oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx) {
        // 如果满足条件 说明有新的节点遗留
        for(let i = newStartIdx; i <= newEndIdx; i++) {
            console.log('循环后检查)元素[%s]: 新增在元素[%s]前', news[i], oldStartVal);
        }
    }
const olds = ['1', '2', '3'];
const news =  ['4', '5', '1', '2', '3'];
doubleEndedDiff(olds, news);

***双端diff***
初始数组: [ '1', '2', '3' ]
目标数组: [ '4', '5', '1', '2', '3' ]

结束循环
olds index: start=0, end=-1
news index: start=0, end=1
循环后检查)元素[4]: 新增在元素[1]前
循环后检查)元素[5]: 新增在元素[1]前

移除元素

    // 循环结束后检查索引的值
    if(oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx) {
    }else if(newEndIdx < newStartIdx && oldStartIdx <= oldEndIdx) {
       // 旧数组中还有元素没处理
        for(let i = oldStartIdx; i <= oldEndIdx; i++) {
            if(olds[i]) {
                console.log('元素[%s]: 删除', olds[i]);
            }
        }
    }
const olds = ['1', '2', '3', '4'];
const news =  ['1', '3'];
doubleEndedDiff(olds, news);

***双端diff***
初始数组: [ '1', '2', '3', '4' ]
目标数组: [ '1', '3' ]

两端不匹配)元素[3]: 移动到元素[2]前
结束循环
olds index: start=1, end=3
news index: start=2, end=1
元素[2]: 删除
元素[4]: 删除

快速 Diff 算法

此算法也是目前vue3采用的Diff算法

预处理

快速Diff借鉴了纯文本Diff算法,先对首尾相同的数据做预处理

function fastDiff(olds, news) {
    console.log('\n***快速diff***');
    console.log('初始数组:', olds);
    console.log('目标数组:', news, '\n');
    // 预处理:相同的前置和后置节点
    let i = 0;
    let oldVal = olds[i];
    let newVal = news[i];
    while(oldVal === newVal) {
        i++;
        oldVal = olds[i];
        newVal = news[i];
    }

    let oldEnd = olds.length - 1;
    let newEnd = news.length - 1;
    oldVal = olds[oldEnd];
    newVal = news[newEnd];
    while(oldVal === newVal) {
        oldEnd--;
        newEnd--;
        oldVal = olds[oldEnd];
        newVal = news[newEnd];
    }
    console.log('预处理结束:i = %d, oldEnd = %d, newEnd = %d', i, oldEnd, newEnd);
    console.log('相同的前置节点个数%d,后置节点的个数%d', i, olds.length - 1 - oldEnd);

    // 预处理完毕后,如果满足条件,说明j  newEnd之间存在新增节点
    // i > oldEnd 说明旧的数组处理完了
    // i <= newEnd 说明新数组还有节点没处理
    if(i > oldEnd && i <= newEnd) {
        const anchorIndex = newEnd + 1;
        while(i <= newEnd) {
            console.log('预处理后)元素[%s]: 新增在元素[%s]前', news[i], news[anchorIndex]);
            i++;
        }
    }else if(i > newEnd && i<= oldEnd) {
        // 说的新的已经处理完了,旧的需要卸载
        while(i <= oldEnd) {
            console.log('预处理后)元素[%s]: 删除', olds[i]);
            i++;
        }
    }
}

新增

const olds = [1, 2, 3];
const news = [1, 4, 5, 2, 3];
fastDiff(olds, news);

***快速diff***
初始数组: [ 1, 2, 3 ]
目标数组: [ 1, 4, 5, 2, 3 ]

预处理结束:i = 1, oldEnd = 0, newEnd = 2
相同的前置节点个数1,后置节点的个数2
预处理后)元素[4]: 新增在元素[2]前
预处理后)元素[5]: 新增在元素[2]前

删除

const olds = [1, 2, 3];
const news = [1, 3];
fastDiff(olds, news);

***快速diff***
初始数组: [ 1, 2, 3 ]
目标数组: [ 1, 3 ]

预处理结束:i = 1, oldEnd = 1, newEnd = 0
相同的前置节点个数1,后置节点的个数1
预处理后)元素[2]: 删除

以上部分的用例都比较理想,或是olds全部处理完了,只需要新增,或是news处理完了,只需删除。 还可能出现更复杂的场景,此时索引的值会不满足上面两个分支条件。

继续处理

在预处理完后,还有部分节点需要进一步处理(此时索引i、newEnd、oldEnd不满足上面的两个分支条件),怎么处理勒?

  1. 判断是否有节点需要移动,以及如何移动
  2. 找出需要新增或删除的节点

计算索引表

  • 索引表是新数组预处理后的剩余节点在旧数组中的索引,如果没有则是-1
  • 如果索引表是递增的,说明元素不需要移动,反之则需要
if(i > oldEnd && i <= newEnd) {
}else if(i > newEnd && i<= oldEnd) {
}else {
    console.log('\n继续处理');
    // 新数组中的剩余处理节点数量
    const newStart = i;
    const count = newEnd - newStart + 1;
    // 存储新子节点在olds中的索引
    const source = new Array(count);
    source.fill(-1);
    // 子节点在新数组中的索引
    const keyIndex = {};
    for(let j = newStart; j <= newEnd; j++) {
        const newVal = news[j];
        keyIndex[newVal] = j;
    }
    
    // 判断剩余节点是否需要移动
    let moved = false;
    let pos = 0;
    // 遍历旧数组中的剩余未处理节点
    const oldStart = i;
    for(let j = oldStart; j <= oldEnd; j++) {
        const oldVal = olds[j];
        // 元素在news中的索引
        const k = keyIndex[oldVal];
        if(k !== undefined){
            // 元素在source中的索引
            const index = k - newStart;
            source[index] = j;
            if(k < pos) {
                moved = true;
            }else {
                pos = k;
            }
        }else{
            console.log('索引表计算)元素[%s]: 删除', oldVal);
        }
    }
    console.log('索引表:', source);
    if(!moved) {
        console.log('剩余元素不需要移动');
        return;
    }
}
const olds = [1, 2, 3, 4, 6, 5];
const news = [1, 3, 4, 2, 7, 5];
fastDiff(olds, news);

***快速diff***
初始数组: [ 1, 2, 3, 4, 6, 5 ]
目标数组: [ 1, 3, 4, 2, 7, 5 ]

预处理结束:i = 1, oldEnd = 4, newEnd = 4
相同的前置节点个数1,后置节点的个数1

继续处理
索引表计算)元素[6]: 删除
索引表: [ 2, 3, 1, -1 ]

元素移动方案

  1. 根据索引表是否递增来判断是否需要移动,如果不是递增则需要移动
  2. 如果不需要移动,则结束,否则进行下一步
  3. 计算出索引表中的最长递增子序列对应的索引表(seq)
  4. 对seq和剩余节点(source)开启双指针循环,来判断是元素新增、移动还是不变
    if(!moved) {
        console.log('剩余元素不需要移动');
        return;
    }
    
    // 计算最长递增子序列的索引,方法见附录
    const seq = getSequence(source);
    const seqVals = seq.map(index => source[index]);
    console.log('索引表的最长递增子序列为:' + seqVals, ',对应索引是' + seq);

    
     // 开启循环,移动递增子序列和剩余节点
    // 递增子序列的最后一个节点, seq的长度<=count
    let s = seq.length - 1;
    // 剩余节点的最后一个节点
    let j = count - 1;

    while(j >= 0) {
        if(j === seq[s]) {
             // 说明节点不需要移动,子序列指向下一个位置
            s--;
            j--;
            continue;
        }
        // 元素在news中的索引
        const pos = j + newStart;
        // 倒序循环的,所以后面的节点一定处理过了
        const nextVal = pos + 1 < news.length  ? news[pos+1] : news[news.length-2];
        if(source[j] === -1) {
            // 此节点不在olds中,需要新增
            console.log('递增子序列处理)元素[%s]: 新增在元素[%s]前', news[pos], nextVal);
        }else{
            // j !== seq[s],说明该节点需要移动
            console.log('递增子序列处理)元素[%s]: 移动到元素[%s]前', news[pos], nextVal);
        }
        j--;
    }

还是上面的例子

***快速diff***
初始数组: [ 1, 2, 3, 4, 6, 5 ]
目标数组: [ 1, 3, 4, 2, 7, 5 ]

预处理结束:i = 1, oldEnd = 4, newEnd = 4
相同的前置节点个数1,后置节点的个数1

继续处理
索引表计算)元素[6]: 删除
索引表: [ 2, 3, 1, -1 ]
索引表的最长递增子序列为:2,3 ,对应索引是0,1
递增子序列处理)元素[7]: 新增在元素[5]前
递增子序列处理)元素[2]: 移动到元素[7]前

运行对比

这里用一个案例来看不同方法的处理时间

const olds = [1, 2, 3, 4, 6, 5];
const news = [1, 3, 4, 2, 7, 5];

console.time('simpleDiff');
simpleDiff(olds, news);
console.timeEnd('simpleDiff');
console.log('\n');

console.time('doubleEndedDiff');
doubleEndedDiff(olds, news);
console.timeEnd('doubleEndedDiff');
console.log('\n');

console.time('fastDiff');
fastDiff(olds, news);
console.timeEnd('fastDiff');

在这个案例下

  • 简单Diff和快速Diff的操作方案相同,都是一次新增、一次移动、一次删除,但快速Diff的计算时间更短
  • 双端Diff的操作方案差一些,一次新增、两次移动、一次删除,计算时间最短

***简单diff***
初始数组: [ 1, 2, 3, 4, 6, 5 ]
目标数组: [ 1, 3, 4, 2, 7, 5 ]

元素[1]: 不动
元素[3]: 不动
元素[4]: 不动
元素[2]: 移动到元素[4]后
元素[7]: 新增在元素[2]后
元素[5]: 不动
元素[6]: 删除
simpleDiff: 13.122ms



***双端diff***
初始数组: [ 1, 2, 3, 4, 6, 5 ]
目标数组: [ 1, 3, 4, 2, 7, 5 ]

两端不匹配)元素[3]: 移动到元素[2]前
两端不匹配)元素[4]: 移动到元素[2]前
两端不匹配)元素[7]: 新增在元素[6]前
结束循环
olds index: start=4, end=4
news index: start=5, end=4
元素[6]: 删除
doubleEndedDiff: 2.690ms



***快速diff***
初始数组: [ 1, 2, 3, 4, 6, 5 ]
目标数组: [ 1, 3, 4, 2, 7, 5 ]

预处理结束:i = 1, oldEnd = 4, newEnd = 4
相同的前置节点个数1,后置节点的个数1

继续处理
索引表计算)元素[6]: 删除
索引表: [ 2, 3, 1, -1 ]
索引表的最长递增子序列为:2,3 ,对应索引是0,1
递增子序列处理)元素[7]: 新增在元素[5]前
递增子序列处理)元素[2]: 移动到元素[7]前
fastDiff: 5.084ms

附录

  • 获取数组中最长子序列对应的索引
function getSequence(arr) {
    const p = arr.slice();
    const result = [0];

    let i, j, u, v, c;
    const len = arr.length;
    for(i = 0; i < len; i++) {
        const arrI = arr[i];
        if(arrI !== 0) {
            j = result[result.length - 1];
            if(arr[j] < arrI) {
                p[i] = j;
                result.push(i);
                continue;
            }
            u = 0;
            v = result.length - 1;
            while(u < v) {
                c = ((u + v) / 2) | 0;
                if(arr[result[c]] < arrI) {
                    u = c + 1;
                }else {
                    v = c;
                }
            }
            if(arrI < arr[result[u]]) {
                if(u > 0) {
                    p[i] = result[u-1];
                }
                result[u] = i;
            }
        }
    }
    u = result.length;
    v = result[u-1];
    while(u-- > 0) {
        result[u] = v;
        v = p[v];
    }
    return result;
}