关于javascript:手写一个基于-Proxy-的缓存库

6次阅读

共计 11746 个字符,预计需要花费 30 分钟才能阅读完成。

两年前,我写了一篇对于业务缓存的博客 前端 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())();}

激励一下

如果你感觉这篇文章不错,心愿能够给与我一些激励,在我的 github 博客下帮忙 star 一下。

博客地址

参考资料

前端 api 申请缓存计划

ECMAScript 6 入门 代理篇

memoizee

memoizee-proxy

正文完
 0