双向绑定的前世今生

在 Vue 中,双向绑定主要是指响应式数据改变后对应的 DOM 发生变化,用 <input v-model> 这种 DOM 改变、影响响应式数据的方式也属于双向绑定,其本质都是响应式数据改变所发生的一系列变化,其中包括响应式方法触发、新的 VNode 生成、新旧 VNode 的 diff 过程,对应需要改变 DOM 节点的生成和渲染。整体流程如图11-6所示。

image 2024 02 26 13 32 58 167
Figure 1. 图11-6 双向绑定流程图

我们修改一下上一节的 demo 代码,让其触发一次响应式数据变化,代码如下:

<div id="app">
    <div>
        {{name}}
    </div>
    <p>123</p>
</div>
const app = Vue.createApp({
    data(){
        return {
            attr : 'attr',
            name : 'abc'
        }
    },
    mounted(){
        setTimeout(()=>{
            // 改变响应式数据
            this.name = 'efg'
        },1000*5)
    }
}).mount("#app")

当修改 this.name 时,页面上对应的 name 值会对应地发生变化,整个过程到最后的 DOM 变化在源码层面的执行过程如图11-7所示(顺序从下往上)。

image 2024 02 26 13 34 19 088
Figure 2. 图11-7 双向绑定源码执行过程

上述流程包括响应式方法触发、新的 VNode 生成、新旧 VNode 的对比 diff 过程,对应需要改变 DOM 节点的生成和渲染。当执行最终的 setElementText 方法时,页面的 DOM 就被修改了,代码如下:

...
setElementText: (el, text) => {
    el.textContent = text// 修改为efg
}
...

可以看到,这一系列复杂的过程最终都会落到最简单的修改 DOM 上。接下来对这些流程进行一一讲解。

响应式触发

在之前的响应式原理中,在创建响应式数据时,会对监听进行收集,在源码 reactivity/src/effect.ts 的 track 方法中,其核心代码如下:

export function track(target: object, type: TrackOpTypes, key: unknown) {
    ...
    // 获取当前target对象对应的depsMap
    let depsMap = targetMap.get(target)
    if (!depsMap) {
        targetMap.set(target, (depsMap = new Map()))
    }
    // 获取当前key对应的dep依赖
    let dep = depsMap.get(key)
    if (!dep) {
        depsMap.set(key, (dep = new Set()))
    }
    if (!dep.has(activeEffect)) {
        // 收集当前的effect作为依赖
        dep.add(activeEffect)
        // 当前的effect收集dep集合作为依赖
        activeEffect.deps.push(dep)
    }
}

收集完监听后,会得到 targetMap,在触发监听 trigger 时,从 targetMap 拿到当前的 target。

name 是一个响应式数据,所以在触发 name 值修改时,会进入对应的 Proxy 对象中 handler 的 set 方法,在源码 reactivity/src/baseHandlers.ts 中,其核心代码如下:

function createSetter() {
    ...
    // 触发监听
    trigger(target, TriggerOpTypes.SET, key//name, value//efg, oldValue//abc)
    ...
}

从而进入 trigger 方法触发监听,在源码 reactivity/src/effect.ts 的 trigger 方法中,其核心代码如下:

export function trigger(
    target: object,
    type: TriggerOpTypes,
    key?: unknown,
    newValue?: unknown,
    oldValue?: unknown,
    oldTarget?: Map<unknown, unknown> | Set<unknown>
) {
    ...
    // 获取当前target的依赖映射表
    const depsMap = targetMap.get(target)
    if (!depsMap) {
        // never been tracked
        return
    }
    // 声明一个集合和方法,用于添加当前key对应的依赖集合
    const effects = new Set<ReactiveEffect>()
    const add = (effectsToAdd: Set<ReactiveEffect> | undefined) => {
        if (effectsToAdd) {
            effectsToAdd.forEach(effect => effects.add(effect))
        }
    }
    // 声明一个调度方法
    const run = (effect: ReactiveEffect) => {
        if (effect.options.scheduler) {
            effect.options.scheduler(effect)
        } else {
            effect()
        }
    }
    // 根据不同的类型选择使用不同的方式将当前key的依赖添加到effects
    ...
    // 循环遍历,按照一定的调度方式运行对应的依赖
    effects.forEach(run)
}

trigger 方法总结下来,做了如下事情:

  • 首先获取当前 target 对应的依赖映射表,如果没有,则说明这个 target 没有依赖,直接返回,否则进行下一步。

  • 然后声明一个 ReactiveEffect 集合和一个向集合中添加元素的方法。

  • 根据不同的类型选择使用不同的方式向 ReactiveEffect 中添加当前 key 对应的依赖。

  • 声明一个调度方式,根据我们传入 ReactiveEffect 函数中不同的参数选择使用不同的调度 run 方法,并循环遍历执行。

上面的步骤会比较绕,只需要记住 trigger 方法最终的目的是调度方法的调用,即运行 ReactiveEffect 对象中绑定的 run 方法。那么 ReactiveEffect 是什么,如何绑定对应的 run 方法?我们来看一下 ReactiveEffect 的实现,在 源码 reactivity/src/effect.ts 中,其核心代码如下:

export class ReactiveEffect<T = any> {
    ...
    constructor(
        public fn: () => T, // 传入回调方法
        public scheduler: EffectScheduler | null = null,// 调度函数
        scope?: EffectScope | null
    ) {
        recordEffectScope(this, scope)
    }
    run() {
        if (!this.active) {
            return this.fn()
        }
        if (!effectStack.includes(this)) {
            try {
                ...
                // 执行绑定的方法
                return this.fn()
            } finally {
                ...
            }
        }
    }
}

上面的代码中,在其构造函数中,将创建时传入的回调函数进行了 run 绑定,同时在 Vue 的组件挂载时会创建一个 ReactiveEffect 对象,在源码 runtime-core/src/renderer.ts 中,其核心代码如下:

// setupRenderEffect()方法
...
const effect = new ReactiveEffect(
componentUpdateFn,// run方法绑定,该方法包括VNode生成逻辑
() => queueJob(instance.update),
instance.scope // track it in component's effect scope
)

通过 ReactiveEffect 就将响应式和 VNode 逻辑进行了链接,其本身就是一个基于发布/订阅模式的事件对象,track 负责订阅(即收集监听),trigger 负责发布(即触发监听),effect 是桥梁,用于存储事件数据。

同时,ReactiveEffect 也向外暴露了 Composition API 的 effect 方法,可以自定义地添加监听收集,在源码 reactivity/src/effect.ts 中,其核心代码如下:

export function effect<T = any>(
    fn: () => T,
    options: ReactiveEffectOptions = EMPTY_OBJ
): ReactiveEffect<T> {
    if (isEffect(fn)) {
        fn = fn.raw
    }
    // 创建ReactiveEffect对象
    const effect = createReactiveEffect(fn, options)
    if (!options.lazy) {
        effect()
    }
    return effect
}

在使用 effect 方法时,代码如下:

// this.name改变时会触发这里
Vue.effect(()=> {
    console.log(this.name)
})

结合 11.2 节的讲解,我们将这个响应式触发的过程总结成流程图,以便于读者理解,如图 11-8 所示。

image 2024 02 26 14 07 16 572
Figure 3. 图11-8 响应式触发过程

当响应式触发完成以后,就会进入 VNode 生成环节。

生成新的VNode

在响应式逻辑中,创建 ReactiveEffect 时传入了 componentUpdateFn,当响应式触发时,便会进入这个方法,在源码 runtime-core/src/renderer.ts 中,其核心代码如下:

const componentUpdateFn = () => {
    // 首次渲染,直接找到对应DOM挂载即可,无须对比新旧VNode
    if (!instance.isMounted) {
        ...
        instance.isMounted = true
        ...
    } else {
        let { next, bu, u, parent, vnode } = instance
        let originNext = next
        let vnodeHook: VNodeHook | null | undefined
        // 判断是否是父组件带来的更新
        if (next) {
            next.el = vnode.el
            // 子组件更新
            updateComponentPreRender(instance, next, optimized)
        } else {
            next = vnode
        }
        ...
        // 获取新的VNode(根据新的响应式数据,执行render方法得到VNode)
        const nextTree = renderComponentRoot(instance)
        // 从subTree字段获取旧的VNode
        const prevTree = instance.subTree
        // 将新值赋值给subTree字段
        instance.subTree = nextTree
        // 进行新旧VNode对比
        patch(
            prevTree,
            nextTree,
            // teleport判断
            hostParentNode(prevTree.el!)!,
            // fragment判断
            getNextHostNode(prevTree),
            instance,
            parentSuspense,
            isSVG
        )
    }
}

其中,对于新 VNode 的生成,主要是靠 renderComponentRoot 方法,这在之前的 11.3 节也用到过,其内部会执行组件的 render 方法,通过 render 方法就可以获取到新的 VNode,同时将新的 VNode 赋值给 subTree 字段,以便下次对比使用。

之后会进入 patch 方法,进行虚拟 DOM 的对比 diff。

虚拟DOM的diff过程

虚拟 DOM 的 diff 过程的核心是 patch 方法,它主要是利用 compile 阶段的 patchFlag(或者 type)来处理不同情况下的更新,这也可以理解为一种分而治之的策略。在该方法内部,并不是直接通过当前的 VNode 节点去暴力地更新 DOM 节点,而是对新旧两个 VNode 节点的 patchFlag 来分情况进行比较,然后通过对比结果找出差异的属性或节点按需进行更新,从而减少不必要的开销,提升性能。

patch 的过程中主要完成以下几件事情:

  • 创建需要新增的节点。

  • 移除已经废弃的节点。

  • 移动或修改需要更新的节点。

在整个过程中都会用到 patchFlag 进行判断,在 AST 到 render 再到 VNode 生成的过程中,会根据节点的类型打上对应的 patchFlag,只有 patchFlag 还不够,还要依赖于 shapeFlag 的设置,在源码中对应的 createVNode 方法代码如下:

function _createVNode(
    type: VNodeTypes | ClassComponent | typeof NULL_DYNAMIC_COMPONENT,
    props: (Data & VNodeProps) | null = null,
    children: unknown = null,
    patchFlag: number = 0,
    dynamicProps: string[] | null = null,
    isBlockNode = false
): VNode {
    const shapeFlag = isString(type)
    ...
    const vnode = {
        __v_isVNode: true,
        ["__v_skip" /* SKIP */]: true,
        type,
        props,
        key: props && normalizeKey(props),
        ref: props && normalizeRef(props),
        scopeId: currentScopeId,
        children: null,
        component: null,
        suspense: null,
        ssContent: null,
        ssFallback: null,
        dirs: null,
        transition: null,
        el: null,
        anchor: null,
        target: null,
        targetAnchor: null,
        staticCount: 0,
        shapeFlag,
        patchFlag,
        dynamicProps,
        dynamicChildren: null,
        appContext: null
    };
    ...
    return vnode
}

_createVNode 方法主要用来标准化 VNode,同时添加上对应的 shapeFlag 和 patchFlag。其中,shapeFlag 的值是一个数字,每种不同的 shapeFlag 代表不同的 VNode 类型,而 shapeFlag 又是依据之前在生成 AST 时的 NodeType 而定的,所以 shapeFlag 的值和 NodeType 很像,代码如下:

export const enum ShapeFlags {
    ELEMENT = 1, // 元素 string
    FUNCTIONAL_COMPONENT = 1 << 1, // 2 function
    STATEFUL_COMPONENT = 1 << 2, // 4 object
    TEXT_CHILDREN = 1 << 3, // 8 文本
    ARRAY_CHILDREN = 1 << 4, // 16 数组
    SLOTS_CHILDREN = 1 << 5, // 32 插槽
    TELEPORT = 1 << 6, // 64 teleport
    SUSPENSE = 1 << 7, // 128 suspense
    COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8,// 256 keep alive 组件
    COMPONENT_KEPT_ALIVE = 1 << 9, // 512 keep alive 组件
    COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT
    // 组件
}

而 patchFlag 代表在更新时采用不同的策略,其具体每种含义如下:

export const enum PatchFlags {
    // 动态文字内容
    TEXT = 1,
    // 动态 class
    CLASS = 1 << 1,
    // 动态样式
    STYLE = 1 << 2,
    // 动态 props
    PROPS = 1 << 3,
    // 有动态的key,也就是说props对象的key是不确定的
    FULL_PROPS = 1 << 4,
    // 合并事件
    HYDRATE_EVENTS = 1 << 5,
    // children 顺序确定的 fragment
    STABLE_FRAGMENT = 1 << 6,
    // children中带有key的节点的fragment
    KEYED_FRAGMENT = 1 << 7,
    // 没有key的children的fragment
    UNKEYED_FRAGMENT = 1 << 8,
    // 只有非props需要patch,比如`ref`
    NEED_PATCH = 1 << 9,
    // 动态的插槽
    DYNAMIC_SLOTS = 1 << 10,
    ...
    // 特殊的flag,不会在优化中被用到,是内置的特殊flag
    ...SPECIAL FLAGS
    // 表示它是静态节点,它的内容永远不会改变,在hydrate的过程中,不需要再对其子节点进行
    diff
    HOISTED = -1,
    // 用来表示一个节点的diff应该结束
    BAIL = -2,
}

包括 shapeFlag 和 patchFlag,和其名字的含义一致,其实就是用一系列的标志来标识一个节点该如何进行更新,其中 CLASS = 1 << 1 这种方式表示位运算,就是利用每个 patchFlag 取二进制中的某一位数来表示,这样更加方便扩展,例如 TEXT|CLASS 可以得到 0000000011,这个值表示其既有 TEXT 的特性,也有 CLASS 的特性,如果需要新加一个 flag,则直接用新数 num 左移 1 位即可,即 1 <<num。

shapeFlag 可以理解成 VNode 的类型,而 patchFlag 则更像 VNode 变化的类型。

例如在 demo 代码中,我们给 props 绑定响应式变量 attr,代码如下:

...
<div :data-a="attr"></div>
...

得到的 patchFlag 就是 8(1<<3)。在源码 compiler-core/src/transforms/transformElement.ts 中可以看到对应的设置逻辑,核心代码如下:

...
// 每次都按位与,可以对多个数值进行设置
if (hasDynamicKeys) {
    patchFlag |= PatchFlags.FULL_PROPS
} else {
    if (hasClassBinding && !isComponent) {
        patchFlag |= PatchFlags.CLASS
    }
    if (hasStyleBinding && !isComponent) {
        patchFlag |= PatchFlags.STYLE
    }
    if (dynamicPropNames.length) {
        patchFlag |= PatchFlags.PROPS
    }
    if (hasHydrationEventBinding) {
        patchFlag |= PatchFlags.HYDRATE_EVENTS
    }
}

一切准备就绪,下面进入 patch 方法,在源码 runtime-core/src/renderer.ts 中,其核心代码如下:

const patch: PatchFn = (
    n1,
    n2,
    container,
    anchor = null,
    parentComponent = null,
    parentSuspense = null,
    isSVG = false,
    slotScopeIds = null,
    optimized = false
) => {
    // 新旧VNode是同一个对象,就不再对比
    if (n1 === n2) {
        return
    }
    // patching & 不是相同类型的 VNode,因此从节点树中卸载
    if (n1 && !isSameVNodeType(n1,n2)) {
        anchor = getNextHostNode(n1)
        unmount(n1, parentComponent, parentSuspense, true)
        n1 = null
    }
    // PatchFlag 是 BAIL 类型的,因此跳出优化模式
    if (n2.patchFlag === PatchFlags.BAIL) {
        optimized = false
        n2.dynamicChildren = null
    }

    const { type, ref, shapeFlag } = n2
    switch (type) { // 根据 VNode 类型判断
        case Text: // 文本类型
            processText(n1, n2, container, anchor)
            break
        case Comment: // 注释类型
            processCommentNode(n1, n2, container, anchor)
            break
        case Static: // 静态节点类型
            if (n1 === null) {
                mountStaticNode(n2, container, anchor, isSVG)
            }
            break
        case Fragment: // Fragment 类型
            processFragment(/* 忽略参数 */)
            break
        default:
            if (shapeFlag & ShapeFlags.ELEMENT) { // 元素类型
                processElement(
                    n1,
                    n2,
                    container,
                    anchor,
                    parentComponent,
                    parentSuspense,
                    isSVG,
                    slotScopeIds,
                    optimized
                )
            } else if (shapeFlag & ShapeFlags.COMPONENT) { //组件类型
                ...
            } else if (shapeFlag & ShapeFlags.TELEPORT) { // TELEPORT 类型
                ...
            } else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) { // SUSPENSE 类型
                ...
            }

    }
}

其中,n1 为旧 VNode,n2 为新 VNode,如果新旧 VNode 是同一个对象,就不再对比,如果旧节点存在,并且新旧节点不是同一类型,则将旧节点从节点树中卸载,这时还没有用到 patchFlag。再往下看,通过 switch case 来判断节点类型,并分别对不同的节点类型执行不同的操作,这里用到了 ShapeFlag,对于常用的 HTML 元素类型,则会进入 default 分支,我们以 ELEMENT 为例,进入 processElement 方法,在源码 runtime-core/src/renderer.ts 中,其核心代码如下:

const processElement = (
    n1: VNode | null,
    n2: VNode,
    container: RendererElement,
    anchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,
    slotScopeIds: string[] | null,
    optimized: boolean
) => {
// 如果旧节点不存在,则直接渲染
    if (n1 == null) {
        mountElement(
            n2,
            container,
            anchor
    ...
    )
    } else {
        patchElement(
            n1,
            n2,
            parentComponent,
            parentSuspense,
            isSVG,
            slotScopeIds,
            optimized
        )
    }
}

processElement 方法的逻辑相对简单,只是多加了一层判断,当没有旧节点时,直接进行渲染流程,这也是调用根实例初始化 createApp 时会用到的逻辑。真正进行对比,会进入 patchElement 方法,在源码 runtime-core/src/renderer.ts 中,其核心代码如下:

const patchElement = () => {

}

在 processElement 方法的开头会执行一些钩子函数,然后判断新节点是否有已经标识的动态节点(就是在静态提升那一部分的优化,将动态节点和静态节点进行分离),如果有就会优先进行更新(无须对比,这样更快)。接下来通过 patchProps 方法更新当前节点的 props、style、class 等,主要逻辑如下:

  • 当 patchFlag 为 FULL_PROPS 时,说明此时的元素中可能包含动态的 key,需要进行全量的 props diff。

  • 当 patchFlag 为 CLASS 时,如果新旧节点的 class 不一致,则会对 class 进行 atch;如果新旧节点的 class 属性完全一致,则不需要进行任何操作。这个 Flag 标记会在元素有动态的 class 绑定时加入。

  • 当 patchFlag 为 STYLE 时,会对 style 进行更新,这是每次 patch 都会进行的,这个 Flag 会在有动态 style 绑定时被加入。

  • 当 patchFlag 为 PROPS 时,需要注意这个 Flag 会在元素拥有动态的属性或者 attrs 绑定时添加,不同于 class 和 style,这些动态的 prop 或 attrs 的 key 会被保存下来以便于更快速地迭代。

  • 当 patchFlag 为 TEXT 时,如果新旧节点中的子节点是文本发生变化,则调用 hostSetElementText 进行更新。这个 Flag 会在元素的子节点只包含动态文本时被添加。

每种 patchFlag 对应的方法中,最终都会进入 DOM 操作的逻辑,例如对于 STYLE 更新,会进入 setStyle 方法,在源码 runtime-dom/src/modules/style.ts 中,其核心代码如下:

function setStyle(

) {

}

对于一个 VNode 节点来说,除了属性(如 props、class、style 等)外,其他的都叫作子节点内容,<div>hi</div> 中的文本 hi 也属于子节点。对于子节点,会进入 patchChildren 方法,在源码 runtime-core/src/renderer.ts 中,其核心代码如下:

const patchChildren: PatchChildrenFn = () => {

}

上面的代码中,首先根据 patchFlag 进行判断:

  • 若 patchFlag 是存在 key 值的 Fragment: KEYED_FRAGMENT,则调用 patchKeyedChildren 来继续处理子节点。

  • 若 patchFlag 是没有设置 key 值的 Fragment: UNKEYED_FRAGMENT,则调用 patchUnkeyed Children 处理没有 key 值的子节点。

  • 然后根据 shapeFlag 进行判断:

    • 如果新子节点是文本类型,而旧子节点是数组类型(含有多个子节点),则直接卸载旧节点的子节点,然后用新节点替换。

    • 如果旧子节点类型是数组类型,当新子节点也是数组类型时,则调用 patchKeyedChildren 进行全量的 diff,当新子节点不是数组类型时,则说明不存在新子节点,直接从树中卸载旧节点即可。

    • 如果旧子节点是文本类型,由于已经在一开始就判断过新子节点是否为文本类型,因此此时可以肯定新子节点不是文本类型,可以直接将元素的文本置为空字符串。

    • 如果新子节点是数组类型,而旧子节点不为数组,则说明此时需要在树中挂载新子节点,进行 mount 操作即可。

无论多么复杂的节点数组嵌套,其实最后都会落到基本的 DOM 操作,包括创建节点、删除节点、修改节点属性等,但核心是针对新旧两个树找到它们之间需要改变的节点,这就是 diff 的核心,真正的 diff 需要进入 patchUnkeyedChildren 和 patchKeyedChildren 来一探究竟。首先看一下 patchUnkeyedChildren 方法,在源码 runtime-core/src/renderer.ts 中,其核心代码如下:

const patchUnkeyedChildren = () => {

}

主要逻辑是首先拿到新旧节点的最短公共长度,然后遍历公共部分,对公共部分再次递归执行 patch 方法,如果旧节点的数量大于新节点的数量,则直接卸载多余的节点,否则新建节点。

可以看到对于没有 key 的情况,diff 比较简单,但是性能也相对较低,很少实现 DOM 的复用,更多的是创建和删除节点,这也是 Vue 推荐对数组节点添加唯一 key 值的原因。

下面是 patchKeyedChildren 方法,在源码 runtime-core/src/renderer.ts 中,其核心代码如下:

const patchKeyedChildren = () => {
    let i = 0
    const l2 = c2.length
    let e1 = c1.length - 1 // prev ending index
    let e2 = l2 - 1 // next ending index
    // 1.进行头部遍历,遇到相同的节点则继续,遇到不同的节点则跳出循环
    while (i <= e1 && i <= e2) {...}
    // 2.进行尾部遍历,遇到相同的节点则继续,遇到不同的节点则跳出循环
    while (i <= e1 && i <= e2) {...}
    // 3.如果旧节点已遍历完毕,并且新节点还有剩余,则遍历剩下的节点
    if (i > e1) {
        if (i <= e2) {...}
    }
    // 4.如果新节点已遍历完毕,并且旧节点还有剩余,则直接卸载
    else if (i > e2) {
        while (i <= e1) {...}
    }
    // 5.新旧节点都存在未遍历完的情况
    else {
        // 5.1创建一个map,为剩余的新节点存储键值对,映射关系:key => index
        // 5.2遍历剩下的旧节点,对比新旧数据,移除不使用的旧节点
        // 5.3拿到最长递增子序列进行移动或者新增挂载
    }
}

patchKeyedChildren 方法是整个 diff 的核心,其内部包括具体算法和逻辑,用代码讲解起来比较复杂,这里用一个简单的例子来说明该方法到底做了些什么,有两个数组,如下所示:

// 旧数组
["a", "b", "c", "d", "e", "f", "g", "h"]
// 新数组
["a", "b", "d", "f", "c", "e", "x", "y", "g", "h"]

上面的数组中,每个元素代表 key,执行步骤如下:

  1. 从头到尾开始比较,[a,b] 是 sameVnode,进入 patch,到 [c] 停止。

  2. 从尾到头开始比较,[h,g] 是 sameVnode,进入 patch,到 [f] 停止。

  3. 判断旧数据是否已经比较完毕,多余的说明是新增的,需要 mount,例子中没有。

  4. 判断新数据是否已经比较完毕,多余的说明是删除的,需要 unmount,例子中没有。

  5. 到这里,说明顺序被打乱,进入 5:

    • 5.1 创建一个还未比较的新数据 index 的 Map:[{d:2},{f:3},{c:4},{e:5},{x:6},{y:7}]。

    • 5.2 根据未比较完的数据长度,建一个填充 0 的数组 [0,0,0,0,0],然后循环一遍旧剩余数据,找到未比较的数据的索引 arr:[4(d),6(f),3(c),5(e),0,0],如果没有在新剩余数据中找到,则说明是删除就 unmount 掉,找到了就和之前的 patch 一下。

    • 5.3 从尾到头循环之前的索引 arr,如果是 0,则说明是新增的数据,就 mount 进去,如果不是 0,则说明在旧数据中,我们只要把它们移动到对应 index 的前面就行了,如下:

      • 把 f 移动到 c 之前。

      • 把 d 移动到 f 之前。

    • 移动之后,c 自然会到 e 前面,这可以由之前的 arr 索引按最长递增子序列来找到 [3,5],这样 [3,5] 对应的 c 和 e 就无须移动了。

这就是整个 patchKeyedChildren 方法中 diff 的核心内容和原理,当然还有很多代码细节,感兴趣的读者可以阅读 patchKeyedChildren 完整源码。

完成真是DOM的修改

无论多么复杂的节点数组嵌套,其实最后都会落到基本的 DOM 操作,包括创建节点、删除节点、修改节点属性等,当拿到 diff 后的结果时,会调用对应的 DOM 操作方法,这部分逻辑在源码 runtime-dom\src\nodeOps.ts 中,存放的都是一些工具方法,其核心代码如下:

export const nodeOps: Omit<RendererOptions<Node, Element>, 'patchProp'> = {
    // 插入元素
    insert: (child, parent, anchor) => {
        parent.insertBefore(child, anchor || null)
    },
    // 删除元素
    remove: child => {
        const parent = child.parentNode
        if (parent) {
            parent.removeChild(child)
        }
    },
    // 创建元素
    createElement: (tag, isSVG, is, props): Element => {
        ...
    },
    // 创建文本
    createText: text => doc.createTextNode(text),
    // 创建注释
    createComment: text => doc.createComment(text),
    // 设置文本
    setText: (node, text) => {
        node.nodeValue = text
    },
    // 设置文本
    setElementText: (el, text) => {
        el.textContent = text
    },
    parentNode: node => node.parentNode as Element | null,
    nextSibling: node => node.nextSibling,
    querySelector: selector => doc.querySelector(selector),
    // 设置元素属性
    setScopeId(el, id) {
        el.setAttribute(id, '')
    },
    // 克隆DOM
    cloneNode(el) {
        ...
    },
    // 插入静态内容,包括处理SVG元素
    insertStaticContent(content, parent, anchor, isSVG) {
        ...
    }
}

这部分逻辑都是常规的 DOM 操作,比较简单,读者直接阅读源码即可。