从 keep-alive 源码掌握 LRU Cache

896 阅读11分钟

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

前言

在进入本文正题之前,我们先来看一个问题:

如果你的系统有很多页面需要被缓存,但考虑到内存问题,最多只能缓存10个页面,你会怎么设计缓存的算法?

如果是我被问到这个问题,就卡壳了,我可能会说,用队列,先进先出。

const queue = ['页面1', '页面2', ... ,'页面10']    假设新进来页面11,就淘汰掉页面1

提问者说:可以这么做,但还有更好的解决方法。

我又说道:那就用一个对象把每个页面被访问的次数存起来,每次进来新的就把被访问得少的淘汰掉。

const obj = {                假设新进来页面11,就淘汰掉页面1
  '页面1': 1,
  '页面2': 5,
  '页面3': 9,
  ...
  '页面10': 15,
}

提问者说:这个想法挺好的,就是内存开销有点大,还有更好的解决方法。

我:我想不到别的办法了。

提问者说:你用过 keep-alive 吗?keep-alive 不就是处理缓存的吗,对吧。

我:对呀!keep-alive 就是用来缓存组件的,可以保存组件状态,避免重新渲染,提高页面性能。

提问者:是的,那你知道 keep-alive 的原理吗?keep-alive 内部肯定也做了一些优化吧,不然缓存的组件多了,内存也会溢出的。

我:。。。

提问者:其实keep-alive 内部用的是 LRU Cache,那你知道什么是 LRU Cache 吗?

我:。。。

提问者:Vue3 的源码里,不仅是 keep-alive,编译单文件组件(compiler-sfc)也用到了 LRU Cache 哦

我:对不起,我太菜了,啥也不知道。

提问者:不不不,没关系,给你个维基百科的链接,缓存算法可是有十几种呢,平时开发量力而为就好,能掌握一种 LRU,也能在遇到类似问题的时候有点思路。

Cache_replacement_policies

我:我就一小前端,你高看我了啊,算了,既然 vue3 源码里都用到了,就学一下 LRU 吧。

LRU Cache

LRU Cache 是什么?

LRU,全称 Least recently used,也就是最近最少使用,是一种缓存淘汰策略。

计算机的内存空间是有限的,如果内存满了就要删除一些内容,给新内容腾空间。

但问题是,删除哪些内容呢?我们肯定希望删掉那些不怎么用的数据,而把经常用的数据继续留着,方便之后继续使用。

LRU Cache 认为缓存里最近最少使用的数据,是不重要的,就要先被删除掉。

生活中,我们每天都在使用 LRU Cache,不信你看下面这个例子。

手机后台应用管理

我们知道,手机上的应用都可以运行在后台。

  • 每打开一个新应用,就会把新应用放到后台列表的第一位。
  • 如果一个应用正在后台运行中,被打开,那么会把这个应用放到后台列表的第一位。
  • 太久没用的应用就会被放到后台列表的末尾。

看下面这个例子:

app.gif

假设你的手机最多只能后台运行3个应用程序。

初始状态,手机没有程序后台运行,依次打开了 keep、b站 和 京东 三个应用,缓存列表中插入3个成员。

每次新打开一个应用,都是把这个应用放到列表的最右边。

keep

keep -> bilibili

keep -> bilibili -> jd

这3个程序在后台运行一段时间,我突然打开keep。

keep -> bilibili -> jd
变为
bilibili -> jd -> keep

keep 从原来的位置移动到列表最右边。

这时,又新打开了一个程序,网易云音乐。

bilibili -> jd -> keep

bilibili -> jd -> keep -> music


            |
bilibili -> | jd -> keep -> music
            |
            
jd -> keep -> music       

由于我们的应用程序最多只能后台运行3个,所以很久没有使用的 b站 被删除了。

上面的过程就是 LRU Cache 的原理。

显然, LRU Cache 会有频繁的插入和删除操作,那么要实现它必然会用到链表这个数据结构,因为链表的插入和删除的时间复杂度为 O(1)。

忘记了链表插入和删除时间复杂度的同学,我在 写给前端开发的链表介绍(js) 这篇文章中,有详细介绍。

用 js 实现 LRU Cache

在 JS 中,没有实现链表这一数据结构,但是 JS 中的 Iterator(遍历器)跟链表非常相似,可以用 Map 这个数据结构来实现 LRU Cache。

  • 使用 Map.keys() 方法返回键名的遍历器。
  • 使用遍历器的 next() 方法,可以指向遍历器的第一个成员。

用上面的手机后台应用的例子,我们来测试一下 Map 这个数据结构:

// keep -> b站 -> jd -> music

const cache = new Map()

cache.set('keep', 'keep')
cache.set('b站', 'b站')
cache.set('jd', 'jd')
cache.set('music', 'music')

console.log('cache :>> ', cache)
console.log('cache.keys() :>> ', cache.keys())             
console.log('cache.keys().next() :>> ', cache.keys().next())
console.log('cache.keys().next().value :>> ', cache.keys().next().value)

image.png

有了这样的基础,我们就可以尝试来实现一个 LRU Cache。

class LRUCache {
  constructor (capacity) {
    this.cache = new Map()
    this.max = capacity                    // max 为缓存最大容量
  }

  get (key) {                              // 获取方法
    if (this.cache.has(key)) {             // 如果缓存列表里有成员,就把成员移动到遍历器最新
      const val = this.cache.get(key)     
      this.cache.delete(key)               // 移动操作是先删除,然后放到最后面
      this.cache.set(key, val)
      return val
    }
    return -1
  }

  put (key, value) {                                    // 变更方法
    if (this.cache.has(key)) {                          // 有成员就把成员移动到遍历器最新
      this.cache.delete(key)
    } else if (this.cache.size >= this.max) {           // 超过容量就删除遍历器第一个成员(最老的成员)
      const firstEle = this.cache.keys().next().value
      this.cache.delete(firstEle)
    }
    this.cache.set(key, value)                          // 移动操作是先删除,然后放到最后面
  }
}

代码写完了,按照上面 gif的流程,我们来测试一下:

const list = new LRUCache(3)                          // 设置最大容量为3
list.put('keep', 'keep')
list.put('b站', 'b站')
list.put('jd', 'jd')                                  // 先依次运行 keep、b站、jd
 
console.log(list.cache.keys())                        // 查看一下程序运行的状况 

list.get('keep')                                      // 运行 keep
console.log(list.cache.keys())

list.put('music', 'music')                            // 新打开 music 应用
console.log(list.cache.keys())

image.png

至此,我们用 JS 实现了一个 LRU Cache。

keep-alive 中 LRU Cache 的应用

说了这么多,keep-alive 和 LRU Cache 有什么关系呢?

我们知道,keep-alive 就是用来缓存组件的,可以保存组件状态,避免重新渲染,提高页面性能。

假设系统中有 1000 个组件需要被缓存,keep-alive 会全部缓存吗?

显然不会,因为浏览器内存是有限的,如果来多少个组件缓存多少个组件,内存早就溢出了。

keep-alive 中用到的正是 LRU Cache。

vue2 keep-alive 源码分析

要去分析 keep-alive 的具体实现还是比较繁琐,我们重点看 keep-alive 中 LRU Cache 的实现。

keep-alive 其实就是一个组件,在 created 钩子里定义了 this.cache 和 this.keys,用来缓存已经创建过的 vnode(虚拟dom)。

created () {
  this.cache = Object.create(null)     // 缓存虚拟dom
  this.keys = []                       // 缓存虚拟dom的key集合
},

props 中定义 max,表示缓存组件的数目上限,超出上限就会采用 LRU 淘汰策略。

props: {
    ...
    max: [String, Number]
  },

渲染时,如果命中缓存,则直接从缓存中拿 vnode 的组件实例,并且重新调整了 key 的顺序放在了最后一个;否则触发 updated 生命周期,执行 cacheVNode 函数,把 vnode 设置进缓存

render() {
  ...
      const { cache, keys } = this
      const key: ?string = vnode.key == null      // 定义组件的缓存key,虚拟dom有 key 就用,没有 key 就创建一个
      ? componentOptions.Ctor.cid + (componentOptions.tag ? `::${componentOptions.tag}` : '')
      : vnode.key
      if (cache[key]) {                           // 如果已经缓存过该组件
        vnode.componentInstance = cache[key].componentInstance
        remove(keys, key)
        keys.push(key)                           // 重新调整 key 的顺序,放在最后一个
      } else {                                   // 如果没有缓存过该组件
        this.vnodeToCache = vnode                // 触发updated 生命周期,缓存组件
        this.keyToCache = key
      }
  ...
}
updated () {
  this.cacheVNode()
},

在 cacheVNode 方法中, 缓存的vnode长度如果超过了 this.max,就执行 pruneCacheEntry 方法,它的作用是删除缓存中的第一个组件。

cacheVNode() {
  const { cache, keys, vnodeToCache, keyToCache } = this
  if (vnodeToCache) {
    const { tag, componentInstance, componentOptions } = vnodeToCache
    cache[keyToCache] = {
      name: getComponentName(componentOptions),
      tag,
      componentInstance,
    }
    keys.push(keyToCache)
    if (this.max && keys.length > parseInt(this.max)) {  // 超过缓存数限制,将第一个删除
      pruneCacheEntry(cache, keys[0], keys, this._vnode)
    }
    this.vnodeToCache = null
  }
}

除了从缓存中删除外,还要判断如果要删除的缓存组件的 tag 是不是当前渲染组件 tag,如果是,也执行删除缓存的组件实例的 $destroy 方法。

function pruneCacheEntry (
  cache: CacheEntryMap,
  key: string,
  keys: Array<string>,
  current?: VNode
) {
  const entry: ?CacheEntry = cache[key]
  if (entry && (!current || entry.tag !== current.tag)) {  // 如果要删除的缓存组件的 `tag` 是当前渲染组件 `tag`,就销毁
    entry.componentInstance.$destroy()
  }
  cache[key] = null
  remove(keys, key)
}

假设 max 为 4,画个图来回顾下整体的流程:

image.png

  • 缓存未满时新增,组件一个一个地写入 cache 中
  • 缓存已满时新增,删除第一个组件(keys[0]),腾出空间让最新的组件从后面 push
  • 访问已有组件,把已有组件挪到最后面(删除已有组件在原来的位置,再把已有组件从后面 push)

整体分析下来,vue2 中的 keep-alive 的缓存策略和我们之前分析的手机后台应用管理是一模一样的,都是 LRU Cache。

理解了组件的缓存机制,我们再分析一下完整的 keep-alive 代码,打开 src/core/components/keep-alive.js

由于太多了,我就通过注释来标明每一步做什么了。

export default {
  name: 'keep-alive',
  abstract: true,                             // 抽象组件,这样定义之后 keep-alive 不会被渲染成真实DOM

  props: {
    include: patternTypes,                    // 缓存白名单
    exclude: patternTypes,                    // 缓存黑名单
    max: [String, Number]                     // 缓存组件的数目上限,超出上限就会采用 LRU 淘汰策略。
  },

  methods: {
    cacheVNode() {                            // 缓存组件的逻辑在这个函数里
      const { cache, keys, vnodeToCache, keyToCache } = this
      if (vnodeToCache) {
        const { tag, componentInstance, componentOptions } = vnodeToCache
        cache[keyToCache] = {
          name: getComponentName(componentOptions),
          tag,
          componentInstance,
        }
        keys.push(keyToCache)                                // 缓存未满,新增
        // 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)        // 缓存虚拟dom
    this.keys = []                          // 缓存虚拟dom的key集合
  },

  destroyed () {
    for (const key in this.cache) {         // 组件销毁时,删除所有的缓存
      pruneCacheEntry(this.cache, key, this.keys)
    }
  },

  mounted () {
    this.cacheVNode()                                // 初始化调一次 cacheVNode 方法,把组件写入缓存。
    this.$watch('include', val => {                  // 监听白名单变动
      pruneCache(this, name => matches(val, name))   // 根据白名单变动更新缓存的值
    })
    this.$watch('exclude', val => {                  // 监听黑名单变动 
      pruneCache(this, name => !matches(val, name))  // 根据黑名单变动更新缓存的值
    })
  },

  updated () {
    this.cacheVNode()                                // 渲染时如果未命中缓存,会触发 updated 生命周期
  },

  render () {
    const slot = this.$slots.default
    const vnode: VNode = getFirstComponentChild(slot)      // 获取第一个子元素的 vnode
    const componentOptions: ?VNodeComponentOptions = vnode && vnode.componentOptions
    if (componentOptions) {                                
      const name: ?string = getComponentName(componentOptions)  // 判断当前组件的是否在黑名单或白名单中
      const { include, exclude } = this
      if (
        (include && (!name || !matches(include, name))) ||    // 如果配置了白名单,没匹配到当前组件
        (exclude && name && matches(exclude, name))           // 或者配置了黑名单,匹配到了当前组件
      ) {
        return vnode                                          // 就返回 当前组件的vnode
      }

      const { cache, keys } = this                            // 否则就走缓存逻辑
      const key: ?string = vnode.key == null                  // 定义组件的缓存key,虚拟dom有 key 就用,没有 key 就创建一个
        ? componentOptions.Ctor.cid + (componentOptions.tag ? `::${componentOptions.tag}` : '')
        : vnode.key
      if (cache[key]) {                                       // 如果已经缓存过该组件
        vnode.componentInstance = cache[key].componentInstance
        remove(keys, key)
        keys.push(key)                                        // 就重新调整 key 的顺序,放在最后一个
      } else {                                                // 如果没有缓存过该组件
        this.vnodeToCache = vnode                             
        this.keyToCache = key                                 // 就缓存该组件,这里数据更新会触发 updated 生命周期函数,执行 cacheVNode 方法,实现组件的缓存
      }

      vnode.data.keepAlive = true                            // 被keep-alive 组件包裹的组件不会执行正常生命周期的关键,原因可在 src/core/vdom/create-component.js 中找到
    }
    return vnode || (slot && slot[0])                        // 返回 vnode
  }
}

看到现在,我们发现 Vue2 的 keep-alive 是用一个对象和一个数组来实现 LRU Cache 的,和我们刚用 Map 来实现还是不一样的。

我为啥知道可以用 Map 来实现 LRU Cache 呢,因为我看过 Vue3 的 keep-alive源码。

vue3 keep-alive源码分析

在 vue3 中,keep-alive 是用 composition api 实现的,同样,keep-alive 的代码实在太多,我们只看关于 LRU Cache 的部分。

setup 中定义 cache 和 keys,用来缓存已经创建过的 vnode(虚拟dom)。和 Vue2 不同的是,使用了新的数据结构 Map 和 Set

setup() {
  ...
    const cache: Cache = new Map()
    const keys: Keys = new Set()
  ...
}

同样在 props 中定义 max,表示缓存组件的数目上限,超出上限就会采用 LRU 淘汰策略。

props: {
    ...
    max: [String, Number]
  },

渲染时,如果命中缓存,则直接从缓存中拿 vnode 的组件实例,并且重新调整了 key 的顺序放在了最后一个;否则把 vnode 设置进缓存。

如果缓存的vnode长度如果超过了 max,就执行 pruneCacheEntry 方法,它的作用是删除缓存中的第一个组件。

setup() {
  ...
  return () => {
    ...
    const cachedVNode = cache.get(key)
    if (cachedVNode) {                                           // 如果已经缓存过该组件
        vnode.el = cachedVNode.el
        vnode.component = cachedVNode.component                  // 就直接从缓存中拿 `vnode` 的组件实例
        if (vnode.transition) {
          setTransitionHooks(vnode, vnode.transition!)
        }
        vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE
        keys.delete(key)                                         // 重新调整 key 的顺序放在了最后一个
        keys.add(key)
      } else {                                                   // 如果没有缓存该组件
        keys.add(key)                                            // 把该组件设置进缓存
        if (max && keys.size > parseInt(max as string, 10)) {    // 超过最大值,就删除缓存中第一个组件 
          pruneCacheEntry(keys.values().next().value)
        }
      }
  }
}
function pruneCacheEntry(key: CacheKey) {
  const cached = cache.get(key) as VNode
  if (!current || cached.type !== current.type) {
    unmount(cached)
  } else if (current) {
    resetShapeFlag(current)
  }
  cache.delete(key)                                     // 根据 key 值删除缓存的组件
  keys.delete(key)
}

看到这里,大家也就明白,Vue3 和 Vue2 关于 keep-alive 的设计都是基于 LRU Cache,只是实现 LRU Cache 用的数据结构不同。

还有一点不同,Vue2 用的 Flow 和 options api,Vue3 用的 Typescript 和 composition api。

但是 LRU Cache 的思路绝对是一模一样的。

Vue3 另一个用到 LRU Cache 的地方

core/packages/compiler-sfc/src/cache.ts

import LRU from 'lru-cache'

export function createCache<T>(size = 500) {
  return __GLOBAL__ || __ESM_BROWSER__
    ? new Map<string, T>()
    : (new LRU(size) as any as Map<string, T>)
}

解析单文件组件的地方用到了 LRU,不过是引的三方库。

keep-alive 的 LRU 为啥不引三方库要自己写呢,我猜可能是 keep-alive 那里的 LRU Cache 需要比较精细化的设计,才会自己写吧。

leetcode相关算法题

146. LRU 缓存

请你设计并实现一个满足  LRU (最近最少使用) 缓存 约束的数据结构。

实现 LRU Cache 类:

  • LRUCache(int capacity) 以 正整数 作为容量 capacity 初始化 LRU 缓存
  • int get(int key) 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1 。
  • void put(int key, int value) 如果关键字 key 已经存在,则变更其数据值 value ;如果不存在,则向缓存中插入该组 key-value 。如果插入操作导致关键字数量超过 capacity ,则应该 逐出 最久未使用的关键字。 函数 get 和 put 必须以 O(1) 的平均时间复杂度运行。

示例:

输入
["LRUCache", "put", "put", "get", "put", "get", "put", "get", "get", "get"]
[[2], [1, 1], [2, 2], [1], [3, 3], [2], [4, 4], [1], [3], [4]]
输出
[null, null, null, 1, null, -1, null, -1, 3, 4]

解释
LRUCache lRUCache = new LRUCache(2);
lRUCache.put(1, 1); // 缓存是 {1=1}
lRUCache.put(2, 2); // 缓存是 {1=1, 2=2}
lRUCache.get(1);    // 返回 1
lRUCache.put(3, 3); // 该操作会使得关键字 2 作废,缓存是 {1=1, 3=3}
lRUCache.get(2);    // 返回 -1 (未找到)
lRUCache.put(4, 4); // 该操作会使得关键字 1 作废,缓存是 {4=4, 3=3}
lRUCache.get(1);    // 返回 -1 (未找到)
lRUCache.get(3);    // 返回 3
lRUCache.get(4);    // 返回 4

其实,这道题就是手写一个 LRU Cache,我们在上文已经实现过了。

连 Vue2 和 Vue3 源码中关于 LRU Cache 的设计都掌握了,这道题还能难倒我们吗?

class LRUCache {
  constructor (capacity) {
    this.cache = new Map()
    this.max = capacity                    
  }

  get (key) {                              
    if (this.cache.has(key)) {            
      const val = this.cache.get(key)     
      this.cache.delete(key)              
      this.cache.set(key, val)
      return val
    }
    return -1
  }

  put (key, value) {                                    
    if (this.cache.has(key)) {                          
      this.cache.delete(key)
    } else if (this.cache.size >= this.max) {           
      const firstEle = this.cache.keys().next().value
      this.cache.delete(firstEle)
    }
    this.cache.set(key, value)                          
  }
}

时间复杂度: 对于 put 和 get 都是 O(1)。

空间复杂度:O(capacity),和最大容量有关。

小结

看了这么多,我想你已经理解 LRU Cache 了。

LRU Cache 的本质是链表,我们可以这么理解:

设计系统的缓存算法 -> 想到了 LRU Cache -> 用 Map 来实现 
-> Map 是 Iterator -> Iterator 有链表的特性 -> 用链表解决复杂问题

所以说啊,我们解决这个难题,最终还是回到了链表这个数据结构,可见学好算法多么重要!

往期算法相关文章

链表介绍

写给前端开发的算法简介

树简介

写给算法初学者的分治法和快速排序(js)

散列表介绍

广度优先搜索

深度优先搜索

写给算法初学者的贪心算法