浅析KeepAlive组件原理

463 阅读9分钟

KeepAlive组件实现原理

KeepAlive 一词借鉴于 HTTP 协议。在 HTTP 协议中,KeepAlive又称 HTTP 持久连接(HTTP persistent connection),其作用是允许多个请求或响应共用一个 TCP 连接。在没有 KeepAlive 的情况下,一个HTTP 连接会在每次请求/响应结束后关闭,当下一次请求发生时,会建立一个新的 HTTP 连接。频繁地销毁、创建 HTTP 连接会带来额外的性能开销,KeepAlive 也许就是为了解决这个问题而生的。

默认情况下,如果一个组件实例在被替换后就会被销毁掉,这样的话,相当于会丢失组件实例所有已变化的状态;当在被显示时,相当于重置所有的状态,创建一个新实例

参数:

  props: {
    include: [String, RegExp, Array],
    exclude: [String, RegExp, Array],
    max: [String, Number],
  },
  • <KeepAlive>默认会缓存内部所有的组件实例,当然可以通过include(包含)和exclude(排除)来设置缓存行为,此外,还可以设置max来限制最大缓存的组件实例数。

  • <KeepAlive>会根据组件的name选项进行匹配,如果需要选择性的缓存组件、需要声明一个name,在vue3.3+中可以通过defineOptions设置,或者根据组件文件名称进行自动生成

  • 如果设置了一个max,那么类似于一个LRU,如果缓存的实例数量即将超过max,那么最久没有被使用的(访问)组件实例将会被销毁,以腾出新的空间

    来自百度百科的LRU:LRU是Least Recently Used的缩写,即最近最少使用,是一种常用的页面置换算法,选择最近最久未使用的页面予以淘汰。该算法赋予每个页面一个访问字段,用来记录一个页面自上次被访问以来所经历的时间 t,当须淘汰一个页面时,选择现有页面中其 t 值最大的,即最近最少(也就是最久没被访问使用)使用的页面予以淘汰。

生命周期

  • 当一个组件实例从DOM上被移除时,会触发相应的卸载钩子(如onBeforeUnmountonUnmounted);但在被<KeepAlive>缓存的组件实例,并不会触发这些生命周期钩子(换句话说就是,并不会被销毁或者卸载),而是变为“不活跃状态”,当再次被插入到DOM时,将会被重新激活
  • 一个被缓存持续存在的组件,通过onActivated()onDeactivated() 注册相应的生命周期钩子
  • onActivated() 会在组件首次挂载时、从缓存重新激活时会被调用
  • onDeactivated()会在组件销毁时、从DOM移到缓存(变为不活跃状态)调用

当一个被 <KeepAlive> 包裹的组件(根组件)被激活时,它内部的所有后代组件也会触发 activated 钩子;同样,当这个根组件被停用时,它内部的所有后代组件也会触发 deactivated 钩子。

示例

创建两个子组件A.vue和``B.vue`,一个包含A和B的父组件

// A.vue
<script setup lang="ts">
import {onActivated, onDeactivated, ref} from "vue";
import {chalkLog} from "./log.ts";

onActivated(()=>{
  chalkLog('子组件A缓存被激活')
})
onDeactivated(()=>{
  chalkLog('子组件A缓存被销毁')
})
const aText = ref('')
</script>

<template>
  <el-input placeholder="A组件" v-model="aText"></el-input>
</template>

<style scoped lang="scss">

</style>

// B.vue
<script setup lang="ts">
import {onActivated, onDeactivated, ref} from "vue";
import {chalkLog} from "./log.ts";

onActivated(()=>{
  chalkLog('子组件B缓存被激活', true)
})
onDeactivated(()=>{
  chalkLog('子组件B缓存被销毁', true)
})
const bText = ref('')
</script>

<template>
  <el-input placeholder="B组件" v-model="bText"></el-input>
</template>

<style scoped lang="scss">

</style>

// App.vue
<script setup lang="ts">

import ChildrenA from "./A.vue";
import ChildrenB from "./B.vue";
import {ref} from "vue";
const toggleCom = ref('A')
const handleToggle = ()=> {
  toggleCom.value = toggleCom.value ==='A' ? 'B': 'A'
}
</script>

<template>
  <el-button @click="handleToggle">切换{{toggleCom==='A' ? 'B': 'A'}}</el-button>
  <keep-alive>
    <component :is="toggleCom==='A' ?ChildrenA : ChildrenB">
    </component>
  </keep-alive>

</template>

<style scoped lang="scss">

</style>

20241112103759

  • 可以看到,使用了缓存,组件动态切换时,依然保持原有的状态

  • 首次挂载组件时,会执行onActivated()钩子或者从缓存中挂起(激活)时,也会执行该钩子

  • 从DOM组件移除到缓存时,会执行onDeactivated()钩子

  • 如果没有设置组件名称name时,会使用组件文件名称,即AB

  • 上面的缓存相当于

      <keep-alive include="A,B">
        <component :is="toggleCom==='A' ?ChildrenA : ChildrenB">
        </component>
      </keep-alive>
    // 等价于
      <keep-alive include="A,B">
        <children-a v-if="toggleCom==='A'"></children-a>
        <children-b v-else></children-b> // 如果使用v-if,比如要有一个 v-else,即确保要有一个组件被渲染
      </keep-alive>
    
    
  • 如果在组件中,通过defineOptions设置了名称name,那么 <KeepAlive> 应该使用该name,否则不会生效(该过程可以自行测试)

  • 此外,如果在 <KeepAlive> 中使用v-if的话,需要确保有且仅有一个子组件被渲染,否则将会报错:Vue: <KeepAlive> expects exactly one child component(只需要一个子组件)(该过程可以自行测试)

    如,这样是错误的:

    // 错误, Vue: <KeepAlive> expects exactly one child component.
      <keep-alive include="A,B">
        <children-a v-if="toggleCom==='A'"></children-a>
        <children-b v-if="toggleCom==='B'"f></children-b>
      </keep-alive>
    

    编译器警告

    image-20241112105828327

原理浅析

首先定位到该组件的源码位置:runtime-core/src/components/KeepAlive.ts

const KeepAliveImpl: ComponentOptions = {
  name: `KeepAlive`,
  // 用于渲染器内部特殊处理的标记
  // 直接在渲染器中检查KeepAlive,因为直接导入它,可以防止它被树摇动。
  __isKeepAlive: true,
 // 参数
  props: {
    include: [String, RegExp, Array],
    exclude: [String, RegExp, Array],
    max: [String, Number],
  },

  setup(props: KeepAliveProps, { slots }: SetupContext) {
    const instance = getCurrentInstance()!
    // KeepAlive通过以下方式与实例化的渲染器通信
    // ctx,渲染器在其内部传递,
    // 和KeepAlive实例公开已激活的激活实现。
    // 这样做的全部目的是避免直接在渲染器,以方便树摇。
    const sharedContext = instance.ctx as KeepAliveContext

    // 省略部分代码。。。。。

    // 缓存
    const cache: Cache = new Map()

    // 缓存的键、一般按道理应该是name
    const keys: Keys = new Set()

    // 当前激活的节点
    let current: VNode | null = null

    // 省略部分代码。。。。。
    const parentSuspense = instance.suspense

    const {
      renderer: {
        p: patch,
        m: move,
        um: _unmount,
        o: { createElement },
      },
    } = sharedContext
    // 将节点缓存的容器,猜测出,缓存的实现主要通过将节点隐藏到另一个容器中,需要激活时,再从该容器提取出来
    const storageContainer = createElement('div')

    sharedContext.activate = (
      vnode,
      container,
      anchor,
      namespace,
      optimized,
    ) => {
      // 拿到组件实例
      const instance = vnode.component!
      // 将缓存组件移动到容器中
      move(vnode, container, anchor, MoveType.ENTER, parentSuspense)
       // 省略部分代码。。。。。
    }
    

    // 渲染后缓存子树
    let pendingCacheKey: CacheKey | null = null
    const cacheSubtree = () => {
      // fix #1621, the pendingCacheKey could be 0
      if (pendingCacheKey != null) {
        // if KeepAlive child is a Suspense, it needs to be cached after Suspense resolves
        // avoid caching vnode that not been mounted
        if (isSuspense(instance.subTree.type)) {
          queuePostRenderEffect(() => {
            cache.set(pendingCacheKey!, getInnerChild(instance.subTree))
          }, instance.subTree.suspense)
        } else {
          cache.set(pendingCacheKey, getInnerChild(instance.subTree))
        }
      }
    }
    // 在组件挂载或者更新时,缓存子树节点
    onMounted(cacheSubtree)
    onUpdated(cacheSubtree)
    
    // 组件被卸载之前,清理缓存
    onBeforeUnmount(() => {
      cache.forEach(cached => {
        const { subTree, suspense } = instance
        const vnode = getInnerChild(subTree)
        if (cached.type === vnode.type && cached.key === vnode.key) {
          // current instance will be unmounted as part of keep-alive's unmount
          resetShapeFlag(vnode)
          // but invoke its deactivated hook here
          const da = vnode.component!.da
          da && queuePostRenderEffect(da, suspense)
          return
        }
        // 清理掉缓存
        unmount(cached)
      })
    })

    return () => {
      pendingCacheKey = null
    // 如果 没有默认插槽。即子节点,直接返回空
      if (!slots.default) {
        return null
      }

      // 拿到子节点内容
      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
      } else if (
        !isVNode(rawVNode) ||
        (!(rawVNode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) &&
          !(rawVNode.shapeFlag & ShapeFlags.SUSPENSE))
      ) {
        current = null
        return rawVNode
      }

      let vnode = getInnerChild(rawVNode)
      const comp = vnode.type as ConcreteComponent

      // 根据组件,拿到具备缓存的组件名称
      // 对于异步组件,名称检查应基于其加载的内部组件(如果可用)
      const name = getComponentName(
        isAsyncWrapper(vnode)
          ? (vnode.type as ComponentOptions).__asyncResolved || {}
          : comp,
      )

      const { include, exclude, max } = props

      // matches 应该是一个根据include中name排除掉缓存的组件
      if (
        (include && (!name || !matches(include, name))) ||
        (exclude && name && matches(exclude, name))
      ) {
        current = vnode
        return rawVNode
      }
      // 然后,根据组件名称,也就是提到的基于文件名称或者组件名称的缓存key
      // 拿到具备缓存条件的组件
      const key = vnode.key == null ? comp : vnode.key
      const cachedVNode = cache.get(key)

      // 克隆vnode,防止原始的节点被修改
      if (vnode.el) {
        vnode = cloneVNode(vnode)
        if (rawVNode.shapeFlag & ShapeFlags.SUSPENSE) {
          rawVNode.ssContent = vnode
        }
      }

      // 省略代码。。。

      // 如果有缓存节点
      if (cachedVNode) {
        // 将缓存的节点复制并覆盖当前的节点
        vnode.el = cachedVNode.el
        vnode.component = cachedVNode.component

        // 如果是动画组件
        if (vnode.transition) {
          // 递归更新subTree上的转换钩子
          setTransitionHooks(vnode, vnode.transition!)
        }
        // 避免将vnode作为新挂载

        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)
        }
      }
      // 避免卸载vnode
      vnode.shapeFlag |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
      // 将需要激活的覆盖当前的节点
      current = vnode
      return isSuspense(rawVNode.type) ? rawVNode : vnode
    }
  },
}
  • 简单来说,KeepAlive组件是一个抽象的组件,普通的组件相比,KeepAlive组件本身并不会渲染额外的内容,它的渲染函数最终返回需要被缓存的组件或者原始组件(exclude排除的组件)
  • KeepAlive内部会通过onMountedonUpdated对第一个子节点进行缓存
  • 在卸载之前onBeforeUnmount钩子时,会清理掉缓存节点
  • 如果组件的 vnode 对象中存在 __isKeepAlive标识,则渲染器不会重新挂载它,而是会通过sharedContext.activate函数来激活它
  • 如设置了max,超过该值会裁剪掉最久没有使用的缓存实例
  • 缓存时,组件并不会真的被卸载,而会被移动到一个隐藏容器中。当重新“挂载”该组件时,它也不会被真的挂载,而会被从隐藏容器中取出,再“放回”原来的容器中(即KeepAlive),因此会触发相应的钩子(onActivatedonDeactivated

总结

<KeepAlive> 用于缓存组件实例,提升应用性能,避免频繁销毁和重建组件实例

  1. 参数配置

    • include:指定哪些组件需要被缓存。
    • exclude:指定哪些组件不需要被缓存。
    • max:设置最大缓存的组件实例数,超出时会根据 LRU(最近最少使用)策略裁剪缓存。
  2. 生命周期钩子

    • onActivated:组件被激活时调用(首次挂载或者从缓存中恢复)。
    • onDeactivated:组件从 DOM 移除到缓存时调用。
  3. 缓存机制

    • <KeepAlive> 通过 Map 数据结构来缓存组件实例。
    • 缓存的键通常是组件的 name 或文件名,此外如果在组件中,通过defineOptions设置了名称name,那么 <KeepAlive> 应该使用该name,否则不会生效
    • 当组件被移除时,并不会是真正的被销毁,而是被移动到一个隐藏的容器中;当组件需要重新激活时,从隐藏容器中取出并重新插入到 DOM 中。
  4. 渲染逻辑

    • <KeepAlive> 的渲染函数会处理其子节点,确保有且仅有一个子组件被渲染。

    • 如果子组件符合缓存条件(不在 exclude 列表中且在 include 列表中),则会进行缓存。

    • 缓存的组件实例会通过 cloneVNode 克隆并覆盖当前的节点,避免直接修改原始节点。

    • 组件实例 onMountedonUpdated 钩子在组件挂载和更新时缓存子节点。

    • 使用 onBeforeUnmount 钩子在组件卸载前清理缓存。