API 缓存 在开发 web 应用程序时,性能都是必不可少的话题。对于单页面应用程序而言,我们可以采用很多方式来对性能进行优化,比方说 tree-shaking、模块懒加载、利用网络cdn加速等这些常规的优化。
而事实上,缓存一定是提升web应用程序性能最有效方法之一,这是一种用空间换取时间的做法,尤其是用户受限于网速的情况下,利用额外的存储来提升系统的响应能力,降低网络的消耗,可以有效的提升 web 应用的性能。
以浏览器而言,我们有很多缓存数据与资源的方法,例如 标准的浏览器缓存(包括强缓存和协商缓存) 以及 Service worker 等技术。但一般而言,他们都更适合静态内容的缓存。例如 html,js,css以及图片等文件。而如果需要缓存系统数据的话,我们需要采用另外的方案。
适用场景 对 API 进行缓存,在某些场景下是有害的,例如页面对实时性要求比较高,需要频繁的请求 server 且每次请求 server 获取的数据均不同,或者 server 返回的数据是带策略的,比如有个性化推荐,那么就完全不能对 API 进行缓存。什么场景比较适合对 API 进行缓存呢?当 API 请求的地址和参数不变时,server 返回的数据也是固定的,该部分数据之所以从接口返回是因为该部分数据量级很大,不能完全放到前端,这种情况对 API 进行缓存就比较合适了。一些常见的业务场景有:
文档类型的应用 文档类型的页面如果做成静态网站,虽然可以通过HTTP缓存在第二次进入页面时有更快的性能体验,但是在不同的页面之间跳转都会请求静态页面,有刷新感,体验较差,如果做成SPA,切换页面时也会请求数据,如果能缓存这部分数据,在第二次进入页面时就会有更快的体验。
分发类的应用 分发类的应用有固定的页面更新时间,在用户进入一次页面之后,可以缓存当前的分发数据。
论坛类的应用 文章详情页可以进行缓存,因为文章内容在发布后几乎不会变化,数量巨大,如果缓存了,用户在第二次进入相同的文章时可以立即看到文章。
如何实现
简单的数据缓存,第一次请求时候获取数据,之后便使用数据,不再请求后端api
我们假设获取数据的 function 是这样声明的,它返回一个 promise
1 2 3 4 5 6 7 function getDataAPI (params ) { console .log (arguments .callee .name , 'is called.' ); return Promise .resolve ({data : {}, status : 0 , msg : 'Success.' }); return request.get ('/getData' , params); }
我们希望将 fn 的结果缓存,一般而言,前端可以将数据缓存到内存和硬盘上。内存就是我们正在运行的程序中,我们可以声明一个 Map 或者 Object 用来存储我们的数据。硬盘就是浏览器给我们提供的持久化数据的方法,可以使用 localStorage 或者 IndexedDB。这里我们使用 Object 数据结构用来将数据存储到内存中,用 localStorage 来将数据持久化到客户端。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 let storageCache = localStorage .getItem ('cache' ) || '{}' ;let dataCache = JSON .parse (storageCache);function getData (params ) { let apiName = arguments .callee .name ; let data = dataCache[apiName]; if (data) { return Promise .resolve (data); } return getDataAPI (params).then (res => { dataCache[apiName] = res; localStorage .setItem ('cache' , JSON .stringify (dataCache)); return Promise .resolve (res); }); }
调用方式:
1 2 3 getData ().then (data => { console .log (1 , data) });getData ().then (data => { console .log (2 , data) });
如果此 api 有同时两个以上的调用,会因为请求未返回而进行第二次 api 请求,所以我们需要对 promise 进行缓存,因为只需在内存中缓存,所以在这里我们使用 Map 数据结构
首先改造下请求 api 使它不那么快的返回
1 2 3 4 5 6 7 8 9 10 11 12 function getDataAPI (params ) { console .log (arguments .callee .name , ' request.' ); return new Promise ((resolve, reject ) => { setTimeout (() => { console .log (arguments .callee .name , ' response.' ); resolve ({data : {}, status : 0 , msg : 'Success.' }); }, 3000 ); }); return request.get ('/getData' , params); }
然后改造下缓存方法添加对 promise 的缓存
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 let storageCache = localStorage .getItem ('cache' ) || '{}' ;let dataCache = JSON .parse (storageCache);let promiseCache = new Map ();function getData (params ) { let apiName = arguments .callee .name ; let data = dataCache[apiName]; if (data) { return Promise .resolve (data); } let promise = promiseCache.get (apiName); if (promise) { return promise; } promise = new Promise ((resolve, reject ) => { getDataAPI (params).then (res => { dataCache[apiName] = res; localStorage .setItem ('cache' , JSON .stringify (dataCache)); resolve (res); }).catch (e => { reject (e); }).finally (() => { promiseCache.delete (apiName); }); }); promiseCache.set (apiName, promise); return promise; }
该代码解决了同一时间发起多次同样请求时不会读取缓存的问题。同时也在后端出错的情况下对 promise 进行了删除,不会出现缓存了错误的 promise 一直出错。
调用方式:
1 2 getData ().then (data => { console .log (1 , data) });getData ().then (data => { console .log (2 , data) });
同时同时缓存需要设置过期时间,防止一直获取不到最新数据的问题
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 let storageCache = localStorage .getItem ('cache' ) || '{}' ;let dataCache = JSON .parse (storageCache);let promiseCache = new Map (); const MAX_CACHE_TIME = 1000 * 60 * 60 * 24 * 7 ;function getData (params ) { let apiName = arguments .callee .name ; let data = dataCache[apiName]; if (data) { if (Date .now () - data.__timestamp < MAX_CACHE_TIME ) { return Promise .resolve (data.res ); } delete dataCache[apiName]; } let promise = promiseCache.get (apiName); if (promise) { return promise; } promise = new Promise ((resolve, reject ) => { return getDataAPI (params).then (res => { data = { res, __timestamp : Date .now () }; dataCache[apiName] = data; localStorage .setItem ('cache' , JSON .stringify (dataCache)); resolve (res); }).catch (e => { reject (e); }).finally (() => { promiseCache.delete (apiName); }); }); promiseCache.set (apiName, promise); return promise; }
将该方法改为工厂方法,即通过该函数可以将任意 api方法 改造为 可以缓存api的方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 let storageCache = localStorage .getItem ('cache' ) || '{}' ;let dataCache = JSON .parse (storageCache);let promiseCache = new Map ();const MAX_CACHE_TIME = 1000 * 60 * 60 * 24 * 7 ;function cacheApi (fn ) { const apiName = fn.name ; return function (params ) { let data = dataCache[apiName]; if (data) { if (Date .now () - data.__timestamp < MAX_CACHE_TIME ) { return Promise .resolve (data.res ); } delete dataCache[apiName]; } let promise = promiseCache.get (apiName); if (promise) { return promise; } promise = new Promise ((resolve, reject ) => { return fn (params).then (res => { data = { res, __timestamp : Date .now () }; dataCache[apiName] = data; localStorage .setItem ('cache' , JSON .stringify (dataCache)); resolve (res); }).catch (e => { reject (e); }).finally (() => { promiseCache.delete (apiName); }); }); promiseCache.set (apiName, promise); return promise; }; }
调用方式:
1 2 let getData = cacheApi (getDataAPI);getData ().then (data => { });
将该方法改造为类实例方法,避免对全局变量的干扰,同时对同一类的 api 可以修改相同的最大缓存时间。如果所有的api都希望设置相同的最大缓存时间,可以将 memorize 方法改为静态方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 class ApiCache { constructor (time ) { this .MAX_CACHE_TIME = Number (time) || 1000 * 60 * 60 * 24 * 7 ; this .promiseCache = new Map (); this .dataCache = JSON .parse (localStorage .getItem ('cache' )) || {}; } setMaxCacheTime (time ) { this .MAX_CACHE_TIME = time; return this ; } clearCache ( ) { this .dataCache = {}; this .promiseCache .clear (); localStorage .removeItem ('cache' ); return this ; } memorize (fn ) { const apiName = fn.name ; return (params ) => { let data = this .dataCache [apiName]; if (data) { if (Date .now () - data.__timestamp < this .MAX_CACHE_TIME ) { return Promise .resolve (data.res ); } delete this .dataCache [apiName]; } let promise = this .promiseCache .get (apiName); if (!promise) { promise = new Promise ((resolve, reject ) => { return fn (params).then (res => { data = { res, __timestamp : Date .now () }; this .dataCache [apiName] = data; localStorage .setItem ('cache' , JSON .stringify (this .dataCache )); resolve (res); }).catch (e => { reject (e); }).finally (() => { this .promiseCache .delete (apiName); }); }); this .promiseCache .set (apiName, promise); } return promise; }; } }
用法:
1 2 3 const getData = new ApiCache ().memorize (getDataAPI);getData ().then (data => { console .log (1 , data) });getData ().then (data => { console .log (2 , data) });
以上只是根据 api 的名字进行了缓存,实际上,一般会根据 api 名字和参数一起来进行缓存,只有当 api名字和参数 均一致时才从缓存中获取;另外,以上代码在不超过最大缓存时间时就会从缓存获取数据,而实际上,即使从缓存获取数据,我们也希望可以异步的更新下缓存;目前只有最大缓存时间,也可以设置最大缓存日期,过期失效;目前采用 class 实现,实际上大部分情况下对于一个api只会调用 new ApiCache(1000 * 60 * 60 * 24 * 30).memorize(getDataAPI)
这一行代码来使之支持缓存,那么我们可以采用代理(proxy)或者修饰器(decorators)的方法对api请求方法进行拦截,会更加方便;
// TODO: 未完待续