# webWorker+indexedDB性能优化

lastUpdated: 2023-9-01

# 名词解释

webworker: 就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。等到 Worker 线程完成计算任务,再把结果返回给主线程。这样的好处是,一些计算密集型或高延迟的任务,被 Worker 线程负担了,主线程(通常负责 UI 交互)就会很流畅,不会被阻塞或拖慢。

indexedDB: 随着浏览器的功能不断增强,越来越多的网站开始考虑,将大量数据储存在客户端,这样可以减少从服务器获取数据,直接从本地获取数据。现有的浏览器数据储存方案,都不适合储存大量数据:Cookie 的大小不超过4KB,且每次请求都会发送回服务器;LocalStorage 在 2.5MB 到 10MB 之间(各家浏览器不同),而且不提供搜索功能,不能建立自定义的索引。所以,需要一种新的解决方案,这就是 IndexedDB 诞生的背景。

# webworker

我们使用webworker的时候要注意以下几点:

同源限制: 分配给 Worker 线程运行的脚本文件,必须与主线程的脚本文件同源。

DOM限制: Worker 线程所在的全局对象,与主线程不一样,无法读取主线程所在网页的 DOM 对象,也无法使用document、window、parent这些对象。但是,Worker 线程可以navigator对象和location对象。

通信联系: Worker 线程和主线程不在同一个上下文环境,它们不能直接通信,必须通过消息完成。

脚本限制: Worker 线程不能执行alert()方法和confirm()方法,但可以使用 XMLHttpRequest 对象发出 AJAX 请求。

文件限制: Worker 线程无法读取本地文件,即不能打开本机的文件系统(file://),它所加载的脚本,必须来自网络。

# indexedDB

键值对储存: IndexedDB 内部采用对象仓库(object store)存放数据。所有类型的数据都可以直接存入,包括 JavaScript 对象。对象仓库中,数据以"键值对"的形式保存,每一个数据记录都有对应的主键,主键是独一无二的,不能有重复,否则会抛出一个错误。

异步: IndexedDB 操作时不会锁死浏览器,用户依然可以进行其他操作,这与 LocalStorage 形成对比,后者的操作是同步的。异步设计是为了防止大量数据的读写,拖慢网页的表现。

支持事务: IndexedDB 支持事务(transaction),这意味着一系列操作步骤之中,只要有一步失败,整个事务就都取消,数据库回滚到事务发生之前的状态,不存在只改写一部分数据的情况。

同源限制: IndexedDB 受到同源限制,每一个数据库对应创建它的域名。网页只能访问自身域名下的数据库,而不能访问跨域的数据库。

储存空间大: IndexedDB 的储存空间比 LocalStorage 大得多,一般来说不少于 250MB,甚至没有上限。

支持二进制储存: IndexedDB 不仅可以储存字符串,还可以储存二进制数据(ArrayBuffer 对象和 Blob 对象)。

# 如何提高应用性能?

在这里我推荐使用 dexie ,这是一个已经封装好的操作indexedDB的工具库。

为什么不使用原生API?下面是摘抄MDN上的原话,传送门 (opens new window)

注意:IndexedDB API是强大的,但对于简单的情况可能看起来太复杂。如果你更喜欢一个简单的API,请尝试 localForage (opens new window)dexie.js (opens new window)PouchDB (opens new window)idb (opens new window)idb-keyval (opens new window)JsStore (opens new window) 或者 lovefield (opens new window) 之类的库,这些库使 IndexedDB 对开发者来说更加友好。

对于使用indexed当作本地缓存数据库使用的话,原生API的功能对于我们来说实在有些大材小用了。像事务等功能,缓存场景并不适用,所以我更推荐使用现成已经有的轮子来对数据库进行增删改查,这样可以大大减少我们的代码量,同时可以大幅度降低代码逻辑复杂度。

问: 本地数据库和远程数据库数据一致性怎么保证呢?

答:所以个人其实更推荐将一些改动幅度比较小的api缓存到本地数据库中。

如果api更改的很频繁,并不建议让其缓存到本地数据库中。

问:那有一些场景既不频繁,但偶尔也有更改的场景应该如何处理呢?

答:可以先使用本地数据库,然后在fetch api进行比对,如果比对结果无diff不做任何处理,

如果有diff的话,我们更新本地数据库,同时rerender页面。

问:这样的话虽然页面展示出来了,但是fetch时候依然占用主线程,导致主线程挂起?

答:所以另一个主角webworker迈着正步向我们走来了,可以起一个webworker代替我们进行

fetch和diff,这样既不需要占用主线程,还可以帮我们搬砖。Wow...

# 创建indexedDB

// 实例化一个数据库 数据库名称是tikhawk_db
const db = new Dexie('dbname') as ILocalIndexDB;

// 在dexie框架中我们并不需要管理复杂的版本系统,默认使用框架内部的处理,使用1即可。
// 在数据库中加了一个数据表 common, 同时指定主键 stamp
db.version(1).stores({
  common: 'stamp',
});

// 继续封装我们common表的操作。
// why? 缓存数据库并不像服务端数据库一样,我们需要固定格式的数据,我更推荐使用一个表才缓存数据。
// 这样我们可以更高性能的根据主键来获取我们缓存的整个数据对象。

export const dbPut = <T>(data: T, stamp: string) => {
  db.common.put({data, stamp})
}

export const dbGet = (stamp: string) => {
  return db.common.get(stamp);
}

//以上操作一个数据库和数据库都已经建好了, 下面来使用。

const enum IndexedDBStampEnum {
  PRODUCT = "product",
}

// 向数据表中插入一条主键为product的记录,如果该记录不存在则创建,如果该记录存在则覆盖。
await dbPut({name: "pencil", price: 100}, IndexedDBStampEnum.PRODUCT);
// 查询数据表中主键为product的记录。
const {data, stamp} = await dbGet(IndexedDBStampEnum.PRODUCT);

上面已经成功创建一个名称为 "dbname" 的数据库。

在tikhawk_db数据库中创建了一个名称为"common"的数据表。

并插入了一条 {name: "pencil", price: 100}的记录。

打开控制台看下数据库中是否有这条记录。

# 创建web Worker

// 在worker.js中导出一个worker的方法
export function entitiesWorker() {
  // 当收到主线程来信的时候需要做的处理
  self.onmessage = e => {
  // 请求接口
    fetch(self.location.origin + '/api/entities', {
      method: 'GET',
    }).then( r => r.json()).then(
      r => {
      // 进行比对
      // 或者使用深拷贝
        if(JSON.stringify(e.data) === JSON.stringify(r.data[0])) {
        // 不需要更新
          self.postMessage({
            isUpdate: false,
          });
        } else {
        // 需要更新
          self.postMessage({
            isUpdate: true,
            data: r.data,
          });
        }
      }
    )
  }
}

// 主线程
// 由于web Worker只能通过url引入,同时还有同源策略所以需要我们自己处理worker.js文件
// transWorker 是我们转worker的工具
// 将worker转成一个IIFE之后封装成一个blob 然后传入worker。
export const transWorker = (worker) => {
  let blob = new Blob([ "(" + worker.toString() + ')()'], {type: "application/javascript"});
  return new Worker(URL.createObjectURL(blob));
}
let apiWorker = transWorker(entitiesWorker);

// 经过上面一番简单的折腾我们的worker就建好了

# web Worker和indexedDB通信

上面我们已经写好了web Worker 和 indexedDB,接下来结合两者进行本地数据缓存和单开线程进行数据diff及更新。

const {data} = await dbGet(IndexedDBStampEnum.PRODUCT);
// 如果本地数据库中有数据
if(data) {
  // 将本地缓存数据给State
  this.setState({ data });
  // 单开worker线程
  let apiWorker = transWorker(entitiesWorker);
  // 由于worker无法访问indexedDB,所以需要手动将数据传给worker线程
  apiWorker.postMessage(data);
  // 等待worker响应
  apiWorker.onmessage = e => {
    // 如果数据有diff
    if(e.data.isUpdate) {
      // 更新数据库及rerender页面
      dbPut(e.data.data, IndexedDBStampEnum.PRODUCT);
      this.setState({ data: e.data.data });
    }
    //关闭线程
    apiWorker.terminate();
  }
} else {
  //没有数据则执行api拉取,不要忘记在api层更新indexedDB的数据。
  await this.getState();
}

这样api就在本地缓存好了,我们看一下大概可以提升多少性能呢?

可以看到优化之前loading时长大概3s。 loading时常就是我们的api请求时长。我们再来看一下使用了本地缓存之后的。

不光首屏加速了,而且api的加载市场已经缩短到300-400ms左右了。 性能大概提升了10倍。 而且我们的api还是通过web worker异步拉取并比较的,所以就算数据有更新,页面的性能也不会有损失。

但是该方法还是只适用于数据不经常改变的请求上。 将一些请求缓存到本地可以大幅度提升性能,给用户提供更好的体验。

# Comment area