Vue - The Good Parts: keep-alive

avatar
@滴滴出行

前言

Vue 中有一个特别好用的组件 keep-alive 组件,我们在很多场景下,都是可以借助于这个组件来提升我们的产品体验,基本上0成本实现缓存效果。用的最多的场景就是和路由搭配,缓存不怎么更新的路由页面。

那这么好用的功能,背后是怎么实现的,有哪些可以学习的,一起来分析下。

正文分析

What

来自官网的介绍 cn.vuejs.org/v2/api/#kee…

image2021-6-23_17-18-35.png

可以看到,他的使用,更多的是跟随者动态组件一起使用。最核心的就是缓存组件实例,以提升性能。在官网上也有一个tab切换的示例,就是他的一种使用场景 cn.vuejs.org/v2/guide/co…

How

既然是一个内置组件,那么它肯定也就是一个按照组件来定义的,Vue 中核心的实现在 github.com/vuejs/vue/b… 这里

export default {
  // 组件名字
  name: 'keep-alive',
  // 抽象组件 这是一个没有对外暴露的组件声明属性
  // 作用的话,就是不渲染DOM 也不会出现在父组件的children中
  abstract: true,
 
  props: {
    include: patternTypes,
    exclude: patternTypes,
    max: [String, Number]
  },
 
  methods: {
    cacheVNode() {
      const { cache, keys, vnodeToCache, keyToCache } = this
      // 存在需要缓存的节点
      if (vnodeToCache) {
        const { tag, componentInstance, componentOptions } = vnodeToCache
        // 缓存到 cache 对象中
        cache[keyToCache] = {
          name: getComponentName(componentOptions),
          tag,
          componentInstance,
        }
        keys.push(keyToCache)
        // 判断是否超出了 max 最大缓存实例数
        // prune oldest entry
        if (this.max && keys.length > parseInt(this.max)) {
          // 如果超出了 就销毁超出的
          pruneCacheEntry(cache, keys[0], keys, this._vnode)
        }
        this.vnodeToCache = null
      }
    }
  },
 
  created () {
    // 初始化缓存对象
    this.cache = Object.create(null)
    this.keys = []
  },
 
  destroyed () {
    // 销毁的时候 所有实例全部销毁
    for (const key in this.cache) {
      pruneCacheEntry(this.cache, key, this.keys)
    }
  },
 
  mounted () {
    this.cacheVNode()
    // 如果这些有更新 一样需要再次 check 一遍所有的缓存实例 是否应该缓存
    this.$watch('include', val => {
      pruneCache(this, name => matches(val, name))
    })
    this.$watch('exclude', val => {
      pruneCache(this, name => !matches(val, name))
    })
  },
 
  updated () {
    // 更新钩子 再次缓存
    this.cacheVNode()
  },
 
  render () {
    // 重点 render 的实现
    // 可以获得组件内的默认内容 其实也就是 默认插槽内容
    const slot = this.$slots.default
    // 找到里边第一个组件节点
    const vnode: VNode = getFirstComponentChild(slot)
    // 通过 vnode 节点可以获得组件配置项
    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
        // 先删除掉这个 key 然后在push 保证这个 key 是新鲜的 超限check的时候 有用
        // make current key freshest
        remove(keys, key)
        keys.push(key)
      } else {
        // delay setting the cache until update
        // 设置好 vnodeToCache 当更新的时候 再去缓存 参考 updated 钩子中的逻辑
        this.vnodeToCache = vnode
        this.keyToCache = key
      }
 
      vnode.data.keepAlive = true
    }
    // 返回的就是内部的节点
    return vnode || (slot && slot[0])
  }
}

上边大概就是核心的一个流程:

  • 默认进来,取得当前渲染的 vnode
  • 然后进入 mounted 钩子,缓存上
  • 当有更新的时候,再次调用 render,设置 vnodeToCache
  • 到 updated 钩子,再次缓存上
  • 下次如果命中缓存,直接用现有实例即可

你会发现要想很好的理解上述过程,要很好的理解 Vue 的生命周期,可以参考 cn.vuejs.org/v2/guide/in… 图如下:

lifecycle.png

此外,还有很多关于 vnode 上的属性,如:componentOptions、key、data、componentInstance、tag 等,以及相配合的在 Vue 中是如何识别和运用这些属性的:如何不创新新的实例,如何触发新的生命周期钩子 activated deactivated 等,如果你对所有的逻辑细节比较感兴趣,可以参考黄老师的 ustbhuangyi.github.io/vue-analysi…

Why

我们可以理解为 Vue 为什么提供了内置组件 keep-alive?

在前言的部分,我们也讲了在实际场景中,还是会遇到不少缓存组件的情况,在遇到路由场景的时候更甚。

那 Vue 的一个理念就是对开发者很友好,框架做了很多事情,使得开发者可以专注于自身的逻辑开发工作,这也是为什么全球会有那么多开发者钟爱它的原因之一。那从这个点出发,因为有这么多的需求,所以 Vue 也就提供了这么好用的内置组件也就不难理解了。

总结

我们可以看出,keep-alive 的组件实现并不复杂,全部文件也就 150 行上下,但是功能却很强大,所有的功能参考 cn.vuejs.org/v2/api/#kee… 。那么从这个组件上,我们可以学到些什么东西,有什么可以借鉴的吗?

Vue生命周期

组件定义虽然不多,但是却是用到了 Vue 中绝大多数的生命周期钩子,且是我们也能经常使用到的:created、mounted、updated、destroyed。这也是我们写好组件的基石,正确理解并运用他们,知道他们的关系和过程,以及在对应的生命周期中适合做什么样的事情。

额外提一点,在 created 生命周期钩子函数中,我们看到了如何给实例定义一些非响应式的对象,可以在 created 中直接 this.xxx = xxxx 的这种方式,而不是习惯性的把这些属性放到 data() 中去定义(会变为响应式对象,增加额外的开销),这种技巧值得我们学习和应用。

Vue手写render

在这里虽然十分简单,直接返回了默认插槽内容的节点,但是我们可以从这个点出发,在一些特殊场景,还是需要我们去手写 render 的,这部分也是需要我们去熟练运用的,详细的可以参考官网 cn.vuejs.org/v2/guide/re… 关于 render 函数的使用以及createElement、数据对象。

keys设计

keys 是一个数组,我们知道 keep-alive 还提供了一个能力:max 这个 prop,指定了最多可以缓存多少组件实例,一旦这个数字达到了,在新实例被创建之前,已缓存组件中最久没有被访问的实例会被销毁掉。注意这里的关键:最久没有被访问的实例会被销毁掉

这个是怎么做到的,排序吗?不用那么麻烦,通过上边的分析我们知道Vue中采用了一个很巧妙的做法:

remove(keys, key)
keys.push(key)

简单来说,将 keys 中现在对应的 key 删除掉,然后把这个 key 再 push 到数组最后即可。

通过这种方式,就可以保障了 keys 这个数组中最尾部的元素就是最新鲜的元素,最开始的元素就是最不新鲜的,在我们的场景中就可以对应为:最近被访问的实例。

// prune oldest entry
if (this.max && keys.length > parseInt(this.max)) {
  pruneCacheEntry(cache, keys[0], keys, this._vnode)
}

可以看到实际代码也是这样的,当超出 max 的时候,就是销毁的keys[0]对应的组件实例。

内存泄漏

在 destroyed 中,销毁了所有的实例,以释放在 cache 对象中缓存的对象们,这个就是对内存的管理,防止内存泄漏问题。这个是明面的内存销毁逻辑,大家只要注意了就不会遇到内存泄漏的问题了。

但是这个不是绝对的,我们看一下最新的这个PR github.com/vuejs/vue/p… ,我们发现解决了两个内存泄漏的 issue,直观看起来是不应该存在内存泄漏的才对。

这里就惊醒我们,要注意内存泄漏问题,他可能会是由于我们不经意之间写的代码所导致的。我们需要做到怎么利用开发者工具:

  • 如何知道内存泄漏了
  • 来定位&解决内存泄漏问题

其他小Tips

  • 手工销毁组件实例,调用实例的 $destroy()
  • 访问 $slots.default 获得默认子节点们
  • vnode 上可以访问到 componentOptions以及 componentInstance,这俩还是很有用的

滴滴前端技术团队的团队号已经上线,我们也同步了一定的招聘信息,我们也会持续增加更多职位,有兴趣的同学可以一起聊聊。