Vue3 源码解读之 KeepAlive 组件

560

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第11天,点击查看活动详情 >>

版本:3.2.31

KeepAlive 组件的作用

Vue.js 内建的KeepAlive 组件用于缓存组件,可以避免一个组件被频繁的销毁/重建。假设我们的页面中有一组 组件,如下面的代码所示:

<template>
  <Tab v-if="currentTab === 1">tab1</Tab>
  <Tab v-if="currentTab === 2">tab2</Tab>
  <Tab v-if="currentTab === 3">tab3</Tab>
</template>

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

<template>
<!-- 使用 KeepAlive 组件包裹-->
  <KeepAlive>
    <Tab v-if="currentTab === 1">tab1</Tab>
    <Tab v-if="currentTab === 2">tab2</Tab>
    <Tab v-if="currentTab === 3">tab3</Tab>
  </KeepAlive>
</template>

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

KeepAlive 组件的实现原理

KeepAlive 的本质是缓存管理和特殊的挂载/卸载逻辑。KeepAlive 组件的实现需要渲染器层面的支持。这是因为被 KeepAlive 的组件在卸载的时候,渲染器并不会真的将其卸载,而是会将该组件搬运到一个隐藏的容器中,实现 “假卸载”,从而使得组件可以维持当前状态。当被 KeepAlive 的组件再次被 “挂载” 时,渲染器也不会真的挂载它,而是将它从隐藏容器中搬运到原容器。这个过程对应到组件的生命周期,其实就是 activated 和 deactiveated 。

“卸载” 和 “挂载” 一个被 KeepAlive 的组件的过程如下图所示:

如上图所示,“卸载” 一个被 KeepAlive 的组件时,它并不会真的被卸载,而会被移动一个隐藏容器中。当重新 “挂载” 该组件时,它也不会真的被挂载,而会被从隐藏容器中取出,再 “放回” 原来的容器中,即页面中。

了解了 KeepAlive 组件的原理,下面,我们从源码上来分析 KeepAlive 组件的实现。

KeepAlive 组件的基本结构

// packages/runtime-core/src/components/KeepAlive.ts

const KeepAliveImpl: ComponentOptions = {
  name: `KeepAlive`,
  
  // Marker for special handling inside the renderer. We are not using a ===
  // check directly on KeepAlive in the renderer, because importing it directly
  // would prevent it from being tree-shaken.
  // KeepAlive 组件独有的特性,用作标识
  __isKeepAlive: true,
  
  // KeepAlive 组件的属性
  props: {
    include: [String, RegExp, Array], // 配置了该属性,那么只有名称匹配的组件会被缓存
    exclude: [String, RegExp, Array], // 配置了该属性,那么任何名称匹配的组件都不会被缓存
    max: [String, Number] // 最多可以缓存多少组件实例
  },
  setup(props: KeepAliveProps, { slots }: SetupContext) {
    // 省略部分代码
  }
}


export const KeepAlive = KeepAliveImpl as any as {
  __isKeepAlive: true
  new (): {
    $props: VNodeProps & KeepAliveProps
  }
}

从上面的代码可以看出,一个组件就是一个选项对象。KeepAlive 组件上有 name、__isKeepAlive、props、setup 等属性。其中 __isKeepAlive 属性是KeepAlive 组件独有的特性,用作标识。props 中的属性是用户使用 KeepAlive 组件时需要传递给组件的 Props。setup 函数是组件选项,用于配置组合式API。

KeepAlive 组件的setup函数

setup 函数是 Vue.js 3 新增的组件选项,主要用于配置组合式API.在组件的整个生命周期中,setup 函数只会在被挂载时执行一次,它的返回值有以下两种情况:

  • 返回一个函数,该函数将会作为组件的 render 函数:

    const Comp = { setup() { // setup 函数可以返回一个函数,该函数作为组件的渲染函数 return () => { return { type: 'div', children: 'hello' } } } }

  • 返回一个对象,该对象中包含的数据将会暴露给模板使用:

    const Comp = { setup() { const count = ref(0) // 返回一个对象,对象中的数据将暴露给模板使用 return { count } },

    render() { // 通过 this 可以访问 setup 暴露出来的响应式数据 return { type: 'div', children: count is: ${this.count} } } }

在 KeepAlive 组件的 setup 函数中,返回的是一个函数,该函数将会直接作为组件的render函数。如下面的代码所示:

// packages/runtime-core/src/components/KeepAlive.ts

// setup 函数用于配置组合式 API
// 在组件的整个生命周期中,setup 函数只会在被挂载时执行一次
setup(props: KeepAliveProps, { slots }: SetupContext) {

  // 省略部分代码    

  // 在 setup 函数中返回一个函数,这个函数会作为组件的 render 函数
  return () => {
    pendingCacheKey = null

    if (!slots.default) {
      return null
    }

    // KeepAlive 的默认插槽就是要被 KeepAlive 的组件
    const children = slots.default()

    // 省略部分代码

    // 返回要渲染的组件
    return rawVNode
  }
}

KeepAlive 组件的挂载和卸载

KeepAlive 的本质是缓存管理和特殊的挂载/卸载逻辑。接下来,我们来看看 KeepAlive 组件的挂载和卸载时如何实现的。

挂载

挂载的过程对应到组件的生命周期,其实就是 activated 。我们来看看源码中 activated 的实现:

// packages/runtime-core/src/components/KeepAlive.ts

// 将组件从隐藏容器中移动到原容器中(即页面中)
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 组件的实例上添加了一个 activate 函数,该函数用于激活组件。在重新 “挂载” 组件时,并不是真正的挂载,而是调用 move 方法,将组件从隐藏容器中移动到原容器中(即页面中)。由于在重新 “ 挂载” 的过程中,props 可能会发生变化,因此需要执行 patch 过程。

activate 函数的调用在首次渲染阶段。在首次渲染阶段,判断VNode节点上的shapeFlag属性,如果要挂载的组件是KeepAlive 组件,则调用activate激活组件,即将隐藏容器中移动到原容器中,否则直接调用mountComponent 挂载组件。如下面的代码所示:

// packages/runtime-core/src/renderer.ts

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

卸载

卸载的过程对应到组件的生命周期,其实就是 deactiveated 。我们来看看源码中 deactiveated 的实现:

// packages/runtime-core/src/components/KeepAlive.ts

// 将组件移动到隐藏容器中
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)
  }
}

可以看到,在 KeepAlive 组件的实例上添加了一个 deactivate 函数,该函数用于使组件失活。在 “卸载” 组件时,并不是真正的卸载,而是调用 move 方法,将组件搬运到一个隐藏的容器中。

deactivate 函数的调用组件的卸载阶段。在组件卸载阶段,判断VNode节点上的shapeFlag属性,如果要卸载的组件是KeepAlive 组件,则调用deactivate使组件失活,即将组件搬运到一个隐藏的容器中,然后直接返回。否则执行真正卸载组件的逻辑,将组件真正卸载掉。如下面的代码所示:

//  packages/runtime-core/src/renderer.ts

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) {
    // 调用 KeepLive 组件的deactivate方法使组件失活,即将组件搬运到一个隐藏的容器中
    ;(parentComponent!.ctx as KeepAliveContext).deactivate(vnode)
    return
  }


  // 省略真正卸载组件的处理逻辑
}

KeepAlive 组件的include 和 exclude

在默认情况下,KeepAlive 组件会对所有 “内部组件” 进行缓存。有时候用户期望只缓存特定组件,为了使用用户能够自定义缓存规则,KeepLive 组件提供了 include 和 exclude 这两个props来指定哪些组件需要被 KeepAlive,哪些组件不需要被 KeepAlive。

KeepLive 组件中的 props 定义如下:

// packages/runtime-core/src/components/KeepAlive.ts

const KeepAliveImpl: ComponentOptions = {
  name: `KeepAlive`,

  // KeepAlive 组件独有的特性,用作标识
  __isKeepAlive: true,
  
  // 定义 include 和 exclude
  props: {
    include: [String, RegExp, Array], // 配置了该属性,那么只有名称匹配的组件会被缓存
    exclude: [String, RegExp, Array], // 配置了该属性,那么任何名称匹配的组件都不会被缓存
    max: [String, Number] // 最多可以缓存多少组件实例
  },
  setup(props: KeepAliveProps, { slots }: SetupContext) {
    // 省略部分代码
  }
}

可以看到,include 和 exclude 的接收字符串、正则表达式以及数组类型的值。在 KeepLive 组件被挂载时,它根据 “内部组件” 的名称 (即 name 选项) 进行匹配,如下面的代码所示:

setup(props: KeepAliveProps, { slots }: SetupContext) {
  
  // 省略部分代码

  // 获取内部组件的 name
  const name = getComponentName(
    isAsyncWrapper(vnode)
      ? (vnode.type as ComponentOptions).__asyncResolved || {}
      : comp
  )

  // 获取用户传递的 include, exclude, max
  const { include, exclude, max } = props

  if (
    // 如果 name 无法被 include 匹配 
    (include && (!name || !matches(include, name))) ||
    // 或者被 exclude 匹配
    (exclude && name && matches(exclude, name))
  ) {
    // 则直接渲染 ”内部组件“,不对其进行后续的缓存操作
    // 将当前渲染的属性存储到 current 上
    current = vnode
    return rawVNode
  }
  
  
  // 省略部分代码
  
}

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 (pattern.test) {
    // 如果是正则,则使用正则匹配判断是否包含当前组件
    return pattern.test(name)
  }
  /* istanbul ignore next */
  return false
}

可以看到,源码中会根据用户指定的 include 和 exclude 规则,对 “内部组件” 的名称进行匹配,如果 “内部组件” 无法被 include 匹配,或者无法被 exclude 匹配,则直接渲染 “内部组件”,不对其进行缓存。

KeepAlive 组件的缓存管理

在 KeepAlive 组件的实现中,使用了 Map 对象和 Set 对象来实现组件的缓存,如下代码所示:

// 创建一个Map类型的缓存对象
// key: vnode.type
// value: vnode
const cache: Cache = new Map()
// 存储组件的 key
const keys: Keys = new Set()

其中 Map 对象的键是组件选项对象,即 vnode.type 属性的值,而该 Map 对象的值是用于描述组件的 vnode 对象。由于用于描述组件的 vnode 对象存在对组件实例的引用 (即 vnode.component属性),所以缓存 vnode 对象,就等价与缓存了组件实例。

Set 对象存储的是组件对象的 key。Set 对象具有自动去重的功能,因此Set对象中存储重复的组件对象。

KeepLive 组件中对缓存的管理时,首先会在组件的 onMounted 或 onUpdated 生命周期钩子函数中设置缓存,如下代码所示:

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

然后在KeepLive组件返回的函数中根据 vnode 对象的 key 去缓存中查找是否有缓存的组件,如果缓存存在,则继承组件实例,并将用于描述组件的 vnode 对象标记为 COMPONENT_KEPT_ALIVE,这样渲染器就不会重新创建新的组件实例;如果缓存不存在,则将 vnode 对象的key 添加到 keys 集合中,然后判断当缓存数量超过指定阈值时就对缓存进行修剪。如下代码所示

return () => {

  // 省略部分代码

  // 如果 vnode 上不存在 key,则使用 vnode.type 作为key
  const key = vnode.key == null ? comp : vnode.key
  // 根据 vnode 的key去缓存中查找是否有缓存的组件
  const cachedVNode = cache.get(key)

  // clone vnode if it's reused because we are going to mutate it
  if (vnode.el) {
    vnode = cloneVNode(vnode)
    if (rawVNode.shapeFlag & ShapeFlags.SUSPENSE) {
      rawVNode.ssContent = vnode
    }
  }
  // #1513 it's possible for the returned vnode to be cloned due to attr
  // fallthrough or scopeId, so the vnode here may not be the final vnode
  // that is mounted. Instead of caching it directly, we store the pending
  // key and cache `instance.subTree` (the normalized vnode) in
  // beforeMount/beforeUpdate hooks.
  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 {
    // 将 vnode 的key 添加到 keys 集合中,keys 集合用户维护缓存组件的 key
    keys.add(key)
    // prune oldest entry
    // 当缓存数量超过指定阈值时对缓存进行修剪
    if (max && keys.size > parseInt(max as string, 10)) {
      // 修剪缓存的组件
      pruneCacheEntry(keys.values().next().value)
    }
  }
  
  // 省略部分代码

}

Vue.js 中采用的修剪策略叫作 “最新一次访问”,其核心在于,把当前访问 (或渲染) 的组件作为最新一次渲染的组件,并且该组件在缓存修剪过程中始终是安全的,即不会被修剪。如下面的代码所示:

// packages/runtime-core/src/components/KeepAlive.ts

function pruneCacheEntry(key: CacheKey) {
  // 从缓存对象中获取缓存的 VNode
  const cached = cache.get(key) as VNode
  // 如果没有当前渲染的组件或当前渲染的组件和缓存中的组件不是同一个,卸载缓存中的组件
  if (!current || cached.type !== current.type) {
    unmount(cached)
  } else if (current) {
    // current active instance should no longer be kept-alive.
    // we can't unmount it now but it might be later, so reset its flag now.
    // 当前活动实例不应再保持活动状态。
    // 我们现在不能卸载它,但可能会在以后,所以现在重置它的标志。
    resetShapeFlag(current)
  }
  cache.delete(key)
  keys.delete(key)
}

总结

KeepLive 组件的作用类似于 HTTP 中的持久链接。它可以避免组件实例不断地被销毁和重建。KeepAlive 的实现并不复杂。当被 KeepAlive 的组件在卸载的时候,渲染器并不会真的将其卸载,而是会将该组件搬运到一个隐藏的容器中,从而使得组件可以维持当前状态。当被 KeepAlive 的组件再次被 “挂载” 时,渲染器也不会真的挂载它,而是将它从隐藏容器中搬运到原容器。

KeepAlive 组件的 include 和exclude 这两个属性用来指定哪些组件需要被 KeepAlive,哪些组件不需要被 KeepAlive。默认情况下,include 和 exclude 会匹配组件的 name 选项。

Vue.js 中缓存策略默认采用 “最新一次访问” 的修剪策略。