专业编程教程与实战项目分享平台

网站首页 > 技术文章 正文

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

ins518 2024-09-21 00:33:02 技术文章 13 ℃ 0 评论

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

最近我们在项目中就碰到了这样一个场景。我们的项目只是一个非常传统的数据看板类项目,用户打开页面,通过调用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('|')}使用Axios+indexedDB构建完整的前端缓存策略-今日头条
) 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的方案是最经济的。方案设计如下图所示:

  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 = 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缓存方案就到此为止了。

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

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

Tags:

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表