Web Worker:一柄前端性能优化的利剑🗡

发布于:2024-04-29 ⋅ 阅读:(21) ⋅ 点赞:(0)

Web Worker的由来

JavaScript之父Brendan Eich最初设计这门语言时只是将它定位成一个小型的脚本语言,用来实现网页上一些简单的动态特性,没有考虑到用它实现今天这样复杂的场景,因此JavaScript就被设计成一个单线程语言。试想,如果JavaScript被设计成多线程,在用户交互中多线程同步操作DOM,势必会引发抢占等问题。举个例子,一个线程想要修改某个DOM节点,另外一个线程同时想要删除这个节点,那么该听谁的呢?

单线程是JavaScript的一个基本特征,因此浏览器绝对不会同时运行两个事件处理程序,也不会在一个事件处理程序运行的时候触发其它计时器。这样就无法并发更新应用或文档状态。一个必然的结果就是JavaScript函数不能运行太长时间,否则它们就会阻塞事件循环,而浏览器也会变得不能响应用户输入。事实上这也是fetch()被设计为异步函数的原因。

如今,现代Web应用的交互、逻辑、数据处理越来越复杂,对性能的要求越来越高,需要处理大量数据、进行复杂计算的场景也越来越多,JavaScript单线程模型很难满足这些需求。在这样的背景下,Web Worker技术诞生了。

Web Worker 属于 HTML 规范,它并不是一项很新的技术,早在2009年就提出了草案。同年FireFox率先实现了Web Worker,2011年Web Worker正式成为HTML5标准的一部分,2012年发布的IE10也实现了Web Worker,标志着主流浏览器的全面支持。

Web Worker的定义及分类

W3C这样定义Web Worker:

an API for running scripts in the background independently of any user interface scripts.

官方文档MDN中是这样描述Web Worker的:

Web Workers makes it possible to run a script operation in a background thread separate from the main execution thread of a web application.

因为是独立的线程,Worker 线程与 js 主线程能够同时运行,互不阻塞。所以,在我们有大量运算任务时,可以把运算任务交给 Worker 线程去处理,当 Worker 线程计算完成,再把结果返回给 js 主线程。这样,js 主线程只用专注处理业务逻辑,不用耗费过多时间去处理大量复杂计算,从而减少了阻塞时间,也提高了运行效率,页面流畅度和用户体验自然而然也提高了。

Web Worker分为三类。Dedicated WorkerShared WorkerService Worker

  • Dedicated Worker:每个 Dedicated Worker 都与创建它的脚本线程相关联,它们之间是一对一的关系。这意味着每个 Dedicated Worker 只能被一个脚本使用。
  • Shared Worker:可以由在不同窗口、IFrame 等中运行的多个脚本使用的 Worker,只要它们与 Worker 在同一域中。它们比专用的 Worker 稍微复杂一点——脚本必须通过活动端口进行通信。
  • Service Worker:基本上是作为代理服务器,位于 web 应用程序、浏览器和网络(如果可用)之间。它们的目的是(除开其他方面)创建有效的离线体验、拦截网络请求,以及根据网络是否可用采取合适的行动并更新驻留在服务器上的资源。它们还将允许访问推送通知和后台同步 API。Service Worker是实现PWA的重要一环。

Web Worker 的使用

首先通过一个简单的例子看下Web Worker最简单的使用方法:

主线程:

const worker = new Worker("/worker.js");
worker.onmessage = e => {
    console.log(e.data);   //Hi, Master!
};
worker.postMessage("Hello, Worker!");

Worker线程:

self.onmessage = e => {
    console.log(e.data);   //Hello, Worker!
    self.postMessage("Hi, Master!")
}

Web Worker的创建

创建Worker只需要用new调用Worker构造函数即可。它接收两个参数

const worker = new Worker(aURL, options);
  • aURL表示worker即将执行的脚本的url,注意,它必须遵守同源策略,和主线程的路径同源。

  • options是一个可选参数,它是一个对象,可用属性如下:

    • type: 用于指定worker类型,该值可以是classic或者module,如果未指定,默认值是classic。module与在HTML<Script>标签中添加type="module"类似,都是表示将当前代码作为模块来解释,允许使用import语法。不过需要注意的是,主流浏览器中直到2020年初,Chrome才支持该参数,允许在worker中使用模块和import声明。而Firefox则在2023年6月才支持。对于Service Worker,Firefox目前仍然不支持模块语法。
    • credentials: 用于指定worker凭证。该值可以是omitsame-origin,或include。如果未指定,或者type是classic,将默认使用omit(不要求凭证)。
    • name: 这个参数会成为worker全局对象的name属性值。主要用于调试目的。在通过console.warn()console.error()打印的任何消息中,worker都包含这个名字。

如果文档不允许启动worker,会引发SecurityError,例如提供的aURL有语法错误,或者不符合同源策略。如果aURL无法解析,则会引发SyntsxError。

若定义worker的脚本的MIME类型为text/csv, image/*, video/*,或 audio/*, 则会引发 NetworkError。它应该始终是 text/javascript。不过浏览器厂商对这一点存在分歧,目前只有Firefox和Safari会进行严格的MIME类型检查。

Web Worker的异常处理

worker提供了两个错误监听事件,errormessageerror

当worker发生错误时,会触发error事件。

const myWorker = new Worker("worker.js");myWorker.onerror = (event) => {
  console.log("There is an error with your worker!");
};

当worker对象接收到一条无法被序列化的消息时,会在该对象上触发messageerror事件。此事件不能取消,也不会冒泡。

// main.jsconst worker = new Worker("static/scripts/worker.js");
​
worker.onmessage = (event) => {
  console.error(`Received message from worker: ${event}`);
};
​
worker.onmessageerror = (event) => {
  console.error(`Error receiving message from worker: ${event}`);
};
​

Web Worker的数据传递

主线程和Worker有三种传递数据的方式,structedClone(结构化克隆算法)、Transferable object(可转移对象)、SharedArrayBuffer(共享内存)。都通过postMessage()方法发送数据,通过监听message事件接收消息。

structedClone

structedClone(结构化克隆算法)是主线程和Worker线程默认的也是主要的数据传递方式,它的兼容性最好。

结构化克隆算法是一种比JSON.stringify()JSON.parse()更可靠的序列化技术,是一种深拷贝方法。这个算法由HTML标准定义,它可以涵盖JSON.stringify()能够序列化的一切值,还支持很多其它JavaScript类型,比如Map、Set、Date、RegExp和TypedArray,它甚至还能处理包含循环引用的数据结构。不过结构化克隆算法不能序列化函数和类。在克隆对象时,他不会复制原型对象、getter和setter函数,也不会复制不可枚举属性。宿主环境定义的类型也不能复制,例如DOM节点。

const myWorker = new Worker('/worker.js');
myWorker.postMessage(obj);

如下图所示通过结构化克隆算法,复制一份线程A的JS Object内存给到线程B,线程B 能获取和操作新复制的内存。

image-20240428161045166.png Structured Clone 通过复制内存的方式简单有效地隔离不同线程内存, 避免冲突; 且传输的 Object 数据结构很灵活. 但复制过程中, 线程A 要同步执行 Object Serialization, 线程B 要同步执行 Object Deserialization; 如果 Object 规模过大, 会占用大量的线程时间。

Transferable Object

postMessage()方法还接收可选的第二个参数,该参数是一个数组,数组的元素不是被复制到Worker中,而是被转移到Worker中。并非所有类型的数据都能够被转移,可以被转移的类型称为可转移对象,包括以下类型。

需要注意,其中一些可转移对象目前尚未得到浏览器的广泛支持。

可转移对象是拥有属于自己的资源的对象,这些资源可以从一个上下文转移到另一个,确保资源一次仅在一个上下文可用。传输后,原始对象不再可用;它不再指向转移后的资源,并且任何读取或者写入该对象的尝试都将抛出异常。

可转移对象通常用于共享资源,该资源一次仅能安全地暴露在一个 JavaScript 线程中。例如,ArrayBuffer是一个拥有内存块的可转移对象。当此类缓冲区(buffer)在线程之间传输时,相关联的内存资源将从原始的缓冲区分离出来,并且附加到新线程创建的缓冲区对象中。原始线程中的缓冲区对象不再可用,因为它不再拥有属于自己的内存资源了。

// 创建并填充一个8M的文件
const uInt8Array = new Uint8Array(1024 * 1024 * 8).map((v, i) => i);
console.log(uInt8Array.byteLength); // 8388608// 把文件转移到work线程中
worker.postMessage(uInt8Array, [uInt8Array.buffer]);
console.log(uInt8Array.byteLength); // 0

事实上,使用结构化克隆算法创建对象的深拷贝时,也可以使用转移。克隆操作后,传输的资源将被移动到克隆的对象,而不是复制。

通过转移可转移对象所占有的内存,由于不需要序列化和反序列化,能大大减少传输过程占用的线程时间。如下图所示,线程A 将指定内存的所有权和操作权转给线程B,但转让后线程A 无法再访问这块内存。

image-20240428163450101.png

SharedArrayBuffer

SharedArrayBuffer 是共享内存,不同线程可以同时访问和操作同一块内存空间。数据都共享了,也就不需要传输了。这种方式最贴近传统的多线程方案,线程间共享内存是多线程的基石。

SharedArrayBuffer 对象用来表示一个通用的原始二进制数据缓冲区,类似于ArrayBuffer 对象,但它可以用来在共享内存上创建视图。与可转移的ArrayBuffer不同,SharedArrayBuffer 不是可转移对象。

从主线程中将SharedArrayBuffer对象 传给Worker线程,会在Worker线程中产生一个新的SharedArrayBuffer 对象,这两个SharedArrayBuffer 对象指向的共享数据块其实是同一个数据块,一个线程中对数据块的修改在另一个线程中可见。

const sab = new SharedArrayBuffer(1024);
worker.postMessage(sab);

共享内存可以被 Worker 线程或主线程创建和同时更新。根据系统(CPU、操作系统、浏览器)的不同,需要一段时间才能将变化传递给所有上下文环境。因此需要通过原子操作来进行同步。

由于幽灵漏洞,共享内存曾在2018年1月5日开始被禁用。在 2020 年,一种新的、安全的方法已经被标准化,以重新启用共享内存。基本要求是文档要处在一个安全上下文中。

对于顶级文档,需要设置两个标头来实现你网站的跨源隔离:

  • Cross-Origin-Opener-Policy设置为same-origin(来保护你的源站点免受攻击)
  • Cross-Origin-Embedder-Policy设置为 require-corpcredentialless(保护受害者免受你的源站点的影响)

如果不设置这两个标头,postMessage()在处理SharedArrayBuffer对象时会抛出异常。

线程间通信的真相:MessagePort和MessageChannel

线程间数据传递离不开线程间通信。实际上主线程中Worker对象的postMessage()方法和worker内部的全局postMessage()方法,都是通过调用在创建工作线程时一起创建的一对MessagePort对象的postMessage()方法来实现通信的。客户端JavaScript无法直接访问这两个自动创建的MessagePort对象,但可以通过MessageChannel()构造函数创建一对新的关联端口。

const channel = new MessageChannel();
const myPort = channel.port1;
const yourPort = channel.port2;
myPort.postMessage("Can you hear me?");
yourPort.onmessage = e => console.log(e.data);

实际执行效果:

image-20240428172352726.png

MessageChannel构造出的对象有两个属性port1port2,引用一对关联的MessagePort对象。MessagePort对象有一个PostMessage()方法和一个onmessage事件处理程序属性。在一个消息端口上调用postMessage(),会触发关联消息端口的message事件,通过设置onmessage属性或调用addEventListener()message事件注册监听可以收到这些message事件。

发送到一个端口的消息在该端口定义onmessage属性或调用start()方法之前会被放在一个队列中。这样可以防止信道的一端发送的消息被另一端错过。如果调用了MessagePortaddEventListener(),需要手动调用start(),否则可能永远看不到发过来的消息,使用onmessage则不需要手动开启。

MessagePort也属于前文所述可转移对象。假设你已经创建了一个Worker线程,但希望有两个信道可以与之通信。那么可以在主线程中创建一个MessageChannel,然后调用Worker对象的postMessage()方法,把其中一个MessagePort传给Worker线程。

const worker = new Worker("worker.js");
const urgentChannel = new MessageChannel();
const urgentPort = urgentChannel.port1;
//把port2传给worker线程
worker.postMessage({command: "setUrgentPort", value: urgentChannel.port2},[urgentChannel.port2]);
urgentPort.addEventListener("message", handleUrgentMessage);
urgentPort.start();
urgentPort.postMessage("test");

使用MessageChannel也可以实现两个工作线程间直接通信,从而避免通过主线程代为转发消息。

Web Worker的关闭

只要还有收到消息事件的可能,Worker就不会关闭。而如果Worker没有监听消息事件,它会运行直到没有其它待决的任务(例如fetch()和定时器),且所有任务相关的回调都被调用。在所有注册的回调被调用之后,Worker已经不可能再启动新任务了,此时线程可以安全退出,而且是自动的。worker线程也可以调用全局的close()函数显式将自己终止。注意,Worker对象上没有任何属性或方法可以告诉我们Worker线程是否还在运行,因此除非与父线程协商一致,否则Worker不应该主动终止自己。

在主线程中,Worker对象除了postMessage()方法,只有另外一个方法terminate(),用于强制停止Worker线程。

// main.js
const myWorker = new Worker('/worker.js'); 
myWorker.terminate(); 
// worker.js
self.close(); 

Web Worker的运行环境

在Worker线程中运行JS,会创建独立于主线程的JS运行环境,我们需关注 Worker 环境和主线程环境的异同, 以及 Worker 在不同浏览器上的差异。

在通过Worker()创建新工作线程时,其中的代码在一个新的、干净的JavaScript执行环境中执行,这个执行环境上下文是WorkerGlobalScope对象。它比核心JavaScript对象多一些东西,又比客户端中完整的Window对象少一些东西。

除核心JavaScript全局对象的所有属性如JSON对象、isNaN()函数、Date()函数之外,WorkerGlobalScope还拥有以下客户端Window对象的属性:

  • self是全局对象自身的引用。WorkerGlobalScope不是Window对象,没有定义window属性。
  • setTimeout()clearTimeout()setInterval()clearInterval()等定时器方法。
  • location属性描述传给Worker()构造函数的URL。这个属性引用一个Location对象,就像Window对象上的location属性一样。Location对象有hrefprotocolhosthostnameportpathnamesearchhash属性。但在worker中,这些属性都是只读的。
  • navigator属性引用的是一个类似Window的Navigator对象。Worker的Navigator对象拥有appName、appVersion、platformuserAgentonLine属性。
  • 常用的事件目标方法addEventListener()removeEventListener()

WorkerGlobalScope对象还包含重要的客户端JavaScript API,比如console对象、fetch()函数和IndexedDB API。WorkerGlobalScope也包含Worker()构造函数,这意味着Worker线程也可以再创建自己的Worker线程。

但我们也需要知道Worker是无UI的线程,无法调用UI相关的DOM/BOM API。使用时有以下限制:

  • Worker 线程没有 DOM API, 无法新建和操作 DOM; 也无法访问到主线程的 DOM Element。
  • Worker 线程和主线程间内存独立, Worker 线程无法访问页面上的全局变量(window, document 等)和 JS 函数。
  • Worker 线程不能调用 alert()confirm() 等 UI 相关的 BOM API。

Worker 线程无法染指 UI, 并受主线程控制, 适合默默干活。

Web Worker的应用及实践建议

使用场景

在实际项目中,Web Worker更适合处理以下场景:

  • 计算密集型任务:例如,数据分析、图像处理、加密算法等。这些任务通常耗时较长且对 CPU性能要求较高。通过将这些任务委托给Web Worker,可以避免阻塞主线程,从而保持应用的响应性。
  • 异步操作:当需要执行一些异步操作时,例如从服务器获取数据,可以使用Web Worker来避免因为等待异步操作完成而导致的主线程卡顿。

你真的需要用Web Worker吗

使用Web Worker做性能优化并不是无偿的,如同React中的useMemouseCallback这两个Hook一样,使用Web Worker也是有成本的,使用不当很可能会造成负优化。

Worker 线程会占用系统资源,使用Web Worker需要处理线程间的通信,这增加了编程的复杂性。对于简单的任务使用Web Worker可能并不划算。迫切需要Worker的场景并不多,开发者需要考虑投入效益比。

实际使用中有以下建议:

  • Worker应该是常驻线程。虽然 Worker 规范提供了terminate()方法来结束Worker线程,但线程的频繁新建会消耗资源.。大多数场景下, Worker 线程应该用作常驻的线程。开发中优先复用常驻线程。
  • 控制Worker线程数目。Worker 线程在争取 CPU 计算资源时, 受限于 CPU 的核心数, 过多的线程并不能线性地提升性能,而每个Worker会有固有的内存消耗。
  • 理解多线程开发方式。对于没有接触过多线程编程的开发者来说,需要培养多线程开发的思维和方式,需要控制线程间的通信规模, 减少线程间数据和状态的依赖, 尝试去了解和控制 Worker 线程。

总结

单线程的JavaScript语言在前端越来越复杂的场景下性能方面遇到了诸多的性能问题,Web Worker引入的多线程方案具有很好的性能优化效果,但它并没有改变JavaScript单线程的本质。在使用Web Worker之前我们需要慎重考虑所要付出的代价,毕竟它也有自身的使用限制。一些场景下,优化CSS、HTML缓存、预渲染、定时器和Promise等方式或许是更好的性能优化方案。但只要使用得当,Web Worker真的是一柄前端性能优化的利剑!

参考资料

《JavaScript权威指南(第7版)》