Vue3 KeepAlive组件原理分析

1,610 阅读8分钟

前言

知其然而知其所以然,优秀的工程师不仅要能熟练的使用框架,还要了解其底层是如何实现的。本文主要探究Vue3源码中内置KeepAlive组件实现原理。

KeepAlive 是一个抽象组件,它并不会渲染成一个真实的 DOM,只会渲染内部包裹的子节点,并且让内部的子组件在切换的时候,不会走一整套递归卸载和挂载 DOM的流程,从而优化了性能。如果你要了解使用方法,官网已经介绍的很详细了,你可以点击查看 Vue3 KeepAlive

实现原理

KeepAlive 组件在源码实现的是一个对象,其实现主要是组件的渲染、组件缓存的处理、props三个参数的处理和组件卸载过程。

// packages/runtime-core/src/components/KeepAlive.ts
const KeepAliveImpl: ComponentOptions = {
  name: `KeepAlive`,
  // keep-alive 组件接收的三个参数
  props: {
    include: [String, RegExp, Array],
    exclude: [String, RegExp, Array],
    max: [String, Number]
  },
  ...

  setup(props: KeepAliveProps, { slots }: SetupContext) {
    // 组件卸载逻辑
    ...
    // 返回渲染函数
    return () => {
      ...
       // 缓存逻辑处理
       // props 参数处理逻辑
       // 组件初始化逻辑
    }
  }
}

上面示例中,当 setup 函数返回一个函数,这个函数就是组件的渲染函数。

组件渲染

直接看下面源码:

// packages/runtime-core/src/components/KeepAlive.ts
const KeepAliveImpl: ComponentOptions = {
  name: `KeepAlive`,
  ...
  setup(props: KeepAliveProps, { slots }: SetupContext) {
    ...
    // 返回渲染函数
    return () => {
      pendingCacheKey = null

      if (!slots.default) {
        return null
      }
      // 获取keep-alive 包裹的 children 元素
      const children = slots.default()
      const rawVNode = children[0]
      if (children.length > 1) {
        // keep-alive 只渲染单个子节点,大于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))
      ) {
        // 不是vnode节点 或者自定义组件和SUSPENSE 则返回
        current = null
        return rawVNode
      }
      ...
      current = vnode
      return rawVNode
    }
  }
}

从上面可以看出,KeepAlive 渲染的 vnode 就是子节点 children 的第一个元素,它是函数的返回值。因此我们说 KeepAlive 是抽象组件,它本身不渲染成实体节点,而是渲染它的第一个子节点。

缓存处理

KeepAlive 组件缓存的东西是 DOM,因为渲染 DOM 是 patch 递归的过程,也是最损耗性能的。 KeepAlive 组件注入了两个钩子函数,onMounted 和 onUpdated,在这两个钩子函数内部都执行了 cacheSubtree 函数来做缓存:

...
// packages/runtime-core/src/components/KeepAlive.ts
const cacheSubtree = () => {
  // fix #1621, the pendingCacheKey could be 0
  if (pendingCacheKey != null) {
    cache.set(pendingCacheKey, getInnerChild(instance.subTree))
  }
}
onMounted(cacheSubtree)
onUpdated(cacheSubtree)

pendingCacheKey 是在 KeepAlive 的 render 函数中才会被赋值,所以 KeepAlive 首次进入 onMounted 钩子函数的时候是不会缓存的。然后 KeepAlive 执行 render 的时候,pendingCacheKey 会被赋值为 vnode.key。

所以当组件切换会触发组件的重新渲染,进而也触发了 KeepAlvie 组件的重新渲染,在组件重新渲染前,会执行 onUpdate 对应的钩子函数,也就再次执行到 cacheSubtree 函数中。

这个时候 pendingCacheKey 对应的是 A 组件 vnode 的 key,instance.subTree 对应的也是 A 组件的渲染子树,所以 KeepAlive 每次在更新前,会缓存前一个组件的渲染子树。

当我们再次切换回原来组件,会再次触发KeepAlvie 组件的重新渲染,当然此时执行 onUpdate 钩子函数缓存的就是 B 组件的渲染子树了。

接着再次执行 KeepAlive 组件的 render 函数,此时就可以从缓存中根据 A 组件的 key 拿到对应的渲染子树 cachedVNode 的了,然后执行如下逻辑:

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!)
    }
    // 避免 vnode 节点作为新节点被挂载
    vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE
    // 让这个 key 始终新鲜
    keys.delete(key)
    keys.add(key)
} else {
    keys.add(key)
    // 删除最久不用的 key
    if (max && keys.size > parseInt(max as string, 10)) {
      pruneCacheEntry(keys.values().next().value)
    }
}

有了缓存的渲染子树后,我们就可以直接拿到它对应的 DOM 以及组件实例 component,赋值给 KeepAlive 的 vnode,并更新 vnode.shapeFlag,以便后续 patch 阶段使用。patch 主要由 processComponent 方法实现:源码如下:

  // packages/runtime-core/src/renderer.ts
  const processComponent = (n1: VNode | null,n2: VNode, ...) => {
    n2.slotScopeIds = slotScopeIds
    if (n1 == null) {
      // 处理 keep-alive
      if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
        ;(parentComponent!.ctx as KeepAliveContext).activate(
          n2,
          container,
          anchor,
          isSVG,
          optimized
        )
      } else {
        // 挂载组件
        mountComponent(n2,...)
      }
    } else {
      updateComponent(n1, n2, optimized)
    }
  }

KeepAlive 首次渲染某一个子节点时,和正常的组件节点渲染没有区别,但是有缓存后,由于标记了 shapeFlag,所以在执行processComponent函数时会走到处理 KeepAlive 组件的逻辑中,执行 KeepAlive 组件实例上下文中的 activate 函数,我们来看它的实现:

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

可以看到,由于此时已经能从 vnode.el 中拿到缓存的 DOM 了,所以可以直接调用 move 方法挂载节点,然后执行 patch 方法更新组件,以防止 props 发生变化的情况。接下来,就是通过 queuePostRenderEffect 的方式,在组件渲染完毕后,执行子节点组件定义的 activated 钩子函数。

props 参数处理

KeepAlive 一共支持了三个 Props,分别是 include、exclude 和 max。

// packages/runtime-core/src/components/KeepAlive.ts
props: {
    include: [String, RegExp, Array],
    exclude: [String, RegExp, Array],
    max: [String, Number]
}

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
}

如果子组件名称不匹配 include 的 vnode ,以及子组件名称匹配 exclude 的 vnode 都不应该被缓存,而应该直接返回。

由于 props 是响应式的,在 include 和 exclude props 发生变化的时候也应该有相关的处理逻辑,如下:

// packages/runtime-core/src/components/KeepAlive.ts
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 }
)

监听的逻辑也很简单,当 include 发生变化的时候,从缓存中删除那些 name 不匹配 include 的 vnode 节点;当 exclude 发生变化的时候,从缓存中删除那些 name 匹配 exclude 的 vnode 节点。

除了 include 和 exclude 之外,KeepAlive 组件还支持了 max prop 来控制缓存的最大个数。

// packages/runtime-core/src/components/KeepAlive.ts
if (cachedVNode) {
    ...
  } else {
    keys.add(key)
    // prune oldest entry
    if (max && keys.size > parseInt(max as string, 10)) {
      pruneCacheEntry(keys.values().next().value)
    }
  }

由于新的缓存 key 都是在 keys 的结尾添加的,所以当缓存的个数超过 max 的时候,就从最前面开始删除。

组件卸载

组件的卸载会执行 unmount 方法,其中有一个关于 KeepAlive 组件的逻辑,如下:

// packages/runtime-core/src/renderer.ts
const unmount: UnmountFn = (vnode,parentComponent,parentSuspense,doRemove = false,optimized = false) => {
  ...
  if (shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
    ;(parentComponent!.ctx as KeepAliveContext).deactivate(vnode)
    return
  }
   ...
}

如果 shapeFlag 满足 KeepAlive 的条件,则执行相应的 deactivate 函数,它的定义如下:

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

函数首先通过 move 方法从 DOM 树中移除该节点,接着通过 queuePostRenderEffect 的方式执行定义的 deactivated 钩子函数。

当 KeepAlive 所在的组件卸载时,由于卸载的递归特性,也会触发 KeepAlive 组件的卸载,在卸载的过程中会执行 onBeforeUnmount 钩子函数,如下:

// packages/runtime-core/src/components/KeepAlive.ts
onBeforeUnmount(() => {
  cache.forEach(cached => {
    const { subTree, suspense } = instance
    const vnode = getInnerChild(subTree)
    if (cached.type === vnode.type) {
      // 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)
  })
})

它会遍历所有缓存的 vnode,并且比对缓存的 vnode 是不是当前 KeepAlive 组件渲染的 vnode。

如果是的话,则执行 resetShapeFlag 方法,它的作用是修改 vnode 的 shapeFlag,不让它再被当作一个 KeepAlive 的 vnode 了,这样就可以走正常的卸载逻辑。接着通过 queuePostRenderEffect 的方式执行子组件的 deactivated 钩子函数。

如果不是,则执行 unmount 方法重置 shapeFlag 以及执行缓存 vnode 的整套卸载流程。

写在最后

最后总结一下KeepAlive组件的实现原理:

  • KeepAlive 组件渲染的 vnode 就是子节点 children 的第一个元素,它是函数的返回值。
  • KeepAlive 组件注入了两个钩子函数,onMounted 和 onUpdated,在这两个钩子函数内部都执行了 cacheSubtree 函数来做缓存。而钩子函数早于render 函数执行,所以切换的时候会缓存上一个组件,之后 patch 过程调用 activate 函数直接使用缓存vnode挂载。
  • KeepAlive 一共支持了三个 Props,分别是 include、exclude 和 max。如果子组件名称不匹配 include 的 vnode ,以及子组件名称匹配 exclude 的 vnode 都不应该被缓存,而应该直接返回。缓存 key 都是在 keys 的结尾添加的,所以当缓存的个数超过 max 的时候,就从最前面开始删除。
  • 组件的卸载会执行 unmount 方法,该方法直接调用 KeepAlive 的 deactivate 移除 DOM,当 KeepAlive 所在的组件卸载时,由于卸载的递归特性,也会触发 KeepAlive 组件的卸载,在卸载的过程中会执行 onBeforeUnmount 钩子函数循环卸载节点。

PS:【想要快速搭建自己的前端静态博客,欢迎查阅Vuepress 快速搭建博客--一款你值得拥有的博客主题。】

参考文档

Vue.js 3.0 核心源码内参