问题: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);
可转移的对象
大多数浏览器都实现了结构化克隆算法,允许您将更复杂的类型(例如 File
、Blob
、ArrayBuffer
和 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 环境中,self
和 this
引用 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.js
和 script2.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 File
或 Blob
对象中的数据。例如:
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 概览。
样本歌曲
参考编号
- Web Worker 规范
- Mozilla 开发者网络网络文档中的“使用 Web 工作器”。
- Dev.Opera 的“Web Workers rise up!”