API 缓存

在开发 web 应用程序时,性能都是必不可少的话题。对于单页面应用程序而言,我们可以采用很多方式来对性能进行优化,比方说 tree-shaking、模块懒加载、利用网络cdn加速等这些常规的优化。

而事实上,缓存一定是提升web应用程序性能最有效方法之一,这是一种用空间换取时间的做法,尤其是用户受限于网速的情况下,利用额外的存储来提升系统的响应能力,降低网络的消耗,可以有效的提升 web 应用的性能。

以浏览器而言,我们有很多缓存数据与资源的方法,例如 标准的浏览器缓存(包括强缓存和协商缓存) 以及 Service worker 等技术。但一般而言,他们都更适合静态内容的缓存。例如 html,js,css以及图片等文件。而如果需要缓存系统数据的话,我们需要采用另外的方案。

适用场景

对 API 进行缓存,在某些场景下是有害的,例如页面对实时性要求比较高,需要频繁的请求 server 且每次请求 server 获取的数据均不同,或者 server 返回的数据是带策略的,比如有个性化推荐,那么就完全不能对 API 进行缓存。什么场景比较适合对 API 进行缓存呢?当 API 请求的地址和参数不变时,server 返回的数据也是固定的,该部分数据之所以从接口返回是因为该部分数据量级很大,不能完全放到前端,这种情况对 API 进行缓存就比较合适了。一些常见的业务场景有:

  1. 文档类型的应用
    文档类型的页面如果做成静态网站,虽然可以通过HTTP缓存在第二次进入页面时有更快的性能体验,但是在不同的页面之间跳转都会请求静态页面,有刷新感,体验较差,如果做成SPA,切换页面时也会请求数据,如果能缓存这部分数据,在第二次进入页面时就会有更快的体验。

  2. 分发类的应用
    分发类的应用有固定的页面更新时间,在用户进入一次页面之后,可以缓存当前的分发数据。

  3. 论坛类的应用
    文章详情页可以进行缓存,因为文章内容在发布后几乎不会变化,数量巨大,如果缓存了,用户在第二次进入相同的文章时可以立即看到文章。

如何实现

  1. 简单的数据缓存,第一次请求时候获取数据,之后便使用数据,不再请求后端api

我们假设获取数据的 function 是这样声明的,它返回一个 promise

1
2
3
4
5
6
7
function getDataAPI(params) {
console.log(arguments.callee.name, 'is called.');
// mock
return Promise.resolve({data: {}, status: 0, msg: 'Success.'});
// online
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
// 从storage中获取缓存
let storageCache = localStorage.getItem('cache') || '{}';
// 将缓存放到内存中
let dataCache = JSON.parse(storageCache);

function getData(params) {
// 获取缓存的key
let apiName = arguments.callee.name;
// 从内存缓存中获取数据
let data = dataCache[apiName];
if (data) {
// 有数据则返回之前的缓存数据
return Promise.resolve(data);
}
// 没有数据则请求服务器
return getDataAPI(params).then(res => {
// 设置内存数据缓存
dataCache[apiName] = res;
// 同步storage缓存
localStorage.setItem('cache', JSON.stringify(dataCache));
// 返回最新数据
return Promise.resolve(res);
});
}

调用方式:

1
2
3
getData().then(data => { console.log(1, data) });
// 第二次调用 不再发起 api 请求,直接取得先前的 data
getData().then(data => { console.log(2, data) });
  1. 如果此 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.');
// mock
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log(arguments.callee.name, ' response.');
resolve({data: {}, status: 0, msg: 'Success.'});
}, 3000);
});
// online
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);
}
// 如果 promise 缓存中已有该 api ,说明在不久之前我们刚请求过,而且还未返回
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(() => {
// 请求结束则从内存缓存中删除该api请求
promiseCache.delete(apiName);
});
});
// 将该请求的promise存放到缓存中去
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. 同时同时缓存需要设置过期时间,防止一直获取不到最新数据的问题
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();
// api 最大缓存时间 一周
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;
}
  1. 将该方法改为工厂方法,即通过该函数可以将任意 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 => { /* 业务逻辑 */ });
  1. 将该方法改造为类实例方法,避免对全局变量的干扰,同时对同一类的 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 里
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) });
  1. 以上只是根据 api 的名字进行了缓存,实际上,一般会根据 api 名字和参数一起来进行缓存,只有当 api名字和参数 均一致时才从缓存中获取;另外,以上代码在不超过最大缓存时间时就会从缓存获取数据,而实际上,即使从缓存获取数据,我们也希望可以异步的更新下缓存;目前只有最大缓存时间,也可以设置最大缓存日期,过期失效;目前采用 class 实现,实际上大部分情况下对于一个api只会调用 new ApiCache(1000 * 60 * 60 * 24 * 30).memorize(getDataAPI) 这一行代码来使之支持缓存,那么我们可以采用代理(proxy)或者修饰器(decorators)的方法对api请求方法进行拦截,会更加方便;

// TODO: 未完待续