likes
comments
collection
share

前端性能优化:更高级的缓存策略一、前言 受到 HTTP RFC 5861 和 React Hook SWR 的启发设计和

作者站长头像
站长
· 阅读数 20

文中的缓存策略并不局限某个前端生态,甚至不局限于前端,但为了讨论我们将其预设在前端生态中.

一、前言

受到 HTTP RFC 5861React Hook SWR 的启发设计和实现一个可以用在客户端和服务端的数据缓存策略,以减少相同异步的请求.可以用于加快SSR页面响应和增强SPA的性能等场景(HTTP RFC 5861并不是标准规范,在具体实现时会包含不在文档描述内的情况,如“同时设置stale-while-revalidate和stale-if-error”)

二、背景

最近在做Nextjs网站的项目优化,发现拖慢网页响应速度的罪魁祸首竟然是API请求,深入研究后发现大部分SSR页面使用Api请求的数据对时效性要求并不高,但每次渲染页面时却会反复请求,借此希望能有一个好的工具可以解决该问题.

Q: 那么如何在当前业务情况下(不要求数据实时性)不依赖第三方(后端)来解决该问题呢?

A: 缓存🫡

用一定的策略利用空间换时间达到我们的目的.

  • React.cache : React官方提供的缓存方法,但是它是提供React组件间数据缓存,在页面重新渲染时便会失效.官方说明
  • SWR : 一个极速、轻量、可重用数据请求方案,可以轻松实现数据缓存、乐观UI等功能,但是对RSC支持较差,且达不到我们的目的.官方说明

遂决定自行实现一套缓存机制,并将它分享出来,为更多小伙伴抛砖引玉💪

三、要求

  1. 可复用
  2. 可配置
  3. 持久缓存,缓存可共享
  4. 尽可能的响应请求,减少等待耗时
  5. 一次配置全局通用
  6. 自行垃圾回收,避免内存无效占用

四、设计

总体设计

前端性能优化:更高级的缓存策略一、前言 受到 HTTP RFC 5861 和 React Hook SWR 的启发设计和

其中缓存器便是我们接下来要实现的

流程设计

先介绍几个名词,方便后续的设计理解:

这里的名词解释可以当作字典,若后续的文章中出现且你不理解时,可以返回这里查询并带入原文中理解

  • Update: 缓存更新动作, 它包含两个操作,异步发起缓存更新请求 和 更新缓存

  • 窗口期: 指由开始时间点至结束时间点的中间时间段

  • maxAge窗口期: 为缓存有效窗口期,由max-age参数配置, 在该窗口期内缓存被认定为有效

  • SWR窗口期: 为缓存过期容忍窗口期, 由stale-while-revalidate参数配置,该窗口期内缓存被认定为失效但可用

  • SIE窗口期: 更新错误容忍窗口期,由stale-if-error参数配置,当缓存更新动作执行失败时进入,该窗口期内缓存被认定为失效但可用

  • Block: 阻塞窗口期,该窗口期内接受到的所有外部请求都将被劫持,直到缓存更新动作执行完成后响应(不处于maxAge、SWR和SIE窗口期的时期就可以称为Block)

更新成功的情况

前端性能优化:更高级的缓存策略一、前言 受到 HTTP RFC 5861 和 React Hook SWR 的启发设计和

  • 当请求命中maxAge窗口期时,缓存器便直接响应

  • 当请求命中SWR窗口期时,缓存器直接响应 并 开启Update

  • 当Update执行完毕时(Success)

    • 若处于SWR窗口期或SIE窗口期内,窗口期会提前结束,这是为了可以更快的响应最新数据 *
    • 若处于SWR窗口期或SIE窗口期外,此时便会出现Block窗口期,Block窗口期中外部请求响应将会被延迟至Update执行完毕

更新失败的情况

前端性能优化:更高级的缓存策略一、前言 受到 HTTP RFC 5861 和 React Hook SWR 的启发设计和

  • 当请求命中maxAge窗口期时,缓存器便直接响应

  • 当请求命中SWR窗口期时,缓存器直接响应 并 开启Update

  • 当Update执行完毕时(Error)

    • 若处于SWR窗口期内,SWR窗口期会提前结束,并直接进入SIE窗口期 *
    • 若处于SIE窗口期内,将忽略本次Error *
    • 若处于SWR窗口期或SIE窗口期外,此时便会出现Block窗口期,Block窗口期中外部请求响应将会被延迟至Update执行完毕

注意比较 更新成功的情况更新失败的情况 中标记*的区别.

  • 缓存更新成功会立刻结束当前窗口期(SWR/SIE)并进入maxAge窗口期
  • 缓存更新失败则会根据当前不同的窗口期做出不同的操作

推荐设置

  1. SWR和SIE的值应该尽可能大于Update的执行时间,以此来尽量减少Block时间

  2. 对于错误敏感的场景,可以将SIE设置的短一些

  3. 当你的服务接受的请求稀疏,可以尝试将SWR设置的更长一些来避免请求落在Block窗口期

  4. 当你的服务接受的请求很密集,更新请求已经对被请求方造成压力,可以尝试增加maxAge,并在满足推荐设置1的条件下尽可能的小

骚操作

将SWR和SIE设置为Infinity,那么就可以达到除了首次请求以外,其他的任何时间缓存器都会快速响应外部请求的效果,并且还能尽可能的保证数据的更新😜

实现逻辑

前端性能优化:更高级的缓存策略一、前言 受到 HTTP RFC 5861 和 React Hook SWR 的启发设计和

五、用例

工具函数

const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

模拟耗时异步操作

let count = 0;
const mockRequest = async () => {
  await delay(200);
  return count++;
};

1、maxAge

用于标记缓存的有效时长,缓存有效时不会尝试更新缓存

不使用cache

// max-age-no-cache.js
await mockRequest().then(console.log); // 0
await mockRequest().then(console.log); // 1

使用cache

// max-age-cache.js
const requestCache = cache(mockRequest, { maxAge: 500, swr: 0, sie: 0 });
// 下一行执行完毕时便会产生缓存,缓存的窗口期如下
// 0 -- [maxAge] -- 500 -- [block] -- Infinity
await requestCache().then(console.log); // 0
// 在maxAge内,则输出0
await requestCache().then(console.log); // 0
await delay(200);
// 0 < 200 < 500,在maxAge内,则输出0
await requestCache().then(console.log); // 0
await delay(400);
// 500 < 600(200 + 400),在maxAge外,缓存失效,重新执行mockRequest,输出1
await requestCache().then(console.log); // 1

2、stale-while-revalidate

用于标记缓存过期容忍时长,若当前缓存过期但可以容忍,缓存会被立刻返回给请求方,并尝试更新缓存

我们将max-age-cache.jsswr的值设置为1000之后再运行,会发现打印结果变为:

const requestCache = cache(mockRequest, { maxAge: 500, swr: 1000, sie: 0 });
// 0 -- [maxAge] -- 500 -- [swr] -- 1500
await requestCache().then(console.log); // 0
await requestCache().then(console.log); // 0
await delay(200);
await requestCache().then(console.log); // 0
await delay(400);
// 500 < 600(200 + 400),在maxAge外,缓存失效
await requestCache().then(console.log); // 0

只有 标记的代码输出不一致,这是因为在时间点600处即使缓存无效,但此时在swr内(500 < 600 < 1500),则缓存器认为缓存依然是可以被使用的,便会立刻返回缓存。于此同时会发起更新缓存的请求。

我们在 下方增加一行代码:

await delay(300);
await requestCache().then(console.log); // 1

会发现获取到了最新的结果,这由两个原因导致:

  1. swr发起了更新缓存请求
  2. 设置的mockRequest执行完成需要200ms

也就是说在mockRequest完成之后外部请求就会立刻拿到最新结果,而在此之前只能拿到过时的缓存值!

我们将await delay(300)换成await delay(100)来验证一下:

await delay(100);
await requestCache().then(console.log); // 0

此时便只能拿到缓存值。

3、stale-if-error

用于更新错误容忍时长,当尝试更新缓存发生错误时,若后续的请求处于该时间内会立刻返回过期缓存给请求方,同时尝试更新缓存

模拟可以切换成功和失败耗时异步请求

// mock-switch-request.js
let success = true;
let successCount = 0;
let errorCount = 0;

const mockSwitchRequest = async () => {
  await delay(200);
  if (success) {
    return `success:${successCount++}`;
  }
  throw `error:${errorCount++}`;
};

swr设置为0,sie设置为1000:

// stale-if-error.js
 const requestCache = cache(mockSwitchRequest, { maxAge: 500, swr: 0, sie: 1000 });
 success = true;
 // 0 -- [maxAge] -- 500 -- [sie] -- 1500 -- [block] -- Infinity
 await requestCache().then(console.log); // success:0
 
 success = false;
 await delay(600);
 requestCache().catch(console.error); // error:0

单独设置sie而不启用swr其实没有意义,这里便不多赘述。

4、stale-while-revalidate 和 stale-if-error

这两个参数合用的场景更高频,而HTTP RFC 5861对此并没有具体描述,它们之间相互影响却又各司其职

当缓存过期后,能使用缓存立刻响应外部请求会有最小时长与最大时长:

  • 最小时长,公式Update+SIE,在下列情况下该公式的值达到最小

    • 既在SWR刚开始时便发起缓存更新请求且请求失败,此时SWR会立刻坍缩并被置为SIE
  • 最大时长,公式SWR+SIE,在下列情况下该公式的值达到最大

    • SWR结束与Update完成同时发生,SWR刚刚结束便直接进入SIE

5、globalCache

是否启用全局cache

不启用globalCache

当不使用globalCache时,当前cache仅存在于当前闭包环境下,当cache引用丢失后,会被当前宿主环境的垃圾回收机制清理。

let requestCache = cache(mockSwitchRequest, { globalCache: false })
requestCache();

requestCache = null; // cache引用丢失

启用globalCache

当使用globalCache时,当前cache会被放到cache.ts所在的模块中,即使当前“cache引用丢失”,而缓存依然存在,他只会被cache.ts内置的垃圾回收定时清理全局引用,最后由宿主环境的垃圾回收机制清理。

let requestCache = cache(mockSwitchRequest, { globalCache: true, gcThrottle: 1000})
requestCache();

requestCache = null; // cache引用丢失,此时缓存依然存在

// 直到{gcThrottle}后,全局引用才会被清理

6、gcThrottle

定于内部垃圾回收的节流频率

垃圾回收节流器会在每一次请求时触发,每一次垃圾回收执行会清理过期并处于Block窗口期的缓存。

边缘情况

当收到的请求过于稀疏,比如仅收到一次请求,此时若垃圾回收执行完毕时缓存还未过期,之后又并没有请求再来触发垃圾回收,那么这个过期的缓存在全局模式下将一直保存在内存中,而在非全局模式下只有当cache应用被主动清理后才能释放否则将一直保存在内存中。

7、cacheFulfilled 和 cacheRejected

是否缓存 更新缓存结果

// args为requestCache的arguments,value为更新请求的正确响应结果
const cacheFulfilled = (args, value) => boolean;
// args为requestCache的arguments,error为更新请求的异常响应结果
const cacheRejected = (args, error) => boolean;

const requestCache = cache(mockSwitchRequest, { cacheFulfilled , cacheRejected })

用这两个配置,你可以选择性的缓存数据,例如“我只想缓存分页的第一页数据”

8、缓存隔离

是否命中缓存取决于arguments的深比较,若比较相等则命中缓存块,否则将会新建另外的缓存块

const mockRequest = async (params) => {
  return `${JSON.stringify(params)}-${Date.now()}`;
};

const requestCache = cache(mockRequest, { maxAge: 500 });
// 缓存命中
requestCache({ name: "neno" }); // {"name":"neno"}-1719324000000
requestCache({ name: "neno" }); // {"name":"neno"}-1719324000000

// 缓存不命中
requestCache({ name: "neno" }); // {"name":"neno"}-1719324000000
requestCache({ name: "nenoless" }); // {"name":"nenoless"}-1719324000004

// { name: "nenoless" } 对应的块再次命中
requestCache({ name: "nenoless" }); // {"name":"nenoless"}-1719324000004

9、使用自定义缓存媒介

缓存器是默认使用Javascript对象Map来在内存中缓存数据的,如果你希望使用其他媒介(例如: SQLite, LocalStorage等)请使用 storeCreator来传入你的缓存存储器,storeCreator返回的对象需要实现entriesdeletesetget它们的行为需要与Map保持一致。

除此之外使用自定义媒介时,你需要解决Map<key, value>key序列化与argsEqual的相等判断问题。

附件

转载自:https://juejin.cn/post/7384242370223865897
评论
请登录