React 源码系列:怎么设计一个 LRU

254 阅读4分钟

「这是我参与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 中被移除,那么它的 previousnext 指针都会被置 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 报错的优雅方法,恳请大佬在评论区分享一下。