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

以下文章來源于小李的前端小屋 ,作者Leecason


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

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

什么是 SWR
很多同學可能都沒聽過什么是 SWR,更不用說用代碼實現(xiàn)了。

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

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

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

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

而這個 SWR 策略是說:當 revalidate 請求進行時,客戶端可以不等待,直接使用過期的緩存,revalidate 完了緩存就更新了,下次用的就是新的了。

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

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

SWR 的使用場景通常有:當前天氣狀況的 API,或者過去一小時內(nèi)編寫的頭條新聞等。

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

實現(xiàn)之前,先拆解下目標:

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

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

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

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

我們在面試中,應(yīng)該隨時向面試官展現(xiàn)我們的知識儲備,因此這里選擇 Map 更好。

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

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

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

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

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

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

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

當前正在進行中的請求(如果有),避免多次請求

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

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

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

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

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

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);
 
  // 是否過期
  const isStaled = Date.now() - data.time > cacheTime;
  // 已經(jīng)過期了,且也沒有在請求中,需要發(fā)送請求
  if (isStaled && !data.promise) {
    data.promise = fetcher()
      .then((val) => {
        data.value = val;
        data.time = Date.now(); // 保存獲取到數(shù)據(jù)的時間
      });
      .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ù)據(jù)。隨后調(diào)用會立即返回緩存數(shù)據(jù)。如果調(diào)用間隔超過 3s,將先返回緩存數(shù)據(jù),再請求接口獲取最新的數(shù)據(jù)。

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

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

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

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

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 緩存淘汰
讓我們來繼續(xù)發(fā)揮 Map 的能力。

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

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

整個流程大致為:

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

其實,LRU 算法通常會單獨作為一道手寫題,因此今天我們也手寫鞏固一下:

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

規(guī)定它的最大緩存容量 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
// 后續(xù)的 SWR 代碼不做改動
使用 Map 實現(xiàn) LRU 的時間復雜度為 O(1)

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

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

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






作者:Leecason

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