200 行代碼實現(xiàn)一個高效緩存庫

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

在第 ① 步實例化時,Cacheables 內(nèi)部構(gòu)造函數(shù)會將入?yún)⒈4嫫饋恚涌诙x如下:
const cache = new Cacheables({
logTiming: true,
log: true,
});
export type CacheOptions = {
// 緩存開關(guān)
enabled?: boolean;
// 啟用/禁用緩存命中日志
log?: boolean;
// 啟用/禁用計時
logTiming?: boolean;
};
根據(jù)參數(shù)可以看出,此時我們 Cacheables 實例支持緩存日志和計時功能。
2. 包裝緩存方法
第 ② 步中,我們將請求方法包裝在 cache.cacheable方法中,實現(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>, // 一個返回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 方法接收三個參數(shù):
resource:需要包裝的函數(shù),是一個返回 Promise 的函數(shù),如() => fetch();key:用來做緩存的key;options:緩存策略的配置選項;
返回 this.#cacheable私有方法執(zhí)行的結(jié)果,this.#cacheable私有方法實現(xiàn)如下:
// 處理緩存,如保存緩存對象等
async #cacheable<T>(
resource: () => Promise<T>,
key: string,
options?: CacheableOptions,
): Promise<T> {
// 先通過 key 獲取緩存對象
let cacheable = this.#cacheables[key] as Cacheable<T> | undefined
// 如果不存在該 key 下的緩存對象,則通過 Cacheable 實例化一個新的緩存對象
// 并保存在該 key 下
if (!cacheable) {
cacheable = new Cacheable()
this.#cacheables[key] = cacheable
}
// 調(diào)用對應(yīng)緩存策略
return await cacheable.touch(resource, options)
}
this.#cacheable私有方法接收的參數(shù)與 cacheable方法一樣,返回的是 cacheable.touch方法調(diào)用的結(jié)果。如果 key 的緩存對象不存在,則通過 Cacheable類創(chuàng)建一個,其 UML 類圖如下:
3. 處理緩存策略
上一步中,會通過調(diào)用 cacheable.touch方法,來執(zhí)行對應(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()
}
// 通過實例化 Cacheables 時候配置的 options 的 cachePolicy 選擇對應(yīng)策略進行處理
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方法接收兩個參數(shù),來自 #cacheable私有方法參數(shù)的 resource和 options。本案例使用的是 max-age緩存策略,所以我們看看對應(yīng)的 #handleMaxAge私有方法定義(其他的類似):
// maxAge 緩存策略的處理方法
#handleMaxAge(resource: () => Promise<T>, maxAge: number) {
// #lastFetch 最后發(fā)送時間,在 fetch 時會記錄當(dāng)前時間
// 如果當(dāng)前時間大于 #lastFetch + maxAge 時,會非并發(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 秒,所有之后就會緩存失效,重新發(fā)請求。
再看下 #fetchNonConcurrent私有方法定義,該方法用來發(fā)送非并發(fā)的請求:
// 發(fā)送非并發(fā)請求
async #fetchNonConcurrent(resource: () => Promise<T>): Promise<T> {
// 非并發(fā)情況,如果當(dā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í)行請求發(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ù),并通過對「守衛(wèi)變量」賦值,控制任務(wù)的執(zhí)行,在剛開始執(zhí)行時進行賦值,任務(wù)執(zhí)行完成以后,清空守衛(wèi)變量。這也是我們實際業(yè)務(wù)開發(fā)經(jīng)常用到的方法,比如發(fā)請求前,通過一個變量賦值,表示當(dāng)前有任務(wù)執(zhí)行,不能在發(fā)其他請求,在請求結(jié)束后,將該變量清空,繼續(xù)執(zhí)行其他任務(wù)。完成任務(wù)?!竎acheables」執(zhí)行過程大致是這樣,接下來我們總結(jié)一個通用的緩存方案,便于理解和拓展。
四、通用緩存庫設(shè)計方案
在 Cacheables 中支持五種緩存策略,上面只介紹其中的 max-age:

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

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

