image.png

JS单线程与浏览器渲染

用户使用浏览器一般会打开多个页面 (多 Tab), 现代浏览器使用单独的进程 (Render Process) 渲染每个页面, 以提升页面性能和稳定性, 并进行操作系统级别的内存隔离.
image.png

页面内, 内容渲染和用户交互主要由 Render Process 中的主线程进行管理. 主线程渲染页面每一帧 (Frame), 如下图所示, 会包含5 个步骤: JavaScript → Style → Layout → Paint → Composite, 如果 JS 的执行修改了 DOM, 可能还会暂停 JS, 插入并执行 Style 和 Layout.

image.png

我们熟知的JS 单线程和 Event Loop, 是主线程的一部分. JS 单线程执行避免了多线程开发中的复杂场景 (如竞态和死锁). 但单线程的主要困扰是: 主线程同步 JS 执行耗时过久时 (浏览器理想帧间隔约 16ms),会阻塞用户交互和页面渲染.

image.png
如上图所示, 长耗时任务执行时, 页面将无法更新, 也无法响应用户的输入/点击/滚动等操作. 如果卡死太久, 浏览器可能会抛出卡顿的提示. 如下图所示.

Web Worker 多线程

Web Worker 会创建操作系统级别的线程.

The Worker interface spawnsreal OS-level threads. —MDN

JS 多线程, 是有独立于主线程的 JS 运行环境. 如下图所示: Worker 线程有独立的内存空间, Message Queue, Event Loop, Call Stack 等, 线程间通过 postMessage 通信.
Web Worker - 多线程 - 图5
多个线程可以并发运行 JS.
熟悉 JS 异步编程的同学可能会说,setTimeout/Promise.all不就是并发吗, 我写得可溜了.

JS 单线程中的” 并发”, 准确来说是Concurrent. 如下图所示, 运行时只有一个函数调用栈, 通过 Event Loop 实现不同 Task 的上下文切换 (Context Switch). 这些 Task 通过 BOM API 调起其他线程为主线程工作, 但回调函数代码逻辑依然由 JS 串行运行.

Web Worker 是 JS 多线程运行技术, 准确来说是Parallel. 其与Concurrent的区别如下图所示:
Parallel 有多个函数调用栈, 每个函数调用栈可以独立运行 Task, 互不干扰.
Web Worker - 多线程 - 图6

应用场景

讨论完主线程和多线程, 我们能更好地理解 Worker 多线程的应用场景:

  • 可以减少主线程卡顿.
  • 可能会带来性能提升.

    减少卡顿

    根据 Chrome 团队提出的用户感知性能模型RAIL, 同步 JS 执行时间不能过长. 量化来说, 播放动画时建议小于 16ms, 用户操作响应建议小于 100ms, 页面打开到开始呈现内容建议小于 1000ms.

    逻辑异步化

    减少主线程卡顿的主要方法为异步化执行, 比如播放动画时, 将同步任务拆分为多个小于 16ms 的子任务, 然后在页面每一帧前通过requestAnimationFrame按计划执行一个子任务, 直到全部子任务执行完毕.
    Web Worker - 多线程 - 图7

拆分同步逻辑的异步方案对大部分场景有效果, 但并不是一劳永逸的银弹. 有以下几个问题:

  • 不是所有 JS 逻辑都可拆分.比如数组排序, 树的递归查找, 图像处理算法等, 执行中需要维护当前状态, 且调用上非线性, 无法轻易地拆分为子任务.
  • 可以拆分的逻辑难以把控粒度.如下图所示, 拆分的子任务在高性能机器 (iphoneX) 上可以控制在 16ms 内, 但在性能落后机器 (iphone6) 上就超过了 deadline. 16ms 的用户感知时间, 并不会因为用户手上机器的差别而变化, Google 给出的建议是再拆小到 3-4ms.
  • 拆分的子任务并不稳定.对同步 JS 逻辑的拆分, 需要根据业务场景寻找原子逻辑, 而原子逻辑会跟随业务变化, 每次改动业务都需要去 review 原子逻辑.

    Worker 一步到位

    Worker 的多线程能力, 使得同步 JS 任务的拆分一步到位:从宏观上将整个同步 JS 任务异步化. 不需要再去苦苦寻找原子逻辑, 逻辑异步化的设计上也更加简单和可维护.
    这给我们带来更多的想象空间. 如下图所示, 在浏览器主线程渲染周期内, 将可能阻塞页面渲染的 JS 运行任务 (Jank Job) 迁移到 Worker 线程中, 进而减少主线程的负担, 缩短渲染间隔, 减少页面卡顿.
    Web Worker - 多线程 - 图8

    性能提升

    Worker 多线程并不会直接带来计算性能的提升, 能否提升与设备 CPU 核数和线程策略有关.

    多线程与 CPU 核数

    CPU 的单核 (Single Core) 和多核 (Multi Core) 离前端似乎有点远了. 但在页面上运用多线程技术时, 核数会影响线程创建策略.
    进程是操作系统资源分配的基本单位,线程是操作系统调度 CPU的基本单位. 操作系统对线程能占用的 CPU 计算资源有复杂的分配策略. 如下图所示:

  • 单核多线程通过时间切片交替执行.

  • 多核多线程可在不同核中真正并行.

Web Worker - 多线程 - 图9

Worker 线程策略

一台设备上相同任务在各线程中运行耗时是一样的. 如下图所示: 我们将主线程 JS 任务交给新建的 Worker 线程, 任务在 Worker 线程上运行并不会比原本主线程更快, 而线程新建消耗和通信开销使得渲染间隔可能变得更久.
Web Worker - 多线程 - 图10

在单核机器上,计算资源是内卷的, 新建的 Worker 线程并不能为页面争取到更多的计算资源. 在多核机器上, 新建的 Worker 线程和主线程都能做运算,页面总计算资源增多, 但对单次任务来说, 在哪个线程上运行耗时是一样的.
真正带来性能提升的是多核多线程并发.

如多个没有依赖关系的同步任务, 在单线程上只能串行执行, 在多核多线程中可以并行执行. 如下图alloy-worker图像处理 demo所示, 在 iMac 上运行时创建了 6 条 Worker 线程, 图像处理总时间比主线程串行处理快了约 2000ms.
Web Worker - 多线程 - 图11

把主线程还给 UI

Worker 的应用场景, 本质上是从主线程中剥离逻辑, 让主线程专注于 UI 渲染. 这种架构设计并非 Web 技术上的独创.
Android 和 iOS 的原生开发中,主线程负责 UI 工作; 前端领域热门的小程序, 实现原理上就是渲染和逻辑的完全分离.

Worker API

通信 API

image.png
如上图所示的 Worker 通信流程, Worker通信 API非常简单. 通俗中文教程可以参考Web Worker 使用教程. 使用细节建议看官方文档.
双向通信示例代码如下图所示,双向通信只需 7 行代码.
Web Worker - 多线程 - 图13
主要流程为:

  1. 主线程调用new Worker(url)创建 Worker 实例,url为 Worker JS 资源 url.
  2. 主线程调用postMessage发送hello, 在onmesssage中监听 Worker 线程消息.
  3. Worker 线程在onmessage中监听主线程消息, 收到主线程的hello; 通过postMessage回复world.
  4. 主线程在消息回调中收到 Worker 的world信息.
  5. postMessage 会在接收线程创建一个MessageEvent, 传递的数据添加到event.data, 再触发该事件;

MessageEvent 的回调函数进入 Message Queue, 成为待执行的宏任务. 因此 postMessage顺序发送的信息, 在接收线程中会顺序执行回调函数. 而且我们无需担心实例化 Worker 过程中 postMessage 的信息丢失问题, 对此 Worker 内部机制已经处理.

说明

  1. 构造函数URl

Worker()构造函数的参数是一个脚本文件,该文件就是 Worker 线程所要执行的任务。由于 Worker 不能读取本地文件,所以这个脚本必须来自网络 。

  • 脚本文件
    1. const worker = new Worker('https://~.js');
  1. 分配给 Worker 线程运行的脚本文件,必须与主线程的脚本文件同源
  2. worker 不能读取本地的文件(不能打开本机的文件系统file://),它所加载的脚本必须来自网络。

如果要使用这种形式的话,在项目中推荐把文件放在静态文件夹中,打包的时候直接拷贝进去,这样我们就可以拿到固定的链接了

  • 字符串形式
    1. const data = `
    2. // worker线程 do something
    3. `;
    4. // 转成二进制对象
    5. const blob = new Blob([data]);
    6. // 生成url
    7. const url = window.URL.createObjectURL(blob);
    8. // 加载url
    9. const worker = new Worker(url);
  1. 进程通信

    主进程和worker进程之间通信,默认是通过拷贝的形式来传递数据的,进行传递的对象需要经过序列化,接下来在另一端还需要反序列化
    幸运的是,Web Worker 提供了一中转移数据的方式,允许主线程把二进制数据直接转移给子线程

DEMO

在项目中安装worker-loader,目的是让webpack识别worker文件 worker-loader参考

  1. npm install --save-dev worker-loader
  1. rules: [
  2. {
  3. test: /\.worker\.js$/, //以.worker.js结尾的文件将被worker-loader加载
  4. use: { loader: 'worker-loader' }
  5. }
  6. ]

index.js

  1. import worker_script from './worker';
  2. var myWorker = new Worker(worker_script, { "name": 'GQC_WEB_WORKER' });
  3. myWorker.onmessage = (m) => {
  4. console.log("msg from worker: ", m);
  5. };
  6. myWorker.postMessage('im from main');

worker.js
转成一个二进制对象,然后为这个二进制对象生成 URL,再让 Worker 加载这个 URL。

  1. const createWorker = func => {
  2. if (func instanceof Worker) return func;
  3. if (typeof func === "string" && func.endsWith(".js")) return new Worker(func);
  4. const code = [
  5. `self.func = ${func.toString()};`,
  6. "self.onmessage = async (e) => {",
  7. " const r = self.func(e.data);",
  8. " if (r[Symbol.asyncIterator]) {",
  9. " for await (const i of r) self.postMessage(i)",
  10. " } else if (r[Symbol.iterator]){",
  11. " for (const i of r) self.postMessage(i)",
  12. " } else {",
  13. " self.postMessage(await r)",
  14. " }",
  15. "};"
  16. ];
  17. const blob = new Blob(code, { type: "text/javascript" });
  18. const url = URL.createObjectURL(blob);
  19. return new Worker(url);
  20. };
  1. class webWorker {
  2. /**
  3. * @description: webworker的简单封装
  4. * @param {String} data js的url/script的id或class
  5. * @param {Object} type 默认值给出,同页面的web worker传个’worker‘即可
  6. * @return: WebWorker 对象
  7. */
  8. constructor(data, type = "url") {
  9. this.worker = null;
  10. this.workerInit(data, type);
  11. }
  12. workerInit(data, type) {
  13. if (type === "url") {
  14. // 默认是以url脚本形式的worker线程
  15. // 此时的data应该是一个url链接
  16. this.worker = new Worker(data);
  17. } else {
  18. // 以字符串形式创建worker线程,把代码字符串,转成二进制对象,生成 URL,加载URL
  19. const blob = new Blob([data]);
  20. const url = window.URL.createObjectURL(blob);
  21. this.worker = new Worker(url); // 加载
  22. }
  23. }
  24. /**
  25. * @description: 给worker线程发送消息
  26. * @param {*} data 要发送的数据
  27. */
  28. postMessage(data) {
  29. return this.worker.postMessage(data);
  30. }
  31. /**
  32. * @description: worker线程发送给主进程的数据
  33. * @param {Function} fn 把数据通过回调的形式传出去
  34. */
  35. onmessage(fn) {
  36. this.worker.onmessage = msg => {
  37. return fn(msg.data);
  38. };
  39. }
  40. // 主线程关闭worker线程
  41. closeWorker() {
  42. return this.worker.terminate();
  43. }
  44. /**
  45. * @description: 主线程监听worker线程的错误信息
  46. * @param {Function} fn 错误信息回调
  47. */
  48. onerror(fn) {
  49. this.worker.onerror = e => {
  50. return fn(e);
  51. };
  52. }
  53. }
  54. const data = `
  55. // worker线程加载脚本 TODO: Worker 线程无法读取本地文件,加载的脚本必须来自网络
  56. // importScripts('hello1.js', 'http~.js');
  57. // 监听主线程传过来的信息
  58. self.onmessage = e => {
  59. console.log('主线程传来的信息:', e.data);
  60. // do something
  61. };
  62. // 发送信息给主线程
  63. self.postMessage('来自worker线程');
  64. // 关闭worker线程
  65. function closeSon() {
  66. return self.close();
  67. }`;
  68. const worker = new webWorker(data, "script");
  69. worker.onmessage(data => {
  70. console.log("父进程接收的数据:", data);
  71. });
  72. worker.postMessage("主进程传给worker线程");
  73. worker.onerror(msg => {
  74. console.log("worker线程报错:", msg);
  75. });
  1. const workercode = () => {
  2. self.onmessage = function(e) {
  3. console.log('Message received from main script');
  4. var workerResult = 'Received from main: ' + (e.data);
  5. console.log('Posting message back to main script');
  6. self.postMessage(workerResult);
  7. }
  8. };
  9. let code = workercode.toString();
  10. code = code.substring(code.indexOf("{")+1, code.lastIndexOf("}"));
  11. const blob = new Blob([code], {type: "application/javascript"});
  12. const worker_script = URL.createObjectURL(blob);
  13. module.exports = worker_script

image.png

调试工具用法

前端工程师对 Worker 多线程开发方式比较陌生, 对开发中的 Worker 代码调试也是如此.

Chrome 调试

Chrome 已完善支持 Worker 代码调试, 开发者面板中的调试方式与主线程 JS 一致.

Console 调试

Console Panel 中可以查看页面全部的 JS 运行环境, 并通过下拉框切换调试的当前环境. 如下图所示, 其中top表示主线程的 JS 运行环境, GQC_WEB_WORKER表示 Worker 线程的 JS 运行环境.

切换后, 就可以在 Worker 运行环境中执行调试代码. 如下图所示, Worker 环境的全局对象为self, 类型为DedicatedWorkerGlobalScope.
image.png

断点调试

Worker 断点调试方式和主线程一致: 源码中添加debugger标识的代码位置会作为断点. 在 Sources Panel 查看页面源码时, 如下图所示, 左侧面板展示 Worker 线程资源; 运行到 Worker 线程断点时, 右侧的Threads提示所在的运行环境是名为GQC_WEB_WORKER的 Worker 线程.
image.png

性能调试

使用 Performance Panel 的录制功能即可. 如下图红框所示, Performance 中也记录了 Worker 线程的运行情况.
image.png

查看内存占用

Worker 的使用场景偏向数据和运算, 开发中适时回顾 Worker 线程的内存占用, 避免内存泄露干扰整个 Render Process. 如下图所示, 在 Memory Panel 中alloyWorker-test线程占用的内存为 1.2M.
image.png

数据流调试

跨线程通信数据流是开发和调试中比较复杂的部分. 因为页面上可能有多个 Worker 实例; Worker 实例上有不同的数据类型 (payload); 而且相同类型的通信可能会多次发起.
通过 onmessage 回调打 log 调试数据流时, 建议添加当前 Worker 实例名称, 通信类型, 通信负载等信息. 以alloy-worker调试模式的 log 为例:
Web Worker - 多线程 - 图19
如上图所示:

  • 每行信息包括: 线程名称, [时间戳, 会话 Id, 事务类型, 事务负载].
  • 绿色的向下箭头 (⬇) 表示 Worker 线程收到的信息.
  • 粉红的向上箭头 (⬆) 表示 Worker 线程发出的信息.

    运行环境

    在 Worker 线程中运行 JS, 会创建独立于主线程的 JS 运行环境, 称之为DedicatedWorkerGlobalScope. 开发者需关注 Worker 环境和主线程环境的异同, 以及 Worker 在不同浏览器上的差异.

    Worker 环境和主线程环境的异同

    Worker 是无 UI 的线程, 无法调用 UI 相关的 DOM/BOM API.
    Worker 具体支持的 API 可参考 MDN 的functions and classes available to workers.
    Web Worker - 多线程 - 图20
    [

](https://dzone.com/articles/introduction-html5-web-workers)
上图展示了 Worker 线程与主线程的异同点. Worker 运行环境与主线程的共同点主要包括:

  • 包含完整的 JS 运行时, 支持 ECMAScript 规范定义的语言语法和内置对象.
  • 支持XmlHttpRequest, 能独立发送网络请求与后台交互.
  • 包含只读的 Location, 指向 Worker 线程执行的 script url, 可通过 url 传递参数给 Worker 环境.
  • 包含只读的 Navigator, 用于获取浏览器信息, 如通过Navigator.userAgent识别浏览器.
  • 支持 setTimeout / setInterval 计时器, 可用于实现异步逻辑.
  • 支持 WebSocket 进行网络 I/O; 支持 IndexedDB 进行文件 I/O.

从共同点上看, Worker 线程其实很强大, 除了利用独立线程执行重度逻辑外, 其网络 I/O 和文件 I/O 能力给业务和技术方案带来很大的想象空间.
Web Worker - 多线程 - 图21
另一方面, Worker 线程运行环境和主线程的差异点有:

  • Worker 线程没有 DOM API, 无法新建和操作 DOM; 也无法访问到主线程的 DOM Element.
  • Worker 线程和主线程间内存独立, Worker 线程无法访问页面上的全局变量 (window, document 等) 和 JS 函数.
  • Worker 线程不能调用 alert() 或 confirm() 等 UI 相关的 BOM API.
  • Worker 线程被主线程控制, 主线程可以新建和销毁 Worker.
  • Worker 线程可以通过self.close自行销毁.

从差异点上看, Worker 线程无法染指 UI, 并受主线程控制, 适合默默干活.

通信速度

Worker 多线程虽然实现了 JS 任务的并行运行, 也带来额外的通信开销. 如下图所示, 从线程 A 调用 postMessage 发送数据到线程 B onmessage 接收到数据有时间差, 这段时间差称为通信消耗.
Web Worker - 多线程 - 图22
[

](https://www.youtube.com/watch?v=AEpG-3XXrjk)
提升的性能 = 并行提升的性能 – 通信消耗的性能. 在线程计算能力固定的情况下, 要通过多线程提升更多性能, 需要尽量减少通信消耗.
而且主线程 postMessage 会占用主线程同步执行,占用时间与数据传输方式和数据规模相关. 要避免多线程通信导致的主线程卡顿, 需选择合适的传输方式, 并控制每个渲染周期内的数据传输规模.

数据传输方式

我们先来聊聊主线程和 Worker 线程的数据传输方式. 根据计算机进程模型, 主线程和 Worker 线程属于同一进程, 可以访问和操作进程的内存空间. 但为了降低多线程并发的逻辑复杂度, 部分传输方式直接隔离了线程间的内存, 相当于默认加了锁.
通信方式有 3 种: Structured Clone, Transfer Memory 和 Shared Array Buffer.

Structured Clone

Structured Clone是 postMessage 默认的通信方式. 如下图所示, 复制一份线程 A 的 JS Object 内存给到线程 B, 线程 B 能获取和操作新复制的内存.
Web Worker - 多线程 - 图23
Structured Clone 通过复制内存的方式简单有效地隔离不同线程内存, 避免冲突; 且传输的 Object 数据结构很灵活. 但复制过程中, 线程 A 要同步执行Object Serialization, 线程 B 要同步执行Object Deserialization; 如果 Object 规模过大, 会占用大量的线程时间.

Transfer Memory

Transfer Memory意为转移内存, 它不需要 Serialization/Deserialization, 能大大减少传输过程占用的线程时间. 如下图所示 , 线程 A 将指定内存的所有权和操作权转给线程 B, 但转让后线程 A 无法再访问这块内存.
Web Worker - 多线程 - 图24
Transfer Memory以失去控制权来换取高效传输, 通过内存独占给多线程并发加锁. 但只能转让ArrayBuffer等大小规整的二进制 (Raw Binary) 数据; 对矩阵数据 (如 RGB 图片) 比较适用. 实践上也要考虑从 JS Object 生成二进制数据的运算成本.

Shared Array Buffers

Shared Array Buffer是共享内存, 线程 A 和线程 B 可以同时访问和操作同一块内存空间. 数据都共享了, 也就没有传输什么事了.
Web Worker - 多线程 - 图25
但多个并行的线程共享内存, 会产生竞争问题 (Race Conditions). 不像前 2 种传输方式默认加锁, Shared Array Buffers 把难题抛给开发者, 开发者可以用Atomics来维护这块共享的内存. 作为较新的传输方式, 浏览器兼容性可想而知, 目前只有 Chrome 68+ 支持.

传输方式小结

  • 全浏览器兼容的 Structured Clone 是较好的选择, 但要考虑数据传输规模, 下文我们会详细展开.
  • Transfer Memory 的兼容性也不错 (IE11+), 但数据独占和数据类型的限制, 使得它是特定场景的最优解, 不是通用解;
  • Shared Array Buffers 当下糟糕的兼容性和线程锁的开发成本, 建议先暗中观察.

    JSON.stringify 更快?

    使用 Structured Clone 传输数据时, 有个阴影一直笼罩着我们: postMessage 前要不要对数据 JSON.stringify 一把,听说那样更快?
    2016 年的High-performance Web Worker messages进行了测试, 确实如此. 但是文章的测试结果也只能停留在 2016 年. 2019 年Surma 进行新的测试: 如下图所示, 横轴上相同的数据规模, 直接 postMessage 的传输时间普遍比 JSON.stringify 更少.
    Web Worker - 多线程 - 图26
    2020 年的当下,不需要再使用 JSON.stringify. 其一是 Structured Clone 内置的 serialize/deserialize 比 JSON.stringify 性能更高; 其二是 JSON.stringify 只适合序列化基本数据类型, 而 Structured Clone 还支持复制其他内置数据类型 (如 Map, Blob, RegExp 等, 虽然大部分应用场景只用到基本数据类型).

    数据传输规模

    我们再来聊聊 Structured Clone 的数据传输规模. Structured Clone 的 serialize/deserialize 执行耗时主要受数据对象复杂度影响, 这很好理解, 因为 serialize/deserialize 至少要以某种方式遍历对象. 数据对象的复杂度本身难以度量, 可以用序列化后的数据规模 (size) 作为参考.
    2015 年的How fast are web workers中等性能手机上进行了测试: postMessage 发送数组的通信速率为 80KB/ms, 相当于理想渲染周期 (16ms) 内发送 1300KB.
    2019 年 Surma 对 postMessage 的数据传输能力进行了更深入研究, 具体见Is postMessage slow.高性能机器 (macbook)上的测试结果如下图所示:
    Web Worker - 多线程 - 图27
    其中:

  • 测试数据为嵌套层数 1 到 6 层 (payload depth, 图中纵坐标), 每层节点的子节点 1 到 6 个 (payload breadth, 图中横坐标) 的对象,数据规模从 10B 到 10MB.

  • 在 macbook 上, 10MB 的数据传递耗时 47ms, 16ms 内可以传递 1MB 级别的数据.

低性能机器 (nokia2)上的测试结果如下图所示:
Web Worker - 多线程 - 图28
其中:

  • 在 nokia2 上传输 10MB 的数据耗时 638ms, 16ms 内可以传递 10KB 级别的数据.
  • 高性能机器和低性能机器有超过 10 倍的传输效率差距.

不管用户侧的机器性能如何, 用户对流畅的感受是一致的: 前端同学的老朋友 16ms 和 100ms. Surma 兼顾低性能机型上 postMessage 容易造成主线程卡顿, 提出的数据传输规模建议是:

  • 如果 JS 代码里面不包括动画渲染 (100ms), 数据传输规模应该保持在 100KB 以下;
  • 如果 JS 代码里面包括动画渲染 (16ms), 数据传输规模应该保持在 10KB 以下.

笔者认为, Surma 给出的建议偏保守, 传输规模可以再大一些.
总之, 数据传输规模并没有最佳实践. 而是充分理解 Worker postMessage 的传输成本, 在实际应用中, 根据业务场景去评估和控制数据规模.

兼容性

从前文 Worker 的历史和兼容性视图上看, Worker 的兼容性应该挺好的.
Web Worker - 多线程 - 图29
如上图所示, 主流浏览器在几年前就支持 Worker.
PC 端:

  • IE10(2012/09)
  • Chrome4(2010/01)
  • Safari4(2009)
  • Firefox3.5(2009)

移动端:

  • iOS5(2012)
  • Android4.4(2013)

    社区配套工具

    现代化前端开发都采用模块化的方式组织代码, 使用 Web Worker 需将模块源码构建为单一资源 (worker.js). 另一方面, Worker 原生的postMessage/onmessage通信 API 在使用上并不顺手, 复杂场景下往往需要进行通信封装和数据约定.
    因此, 开源社区提供了相关的配套工具, 主解决 2 个关键问题:

  • Worker 代码打包. 将模块化的多个文件, 打包为单一 JS 资源.

  • Worker 通信封装. 封装多线程通信, 简化调用; 或约定通信负载的数据格式.

下面介绍社区的一些主要工具, star 数统计时间为 2020.06.

worker-loader(1.1k star)

Webpack 官方的 Worker loader. 负责将 Worker 源码打包为单个 chunk; chunk 可以是独立文件, 或 inline 的 Blob 资源.输出内嵌new Worker()的 function, 通过调用该 function 实例化 Worker.
但 worker-loader 没有提供构建后的 Worker 资源 url, 上层业务进行定制有困难. 已有相关 issue讨论该问题; worker-loader 也不对通信方式做额外处理.

worker-plugin(1.6k star)

Web Worker - 多线程 - 图30
GoogleChromeLabs 提供的 Webpack 构建 plugin.
作为 plugin, 支持 Worker 和 SharedWorker 的构建. 无需入侵源码, 通过解析源码中new Worker和new SharedWorker语法, 自动完成 JS 资源的构建打包. 也提供 loader 功能: 打包资源并且返回资源 url, 这点比 worker-loader 有优势.

comlink(6.2k star)

Web Worker - 多线程 - 图31
也来自 GoogleChromeLabs 团队, 由 Surma 开发. 基于 ES6 的 Proxy 能力, 对 postMessage 进行 RPC(Remote Procedure Call) 封装, 将跨线程的函数调用封装为 Promise 调用.
但它不涉及 Worker 资源构建打包, 需要其他配套工具. 且 Proxy 在部分浏览器中需要 polyfill, 可 polyfill 程度存疑.

workerize-loader(1.7k star)

Web Worker - 多线程 - 图32
目前社区比较完整, 且兼容性好的方案.
类似 worker-loader + comlink 的合体. 但不是基于 Proxy, 而在构建时根据源码 AST 提取出调用函数名称, 在另一线程内置同名函数; 封装跨线程函数为 RPC 调用.
与 workerize-loader 关联的另一个项目是workerize(3.8k star). 支持手写文本函数, 内部封装为 RPC; 但手写文本函数实用性不强.

userWorker(1.8k star)

很有趣的项目, 将 Worker 封装为 React Hook. 基本原理是: 将传入 Hook 的函数处理为BlobUrl去实例化 Worker. 因为会把函数转为BlobUrl的字符串形式, 限制了函数不能有外部依赖, 函数体中也不能调用其他函数.
比较适合一次性使用的纯函数, 函数复杂度受限.

其他可参考项目

  • promise-worker0.4k star.
  • greenlet4.3k star.
  • workly1.7k star.
  • threads.js1.1k star, 支持 nodejs.

    业界实践回顾

    实践场景

    Web Worker 作为浏览器多线程技术, 在页面内容不断丰富, 功能日趋复杂的当下, 成为缓解页面卡顿, 提升应用性能的可选方案.

    2010 年

    2010 年, 文章The Basics of Web Workers列举的 Worker 可用场景如下:
    Web Worker - 多线程 - 图33
    2010 年的应用场景主要涉及数据处理, 文本处理, 图像/视频处理, 网络处理等.

    当下

    2018 年, 文章Parallel programming in JavaScript using Web Workers列举的 Worker 可用场景如下:
    Web Worker - 多线程 - 图34
    可见, 近年来 Worker 的场景比 2010 年更丰富, 拓展到了 Canvas drawing(离屏渲染方面), Virtual DOM diffing(前端框架方面), indexedDB(本地存储方面), Webassembly(编译型语言方面) 等.
    总的来说, Worker 对页面的计算任务/后台任务有用武之地. 接下来笔者将分享的一些具体 case, 并进行简析.

    重度计算场景

    石墨表格之 Web Worker 应用实战

    2017 年的文章, 非常好的实践. 在线表格排序是 CPU 密集型场景, 复杂任务原子化和异步化后依然难以消除页面卡顿. 将排序迁移到 Worker 后, 对 2500 行数据的排序操作,Scripting 时间从 9984ms 减少到 3650ms .

    Making TensorflowJS work faster with WebWorkers

    2020 年的文章, 使用生动的图例说明TF.js在主线程运行造成的掉帧. 以实时摄像头视频的动作检测为例子, 通过 Worker 实现视频动画不卡顿 (16ms 内); 动作检测耗时 50ms, 但是不阻塞视频, 也有约 15FPS.

    腾讯文档 Excel 函数实践

    笔者撰写文章中, 近期发布.

    前端框架场景

    neo — webworkers driven UI framework

    2019 年开源的Worker 驱动前端框架. 其将前端框架的拆分为 3 个 Worker: App Worker, Data Worker 和 Vdom Worker. 主线程只需要维护 DOM 和代理 DOM 事件到 App Worker 中; Data Worker 负责进行后台请求和托管数据 store; Vdom Worker 将模板字符串转换为虚拟节点, 并对每次变化生成增量去更新.

    worker-dom

    Google AMP项目一部分. 在 Worker 中实现 DOM 操作 API 和 DOM 事件监听, 并将 DOM 变化应用到主线程真实 DOM 上.官方 Demo)在 Worker 中直接引入 React 并实现 render!

    Angular

    Angular8 CLI 支持创建 Web Worker 指令, 并将耗 CPU 计算迁移到 Worker 中; 但是 Angular 本身并不能在 Worker 中运行. 官网 angular.io 也用 Worker 来提升搜索性能.
    Web Worker - 多线程 - 图35

    数据流场景

    Off-main-thread React Redux with Performance

    2019 年的文章. 将Redux的action部分迁移到 Worker 中, 开源了项目redux-in-worker.做了 Worker Redux 的 benchmark: 和主线程相差不大 (但是不卡了).

    Off Main Thread Architecture with Vuex

    2019 年的文章. 简单分析 UI 线程过载和 Worker 并发能力. 对 Vue 数据流框架Vuex进行分解, 发现action可以包含异步操作, 适合迁移到 Worker. 实现了 action 的封装函数和质数生成的 demo.

    可视化场景

    PROXX

    PROXX 是 GoogleChromeLabs 开发的在线扫雷游戏, 其 Worker 能力由 Surma 开发的Comlink提供. Surma 特地开发了Worker 版本和非 Worker 版本: 在高性能机型 Pixel3 和 MacBook 上, 两者差异不大; 但在低性能机型 Nokia2 上,非 Worker 版本点击动作卡了 6.6s, Worker 版本点击回调需要 48ms.

    图片风格处理

    2013 年的文章. 使用 Worker 将图片处理为复古色调. 在当年先进的 12 核机器上, 使用 4 个 Worker 线程后,处理时间从 150ms 减低到 80ms; 在当年的双核机器上, 处理时间从 900ms 减低到 500ms.

    OpenCV directly in the browser (webassembly + webworker)

    2020 的文章. 基于 OpenCV 项目, 将项目编译为 webassembly, 并且在 Worker 中动态加载 opencv.js, 实现了图片的灰度处理.

    大型项目

    OffscreenCanvas

    Chrome69+支持, 能将主线程 Canvas 的绘制权 transfer 给 Worker 线程的 OffscreenCanvas, 在 Worker 中绘制后渲染直接到页面上; 也支持在 Worker 中新建 Canvas 绘制图形, 通过 imagebitmap transfer 到主线程展示.

    hls.js

    hls 是基于 JS 实现的 HTTP 实时流媒体播放库. 其使用 Worker 用于流数据的解复用 (demuxer), 使用 Transfer Memory 来最小化传输的消耗.

    pdf.js

    判断浏览器是否支持 Worker 能力, 有 Worker 能力时将 pdf 文件解析 ( parsed and interpreted)全部放在 Worker 线程中; Worker 能力不完备则在主线程运行.

    相关视频/分享 PPT

    Web Workers — I like the way you work it

    2016 年的分享 ppt, Pokedex.org 项目在 Web Worker 中进行 Virtual DOM 的更新, 显著提升快速滚动下的渲染效率.

    The main thread is overworked & underpaid

    Chrome Dev Summit 2019, 非常精彩的分享, 来自 google 的工程师 Surma. 演讲指出页面主线程工作量过大, 特别是发展中国家有大量的低性能设备.运算在 Worker 慢一点但页面不掉帧优于运算在主线程快一点但卡顿.

    Is postMessage slow? - HTTP 203

    同样来自 Surma 的技术访谈. 主要讨论 postMessage 的性能问题. 本文在通信速度部分大量引用 Surma 的研究.
    Surma 在 Worker 领域写了多篇文章, 并开源了 Comlink.

    前端項目上 Web Worker 實踐

    2019 年的演讲, 笔者前同事, 曾在 Worker 实践上紧密合作. 演讲讨论 Web Worker 的使用场景; Worker 的注意点和适应多线程的代码改造; 以及实践中遇到的问题和解决方案.

    Weaving Webs of Workers

    2019 年的演讲, 来自 Netflix 的工程师. 总结使用 Web Worker 遇到的 4 大问题, 并通过引入社区多个配套工具逐一解决.

    Web Workers: A graphical introduction

    2018 年的演讲, 讲多线程和 postMessage 数据传递部分图很漂亮. 将 Web Worker 应用在他开发的Web 钢琴弹奏器.

    What the heack is the event Loop anyway

    2014 年的演讲, 使用生动的图例介绍主线程 Event Loop.

TODO

setTimeout
requestAnimationFrame
对比node多线程 cluster child_progress
[

](https://km.sankuai.com/page/147322119)

References

Web Worker 使用教程-阮一峰
[译] JavaScript 工作原理:Web Worker 的内部构造以及 5 种你应当使用它的场景
Web Worker 文献综述
web worker in create-react-app
前端er来学习一下webWorker吧
canvas-demo-对比
https://codepen.io/ge1doot/pen/LkdOwj/

  1. UPDATE. As create-react-app continues to update its webpack confuguration, the code below will need updates as well. Specifically, the part which locates babel-loader configuration (const babelLoader = ...). Check getBabelLoader() from https://github.com/timarney/react-app-rewired/blob/master/packages/react-app-rewired/index.js to get an updated version of the code in question.
  2. Install react-app-rewired, worker-loader and lodash (the latter is here just for the sake of cloneDeep feel free to replace it with anything you like) :
  3. npm install --save-dev react-app-rewired worker-loader lodash
  4. Create config-overrides.js file in the root directory of your application:
  5. const lodashCloneDeep = require('lodash/cloneDeep');
  6. module.exports = function override(config, env) {
  7. // Add worker-loader by hijacking configuration for regular .js files.
  8. const workerExtension = /\.worker\.js$/;
  9. const babelLoader = config.module.rules.find(
  10. rule => rule.loader && rule.loader.indexOf('babel-loader') !== -1
  11. );
  12. const workerLoader = lodashCloneDeep(babelLoader);
  13. workerLoader.test = workerExtension;
  14. workerLoader.use = [
  15. 'worker-loader',
  16. { // Old babel-loader configuration goes here.
  17. loader: workerLoader.loader,
  18. options: workerLoader.options,
  19. },
  20. ];
  21. delete workerLoader.loader;
  22. delete workerLoader.options;
  23. babelLoader.exclude = (babelLoader.exclude || []).concat([workerExtension]);
  24. config.module.rules.push(workerLoader);
  25. // Optionally output the final config to check it.
  26. //console.dir(config, { depth: 10, colors: true });
  27. return config;
  28. };
  29. Create workers by naming them MySomething.worker.js:
  30. import myDoSomething from 'my-do-something';
  31. onmessage = async function (message) { // eslint-disable-line no-undef
  32. console.log('Message received from main script', message.data);
  33. const workerResult = await myDoSomething(message.data);
  34. console.log('Posting message back to main script');
  35. postMessage(workerResult);
  36. };
  37. Create instances of your worker:
  38. import MySomethingWorker from './MySomething.worker.js';
  39. const worker = new MySomethingWorker();
  40. Enjoy webworkers, stored separately from the main bundle (or inline check worker-loader options) while being processed by Babel. :)