当前位置: 首页 > Web前端 > JavaScript

200行代码实现一个高效率的缓存库

时间:2023-03-26 20:10:05 JavaScript

这两天用了cacheables缓存库,觉得还不错。我想和大家分享一下我阅读的源代码的总结。1.简介“cacheables”,顾名思义,就是用来做内存缓存的。它的代码只有大约200行(不包括注释)。官方介绍如下:一个简单的内存缓存,支持不同的缓存策略,使用TypeScript编写优雅的语法。它的特点:语法优雅,包装已有的API调用,节省API调用;完全键入的结果。不需要类型转换。支持不同的缓存策略。集成日志:检查API调用的时间。使用辅助函数来构建缓存键。适用于浏览器和Node.js。没有依赖性。做广泛的测试。体积小,gzip后1.43kb。当我们在业务中需要对请求等异步任务进行缓存,避免重复请求时,可以使用“cacheables”。2.实践经验缓存的入门非常简单。看看下面的比较://没有缓存fetch("https://some-url.com/api");//缓存cache.cacheable(()=>fetch("https://some-url.com/api"),"密钥");接下来看官网提供的缓存请求的使用示例:1、安装依赖npminstallcacheables//或pnpmaddcacheables2。使用示例import{Cacheables}from"cacheables";constapiUrl="http://localhost:3000/";//创建一个新的缓存实例①constcache=newCacheables({logTiming:true,log:true,});//模拟一个异步任务constwait=(ms:number)=>newPromise((resolve)=>setTimeout(resolve,ms));//包装一个已有的API调用fetch(apiUrl),并分配一个key给weather//下面的例子使用了'max-age'缓存策略,会在一段时间后使缓存失效//这个方法返回一个完整的Promise,就像'fetch(apiUrl)'一样,可以缓存结果。constgetWeatherData=()=>//②cache.cacheable(()=>fetch(apiUrl),"weather",{cachePolicy:"max-age",maxAge:5000,});conststart=async()=>{//获取新数据并将其添加到缓存中constweatherData=awaitgetWeatherData();//3秒后执行awaitwait(3000);//缓存新数据,maxAge设置5秒,还没过期constcachedWeatherData=awaitgetWeatherData();//3秒后执行awaitwait(3000);//缓存已过期超过5秒,请求的数据将被重新缓存constfreshWeatherData=awaitgetWeatherData();};start();在上面的示例代码中,我们实现了一个请求缓存服务。对于maxAge5秒内的重复请求,不会重发请求,而是从缓存中读取结果返回。3.API介绍官方文档介绍了很多API,可以从文档中获取。比较常用的是cache.cacheable(),用来包装一个方法进行缓存。所有API如下:newCacheables(options?):Cacheablescache.cacheable(resource,key,options?):Promisecache.delete(key:string):voidcache.clear():voidcache.keys():string[]cache.isCached(key:string):booleanCacheables.key(...args:(string|number)[]):string可以通过下图加深理解:3.源码分析克隆cacheables后project,可以看到主要的逻辑都在index.ts中,去掉换行和注释,代码量200行左右,阅读起来还是比较方便的。接下来,我们按照官方的例子,以阅读源码为主线。1、在创建缓存实例的例子的步骤①中,首先通过newCacheables()创建一个缓存实例。源码中Cacheables类的定义如下。这里先删掉多余的代码,看看类提供的方法和函数:真的;this.log=options?.log??错误的;this.logTiming=options?.logTiming??错误的;}//使用提供的参数创建一个键statickey():string{}//删除缓存delete():void{}//清除所有缓存clear():void{}//返回缓存对象是否有指定的key存在,并且有效(即是否超时)isCached(key:string):boolean{}//返回所有缓存keykeys():string[]{}//用于包装方法调用并做cachingasynccacheable():Promise{}}这样可以很直观的知道cacheables实例的功能和支持的方法。其UML类图如下:步骤①实例化时,Cacheables的内部构造函数会保存输入参数,接口定义如下:constcache=newCacheables({logTiming:true,log:true,});exporttypeCacheOptions={//启用缓存开关?:boolean;//启用/禁用缓存命中日志log?:boolean;//启用/禁用计时logTiming?:boolean;};根据参数可以看出,此时我们的Cacheables实例支持缓存日志和定时功能。2、包装缓存方法在步骤②中,我们将request方法包装在cache.cacheable方法中,实现一个以max-age为缓存策略,5000毫秒有效的缓存:constgetWeatherData=()=>cache。cacheable(()=>fetch(apiUrl),"天气",{cachePolicy:"max-age",maxAge:5000,});其中,cacheable方法是Cacheables类上的一个成员方法,定义如下(去掉日志相关代码)://执行缓存设置asynccacheable(resource:()=>Promise,//一个返回Promisekey:string,//cachekeyoptions?:CacheableOptions,//cachestrategy)的函数:Promise{constshouldCache=this.enabled//如果没有启用缓存,传入的函数将是直接调用,返回调用结果..省略log代码返回结果}其中cacheable方法接收三个参数:resource:需要包装的函数,即返回Promise的函数,如()=>fetch();key:用于缓存的key;options:缓存策略的配置选项;返回this.#cacheable私有方法的执行结果,this.#cacheable私有方法的实现如下://处理缓存,比如保存缓存对象等async#cacheable(resource:()=>Promise,key:string,options?:CacheableOptions,):Promise{//先通过key获取缓存对象letcacheable=this.#cacheables[key]asCacheable|undefined//如果这个key下没有缓存对象,通过Cacheable缓存对象实例化一个新的缓存对象//并保存在key下if(!cacheable){cacheable=newCacheable()this.#cacheables[key]=cacheable}//调用相应的缓存策略returnawaitcacheable.touch(resource,options)}this.#cacheable私有方法接收与cacheable方法相同的参数,并返回cacheable.touch方法调用的结果如果key的缓存对象不存在,则通过Cacheable类创建一个,其UML类图如下:3.处理缓存策略上一步调用cacheable会执行相应的缓存策略。touch方法,定义如下://执行缓存策略的方法asynctouch(resource:()=>Promise,options?:CacheableOptions,):Promise{if(!this.#initialized){returnthis.#handlePreInit(resource,options)}if(!options){returnthis.#handleCacheOnly()}//通过实例化Cacheables开关时配置的options的cachePolicy来选择对应的处理策略(options.cachePolicy){case'cache-only':返回这个。#handleCacheOnly()case'network-only':returnthis.#handleNetworkOnly(resource)case'stale-while-revalidate':returnthis.#handleSwr(resource)case'max-age'://这里使用的类型casereturnthis.#handleMaxAge(resource,options.maxAge)case'network-only-non-concurrent':returnthis.#handleNetworkOnlyNonConcurrent(resource)}}touch方法从#cacheable私有方法接收两个参数,资源和选项范围。本例使用了max-age缓存策略,那么我们看对应的#handleMaxAge私有方法定义(其他类似)://maxAge缓存策略的处理方法#handleMaxAge(resource:()=>Promise,maxAge:number){//#lastFetch上次发送时间,fetch时会记录当前时间//如果当前时间大于#lastFetch+maxAge,则非并发调用传入方法if(!this.#lastFetch||Date.now()>this.#lastFetch+maxAge){returnthis.#fetchNonConcurrent(resource)}returnthis.#value//如果是在缓存期间,会是之前缓存的结果直接返回}当我们执行getWeatherData()6秒后,已经超过了maxAge设置的5秒。之后缓存会失效,重新发送请求。看一下#fetchNonConcurrent私有方法定义,它是用来发送非并发请求的://sendnon-concurrentrequestsasync#fetchNonConcurrent(resource:()=>Promise):Promise{//non-concurrent在并发的情况下,如果当前请求还在发送中,则直接执行当前正在执行的方法并返回结果if(this.#isFetching(this.#promise)){awaitthis.#promisereturnthis.#value}//否则直接执行传入方法returnthis.#fetch(resource)}#fetchNonConcurrent私有方法只接收参数resource,即需要包装的函数。这里先判断当前状态是否为【发送中】,如果是,则直接调用this.#promise,返回缓存值,结束调用。否则,将资源传递给#fetch执行。#fetch私有方法定义如下://Executerequestsendingasync#fetch(resource:()=>Promise):Promise{this.#lastFetch=Date.now()this.#promise=resource()//定义guard变量,表示当前有任务在执行this.#value=awaitthis.#promiseif(!this.#initialized)this.#initialized=truethis.#promise=undefined//执行完成,清除守卫变量returnthis.#value}#fetch私有方法接收前面需要包装的函数,通过给守卫变量赋值来控制任务的执行,赋值在开始执行,并在任务执行完成后清除守卫变量。这也是我们实际业务开发中经常使用的方法。比如在发送一个请求之前,给一个变量赋值,表示当前有一个任务要执行,不能再发送其他请求。请求结束后,清除该变量,继续执行其他任务。完成任务。“cacheables”的执行过程大致是这样的。下面总结一个通用的缓存方案,方便理解和扩展。4、通用缓存库设计方案Cacheables中支持五种缓存策略,上面只介绍了其中的max-age:这里总结一个通用缓存库设计方案,如下图所示:缓存库支持通过传入options参数进行实例化,使用用户传入的options.key作为key,调用CachePolicyHandler对象获取用户指定的缓存策略(CachePolicy)。然后将用户传入的options.resource作为实际要执行的方法,通过CachePlicyHandler()方法传入并执行。在上图中,我们需要定义各种缓存库操作方法(如读取和设置缓存的方法)和各种缓存策略的处理方法。当然也可以集成Logger等辅助工具,方便用户使用和开发。本文不赘述,核心是介绍这个解决方案。五、总结本文与大家分享的是cacheables缓存库源码的核心逻辑。它的源码逻辑并不复杂,主要是支持各种缓存策略和相应的处理逻辑。文末我会和大家总结一个通用的缓存库设计方案。如果你有兴趣,你可以自己尝试一下。好记性不如烂笔头。想法是最重要的。这个想法可以用在很多场景中。大家可以在实际业务中去实践和总结。六、几点思考1、思考阅读源码的方法。大家都在看源码,讨论源码。如何阅读源代码?个人建议:先确定自己要学习的源码部分(比如Vue2响应式原理,Vue3Ref等);根据自己想学的部分写一个简单的demo;通过demo断点得到一个大概的了解;浏览源码并详细阅读,因为源码中经常会有注释和例子等,如果你只是想开始学习某个库,可以先阅读README.md,重点介绍介绍、特点、使用方法、实例等。掌握其特点和实例,进行有针对性的源码阅读。相信这样阅读会让你的思路更加清晰。2.关于面向接口编程的思考这个库使用了TypeScript。通过各个接口的定义,我们可以清楚的知道各个类、方法、属性的作用。这也是我们需要学习的。当我们接到一个需求任务的时候,我们可以这样做,你的效率往往会提高很多:功能分析:分析整个需求,了解需要实现的功能和细节,通过xmind等工具进行梳理,以及避免这样做。频繁的返工和杂乱的代码结构。功能设计:梳理好需求后,就可以设计各个部分了,比如提取通用方法等。功能实现:前两步做好了,相信功能实现不难~3。想想这个库的优化点。库代码主要集中在index.ts,有利于阅读。代码量大了,阅读体验恐怕就不好了。所以我的建议是:对代码进行拆分,将一些独立的逻辑拆分成单独的文件进行维护。比如每个缓存策略的逻辑可以是一个单独的文件,通过统一的开发方式(比如Plugin)开发,然后统一入口文件导入导出。可以改造Logger等内部工具和方法,支持用户自定义。例如,可以使用其他日志记录工具和方法来代替内置的Logger,这样更加解耦。可以参考插件架构设计,这样这个库会更加灵活和可扩展。