200 行代碼實(shí)現(xiàn)一個(gè)高效緩存庫(kù)

一、介紹
「cacheables」正如它名字一樣,是用來做內(nèi)存緩存使用,其代碼僅僅 200 行左右(不含注釋),官方的介紹如下:
一個(gè)簡(jiǎn)單的內(nèi)存緩存,支持不同的緩存策略,使用 TypeScript 編寫優(yōu)雅的語(yǔ)法。
它的特點(diǎn):
優(yōu)雅的語(yǔ)法,包裝現(xiàn)有 API 調(diào)用,節(jié)省 API 調(diào)用; 完全輸入的結(jié)果。不需要類型轉(zhuǎn)換。 支持不同的緩存策略。 集成日志:檢查 API 調(diào)用的時(shí)間。 使用輔助函數(shù)來構(gòu)建緩存 key。 適用于瀏覽器和 Node.js。 沒有依賴。 進(jìn)行大范圍測(cè)試。 體積小,gzip 之后 1.43kb。
當(dāng)我們業(yè)務(wù)中需要對(duì)請(qǐng)求等異步任務(wù)做緩存,避免重復(fù)請(qǐng)求時(shí),完全可以使用上「cacheables」。
二、上手體驗(yàn)
上手 cacheables很簡(jiǎn)單,看看下面使用對(duì)比:
// 沒有使用緩存
fetch("https://some-url.com/api");
// 有使用緩存
cache.cacheable(() => fetch("https://some-url.com/api"), "key");
接下來看下官網(wǎng)提供的緩存請(qǐng)求的使用示例:
1. 安裝依賴
npm install cacheables
// 或者
pnpm add cacheables
2. 使用示例
import { Cacheables } from "cacheables";
const apiUrl = "http://localhost:3000/";
// 創(chuàng)建一個(gè)新的緩存實(shí)例 ①
const cache = new Cacheables({
logTiming: true,
log: true,
});
// 模擬異步任務(wù)
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// 包裝一個(gè)現(xiàn)有 API 調(diào)用 fetch(apiUrl),并分配一個(gè) key 為 weather
// 下面例子使用 'max-age' 緩存策略,它會(huì)在一段時(shí)間后緩存失效
// 該方法返回一個(gè)完整 Promise,就像' fetch(apiUrl) '一樣,可以緩存結(jié)果。
const getWeatherData = () =>
// ②
cache.cacheable(() => fetch(apiUrl), "weather", {
cachePolicy: "max-age",
maxAge: 5000,
});
const start = async () => {
// 獲取新數(shù)據(jù),并添加到緩存中
const weatherData = await getWeatherData();
// 3秒之后再執(zhí)行
await wait(3000);
// 緩存新數(shù)據(jù),maxAge設(shè)置5秒,此時(shí)還未過期
const cachedWeatherData = await getWeatherData();
// 3秒之后再執(zhí)行
await wait(3000);
// 緩存超過5秒,此時(shí)已過期,此時(shí)請(qǐng)求的數(shù)據(jù)將會(huì)再緩存起來
const freshWeatherData = await getWeatherData();
};
start();
上面示例代碼我們就實(shí)現(xiàn)一個(gè)請(qǐng)求緩存的業(yè)務(wù),在 maxAge為 5 秒內(nèi)的重復(fù)請(qǐng)求,不會(huì)重新發(fā)送請(qǐng)求,而是從緩存讀取其結(jié)果進(jìn)行返回。
3. API 介紹
官方文檔中介紹了很多 API,具體可以從文檔[2]中獲取,比較常用的如 cache.cacheable(),用來包裝一個(gè)方法進(jìn)行緩存。所有 API 如下:
new Cacheables(options?): Cacheablescache.cacheable(resource, key, options?): Promise<T>cache.delete(key: string): voidcache.clear(): voidcache.keys(): string[]cache.isCached(key: string): booleanCacheables.key(...args: (string | number)[]): string
可以通過下圖加深理解:
三、源碼分析
克隆 cacheables[3] 項(xiàng)目下來后,可以看到主要邏輯都在 index.ts中,去掉換行和注釋,代碼量 200 行左右,閱讀起來比較簡(jiǎn)單。接下來我們按照官方提供的示例,作為主線來閱讀源碼。
1. 創(chuàng)建緩存實(shí)例
示例中第 ① 步中,先通過 new Cacheables()創(chuàng)建一個(gè)緩存實(shí)例,在源碼中Cacheables類的定義如下,這邊先刪掉多余代碼,看下類提供的方法和作用:
export class Cacheables {
constructor(options?: CacheOptions) {
this.enabled = options?.enabled ?? true;
this.log = options?.log ?? false;
this.logTiming = options?.logTiming ?? false;
}
// 使用提供的參數(shù)創(chuàng)建一個(gè) key
static key(): string {}
// 刪除一筆緩存
delete(): void {}
// 清除所有緩存
clear(): void {}
// 返回指定 key 的緩存對(duì)象是否存在,并且有效(即是否超時(shí))
isCached(key: string): boolean {}
// 返回所有的緩存 key
keys(): string[] {}
// 用來包裝方法調(diào)用,做緩存
async cacheable<T>(): Promise<T> {}
}
這樣就很直觀清楚 cacheables 實(shí)例的作用和支持的方法,其 UML 類圖如下:

在第 ① 步實(shí)例化時(shí),Cacheables 內(nèi)部構(gòu)造函數(shù)會(huì)將入?yún)⒈4嫫饋?,接口定義如下:
const cache = new Cacheables({
logTiming: true,
log: true,
});
export type CacheOptions = {
// 緩存開關(guān)
enabled?: boolean;
// 啟用/禁用緩存命中日志
log?: boolean;
// 啟用/禁用計(jì)時(shí)
logTiming?: boolean;
};
根據(jù)參數(shù)可以看出,此時(shí)我們 Cacheables 實(shí)例支持緩存日志和計(jì)時(shí)功能。
2. 包裝緩存方法
第 ② 步中,我們將請(qǐng)求方法包裝在 cache.cacheable方法中,實(shí)現(xiàn)使用 max-age作為緩存策略,并且有效期 5000 毫秒的緩存:
const getWeatherData = () =>
cache.cacheable(() => fetch(apiUrl), "weather", {
cachePolicy: "max-age",
maxAge: 5000,
});
其中,cacheable 方法是 Cacheables類上的成員方法,定義如下(移除日志相關(guān)代碼):
// 執(zhí)行緩存設(shè)置
async cacheable<T>(
resource: () => Promise<T>, // 一個(gè)返回Promise的函數(shù)
key: string, // 緩存的 key
options?: CacheableOptions, // 緩存策略
): Promise<T> {
const shouldCache = this.enabled
// 沒有啟用緩存,則直接調(diào)用傳入的函數(shù),并返回調(diào)用結(jié)果
if (!shouldCache) {
return resource()
}
// ... 省略日志代碼
const result = await this.#cacheable(resource, key, options) // 核心
// ... 省略日志代碼
return result
}
其中cacheable 方法接收三個(gè)參數(shù):
resource:需要包裝的函數(shù),是一個(gè)返回 Promise 的函數(shù),如() => fetch();key:用來做緩存的key;options:緩存策略的配置選項(xiàng);
返回 this.#cacheable私有方法執(zhí)行的結(jié)果,this.#cacheable私有方法實(shí)現(xiàn)如下:
// 處理緩存,如保存緩存對(duì)象等
async #cacheable<T>(
resource: () => Promise<T>,
key: string,
options?: CacheableOptions,
): Promise<T> {
// 先通過 key 獲取緩存對(duì)象
let cacheable = this.#cacheables[key] as Cacheable<T> | undefined
// 如果不存在該 key 下的緩存對(duì)象,則通過 Cacheable 實(shí)例化一個(gè)新的緩存對(duì)象
// 并保存在該 key 下
if (!cacheable) {
cacheable = new Cacheable()
this.#cacheables[key] = cacheable
}
// 調(diào)用對(duì)應(yīng)緩存策略
return await cacheable.touch(resource, options)
}
this.#cacheable私有方法接收的參數(shù)與 cacheable方法一樣,返回的是 cacheable.touch方法調(diào)用的結(jié)果。如果 key 的緩存對(duì)象不存在,則通過 Cacheable類創(chuàng)建一個(gè),其 UML 類圖如下:
3. 處理緩存策略
上一步中,會(huì)通過調(diào)用 cacheable.touch方法,來執(zhí)行對(duì)應(yīng)緩存策略,該方法定義如下:
// 執(zhí)行緩存策略的方法
async touch(
resource: () => Promise<T>,
options?: CacheableOptions,
): Promise<T> {
if (!this.#initialized) {
return this.#handlePreInit(resource, options)
}
if (!options) {
return this.#handleCacheOnly()
}
// 通過實(shí)例化 Cacheables 時(shí)候配置的 options 的 cachePolicy 選擇對(duì)應(yīng)策略進(jìn)行處理
switch (options.cachePolicy) {
case 'cache-only':
return this.#handleCacheOnly()
case 'network-only':
return this.#handleNetworkOnly(resource)
case 'stale-while-revalidate':
return this.#handleSwr(resource)
case 'max-age': // 本案例使用的類型
return this.#handleMaxAge(resource, options.maxAge)
case 'network-only-non-concurrent':
return this.#handleNetworkOnlyNonConcurrent(resource)
}
}
touch方法接收兩個(gè)參數(shù),來自 #cacheable私有方法參數(shù)的 resource和 options。本案例使用的是 max-age緩存策略,所以我們看看對(duì)應(yīng)的 #handleMaxAge私有方法定義(其他的類似):
// maxAge 緩存策略的處理方法
#handleMaxAge(resource: () => Promise<T>, maxAge: number) {
// #lastFetch 最后發(fā)送時(shí)間,在 fetch 時(shí)會(huì)記錄當(dāng)前時(shí)間
// 如果當(dāng)前時(shí)間大于 #lastFetch + maxAge 時(shí),會(huì)非并發(fā)調(diào)用傳入的方法
if (!this.#lastFetch || Date.now() > this.#lastFetch + maxAge) {
return this.#fetchNonConcurrent(resource)
}
return this.#value // 如果是緩存期間,則直接返回前面緩存的結(jié)果
}
當(dāng)我們第二次執(zhí)行 getWeatherData() 已經(jīng)是 6 秒后,已經(jīng)超過 maxAge設(shè)置的 5 秒,所有之后就會(huì)緩存失效,重新發(fā)請(qǐng)求。
再看下 #fetchNonConcurrent私有方法定義,該方法用來發(fā)送非并發(fā)的請(qǐng)求:
// 發(fā)送非并發(fā)請(qǐng)求
async #fetchNonConcurrent(resource: () => Promise<T>): Promise<T> {
// 非并發(fā)情況,如果當(dāng)前請(qǐng)求還在發(fā)送中,則直接執(zhí)行當(dāng)前執(zhí)行中的方法,并返回結(jié)果
if (this.#isFetching(this.#promise)) {
await this.#promise
return this.#value
}
// 否則直接執(zhí)行傳入的方法
return this.#fetch(resource)
}
#fetchNonConcurrent私有方法只接收參數(shù) resource,即需要包裝的函數(shù)。這邊先判斷當(dāng)前是否是【發(fā)送中】狀態(tài),如果則直接調(diào)用 this.#promise,并返回緩存的值,結(jié)束調(diào)用。否則將 resource 傳入 #fetch執(zhí)行。
#fetch私有方法定義如下:
// 執(zhí)行請(qǐng)求發(fā)送
async #fetch(resource: () => Promise<T>): Promise<T> {
this.#lastFetch = Date.now()
this.#promise = resource() // 定義守衛(wèi)變量,表示當(dāng)前有任務(wù)在執(zhí)行
this.#value = await this.#promise
if (!this.#initialized) this.#initialized = true
this.#promise = undefined // 執(zhí)行完成,清空守衛(wèi)變量
return this.#value
}
#fetch 私有方法接收前面的需要包裝的函數(shù),并通過對(duì)「守衛(wèi)變量」賦值,控制任務(wù)的執(zhí)行,在剛開始執(zhí)行時(shí)進(jìn)行賦值,任務(wù)執(zhí)行完成以后,清空守衛(wèi)變量。這也是我們實(shí)際業(yè)務(wù)開發(fā)經(jīng)常用到的方法,比如發(fā)請(qǐng)求前,通過一個(gè)變量賦值,表示當(dāng)前有任務(wù)執(zhí)行,不能在發(fā)其他請(qǐng)求,在請(qǐng)求結(jié)束后,將該變量清空,繼續(xù)執(zhí)行其他任務(wù)。完成任務(wù)。「cacheables」執(zhí)行過程大致是這樣,接下來我們總結(jié)一個(gè)通用的緩存方案,便于理解和拓展。
四、通用緩存庫(kù)設(shè)計(jì)方案
在 Cacheables 中支持五種緩存策略,上面只介紹其中的 max-age:

這里總結(jié)一套通用緩存庫(kù)設(shè)計(jì)方案,大致如下圖:

該緩存庫(kù)支持實(shí)例化是傳入 options參數(shù),將用戶傳入的 options.key作為 key,調(diào)用CachePolicyHandler對(duì)象中獲取用戶指定的緩存策略(Cache Policy)。然后將用戶傳入的 options.resource作為實(shí)際要執(zhí)行的方法,通過 CachePlicyHandler()方法傳入并執(zhí)行。上圖中,我們需要定義各種緩存庫(kù)操作方法(如讀取、設(shè)置緩存的方法)和各種緩存策略的處理方法。當(dāng)然也可以集成如 Logger等輔助工具,方便用戶使用和開發(fā)。本文就不在贅述,核心還是介紹這個(gè)方案。
五、總結(jié)
本文與大家分享 cacheables[4] 緩存庫(kù)源碼核心邏輯,其源碼邏輯并不復(fù)雜,主要便是支持各種緩存策略和對(duì)應(yīng)的處理邏輯。文章最后和大家歸納一種通用緩存庫(kù)設(shè)計(jì)方案,大家有興趣可以自己實(shí)戰(zhàn)試試,好記性不如爛筆頭。思路最重要,這種思路可以運(yùn)用在很多場(chǎng)景,大家可以在實(shí)際業(yè)務(wù)中多多練習(xí)和總結(jié)。
六、還有幾點(diǎn)思考
1. 思考讀源碼的方法
大家都在讀源碼,討論源碼,那如何讀源碼?個(gè)人建議:
先確定自己要學(xué)源碼的部分(如 Vue2 響應(yīng)式原理、Vue3 Ref 等); 根據(jù)要學(xué)的部分,寫個(gè)簡(jiǎn)單 demo; 通過 demo 斷點(diǎn)進(jìn)行大致了解; 翻閱源碼,詳細(xì)閱讀,因?yàn)樵创a中往往會(huì)有注釋和示例等。
如果你只是單純想開始學(xué)某個(gè)庫(kù),可以先閱讀 README.md,重點(diǎn)開介紹、特點(diǎn)、使用方法、示例等。抓住其特點(diǎn)、示例進(jìn)行針對(duì)性的源碼閱讀。相信這樣閱讀起來,思路會(huì)更清晰。
2. 思考面向接口編程
這個(gè)庫(kù)使用了 TypeScript,通過每個(gè)接口定義,我們能很清晰的知道每個(gè)類、方法、屬性作用。這也是我們需要學(xué)習(xí)的。在我們接到需求任務(wù)時(shí),可以這樣做,你的效率往往會(huì)提高很多:
「功能分析」:對(duì)整個(gè)需求進(jìn)行分析,了解需要實(shí)現(xiàn)的功能和細(xì)節(jié),通過 xmind 等工具進(jìn)行梳理,避免做著做著,經(jīng)常返工,并且代碼結(jié)構(gòu)混亂。 「功能設(shè)計(jì)」:梳理完需求后,可以對(duì)每個(gè)部分進(jìn)行設(shè)計(jì),如抽取通用方法等, 「功能實(shí)現(xiàn)」:前兩步都做好,相信功能實(shí)現(xiàn)已經(jīng)不是什么難度了~
3. 思考這個(gè)庫(kù)的優(yōu)化點(diǎn)
這個(gè)庫(kù)代碼主要集中在 index.ts中,閱讀起來還好,當(dāng)代碼量增多后,恐怕閱讀體驗(yàn)比較不好。所以我的建議是:
對(duì)代碼進(jìn)行拆分,將一些獨(dú)立的邏輯拆到單獨(dú)文件維護(hù),比如每個(gè)緩存策略的邏輯,可以單獨(dú)一個(gè)文件,通過統(tǒng)一開發(fā)方式開發(fā)(如 Plugin),再統(tǒng)一入口文件導(dǎo)入和導(dǎo)出。 可以將 Logger這類內(nèi)部工具方法改造成支持用戶自定義,比如可以使用其他日志工具方法,不一定使用內(nèi)置 Logger,更加解耦??梢詤⒖疾寮軜?gòu)設(shè)計(jì),這樣這個(gè)庫(kù)會(huì)更加靈活可拓展。
參考資料
cacheables: https://github.com/grischaerbe/cacheables
[2]文檔: https://github.com/grischaerbe/cacheables
[3]cacheables: https://github.com/grischaerbe/cacheables
[4]cacheables: https://github.com/grischaerbe/cacheables

