写点什么

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

用户头像
梁龙先森
关注
发布于: 2021 年 04 月 04 日
Vue3源码 | 读懂keep-alive组件以及缓存机制

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

基础用法

以下是 <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 KeepAliveContextsharedContext.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 个属性 include,exclude,max。其中 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 函数是监听 include、exclude 值变化时做出对应反应,即去删除对应的缓存数据。

卸载过程

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


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

  2. 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 时,重置 vnode 的 ShapeFlag,让它不被当做是 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 缓存机制。

发布于: 2021 年 04 月 04 日阅读数: 116
用户头像

梁龙先森

关注

无情的写作机器 2018.03.17 加入

vite原理/微前端/性能监控方案...,正在来的路上... 最近太忙...

评论

发布
暂无评论
Vue3源码 | 读懂keep-alive组件以及缓存机制