Vue3源码 | 读懂keep-alive组件以及缓存机制

1,189 阅读7分钟

这是我参与更文挑战的第30天,活动详情查看:更文挑战

[vue3源码系列文章连载中...]

日常开发中,如果需要在组件切换时,保存组件的状态,防止它多次销毁,多次渲染,我们通常采用 <keep-alive> 组件处理,因为它能够缓存不活动的组件,而不是销毁它们。同时, <keep-alive> 组件不会渲染自己的DOM元素,也不会出现在组件父链中,属于一个抽象组件。当组件在 <keep-alive> 内被切换时,它的 activated 和 deactivated 这两个钩子函数将会被对应执行。

基础用法

以下是 <keep-alive> 组件的示例用法,

<keep-alive :include="['a', 'b']" :max="10">
  <component :is="view"></component>
</keep-alive>

属性Props

  1. include字符串或表达式。只有名称匹配的组件会被缓存。

  2. exclude字符串或正则表达式。任务名称匹配的组件都不会被缓存。

  3. max数字。最多可以缓存多少组件实例。

注意的是, <keep-alive> 组件是用在直属的子组件被开关的情况,若存在多条件性的子元素,则要求同时只能有一个元素被渲染。

组件源码实现

上面我们了解了 <keep-alive> 组件的定义、属性以及用法,下面就看下源码是如何对应实现的。

抽象组件

我们去掉多余的代码,看看KeepAlive组件是如何定义的。

const KeepAliveImpl = {
  __isKeepAlive: true,
  inheritRef: true,
  props: {
    include: [String, RegExp, Array],
    exclude: [String, RegExp, Array],
    max: [String, Number]
  },
  setup(props: KeepAliveProps, { slots }: SetupContext){

    // 省略其他代码...

    return()=>{
      if (!slots.default) {
        return null
      }
      // 拿到组件的子节点
      const children = slots.default()
      // 取第一个子节点
      let vnode = children[0]  
      // 存在多个子节点的时候,keepAlive组件不生效了,直接返回
      if (children.length > 1) {
        current = null
        return children
      } else if (
        !isVNode(vnode) ||
        !(vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT)
      ) {
        current = null
        return vnode
      }
      // 省略其他代码...

      // 返回第一个子节点
      return vnode
    }
  }
}

从源码可以看出KeepAlive组件是通过Composition API实现的,setup返回的是组件的渲染函数。在渲染函数内,取组件的子节点,当存在多个子节点,则直接返回所有节点,也就KeepAlive组件不生效了。当仅存在一个子节点,则渲染第一个子节点的内容,也就验证了KeepAlive是抽象组件,不渲染本身的DOM元素。

缓存机制

了解KeepAlive组件缓存机制前,我们先了解下LRU算法概念,它正是通过该算法来处理缓存机制。

LRU算法

我们常用缓存来提升数据查询的数据,由于缓存容量有限,当缓存容量到达上限,就需要删除部分数据挪出空间,让新数据添加进来。因此需要制定一些策略对加入缓存的数据进行管理。常见的策略有:

  1. LUR 最近最久未使用

  2. FIFO 先进先出

  3. NRU Clock 置换算法

  4. LFU 最少使用置换算法

  5. PBA 页面缓冲算法

KeepAlive缓存机制使用的是LRU算法(Least Recently Used),当数据在最近一段时间被访问,那么它在以后也会被经常访问。这就意味着,如果经常访问的数据,我们需要能够快速命中,而不常访问的数据,我们在容量超出限制,要将其淘汰。

我们这里只讲概念,如果想深入理解LRU算法,可自行查找。

缓存实现

简化下代码,抽离出核心代码,看看缓存机制

const KeepAliveImpl = {
  setup(props){
    // 缓存KeepAlive子节点的数据结构{key:vNode}  
    const cache: Cache = new Map()
    // 保存KeepAlive子节点唯一标识的数据结构
    const keys: Keys = new Set()
    let current: VNode | null = null

    let pendingCacheKey: CacheKey | null = null
    
    // 在beforeMount/Update 缓存子树
    const cacheSubtree = () => {
      if (pendingCacheKey != null) {
        cache.set(pendingCacheKey, instance.subTree)
      }
    }
    onBeforeMount(cacheSubtree)
    onBeforeUpdate(cacheSubtree)

    return ()=>{
      pendingCacheKey = null

      const children = slots.default()
      let vnode = children[0]

      const comp = vnode.type as Component
      const name = getName(comp)
      // 解构出属性值
      const { include, exclude, max } = props
      // key值是KeepAlive子节点创建时添加的,作为缓存节点的唯一标识
      const key = vnode.key == null ? comp : vnode.key
      // 通过key值获取缓存节点
      const cachedVNode = cache.get(key)

      if (cachedVNode) {
        // 缓存存在,则使用缓存装载数据
        vnode.el = cachedVNode.el
        vnode.component = cachedVNode.component
        if (vnode.transition) {
          // 递归更新子树上的 transition hooks
          setTransitionHooks(vnode, vnode.transition!)
        }
        // 阻止vNode节点作为新节点被挂载
        vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE
        // 让key始终新鲜
        keys.delete(key)
        keys.add(key)
      } else {
        keys.add(key)
        // 属性配置max值,删除最久不用的key,这很符合LRU的思想
        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 vnode;
    }
  }
}

从源码中可以看出KeepAlive声明了了个cache变量来缓存节点数据,它是Map结构。并采用LRU缓存算法来处理子节点存储机制,具体说明如下:

  1. 声明有序集合keys作为缓存容器,容器内缓存组件的唯一标识key

  2. keys缓存容器中的数据,越靠前的key值越少被访问越旧,往后的值越新鲜

  3. 渲染函数执行时,若命中缓存时,则从keys中删除当前命中的key,并往keys末尾追加key值,保存新鲜

  4. 未命中缓存时,则keys追加缓存数据key值,若此时缓存数据长度大于max最大值,则删除最旧的数据,这里的值是keys中第一个值,很符合LRU思想。

  5. 当触发beforeMount/update生命周期,缓存当前激活的子树的数据

挂载区别

通常组件挂载、卸载都会触发各自生命周期,那KeepAlive子树有无缓存在挂载阶段是否存在区别呢?以下抽离下patch阶段中 ShapeFlags.COMPONENT 类型相关核心代码看看。

 const processComponent = (n1: VNode | null,n2: VNode,container: RendererElement,anchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null,parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,optimized: boolean
  ) => {
    if (n1 == null) {
      // 存在COMPONENT_KEPT_ALIVE ,激活n2
      if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
        ;(parentComponent!.ctx as KeepAliveContext).activate(n2,container,anchor,isSVG,optimized)
      } else {
        // 否则,挂载组件
        mountComponent(n2,container,anchor,parentComponent,parentSuspense,isSVG,optimized)
      }
    } else {
      // 更新组件
      updateComponent(n1, n2, optimized)
    }
  }

KeepAlive组件在渲染函数执行时,若存在缓存,会给vNode赋予vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE状态,因此再次渲染该子树时,会执行parentComponent!.ctx.activate 函数激活子树的状态。那这里的activate函数是什么呢?看下代码

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(instance.vnode,vnode,container,anchor,instance,parentSuspense,isSVG,optimized)
  queuePostRenderEffect(() => {
    // 组件渲染完成后,执行子节点组件定义的actived钩子函数
    instance.isDeactivated = false
    if (instance.a) {invokeArrayFns(instance.a)}
    const vnodeHook = vnode.props && vnode.props.onVnodeMounted
    if (vnodeHook) {
      invokeVNodeHook(vnodeHook, instance.parent, vnode)
    }
  }, parentSuspense)
}

再次激活子树时,因为上次渲染已经缓存了 vNode,能够从vNode直接获取缓存的DOM了,也就无需再次转次vNode。因此可以直接执行 move 挂载子树,然后再执行patch更新组件,最后再通过queuePostRenderEffect,在组件渲染完成后,执行子节点组件定义的 activate 钩子函数。

再看下激活/失效的实现思路,通过将渲染器传入KeepAlive实例的ctx属性内部,实现KeepAlive与渲染器实例的通信,并且通过KeepAlive暴露activate/deactivate两个实现。这样做的目的是,避免在渲染器直接导入KeepAlive产生 tree-shaking

属性实现

KeepAlive支持3个属性includeexcludemax。其中max在上面已经讲过了,这里看下另外2个属性的实现。

setup(){
  watch(
    () => [props.include, props.exclude],
      ([include, exclude]) => {
      include && pruneCache(name => matches(include, name))
      exclude && pruneCache(name => matches(exclude, name))
    }
  )

  return ()=>{
    if (
      (include && (!name || !matches(include, name))) ||
      (exclude && name && matches(exclude, name))
    ) {
      return (current = vnode)
    }
  }
}

这里很好理解,当子组件名称不匹配include的配置值,或者子组件名称匹配了exclude的值,都不该被缓存,而是直接返回。而watch函数是监听includeexclude值变化时做出对应反应,即去删除对应的缓存数据。

卸载过程

卸载分为子组件切换时产生的子组件卸载流程,以及KeepAlive组件卸载导致的卸载流程。

子组件卸载流程组件卸载过程,会执行unmount方法,然后执行 parentComponent.ctx.deactivate(vnode)函数,在函数里通过move函数移除节点,然后通过 queuePostRenderEffect 的方式执行定义的deactivated钩子函数。此过程跟挂载过程类似,不过多描述。

KeepAlive组件卸载当KeepAlive组件卸载时,会触发onBeforeUnmount函数,现在看看该函数的实现:

onBeforeUnmount(() => {
  cache.forEach(cached => {
  const { subTree, suspense } = instance
  if (cached.type === subTree.type) {
      resetShapeFlag(subTree)
      const da = subTree.component!.da
      da && queuePostRenderEffect(da, suspense)
      return
    }
    unmount(cached)
  })
})

当缓存的vnode为当前KeepAlive组件渲染的vnode时,重置vnodeShapeFlag,让它不被当做是KeepAlive的vNode,然后通过queuePostRenderEffect 执行子组件的deactivated函数,这样就完成了卸载逻辑。否则,则执行unmount方法执行vnode的整套卸载路程。

附:LRU算法

class LRUCache{
    constructor(capacity){
        this.capacity = capacity || 2
        this.cache = new Map()
    }
    // 存值,超出最大则默认删除第一个:最近最少被用元素
    put(key,val){
        if(this.cache.has(key)){
            this.cache.delete(key)
        }
        if(this.cache.size>=this.capacity){
            this.cache.delete(this.cache.keys().next().value)
        }
        this.cache.set(key,val)
    }
    // 取值,同时刷新缓存新鲜度
    get(key){
        if(this.cache.has(key)){
            const temp = this.cache.get(key)
            this.cache.delete(key)
            this.cache.set(key,temp)
            return temp
        }
        return -1
    }
}

总结

至此我们便梳理了KeepAlive组件的作为抽象组件是如何设计,以及相关属性是如何实现,并了解了LRU缓存机制。