面试官:请使用 JS 简单实现一套 SWR 机制

1,213

本文首发于公众号「小李的前端小屋」,欢迎关注~

前言

前端面试的最后一道题往往是手写题,题目不限于基础 API 实现,算法题,场景应用题等。

又到了金三银四,今天和大家分享一下之前我面试某大厂时遇到的一道手写题:使用 JS 简单实现一套 SWR 机制

什么是 SWR

很多同学可能都没听过什么是 SWR,更不用说用代码实现了。

WechatIMG49.jpeg

SWR 这个名字来自于 stale-while-revalidate:一种由 HTTP RFC 5861 推广的 HTTP 缓存失效策略。与 max-age 类似,它是控制缓存的,是 Cache-Control 的一个指令,英文单词 stale 的意思是陈旧的,不新鲜的。在 HTTP 缓存领域,stale 用来形容一个缓存过期了。

普通的缓存策略是这样的:当一个资源的缓存过期之后,如果想再次使用它,需要先对该缓存进行 revalidate。在 revalidate 执行期间,客户端就得等待,直到 revalidate 请求结束。

在一些特别注重性能的场景下,这种传统的同步更新缓存的机制被认为是有性能问题的。

而这个 SWR 策略是说:当 revalidate 请求进行时,客户端可以不等待,直接使用过期的缓存,revalidate 完了缓存就更新了,下次用的就是新的了。

所以 SWR 实现的功能用通俗的词语解释就是“后台缓存刷新”、“异步缓存更新”。

SWR 通常与 max-age 一起使用,比如 Cache-Control: max-age=1, stale-while-revalidate=59 表示:这个缓存在 1s 内是新鲜的,在 1-60s 内,虽然缓存过期了,但仍可以直接使用,同时进行异步 revalidate,在 60s 后,缓存完全过期需要进行传统的同步 revalidate。

SWR 的使用场景有:当前天气状况的 API,或者过去一小时内编写的头条新闻等。

代码实现

了解了什么是 SWR 后,接下来看看如何实现它。

实现之前,先拆解下目标:

1. 当请求数据时,首先从缓存中读取,并立即返回给调用者

2. 如果数据已经过期,则发起 fetch 请求,获取最新数据

我们需要用一个“容器”来缓存请求回来的复杂数据,在 JS 中,我们很容易第一时间想到使用 Object。

使用 Object 虽然没有什么问题,但它的结构是 “字符串—值” 的对应,只支持字符串作为键名。而在 ES6 中,Map 提供了 “值—值” 对应这种更完善的 Hash,更适合用于“键值对”这种数据结构。

我们在面试中,应该随时向面试官展现我们的知识储备,因此这里选择 Map 更好。

为了方便代码实现后,有一个比较好的对比。这里先写一下不使用缓存时数据请求方式:

const data = await fetcher();

支持数据缓存

为了让 fetcher 支持数据缓存的能力,这里需要对 fetcher 进行一层封装。

封装之前,先定义一下需要被缓存的数据,那么什么数据需要被缓存呢?

很显然,不就是 请求返回的数据吗。

但与此同时,你也应该想到,如果重复调用函数,最好不要发送多次请求

所以缓存数据中应该有:

  • 请求返回的数据

  • 当前正在进行中的请求(如果有),避免多次请求

const cache = new Map(); // 缓存数据

async function swr(cacheKey, fetcher) {
  // 首先从缓存中获取
  let data = cache.get(cacheKey) || { value: null, promise: null };
  // 写入缓存
  cache.set(cacheKey, data);
  
  // 没有数据且也没有在请求中,需要发送请求
  if (!data.value && !data.promise) {
    // 保存当前请求的 promise
    data.promise = fetcher()
      .then((val) => {
        data.value = val; // 请求成功,将数据存起来
      });
      .catch((err) => {
        console.log(err);
      })
      .finally(() => {
        data.promise = null; // 请求完毕,不再保存 promise
      });
  }
  
  // 没有数据,但正在请求中,复用保存的 promise
  if (data.promise && !data.value) await data.promise;
  // 返回数据
  return data.value;
}

这样,我们就实现了数据缓存的能力。

支持缓存过期时间

在已有缓存能力的基础上,再支持过期时间 cacheTime 就很容易了。

只需要在发起新的请求前,判断下是否过期:

const isStaled = Date.now() - 获取到数据的时间 > cacheTime

所以,在缓存数据中我们还需要保存获取到数据的时间

const cache = new Map();

// 新增 cacheTime 参数
async function swr(cacheKey, fetcher, cacheTime) {
  let data = cache.get(cacheKey) || { value: null, time: 0, promise: null };
  cache.set(cacheKey, data);
  
  // 是否过期
  const isStaled = Date.now() - data.time > cacheTime;
  // 已经过期了,且也没有在请求中,需要发送请求
  if (isStaled && !data.promise) {
    data.promise = fetcher()
      .then((val) => {
        data.value = val;
        data.time = Date.now(); // 保存获取到数据的时间
      });
      .catch((err) => {
        console.log(err);
      })
      .finally(() => {
        data.promise = null;
      });
  }
  
  if (data.promise && !data.value) await data.promise;
  return data.value;
}

有了以上的封装,调用方法变更为:

// before
const data = await fetcher();

// after
const data = await swr('cache-key', fetcher, 3000);

首次调用时,会通过接口请求数据。随后调用会立即返回缓存数据。如果调用间隔超过 3s,将先返回缓存数据,再请求接口获取最新的数据。

大功告成!我们用近 20 行代码简单实现了一套 SWR 机制。

WechatIMG50.jpeg

以上的代码只能是一个合格的水平,我们应该充分利用自己的技术来打动面试官,让他记住你。

条件请求

目前的代码中,我们虽然使用了 Map,但使用时 cacheKey 还是一个字符串,没有真正发挥 Map 的作用。作为基础能力的补充,可以考虑将 function 作为 cacheKey 传入来实现条件请求特性。

将函数返回值作为 cacheKey,如果有返回,则执行上述逻辑,如果没有,则不缓存。

const shouldCache = function() { ... }

// cacheKey 支持传入函数
const data = await swr(shouldCache, fetcher, 3000);

async function swr(cacheKey, fetcher, cacheTime) {
  // 如果是函数,则调用函数将返回值作为 cacheKey
  const cKey = typof cacheKey === 'function' ? cacheKey() : cacheKey;
  
  // 如果有 cacheKey 才启用缓存
  if (cKey) {
    let data = cache.get(cKey) || { value: null, time: 0, promise: null };
    cache.set(cKey, data);
    
    ...
  } else {
    return await fetcher();
  }
}

LRU 缓存淘汰算法

让我们来继续发挥 Map 的能力。

我们知道,Map 的遍历顺序就是插入顺序,再加上其键值对的数据结构,很容易想到基于此特性来实现 LRU 缓存淘汰策略。

LRU(Least recently used,最近最少使用)算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。

整个流程大致为:

  1. 新加入的数据插入到第一项
  2. 每当缓存命中(即缓存数据被访问),则将数据提升到第一项
  3. 当缓存数量满的时候,将最后一项的数据丢弃

由于面试时间有限,我不推荐大家在面试时继续写了,很容易弄巧成拙。但你可以积极地向面试官介绍这个思路和想法,继续加分,最好再补一句:“Vue 的 keep-alive 组件中就用到了此算法”,间接地向面试官传递你清楚 Vue 相关的原理实现这个信息。

其实,LRU 算法通常会单独作为一道手写题,因此今天我们也手写巩固一下:

只需要对之前声明的 cache 容器 const cache = new Map(); 进行一些改造:

  • 规定它的最大缓存容量 capacity
  • 同时向外暴露的 get 和 set API 用法保持不变
class LRUCahe {
  constructor(capacity) {
    this.cache = new Map();
    this.capacity = capacity; // 最大缓存容量
  }

  get(key) {
    // 存在即更新(删除后加入)
    if (this.cache.has(key)) {
      const temp = this.cache.get(key);
      this.cache.delete(key);
      this.cache.set(key, temp);

      return temp;
    }
    return undefined;
  }

  set(key, value) {
    if (this.cache.has(key)) {
      // 存在即更新(删除后加入)
      this.cache.delete(key);
    } else if (this.cache.size >= this.capacity) {
      // 不存在即加入
      // 缓存超过最大值,则移除最近没有使用的,也就是 map 的第一个 key
      // map.keys() 会返回 Iterator 对象
      this.cache.delete(this.cache.keys().next().value);
    }
    this.cache.set(key, value);
  }
}

// before
const cache = new Map();

// after
const cache = new LRUCahe(50); // 缓存最大容量为 50
// 后续的 SWR 代码不做改动

使用 Map 实现 LRU 的时间复杂度为 O(1)

结语

可见,一个小小的手写题还是隐藏了很多很深的知识点的。面试官考察的是你全方位的能力,如果你写出了以上的代码,并向面试官陈述你因为时间关系没来得及实现的后续特性,可以体现你多方面的能力:

  • 理解 HTTP 相关缓存策略
  • 理解 Object 与 Map 的差异与 Map 的使用场景
  • 理解 Promise 与 async 函数,并能实际使用
  • 写代码时考虑性能优化
  • 掌握数据类型的判断方法
  • 了解 Vue 相关原理实现
  • 具有 API 抽象与封装能力
  • 能严谨,全面地考虑问题

如果我是面试官,一定已经被惊艳到了。

WechatIMG51.jpeg

最后,祝各位都能在金三银四赢得满意的 offer!

❤️支持

如果本文对你有帮助,点赞👍支持下我吧,你的「赞」是我创作的动力。

关于我,目前是字节跳动一线开发,工作四年半,工作中使用 React,业余时间开发喜欢 Vue。

平时会不定期对前端的工作思考与实践进行深度分享和总结,公众号「小李的前端小屋」,感谢关注~