现代前端应用逻辑日趋复杂,在平衡性能和数据实效性方面,前端也逐渐开始承担一些责任。
最近我们在项目中就碰到了这样一个场景。我们的项目只是一个非常传统的数据看板类项目,用户打开页面,通过调用API读取数据,渲染页面,完成任务。
但是这个项目有几个特点,我需要特别说明一下:
- 公司高管们每天都会使用,并且非常关注
- 高管们在手机上使用,网络条件不定
- 高管们查看数据时通常都比较焦躁
于是乎,一个本来看似简单的项目,就逐渐变成性能优化的急先锋。
version alpha
最开始我们的策略非常简单,就是给把数据存储到indexedDB中,并设置一个过期时间。整体流程如下:
关于indexedDB的初始版本代码大致包含如下几个部分
获取indexedDB链接
const TABLE_NAME = 'xhr_cache'
export const getDBConnection = () => {
const request = window.indexedDB.open(DB_NAME)
request.onupgradeneeded = function (event) {
const db = event.target.result
if (!db.objectStoreNames.contains(TABLE_NAME)) {
const table = db.createObjectStore(TABLE_NAME, {
keyPath: 'uid'
})
table.createIndex('uid', 'uid', { unique: true })
}
}
return new Promise((resolve, reject) => {
request.onsuccess = function () {
resolve(request.result)
}
})
}
const dbConn = await getDBConnection()
根据request生成唯一key
import MD5 from 'crypto-js/md5'
getKey(config) {
const hashedKey = MD5( `${config.url}_${JSON.stringify(config.payload)}` ).toString()
return hashedKey
}
根据一个请求的URL + payload,我们可以识别一个唯一的请求。
对其值进行md5哈希之后得到一个唯一的键,代表一个请求,并将其作为存储在indexedDB中的主键。
读取数据和写入数据
/* 写入API response数据 */
const response = {
uid: key,
content: axiosRequest.response.data,
created_at: new Date().getTime(),
expired: expired_at
}
const addResponseToIndexedDB = function (response) {
dbConn
.transaction([TABLE_NAME], 'readwrite')
.objectStore(TABLE_NAME)
.put(response)
}
/* 读取缓存 */
const request = dbConn
.transaction([TABLE_NAME], 'readonly')
.objectStore(TABLE_NAME)
.index('uid')
.get(key)
const result = await new Promise((resolve => {
request.onsuccess = function () {
resolve(request.result)
}
})
清除过期缓存
虽然indexedDB可以存储远大于localStorage的数据,但我们也不希望indexedDB随着用户不断访问存储大量冗余数据。因此,会在每次应用加载的开始对于过期数据统一进行一次清理:
const isExpireded = (result, expired = 60000) => {
const now = new Date().getTime()
const created_at = result.created_at
return !created_at || (now - created_at > expired) ? true : false
}
const delCacheByExpireded = () => {
var request = dbConn
.transaction([TABLE_NAME], 'readwrite')
.objectStore(TABLE_NAME)
.openCursor();
request.onsuccess = function (e) {
var cursor = e.target.result;
if (cursor && cursor !== null) {
const key = cursor.key
const expireded = isExpireded(cursor.value)
if (expireded) {
that.delCacheByKey(key)
}
cursor.continue();
}
}
}
Axios Request / Response Interceptor
有了上述这些能力,我们就可以在自己的Axios拦截器中使用indexedDB的缓存数据。
axios request 拦截器
...
const CACHED_URL_REGEX = [
'somepath/data/version/123',
'user/info/name',
...
]
Axios.interceptors.request.use(async function (config) {
const r = new Regex(`${CACHED_URL_REGEX.join('|')}$`)
if (r.test(config.url)) {
const key = getKey()
const request = dbConn
.transaction([TABLE_NAME], 'readonly')
.objectStore(TABLE_NAME)
.index('uid')
.get(key)
const result = await new Promise((resolve) => {
request.onsuccess = function (event) {
resolve(request.result)
}
request.onerror = function (event) {
resolve()
}
})
if (result && isExpired(result)) {
config.adapter = function (config) {
return new Promise((resolve) => {
const res = {
data: result.content,
status: 200,
statusText: 'OK',
headers: { 'content-type': 'text/plain; charset=utf-8' },
config,
request: {}
}
return resolve(res)
})
}
}
return config
}
})
...
可以看到,我们在request 拦截器中进行了以下操作:
- axios request interceptor的参数中包含URL和payload属性
- 根据URL判断当前资源是否需要缓存
- 如需要缓存,则根据URL和payload信息生成唯一的key
- 根据此key去indexedDB中查找是否已有缓存
- 如有则直接构建一个response并返回
- 如没有则返回原始config,继续进行axios默认行为
注意下面这段代码
const result = await new Promise((resolve) => {
request.onsuccess = function (event) {
resolve(request.result)
}
request.onerror = function (event) {
resolve()
}
})
这里的代码使用了await,以此等待indexedDB的异步查询结束。异步查询结束之后才能根据其结果判断是否要直接返回还是继续axios默认行为。
axios response 拦截器
Axios.interceptors.response.use(function (response) {
...
let success = response.status < 400
const key = getKey(response.config)
dbConn
.transaction([TABLE_NAME], 'readwrite')
.objectStore(TABLE_NAME)
.put({
uid: key,
content: response.data,
created_at: new Date().getTime()
})
...
return response
}
在response拦截器中,无需等待indexedDB的异步写入过程,因此不需要使用await。
截至目前,基于Axios + indexedDB的缓存方案已经大体可用,当然以上代码并不完全,如需使用还得根据自己的项目做一些修改。
IndexedDB不够快?
上述设计方案实现之后,我们发现在读取indexedDB的时候有时会很快,但有些时候却非常慢。根据观测,在某些手机上,读取一小段不超过100K的数据,有时候需要400ms以上。根据经验这是无法理解的。
进一步调查发现,在主线程繁忙时,初始化indexedDB事务到indexedDB返回数据就会比较慢;反之,在主线程空闲时,经过测量,同一过程耗时大约在5ms以下,这才在数据库读取速度的正常认知范围之内。
但众所周知,基于react + antd的前端应用,DOM结构复杂,主线程在渲染时会非常繁忙,这就造成了我们观察到的读取indexedDB耗时较长。
说到这里,还记得上边在Axios Request Interceptor中需要先等待读取到indexedDB数据,根据结果判断是否要请求API的代码吗?
于是尴尬的一幕出现了。假设一次请求叠加了如下因素:
- 主线程正在进行大范围的DOM渲染,造成CPU繁忙
- indexedDB读取耗时从若干毫秒跳级到几百毫秒
- 读取到的数据过期,经过判断需要请求API
- 请求API耗时200ms以上
本来应该提高性能的手段,在这种条件下不仅没有节省耗时,反而会增加耗时。更进一步,在我们自己的调试过程中,发现对于某些低级手机机型,渲染初始页面时CPU本就繁忙,此时即便从本地缓存获取到的数据没有过期,耗时也可能高达无法理解的一秒左右。这种结果表示,此场景下的缓存方式显然是得不偿失的。
下表为我们针对alpha版本缓存方案在Chrome浏览器上的性能做出的统计。其中每一列分别表示在React进行初始化渲染阶段的indexedDB请求耗时。
API 1 | API 2 | API 3 |
---|---|---|
180ms | 82ms | 51ms |
如果将Chrome的CPU throttle调低到1/4的效率,数据则更加无法理解
API 1 | API 2 | API 3 |
---|---|---|
956ms | 183ms | 253ms |
与之对应的,在CPU空闲的时候,也就是初始化渲染完毕之后的indexedDB请求耗时分别为:
API 1 | API 2 | API 3 |
---|---|---|
13ms | 12ms | 13ms |
Version BETA
由于上一节的结论,这样的缓存策略显然无法达到本来的目的。因此我们又设计了几个方案进行对比:
-
利用serviceWorker进行数据缓存
-
在应用开始之初将indexedDB数据dump到内存,之后的取用直接通过内存缓存。根据dump的时间点,又细分为
- 在react app初始化时进行dump
- 在html script标签中使用主线程执行dump代码
- 在html script标签中使用web worker执行dump代码
其中dump数据到内存中进行缓存取用的三种细分,我们分别命名为:
- ReactAPP初始化MemCache的方案
- HTML加载时初始化MemCache的方案
- webWorker初始化MemCache的方案
策略的对比如下:
方案 | 对比 |
---|---|
ReactAPP初始化MemCache | 为了避免API调用在dump数据到内存完成之前,需要等待初始化MemCache之后再调用react app的render方法 由于这种顺序执行,会牺牲一部分APP渲染的耗时 |
HTML加载时初始化MemCache | HTML加载时初始化,CPU相对比较空闲,进行dump操作效率较高,但也取决于当时是否正在对加载的JS资源进行script evaluate 如浏览器正在进行脚本文件的执行和编译,dump时长仍然比较长 |
webWorker初始化MemCache | 利用webworker在主线程之外进行indexedDB的dump操作,可以避免主渲染线程繁忙与否对于indexedDB读取耗时的影响 但初始化webworker本身仍然需要额外耗时 |
由于以上方案相对于上一节中单次indexedDB调用增加了前置dump数据到内存的操作耗时,所以我们这次对测量方案增加了TOTAL一栏,表示从html页面载入到react app完全渲染完毕的耗时。
下表中包含共5种方案的性能对比:alpha版本,serviceWorker方案,以及MemCache的三种方案。每种方案测试十次,取四个阶段以及TOTAL耗时的平均值:
- HTML开始加载到静态资源加载完成的耗时
- 初始化渲染过程中的三次indexedDB调用耗时
- TOTAL耗时
评测数据见下表(细字体的部分为正常CPU负载情况下,粗体字的部分表示CPU效率降级为1/4时的情况):
方案 | 静态资源 | API 1 | API 2 | API 3 | TOTAL | 静态资源 | API 1 | API 2 | API 3 | TOTAL |
---|---|---|---|---|---|---|---|---|---|---|
alpha版本 | 525.4 | 180.4 | 82.2 | 51.3 | 1544.7 | 2500.5 | 956.5 | 183.6 | 253 | 6562.5 |
service worker方案 | 827.5 | 60.9 | 208.4 | 351.6 | 1777.2 | 4053.7 | 138.4 | 991.4 | 546.3 | 7357.3 |
ReactAPP初始化MemCache | 1042.9 | 1.8 | 26 | 9.8 | 1659.5 | 4512.9 | 7.5 | 31.3 | 35.5 | 6410.1 |
HTML加载时初始化MemCache | 1021 | 2.4 | 10.3 | 9.7 | 1564.7 | 5273.1 | 7.2 | 31.6 | 34.5 | 7178.3 |
webWorker初始化MemCache | 797.9 | 0.9 | 8.9 | 7.1 | 1299.6 | 3853.7 | 5.6 | 31.2 | 45 | 5975 |
Finally!! We have a winner
根据数据显示,对于我们的场景来说,使用webworker启动MemCache的方案是最经济的。方案设计如下图所示:
- HTML加载时就启动WebWorker,WebWorker内部执行dump操作
- dump完成之后通过postMessage向主线程发送dump之后的数据
- 主线程收到数据后会将其暂存在特定全局变量上
- APP启动之后初始化MemCache,会先判断该全局变量是否已经被赋值,若还未赋值,则会在初始化MemCache之前自行执行一次dump数据,以保证indexedDB数据已全量dump出来
- 在这之后,与alpha版本不同,Axios的请求/响应拦截器会通过MemCache类进行缓存的查询和添加
- MemCache类负责返回和更新缓存,并将其同步回indexedDB
WebWorker 脚本 / APP内部的dump数据脚本
由于dump数据的操作基本一致,因此WebWorker脚本和APP内部用于dump数据的lib文件内容基本一致。大体代码可见:
const DB_NAME = 'db_name'
const TABLE_NAME = 'xhr_cache'
export const getDBConnection = () => {
const request = window.indexedDB.open(DB_NAME)
request.onupgradeneeded = function (event) {
const db = event.target.result
if (!db.objectStoreNames.contains(TABLE_NAME)) {
const table = db.createObjectStore(TABLE_NAME, {
keyPath: 'uid'
})
table.createIndex('uid', 'uid', { unique: true })
}
}
return new Promise((resolve, reject) => {
let completed = false
request.onsuccess = function () {
if (completed === false) {
completed = true
resolve(request.result)
} else {
request.result.close()
}
}
request.onerror = function (err) {
if (completed === false) {
completed = true
reject(err)
}
}
setTimeout(() => {
if (completed === false) {
completed = true
reject(new Error('getDBConnection timeout after app rendered'))
}
}, 1000)
})
}
export const dump2Memory = async (db) => {
const transaction = db.transaction([TABLE_NAME], 'readonly')
const table = transaction.objectStore(TABLE_NAME)
const request = table.index('uid').getAll()
const records = await new Promise((resolve, reject) => {
request.onsuccess = function () {
resolve(request.result)
}
request.onerror = function () {
console.log('dump2Memory error')
resolve()
}
})
return records
}
export const delCacheByExpireded = async (records) => {
const validRecords = records.filter((record) => !getExpireded(record))
const objectStore = DBCache.conn
.transaction(['xhr_cache'], 'readwrite')
.objectStore('xhr_cache')
const clearRequest = objectStore.clear()
clearRequest.onsuccess = function () {
validRecords.forEach((record) => {
objectStore.add(record)
})
}
return validRecords
}
在这里定义了三个函数
- 获取indexedDB链接的函数
- 从indexedDB中dump所有数据到内存的函数
- 对内存中的全量数据进行过期筛查的函数,其中筛查出已过期的数据进行删除操作,留下来的有效缓存再次存回到indexedDB
注意在获取indexedDB链接的函数中,相对alpha版本增加了容错处理。如果一个浏览器多个tab同时打开同一个indexedDB的链接,可能会导致后面打开的indexedDB链接被block住。因此在这里做了超时处理。
如果新的链接打开超时则不初始化内存缓存,作为降级处理方案。
于此同时,MemCache类也需要对这种降级做出兼容。
MemCache类
DBCache.conn = null
DBCache.memCache = {
__memCache: null,
initialize: function (records) {
this.__memCache = new Map(records.map((record) => [record.uid, record]))
},
get: function (key) {
const result = this.__memCache.get(key)
if (result) {
return cloneDeep(result)
} else {
return null
}
},
add: function (record) {
this.__memCache.set(record.uid, record)
}
}
DBCache.prepare = async function () {
try {
DBCache.conn = await getDBConnection()
let dbRecordList = []
if (window.__db_cache_prepared_records__.length) {
dbRecordList = cloneDeep(window.__db_cache_prepared_records__)
} else {
console.time('dump')
dbRecordList = await dump2Memory(DBCache.conn)
console.timeEnd('dump')
}
const validRecords = await delCacheByExpireded(dbRecordList)
DBCache.memCache.initialize(validRecords || [])
} catch (err) {
DBCache.memCache.initialize([])
console.error(err)
}
}
DBCache.updateRecord = (record) => {
if (DBCache.conn) {
DBCache.memCache.add(record)
DBCache.conn
.transaction(['xhr_cache'], 'readwrite')
.objectStore('xhr_cache')
.put(record)
}
}
请注意,DBCache对象的prepare静态方法中:
由于获取链接超时会抛出异常,因此在getDBConnection方法外围添加了try{}catch{}块。
如果获取DB连接发生异常,则会给MemCache初始化为空数组,这样Axios拦截器在调用DBCache.memCache.get方法时则会永远返回缓存未命中,于是所有Axios请求全部降级为API调用。
另外一个需要注意的点是,DBCache.memCache.get的方法实现中对于内存中的数据进行深拷贝的操作。原因在于,如果直接向react业务代码传递该内存块的引用,很显然业务代码会对该内存引用的对象进行修改。那么下次再使用命中的缓存时,就会因为缓存数据与API返回的数据结构不一致导致报错。
初始化WebWorker
到现在为止,几乎所有必须模块的代码都已经实现了。整个流程只剩下最后一块砖:HTML里script标签内用于启动WebWorker以及WebWorker中通知主线程的代码。
<script>
window.__db_cache_prepared_records__ = []
if (window.Worker) {
console.time('dump in html')
const dbWorker = new Worker('./webworker.dump.prepare.js');
dbWorker.onmessage = function(e) {
if (e.data.eventName = 'onDBDump') {
if (window.__db_cache_prepared_records__.length === 0)
window.__db_cache_prepared_records__ = e.data.data
console.timeEnd('dump in html')
}
}
}
</script>
PostMessage
// other codes in dump script section.
// I'm not gonna repeat those. see it yourself please
...
if (indexedDB) {
console.time('dump2Memory')
getDBConnection().then(conn => {
dump2Memory(conn).then(records => {
console.timeEnd('dump2Memory')
postMessage({
eventName: 'onDBDump',
data: records
})
})
}).catch(err => {
console.error(err)
})
}
结论
截至目前,我们使用Axios + indexedDB + WebWorker实现的最高效的前端API缓存方案就到此为止了。
实话实说,现在还只是搭建了一个高效缓存的框架,至于各种适合不同应用场景的缓存策略还没有实现。
如果你有有意思的缓存场景或需要何种缓存策略,欢迎留言。