「这是我参与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,也能在遇到类似问题的时候有点思路。
我:我就一小前端,你高看我了啊,算了,既然 vue3 源码里都用到了,就学一下 LRU 吧。
LRU Cache
LRU Cache 是什么?
LRU,全称 Least recently used,也就是最近最少使用,是一种缓存淘汰策略。
计算机的内存空间是有限的,如果内存满了就要删除一些内容,给新内容腾空间。
但问题是,删除哪些内容呢?我们肯定希望删掉那些不怎么用的数据,而把经常用的数据继续留着,方便之后继续使用。
LRU Cache 认为缓存里最近最少使用的数据,是不重要的,就要先被删除掉。
生活中,我们每天都在使用 LRU Cache,不信你看下面这个例子。
手机后台应用管理
我们知道,手机上的应用都可以运行在后台。
- 每打开一个新应用,就会把新应用放到后台列表的第一位。
- 如果一个应用正在后台运行中,被打开,那么会把这个应用放到后台列表的第一位。
- 太久没用的应用就会被放到后台列表的末尾。
看下面这个例子:
假设你的手机最多只能后台运行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)
有了这样的基础,我们就可以尝试来实现一个 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())
至此,我们用 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,画个图来回顾下整体的流程:
- 缓存未满时新增,组件一个一个地写入 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 有链表的特性 -> 用链表解决复杂问题
所以说啊,我们解决这个难题,最终还是回到了链表这个数据结构,可见学好算法多么重要!
往期算法相关文章