<keep-alive>的魔法

<keep-alive> 是 Vue.js 的一个内置组件,可以使被包含的组件保留状态或避免重新渲染。下面来分析源码 runtime-core/src/components/KeepAlive.ts 的实现原理。

在 setup 方法中会创建一个缓存容器和缓存的 key 列表,其代码如下:

setup(){
    /* 缓存对象 */
    const cache: Cache = new Map()
    const keys: Keys = new Set()
    // keep-alive组件的上下文对象
    const instance = getCurrentInstance()!
    const sharedContext = instance.ctx as KeepAliveContext
    // 替换内容
    sharedContext.activate = (vnode, container, anchor, isSVG, optimized) => {
        const instance = vnode.component!move(vnode, container, anchor, MoveType.ENTER, parentSuspense)
        // 处理props改变
        patch(
            ...
        )
        ...
    }
    // 替换内容
    sharedContext.deactivate = (vnode: VNode) => {
        const instance = vnode.component!
            move(vnode, storageContainer, null, MoveType.LEAVE, parentSuspense)
        ...
    }
}

<keep-alive> 自己实现了 render 方法,并没有使用 Vue 内置的 render 方法(经过 <template> 内容提取、转换 AST、render 字符串等一系列过程),在执行 <keep-alive> 组件渲染时,就会执行这个 render 方法:

render () {
    // 得到插槽中的第一个组件
    const children = slots.default()
    const rawVNode = children[0]
    ...
    // 获取组件名称,优先获取组件的name字段
    const name = getComponentName(
        isAsyncWrapper(vnode)
            ? (vnode.type as ComponentOptions).__asyncResolved || {}
            : comp
    )
    // name不在include中或者exclude中,则直接返回vnode(没有存取缓存)
    const { include, exclude, max } = props
    if (
        (include && (!name || !matches(include, name))) ||
        (exclude && name && matches(exclude, name))
    ) {
        current = vnode
        return rawVNode
    }
    ...
    const key = vnode.key == null ? comp : vnode.key
    const cachedVNode = cache.get(key)
    // 如果已经缓存了,则直接从缓存中获取组件实例给vnode,若还未缓存,则先进行缓存
    if (cachedVNode) {
        // copy over mounted state
        vnode.el = cachedVNode.el
        vnode.component = cachedVNode.component
        if (vnode.transition) {
            // 执行transition
            setTransitionHooks(vnode, vnode.transition!)
        }
        //  设置shapeFlag标志位,为了避免执行组件mounted方法
        vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE
        // 重新设置一下key保证最新
        keys.delete(key)
        keys.add(key)
    } else {
        keys.add(key)
        // 当超出max值时,清除缓存
        if (max && keys.size > parseInt(max as string, 10)) {
            pruneCacheEntry(keys.values().next().value)
        }
    }
    return rawVNode
}

在上面的代码中,当缓存的个数超过 max(默认值为 10)的值时,就会清除旧的数据,这其中就包含 <keep-alive> 的缓存更新策略,其遵循了 LRU(Least Rencently Used) 算法。

LRU算法

LRU 算法根据数据的历史访问记录来淘汰数据,其核心思想是 “如果数据最近被访问过,那么将来被访问的概率也更高”。利用这个思路,我们可以对 <keep-alive> 中缓存的组件数据进行删除和更新,其算法的核心实现如下:

var LRUCache = function (max) {

}

上面的代码中,主要利用 map 来存储缓存数据,利用 map.keyIterator.next() 来找到最久没有使用的 key 对应的数据,从而对缓存进行删除和更新。这里举一个多 tab 切换的例子,如图11-9所示。

image 2024 02 26 16 48 11 097
Figure 1. 图11-9 6个tab切换页面

在上面 6 个 tab 组件中,都是用 <keep-alive> 包裹进行缓存,但是配置了 max 为 5,即最多缓存 5 个 tab 组件的内容,那么在它们直接互相切换时,必然会有一个组件被清除而无法缓存。例如切换的顺序依次是 [1,2,3,4,5,6],那么 1 组件是最久没有被使用到的,所以它将会被清除掉缓存,再如切换顺序是 [1,2,3,4,5,6,1],那么 2 组件就变成了最久没有被使用到的组件,它将会被清除掉缓存,这就是 LRU 算法的思路。

缓存VNode对象

在 render 方法中,<keep-alive> 并不是直接缓存的 DOM 节点,而是 Vue 中内置的 VNode 对象,VNode 经过 render 方法后,会被替换成真正的 DOM 内容。首先通过 slots.default().children[0] 获取第一个子组件,获取该组件的 name。接下来会将这个 name 通过 include 与 exclude 属性进行匹配,若匹配不成功(说明不需要进行缓存),则不进行任何操作直接返回 VNode。需要注意的是,<keep-alive> 只会处理它的第一个子组件,所以如果给 <keep-alive> 设置多个子组件,是无法生效的。

<keep-alive> 还有一个 watch 方法,用来监听 include 和 exclude 的改变,代码如下:

watch(
    () => [props.include, props.exclude],
    // 监听include和exclude,在被修改时对cache进行修正
    ([include, exclude]) => {
        include && pruneCache(name => matches(include, name))
        exclude && pruneCache(name => !matches(exclude, name))
    },
    // prune post-render after `current` has been updated
    { flush: 'post', deep: true }
)

这里的程序逻辑是动态监听 include 和 exclude 的改变,从而动态地维护之前创建的缓存对象 cache,其实就是对 cache 进行遍历,发现缓存的节点名称和新的规则没有匹配上时,就把这个缓存节点从缓存中摘除。下面来看 pruneCache 这个方法,代码如下:

function pruneCache(filter?: (name: string) => boolean) {
    cache.forEach((vnode, key) => {
        const name = getComponentName(vnode.type as ConcreteComponent)
        if (name && (!filter || !filter(name))) {
            pruneCacheEntry(key)
        }
    })
}

遍历 cache 中的所有项,如果不符合 filter 指定的规则,则会执行 pruneCacheEntry,代码如下:

function pruneCacheEntry(key: CacheKey) {
    const cached = cache.get(key) as VNode
    if (!current || cached.type !== current.type) {
        unmount(cached)
    } else if (current) {
        // current active instance should no longer be kept-alive.
        // we can't unmount it now but it might be later, so reset its flag now.
        resetShapeFlag(current)
    }
    // 销毁VNode对应的组件实例
    cache.delete(key)
    keys.delete(key)
}

上面的内容完成以后,当响应式触发时,<keep-alive> 中的内容会改变,会调用 <keep-alive> 的 render 方法得到 VNode,这里并没有用很深层次的 diff 去对比缓存前后的 VNode,而是直接将旧节点置为 null,用新节点进行替换,在 patch 方法中,直接命中这里的逻辑,代码如下:

// n1为缓存前的节点,n2为将要替换的节点
if (n1 && !isSameVNodeType(n1, n2)) {
    anchor = getNextHostNode(n1)
    // 卸载旧节点
    unmount(n1, parentComponent, parentSuspense, true)
    n1 = null
}

然后通过 setup 方法中的 sharedContext.activate 和 sharedContext.deactivate 来进行内容的替换,其核心是 move 方法,代码如下:

const move: MoveFn = () => {
    // 替换DOM
    ...
    hostInsert(el!, container, anchor) // insertBefore修改DOM
}

总结一下,<keep-alive> 组件也是一个 Vue 组件,它通过自定义的 render 方法实现,并且使用了插槽。由于是直接使用 VNode 方式进行内容替换,不是直接存储 DOM 结构,因此不会执行组件内的生命周期方法,它通过 include 和 exclude 维护组件的 cache 对象,从而来处理缓存中的具体逻辑。

小结与练习

本章讲解了 Vue 3 的核心源码和原理,主要内容包括:源码目录解析、响应式原理解析、虚拟 DOM 原理解析、双向绑定原理解析、<keep-alive> 原理解析相关知识。

通过阅读源码,可以对框架本身的运行机制进行学习,也能了解框架的 API 设计、原理及流程、设计思路等,并且当前大部分的前端面试都会问到 Vue 相关的原理性知识,掌握这些内容更有利于找到心仪的工作。

下面来检验一下读者对本章内容的掌握程度。

  • 在 Vue 3 中如何利用 Proxy 处理复杂对象的响应式?

  • Vue 3 生成虚拟 DOM 经过哪些流程?

  • Vue 3 虚拟 DOM 的 diff 源码中,patchFlag 和 shapeFlag 的区别是什么?

  • Vue 3 <keep-alive> 是如何工作的?