如何基于 HTTP 緩存失效策略實(shí)現(xiàn) Request 緩存?

以下文章來(lái)源于小李的前端小屋 ,作者Leecason


前端面試的最后一道題往往是手寫(xiě)題,題目不限于基礎(chǔ) API 實(shí)現(xiàn),算法題,場(chǎng)景應(yīng)用題等。

又到了金三銀四,今天和大家分享一下之前我面試某大廠時(shí)遇到的一道手寫(xiě)題:使用 JS 簡(jiǎn)單實(shí)現(xiàn)一套 SWR 機(jī)制。

什么是 SWR
很多同學(xué)可能都沒(méi)聽(tīng)過(guò)什么是 SWR,更不用說(shuō)用代碼實(shí)現(xiàn)了。

SWR 這個(gè)名字來(lái)自于 stale-while-revalidate:一種由 HTTP RFC 5861[1] 推廣的 HTTP 緩存失效策略。

與 max-age 類似,它是控制緩存的,是 Cache-Control 的一個(gè)指令,英文單詞 stale 的意思是陳舊的,不新鮮的。在 HTTP 緩存領(lǐng)域,stale 用來(lái)形容一個(gè)緩存過(guò)期了。

普通的緩存策略是這樣的:當(dāng)一個(gè)資源的緩存過(guò)期之后,如果想再次使用它,需要先對(duì)該緩存進(jìn)行 revalidate。在 revalidate 執(zhí)行期間,客戶端就得等待,直到 revalidate 請(qǐng)求結(jié)束。

在一些特別注重性能的場(chǎng)景下,這種傳統(tǒng)的同步更新緩存的機(jī)制被認(rèn)為是有性能問(wèn)題的。

而這個(gè) SWR 策略是說(shuō):當(dāng) revalidate 請(qǐng)求進(jìn)行時(shí),客戶端可以不等待,直接使用過(guò)期的緩存,revalidate 完了緩存就更新了,下次用的就是新的了。

所以 SWR 實(shí)現(xiàn)的功能用通俗的詞語(yǔ)解釋就是“后臺(tái)緩存刷新”、“異步緩存更新”。

SWR 通常與 max-age 一起使用,比如 Cache-Control: max-age=1, stale-while-revalidate=59 表示:這個(gè)緩存在 1s 內(nèi)是新鮮的,在 1-60s 內(nèi),雖然緩存過(guò)期了,但仍可以直接使用,同時(shí)進(jìn)行異步 revalidate,在 60s 后,緩存完全過(guò)期需要進(jìn)行傳統(tǒng)的同步 revalidate。

SWR 的使用場(chǎng)景通常有:當(dāng)前天氣狀況的 API,或者過(guò)去一小時(shí)內(nèi)編寫(xiě)的頭條新聞等。

代碼實(shí)現(xiàn)
了解了什么是 SWR 后,接下來(lái)看看如何實(shí)現(xiàn)它。

實(shí)現(xiàn)之前,先拆解下目標(biāo):

1. 當(dāng)請(qǐng)求數(shù)據(jù)時(shí),首先從緩存中讀取,并立即返回給調(diào)用者

2. 如果數(shù)據(jù)已經(jīng)過(guò)期,則發(fā)起 fetch 請(qǐng)求,獲取最新數(shù)據(jù)

我們需要用一個(gè)“容器”來(lái)緩存請(qǐng)求回來(lái)的復(fù)雜數(shù)據(jù),在 JS 中,我們很容易第一時(shí)間想到使用 Object。

使用 Object 雖然沒(méi)有什么問(wèn)題,但它的結(jié)構(gòu)是 “字符串—值” 的對(duì)應(yīng),只支持字符串作為鍵名。而在 ES6 中,Map 提供了 “值—值” 對(duì)應(yīng)這種更完善的 Hash,更適合用于“鍵值對(duì)”這種數(shù)據(jù)結(jié)構(gòu)。

我們?cè)诿嬖囍?,?yīng)該隨時(shí)向面試官展現(xiàn)我們的知識(shí)儲(chǔ)備,因此這里選擇 Map 更好。

為了方便代碼實(shí)現(xiàn)后,有一個(gè)比較好的對(duì)比。這里先寫(xiě)一下不使用緩存時(shí)數(shù)據(jù)請(qǐng)求方式:

const data = await fetcher();
支持?jǐn)?shù)據(jù)緩存
為了讓 fetcher 支持?jǐn)?shù)據(jù)緩存的能力,這里需要對(duì) fetcher 進(jìn)行一層封裝。

封裝之前,先定義一下需要被緩存的數(shù)據(jù),那么什么數(shù)據(jù)需要被緩存呢?

很顯然,不就是 請(qǐng)求返回的數(shù)據(jù)嗎。

但與此同時(shí),你也應(yīng)該想到,如果重復(fù)調(diào)用函數(shù),最好不要發(fā)送多次請(qǐng)求。

所以緩存數(shù)據(jù)中應(yīng)該有:

請(qǐng)求返回的數(shù)據(jù)

當(dāng)前正在進(jìn)行中的請(qǐng)求(如果有),避免多次請(qǐng)求

const cache = new Map(); // 緩存數(shù)據(jù)

async function swr(cacheKey, fetcher) {
  // 首先從緩存中獲取
  let data = cache.get(cacheKey) || { value: null, promise: null };
  // 寫(xiě)入緩存
  cache.set(cacheKey, data);
 
  // 沒(méi)有數(shù)據(jù)且也沒(méi)有在請(qǐng)求中,需要發(fā)送請(qǐng)求
  if (!data.value && !data.promise) {
    // 保存當(dāng)前請(qǐng)求的 promise
    data.promise = fetcher()
      .then((val) => {
        data.value = val; // 請(qǐng)求成功,將數(shù)據(jù)存起來(lái)
      });
      .catch((err) => {
        console.log(err);
      })
      .finally(() => {
        data.promise = null; // 請(qǐng)求完畢,不再保存 promise
      });
  }
 
  // 沒(méi)有數(shù)據(jù),但正在請(qǐng)求中,復(fù)用保存的 promise
  if (data.promise && !data.value) await data.promise;
  // 返回?cái)?shù)據(jù)
  return data.value;
}
這樣,我們就實(shí)現(xiàn)了數(shù)據(jù)緩存的能力。

支持緩存過(guò)期時(shí)間
在已有緩存能力的基礎(chǔ)上,再支持過(guò)期時(shí)間 cacheTime 就很容易了。

只需要在發(fā)起新的請(qǐng)求前,判斷下是否過(guò)期:

const isStaled = Date.now() - 獲取到數(shù)據(jù)的時(shí)間 > cacheTime
所以,在緩存數(shù)據(jù)中我們還需要保存獲取到數(shù)據(jù)的時(shí)間:

const cache = new Map();

// 新增 cacheTime 參數(shù)
async function swr(cacheKey, fetcher, cacheTime) {
  let data = cache.get(cacheKey) || { value: null, time: 0, promise: null };
  cache.set(cacheKey, data);
 
  // 是否過(guò)期
  const isStaled = Date.now() - data.time > cacheTime;
  // 已經(jīng)過(guò)期了,且也沒(méi)有在請(qǐng)求中,需要發(fā)送請(qǐng)求
  if (isStaled && !data.promise) {
    data.promise = fetcher()
      .then((val) => {
        data.value = val;
        data.time = Date.now(); // 保存獲取到數(shù)據(jù)的時(shí)間
      });
      .catch((err) => {
        console.log(err);
      })
      .finally(() => {
        data.promise = null;
      });
  }
 
  if (data.promise && !data.value) await data.promise;
  return data.value;
}
有了以上的封裝,調(diào)用方法變更為:






// before
const data = await fetcher();

// after
const data = await swr('cache-key', fetcher, 3000);
首次調(diào)用時(shí),會(huì)通過(guò)接口請(qǐng)求數(shù)據(jù)。隨后調(diào)用會(huì)立即返回緩存數(shù)據(jù)。如果調(diào)用間隔超過(guò) 3s,將先返回緩存數(shù)據(jù),再請(qǐng)求接口獲取最新的數(shù)據(jù)。

大功告成!我們用近 20 行代碼簡(jiǎn)單實(shí)現(xiàn)了一套 SWR 機(jī)制。

以上的代碼只能是一個(gè)合格的水平,我們應(yīng)該充分利用自己的技術(shù)來(lái)打動(dòng)面試官,讓他記住你。

條件請(qǐng)求
目前的代碼中,我們雖然使用了 Map,但使用時(shí) cacheKey 還是一個(gè)字符串,沒(méi)有真正發(fā)揮 Map 的作用。作為基礎(chǔ)能力的補(bǔ)充,可以考慮將 function 作為 cacheKey 傳入來(lái)實(shí)現(xiàn)條件請(qǐng)求特性。

將函數(shù)返回值作為 cacheKey,如果有返回,則執(zhí)行上述邏輯,如果沒(méi)有,則不緩存。

const shouldCache = function() { ... }

// cacheKey 支持傳入函數(shù)
const data = await swr(shouldCache, fetcher, 3000);

async function swr(cacheKey, fetcher, cacheTime) {
  // 如果是函數(shù),則調(diào)用函數(shù)將返回值作為 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 緩存淘汰
讓我們來(lái)繼續(xù)發(fā)揮 Map 的能力。

我們知道,Map 的遍歷順序就是插入順序,再加上其鍵值對(duì)的數(shù)據(jù)結(jié)構(gòu),很容易想到基于此特性來(lái)實(shí)現(xiàn) LRU 緩存淘汰策略。

LRU(Least recently used,最近最少使用)算法根據(jù)數(shù)據(jù)的歷史訪問(wèn)記錄來(lái)進(jìn)行淘汰數(shù)據(jù),其核心思想是“如果數(shù)據(jù)最近被訪問(wèn)過(guò),那么將來(lái)被訪問(wèn)的幾率也更高”。

整個(gè)流程大致為:

新加入的數(shù)據(jù)插入到第一項(xiàng)
每當(dāng)緩存命中(即緩存數(shù)據(jù)被訪問(wèn)),則將數(shù)據(jù)提升到第一項(xiàng)
當(dāng)緩存數(shù)量滿的時(shí)候,將最后一項(xiàng)的數(shù)據(jù)丟棄
由于面試時(shí)間有限,我不推薦大家在面試時(shí)繼續(xù)寫(xiě)了,很容易弄巧成拙。但你可以積極地向面試官介紹這個(gè)思路和想法,繼續(xù)加分,最好再補(bǔ)一句:“Vue 的 keep-alive 組件中就用到了此算法”,間接地向面試官傳遞你清楚 Vue 相關(guān)的原理實(shí)現(xiàn)這個(gè)信息。

其實(shí),LRU 算法通常會(huì)單獨(dú)作為一道手寫(xiě)題,因此今天我們也手寫(xiě)鞏固一下:

只需要對(duì)之前聲明的 cache 容器 const cache = new Map(); 進(jìn)行一些改造:

規(guī)定它的最大緩存容量 capacity
同時(shí)向外暴露的 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) {
      // 不存在即加入
      // 緩存超過(guò)最大值,則移除最近沒(méi)有使用的,也就是 map 的第一個(gè) key
      // map.keys() 會(huì)返回 Iterator 對(duì)象
      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
// 后續(xù)的 SWR 代碼不做改動(dòng)
使用 Map 實(shí)現(xiàn) LRU 的時(shí)間復(fù)雜度為 O(1)

結(jié)語(yǔ)
可見(jiàn),一個(gè)小小的手寫(xiě)題還是隱藏了很多很深的知識(shí)點(diǎn)的。面試官考察的是你全方位的能力,如果你寫(xiě)出了以上的代碼,并向面試官陳述你因?yàn)闀r(shí)間關(guān)系沒(méi)來(lái)得及實(shí)現(xiàn)的后續(xù)特性,可以體現(xiàn)你多方面的能力:

理解 HTTP 相關(guān)緩存策略
理解 Object 與 Map 的差異與 Map 的使用場(chǎng)景
理解 Promise 與 async 函數(shù),并能實(shí)際使用
寫(xiě)代碼時(shí)考慮性能優(yōu)化
掌握數(shù)據(jù)類型的判斷方法
了解 Vue 相關(guān)原理實(shí)現(xiàn)
具有 API 抽象與封裝能力
能嚴(yán)謹(jǐn),全面地考慮問(wèn)題
如果我是面試官,一定已經(jīng)被驚艷到了。
最后,祝各位都能在金三銀四贏得滿意的 offer!

參考資料
[1]
HTTP RFC 5861: https://tools.ietf.org/html/rfc5861






作者:Leecason

歡迎關(guān)注微信公眾號(hào) :前端印象