Vue源码分析之keep-alive(一)

303 阅读4分钟

这是我参与2022首次更文挑战的第3天,活动详情查看:2022首次更文挑战

前言

keep-alive是Vue的内置组件。

官方文档这样介绍:keep-alive包裹动态组件时,会缓存不活动的组件实例,而不是销毁它们。它是一个抽象组件,自身不会被渲染为DOM元素,也不会出现在组件的父组件链中。

keep-alive主要是用来保留组件状态或避免重新渲染。

接下来就来具体看下keep-alive是怎么实现的:

基本用法

下边是官方提供的常用的几种使用方法:

image.png

keep-alive还提供了三个props:

  • include:名称匹配的组件被缓存,可以用逗号分割的字符串,正则表达式或数组。
  • exclude:名称匹配的组件不会被缓存,可以用逗号分割的字符串,正则表达式或数组。
  • max:最多缓存多少组实例。一旦超过这个数字,在新实例创建之前,已缓存组件中最久没有被访问的实例会被销毁。

源码分析

定义在src/core/components/keep-alive.js

export default {
  name: 'keep-alive',
  abstract: true,

  props: {
    include: patternTypes,
    exclude: patternTypes,
    max: [String, Number]
  },

  created () {
    this.cache = Object.create(null)
    this.keys = []
  },

  destroyed () {
    for (const key in this.cache) {
      pruneCacheEntry(this.cache, key, this.keys)
    }
  },

  mounted () {
    this.$watch('include', val => {
      pruneCache(this, name => matches(val, name))
    })
    this.$watch('exclude', val => {
      pruneCache(this, name => !matches(val, name))
    })
  },

  render () {
    ...
  }
}

整体来看keep-alive就是一个Vue组件,该组件没有写template,而是自定义了render函数。

abstract

看到keep-alive组件首先设置了abstract属性为true,在Vue文档中并没有看到对abstract这个属性的介绍,那这个属性是用来干什么的呢??

其实是为了在建立组件的父子关系时忽略keep-alive组件。具体代码定义在src/core/instance/lifecycle.js

export function initLifecycle (vm: Component) {
  const options = vm.$options

  // locate first non-abstract parent
  let parent = options.parent
  if (parent && !options.abstract) {
    while (parent.$options.abstract && parent.$parent) {
      parent = parent.$parent
    }
    parent.$children.push(vm)
  }

  vm.$parent = parent
  
  ...
}

Vue官方文档中也写到keep-alive是一个抽象组件:不会渲染为一个DOM元素,也不会出现在组件的父组件链中。

created

created () {
  this.cache = Object.create(null)
  this.keys = []
}

在keep-alive组件的created钩子定义了两个数据属性来实现组件缓存:

  • cache: 保存已经创建过的组件vnode

  • keys: 保存所有创建的vnode的key

destroyed

destroyed钩子函数内部,for...in遍历cache对象,依次执行pruneCacheEntry函数去移除缓存中的vnode

destroyed () {
  for (const key in this.cache) {
    pruneCacheEntry(this.cache, key, this.keys)
  }
}

具体来看下pruneCacheEntry的实现:

pruneCacheEntry

function pruneCacheEntry (
  cache: VNodeCache,
  key: string,
  keys: Array<string>,
  current?: VNode
) {
  const cached = cache[key]
  if (cached && (!current || cached.tag !== current.tag)) {
    cached.componentInstance.$destroy()
  }
  cache[key] = null
  remove(keys, key)
}
  • 首先通过key找到缓存数组中对应的组件vnode

  • 对组件vnode的实例执行destroy方法去销毁该实例(destroy方法后边具体看)

  • 将缓存对象中当前key对应的vnode值置为null

  • 执行remove方法将key从keys数组中移除

remove

在移除key的时候调用了个remove方法,来看下这个方法的实现,定义在src/shared/util.js

export function remove (arr: Array<any>, item: any): Array<any> | void {
  if (arr.length) {
    const index = arr.indexOf(item)
    if (index > -1) {
      return arr.splice(index, 1)
    }
  }
}

工具函数很简单,就是使用splice方法移除数组中的对应项。

mounted

mounted函数主要是监听了include和exclude属性。当发生变化时,执行prunCache函数。

mounted () {
  this.$watch('include', val => {
    pruneCache(this, name => matches(val, name))
  })
  this.$watch('exclude', val => {
    pruneCache(this, name => !matches(val, name))
  })
}

看一下prunCache函数:

prunCache

function pruneCache (keepAliveInstance: any, filter: Function) {
  const { cache, keys, _vnode } = keepAliveInstance
  for (const key in cache) {
    const cachedNode: ?VNode = cache[key]
    if (cachedNode) {
      const name: ?string = getComponentName(cachedNode.componentOptions)
      if (name && !filter(name)) {
        pruneCacheEntry(cache, key, keys, _vnode)
      }
    }
  }
}
  • 该方法首先遍历cache对象,获取对应组件vnode的name值

  • 调用matches方法,看是否满足过滤条件

  • 如果不满足过滤条件,则直接调用pruneCacheEntry方法删除对应项。这次调用pruneCacheEntry函数时与destroy钩子函数调用有个不一样的点是传了第四个参数(当前keep-alive组件)。

接下来看一下用来过滤的matches方法:

matches

我们知道include和exclude属性接收三种类型:数组,逗号分割的字符串,正则表达式。matches方法就是针对这三种类型来做判断的。

function matches (pattern: string | RegExp | Array<string>, name: string): boolean {
  if (Array.isArray(pattern)) {
    return pattern.indexOf(name) > -1
  } else if (typeof pattern === 'string') {
    return pattern.split(',').indexOf(name) > -1
  } else if (isRegExp(pattern)) {
    return pattern.test(name)
  }
  /* istanbul ignore next */
  return false
}
  • 首先判断匹配类型是不是数组,如果是则判断组件name是否存在数组中

  • 接下来判断是不是字符串,如果是则调用split方法将其转化为数组,再判断name是否在数组中

  • 最后判断是否是正则,如果是直接调用test判断name是否符合正则条件

render

最后就是缓存组件的重点:render函数了

render () {
  const slot = this.$slots.default
  const vnode: VNode = getFirstComponentChild(slot)
  const componentOptions: ?VNodeComponentOptions = vnode && vnode.componentOptions
  if (componentOptions) {
    // check pattern
    const name: ?string = getComponentName(componentOptions)
    const { include, exclude } = this
    if (
      // not included
      (include && (!name || !matches(include, name))) ||
      // excluded
      (exclude && name && matches(exclude, name))
    ) {
      return vnode
    }

    const { cache, keys } = this
    const key: ?string = vnode.key == null
      // same constructor may get registered as different local components
      // so cid alone is not enough (#3269)
      ? componentOptions.Ctor.cid + (componentOptions.tag ? `::${componentOptions.tag}` : '')
      : vnode.key
    if (cache[key]) {
      vnode.componentInstance = cache[key].componentInstance
      // make current key freshest
      remove(keys, key)
      keys.push(key)
    } else {
      cache[key] = vnode
      keys.push(key)
      // prune oldest entry
      if (this.max && keys.length > parseInt(this.max)) {
        pruneCacheEntry(cache, keys[0], keys, this._vnode)
      }
    }

    vnode.data.keepAlive = true
  }
  return vnode || (slot && slot[0])
}
  • 首先获取keep-alive的默认插槽,然后调用getFirstComponentChild获取插槽的第一个子节点(keep-alive只处理第一个子元素

  • 获取组件参数vnode.componentOptions赋值给componentOptions变量

  • 如果componentOptions不存在,则直接返回该节点(slot部分的实现后边单独来看)

  • componentOptions存在,则首先获取传入keep-alive的子组件的名称,如果没设置名称,则直接拿tag

  • 接下来根据include和exclude属性来判断传入的组件是否满足条件,如果不满足,则直接返回该vnode

  • 如果满足条件,则获取被包裹的组件的key(会先判断vnode.key值是否为空,如果为空则用组件的cid+tag来代替key)

  • 在缓存对象中查找该key对应的组件vnode。如果存在,则获取对应的实例,并且从keys中将该key移除,然后再重新添加到最后边。(之所以要先删除再添加主要是为了提高该key的存活,LRU缓存策略)

  • 如果该key在缓存对象中不存在,则添加对应的vnode到缓存对象,同时添加key到keys数组中。最后判断keys的长度是不是超过了max限制,如果超过了,则删除缓存中的第一个(这也是为什么每次缓存命中都要先删除key再添加的原因)。