你不知道的JS系列——了解 Web Worker

2,659 阅读8分钟

你明知道,我知道你知道

说明

学习总结 + 个人理解,巩固 + 方便查阅,大家愿意看的简单看看就好

为什么需要 Web Worker

首先上一张脑图,让我们对浏览器有个大致的了解:

这里最重要的一点:js 引擎是单线程的,且和 GUI 渲染线程互斥。
解释:当 JS 引擎执行时 GUI 线程会被挂起,GUI 更新则会被保存在一个队列中等到 JS 引擎线程空闲时立即被执行,它们不能同时运行,因为同时运行会导致渲染出现不可预期的结果。

考虑:既然 JS 引擎线程与 GUI 渲染线程互斥,那么当 JS 执行时间过长(比如进行巨量计算),此时 GUI 渲染线程被阻塞拿不到执行权,必然导致页面渲染加载阻塞,影响用户体验,从而导致用户流量缺失。

疑问:有没有那么一种方式既能够进行计算又不影响渲染? 还有 JS 真的就对 CPU 密集型计算无能为力了吗 ?
tips: CPU 密集型、计算密集型计算,顾名思义就是应用需要非常多的CPU计算资源。
解决:Web Worker 诞生,走出困境。

对于本小节更详细的介绍可以看这篇《从浏览器多进程到JS单线程,JS运行机制最全面的一次梳理》

Web Worker 是什么?

MDNWeb WorkerWeb 内容在后台线程中运行脚本提供了一种简单的方法。线程可以执行任务而不干扰用户界面。

也就是说 Web WorkerJavaScript 创造了多线程环境,允许 JS 主线程创建 Worker 子线程,将一些任务分配给后者运行。这样做就能充分发挥多核 CPU 主机的优势,让两个线程并行执行,给用户带来飞一般的享受。

在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。一些计算密集型或高延迟的任务,被 Worker 线程负担了,主线程(通常负责 UI 交互)就会很流畅, Worker 线程会在计算任务完成时,通过特定的通信方式将结果返回给主线程。

注意: Worker 子线程是浏览器开的,完全受主线程控制。即 Web Worker 是浏览器提供的,JS 并不提供,JS 还是单线程的。

插点题外话: 并行的前提是并发,即程序在设计的时候只有被设计成并发式的,才有能够在多核 CPU 情况下并行执行的可能。若一开始程序就被设计为同步阻塞式的即不具备并发实现,即使放在多核 CPU 环境下也不可能并行执行。并发的优势在单核 CPU 情况下不足以体现不出来,最多与同步代码执行时间相同,但它的优势将会在多核 CPU 情况下体现的淋漓尽致,所以将程序设计为并发式的只会有好处没有坏处。

Web Worker 基本用法

主线程

JS 主线程通过调用 Worker() 构造函数,新建一个 Worker 线程:

var worker = new Worker('work.js');

注意:

  • 构造函数的参数是一个 url
  • 分配给 Worker 线程运行的脚本文件,必须与主线程的脚本文件同源(协议、端口、主机相同)
  • Worker 不能读取本地的文件(不能打开本机的文件系统file://),它所加载的脚本必须来自网络

疑问: 这个 url 被限定为网络文件,我们想传入自己写的 JS 代码就无能为力了吗?
既然没有 url ,那我们就创建一个 url ,所以这样解决:

const data = `
    //  worker线程 do something
    `;
// 转成二进制对象
const blob = new Blob([data]);
// 生成url
const url = window.URL.createObjectURL(blob);
// 加载url
const worker = new Worker(url);

objectURL = URL.createObjectURL(object);

  • object
    用于创建 URLFile 对象、Blob 对象或者 MediaSource 对象
  • 返回值
    一个DOMString包含了一个对象URL,该URL可用于指定源 object的内容

专用 Worker

一个专用Worker仅仅能被生成它的脚本所使用。var myWorker = new Worker('worker.js') 这就是专用Worker

专用Worker消息的接收和发送

在主线程和Worker内都是通过postMessage()方法发送数据,监听message事件接收数据

  • 主线程内
myWorker.onmessage = function (event) { // 接收来自子线程内部的数据
  console.log('Received message ' + event.data);
  doSomething();
}

myWorker.postMessage('Work done!'); // 向`worker`内部发送数据
  • Worker子线程内
addEventListener('message', function (e) {  // 接收来自主线程数据
  postMessage('You said: ' + e.data);   // 发送到主线程
}, false);  // false指定事件句柄在冒泡阶段执行

终止 Worker

Worker 线程有利于随时响应主线程的通信。但是,这也造成了 Worker 比较耗费资源,不应该过度使用,而且一旦使用完毕,就应该关闭。

  • 从主线程中立刻终止一个运行中的 Worker: myWorker.terminate();
  • Worker 线程中自行终止:self.close();self代表子线程自身)

注意: Worker 线程关闭就会被立即杀死,不会有任何机会让它完成自己的操作或清理工作,理解为秒杀就 OK 啦。

Worker 加载脚本

Worker 内部如果要加载其他脚本,有一个专门的方法importScripts(),它可以接收一个或多个参数:

importScripts('script1.js', 'script2.js');

错误处理

在主线程和Worker内都通过监听 error 事件来捕获错误:

myWorker.onerror(function (event) {
    // 做处理 
});
addEventListener('error', function (event) {
    // 做处理 
})

回调函数参数 event 的属性:

  • message: 可读性良好的错误消息
  • filename: 发生错误的脚本文件名
  • lineno: 发生错误时所在脚本文件的行号

Web Worker 的注意点

(1)同源限制
分配给 Worker 线程运行的脚本文件,必须与主线程的脚本文件同源。
(2)DOM 限制
Worker 线程所在的全局对象,与主线程不一样,无法读取主线程所在网页的 DOM 对象,也无法使用document、window、parent这些对象。但是,Worker 线程可以navigator对象和location对象。
(3)脚本限制
Worker 线程不能执行alert()方法和confirm()方法,但可以使用 XMLHttpRequest 对象发出 AJAX 请求。
(4)Worker 内部还可以新建 Worker,但只有 Firefox 浏览器支持。

模拟 Web Worker

由于环境所限,你可能需要在缺乏对此支持的更老的浏览器中运行代码。因为 Worker 是一种 API 而不是语法,所以我们可以作为扩展来模拟它。

如果浏览器不支持 Worker,那么从性能的角度来说是没法模拟多线程的。通常认为 Iframe 提供了并行环境,但是在所有的现代浏览器中,它们实际上都是和主页面运行在同一个线程中的,所以并不足以模拟 Worker

JavaScript 的异步(不是并行)来自于事件循环队列,所以可使用定时器(setTimeout(..) 等)强制模拟实现异步的伪 Worker,然后你只需要提供一个 Worker API 的封装。当然最稳妥的方式还是找一个已经成熟的第三方库。

关于 Iframe:
优点:

  • 能够原封不动的把嵌入的网页展现出来
  • 如果有多个网页引用iframe,那么你只需要修改iframe的内容,就可以实现调用的每一个页面内容的更改,方便快捷
  • 可以用来解决加载缓慢的第三方内容如图标和广告问题

缺点:

  • 用户体验度差,代码复杂,不利于搜索引擎优化(SEO
  • 设备兼容性差,很多的移动设备无法完全显示框架
  • 会增加服务器的http请求,影响性能
  • 阻塞主页面的Onload事件
  • 和主页面共享连接池,而浏览器对相同域的连接有限制,所以会影响页面的并行加载
  • 比其它包括scriptscssDOM 元素的创建慢 1-2个数量级

注意: 主页面和其中的 iframe 共享连接池,这个连接池可以理解为浏览器当前打开页面到web服务器一个域名同时可打开的连接数(2-6个),如果 iframe 在加载资源时用光了所有的可用连接,那么主页面资源的加载就会被阻塞,这也解释了上面的观点(iframe不足以模拟web worker)。所以要谨慎的使用 iframe,因为它会极大地影响性能。

共享 Worker—— SharedWorker

SharedWorker 是浏览器所有页面共享的,不能采用与Worker同样的方式实现,因为它不隶属于某个Render 进程,可以为多个Render进程共享使用。
生成共享 Worker:

var myWorker = new SharedWorker('worker.js');

共享Worker通信必须通过端口对象,在传递消息之前,端口连接必须被显式的打开,打开方式是使用 start() 方法:

myWorker.port.start();  // 父级线程中的调用
port.start(); // worker线程中的调用, 假设port变量代表一个端口

共享worker中消息的接收和发送必须通过端口对象调用postMessage() 方法:

myWorker.port.postMessage(data);    // 主线程
// worker 子线程,当一个端口连接被创建时,使用onconnect事件处理函数来执行代码
onconnect = function(e) {
  var port = e.ports[0];
  port.onmessage = function(e) {
    var workerResult = 'Result: ' + (e.data[0] * e.data[1]);
    port.postMessage(workerResult);
  }
}

注意: SharedWorker 目前 IESafari 及移动端浏览器不支持; WorkerIE10 以下不支持外,基本都支持。