這兩天用到 cacheables 快取庫,覺得挺不錯的,和大家分享一下我看完原始碼的總結。
一、介紹
「cacheables」正如它名字一樣,是用來做記憶體快取使用,其程式碼僅僅 200 行左右(不含註釋),官方的介紹如下:
一個簡單的記憶體快取,支援不同的快取策略,使用 TypeScript 編寫優雅的語法。
它的特點:
- 優雅的語法,包裝現有 API 呼叫,節省 API 呼叫;
- 完全輸入的結果。不需要型別轉換。
- 支援不同的快取策略。
- 整合日誌:檢查 API 呼叫的時間。
- 使用輔助函式來構建快取 key。
- 適用於瀏覽器和 Node.js。
- 沒有依賴。
- 進行大範圍測試。
- 體積小,gzip 之後 1.43kb。
當我們業務中需要對請求等非同步任務做快取,避免重複請求時,完全可以使用上「cacheables」。
二、上手體驗
上手 cacheables
很簡單,看看下面使用對比:
// 沒有使用快取
fetch("https://some-url.com/api");
// 有使用快取
cache.cacheable(() => fetch("https://some-url.com/api"), "key");
接下來看下官網提供的快取請求的使用示例:
1. 安裝依賴
npm install cacheables
// 或者
pnpm add cacheables
2. 使用示例
import { Cacheables } from "cacheables";
const apiUrl = "http://localhost:3000/";
// 建立一個新的快取例項 ①
const cache = new Cacheables({
logTiming: true,
log: true,
});
// 模擬非同步任務
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// 包裝一個現有 API 呼叫 fetch(apiUrl),並分配一個 key 為 weather
// 下面例子使用 'max-age' 快取策略,它會在一段時間後快取失效
// 該方法返回一個完整 Promise,就像' fetch(apiUrl) '一樣,可以快取結果。
const getWeatherData = () =>
// ②
cache.cacheable(() => fetch(apiUrl), "weather", {
cachePolicy: "max-age",
maxAge: 5000,
});
const start = async () => {
// 獲取新資料,並新增到快取中
const weatherData = await getWeatherData();
// 3秒之後再執行
await wait(3000);
// 快取新資料,maxAge設定5秒,此時還未過期
const cachedWeatherData = await getWeatherData();
// 3秒之後再執行
await wait(3000);
// 快取超過5秒,此時已過期,此時請求的資料將會再快取起來
const freshWeatherData = await getWeatherData();
};
start();
上面示例程式碼我們就實現一個請求快取的業務,在 maxAge
為 5 秒內的重複請求,不會重新傳送請求,而是從快取讀取其結果進行返回。
3. API 介紹
官方文件中介紹了很多 API,具體可以從文件中獲取,比較常用的如 cache.cacheable()
,用來包裝一個方法進行快取。
所有 API 如下:
new Cacheables(options?): Cacheables
cache.cacheable(resource, key, options?): Promise<T>
cache.delete(key: string): void
cache.clear(): void
cache.keys(): string[]
cache.isCached(key: string): boolean
Cacheables.key(...args: (string | number)[]): string
可以通過下圖加深理解:
三、原始碼分析
克隆 cacheables 專案下來後,可以看到主要邏輯都在 index.ts
中,去掉換行和註釋,程式碼量 200 行左右,閱讀起來比較簡單。
接下來我們按照官方提供的示例,作為主線來閱讀原始碼。
1. 建立快取例項
示例中第 ① 步中,先通過 new Cacheables()
建立一個快取例項,在原始碼中Cacheables
類的定義如下,這邊先刪掉多餘程式碼,看下類提供的方法和作用:
export class Cacheables {
constructor(options?: CacheOptions) {
this.enabled = options?.enabled ?? true;
this.log = options?.log ?? false;
this.logTiming = options?.logTiming ?? false;
}
// 使用提供的引數建立一個 key
static key(): string {}
// 刪除一筆快取
delete(): void {}
// 清除所有快取
clear(): void {}
// 返回指定 key 的快取物件是否存在,並且有效(即是否超時)
isCached(key: string): boolean {}
// 返回所有的快取 key
keys(): string[] {}
// 用來包裝方法呼叫,做快取
async cacheable<T>(): Promise<T> {}
}
這樣就很直觀清楚 cacheables 例項的作用和支援的方法,其 UML 類圖如下:
在第 ① 步例項化時,Cacheables 內部建構函式會將入參儲存起來,介面定義如下:
const cache = new Cacheables({
logTiming: true,
log: true,
});
export type CacheOptions = {
// 快取開關
enabled?: boolean;
// 啟用/禁用快取命中日誌
log?: boolean;
// 啟用/禁用計時
logTiming?: boolean;
};
根據引數可以看出,此時我們 Cacheables 例項支援快取日誌和計時功能。
2. 包裝快取方法
第 ② 步中,我們將請求方法包裝在 cache.cacheable
方法中,實現使用 max-age
作為快取策略,並且有效期 5000 毫秒的快取:
const getWeatherData = () =>
cache.cacheable(() => fetch(apiUrl), "weather", {
cachePolicy: "max-age",
maxAge: 5000,
});
其中,cacheable
方法是 Cacheables
類上的成員方法,定義如下(移除日誌相關程式碼):
// 執行快取設定
async cacheable<T>(
resource: () => Promise<T>, // 一個返回Promise的函式
key: string, // 快取的 key
options?: CacheableOptions, // 快取策略
): Promise<T> {
const shouldCache = this.enabled
// 沒有啟用快取,則直接呼叫傳入的函式,並返回撥用結果
if (!shouldCache) {
return resource()
}
// ... 省略日誌程式碼
const result = await this.#cacheable(resource, key, options) // 核心
// ... 省略日誌程式碼
return result
}
其中cacheable
方法接收三個引數:
resource
:需要包裝的函式,是一個返回 Promise 的函式,如() => fetch()
;key
:用來做快取的key
;options
:快取策略的配置選項;
返回 this.#cacheable
私有方法執行的結果,this.#cacheable
私有方法實現如下:
// 處理快取,如儲存快取物件等
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
}
// 呼叫對應快取策略
return await cacheable.touch(resource, options)
}
this.#cacheable
私有方法接收的引數與 cacheable
方法一樣,返回的是 cacheable.touch
方法呼叫的結果。
如果 key 的快取物件不存在,則通過 Cacheable
類建立一個,其 UML 類圖如下:
3. 處理快取策略
上一步中,會通過呼叫 cacheable.touch
方法,來執行對應快取策略,該方法定義如下:
// 執行快取策略的方法
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 選擇對應策略進行處理
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
方法接收兩個引數,來自 #cacheable
私有方法引數的 resource
和 options
。
本案例使用的是 max-age
快取策略,所以我們看看對應的 #handleMaxAge
私有方法定義(其他的類似):
// maxAge 快取策略的處理方法
#handleMaxAge(resource: () => Promise<T>, maxAge: number) {
// #lastFetch 最後傳送時間,在 fetch 時會記錄當前時間
// 如果當前時間大於 #lastFetch + maxAge 時,會非併發呼叫傳入的方法
if (!this.#lastFetch || Date.now() > this.#lastFetch + maxAge) {
return this.#fetchNonConcurrent(resource)
}
return this.#value // 如果是快取期間,則直接返回前面快取的結果
}
當我們第二次執行 getWeatherData()
已經是 6 秒後,已經超過 maxAge
設定的 5 秒,所有之後就會快取失效,重新發請求。
再看下 #fetchNonConcurrent
私有方法定義,該方法用來傳送非併發的請求:
// 傳送非併發請求
async #fetchNonConcurrent(resource: () => Promise<T>): Promise<T> {
// 非併發情況,如果當前請求還在傳送中,則直接執行當前執行中的方法,並返回結果
if (this.#isFetching(this.#promise)) {
await this.#promise
return this.#value
}
// 否則直接執行傳入的方法
return this.#fetch(resource)
}
#fetchNonConcurrent
私有方法只接收引數 resource
,即需要包裝的函式。
這邊先判斷當前是否是【傳送中】狀態,如果則直接呼叫 this.#promise
,並返回快取的值,結束呼叫。否則將 resource
傳入 #fetch
執行。
#fetch
私有方法定義如下:
// 執行請求傳送
async #fetch(resource: () => Promise<T>): Promise<T> {
this.#lastFetch = Date.now()
this.#promise = resource() // 定義守衛變數,表示當前有任務在執行
this.#value = await this.#promise
if (!this.#initialized) this.#initialized = true
this.#promise = undefined // 執行完成,清空守衛變數
return this.#value
}
#fetch
私有方法接收前面的需要包裝的函式,並通過對守衛變數賦值,控制任務的執行,在剛開始執行時進行賦值,任務執行完成以後,清空守衛變數。
這也是我們實際業務開發經常用到的方法,比如發請求前,通過一個變數賦值,表示當前有任務執行,不能在發其他請求,在請求結束後,將該變數清空,繼續執行其他任務。
完成任務。「cacheables」執行過程大致是這樣,接下來我們總結一個通用的快取方案,便於理解和擴充。
四、通用快取庫設計方案
在 Cacheables 中支援五種快取策略,上面只介紹其中的 max-age
:
這裡總結一套通用快取庫設計方案,大致如下圖:
該快取庫支援例項化是傳入 options
引數,將使用者傳入的 options.key
作為 key,呼叫CachePolicyHandler
物件中獲取使用者指定的快取策略(Cache Policy)。
然後將使用者傳入的 options.resource
作為實際要執行的方法,通過 CachePlicyHandler()
方法傳入並執行。
上圖中,我們需要定義各種快取庫操作方法(如讀取、設定快取的方法)和各種快取策略的處理方法。
當然也可以整合如 Logger
等輔助工具,方便使用者使用和開發。本文就不在贅述,核心還是介紹這個方案。
五、總結
本文與大家分享 cacheables 快取庫原始碼核心邏輯,其原始碼邏輯並不複雜,主要便是支援各種快取策略和對應的處理邏輯。文章最後和大家歸納一種通用快取庫設計方案,大家有興趣可以自己實戰試試,好記性不如爛筆頭。
思路最重要,這種思路可以運用在很多場景,大家可以在實際業務中多多練習和總結。
六、還有幾點思考
1. 思考讀原始碼的方法
大家都在讀原始碼,討論原始碼,那如何讀原始碼?
個人建議:
- 先確定自己要學原始碼的部分(如 Vue2 響應式原理、Vue3 Ref 等);
- 根據要學的部分,寫個簡單 demo;
- 通過 demo 斷點進行大致瞭解;
- 翻閱原始碼,詳細閱讀,因為原始碼中往往會有註釋和示例等。
如果你只是單純想開始學某個庫,可以先閱讀 README.md,重點開介紹、特點、使用方法、示例等。抓住其特點、示例進行鍼對性的原始碼閱讀。
相信這樣閱讀起來,思路會更清晰。
2. 思考面向介面程式設計
這個庫使用了 TypeScript,通過每個介面定義,我們能很清晰的知道每個類、方法、屬性作用。這也是我們需要學習的。
在我們接到需求任務時,可以這樣做,你的效率往往會提高很多:
- 功能分析:對整個需求進行分析,瞭解需要實現的功能和細節,通過 xmind 等工具進行梳理,避免做著做著,經常返工,並且程式碼結構混亂。
- 功能設計:梳理完需求後,可以對每個部分進行設計,如抽取通用方法等,
- 功能實現:前兩步都做好,相信功能實現已經不是什麼難度了~
3. 思考這個庫的優化點
這個庫程式碼主要集中在 index.ts
中,閱讀起來還好,當程式碼量增多後,恐怕閱讀體驗比較不好。
所以我的建議是:
- 對程式碼進行拆分,將一些獨立的邏輯拆到單獨檔案維護,比如每個快取策略的邏輯,可以單獨一個檔案,通過統一開發方式開發(如 Plugin),再統一入口檔案匯入和匯出。
- 可以將
Logger
這類內部工具方法改造成支援使用者自定義,比如可以使用其他日誌工具方法,不一定使用內建 Logger,更加解耦。可以參考外掛化架構設計,這樣這個庫會更加靈活可擴充。