【源码分析】vue3 KeepAlive 原理

我正在参与掘金创作者训练营第4期,点击了解活动详情,一起学习吧!

前言

在我们日常开发中,经常会用到缓存路由或者组件的功能,如需要在两个组件之间来回切换;又比如从一个列表页跳转到详情页,在返回的时候,仍然能保持列表页的上一次操作状态(分页、搜索条件等等),这时候我们就可以用到keep-alive组件。

KeepAlive组件的设计思想其实起源于 HTTP 协议中的持久连接 KeepAlive:为了避免频繁地创建、销毁HTTP 连接带来的额外性能开销。在 vue 中KeepAlive的作用就是用来缓存组件,不但能避免频繁的创建销毁组件,还能够用来记忆组件的状态(一般用于缓存路由)。

基本使用

我们分别定义组件test-atest-b,为方便书写,下文简称为 a 组件和 b 组件:

test-a

<template>
    <div @click="increment()">i am test a, number: {{ number }}</div>
</template>
​
<script lang="ts" setup>
    import { ref } from 'vue'
    const number = ref(0)
    function increment() {
        number.value++
    }
</script>
复制代码

这是一个简单的累加器组件,点击文本部分就能让number加一

test-b

<template>
    <div>i am test b</div>
</template>
复制代码

然后在index.vue中引入这两个文件:

<script lang="ts" setup>
    import { ref } from 'vue'
    import TestA from '../components/test-a.vue'
    import TestB from '../components/test-b.vue'
​
    const flag = ref(true)
</script>
​
<template>
    <keep-alive>
        <test-a v-if="flag"></test-a>
        <test-b v-else></test-b>
    </keep-alive>
    <button @click="flag = !flag">切换</button>
</template>
​
复制代码

注意:KeepAlive 只能缓存组件类型的节点,非组件会被直接渲染。且要保证子组件同时只能有一个存在。

v-if的作用大家应该都很清楚:会将值为false的组件从dom 树中完全移除

我们先点击test-a两下,让number的值变为2,点击切换按钮再切换回来,可以发现test-a中的number并不会随着切换而清零。

1.gif 打开 vue devTools 可以很清楚的看到,在我们从组件 a 切换到组件 b 之后,组件 a 并没有被销毁,而是打上了一个inactive标签,反之亦然:

image-20220227183131523.png

这就是KeepAlive的作用了:缓存组件的全部状态,避免重复创建和销毁

实现原理

KeepAlive实现原理其实很简单:

在我们卸载keep-alive包裹的 a 组件之前,会将 a 组件从原来的位置移动到一个隐藏容器里,等到需要被再次挂载的时候,就从隐藏容器里移动到原来的位置。我画了个图看起来更清晰一点:

image-20220227191408474.png

源码分析

KeepAlive 源码地址,注:本文是基于 latest (2022/2/27) 代码,版本v3.2.31

我把源码的主要流程梳理一下,可以分成六个阶段:

image.png

  1. 获取渲染器方法
  2. 创建隐藏容器
  3. 给共享上下文对象添加activatedeactivate钩子
  4. 判断子组件是否满足缓存要求
  5. 获取缓存内容/添加缓存内容
  6. 渲染组件

0.获取渲染器方法

const instance = getCurrentInstance()
const parentSuspense = instance.suspense
const sharedContext = instance.ctx
const { renderer: { p: patch, m: move, um: _unmount, o: { createElement } } } = sharedContext
复制代码

KeepAlive 组件是通过渲染器实例的上下文对象instance.ctx与渲染器进行通信,这里我们主要是获取更新patch、移动move、卸载_unmount和创造元素createElement四个方法

1. 创建隐藏容器

接着用从渲染器拿到的createElement方法创建一个隐藏容器

const storageContainer = createElement('div')
复制代码

2. 给共享上下文对象添加activatedeactivate钩子

activatedeactivate这两个钩子,是被KeepAlive混村的组件中特有的函数,分别会在激活和停用组件的时候被触发,避免重复调用mountComponent挂载缓存组件和unmount方法卸载缓存组件

activate

sharedContext.activate = (vnode, container, anchor, isSVG, optimized) => {
    const instance = vnode.component
    move(vnode, container, anchor, 0 /* ENTER */, parentSuspense)
    patch(instance.vnode, vnode, container, anchor, instance, parentSuspense, isSVG, vnode.slotScopeIds, optimized)
    queuePostRenderEffect(() => {
        instance.isDeactivated = false
        if (instance.a) {
            invokeArrayFns(instance.a)
        }
        const vnodeHook = vnode.props && vnode.props.onVnodeMounted
        if (vnodeHook) {
            invokeVNodeHook(vnodeHook, instance.parent, vnode)
        }
    }, parentSuspense)
}
复制代码

activate的作用就是将被缓存的组件节点从隐藏节点移动会原来的容器当中,并在渲染队列中将组件实例的isDeactivated属性标记为 false。在 move 函数后面,调用了更新方法 patch:

const patch(n1, n2, container, achor) {
    if (shapeFlag & ShapeFlags.COMPONENT) {
        if (n1 == null) {
             if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
                 parentComponent.ctx.activate(n2, container, anchor, parentComponent, parentSuspense)
             } else {
                 // 挂载组件
                 mountComponent(n2, container, anchor, parentComponent, parentSuspense)
             }
        }
    }
}
复制代码

可以看到如果节点类型中存在COMPONENT_KEPT_ALIVE表示,则渲染器不会重新挂载它,而是调用activate来激活它本身

deactivate

sharedContext.deactivate = (vnode) => {
    const instance = vnode.component
    move(vnode, storageContainer, null, 1 /* LEAVE */, parentSuspense)
    queuePostRenderEffect(() => {
        if (instance.da) {
            invokeArrayFns(instance.da)
        }
        const vnodeHook = vnode.props && vnode.props.onVnodeUnmounted
        if (vnodeHook) {
            invokeVNodeHook(vnodeHook, instance.parent, vnode)
        }
        instance.isDeactivated = true
    }, parentSuspense)
}
复制代码

deactivate被触发时,会将被缓存的组件节点从父容器parentSuspense的位置移动到隐藏容器storageContainer中去,并将组件实例的isDeactivated标记为 true

3. 判断子组件是否满足缓存要求

当且仅当需要被缓存的节点是组件节点时KeepAlive才会生效

首先是判断KeepAlive组件中有没有内容,没有直接返回 null

if (!slots.default) {
    return null
}
复制代码

如果KeepAlive中子组件超过了一个,则会在生产环境抛出警告并返回子组件列表

const children = slots.default()
const rawVNode = children[0]
if (children.length > 1) {
    if (__DEV__) {
        warn(`KeepAlive should contain exactly one component child.`)
    }
    current = null
    return children
}
复制代码

KeepAlive中的不是组件节点,则返回原生节点

if (
    !isVNode(rawVNode) ||
    (!(rawVNode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) &&
     !(rawVNode.shapeFlag & ShapeFlags.SUSPENSE))
) {
    current = null
    return rawVNode
}
复制代码

经过层层筛选后,我们就可以对真正需要被缓存的子组件进行缓存了:

4. 获取/添加缓存内容

先创建一个缓存对象 cache

const cache = new Map()
复制代码

同时创建一个没有重复值的 keys,它就是专门为 KeepAlive 的缓存设计的,这样每一个子节点都能有一个唯一的 key

const keys = new Set()
复制代码

再创建一个pendingCacheKey用于在渲染后缓存子树组件

let pendingCacheKey = null
复制代码

在挂载节点之前先判断一下缓存中有没有需要被挂载的内容

const key = vnode.key == null ? comp : vnode.key
const cachedVNode = cache.get(key)
复制代码

同时将 pendingCacheKey 赋值为key,做好为这个实例缓存的准备

pendingCacheKey = key
复制代码

如果有,说明不需要执行挂载

if (cachedVNode) {
    // 继承被缓存的组件实例
    vnode.el = cachedVNode.el
    vnode.component = cachedVNode.component
    // 如果组件使用了transition过渡动画则执行动画
    if (vnode.transition) {
        setTransitionHooks(vnode, vnode.transition!)
    }
    // 更改节点的 shapeFlag 类型为COMPONENT_KEPT_ALIVE, 避免节点被当做新节点挂载
    vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE
    // 保证 key 值为最新,这一步主要是为了缓存管理,下文会讲到
    keys.delete(key)
    keys.add(key)
} 
复制代码

如果没有,将组件的 key 添加到 keys 中

else {
    keys.add(key)
    // 如果组件中传入了max属性且已缓存的组件数超过了max,就将最旧的缓存实例删除
    if (max && keys.size > parseInt(max, 10)) {
        pruneCacheEntry(keys.values().next().value)
    }
}
复制代码

删除最旧缓存实例的函数:

function pruneCacheEntry(key: CacheKey) {
    const cached = cache.get(key)
    // 如果需要删除的缓存实例不在当前页面存在的实例,就直接通过unmount卸载实例
    if (!current || cached.type !== current.type) {
        unmount(cached)
    // 如果删除的是当前页面存在的实例,那么这个实例不应该再被缓存
    // 但是我们也不该将它删除,所以我们删除该实例ShapeFlag中的COMPONENT_KEPT_ALIVE
    } else if (current) {
        resetShapeFlag(current) // current.shapeFlag -= ShapeFlags.COMPONENT_KEPT_ALIVE
    }
    cache.delete(key)
    keys.delete(key)
}
复制代码

还记得上面 pendingCacheKey 吗?他保存了需要被缓存的组件实例,在生命周期的onMounted阶段将需要缓存的组件实例存到 cache 当中:

const cacheSubtree = () => {
    if (pendingCacheKey != null) {
        cache.set(pendingCacheKey, getInnerChild(instance.subTree))
    }
}
onMounted(cacheSubtree)
// onUpdated触发时也会添加一次缓存
onUpdated(cacheSubtree)
复制代码

5. 渲染节点

最后,添加节点类型,然后渲染KeepAlive中的第一个子组件

vnode.shapeFlag |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
​
current = vnode
// 可以往上翻翻,这个是第一个子组件const rawVNode = children[0]
return rawVNode
复制代码

所以 KeepAlive可以看作是一个虚拟组件,因为它并没有真实存在 dom 树中,返回的是它的第一个子组件实例。

缓存原理

默认情况下,所有使用KeepAlive包含的子组件都会被缓存,当缓存组件过多时,无可避免的会出现性能问题。我们可以使用max属性来指定最大缓存数量。。

还记得我们上面提到过的,在判断已有缓存实例存在时,将 key 删了有加回来的操作吗?

keys.delete(key)
keys.add(key)
复制代码

还有当组件中传入了max属性且已缓存的组件数超过了max,就将最旧的缓存实例删除

if (max && keys.size > parseInt(max, 10)) {
    pruneCacheEntry(keys.values().next().value)
}
复制代码

这就是KeepAlive用到的缓存淘汰算法:LRU(Least Recently Used)

它的原理比较简单:就是设定一个有限的容量往里面塞缓存,当容量满了就将最早塞进去的缓存删除,给最新的缓存腾位置。

KeepAlive中具体的实现是这样的,我画了个流程图:

image-20220227235021424.png

include & exclude

  • include用于显示配置应该被缓存的组件
  • exclude用于显示配置不应该被缓存的组件

当我们配置了这两个属性后,它会在生成缓存实例之前进行判断:

if (
    (include && (!name || !matches(include, name))) ||
    (exclude && name && matches(exclude, name))
) {
    current = vnode
    return rawVNode
}
复制代码

当组件 nameinclude的值/正则不符合,或者与exclude值/正则匹配时,直接返回原生节点

参考

Guess you like

Origin juejin.im/post/7069422231387439111