「这是我参与11月更文挑战的第15天,活动详情查看:2021最后一次更文挑战」
还记得当初学操作系统的时候,在学到进程调度算法的时候,你是否有被 FIFS、LRU、LFU、Clock、改进 Clock 算法等各种名词唬得一脸懵逼,不知怎么撕代码?今天就来聊聊前端是怎么实现一个 LRU 的。
LRU,全称 Least Recently Used(最近最少使用),LRU 算法在前端里经常用在实现一个 cache。cache 是有一定容量的,每次访问新的内容,就会将新内容存入 cache,当 cache 满了之后,如果要将新内容存入 cache,则需要将某一个旧的内容移出去,以腾出存放新内容的空间。如何选择需要被替换的内容,这需要规定一种策略,其中一种常用的策略就是 LRU:最近访问次数最少的内容优先被替换出去。
最近在学习 react 源码的时候,看到 react 仓库有一个 react-cache 包,里面的实现刚好是一个 LRU 算法,故今天来分析分析这个实现的原理。
原理用一句话总结就是:用一个双向循环链表的数据结构存储数据,每次访问的某个数据的时候,将该数据对应的节点移动到表头,当 cache 容量超限需要移除节点的时候,从表尾开始逐个移除节点。
首先对于 cache 中的数据的类型定义如下:
type Entry<T> = {|
value: T,
onDelete: () => unknown,
previous: Entry<T>,
next: Entry<T>,
|};
作为一个双向循环链表节点,必然含有两个指针,分别指向前一个节点和指向后一个节点,还要有value存储实际数据,此外,react-cache 的设计上还添加了 onDelete 回调机制,当数据从 cache 上被清除的时候会触发onDelete函数。
为了将 cache 功能提供给开发者使用,必然要提供一个 createLRU函数返回一个 cache 对象,它应该暴露 4 个方法:add(增加数据)、update(修改数据)、access(访问数据)、setLimit(修改cache容量)。。
export function createLRU<T>(limit: number) {
// 定义 add、update、access、setLimit 函数
return {
add,
update,
access,
setLimit,
};
}
add 函数添加一个数据和 onDelete 回调,返回生成的 entry 节点,同时更新 cache 的当前容量。
function add(value: Object, onDelete: () => mixed): Entry<Object> {
const entry = {
value,
onDelete,
next: (null: any),
previous: (null: any),
};
if (first === null) {
entry.previous = entry.next = entry;
first = entry;
} else {
// Append to head
const last = first.previous;
last.next = entry;
entry.previous = last;
first.previous = entry;
entry.next = first;
first = entry;
}
size += 1;
return entry;
}
access 函数访问 entry 节点中的 value,如果该节点在 cache (实际是一个双向循环链表)中,则将该结点移动到表头。由于每次访问数据都移动到表头,那么表尾节点就是最近最少使用的节点,下一次清除节点优先清除表尾节点。
function access(entry: Entry<T>): T {
const next = entry.next;
if (next !== null) {
// Entry already cached
const resolvedFirst: Entry<T> = (first: any);
if (first !== entry) {
// Remove from current position
const previous = entry.previous;
previous.next = next;
next.previous = previous;
// Append to head
const last = resolvedFirst.previous;
last.next = entry;
entry.previous = last;
resolvedFirst.previous = entry;
entry.next = resolvedFirst;
first = entry;
}
} else {
// Cannot access a deleted entry
// TODO: Error? Warning?
}
scheduleCleanUp();
return entry.value;
}
注意,代码中没有遍历链表的操作,那么它是如何判断入参 entry 是否在 cache 中的呢?这里的设计是如果一个 entry 从 cache 中被移除,那么它的 previous 和 next 指针都会被置 null,而在使用 add 函数添加数据时,而存在于双向链表中的节点的两个指针一定是非 null 的,指针分别指向相邻节点,如果只有 cache 只有一个节点,则该节点指针指向它自身)。因此,根据是 entry 的 next 指针是否为空即可判断该 entry 节点是否在 cache 链表中。
cache还有一个 setLimit 函数,一旦修改后的最大限制容量小于当前容量,则会执行清除操作
function setLimit(newLimit: number) {
LIMIT = newLimit;
scheduleCleanUp();
}
scheduleCleanUp会间接执行以下函数。从代码可以看到,被移除的节点的 previous 和 next 指针都被设置成 null 了,已经成为一个孤立节点,不再是 cache 链表的一员了。
function deleteLeastRecentlyUsedEntries(targetSize: number) {
// Delete entries from the cache, starting from the end of the list.
if (first !== null) {
const resolvedFirst: Entry<T> = (first: any);
let last = resolvedFirst.previous;
while (size > targetSize && last !== null) {
const onDelete = last.onDelete;
const previous = last.previous;
last.onDelete = (null: any);
// Remove from the list
last.previous = last.next = (null: any);
if (last === first) {
// Reached the head of the list.
first = last = null;
} else {
(first: any).previous = previous;
previous.next = (first: any);
last = previous;
}
size -= 1;
// Call the destroy method after removing the entry from the list. If it
// throws, the rest of cache will not be deleted, but it will be in a
// valid state.
onDelete();
}
}
}
react-cache 的这部分代码是用 js + flow(一个静态类型检查器) 编写的,看起来与 typescript 很相似,但是有些地方还是不同的,比如 flow 的 mixed 在 ts 中对应的是 unknown。这种 js + flow 的写法无法在 vscode 被直接识别,一打开代码就是一堆波浪线报错,提示类型注释只能在 TypeScript 文件中使用。
如果有什么解决这种 flow 报错的优雅方法,恳请大佬在评论区分享一下。