两年前,我写了一篇关于业务缓存的博客 前端 api 请求缓存方案, 这篇博客反响还不错,其中介绍了如何缓存数据,Promise 以及如何超时删除(也包括如何构建修饰器)。若是对此不够了解,能够阅读博客进行学习。前端
但以前的代码和方案终归仍是简单了些,并且对业务有很大的侵入性。这样很差,因而笔者开始从新学习与思考代理器 Proxy。git
Proxy 能够理解成,在目标对象以前架设一层“拦截”,外界对该对象的访问,都必须先经过这层拦截,所以提供了一种机制,能够对外界的访问进行过滤和改写。Proxy 这个词的原意是代理,用在这里表示由它来“代理”某些操做,能够译为“代理器”。关于 Proxy 的介绍与使用,建议你们仍是看阮一峰大神的 ECMAScript 6 入门 代理篇。es6
任何项目都不是一触而就的,下面是关于 Proxy 缓存库的编写思路。但愿能对你们有一些帮助。github
固然,其实代理器中的 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 的缓存。浏览器
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 。
咱们在使用 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 的英文全称是 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; }
事情到了这一步,咱们就能够从以前的代码细节中解放出来了,看看基于这些功能所作出的接口与主函数。
// 面向接口,不管后面还会不会增长其余类型的缓存类 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 测试库给我提供了大量的帮助,它帮助我从新思考每个类以及每个函数应该具备的功能与参数校验。以前的代码我老是在项目的主入口进行校验,对于每一个类或者函数的参数没有深刻思考。事实上,这个健壮性是不够的。由于你不能决定用户怎么使用你的库。
事实上,代理的应用场景是不可限量的。这一点,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 一下。