手寫一個基於 Proxy 的緩存庫

兩年前,我寫了一篇關於業務緩存的博客 前端 api 請求緩存方案, 這篇博客反響還不錯,其中介紹瞭如何緩存數據,Promise 以及如何超時刪除(也包括如何構建修飾器)。若是對此不夠了解,能夠閱讀博客進行學習。前端

但以前的代碼和方案終歸仍是簡單了些,並且對業務有很大的侵入性。這樣很差,因而筆者開始從新學習與思考代理器 Proxy。git

Proxy 能夠理解成,在目標對象以前架設一層「攔截」,外界對該對象的訪問,都必須先經過這層攔截,所以提供了一種機制,能夠對外界的訪問進行過濾和改寫。Proxy 這個詞的原意是代理,用在這裏表示由它來「代理」某些操做,能夠譯爲「代理器」。關於 Proxy 的介紹與使用,建議你們仍是看阮一峯大神的 ECMAScript 6 入門 代理篇es6

項目演進

任何項目都不是一觸而就的,下面是關於 Proxy 緩存庫的編寫思路。但願能對你們有一些幫助。github

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

自定義函數參數

咱們仍舊能夠利用以前博客介紹的的函數生成惟一值,只不過咱們再也不須要函數名了:api

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
  }
}

雖然庫自己能夠基於函數參數提供惟一值,可是針對形形色色的不一樣業務來講,這確定是不夠用的,須要提供用戶能夠自定義參數序列化。promise

// 若是配置中有 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())();
}

鼓勵一下

若是你以爲這篇文章不錯,但願能夠給與我一些鼓勵,在個人 github 博客下幫忙 star 一下。

博客地址

參考資料

前端 api 請求緩存方案

ECMAScript 6 入門 代理篇

memoizee

memoizee-proxy

相關文章
相關標籤/搜索