手寫一個基於 Proxy 的緩存庫
兩年前,我寫了一篇關於業務緩存的文章 前端 api 請求緩存方案, 反響還不錯,其中介紹瞭如何緩存數據,Promise 以及如何超時刪除(也包括如何構建修飾器)。如果對此不夠了解,可以閱讀文章進行學習。
但之前的代碼和方案終歸還是簡單了些,而且對業務有很大的侵入性。這樣不好,於是筆者開始重新學習與思考代理器 Proxy。
Proxy 可以理解成,在目標對象之前架設一層 “攔截”,外界對該對象的訪問,都必須先通過這層攔截,因此提供了一種機制,可以對外界的訪問進行過濾和改寫。Proxy 這個詞的原意是代理,用在這裏表示由它來“代理” 某些操作,可以譯爲“代理器”。關於 Proxy 的介紹與使用,建議大家還是看阮一峯大神的 ECMAScript 6 入門 代理篇。
項目演進
任何項目都不是一觸而就的,下面是關於 Proxy 緩存庫的編寫思路。希望能對大家有一些幫助。
proxy handler 添加緩存
當然,其實代理器中的 handler 參數也是一個對象,那麼既然是對象,當然可以添加數據項,如此,我們便可以基於 Map 緩存編寫 memoize 函數用來提升算法遞歸性能。
type TargetFun<V> = (...args: any[]) => V
function memoize<V>(fn: TargetFun<V>) {
return new Proxy(fn, {
// 此處目前只能略過 或者 添加一箇中間層集成 Proxy 和 對象。
// 在對象中添加 cache
// @ts-ignore
cache: new Map<string, V>(),
apply(target, thisArg, argsList) {
// 獲取當前的 cache
const currentCache = (this as any).cache
// 根據數據參數直接生成 Map 的 key
let cacheKey = argsList.toString();
// 當前沒有被緩存,執行調用,添加緩存
if (!currentCache.has(cacheKey)) {
currentCache.set(cacheKey, target.apply(thisArg, argsList));
}
// 返回被緩存的數據
return currentCache.get(cacheKey);
}
});
}
我們可以嘗試 memoize fibonacci 函數,經過了代理器的函數有非常大的性能提升(肉眼可見):
const fibonacci = (n: number): number => (n <= 1 ? 1 : fibonacci(n - 1) + fibonacci(n - 2));
const memoizedFibonacci = memoize<number>(fibonacci);
for (let i = 0; i < 100; i++) fibonacci(30); // ~5000ms
for (let i = 0; i < 100; i++) memoizedFibonacci(30); // ~50ms
自定義函數參數
我們仍舊可以利用之前博客介紹的的函數生成唯一值,只不過我們不再需要函數名了:
const generateKeyError = new Error("Can't generate key from function argument")
// 基於函數參數生成唯一值
export default function generateKey(argument: any[]): string {
try{
return `${Array.from(argument).join(',')}`
}catch(_) {
throw generateKeyError
}
}
雖然庫本身可以基於函數參數提供唯一值,但是針對形形色色的不同業務來說,這肯定是不夠用的,需要提供用戶可以自定義參數序列化。
// 如果配置中有 normalizer 函數,直接使用,否則使用默認函數
const normalizer = options?.normalizer ?? generateKey
return new Proxy<any>(fn, {
// @ts-ignore
cache,
apply(target, thisArg, argsList: any[]) {
const cache: Map<string, any> = (this as any).cache
// 根據格式化函數生成唯一數值
const cacheKey: string = normalizer(argsList);
if (!cache.has(cacheKey))
cache.set(cacheKey, target.apply(thisArg, argsList));
return cache.get(cacheKey);
}
});
添加 Promise 緩存
在之前的博客中,提到緩存數據的弊端。同一時刻多次調用,會因爲請求未返回而進行多次請求。所以我們也需要添加關於 Promise 的緩存。
if (!currentCache.has(cacheKey)){
let result = target.apply(thisArg, argsList)
// 如果是 promise 則緩存 promise,簡單判斷!
// 如果當前函數有 then 則是 Promise
if (result?.then) {
result = Promise.resolve(result).catch(error => {
// 發生錯誤,刪除當前 promise,否則會引發二次錯誤
// 由於異步,所以當前 delete 調用一定在 set 之後,
currentCache.delete(cacheKey)
// 把錯誤衍生出去
return Promise.reject(error)
})
}
currentCache.set(cacheKey, result);
}
return currentCache.get(cacheKey);
此時,我們不但可以緩存數據,還可以緩存 Promise 數據請求。
添加過期刪除功能
我們可以在數據中添加當前緩存時的時間戳,在生成數據時候添加。
// 緩存項
export default class ExpiredCacheItem<V> {
data: V;
cacheTime: number;
constructor(data: V) {
this.data = data
// 添加系統時間戳
this.cacheTime = (new Date()).getTime()
}
}
// 編輯 Map 緩存中間層,判斷是否過期
isOverTime(name: string) {
const data = this.cacheMap.get(name)
// 沒有數據(因爲當前保存的數據是 ExpiredCacheItem),所以我們統一看成功超時
if (!data) return true
// 獲取系統當前時間戳
const currentTime = (new Date()).getTime()
// 獲取當前時間與存儲時間的過去的秒數
const overTime = currentTime - data.cacheTime
// 如果過去的秒數大於當前的超時時間,也返回 null 讓其去服務端取數據
if (Math.abs(overTime) > this.timeout) {
// 此代碼可以沒有,不會出現問題,但是如果有此代碼,再次進入該方法就可以減少判斷。
this.cacheMap.delete(name)
return true
}
// 不超時
return false
}
// cache 函數有數據
has(name: string) {
// 直接判斷在 cache 中是否超時
return !this.isOverTime(name)
}
到達這一步,我們可以做到之前博客所描述的所有功能。不過,如果到這裏就結束的話,太不過癮了。我們繼續學習其他庫的功能來優化我的功能庫。
添加手動管理
通常來說,這些緩存庫都會有手動管理的功能,所以這裏我也提供了手動管理緩存以便業務管理。這裏我們使用 Proxy get 方法來攔截屬性讀取。
return new Proxy(fn, {
// @ts-ignore
cache,
get: (target: TargetFun<V>, property: string) => {
// 如果配置了手動管理
if (options?.manual) {
const manualTarget = getManualActionObjFormCache<V>(cache)
// 如果當前調用的函數在當前對象中,直接調用,沒有的話訪問原對象
// 即使當前函數有該屬性或者方法也不考慮,誰讓你配置了手動管理呢。
if (property in manualTarget) {
return manualTarget[property]
}
}
// 當前沒有配置手動管理,直接訪問原對象
return target[property]
},
}
export default function getManualActionObjFormCache<V>(
cache: MemoizeCache<V>
): CacheMap<string | object, V> {
const manualTarget = Object.create(null)
// 通過閉包添加 set get delete clear 等 cache 操作
manualTarget.set = (key: string | object, val: V) => cache.set(key, val)
manualTarget.get = (key: string | object) => cache.get(key)
manualTarget.delete = (key: string | object) => cache.delete(key)
manualTarget.clear = () => cache.clear!()
return manualTarget
}
當前情況並不複雜,我們可以直接調用,複雜的情況下還是建議使用 Reflect 。
添加 WeakMap
我們在使用 cache 時候,我們同時也可以提供 WeakMap (WeakMap 沒有 clear 和 size 方法), 這裏我提取了 BaseCache 基類。
export default class BaseCache<V> {
readonly weak: boolean;
cacheMap: MemoizeCache<V>
constructor(weak: boolean = false) {
// 是否使用 weakMap
this.weak = weak
this.cacheMap = this.getMapOrWeakMapByOption()
}
// 根據配置獲取 Map 或者 WeakMap
getMapOrWeakMapByOption<T>(): Map<string, T> | WeakMap<object, T> {
return this.weak ? new WeakMap<object, T>() : new Map<string, T>()
}
}
之後,我添加各種類型的緩存類都以此爲基類。
添加清理函數
在緩存進行刪除時候需要對值進行清理,需要用戶提供 dispose 函數。該類繼承 BaseCache 同時提供 dispose 調用。
export const defaultDispose: DisposeFun<any> = () => void 0
export default class BaseCacheWithDispose<V, WrapperV> extends BaseCache<WrapperV> {
readonly weak: boolean
readonly dispose: DisposeFun<V>
constructor(weak: boolean = false, dispose: DisposeFun<V> = defaultDispose) {
super(weak)
this.weak = weak
this.dispose = dispose
}
// 清理單個值(調用 delete 前調用)
disposeValue(value: V | undefined): void {
if (value) {
this.dispose(value)
}
}
// 清理所有值(調用 clear 方法前調用,如果當前 Map 具有迭代器)
disposeAllValue<V>(cacheMap: MemoizeCache<V>): void {
for (let mapValue of (cacheMap as any)) {
this.disposeValue(mapValue?.[1])
}
}
}
當前的緩存如果是 WeakMap,是沒有 clear 方法和迭代器的。個人想要添加中間層來完成這一切 (還在考慮,目前沒有做)。如果 WeakMap 調用 clear 方法時,我是直接提供新的 WeakMap 。
clear() {
if (this.weak) {
this.cacheMap = this.getMapOrWeakMapByOption()
} else {
this.disposeAllValue(this.cacheMap)
this.cacheMap.clear!()
}
}
添加計數引用
在學習其他庫 memoizee 的過程中,我看到了如下用法:
memoized = memoize(fn, { refCounter: true });
memoized("foo", 3); // refs: 1
memoized("foo", 3); // Cache hit, refs: 2
memoized("foo", 3); // Cache hit, refs: 3
memoized.deleteRef("foo", 3); // refs: 2
memoized.deleteRef("foo", 3); // refs: 1
memoized.deleteRef("foo", 3); // refs: 0,清除 foo 的緩存
memoized("foo", 3); // Re-executed, refs: 1
於是我有樣學樣,也添加了 RefCache。
export default class RefCache<V> extends BaseCacheWithDispose<V, V> implements CacheMap<string | object, V> {
// 添加 ref 計數
cacheRef: MemoizeCache<number>
constructor(weak: boolean = false, dispose: DisposeFun<V> = () => void 0) {
super(weak, dispose)
// 根據配置生成 WeakMap 或者 Map
this.cacheRef = this.getMapOrWeakMapByOption<number>()
}
// get has clear 等相同。不列出
delete(key: string | object): boolean {
this.disposeValue(this.get(key))
this.cacheRef.delete(key)
this.cacheMap.delete(key)
return true;
}
set(key: string | object, value: V): this {
this.cacheMap.set(key, value)
// set 的同時添加 ref
this.addRef(key)
return this
}
// 也可以手動添加計數
addRef(key: string | object) {
if (!this.cacheMap.has(key)) {
return
}
const refCount: number | undefined = this.cacheRef.get(key)
this.cacheRef.set(key, (refCount ?? 0) + 1)
}
getRefCount(key: string | object) {
return this.cacheRef.get(key) ?? 0
}
deleteRef(key: string | object): boolean {
if (!this.cacheMap.has(key)) {
return false
}
const refCount: number = this.getRefCount(key)
if (refCount <= 0) {
return false
}
const currentRefCount = refCount - 1
// 如果當前 refCount 大於 0, 設置,否則清除
if (currentRefCount > 0) {
this.cacheRef.set(key, currentRefCount)
} else {
this.cacheRef.delete(key)
this.cacheMap.delete(key)
}
return true
}
}
同時修改 proxy 主函數:
if (!currentCache.has(cacheKey)) {
let result = target.apply(thisArg, argsList)
if (result?.then) {
result = Promise.resolve(result).catch(error => {
currentCache.delete(cacheKey)
return Promise.reject(error)
})
}
currentCache.set(cacheKey, result);
// 當前配置了 refCounter
} else if (options?.refCounter) {
// 如果被再次調用且當前已經緩存過了,直接增加
currentCache.addRef?.(cacheKey)
}
添加 LRU
LRU 的英文全稱是 Least Recently Used,也即最不經常使用。相比於其他的數據結構進行緩存,LRU 無疑更加有效。
這裏考慮在添加 maxAge 的同時也添加 max 值 (這裏我利用兩個 Map 來做 LRU,雖然會增加一定的內存消耗,但是性能更好)。
如果當前的此時保存的數據項等於 max ,我們直接把當前 cacheMap 設爲 oldCacheMap,並重新 new cacheMap。
set(key: string | object, value: V) {
const itemCache = new ExpiredCacheItem<V>(value)
// 如果之前有值,直接修改
this.cacheMap.has(key) ? this.cacheMap.set(key, itemCache) : this._set(key, itemCache);
return this
}
private _set(key: string | object, value: ExpiredCacheItem<V>) {
this.cacheMap.set(key, value);
this.size++;
if (this.size >= this.max) {
this.size = 0;
this.oldCacheMap = this.cacheMap;
this.cacheMap = this.getMapOrWeakMapByOption()
}
}
重點在與獲取數據時候,如果當前的 cacheMap 中有值且沒有過期,直接返回,如果沒有,就去 oldCacheMap 查找,如果有,刪除老數據並放入新數據 (使用 _set 方法),如果都沒有,返回 undefined.
get(key: string | object): V | undefined {
// 如果 cacheMap 有,返回 value
if (this.cacheMap.has(key)) {
const item = this.cacheMap.get(key);
return this.getItemValue(key, item!);
}
// 如果 oldCacheMap 裏面有
if (this.oldCacheMap.has(key)) {
const item = this.oldCacheMap.get(key);
// 沒有過期
if (!this.deleteIfExpired(key, item!)) {
// 移動到新的數據中並刪除老數據
this.moveToRecent(key, item!);
return item!.data as V;
}
}
return undefined
}
private moveToRecent(key: string | object, item: ExpiredCacheItem<V>) {
// 老數據刪除
this.oldCacheMap.delete(key);
// 新數據設定,重點!!!!如果當前設定的數據等於 max,清空 oldCacheMap,如此,數據不會超過 max
this._set(key, item);
}
private getItemValue(key: string | object, item: ExpiredCacheItem<V>): V | undefined {
// 如果當前設定了 maxAge 就查詢,否則直接返回
return this.maxAge ? this.getOrDeleteIfExpired(key, item) : item?.data;
}
private getOrDeleteIfExpired(key: string | object, item: ExpiredCacheItem<V>): V | undefined {
const deleted = this.deleteIfExpired(key, item);
return !deleted ? item.data : undefined;
}
private deleteIfExpired(key: string | object, item: ExpiredCacheItem<V>) {
if (this.isOverTime(item)) {
return this.delete(key);
}
return false;
}
整理 memoize 函數
事情到了這一步,我們就可以從之前的代碼細節中解放出來了,看看基於這些功能所做出的接口與主函數。
// 面向接口,無論後面還會不會增加其他類型的緩存類
export interface BaseCacheMap<K, V> {
delete(key: K): boolean;
get(key: K): V | undefined;
has(key: K): boolean;
set(key: K, value: V): this;
clear?(): void;
addRef?(key: K): void;
deleteRef?(key: K): boolean;
}
// 緩存配置
export interface MemoizeOptions<V> {
/** 序列化參數 */
normalizer?: (args: any[]) => string;
/** 是否使用 WeakMap */
weak?: boolean;
/** 最大毫秒數,過時刪除 */
maxAge?: number;
/** 最大項數,超過刪除 */
max?: number;
/** 手動管理內存 */
manual?: boolean;
/** 是否使用引用計數 */
refCounter?: boolean;
/** 緩存刪除數據時期的回調 */
dispose?: DisposeFun<V>;
}
// 返回的函數(攜帶一系列方法)
export interface ResultFun<V> extends Function {
delete?(key: string | object): boolean;
get?(key: string | object): V | undefined;
has?(key: string | object): boolean;
set?(key: string | object, value: V): this;
clear?(): void;
deleteRef?(): void
}
最終的 memoize 函數其實和最開始的函數差不多,只做了 3 件事
-
檢查參數並拋出錯誤
-
根據參數獲取合適的緩存
-
返回代理
export default function memoize<V>(fn: TargetFun<V>, options?: MemoizeOptions<V>): ResultFun<V> {
// 檢查參數並拋出錯誤
checkOptionsThenThrowError<V>(options)
// 修正序列化函數
const normalizer = options?.normalizer ?? generateKey
let cache: MemoizeCache<V> = getCacheByOptions<V>(options)
// 返回代理
return new Proxy(fn, {
// @ts-ignore
cache,
get: (target: TargetFun<V>, property: string) => {
// 添加手動管理
if (options?.manual) {
const manualTarget = getManualActionObjFormCache<V>(cache)
if (property in manualTarget) {
return manualTarget[property]
}
}
return target[property]
},
apply(target, thisArg, argsList: any[]): V {
const currentCache: MemoizeCache<V> = (this as any).cache
const cacheKey: string | object = getKeyFromArguments(argsList, normalizer, options?.weak)
if (!currentCache.has(cacheKey)) {
let result = target.apply(thisArg, argsList)
if (result?.then) {
result = Promise.resolve(result).catch(error => {
currentCache.delete(cacheKey)
return Promise.reject(error)
})
}
currentCache.set(cacheKey, result);
} else if (options?.refCounter) {
currentCache.addRef?.(cacheKey)
}
return currentCache.get(cacheKey) as V;
}
}) as any
}
完整代碼在 memoizee-proxy 中。大家自行操作與把玩。
下一步
測試
測試覆蓋率不代表一切,但是在實現庫的過程中,JEST 測試庫給我提供了大量的幫助,它幫助我重新思考每一個類以及每一個函數應該具有的功能與參數校驗。之前的代碼我總是在項目的主入口進行校驗,對於每個類或者函數的參數沒有深入思考。事實上,這個健壯性是不夠的。因爲你不能決定用戶怎麼使用你的庫。
Proxy 深入
事實上,代理的應用場景是不可限量的。這一點,ruby 已經驗證過了(可以去學習《ruby 元編程》)。
開發者使用它可以創建出各種編碼模式,比如 (但遠遠不限於) 跟蹤屬性訪問、隱藏屬性、阻止修改或刪除屬性、函數參數驗證、構造函數參數驗證、數據綁定,以及可觀察對象。
當然,Proxy 雖然來自於 ES6 ,但該 API 仍需要較高的瀏覽器版本,雖然有 proxy-pollfill ,但畢竟提供功能有限。不過已經 2021,相信深入學習 Proxy 也是時機了。
深入緩存
緩存是有害的!這一點毋庸置疑。但是它實在太快了!所以我們要更加理解業務,哪些數據需要緩存,理解那些數據可以使用緩存。
當前書寫的緩存僅僅只是針對與一個方法,之後寫的項目是否可以更細粒度的結合返回數據?還是更往上思考,寫出一套緩存層?
小步開發
在開發該項目的過程中,我採用小步快跑的方式,不斷返工。最開始的代碼,也僅僅只到了添加過期刪除功能那一步。
但是當我每次完成一個新的功能後,重新開始整理庫的邏輯與流程,爭取每一次的代碼都足夠優雅。同時因爲我不具備第一次編寫就能通盤考慮的能力。不過希望在今後的工作中,不斷進步。這樣也能減少代碼的返工。
其他
函數創建
事實上,我在爲當前庫添加手動管理時候,考慮過直接複製函數,因爲函數本身是一個對象。同時爲當前函數添加 set 等方法。但是沒有辦法把作用域鏈拷貝過去。
雖然沒能成功,但是也學到了一些知識,這裏也提供兩個創建函數的代碼。
我們在創建函數時候基本上會利用 new Function 創建函數,但是瀏覽器沒有提供可以直接創建異步函數的構造器,我們需要手動獲取。
AsyncFunction = (async x => x).constructor
foo = new AsyncFunction('x, y, p', 'return x + y + await p')
foo(1,2, Promise.resolve(3)).then(console.log) // 6
對於全局函數,我們也可以直接 fn.toString() 來創建函數,這時候異步函數也可以直接構造的。
function cloneFunction<T>(fn: (...args: any[]) => T): (...args: any[]) => T {
return new Function('return '+ fn.toString())();
}
轉自:jump__jump
https://segmentfault.com/a/1190000039217566
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/M_scFqxadOX_f5G_7IvSGQ