Web Worker 的基础知识

问题:JavaScript 并发

一些瓶颈会阻止有趣的应用移植(例如,从大量服务器端的实现)移植到客户端 JavaScript。其中包括浏览器兼容性、静态类型、无障碍功能和性能。幸运的是,随着浏览器供应商迅速提高其 JavaScript 引擎的速度,后一种技术很快就会成为历史。

实际上,阻碍 JavaScript 的却是语言本身。JavaScript 属于单线程环境,这意味着无法同时运行多个脚本。例如,假设有一个网站需要处理界面事件、查询和处理大量 API 数据以及操作 DOM。这很常见,对吧?遗憾的是,由于浏览器 JavaScript 运行时的限制,所有这些操作都无法同时进行。脚本在单个线程中执行。

开发者使用 setTimeout()setInterval()XMLHttpRequest 和事件处理脚本等技术来模拟“并发”。所有这些功能确实都是异步运行,但不阻塞并不一定意味着并发。系统会在生成当前执行脚本后处理异步事件。好消息是,HTML5 能为我们提供比这些技巧更棒的功能!

Web Worker 简介:为 JavaScript 引入线程处理

Web Worker 规范定义了用于在 Web 应用中生成后台脚本的 API。借助 Web Worker,您可以执行一些操作,例如启动长时间运行的脚本来处理计算密集型任务,但不会阻止界面或其他脚本处理用户互动。他们将帮助消除“无响应脚本”这一讨厌的对话,我们都非常喜欢:

脚本无响应对话框
常见的无响应脚本对话框。

Worker 利用类线程的消息传递来实现并行。它们非常适合用来保持界面刷新、性能出色并且对用户的响应速度非常快。

Web Worker 的类型

值得注意的是,规范讨论了两种类型的 Web 工作器:专用工作器共享工作器。本文将仅介绍专用工作器。我把它们统称为“Web Worker”或“Worker”。

使用入门

Web Worker 在独立线程中运行。因此,它们执行的代码需要包含在一个单独的文件中。但在此之前,我们需要先在主页面中创建一个新的 Worker 对象。构造函数会获取 worker 脚本的名称:

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

如果指定文件存在,浏览器将生成新的工作器线程,该线程会被异步下载。 在完全下载并执行文件之前,不会启动 Worker。如果指向工作器的路径返回 404,则工作器将静默失败。

创建 worker 后,通过调用 postMessage() 方法启动它:

worker.postMessage(); // Start the worker.

通过消息传递与 worker 通信

工作与其父页面之间的通信是使用事件模型和 postMessage() 方法完成的。postMessage() 可以接受字符串或 JSON 对象作为其单个参数,具体取决于您的浏览器/版本。最新版本的现代浏览器支持传递 JSON 对象。

以下示例展示了如何使用字符串将“Hello World”传递给 doWork.js 中的 worker。worker 仅返回传递给它的消息。

主脚本:

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

worker.addEventListener('message', function(e) {
console.log('Worker said: ', e.data);
}, false);

worker.postMessage('Hello World'); // Send data to our worker.

doWork.js (Worker):

self.addEventListener('message', function(e) {
self.postMessage(e.data);
}, false);

从主页面调用 postMessage() 时,我们的 worker 通过为 message 事件定义 onmessage 处理程序来处理该消息。可以在 Event.data 中访问消息载荷(在本例中为“Hello World”)。虽然这个特定示例并不精彩,但它说明 postMessage() 也是将数据传回主线程的一种方法。非常方便!

在主网页和 Worker 之间传递的消息是复制而不是共享的。例如,下一个示例中 JSON 消息的“msg”属性在这两个位置中均可访问。即使对象在单独的专用空间中运行,它似乎也会直接传递给工作器。实际上,实际情况是对象在传递给 worker 时进行序列化,然后在另一端进行反序列化。由于网页和 worker 不共用同一实例,因此最终结果是每次传递时都会创建一个副本。大多数浏览器都通过对任意一端的值自动进行 JSON 编码/解码来实现此功能。

下面是一个使用 JSON 对象传递消息的更复杂的示例。

主脚本:

<button onclick="sayHI()">Say HI</button>
<button onclick="unknownCmd()">Send unknown command</button>
<button onclick="stop()">Stop worker</button>
<output id="result"></output>

<script>
function sayHI() {
worker.postMessage({'cmd': 'start', 'msg': 'Hi'});
}

function stop() {
// worker.terminate() from this script would also stop the worker.
worker.postMessage({'cmd': 'stop', 'msg': 'Bye'});
}

function unknownCmd() {
worker.postMessage({'cmd': 'foobard', 'msg': '???'});
}

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

worker.addEventListener('message', function(e) {
document.getElementById('result').textContent = e.data;
}, false);
</script>

doWork2.js:

self.addEventListener('message', function(e) {
var data = e.data;
switch (data.cmd) {
case 'start':
    self.postMessage('WORKER STARTED: ' + data.msg);
    break;
case 'stop':
    self.postMessage('WORKER STOPPED: ' + data.msg +
                    '. (buttons will no longer work)');
    self.close(); // Terminates the worker.
    break;
default:
    self.postMessage('Unknown command: ' + data.msg);
};
}, false);

可转移的对象

大多数浏览器都实现了结构化克隆算法,允许您将更复杂的类型(例如 FileBlobArrayBuffer 和 JSON 对象)传入/传出 worker。但是,使用 postMessage() 传递这些类型的数据时,仍会进行复制。因此,如果您传递一个 50MB 的大型文件(举例而言),在工作器和主线程之间获取该文件会产生明显的开销。

结构化克隆非常有用,但一个副本可能需要几百毫秒的时间。为了应对性能要求,您可以使用可传输对象

借助可传输对象,数据从一个上下文传输到另一个上下文。它是零复制的,极大地提高了向 worker 发送数据的性能。如果您是 C/C++ 用户,可以将其视为传递引用。但是,与按引用传递不同,在转移到新上下文后,调用上下文中的“版本”将不再可用。例如,将 ArrayBuffer 从主应用传输到 worker 时,原始 ArrayBuffer 会被清除,无法再使用。其内容会(静默地)传输到 worker 上下文。

如需使用可转移对象,请使用略有不同的 postMessage() 签名:

worker.postMessage(arrayBuffer, [arrayBuffer]);
window.postMessage(arrayBuffer, targetOrigin, [arrayBuffer]);

对于工作器,其中第一个参数是数据,第二个参数是应传输的数据项列表。顺便提一下,第一个参数不必是 ArrayBuffer。例如,它可以是一个 JSON 对象:

worker.postMessage({data: int8View, moreData: anotherBuffer},
                [int8View.buffer, anotherBuffer]);

重要提示:第二个参数必须是 ArrayBuffer 的数组。此处列出了可转移的商品。

要详细了解可转移的内容,请参阅 developer.chrome.com 上的相关博文

工作器环境

工作器范围

在 worker 环境中,selfthis 引用 worker 的全局范围。因此,上一个示例也可以写成:

addEventListener('message', function(e) {
var data = e.data;
switch (data.cmd) {
case 'start':
    postMessage('WORKER STARTED: ' + data.msg);
    break;
case 'stop':
...
}, false);

或者,您也可以直接设置 onmessage 事件处理脚本(尽管 JavaScript 高手们始终建议使用 addEventListener)。

onmessage = function(e) {
var data = e.data;
...
};

可供工作器使用的功能

由于 Web Worker 的多线程行为,因此只能使用 JavaScript 功能的子集:

  • navigator 对象
  • location 对象(只读)
  • XMLHttpRequest
  • setTimeout()/clearTimeout()setInterval()/clearInterval()
  • 应用缓存
  • 使用 importScripts() 方法导入外部脚本
  • 生成其他 Web Worker

工作器无权访问:

  • DOM(非线程安全)
  • window 对象
  • document 对象
  • parent 对象

加载外部脚本

您可以使用 importScripts() 函数将外部脚本文件或库加载到 worker 中。该方法带有零个或多个字符串,表示要导入的资源的文件名。

以下示例将 script1.jsscript2.js 加载到 worker 中:

worker.js:

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

也可以写成单个 import 语句:

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

子 Worker

Worker 可以生成子 Worker。这对于在运行时进一步拆分大型任务非常有用。不过,子 Worker 有几点注意事项:

  • 子 Worker 必须托管在与父网页相同的来源中。
  • 子 Worker 中的 URI 会根据其父 Worker 的位置(而不是主页面)进行解析。

请注意,大多数浏览器会为每个 Worker 生成单独的进程。在开始生成 worker 农场之前,请注意不要占用过多的用户系统资源。这样做的一个原因是,在主页面和 worker 之间传递的消息是复制而不是共享的。请参阅“通过消息传递与 worker 通信”。

如需查看有关如何生成子 worker 的示例,请参阅规范中的示例

内嵌 worker

如果您想即时创建 worker 脚本,或者创建独立的页面而无需创建单独的 worker 文件,该怎么办?借助 Blob(),您可以通过以字符串形式创建工作器代码的网址句柄,从而将 worker “内嵌”到主逻辑所在的同一 HTML 文件中:

var blob = new Blob([
"onmessage = function(e) { postMessage('msg from worker'); }"]);

// Obtain a blob URL reference to our worker 'file'.
var blobURL = window.URL.createObjectURL(blob);

var worker = new Worker(blobURL);
worker.onmessage = function(e) {
// e.data == 'msg from worker'
};
worker.postMessage(); // Start the worker.

Blob 网址

调用 window.URL.createObjectURL() 很神奇。此方法会创建一个简单的网址字符串,该字符串可用于引用存储在 DOM FileBlob 对象中的数据。例如:

blob:http://localhost/c745ef73-ece9-46da-8f66-ebes574789b1

Blob 网址具有唯一性,并且会在应用的整个生命周期内持续有效(例如,直到 document 卸载为止)。如果您要创建许多 Blob 网址,最好发布不再需要的引用。您可以通过将 Blob 网址传递给 window.URL.revokeObjectURL() 来明确释放该网址:

window.URL.revokeObjectURL(blobURL);

在 Chrome 中,有一个很实用的页面可供您查看创建的所有 blob 网址:chrome://blob-internals/

完整示例

再进行一步,我们就可以更巧妙地将 worker 的 JS 代码内嵌到我们的网页中。此方法使用 <script> 标记定义 worker:

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>

<div id="log"></div>

<script id="worker1" type="javascript/worker">
// This script won't be parsed by JS engines
// because its type is javascript/worker.
self.onmessage = function(e) {
    self.postMessage('msg from worker');
};
// Rest of your worker code goes here.
</script>

<script>
function log(msg) {
    // Use a fragment: browser will only render/reflow once.
    var fragment = document.createDocumentFragment();
    fragment.appendChild(document.createTextNode(msg));
    fragment.appendChild(document.createElement('br'));

    document.querySelector("#log").appendChild(fragment);
}

var blob = new Blob([document.querySelector('#worker1').textContent]);

var worker = new Worker(window.URL.createObjectURL(blob));
worker.onmessage = function(e) {
    log("Received: " + e.data);
}
worker.postMessage(); // Start the worker.
</script>
</body>
</html>

我认为,这种新方法更简洁易读。 它使用 id="worker1"type='javascript/worker' 定义脚本标记(这样浏览器就不会解析 JS)。系统会使用 document.querySelector('#worker1').textContent 以字符串形式提取该代码,并将其传递给 Blob() 以创建文件。

加载外部脚本

使用这些技术内嵌 worker 代码时,importScripts() 仅在您提供绝对 URI 时有效。如果您尝试传递相对 URI,浏览器便会提示安全错误。原因在于:Worker(现在通过 blob 网址创建)将使用 blob: 前缀进行解析,而您的应用将通过其他(可能是 http://)方案运行。因此,失败是由于跨域限制造成的。

在内嵌 worker 中使用 importScripts() 的一种方法是,通过将运行 main 脚本的当前网址传递给内嵌 worker 并手动构建绝对网址,来“注入”运行该脚本的当前网址。这将确保外部脚本是从同一来源导入的。假设您的主应用正在从 http://example.com/index.html 运行:

...
<script id="worker2" type="javascript/worker">
self.onmessage = function(e) {
var data = e.data;

if (data.url) {
var url = data.url.href;
var index = url.indexOf('index.html');
if (index != -1) {
    url = url.substring(0, index);
}
importScripts(url + 'engine.js');
}
...
};
</script>
<script>
var worker = new Worker(window.URL.createObjectURL(bb.getBlob()));
worker.postMessage(<b>{url: document.location}</b>);
</script>

处理错误

与任何 JavaScript 逻辑一样,您需要处理 Web Worker 中出现的任何错误。如果在执行 worker 时发生错误,则会触发 ErrorEvent。该接口包含三个有用的属性,可用于找出问题所在:filename - 导致错误的工作器脚本的名称;lineno - 发生错误的行号;message - 对错误的有意义说明。以下示例展示了如何设置 onerror 事件处理脚本来输出错误的属性:

<output id="error" style="color: red;"></output>
<output id="result"></output>

<script>
function onError(e) {
document.getElementById('error').textContent = [
    'ERROR: Line ', e.lineno, ' in ', e.filename, ': ', e.message
].join('');
}

function onMsg(e) {
document.getElementById('result').textContent = e.data;
}

var worker = new Worker('workerWithError.js');
worker.addEventListener('message', onMsg, false);
worker.addEventListener('error', onError, false);
worker.postMessage(); // Start worker without a message.
</script>

示例:workerWithError.js 尝试执行 1/x,其中 x 未定义。

// TODO:DevSite - 代码示例由于使用了内嵌事件处理脚本而被移除

workerWithError.js:

self.addEventListener('message', function(e) {
postMessage(1/x); // Intentional error.
};

安全性说明

对本地访问权限的限制

由于 Google Chrome 的安全限制,工作器无法在最新版本的浏览器中本地运行(例如从 file:// 运行)。相反,它们会静默失败!如需通过 file:// 方案运行您的应用,请运行 Chrome 并设置 --allow-file-access-from-files 标志。

其他浏览器没有相同的限制。

同源注意事项

worker 脚本必须是与其调用页面采用相同架构的外部文件。因此,您无法通过 data: 网址或 javascript: 网址加载脚本,并且 https: 页面无法启动以 http: 网址开头的 worker 脚本。

用例

那么,哪种应用会利用 Web Worker?下面提供了一些有助于您大脑动荡的好点子:

  • 预提取和/或缓存数据以供日后使用。
  • 突出显示代码语法或其他实时文本格式。
  • 拼写检查工具。
  • 分析视频或音频数据。
  • 后台 I/O 或网络服务轮询。
  • 处理大型数组或超大 JSON 响应。
  • <canvas> 中的图片过滤功能。
  • 更新本地网络数据库中的多行数据。

如需详细了解涉及 Web Workers API 的用例,请访问 Workers 概览

样本歌曲

参考编号