如何基于 HTTP 缓存失效策略实现 Request 缓存?
以下文章来源于小李的前端小屋 ,作者Leecason
前端面试的最后一道题往往是手写题,题目不限于基础 API 实现,算法题,场景应用题等。
又到了金三银四,今天和大家分享一下之前我面试某大厂时遇到的一道手写题:使用 JS 简单实现一套 SWR 机制。
什么是 SWR
很多同学可能都没听过什么是 SWR,更不用说用代码实现了。
SWR 这个名字来自于 stale-while-revalidate:一种由 HTTP RFC 5861[1] 推广的 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 机制。
以上的代码只能是一个合格的水平,我们应该充分利用自己的技术来打动面试官,让他记住你。
条件请求
目前的代码中,我们虽然使用了 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,最近最少使用)算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。
整个流程大致为:
新加入的数据插入到第一项
每当缓存命中(即缓存数据被访问),则将数据提升到第一项
当缓存数量满的时候,将最后一项的数据丢弃
由于面试时间有限,我不推荐大家在面试时继续写了,很容易弄巧成拙。但你可以积极地向面试官介绍这个思路和想法,继续加分,最好再补一句:“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 抽象与封装能力
能严谨,全面地考虑问题
如果我是面试官,一定已经被惊艳到了。
最后,祝各位都能在金三银四赢得满意的 offer!
参考资料
[1]
HTTP RFC 5861: https://tools.ietf.org/html/rfc5861
作者:Leecason
欢迎关注微信公众号 :前端印象