前言
有个有趣的统计:在过去两年中,PC用户加载 JavaScript 的次数增加了19%
,而同期移动用户加载 JavaScript 的次数增加了14%
。然而即便我们使用了各种手段来优化加载JavaScript脚本,但是这些脚本终究需要被转换并在执行,而这部分就占用了CPU处理时间的40%
。
所以时至今日,Web优化依然是一个永不落幕的话题。在本文中我们将讨论 Web Worker,包括它所能解决的问题以及如何在构建现代Web应用程序时使用它;同时会介绍Comlink这个工具,看它是如何在减少开发者心智负担的情况下大大提高开发体验。
当今Web应用的现状
现代网络应用程序正变得越来越大,同时也越来越复杂。我们常常将该问题归咎于一个现实的问题:即此类应用程序几乎完全由 JavaScript 驱动,这就意味着需要大量JavaScript代码来支撑。
虽然我们实际开发中会使用懒加载的方式来异步加载脚本以保证UI线程
不会在同一时刻处理大量的脚本数据,但全部代码只在UI线程上运行很可能会对用户体验产生或多或少的影响。
UI线程(又称主线程)应该只用于UI交互工作,比如布局、绘制、分派事件、从输入源(表单、摄像头等) 捕获数据,以及将数据渲染回DOM。
但是数据操作、客户端逻辑(如验证、状态管理等)以及任何形式的与用户交互无关的工作,尤其是计算或内存密集型工作,最好能在 Web Worker 中进行。
Web Worker
此前我们所讨论的大部分JavaScript代码单线程的,尽管异步代码看起来是同时进行的,但实际上在执行一个任务的时候其它任务都会被Block住,因为CPU处理速度几块,所以我们压根察觉不到这里的延迟。
通常每个Web页面都在单个CPU线程上运行 JavaScript 代码、解析 CSS 以及布局和绘制用户看到的内容。因此一旦执行了一段耗时的 JavaScript 代码就会阻止线程中的其他事务,同时也会卡住
页面的渲染工作,就会给用户带来非常差的体验。在过去这种情况甚至会导致浏览器的崩溃,现在之所以不会出现崩溃的情况是因为现代浏览器的处理能力比过去更强了。
为了摆脱单线程运行的限制,开发者可以通过 Web Worker 在页面上使用多线程,幸运的是浏览器提供了几种不同类型的 Worker 用于不同的业务场景,如 Service Worker 和 worklets 等。但本文我们讨论的是更加通用的 Web Wroker。
据统计,Web Worker 已经得到了所有主流浏览器的支持:
使用原生 Web Worker
我们可以使用以下方式启动一个新的Web Worker线程:
js
const worker = new Worker('/worker.js');
本段逻辑将下载 worker.js 文件并在主线程以外的线程中运行,这样就可以在不阻塞主线程的情况下运行复杂的js代码。在下面的示例中,我们可以比较在主线程和在 Worker 中计算 30000 位圆周率的结果。在主线程中计算时,页面的其他部分会被阻塞,而在 Worker 中计算时,页面可以在后台继续运行,直到计算完成。
无Worker情况
有Worker情况
可以看出在 Web Worker 的加持下,用户体验得到了极大得提升。
使用原生 Web Worker的痛点
笔者在公司产品研发中是 Web Worker 的重度使用者,因为需要做很多后台轮询和数据计算。以下是部分业务逻辑的示意代码:
js
// worker.js
self.onmessage(param){
// 1. 业务逻辑处理
const result = (() => { })()
// 2. 将结果返回给主线程
self.postMessage(result)
}
// UI.js
const worker = new Worker(new URL('./xxxxx.worker.js', import.meta.url));
worker.onmessage = function ({ data }) {
callback(data);
};
上部分提到,Web Worker 和 主线程拥有自己各自的上下文和内存,所以它们之间是无法直接进行数据交换,但是浏览器提供了一些API来实现数据的交换:onmessage
用来接受数据,postMessage
用来传递数据。
postMessage
除了字符串,还可以使用 postMessage 共享数组和对象等多种类型的数据结构。发送这些数据后,浏览器会以一种特殊的序列化格式复制数据结构,然后在另一个线程中重建,简单来说就是克隆。
在上述实例代码中,对象 result 会被克隆并转化为可转移的形式,这一过程被称为序列化。然后主线程会接收该对象,并将其转化为原始对象的副本。这个操作可能会比较 "昂贵"(expensive operation),但对于维护复杂的数据结构来说也许是必须的。
如果要传输大量数据,我们可以传输一块内存块,通过这种方式传输的对象称为可传输对象,此外应用范围最广的对象类型是 ArrayBuffer
。
ArrayBuffer是类型化数组API的一部分。不能直接向ArrayBuffer写入数据,需要使用类型化数组来从中读取和写入数据。类型化数组将JavaScript中的数字转换为在ArrayBuffer中存储的原始位和字节。
我们还可以创建一个具有指定大小的新类型化数组,并为其分配一块新的内存块以容纳该大小。这块内存块由底层的ArrayBuffer表示,并以.buffer的形式公开,这个ArrayBuffer实例可以在线程之间传输以共享其内容。
js
// In the worker:const buffer = new ArrayBuffer(32); // 32 Bytes
>> ArrayBuffer { byteLength: 32 }const array = new Float32Array(buffer);
>> Float32Array [ 0, 0, 0, 0, 0, 0, 0, 0 ]; // 4 Bytes per element, so 8 elements long.array[0] = 1;
array[1] = 2;
array[2] = 3;self.postMessage(array.buffer, [array.buffer]);
通过 postMessage 传输 ArrayBuffer 需要注意的是:一旦该对象被post出去了就不可以在原始线程中读取或写入,否则会报错。
ArrayBuffer 与数据无关,它们只是内存块。 他们不关心存储什么类型的数据。 因此我们可以使用单个 ArrayBuffer 来存储大量不同类型的较小数据块,当然这也是最保险的方式。
说得有点偏离主线了,关于 Web Worker 这块的知识点特别多,需要我们自己慢慢去摸索和使用。刚刚说到的痛点,笔者认为最大的痛点就是 onmessage
和 postMessage
本身,会造成我们开发中很多不便的地方,而且会产生很多业务代码之外的冗余代码,所以就是不爽!!
一声惊雷
幸运的是,Google 的 Surma 开发了这个异常牛批的库,它可以将这种消息传递转换为基于 Promise 的异步 API!
Comlink makes WebWorkers enjoyable. Comlink is a tiny library (1.1kB) , that removes the mental barrier of thinking about
postMessage
and hides the fact that you are working with workers.
【译文】Comlink 让 WebWorkers 开发变得更加轻松。 Comlink 是一个小型库(1.1kB),它消除了对 postMessage的顾虑,同时也隐藏了 Web Worker 原生开发的繁琐。
在下面的示例中,我们将 Worker 暴露出的一个类实例化为一个新对象,然后从中调用一些方法。在最初的类中,这些方法完全是同步的,但由于向 Worker 发送消息和返回消息都需要时间,因此 Comlink 返回了一个Promise。幸运的是 async/awit
允许我们编写看起来同步的异步代码,因此代码看起来仍然非常得整洁。
js
import {wrap} from '/comlink/comlink.js';
// This web worker uses Comlink's expose to expose a function
const MathLibrary = wrap(new Worker('/math.js'));
async function main() {
const MathObject = await new MathLibrary();
const result1 = await MathObject.add(2,2);
const result2 = await MathObject.add(3,7);
return await MathObject.multiply(result1, result2);
}
通过代码可知,comlink 通过隐藏使用 Worker 的复杂性,也隐藏了来回发送数据的成本!main 方法中的这一小段代码涉及在 Worker 之间串行发送 6 条信息,每条信息都会等待前一条信息发送完毕后再运行下一条。每次发送信息时,数据都必须进行序列化和重构,并且可能需要进行上下文切换才能得到响应。
在理想情况下,另一个线程已经在不同的 CPU 内核上运行且正在等待某些输入,在这种情况下,一切都会非常高效地运行。如果线程没有被积极处理,CPU 可能不得不从内存中恢复它,这可能会很慢。我们无法控制操作系统何时更换线程,但通过阻塞代码执行,直到其他线程中的某些代码执行完毕,我们就有可能等待数百纳秒,直到我们得到响应。
代码得可读性很重要,但我们也必须警惕对性能的影响。针对上个例子改进的方法之一是并行计算:
js
// This web worker uses Comlink's expose to expose a function
const MathLibrary = proxy(new Worker('/math.js'));
async function main() {
const MathObject = await new MathLibrary();
const [result1, result2] = await Promise.all(
[MathObject.add(2,2), MathObject.add(3,7)]
);
return await MathObject.multiply(result1, result2);
}
结语
通过以上例子的对比可以明显感觉到Comlink对于 Web Worker 开发体验的重要性。同时也希望本文对所有在使用或者即将使用 Web Worker 的开发者朋友们一点参考,同时对未使用过 Web Worker 的朋友们一声呼吁:Web Worker = 香!Comlink + Web Worker = 真香!