KeepAlive 缓存思考

65 阅读3分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第2天,点击查看活动详情

我们先来思考一下,我们需要缓存什么?

组件的递归 patch 过程,主要就是为了渲染 DOM,显然这个递归过程是有一定的性能耗时的,既然目标是为了渲染 DOM,那么我们是不是可以把 DOM 缓存了,这样下一次渲染我们就可以直接从缓存里获取 DOM 并渲染,就不需要每次都重新递归渲染了。

实际上 KeepAlive 组件就是这么做的,它注入了两个钩子函数,onBeforeMount 和 onBeforeUpdate,在这两个钩子函数内部都执行了 cacheSubtree 函数来做缓存:

const cacheSubtree = () => {
    if (pendingCacheKey != null) {
        cache.set(pendingCacheKey, instance.subTree)
    }
}

由于 pendingCacheKey 是在 KeepAlive 的 render 函数中才会被赋值,所以 KeepAlive 首次进入 onBeforeMount 钩子函数的时候是不会缓存的。

然后 KeepAlive 执行 render 的时候,pendingCacheKey 会被赋值为 vnode.key,我们回过头看一下示例渲染后的模板:

import { resolveComponent as _resolveComponent, createVNode as _createVNode, createCommentVNode as _createCommentVNode, KeepAlive as _KeepAlive, openBlock as _openBlock, createBlock as _createBlock } from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {
    const _component_comp_a = _resolveComponent("comp-a")
    const _component_comp_b = _resolveComponent("comp-b")
    return (_openBlock(), _createBlock(_KeepAlive, null, [
        (_ctx.flag)
            ? _createVNode(_component_comp_a, { key: 0 })
            : _createVNode(_component_comp_b, { key: 1 }),
        _createVNode("button", {
            onClick: $event => (_ctx.flag=!_ctx.flag)
        }, "toggle", 8 /* PROPS */, ["onClick"])
    ], 1024 /* DYNAMIC_SLOTS */))
}

我们注意到 KeepAlive 的子节点创建的时候都添加了一个 key 的 prop,它就是专门为 KeepAlive 的缓存设计的,这样每一个子节点都能有一个唯一的 key。

页面首先渲染 A 组件,接着当我们点击按钮的时候,修改了 flag 的值,会触发当前组件的重新渲染,进而也触发了 KeepAlvie 组件的重新渲染,在组件重新渲染前,会执行 onBeforeUpdate 对应的钩子函数,也就再次执行到 cacheSubtree 函数中。

这个时候 pendingCacheKey 对应的是 A 组件 vnode 的 key,instance.subTree 对应的也是 A 组件的渲染子树,所以 KeepAlive 每次在更新前,会缓存前一个组件的渲染子树。

经过前面的分析,我认为 onBeforeMount 的钩子函数注入似乎并没有必要,我在源码中删除后再跑 Vue.js 3.0 的单测也能通过,如果你有不同意见,欢迎在留言区与我分享。

这个时候渲染了 B 组件,当我们再次点击按钮,修改 flag 值的时候,会再次触发KeepAlvie 组件的重新渲染,当然此时执行 onBeforeUpdate 钩子函数缓存的就是 B 组件的渲染子树了。

接着再次执行 KeepAlive 组件的 render 函数,此时就可以从缓存中根据 A 组件的 key 拿到对应的渲染子树 cachedVNode 的了,然后执行如下逻辑:

if (cachedVNode) {
    vnode.el = cachedVNode.el
    vnode.component = cachedVNode.component
    // 避免 vnode 节点作为新节点被挂载
    vnode.shapeFlag |= 512 /* COMPONENT_KEPT_ALIVE */
    // 让这个 key 始终新鲜
    keys.delete(key)
    keys.add(key)
}
else {
    keys.add(key)
    // 删除最久不用的 key,符合 LRU 思想
    if (max && keys.size > parseInt(max, 10)) {
        pruneCacheEntry(keys.values().next().value)
    }
}

有了缓存的渲染子树后,我们就可以直接拿到它对应的 DOM 以及组件实例 component,赋值给 KeepAlive 的 vnode,并更新 vnode.shapeFlag,以便后续 patch 阶段使用。