1、前言

在 Vuejs 中,内置了 KeepAlive组件用于缓存组件,可以避免组件的销毁/重建,提高性能。假设页面有一组 Tab组件,如下代码所示:

<template>
  <Tab v-if="currentTab === 1">...</Tab>
  <Tab v-if="currentTab === 2">...</Tab>
  <Tab v-if="currentTab === 3">...</Tab>
</template>

可以看到,根据变量 currentTab的不同,会渲染不同的 Tab组件。当用户频繁的切换 Tab 时,会导致不停地卸载并重建对应的 Tab组件。为了避免因此产生的性能开销,可以使用 KeepAlive组件解决这个问题,如下代码所示:

<template>
 <KeepAlive>
  <Tab v-if="currentTab === 1">...</Tab>
  <Tab v-if="currentTab === 2">...</Tab>
  <Tab v-if="currentTab === 3">...</Tab>
 </KeepAlive>
</template>

这样,无论用户怎么切换 Tab组件,都不会发生频繁的创建和销毁,因此会极大地优化对用户操作的响应,尤其在大组件场景下,优势会更加明显。

2、KeepAlive 组件-源码实现

2.1 原理

KeepAlive组件的本质是缓存管理以及特殊的挂载/卸载逻辑。被 KeepAlive包裹的组件在卸载的时候并不是真正的卸载,而是将该组件搬运到一个隐藏的容器中,实现假卸载,从而使得组件可以维持当前状态。而当挂载的时候,会讲它从隐藏容器中搬运到原容器。

KeepAlive组件提供了 activatedeactivate两个生命周期函数来实现挂载卸载的逻辑,如下图所示:

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) {

    // 省略部分代码

    // 返回一个函数,该函数将会直接作为组件的render函数
    return () => {

      // 省略部分代码

    }
  }
}
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) {

    // 省略部分代码

    // 返回一个函数,该函数将会直接作为组件的render函数
    return () => {

      // 省略部分代码

    }
  }
}

Vue3 KeepAlive组件完全解读!_自定义

从以上代码中我们可以得知,KeepAlive组件上有 name__isKeepAlivepropssetup 等属性,它们的作用分别是:

  • nameKeepAlive 组件名称;
  • _isKeepAliveKeepAlive 组件标识;
  • propsKeepAlive 组件属性;
  • setupKeepAlive 组件渲染 render函数。

2.2 挂载-activate

在挂载组件的时候会调用 processComponent函数,其源码实现如下:

const processComponent = (
    n1: VNode | null,
    n2: VNode,
    container: RendererElement,
    anchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,
    slotScopeIds: string[] | null,
    optimized: boolean
  ) => {
    n2.slotScopeIds = slotScopeIds
    // 挂载组件
    if (n1 == null) {
      // 判断当前要挂载的组件是否是KeepAlive组件
      if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
        // 激活组件,即将隐藏容器中移动到原容器中
        ;(parentComponent!.ctx as KeepAliveContext).activate(
          n2,
          container,
          anchor,
          isSVG,
          optimized
        )
      } else {
        // 不是KeepAlive组件,调用mountComponent挂载组件
        mountComponent(
          n2,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          optimized
        )
      }
    } else {
      // 更新组件
      updateComponent(n1, n2, optimized)
    }
  }

在该函数中,挂载的时候首先判断了 shapeFlag的值,如果挂载的组件是 KeepAlive组件,则调用 activate函数激活组件,否则调用 mountComponent函数挂载组件。KeepAlive组件中 activate源码实现如下:

activate 函数源码实现

// 该函数用于激活组件
sharedContext.activate = (vnode, container, anchor, isSVG, optimized) => {
  // 组件实例
  const instance = vnode.component!
        // 将组件从隐藏容器中移动到原容器中(即页面中)
        move(vnode, container, anchor, MoveType.ENTER, parentSuspense)
  // in case props have changed
  // props可能会发生变化,因此需要执行patch过程
  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)

  if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
    // Update components tree
    devtoolsComponentAdded(instance)
  }
}

根据上面的源码我们得知,KeepAlive组件挂载时候是调用 move方法,将组件从隐藏容器中移动到原页面中。由于重新挂载时,props可能会发生变化,因此需要重新执行 patch过程。

2.3 卸载-deactivate

unmount 函数源码实现

卸载组件的时候会调用 unmount方法,在该方法中判断了 shapeFlag的值,如果卸载的组件是 KeepAlive组件,则调用 deactivate方法将组件搬运到隐藏容器中,然后直接返回,否则执行的是卸载组件的逻辑,将组件真正的卸载掉。

const unmount: UnmountFn = (
    vnode,
    parentComponent,
    parentSuspense,
    doRemove = false,
    optimized = false
  ) => {
    const {
      type,
      props,
      ref,
      children,
      dynamicChildren,
      shapeFlag,
      patchFlag,
      dirs
    } = vnode
    // unset ref
    if (ref != null) {
      setRef(ref, null, parentSuspense, vnode, true)
    }
    // 判断当前要挂载的组件是否是KeepAlive组件
    if (shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
      // 调用KeepAlive组件的deactivate方法使组件失活,即将组件搬运到一个隐藏的容器中
      ;(parentComponent!.ctx as KeepAliveContext).deactivate(vnode)
      return
    }

    // 其他卸载组件的处理逻辑
  }

接着我们来看deactive 函数源码实现:

// 将组件移动到隐藏容器中
    sharedContext.deactivate = (vnode: VNode) => {
      // 组件实例
      const instance = vnode.component!
      // 将组件移动到隐藏容器中
      move(vnode, storageContainer, null, MoveType.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)

      if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
        // Update components tree
        devtoolsComponentAdded(instance)
      }
    }

在该方法中,调用 move方法,将组件搬运到一个隐藏容器中。

2.4 include 和 exclude

默认情况下,KeepAlive组件会对所有的“内部组件"进行缓存,为了给用户提供自定义的缓存规则,KeepAlive组件提供了 includeexclude这两个 props,用户可以自定义哪些组件需要被 KeepAlive,哪些组件不需要被 KeepAlive

KeepAlive组件的 props 定义如下:

const KeepAliveImpl: ComponentOptions = {
  name: `KeepAlive`,
  // KeepAlive独有标识
  __isKeepAlive: true,

  props: {
    // 配置了该属性,那么只有名称匹配的组件会被缓存
    include: [String, RegExp, Array],
    // 配置了该属性,那么任何名称匹配的组件都不会被缓存
    exclude: [String, RegExp, Array],
    // 最多可以缓存多少组件实例
    max: [String, Number]
  },
}

KeepAlive组件挂载时,它会根据“内部组件”的名称(即 name选项)进行匹配,如下代码所示:

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) {

    // 省略部分代码

    // 返回一个函数,该函数将会直接作为组件的render函数
    return () => {

      // 省略部分代码

      // 获取用户传递的include、exclude、max
      const { include, exclude, max } = props
      // 如果name没有被include匹配或者被exclude匹配
      if (
        (include && (!name || !matches(include, name))) ||
        (exclude && name && matches(exclude, name))
      ) {
        // 则直接渲染内部组件,不对其进行后续的缓存操作,将当前渲染的属性存储到current上
        current = vnode
        return rawVNode
      }

      // 省略部分代码

      current = vnode
      return isSuspense(rawVNode.type) ? rawVNode : vnode
    }
  }
}

function matches(pattern: MatchPattern, name: string): boolean {
  if (isArray(pattern)) {
    // 如果是数组,则遍历pattern,递归调用matches,判断是否包含当前组件
    return pattern.some((p: string | RegExp) => matches(p, name))
  } else if (isString(pattern)) {
    // 如果是字符串,则分割字符串,判断pattern是否包含当前组件
    return pattern.split(',').includes(name)
  } else if (isRegExp(pattern)) {
    // 如果是正则,则使用正则匹配判断是否包含当前组件
    return pattern.test(name)
  }
  /* istanbul ignore next */
  return false
}

根据上面的源码,会根据用户传入的 includeexclude对“内部组件”名称匹配,如果 name没有被匹配到则直接渲染“内部组件”,否则需要缓存组件。

2.5 缓存管理

假设有如下模版内容:

<keep-alive>
  <h1 v-if="flag">h1</h1>
  <h2 v-else>h2</h2>
</keep-alive>

借助Vue SFC Playground平台,编译后的代码如下:

import { unref as _unref, openBlock as _openBlock, createElementBlock as _createElementBlock, createCommentVNode as _createCommentVNode, KeepAlive as _KeepAlive, createBlock as _createBlock } from "vue"

const _hoisted_1 = { key: 0 }
const _hoisted_2 = { key: 1 }

import { ref } from 'vue'


const __sfc__ = {
  __name: 'App',
  setup(__props) {

const msg = ref('Hello World!')
let flag = true

return (_ctx, _cache) => {
  return (_openBlock(), _createBlock(_KeepAlive, null, [
    (_unref(flag))
      ? (_openBlock(), _createElementBlock("h1", _hoisted_1, "h1"))
      : (_openBlock(), _createElementBlock("h2", _hoisted_2, "h2"))
  ], 1024 /* DYNAMIC_SLOTS */))
}
}

}

根据编译后的代码可知,KeepAlive的子节点创建的时候都添加了一个 key_hoisted_1_hoisted_2)。

然后渲染 KeepAlive组件的时候会对缓存做一定的处理,如下所示:

// 返回一个函数,该函数将会直接作为组件的render函数
return () => {
  // 省略部分代码

  // 根据vnode的key去缓存中查找是否有缓存的组件
  const cachedVNode = cache.get(key)

  // clone vnode if it's reused because we are going to mutate it
  if (vnode.el) {
    // 复制vnode为了复用
    vnode = cloneVNode(vnode)
    if (rawVNode.shapeFlag & ShapeFlags.SUSPENSE) {
      rawVNode.ssContent = vnode
    }
  }
  pendingCacheKey = key

  if (cachedVNode) {
    // copy over mounted state
    vnode.el = cachedVNode.el
    // 如果有缓存内容,则说明不应该执行挂载,而应该执行激活集成组件实例
    vnode.component = cachedVNode.component
    if (vnode.transition) {
      // recursively update transition hooks on subTree
      setTransitionHooks(vnode, vnode.transition!)
                         }
                         // avoid vnode being mounted as fresh
                         // 将shapeFlag设置为COMPONENT_KEPT_ALIVE,vnode避免挂载为新的
                         vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE
                         // make this key the freshest
                         keys.delete(key)
      keys.add(key)
    } else {
      keys.add(key)
      // prune oldest entry
      // 当缓存梳理超过指定阈值时对缓存进行修剪
      if (max && keys.size > parseInt(max as string, 10)) {
        pruneCacheEntry(keys.values().next().value)
      }
    }
    // 省略部分代码
  }
}

以上代码是 Vue.js 中处理 KeepAlive组件缓存逻辑的一部分,它允许组件保持状态或避免重新渲染。

  • 如果 cachedVNode 存在,表示有缓存的虚拟节点:
  • 将缓存的 DOM 元素 el 赋值给当前虚拟节点 vnode.el,这样就可以复用 DOM 元素,而不是重新创建。
  • 将缓存的组件实例 component 赋值给当前虚拟节点 vnode.component,这样组件状态可以保持不变。
  • 如果 vnode 有过渡效果,递归地更新子树的过渡钩子函数。
  • 通过设置 vnode.shapeFlagCOMPONENT_KEPT_ALIVE,避免将 vnode 当作新组件挂载。
  • 更新缓存键 key 的位置,将其从当前位置删除后重新添加到 keys 集合的末尾,这样可以保持 key 是最新的。
  • 如果 cachedVNode 不存在,表示没有缓存的虚拟节点:
  • 将新的 key 添加到 keys 集合中。
  • 如果缓存的大小超过了设定的最大值 max,则从缓存中删除最旧的条目。这是通过 pruneCacheEntry 函数实现的,它接收最旧条目的 key 并执行删除操作(LRU缓存策略)。

这段代码的目的是优化组件的渲染性能,通过复用组件实例和 DOM 元素来避免不必要的渲染开销。同时,它也管理着缓存的大小,确保不会因为缓存过多而消耗过多的内存。

KeepAlive组件挂载后会执行 onMounted生命周期函数,组件更新会执行 onUpdated生命周期函数,设置对应 key的组件缓存。

KeepAlive 设置缓存源码实现

// cache sub tree after render
let pendingCacheKey: CacheKey | null = null
const cacheSubtree = () => {
  // fix #1621, the pendingCacheKey could be 0
  if (pendingCacheKey != null) {
    // 设置缓存
    cache.set(pendingCacheKey, getInnerChild(instance.subTree))
  }
}
// 执行生命周期函数
onMounted(cacheSubtree)
onUpdated(cacheSubtree)

3、总结

KeepAlive 的本质作用是缓存组件,总结如下:

  • 卸载不是真正的卸载,是把组件移动到一个隐藏容器中,挂载是从隐藏容器中搬运到原页面中,以提高组件卸载和挂载的性能;
  • 用户可以指定 includeexclude来指定哪些组件可以被缓存,哪些组件不可以被缓存;
  • 缓存策略采用的 LRU策略。

项目附件:点此下载