# Web Worker

# 工作者线程简介

JavaScript 环境实际上是运行在托管操作系统中的虚拟环境。在浏览器中每打开一个页面,就会分配一个它自己的环境。这样,每个页面都有自己的内存、事件循环、 DOM,等等。每个页面就相当于一个沙盒,不会干扰其他页面。

对于浏览器来说,同时管理多个环境是非常简单的,因为所有这些环境都是并行执行的。

使用工作者线程,浏览器可以在原始页面环境之外再分配一个完全独立的二级子环境。这个子环境不能与依赖单线程交互的 API(如 DOM)互操作,但可以与父环境并行执行代码。

# 工作者线程与线程

作为介绍,通常需要将工作者线程与执行线程进行比较。

  • 工作者线程是以实际线程实现的。
  • 工作者线程并行执行。
  • 工作者线程可以共享某些内存。工作者线程能够使用 SharedArrayBuffer 在多个环境间共享内容。
  • 工作者线程不共享全部内存。
  • 工作者线程不一定在同一个进程里。
  • 创建工作者线程的开销更大。

通常,工作者线程应该是长期运行的,启动成本比较高,每个实例占用的内存也比较大。

# 工作者线程的类型

Web 工作者线程规范中定义了三种主要的工作者线程: 专用工作者线程、 共享工作者线程和服务工作者线程。

专用工作者线程

专用工作者线程,通常简称为工作者线程、 Web Worker 或 Worker,是一种实用的工具,可以让脚本单独创建一个 JavaScript 线程,以执行委托的任务。

共享工作者线程

共享工作者线程与专用工作者线程非常相似。主要区别是共享工作者线程可以被多个不同的上下文使用,包括不同的页面。

服务工作者线程

服务工作者线程与专用工作者线程和共享工作者线程截然不同。它的主要用途是拦截、重定向和修改页面发出的请求,充当网络请求的仲裁者的角色。

# WorkerGlobalScope

在网页上, window 对象可以向运行在其中的脚本暴露各种全局变量。在工作者线程内部,没有 window 的概念。这里的全局对象是 WorkerGlobalScope 的实例,通过 self 关键字 暴露出来。

self 上可用的属性是 window 对象上属性的严格子集。

# 专用工作者线程

可以把专用工作者线程称为后台脚本( background script)。 JavaScript 线程的各个方面,包括生命周期管理、代码路径和输入/输出,都由初始化线程时提供的脚本来控制。该脚本也可以再请求其他脚本,但一个线程总是从一个脚本源开始。

# 创建专用工作者线程

创建专用工作者线程最常见的方式是加载 JavaScript 文件。把文件路径提供给 Worker 构造函数,然后构造函数再在后台异步加载脚本并实例化工作者线程。

// emptyWorker.js
// 空的 JS 工作者线程文件
// main.js
console.log(location.href); // "https://example.com/"
const worker = new Worker(location.href + "emptyWorker.js");
console.log(worker); // Worker {}

工作者线程的脚本文件只能从与父页面相同的源加载。

Worker()构造函数返回的 Worker 对象是与刚创建的专用工作者线程通信的连接点。它可用于在工作者线程和父上下文间传输信息,以及捕获专用工作者线程发出的事件。

Worker 对象支持下列事件处理程序属性。

  • onerror 在工作者线程中发生 ErrorEvent 类型的错误事件时会调用
  • onmessage 在工作者线程中发生 MessageEvent 类型的消息事件时会调用
  • onmessageerror 在工作者线程中发生 MessageEvent 类型的错误事件时会调用

Worker 对象还支持下列方法

  • postMessage() 用于通过异步消息事件向工作者线程发送信息。
  • terminate() 用于立即终止工作者线程。

# 执行顺序

// globalScopeWorker.js
console.log("inside worker:", self);
main.js;
const worker = new Worker("./globalScopeWorker.js"); // 异步启动
console.log("created worker:", worker);
// created worker: Worker {}
// inside worker: DedicatedWorkerGlobalScope {}

如此例所示,顶级脚本和工作者线程中的 console 对象都将写入浏览器控制台,这对于调试非常有用。因为工作者线程具有不可忽略的启动延迟,所以即使 Worker 对象存在,工作者线程的日志也会在主线程的日志之后打印出来。

# 配置工作者线程

Worker()构造函数允许将可选的配置对象作为第二个参数。该配置对象支持下列属性。

  • name:可以在工作者线程中通过 self.name 读取到的字符串标识符。
  • type:表示加载脚本的运行方式,可以是"classic"或"module"。
  • credentials:在 type 为"module"时,指定如何获取与传输凭证数据相关的工作者线程模块脚本。值可以是"omit"、 "same-orign"或"include"。

let downlaoder = new Worker("./downloader.js", {
  name: "downloader",
  type: "classic"
});

# 行内创建工作者线程

工作者线程需要基于脚本文件来创建,但这并不意味着该脚本必须是远程资源。专用工作者线程也可以通过 Blob 对象 URL 在行内脚本创建。这样可以更快速地初始化工作者线程,因为没有网络延迟。

// 创建要执行的 JavaScript 代码字符串
const workerScript = `
self.onmessage = ({data}) => console.log("worker:",data);
`;
// 基于脚本字符串生成 Blob 对象
const workerScriptBlob = new Blob([workerScript]);
// 基于 Blob 实例创建对象 URL
const workerScriptBlobUrl = URL.createObjectURL(workerScriptBlob);
// 基于对象 URL 创建专用工作者线程
const worker = new Worker(workerScriptBlobUrl);
worker.postMessage("blob worker script");
// worker: blob worker script

秀一波操作,如果把所有代码写在一块,可以浓缩为这样:

const worker = new Worker(
  URL.createObjectURL(
    new Blob([
      `self.onmessage =
({data}) => console.log(data);`
    ])
  )
);
worker.postMessage("blob worker script");
// blob worker script

工作者线程也可以利用函数序列化来初始化行内脚本。这是因为函数的 toString()方法返回函数代码的字符串,而函数可以在父上下文中定义但在子上下文中执行。

function fibonacci(n) {
  return n < 1 ? 0 : n <= 2 ? 1 : fibonacci(n - 1) + fibonacci(n - 2);
}
const workerScript = `
self.postMessage(
(${fibonacci.toString()})(20)
);
`;
const worker = new Worker(URL.createObjectURL(new Blob([workerScript])), { name: "fibonacci" });
worker.onmessage = ({ data }) => console.log(data);
// 6765
console.log(fibonacci(20));
// 6765

WARNING

像这样序列化函数有个前提,就是函数体内不能使用通过闭包获得的引用,也包括全局变量,比如 window,因为这些引用在工作者线程中执行时会出错。

# 在工作者线程中动态执行脚本

工作者线程中的脚本并非铁板一块,而是可以使用 importScripts()方法通过编程方式加载和执行任意脚本。该方法可用于全局 Worker 对象。这个方法会加载脚本并按照加载顺序同步执行。

//scriptA.js
console.log("scriptA executes");
// scriptB.js
console.log("scriptB executes");
// worker.js
console.log("importing scripts");
importScripts("./scriptA.js");
importScripts("./scriptB.js");
console.log("scripts imported");
// main.js
const worker = new Worker("./worker.js");

最终打印结果如下:

// importing scripts
// scriptA executes
// scriptB executes
// scripts imported

importScripts()方法可以接收任意数量的脚本作为参数。浏览器下载它们的顺序没有限制,但执行则会严格按照它们在参数列表的顺序进行。

worker.js 可以改写为

// worker.js
console.log("importing scripts");
importScripts("./scriptA.js", "./scriptB.js");
console.log("scripts imported");

# 与专用工作者线程通信

与工作者线程的通信都是通过异步消息完成的,但这些消息可以有多种形式。

  1. 使用 postMessage()

const factorialScript = `
function factorial(n) {
let result = 1;
  while(n) { result *= n--; }
return result;
}
self.onmessage = ({data}) => {
  self.postMessage( data+ "! = " + factorial(data));
};
`;
const factorialWorker = new Worker(URL.createObjectURL(new Blob([factorialScript])), { name: "fibonacci" });
factorialWorker.onmessage = ({ data }) => console.log(data);
factorialWorker.postMessage(5);
factorialWorker.postMessage(7);
factorialWorker.postMessage(9);
// 5! = 120
// 7! = 5040
// 9! = 362880

对于传递简单的消息,使用 postMessage()在主线程和工作者线程之间传递消息,与在两个窗口间传递消息非常像。

  1. 使用 MessageChannel

无论主线程还是工作者线程,通过 postMessage()进行通信涉及调用全局对象上的方法,并定义一个临时的传输协议。这个过程可以被 Channel Messaging API 取代,基于该 API 可以在两个上下文间明确建立通信渠道。

MessageChannel 实例有两个端口,分别代表两个通信端点。要让父页面和工作线程通过 MessageChannel 通信,需要把一个端口传到工作者线程中


// worker.js
// 在监听器中存储全局 messagePort
let messagePort = null;
function factorial(n) {
  let result = 1;
  while (n) {
    result *= n--;
  }
  return result;
}
// 在全局对象上添加消息处理程序
self.onmessage = ({ ports }) => {
  // 只设置一次端口
  if (!messagePort) {
    // 初始化消息发送端口,
    // 给变量赋值并重置监听器
    messagePort = ports[0];
    self.onmessage = null;
    // 在全局对象上设置消息处理程序
    messagePort.onmessage = ({ data }) => {
      // 收到消息后发送数据
      messagePort.postMessage(`${data}! = ${factorial(data)}`);
    };
  }
};
// main.js
const channel = new MessageChannel();
const factorialWorker = new Worker("./worker.js");
// 把`MessagePort`对象发送到工作者线程
// 工作者线程负责处理初始化信道
factorialWorker.postMessage(null, [channel.port1]);

// 工作者线程通过信道响应
channel.port2.onmessage = ({ data }) => console.log(data);
// 通过信道实际发送数据
channel.port2.postMessage(5);

使用 MessageChannel 实例与父页面通信很大程度上是多余的。这是因为全局 postMessage()方法本质上与 channel.postMessage()执行的是同样的操作,MessageChannel 真正有用的地方是让两个工作者线程之间直接通信。

const channel = new MessageChannel();
const workerA = new Worker("./worker.js");
const workerB = new Worker("./worker.js");
// worker交换channel端口
workerA.postMessage("workerA", [channel.port1]);
workerB.postMessage("workerB", [channel.port2]);
// 主线程接收来worker发送的postMesasge的响应
workerA.onmessage = ({ data }) => console.log(data);
workerB.onmessage = ({ data }) => console.log(data);

# 工作者线程数据传输

使用工作者线程时,经常需要为它们提供某种形式的数据负载。工作者线程是独立的上下文,因此在上下文之间传输数据就会产生消耗。

在 JavaScript 中,有三种在上下文间转移信息的方式: 结构化克隆算法( structured clonealgorithm)、 可转移对象( transferable objects)和共享数组缓冲区( shared array buffers)。

# 结构化克隆算法

结构化克隆算法可用于在两个独立上下文间共享数据。该算法由浏览器在后台实现,不能直接调用。这个方法不适用于大量数据的交换

# 可转移对象

使用可转移对象( transferable objects)可以把所有权从一个上下文转移到另一个上下文。

postMessage()方法的第二个可选参数是数组,它指定应该将哪些对象转移到目标上下文。

const workerScript = `
self.onmessage = ({data}) => {
  console.log( "worker's buffer size:" + data.byteLength ); // 32
};
`;
const worker = new Worker(URL.createObjectURL(new Blob([workerScript])));
// 创建 32 位缓冲区
const arrayBuffer = new ArrayBuffer(32);
console.log(`page's buffer size: ${arrayBuffer.byteLength}`); // 32
worker.postMessage(arrayBuffer, [arrayBuffer]); //移交上下文
console.log(`page's buffer size: ${arrayBuffer.byteLength}`); // 0
// page's buffer size: 32
// page's buffer size: 0
// worker's buffer size:32

# SharedArrayBuffer

Chrome 此功能在 Nightly 中默认启用,但在 Beta/Developer/Release 中不启用

既不克隆,也不转移, SharedArrayBuffer 作为 ArrayBuffer 能够在不同浏览器上下文间共享。在把 SharedArrayBuffer 传给 postMessage()时,浏览器只会传递原始缓冲区的引用。结果是,两个不同的 JavaScript 上下文会分别维护对同一个内存块的引用。每个上下文都可以随意修改这个缓冲区,就跟修改常规 ArrayBuffer 一样。

const workerScript = `
self.onmessage = ({data}) => {
  const view = new Uint8Array(data);
  console.log("buffer value before worker modification: " + view[0]);
  view[0] += 1;
  self.postMessage(null);
}
`;
const worker = new Worker(URL.createObjectURL(new Blob([workerScript])));
const sharedArrayBuffer = new SharedArrayBuffer(1);
const view = new Uint8Array(sharedArrayBuffer);
view[0] = 1;
worker.onmessage = () => {
  console.log(`buffer value after worker modification: ${view[0]}`);
};

# 共享工作者线程

# 服务工作者线程

服务工作者线程( service worker)是一种类似浏览器中代理服务器的线程,可以拦截外出请求和缓存响应。这可以让网页在没有网络连接的情况下正常使用,因为部分或全部页面可以从服务工作者线程缓存中提供服务。