你知道 KeepAlive 组件的实现原理吗?

发布于:2024-04-28 ⋅ 阅读:(21) ⋅ 点赞:(0)

KeepAlive 组件是 Vue 的内置组件之一,他的功能是在多个组件间动态切换时缓存被移除的组件实例。缓存了的组件,其内部的状态不会被重置。这在动态切换组件,需要保持组件状态的场景下非常有用。

<template>
  <!-- 使用 KeepAlive 组件包裹 -->
  <KeepAlive>
    <Tab v-if="currentTab === 'a'"><com-a /></Tab>
    <Tab v-if="currentTab === 'b'"><com-b /></Tab>
  </KeepAlive>
</template>

KeepAlive 组件的实现原理就是缓存管理和特殊的卸载/挂载逻辑

缓存管理借鉴了 最近最少使用的算法思想。当为缓存设置最大容量,也就是设置了 KeepAlive 组件的 max 属性后,当达到缓存的最大容量时,会将最久没有使用到的组件删除。

KeepAlive 包裹的组件的“卸载”不是真的卸载,而是会将该组件搬运到一个隐藏容器中,从而可以使组件保持当前状态。当被 KeepAlive 的组件“挂载”时,渲染器也不会真的挂载他,而是将他从隐藏容器搬运到原容器。

pic1.png

Tips: 本文中的源码均摘自 Vue.js 3.2.45,为了方便理解,会省略与本文主题无关的代码

KeepAlive 组件是 Vue 的内置组件,具体实现在 packages/runtime-core/src/components/KeepAlive.ts 文件中。KeepAlive 组件内部会有自己的独有标识 __isKeepAlive 。Vue 内部会通过这个标识来判断当前虚拟 DOM 是否为 KeepAlive 组件。

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

const KeepAliveImpl: ComponentOptions = {
  name: `KeepAlive`,
  // KeepAlive 组件独有的属性,用作标识
  __isKeepAlive: true,
}
// packages/runtime-core/src/components/KeepAlive.ts

// 通过 __isKeepAlive 标识判断当前虚拟 DOM 是否为 KeepAlive 组件
export const isKeepAlive = (vnode: VNode): boolean =>
  (vnode.type as any).__isKeepAlive

定义 KeepAlive 组件的 props。

  • include ,如果指定,则只有与 include 名称匹配的组件才会被缓存

  • exclude,如果指定,则任何名称与 exclude 匹配的组件都不会被缓存

  • max,最多可以缓存多少组件实例

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

const KeepAliveImpl: ComponentOptions = {
  // ...
  props: {
    include: [String, RegExp, Array],
    exclude: [String, RegExp, Array],
    max: [String, Number]
  },
}

定义 KeepAlive 组件的 setup 函数,setup 函数如果返回函数的话,该返回的函数会被作为组件的渲染函数。首先是获取 KeepAlive 组件的实例和 KeepAlive 组件的上下文对象。渲染器通过此上下文对象与 KeepAlive 组件通信,渲染器会暴露一下内部的方法到 KeepAlive 组件的上下文对象中,其中 move 函数用来将一段 DOM 移动到另一个容器中。

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

const KeepAliveImpl: ComponentOptions = {
  // ...
  setup(props: KeepAliveProps, { slots }: SetupContext) {
    // 当前 KeepAlive 组件的实例
    const instance = getCurrentInstance()!
    // KeepAlive 组件的上下文对象,渲染器通过此上下文对象与 KeepAlive 组件通信,
    // 渲染器会向此上下文对象暴露一些内部方法,其中 move 函数用来将一段 DOM 移动
    // 到另一个容器中。
    const sharedContext = instance.ctx as KeepAliveContext    
  }
}

从 KeepAlive 组件的上下文对象中取得渲染器注入的方法:

  • patch:更新组件
  • move:移动组件
  • _unmount:卸载组件
  • createElement:创建元素
// packages/runtime-core/src/components/KeepAlive.ts

const KeepAliveImpl: ComponentOptions = {
  // ...
  setup(props: KeepAliveProps, { slots }: SetupContext) {
    // ...
    const {
      renderer: {
        p: patch,
        m: move,
        um: _unmount,
        o: { createElement }
      }
    } = sharedContext    
  }
}

定义缓存对象 cache ,用于保存被 KeepAlive 包裹的组件的虚拟 DOM 。定义一个集合(Set 数据结构),用于保存缓存的 key。定义变量 current ,保存当前被 KeepAlive 包裹组件的虚拟 DOM 。

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

const cache: Cache = new Map()
const keys: Keys = new Set()
let current: VNode | null = null

创建隐藏容器(storageContainer

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

const storageContainer = createElement('div')

在 KeepAlive 组件上下文对象(sharedContext)上定义 activatedeactivate 函数。这两个函数会在渲染器中执行。activate 函数会将组件从隐藏容器中移到原容器中,并调用 patch 函数更新组件,防止因为 props 更新而没有更新组件,并将用户注册的 activated 生命周期函数推入 Post 任务队列。

// 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)
  // 更新组件,因为 props 可能发生变化
  patch(
    instance.vnode,
    vnode,
    container,
    anchor,
    instance,
    parentSuspense,
    isSVG,
    vnode.slotScopeIds,
    optimized
  )
  // 将用户注册的 `activated` 生命周期函数推入 Post 任务队列
  queuePostRenderEffect(() => {
    instance.isDeactivated = false
    if (instance.a) {
      // 执行用户注册的 `activated` 生命周期函数
      invokeArrayFns(instance.a)
    }
  }, parentSuspense)
}

deactivate 函数则会将组件移入隐藏容器中,并将用户注册的 deactivated 生命周期函数推入 Post 任务队列。

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

sharedContext.deactivate = (vnode: VNode) => {
  const instance = vnode.component!
  // 将组件移到隐藏容器
  move(vnode, storageContainer, null, MoveType.LEAVE, parentSuspense)
  // 将用户注册的 `deactivated` 生命周期函数推入 Post 任务队列
  queuePostRenderEffect(() => {    
    if (instance.da) {
      // 执行用户注册的 `deactivated` 生命周期函数
      invokeArrayFns(instance.da)
    }
    const vnodeHook = vnode.props && vnode.props.onVnodeUnmounted
    if (vnodeHook) {
      invokeVNodeHook(vnodeHook, instance.parent, vnode)
    }
    instance.isDeactivated = true
  }, parentSuspense)
}

有关 Vue3 Post 任务队列的更多相关信息可见笔者写的另一篇文章:

定义 unmount 函数,用于卸载传入的组件和重置 keep alive 的标识

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

function unmount(vnode: VNode) {  
  resetShapeFlag(vnode)
  // 卸载组件
  _unmount(vnode, instance, parentSuspense, true)
}

resetShapeFlag 函数则是借助位运算,重置 keep alive 的标识。通过取反(~)操作,再与(&)操作,将原本为 1 的标识重置为 0 。

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

function resetShapeFlag(vnode: VNode) {
  vnode.shapeFlag &= ~ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
  vnode.shapeFlag &= ~ShapeFlags.COMPONENT_KEPT_ALIVE
}

接下来实现 KeepAlive 组件的 include 和 exclude 的 API 。用户可使用这两个 API 设置哪些组件可以被缓存,哪些组件不能被缓存,实现更灵活的缓存控制。

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

// 删除缓存对象中不匹配名字的组件
function pruneCache(filter?: (name: string) => boolean) {
  cache.forEach((vnode, key) => {
    // 获取组件名
    const name = getComponentName(vnode.type as ConcreteComponent)
    if (name && (!filter || !filter(name))) {
      // 如果组件名字不存在,且不匹配(filter(name) 为 false),
      // 则删除缓存中的该组件
      pruneCacheEntry(key)
    }
  })
}

// 删除缓存对象中指定 key 的组件
function pruneCacheEntry(key: CacheKey) {
  const cached = cache.get(key) as VNode
  if (!current || cached.type !== current.type) {
    // 卸载除当前组件外的指定 key 的缓存组件,
    // 因为当前组件正在使用,是不能被卸载的
    unmount(cached)
  } else if (current) {
    // 重置当前组件的 keep alive 标识,使其后续可被正常卸载
    resetShapeFlag(current)
  }
  // 删除指定 key 的缓存
  cache.delete(key)
  // 删除 keys 集合中的相关 key
  keys.delete(key)
}

// 当 include 、exclude 变化时,需要做相关的处理,
// 与 include 匹配的组件名称,则会被缓存,
// 与 exclude 匹配的组件名称,则不会被缓存
watch(
  () => [props.include, props.exclude],
  ([include, exclude]) => {
    include && pruneCache(name => matches(include, name))
    exclude && pruneCache(name => !matches(exclude, name))
  },
  // prune post-render after `current` has been updated
  { flush: 'post', deep: true }
)

matches 函数定义了 include 和 exclude 的匹配逻辑。include 、exclude 可以接收字符串、正则、字符串与正则的数组作为参数。

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

// 判断传入的 name 是否匹配
function matches(pattern: MatchPattern, name: string): boolean {
  if (isArray(pattern)) {
    return pattern.some((p: string | RegExp) => matches(p, name))
  } else if (isString(pattern)) {
    return pattern.split(',').includes(name)
  } else if (isRegExp(pattern)) {
    return pattern.test(name)
  }
  return false
}

定义 pendingCacheKey 变量,pendingCacheKey 变量会在组件 render 的时候被赋值为组件虚拟 DOM 的 key 。然后在组件 Mounted 和 Updated 阶段用于重新设置组件的缓存。因为被缓存的组件虚拟 DOM 不一定是最新的,当组件挂载或更新时,需要用组件最新的虚拟 DOM 来更新之前缓存起来的数据。

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

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)

subTree ,组件渲染函数返回的虚拟 DOM ,即组件的子树

当 KeepAlive 组件卸载时,要将 KeepAlive 组件下缓存的所有组件都卸载掉。当前组件不能马上卸载,因为正在使用中,则需要重置 keep alive 相关标识,后续会被渲染器卸载。

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

onBeforeUnmount(() => {
  cache.forEach(cached => {
    const { subTree, suspense } = instance
    const vnode = getInnerChild(subTree)
    // 在缓存对象中找到了当前组件
    if (cached.type === vnode.type) {
      // 重置当前组件的 keep alive 标识,后续该组件由于没有了 keep alive 标识,
      // 则会被渲染器卸载
      resetShapeFlag(vnode)
      // 将用户注册的 deactivated 生命周期函数推入 Post 任务队列
      const da = vnode.component!.da
      da && queuePostRenderEffect(da, suspense)
      return
    }
    // 卸载除当前组件外的所有缓存的组件
    unmount(cached)
  })
})

KeepAlive 组件的 setup 钩子返回的是函数,而 setup 钩子返回的函数则会被当做组件的渲染函数。

首先将 pendingCacheKey 重置为 null 。

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

const KeepAliveImpl: ComponentOptions = {
  setup(props: KeepAliveProps, { slots }: SetupContext) {
    // ...
    // setup 返回的函数会作为组件的渲染函数使用
    return () => {
      pendingCacheKey = null
    }
  }
}

如果 KeepAlive 组件没有直接子节点,则直接返回 null 。

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

if (!slots.default) {
  return null
}

如果直接子节点不止 1 个,在 DEV 环境下则会给出警告,并直接返回子组件,不走缓存的逻辑。在生产环境虽会免去警告,但也会直接返回子组件,不走缓存逻辑。 KeepAlive 只能有 1 个活跃的直接子组件在 Vue 的官方文档中也有说明。

pic2.png

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

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 组件的虚拟 DOM

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

let vnode = getInnerChild(rawVNode)

获取组件虚拟 DOM 的 type 属性,type 属性表示组件实例的具体类型,可以使用 type 属性得到用户定义组件时为组件设置的名字。

注意 KeepAlive 组件的直接子组件是异步组件的话(isAsyncWrapper(vnode) 为 true),则需要取 type 对象上的 __asyncResolved 属性,因为对于异步组件来说,__asyncResolved 属性存储了异步加载器加载成功后返回的组件。

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

const comp = vnode.type as ConcreteComponent
const name = getComponentName(
  isAsyncWrapper(vnode)
    ? (vnode.type as ComponentOptions).__asyncResolved || {}
    : comp
)
// packages/runtime-core/src/apiAsyncComponent.ts

// 判断传入的虚拟 DOM 是否为 AsyncWrapper
export const isAsyncWrapper = (i: ComponentInternalInstance | VNode): boolean =>
  !!(i.type as ComponentOptions).__asyncLoader

有关 Vue3 异步组件更多相关信息可见笔者写的另一篇文章:

从 KeepAlive 组件的 props 中取得 include 、exclude 和 max 。任何与 include 不匹配的组件或任何与 exclude 匹配的组件都不走缓存。

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

const { include, exclude, max } = props

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

取得 KeepAlive 直接子组件的虚拟 DOM 中的 key 属性,使用该 key 取得缓存对象(cache)中的组件缓存。

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

const key = vnode.key == null ? comp : vnode.key
const cachedVNode = cache.get(key)

将 KeepAlive 直接子组件的虚拟 DOM 中的 key 赋值给 pendingCacheKey 变量。

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

pendingCacheKey = key

如果在缓存对象中可以找到当前 KeepAlive 直接子组件相关 key 的缓存,则将缓存虚拟 DOM 中的 elcomponent 属性赋值给 KeepAlive 直接子组件虚拟 DOM 的 elcomponent 属性。因为在 Updated 生命周期时,会用最新的虚拟 DOM 更新缓存的内容,因此缓存中的虚拟 DOM 是最新的。

然后从 keys 集合中删除对应的 key ,并将 key 重新 add 进 keys 集合中,这样就保证了最新的 key 位于集合 keys 的最后面。

当缓存的组件实例数超过 max 值时,则删除 keys 集合中第一个 key 。因为新的缓存 key 都是在 keys 的结尾添加的,所以当缓存的个数超过 max 的时候,就从最前面开始删除,符合 最近最少使用的算法思想。

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

if (cachedVNode) {
  // copy over mounted state
  vnode.el = cachedVNode.el
  vnode.component = cachedVNode.component
  // 给虚拟 DOM 打上 COMPONENT_KEPT_ALIVE 标识,
  // 防止其走真正的挂载逻辑
  vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE
  // 保证 key 在 keys 集合中最新的位置
  keys.delete(key)
  keys.add(key)
} else {
  keys.add(key)
  // 缓存的组件实例数超出了 max 值
  if (max && keys.size > parseInt(max as string, 10)) {
    // 删除 keys 集合中第一个 key 的组件缓存
    pruneCacheEntry(keys.values().next().value)
  }
}
// packages/runtime-core/src/components/KeepAlive.ts

// 在 Updated 生命周期中更新缓存,保证缓存对象中的组件虚拟 DOM 是最新的
onUpdated(cacheSubtree)

通过或运算,将组件标识为被 KeepAlive 包裹的组件。下次可通过与(&ShapeFlags.COMPONENT_KEPT_ALIVE 是否为 true 来判断是否为被 KeepAlive 包裹的组件。

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

vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE

在渲染器中,通过与(&ShapeFlags.COMPONENT_KEPT_ALIVE 是否为 true 来判断该组件是否为被 KeepAlive 包裹的组件,如果是被 KeepAlive 包裹的组件,则不走真正的挂载逻辑。而是执行 activate 函数,将组件实例从隐藏容器移入原容器。

// 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
) => {
  if (n1 == null) {
    if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
      // 执行与 KeepAlive 相关的逻辑,将组件实例从隐藏容器移入原容器
      ;(parentComponent!.ctx as KeepAliveContext).activate(
        n2,
        container,
        anchor,
        isSVG,
        optimized
      )      
    } else {
      // 不是 KeepAlive 包裹的组件,执行组件真正的挂载逻辑
      mountComponent()
    }
  } else {
    // 更新组件
  }
}

将 KeepAlive 直接子组件的虚拟 DOM 打上 COMPONENT_SHOULD_KEEP_ALIVE 标识。然后返回 KeepAlive 直接子组件的虚拟 DOM 。从这里可以看出,KeepAlive 组件渲染的内容是他的直接子组件。KeepAlive 组件提供的是缓存的作用

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

const KeepAliveImpl: ComponentOptions = {
  // ...
  setup(props: KeepAliveProps, { slots }: SetupContext) {
    // ...
    return () => {
      // ...
      vnode.shapeFlag |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
      current = vnode
      return vnode
    }
  }
}

当渲染器卸载组件的时候,通过与(&ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE 判断是否为被 KeepAlive 包裹的组件,如果是被 KeepAlive 包裹的组件,则执行 deactivate 函数将组件移入隐藏容器,不走真正的组件卸载逻辑。

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

const unmount: UnmountFn = (
  vnode,
  parentComponent,
  parentSuspense,
  doRemove = false,
  optimized = false
) => {
  const {
    shapeFlag
  } = vnode
  if (shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
    // 被 KeepAlive 包裹的组件,执行 deactivate 函数,将组件
    // 移入隐藏容器
    ;(parentComponent!.ctx as KeepAliveContext).deactivate(vnode)
    return
  }
  // 卸载组件
}

总结

KeepAlive 组件的实现原理就是缓存管理和特殊的卸载/挂载逻辑。KeepAlive 组件使用 include 、exclude 和 max 为用户提供更加灵活的缓存控制。

KeepAlive 组件内部会有个隐藏容器,KeepAlive 包裹的组件的卸载不是真正的卸载,而是把组件实例移入缓存容器。KeepAlive 包裹的组件的挂载也不是真正的挂载,而是把组件实例从隐藏容器移入原容器中。


网站公告

今日签到

点亮在社区的每一天
去签到