简单了解vue3双端diff算法,以及为什么使用v-for时必须要给每个元素添加key

377 阅读8分钟

前言

众所周知,vue内部使用的是虚拟节点。当组件状态更改时,vue内部会对比老节点和新节点的差异(对比的是虚拟节点),最后根据差异再来操作真实dom节点。这样能尽量减少对真实dom节点的操作,提高性能。下面就来详细分析分析vue是如何对比新节点和老节点的差异的

vue双端对比算法

首先我们需要清楚vue虚拟节点的结构。vue虚拟节点的结构大致如下

{
    type: any,
    props?: any,
    children?: any
    shapeFlag?: any
    el?: any
    key?: any
}

type为虚拟节点类型。若为字符串,则代表真实dom元素的类型。若虚拟节点类型为对象,则type为组件对象,例

type="p"type="div"

type = {
    name: "App",
    setup() { },
    render() {
        return h("div", { tId: 1 }, [
            h("p", {}, "主页"),
        ])
    }
}

props为节点属性。例

props = {
    id=1
    class="root"
}

children为节点子节点,可以为节点数组,也可以为文本字符串。例:

children = [
    {
        "type": "p",
        "props": {},
        "children": "主页",
        "shapeFlag": 5,
        "el": null
    },
    {
        "type": {
            "name": "ArrayToArray"
        },
        "shapeFlag": 2,
        "el": null
    }
]

children = "hello,你好"

shapeFlag用来标识节点类型

export const enum ShapeFlags {
    //节点类型为真实dom元素类型,例:div,p
    ELEMENT = 1,  //0001
    //节点类型为组件
    STATEFUL_COMPONENT = 1 << 1,  //0010
    //孩子为文本字符串
    TEXT_CHILDREN = 1 << 2,  //0100
    //孩子为节点数组
    ARRAY_CHILDREN = 1 << 3,  //1000
    //孩子为插槽数组
    SLOT_CHILDREN = 1 << 4, // 10000
}

el为元素真实挂载的dom节点,通过el可以操作该虚拟节点对应的真实dom节点

双端对比算法主要就是根据虚拟节点的children属性进行操作

左侧遍历时,从左往右看新节点与老节点是否相同,若相同则继续比较下一个,直到遇到不相同的节点停止

右侧遍历时,从右往左看新节点与老节点是否相同,若相同则继续比较下一个,直到遇到不相同的节点停止

双端对比算法的本质是对children数组分别通过从左往右遍历和从右往左遍历,最终锁定中间需要变更的部分来进行更改。

如图所示:

image.png

这里先给出遍历代码

    /**
     * @param c1 老节点数组
     * @param c2 新节点数组
     */
    function patchKeyedChildren(c1: any[], c2: any[], container: any, parentComponent: any) {
        /**左侧指针 */
        let i = 0
        /**老节点数组右侧指针 */
        let e1 = c1.length - 1
        /**新节点数组右侧指针 */
        let e2 = c2.length - 1
        // 左侧遍历
        while (i <= e1 && i <= e2) {
            /**n1为旧节点数组第i个节点 */
            const n1 = c1[i]
            /**n2为新节点数组第i个节点 */
            const n2 = c2[i]
            if (isSomeVNodeType(n1, n2)) {
                /**n1,n2相同则继续递归遍历n1,n2子节点数组差异 */
                patch(n1, n2, container, parentComponent)
            } else {
                /**n1,n2不相同结束遍历 */
                break;
            }
            i++
        }

        //右侧遍历
        while (i <= e1 && i <= e2) {
            /**n1为旧节点数组第e1个节点 */
            const n1 = c1[e1]
            /**n2为新节点数组第e2个节点 */
            const n2 = c2[e2]

            if (isSomeVNodeType(n1, n2)) {
                /**n1,n2相同则继续递归遍历n1,n2子节点数组差异 */
                patch(n1, n2, container, parentComponent)
            } else {
                /**n1,n2不相同结束遍历 */
                break;
            }
            e1--
            e2--
        }
}

了解完遍历后,接下来就是如何处理中间乱序的部分

中间乱序的部分会有以下几种情况:

  1. 老节点序列中有,但是新节点序列中没有,则需要删除该节点。例如上面图片中老节点序列中有 C ,但是新节点序列中没有
  2. 老节点序列中没有,但是新节点序列中有,则需要创建新节点。例如上面图片中老节点序列中没有 K ,但是新节点序列中有
  3. 老节点序列中有,新节点序列中也有,但是节点所处位置不同,则需要移动节点。例如上面图片中老节点序列中的 H 和新节点序列中的 H 的位置不同

那么vue是通过什么来判断新老节点是否相同的呢就是通过key值。这就是为什么我们在使用v-for时必须给每个元素一个key值。

并且vue会给新节点和老节点建立一个映射关系,如下图:

image.png

建立完映射后,vue会先遍历旧节点乱序部分,把没有映射到新节点序列的节点删除(图中的C节点);

然后再遍历新节点乱序部分,把新节点序列中没有映射到老节点序列的节点进行创建(图中的K节点);

至于移动节点就比较麻烦,需要用到最大递增子序列这个概念,这么说可能听不懂,我们看着图里的例子来理解。我们可以发现,D,E节点都是“相对安稳”的,无论其它节点怎么移动,插入,都不会影响D,E节点的相对顺序,那么我们就可以把D,E看成一个“整体”,不需要动,然后移动其它节点即可。例如图中,我们只需要把H插入到D,E的后面即可。而D,E其实就是一个最大递增子序列。

当然,这里说起来轻松,其实代码实现起来很复杂。不过我们了解大概思路就行,至于代码实现我放在文末大家可以自行理解

另外还有一些特殊情况

1. 新节点比老节点多,需要创建

image.png

image.png

如图所示,都是需要创建节点 C

2. 新节点比老节点少,需要删除

image.png

image.png

如图所示,都需要删除节点 C

完整代码:

/**
     * @param c1 老节点数组
     * @param c2 新节点数组
     */
    function patchKeyedChildren(c1: any[], c2: any[], container: any, parentComponent: any) {
        /**左侧指针 */
        let i = 0
        /**老节点数组右侧指针 */
        let e1 = c1.length - 1
        /**新节点数组右侧指针 */
        let e2 = c2.length - 1
        // 左侧遍历
        while (i <= e1 && i <= e2) {
            /**n1为旧节点数组第i个节点 */
            const n1 = c1[i]
            /**n2为新节点数组第i个节点 */
            const n2 = c2[i]
            if (isSomeVNodeType(n1, n2)) {
                /**n1,n2相同则继续递归遍历n1,n2子节点数组差异 */
                patch(n1, n2, container, parentComponent)
            } else {
                /**n1,n2不相同结束遍历 */
                break;
            }
            i++
        }

        //右侧遍历
        while (i <= e1 && i <= e2) {
            /**n1为旧节点数组第e1个节点 */
            const n1 = c1[e1]
            /**n2为新节点数组第e2个节点 */
            const n2 = c2[e2]

            if (isSomeVNodeType(n1, n2)) {
                /**n1,n2相同则继续递归遍历n1,n2子节点数组差异 */
                patch(n1, n2, container, parentComponent)
            } else {
                /**n1,n2不相同结束遍历 */
                break;
            }
            e1--
            e2--
        }

        //新的比老的多需要创建
        if (i > e1 && i <= e2) {
            /**锚点,即新节点该在哪个节点前面插入,若为null则在尾部插入 */
            const anchor = e2 + 1 > c2.length - 1 ? null : c2[e2 + 1].el
            /**遍历创建所有新节点 */
            while (i <= e2) {
                /**新增并挂载新节点 */
                patch(null, c2[i], container, parentComponent, anchor)
                i++
            }
        } else if (i > e2) {
            //老的比新的多需要删除
            while (i <= e1) {
                // 删除节点
                hostRemove(c1[i].el)
                i++
            }
        } else {
            //中间对比

            /**经过左右遍历后,旧节点序列中间差异部分的第一个节点 */
            let s1 = i
            /**经过左右遍历后,新节点序列中间差异部分的第一个节点 */
            let s2 = i
            /**新节点序列中间差异部分节点数量 */
            const toBePatched = e2 - s2 + 1
            /**记录新节点序列中间差异部分已处理数量 */
            let patched = 0
            /**记录新节点序列中间差异部分的节点的key与位置(index)的映射*/
            const keyToNewIndexMap = new Map()
            /**新老节点映射 */
            const newIndexToOldIndexArr = new Array(toBePatched)
            //判断是否需要移动节点
            let moved = false
            //最后一个映射节点的位置
            let maxNewIndexSoFar = 0
            for (let index = 0; index < toBePatched; index++) {
                newIndexToOldIndexArr[index] = 0
            }
            //记录新节点序列中间差异部分的节点的key与位置(index)的映射
            for (let k = s2; k <= e2; k++) {
                /**新节点 */
                const nextChild = c2[k]
                keyToNewIndexMap.set(nextChild.key, k)
            }

            //遍历老节点序列乱序部分
            for (let j = s1; j <= e1; j++) {
                /**老节点 */
                const prevChild = c1[j]
                /**如果新节点差异部分已处理数量已大于等于新节点差异部分总节点数量,说明老节点比新节点多,直接删除老节点就好了 */
                if (patched >= toBePatched) {
                    //删除老节点
                    hostRemove(prevChild.el)
                    continue
                }
                //情况一:老节点序列有的,新节点序列没有的,需要删除节点
                let newIndex: null | number = null
                if (prevChild.key) {
                    newIndex = keyToNewIndexMap.get(prevChild.key) || null
                } else {
                    for (let h = s2; h <= e2; h++) {
                        if (isSomeVNodeType(prevChild, c2[h])) {
                            newIndex = h
                            break;
                        }
                    }
                }
                if (!newIndex) {
                    // 删除节点
                    hostRemove(prevChild.el)
                } else {
                    //当前映射节点位置大于maxNewIndexSoFar,则更新maxNewIndexSoFar,小于则说明需要移动
                    if (newIndex >= maxNewIndexSoFar) {
                        maxNewIndexSoFar = newIndex
                    } else {
                        moved = true
                    }
                    newIndexToOldIndexArr[newIndex - s2] = j + 1
                    //把老节点和新节点相同,能建立映射关系,继续递归遍历子节点数组差异
                    patch(prevChild, c2[newIndex], container, parentComponent)
                    patched++
                }
            }

            //求最长递增子序列。如果前面已经得出无须移动(moved = false),则直接赋空数组
            const increasingNewIndexSequence = moved ? getSequence(newIndexToOldIndexArr) : []
            let j = increasingNewIndexSequence.length - 1

            //遍历新节点序列乱序部分
            for (let i = toBePatched - 1; i >= 0; i--) {
                const nextIndex = i + s2
                const nextChild = c2[nextIndex]
                const anchor = nextIndex + 1 > c2.length - 1 ? null : c2[nextIndex + 1].el
                if (newIndexToOldIndexArr[i] === 0) {
                    //没有映射的,创建新节点
                    patch(null, nextChild, container, parentComponent, anchor)
                }
                if (moved) {
                    if (j < 0 || i !== increasingNewIndexSequence[j]) {
                        console.log('移动位置');
                        insert(nextChild, container, anchor)
                    } else {
                        j--
                    }
                }


            }
        }
    }