如何基於 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,最近最少使用)算法根據數據的歷史訪問記錄來進行淘汰數據,其核心思想是 “如果數據最近被訪問過,那麼將來被訪問的幾率也更高”。
整個流程大致爲:
-
新加入的數據插入到第一項
-
每當緩存命中(即緩存數據被訪問),則將數據提升到第一項
-
當緩存數量滿的時候,將最後一項的數據丟棄
由於面試時間有限,我不推薦大家在面試時繼續寫了,很容易弄巧成拙。但你可以積極地向面試官介紹這個思路和想法,繼續加分,最好再補一句:“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
前端印象 零一,分享技術,不止前端
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/hFucatFUTwz1N6TTvG-xKw