如何基於 HTTP 緩存失效策略實現 Request 緩存?

前端面試的最後一道題往往是手寫題,題目不限於基礎 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,最近最少使用)算法根據數據的歷史訪問記錄來進行淘汰數據,其核心思想是 “如果數據最近被訪問過,那麼將來被訪問的幾率也更高”。

整個流程大致爲:

  1. 新加入的數據插入到第一項

  2. 每當緩存命中(即緩存數據被訪問),則將數據提升到第一項

  3. 當緩存數量滿的時候,將最後一項的數據丟棄

由於面試時間有限,我不推薦大家在面試時繼續寫了,很容易弄巧成拙。但你可以積極地向面試官介紹這個思路和想法,繼續加分,最好再補一句:“Vue 的 keep-alive 組件中就用到了此算法”,間接地向面試官傳遞你清楚 Vue 相關的原理實現這個信息。

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

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

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)

結語

可見,一個小小的手寫題還是隱藏了很多很深的知識點的。面試官考察的是你全方位的能力,如果你寫出了以上的代碼,並向面試官陳述你因爲時間關係沒來得及實現的後續特性,可以體現你多方面的能力:

如果我是面試官,一定已經被驚豔到了。

最後,祝各位都能在金三銀四贏得滿意的 offer!

參考資料

[1]

HTTP RFC 5861: https://tools.ietf.org/html/rfc5861

前端印象 零一,分享技術,不止前端

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/hFucatFUTwz1N6TTvG-xKw