使用Axios+indexedDB构建完整的前端缓存策略

640 阅读5分钟

现代前端应用逻辑日趋复杂,在平衡性能和数据实效性方面,前端也逐渐开始承担一些责任。

最近我们在项目中就碰到了这样一个场景。我们的项目只是一个非常传统的数据看板类项目,用户打开页面,通过调用API读取数据,渲染页面,完成任务。

但是这个项目有几个特点,我需要特别说明一下:

  • 公司高管们每天都会使用,并且非常关注
  • 高管们在手机上使用,网络条件不定
  • 高管们查看数据时通常都比较焦躁

于是乎,一个本来看似简单的项目,就逐渐变成性能优化的急先锋。

version alpha

最开始我们的策略非常简单,就是给把数据存储到indexedDB中,并设置一个过期时间。整体流程如下:

1.png

关于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 1API 2API 3
180ms82ms51ms

如果将Chrome的CPU throttle调低到1/4的效率,数据则更加无法理解

API 1API 2API 3
956ms183ms253ms

与之对应的,在CPU空闲的时候,也就是初始化渲染完毕之后的indexedDB请求耗时分别为:

API 1API 2API 3
13ms12ms13ms

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加载时初始化MemCacheHTML加载时初始化,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 1API 2API 3TOTAL静态资源API 1API 2API 3TOTAL
alpha版本525.4180.482.251.31544.72500.5956.5183.62536562.5
service worker方案827.560.9208.4351.61777.24053.7138.4991.4546.37357.3
ReactAPP初始化MemCache1042.91.8269.81659.54512.97.531.335.56410.1
HTML加载时初始化MemCache10212.410.39.71564.75273.17.231.634.57178.3
webWorker初始化MemCache797.90.98.97.11299.63853.75.631.2455975

Finally!! We have a winner

根据数据显示,对于我们的场景来说,使用webworker启动MemCache的方案是最经济的。方案设计如下图所示:

2.png

  1. HTML加载时就启动WebWorker,WebWorker内部执行dump操作
  2. dump完成之后通过postMessage向主线程发送dump之后的数据
  3. 主线程收到数据后会将其暂存在特定全局变量上
  4. APP启动之后初始化MemCache,会先判断该全局变量是否已经被赋值,若还未赋值,则会在初始化MemCache之前自行执行一次dump数据,以保证indexedDB数据已全量dump出来
  5. 在这之后,与alpha版本不同,Axios的请求/响应拦截器会通过MemCache类进行缓存的查询和添加
  6. 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 = nullDBCache.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缓存方案就到此为止了。

实话实说,现在还只是搭建了一个高效缓存的框架,至于各种适合不同应用场景的缓存策略还没有实现。

如果你有有意思的缓存场景或需要何种缓存策略,欢迎留言。