react 项目中使用 web worker

最近在做折线图(echarts)相关的需求,有遇到应用卡顿的情况,分析了卡顿原因后,发现在生成折线图的数据时耗时较大(从对象获取指定数据/数组过滤/排序/裁减),决定用 web worker 来做一些曲线绘制前的数据处理工作

Web Worker

js 是单线程执行的,但浏览器为其提供了多线程的能力 - web worker,借助 web worker 可以并行主线程做一些数据解析或计算的工作。但有一定的局限性,比如不能操作 DOM、访问 window、document 对象等、无法读取本地文件等。然后也会有额外的问题,比如内存占用、通信损耗

所以使用 web worker 并非一定会提升应用性能,需要分析当前性能瓶颈主要是卡在哪里,对症下药。解决性能问题往往需要多种方法来协调,大部分情况下解决关键因素即可,比如数据解析、计算、某个 long task 或者渲染耗时

分类:

  • Dedicated Worker
  • SharedWorker
  • ServiceWorker

其他 Worker 自行了解,下面着重讲 Dedicated Worker,以下简称 Worker

单页面应用使用 Worker

以 react 为例,如果是基于最新的 CRA 脚手架搭建的项目,即基于 webpack5 的项目,可以比较便捷地生成 Worker

const worker = new Worker(new URL("./worker.js", import.meta.url));
worker.postMessage({
  name: "Lucas",
});
worker.onmessage = ({ data: { name } }) => {
  console.log(name);
};

import.meta 是一个内置在 ES 模块内部的对象,import.meta.url 表示一个模块在浏览器和 Node.js 的绝对路径。该特性属于 es2020 的一部分,webpack5 才支持

如果是基于 webpack4 的项目,那需要借助其他方法了

第一种比较简单粗暴,可以把 worker.js 固定放在 public 文件夹下,默认打包后 public 下的文件是固定放在根路径下,可以通过 http://localhost:3000/worker.js 找到

第二种是推荐做法,引入 worker-loader

  • 修改项目 webpack 配置。如果是 CRA 创建的项目,需要借助 react-app-rewired 扩展配置或者直接 eject 导出项目的 webpack 配置进行修改(不推荐)。改动内容大致如下,增加一个针对 worker 脚本的 loader 处理流程
module.exports = {
  module: {
    rules: [
      {
        // 以 .worker.js 结尾的文件将被 worker-loader 加载
        test: /\.worker\.(c|m)?js$/i,
        use: {
          loader: "worker-loader",
        },
      },
    ],
  },
};

CRA 项目中引入

const { override, addWebpackModuleRule } = require("customize-cra");

module.exports = {
  webpack: override(
    addWebpackModuleRule({
      test: /\.worker\.(c|m)?js$/i,
      use: [
        {
          loader: "worker-loader",
        },
      ],
    })
  ),
};

使用方法很简单,直接 import 导入并实例化

// main.ts
import Worker from "./test.worker.ts";
const worker = new Worker();
worker.onmessage = (event) => {
  const data = event.data;
};
worker.postMessage({ data: "from main" });
worker.onerror();
// 不用的话可以关闭,节省内存
worker.terminate();
// test.worker.ts
declare const self: any;
export default {} as typeof Worker & { new (): Worker };
self.onmessage = (event) => {
  const data = event.data;
}
self.postMessage({ data: "from worker" });
self.onerror();
self.close();

为了保证 worker 中的代码被 babel 转译,可以让 babel-loaderworker-loader 之前执行。ts-loader 同理

为什么不能直接 import 引入?

好问题..如果是直接 import 导入,那肯定是需要将它转成脚本路径,比如下面

import workPath from "./worker.js";
const worker = new Worker(workPath);

同样也是需要借助特定的 loader,类似于 file-loader。至于 worker-loader 则是将new Worker(workPath)的步骤内置到 loader 处理流程了,并导出一个函数,外面直接使用该函数即可创建指定的 Worker

worker-loader 是咋工作的?

其实原理不难,主要就是俩个步骤:

  1. webpack 构建过程匹配到 worker 脚本(xx.worker.js)
  2. 将文件名和源代码传入 worker-loader 处理函数中,主要输出以下内容
module.exports = function () {
  return new Worker(__webpack_public_path_ + "123abc.worker.js");
};

loader inline 模式输出内容不太一样,参考 worker-loader 源码

第三种不推荐,是将 worker.js 的主函数转化为 blobUrl 导出,供主线程引用。该方法的好处是可以动态创建 worker

// worker.js
const contentCode = function () {} // worker 脚本主函数
const blob = new Blob([contentCode.toString()], {type: 'text/javascript'});
export {url: URL.createObjectURL(blob)}
// main.js
import { url } from './worker.js'
const worker = new Worker(url);

Worker 通信

  • 拷贝通信(Structured Clone),即 postMessage,对于复杂对象,可能有人觉得先序列化成字符串再拷贝通信效率会更高点,答案是不确定的。有人做了详细的对比,可以参考 https://dassur.ma/things/is-postmessage-slow/
  • 转移内存(Transfer Memory)。只支持 Transferable Objects,有数据独占和数据类型的限制
  • 共享内存SharedArrayBuffer)。浏览器兼容性很差,继续观察…

transfer 可转移对象是 ArrayBuffer、MessagePort、ImageBitmap 等二进制数据。Worker 允许主线程把二进制数据直接转移给子线程,但是一旦转移,主线程就无法再使用这些二进制数据了,这使得主线程可以快速把数据交给 Worker,对于影像、声音处理等复杂计算就很有帮助

Worker 使用场景

  • 数据预取和预解析(本文开头提到的场景)
  • 视频解码:一般的视频网站 以优酷为例,当我们开始播放优酷视频的时候,就能看到它会调用 Worker,解码的代码应该写在 Worker 里面
  • 复杂文件解析。需要大量计算的网站 比如 imgcook 这个网站,它能在前端解析 sketch 文件,这部分解析的逻辑就写在 Worker 里
  • 拼写检查
  • 数据加密
  • 光线追踪…

其实主要都是为了不阻塞页面 ui,提高用户体验,但当通信比较耗时、计算不复杂时,这种时候用 Worker 就有些得不偿失了。当然,以上大部分场景都还只是在一些资料中看到,暂时还没实际项目可实践到,以后有机会再进一步写写 Worker 相关的一些文章吧