ITPub博客

首页 > 应用开发 > > JavaScript多线程方案:Web Workers看完即会的教程

JavaScript多线程方案:Web Workers看完即会的教程

作者:bluetooth 时间:2019-09-06 09:19:11 0 删除 编辑

Web Worker为Web内容在后台线程中运行脚本提供了一种简单的方法。线程可以执行任务而不干扰用户界面。此外,他们可以使用XMLHttpRequest执行 I/O (尽管responseXML和channel属性总是为空)。一旦创建, 一个worker 可以将消息发送到创建它的JavaScript代码, 通过将消息发布到该代码指定的事件处理程序(反之亦然)。本文提供了有关使用Web Worker的详细介绍。

Web Workers API

一个worker是使用一个构造函数创建的一个对象(e.g. Worker()) 运行一个命名的JavaScript文件 - 这个文件包含将在工作线程中运行的代码; workers 运行在另一个全局上下文中,不同于当前的window. 因此,使用 window快捷方式获取当前全局的范围 (而不是self) 在一个 Worker内将返回错误。

在专用workers的情况下,DedicatedWorkerGlobalScope 对象代表了worker的上下文(专用workers是指标准worker仅在单一脚本中被使用;共享worker的上下文是SharedWorkerGlobalScope对象)。一个专用worker仅仅能被首次生成它的脚本使用,而共享worker可以同时被多个脚本使用。

在worker线程中你可以运行任何你喜欢的代码,不过有一些例外情况。比如:在worker内,不能直接操作DOM节点,也不能使用window对象的默认方法和属性。然而你可以使用大量window对象之下的东西,包括WebSockets,IndexedDB以及FireFox OS专用的Data Store API等数据存储机制。查看Functions and classes available to workers获取详情。

workers和主线程间的数据传递通过这样的消息机制进行——双方都使用postMessage()方法发送各自的消息,使用onmessage事件处理函数来响应消息(消息被包含在Message事件的data属性中)。这个过程中数据并不是被共享而是被复制。

只要运行在同源的父页面中,workers可以依次生成新的workers;并且可以使用XMLHttpRequest 进行网络I/O,responseXML和XMLHttpRequest的通道属性一直返回null的情况除外。

专用worker

如前文所述,一个专用worker仅仅能被生成它的脚本所使用。这一部分将探讨 专用worker基础示例 (运行专用worker) 中的JavaScript代码:将你输入的2个数字作乘法。输入的数字会发送给一个专用worker,由专用worker作乘法后,再返回给页面进行展示。

这个例子很小,但是我们决定在保持简单的同时向你介绍基础的worker概念。更多的细节会在之后的文章中进行讲解。

worker特性检测

为了更好的错误处理控制以及向下兼容,将你的worker运行代码包裹在以下代码中是一个很好的想法(main.js):

if (window.Worker) {

...

}

生成一个专用worker

创建一个新的worker很简单。你需要做的是调用Worker() 的构造器,指定一个脚本的URI来执行worker线程(main.js):

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

专用worker中消息的接收和发送

workers的魔法通过postMessage() 方法和onmessage事件处理函数生效。向一个worker发送消息需要这样做(main.js):

first.onchange = function() {

myWorker.postMessage([first.value,second.value]);

console.log('Message posted to worker');

}

second.onchange = function() {

myWorker.postMessage([first.value,second.value]);

console.log('Message posted to worker');

}

这段代码中变量first和second代表2个<input>元素;它们当中任意一个的值发生改变时,myWorker.postMessage([first.value,second.value])会将这2个值组成数组发送给worker。你可以在消息中发送许多你想发送的东西。

在worker中接收到消息后,我们可以写这样一个事件处理函数代码作为响应(worker.js):

onmessage = function(e) {

console.log('Message received from main script');

var workerResult = 'Result: ' + (e.data[0] * e.data[1]);

console.log('Posting message back to main script');

postMessage(workerResult);

}

onmessage处理函数允许我们在任何时刻,一旦接收到消息就可以执行一些代码,代码中消息本身作为事件的data属性进行使用。这里我们简单的对这2个数字作乘法处理并再次使用postMessage()方法,将结果回传给主线程。

回到主线程,我们再次使用onmessage以响应worker回传的消息:

myWorker.onmessage = function(e) {

result.textContent = e.data;

console.log('Message received from worker');

}

在这里我们获取消息事件的data,并且将它设置为result的textContent,所以用户可以直接看到运算的结果。

注意: 在主线程中使用时,onmessage和postMessage() 必须挂在worker对象上,而在worker中使用时不用这样做。原因是,在worker内部,worker是有效的全局作用域。当一个消息在主线程和worker之间传递时,它被复制或者转移了,而不是共享。

终止worker

如果你需要从主线程中立刻终止一个运行中的worker,可以调用worker的terminate 方法:

myWorker.terminate();

worker 线程会被立即杀死,不会有任何机会让它完成自己的操作或清理工作。

而在worker线程中,workers 也可以调用自己的 close 方法进行关闭:

close();

处理错误

当 worker 出现运行中错误时,它的 onerror 事件处理函数会被调用。它会收到一个扩展了 ErrorEvent 接口的名为 error的事件。

该事件不会冒泡并且可以被取消;为了防止触发默认动作,worker 可以调用错误事件的 preventDefault()方法。

错误事件有以下三个用户关心的字段:

message

可读性良好的错误消息。

filename

发生错误的脚本文件名。

lineno

发生错误时所在脚本文件的行号。

生成subworker

如果需要的话 worker 能够生成更多的 worker。这就是所谓的subworker,它们必须托管在同源的父页面内。而且,subworker 解析 URI 时会相对于父 worker 的地址而不是自身页面的地址。这使得 worker 更容易记录它们之间的依赖关系。

引入脚本与库

Worker 线程能够访问一个全局函数importScripts()来引入脚本,该函数接受0个或者多个URI作为参数来引入资源;以下例子都是合法的:

importScripts(); /* 什么都不引入 */

importScripts('foo.js'); /* 只引入 "foo.js" */

importScripts('foo.js', 'bar.js'); /* 引入两个脚本 */

浏览器加载并运行每一个列出的脚本。每个脚本中的全局对象都能够被 worker 使用。如果脚本无法加载,将抛出 NETWORK_ERROR 异常,接下来的代码也无法执行。而之前执行的代码(包括使用 window.setTimeout() 异步执行的代码)依然能够运行。importScripts() 之后的函数声明依然会被保留,因为它们始终会在其他代码之前运行。

注意: 脚本的下载顺序不固定,但执行时会按照传入 importScripts() 中的文件名顺序进行。这个过程是同步完成的;直到所有脚本都下载并运行完毕,importScripts() 才会返回。

共享worker

一个共享worker可以被多个脚本使用——即使这些脚本正在被不同的window、iframe或者worker访问。这一部分,我们会讨论共享worker基础示例(运行共享worker)中的javascript代码:该示例与专用worker基础示例非常相像,只是有2个可用函数被存放在不同脚本文件中:两数相乘函数,以及求平方函数。这两个脚本用同一个worker来完成实际需要的运算。

这里,我们关注一下专用worker和共享worker之间的区别。在这个示例中有2个HTML页面,每个页面所包含的javascript代码使用的是同一个worker。

注意:如果共享worker可以被多个浏览上下文调用,所有这些浏览上下文必须属于同源(相同的协议,主机和端口号)。在 Firefox中, 共享worker不能被私有和非私有window对象的document所共享 (bug 1177621)。

生成一个共享worker

生成一个新的共享worker与生成一个专用worker非常相似,只是构造器的名字不同(查看 index.html 和 index2.html)——生成共享worker的代码如下:

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

一个非常大的区别在于,与一个共享worker通信必须通过端口对象——一个确切的打开的端口供脚本与worker通信(在专用worker中这一部分是隐式进行的)。

在传递消息之前,端口连接必须被显式的打开,打开方式是使用onmessage事件处理函数或者start()方法。示例中的 multiply.js 和 worker.js 文件没有调用了start()方法,这些调用并不那么重要是因为onmessage事件处理函数正在被使用。start()方法的调用只在一种情况下需要,那就是消息事件被addEventListener()方法使用。

在使用start()方法打开端口连接时,如果父级线程和worker线程需要双向通信,那么它们都需要调用start()方法。

myWorker.port.start(); // 父级线程中的调用

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

共享worker中消息的接收和发送

现在,消息可以像之前那样发送到worker了,但是postMessage() 方法必须被端口对象调用(你会再一次看到 multiply.js 和 square.js中相似的结构):

squareNumber.onchange = function() {

myWorker.port.postMessage([squareNumber.value,squareNumber.value]);

console.log('Message posted to worker');

}

回到worker中,这里也有一些些复杂(worker.js):

onconnect = function(e) {

var port = e.ports[0];

port.onmessage = function(e) {

var workerResult = 'Result: ' + (e.data[0] * e.data[1]);

port.postMessage(workerResult);

}

}

首先,当一个端口连接被创建时(例如:在父级线程中,设置onmessage事件处理函数,或者显式调用start()方法时),使用onconnect事件处理函数来执行代码。

使用事件的ports属性来获取端口并存储在变量中。

然后,为端口添加一个消息处理函数用来做运算并回传结果给主线程。在worker线程中设置此消息处理函数也会隐式的打开与主线程的端口连接,因此这里跟前文一样,对port.start()的调用也是不必要的。

最后,回到主脚本,我们处理消息(你会又一次看到 multiply.js 和 square.js中相似的结构):

myWorker.port.onmessage = function(e) {

result2.textContent = e.data;

console.log('Message received from worker');

}

当一条消息通过端口回到worker,我们检查结果的类型,然后将运算结果放入结果段落中合适的地方。

关于线程安全

Worker接口会生成真正的操作系统级别的线程,如果你不太小心,那么并发会对你的代码产生有趣的影响。然而,对于 web worker 来说,与其他线程的通信点会被很小心的控制,这意味着你很难引起并发问题。你没有办法去访问非线程安全的组件或者是 DOM,此外你还需要通过序列化对象来与线程交互特定的数据。所以你要是不费点劲儿,还真搞不出错误来

内容安全策略

有别于创建它的document对象,worker有它自己的执行上下文。因此普遍来说,worker并不受限于创建它的document(或者父级worker)的内容安全策略。我们来举个例子,假设一个document有如下头部声明:

Content-Security-Policy: script-src 'self'

这个声明有一部分作用在于,禁止它内部包含的脚本代码使用eval()方法。然而,如果脚本代码创建了一个worker,在worker上下文中执行的代码却是可以使用eval()的。

为了给worker指定内容安全策略,必须为发送worker代码的请求本身加上一个 内容安全策略。

有一个例外情况,即worker脚本的源如果是一个全局性的唯一的标识符(例如,它的URL指定了数据模式或者blob),worker则会继承创建它的document或者worker的CSP(Content security policy内容安全策略)。

worker中数据的接收与发送:详细介绍节

在主页面与 worker 之间传递的数据是通过拷贝,而不是共享来完成的。传递给 worker 的对象需要经过序列化,接下来在另一端还需要反序列化。页面与 worker 不会共享同一个实例,最终的结果就是在每次通信结束时生成了数据的一个副本。大部分浏览器使用结构化拷贝来实现该特性。

在往下进行之前,出于教学的目的,让我们创建一个名为 emulateMessage() 的函数,它将模拟在从 worker 到主页面(反之亦然)的通信过程中,变量的「拷贝而非共享」行为

function emulateMessage (vVal) {

return eval("(" + JSON.stringify(vVal) + ")");

}

// Tests

// test #1

var example1 = new Number(3);

alert(typeof example1); // object

alert(typeof emulateMessage(example1)); // number

// test #2

var example2 = true;

alert(typeof example2); // boolean

alert(typeof emulateMessage(example2)); // boolean

// test #3

var example3 = new String("Hello World");

alert(typeof example3); // object

alert(typeof emulateMessage(example3)); // string

// test #4

var example4 = {

"name": "John Smith",

"age": 43

};

alert(typeof example4); // object

alert(typeof emulateMessage(example4)); // object

// test #5

function Animal (sType, nAge) {

this.type = sType;

this.age = nAge;

}

var example5 = new Animal("Cat", 3);

alert(example5.constructor); // Animal

alert(emulateMessage(example5).constructor); // Object

拷贝而并非共享的那个值称为 消息。再来谈谈 worker,你可以使用 postMessage() 将消息传递给主线程或从主线程传送回来。message 事件的 data 属性就包含了从 worker 传回来的数据。

example.html: (主页面):

var myWorker = new Worker("my_task.js");

myWorker.onmessage = function (oEvent) {

console.log("Worker said : " + oEvent.data);

};

myWorker.postMessage("ali");

my_task.js (worker):

postMessage("I\'m working before postMessage(\'ali\').");

onmessage = function (oEvent) {

postMessage("Hi " + oEvent.data);

};

结构化拷贝算法可以接收JSON数据以及一些JSON不能表示的数据——比如循环引用

传递数据的例子

例子 #1: 创建一个通用的异步 eval()

下面这个例子介绍了,如何在 worker 内使用 eval() 来按顺序执行异步的任何种类的 JavaScript 代码:

// Syntax: asyncEval(code[, listener])

var asyncEval = (function () {

var aListeners = [];

var oParser = new Worker("data:text/javascript;charset=US-ASCII,onmessage%20%3D%20function%20%28oEvent%29%20%7B%0A%09postMessage%28%7B%0A%09%09%22id%22%3A%20oEvent.data.id%2C%0A%09%09%22evaluated%22%3A%20eval%28oEvent.data.code%29%0A%09%7D%29%3B%0A%7D");

oParser.onmessage = function (oEvent) {

if (aListeners[oEvent.data.id]) { aListeners[oEvent.data.id](oEvent.data.evaluated); }

delete aListeners[oEvent.data.id];

};

return function (sCode, fListener) {

aListeners.push(fListener || null);

oParser.postMessage({

"id": aListeners.length - 1,

"code": sCode

});

};

})();

data URL 相当于一个网络请求,它有如下返回:

onmessage = function(oEvent) {

postMessage({

'id': oEvent.data.id,

'evaluated': eval(oEvent.data.code)

});

}

示例使用:

// asynchronous alert message...

asyncEval("3 + 2", function (sMessage) {

alert("3 + 2 = " + sMessage);

});

// asynchronous print message...

asyncEval("\"Hello World!!!\"", function (sHTML) {

document.body.appendChild(document.createTextNode(sHTML));

});

// asynchronous void...

asyncEval("(function () {\n\tvar oReq = new XMLHttpRequest();\n\toReq.open(\"get\", \"http://www.mozilla.org/\", false);\n\toReq.send(null);\n\treturn oReq.responseText;\n})()");

例子 #2:传输 JSON 的高级方式和创建一个交换系统

如果你需要传输非常复杂的数据,还要同时在主页与 Worker 内调用多个方法,那么可以考虑创建一个类似下面的系统。

首先,我们创建一个QueryableWorker的类,它接收worker的url、一个默认侦听函数、和一个错误处理函数作为参数,这个类将会记录所有的侦听的列表并且帮助我们与worker进行通信。

function QueryableWorker(url, defaultListener, onError) {

var instance = this,

worker = new Worker(url),

listeners = {};

this.defaultListener = defaultListener || function() {};

if (onError) {worker.onerror = onError;}

this.postMessage = function(message) {

worker.postMessage(message);

}

this.terminate = function() {

worker.terminate();

}

}

紧接着,我们写出新增和删除侦听的方法。

this.addListeners = function(name, listener) {

listeners[name] = listener;

}

this.removeListeners = function(name) {

delete listeners[name];

}

这里我们让worker处理2个这样的简单操作:区别2个数字并在3秒后弹框提示。为了完成这个操作,我们首先实现一个sendQuery方法,该方法可以查询worker是否真正有我们所需要的对应方法。

/*

This functions takes at least one argument, the method name we want to query.

Then we can pass in the arguments that the method needs.

*/

this.sendQuery = function() {

if (arguments.length < 1) {

throw new TypeError('QueryableWorker.sendQuery takes at least one argument');

return;

}

worker.postMessage({

'queryMethod': arguments[0],

'queryArguments': Array.prototype.slice.call(arguments, 1)

});

}

我们以onmessage方法作为QueryableWorker的结尾。如果worker有我们所需要的对应的方法,它就会返回相对应的侦听方法的名字以及所需要的参数,我们只需要在侦听列表listeners中找到它:

worker.onmessage = function(event) {

if (event.data instanceof Object &&

event.data.hasOwnProperty('queryMethodListener') &&

event.data.hasOwnProperty('queryMethodArguments')) {

listeners[event.data.queryMethodListener].apply(instance, event.data.queryMethodArguments);

} else {

this.defaultListener.call(instance, event.data);

}

}

现在回到worker中。首先我们需要一个能够完成这2个操作的方法:

var queryableFunctions = {

getDifference: function(a, b) {

reply('printStuff', a - b);

},

waitSomeTime: function() {

setTimeout(function() {

reply('doAlert', 3, 'seconds');

}, 3000);

}

}

function reply() {

if (arguments.length < 1) {

throw new TypeError('reply - takes at least one argument');

return;

}

postMessage({

queryMethodListener: arguments[0],

queryMethodArguments: Array.prototype.slice.call(arguments, 1)

});

}

/* This method is called when main page calls QueryWorker's postMessage method directly*/

function defaultReply(message) {

// do something

}

onmessage方法也就很简单了:

onmessage = function(event) {

if (event.data instanceof Object &&

event.data.hasOwnProperty('queryMethod') &&

event.data.hasOwnProperty('queryMethodArguments')) {

queryableFunctions[event.data.queryMethod]

.apply(self, event.data.queryMethodArguments);

} else {

defaultReply(event.data);

}

}

接下来给出一个完整的实现:

example.html (the main page):

my_task.js (the worker):

这个实例中,可以对从主页面到worker、以及worker到主页面之间传递的消息内容进行切换。而且属性名"queryMethod", "queryMethodListeners","queryMethodArguments"可以是任何东西,只要它们在QueryableWorker和worker中保持一致。

通过转让所有权(可转让对象)来传递数据

Google Chrome 17 与 Firefox 18 包含另一种性能更高的方法来将特定类型的对象(可转让对象) 传递给一个 worker/从 worker 传回 。可转让对象从一个上下文转移到另一个上下文而不会经过任何拷贝操作。这意味着当传递大数据时会获得极大的性能提升。如果你从 C/C++ 世界来,那么把它想象成按照引用传递。然而与按照引用传递不同的是,一旦对象转让,那么它在原来上下文的那个版本将不复存在。该对象的所有权被转让到新的上下文内。例如,当你将一个 ArrayBuffer 对象从主应用转让到 Worker 中,原始的 ArrayBuffer 被清除并且无法使用。它包含的内容会(完整无差的)传递给 Worker 上下文。

// Create a 32MB "file" and fill it.

var uInt8Array = new Uint8Array(1024*1024*32); // 32MB

for (var i = 0; i < uInt8Array .length; ++i) {

uInt8Array[i] = i;

}

worker.postMessage(uInt8Array.buffer, [uInt8Array.buffer]);

嵌入式 worker

目前没有一种「官方」的方法能够像 <script> 元素一样将 worker 的代码嵌入的网页中。但是如果一个 <script> 元素没有 src 特性,并且它的 type 特性没有指定成一个可运行的 mime-type,那么它就会被认为是一个数据块元素,并且能够被 JavaScript 使用。「数据块」是 HTML5 中一个十分常见的特性,它可以携带几乎任何文本类型的数据。所以,你能够以如下方式嵌入一个 worker:

现在,嵌入式 worker 已经嵌套进了一个自定义的 document.worker 属性中。

这样也不足为奇,你仍然可以将一个函数转换为blob,然后为这个blob生成URL对象。比如:

function fn2workerURL(fn) {

var blob = new Blob(['('+fn.toString()+')()'], {type: 'application/javascript'})

return URL.createObjectURL(blob)

}

本节提供了几个如何使用 DOM worker 的例子。

在后台执行运算节

worker 的一个优势在于能够执行处理器密集型的运算而不会阻塞 UI 线程。在下面的例子中,worker 用于计算斐波那契数。

JavaScript 代码

下面的 JavaScript 代码保存在「fibonacci.js」文件中,与下一节的 HTML 文件关联。

var results = [];

function resultReceiver(event) {

results.push(parseInt(event.data));

if (results.length == 2) {

postMessage(results[0] + results[1]);

}

}

function errorReceiver(event) {

throw event.data;

}

onmessage = function(event) {

var n = parseInt(event.data);

if (n == 0 || n == 1) {

postMessage(n);

return;

}

for (var i = 1; i <= 2; i++) {

var worker = new Worker("fibonacci.js");

worker.onmessage = resultReceiver;

worker.onerror = errorReceiver;

worker.postMessage(n - i);

}

};

worker 将属性 onmessage 设置为一个函数,当 worker 对象调用 postMessage() 时该函数会接收到发送过来的信息。(注意,这么使用并不等同于定义一个同名的全局变量 ,或是定义一个同名的函数。var onmessage 与 function onmessage 将会定义与该名字相同的全局属性,但是它们不会注册能够接收从创建 worker 的网页发送过来的消息的函数。) 这会启用递归,生成自己的新拷贝来处理计算的每一个循环。

HTML 代码

网页创建了一个 div 元素,ID 为 result , 用它来显示运算结果,然后生成 worker。在生成 worker 后,onmessage 处理函数配置为通过设置 div 元素的内容来显示运算结果,然后 onerror 处理函数被设置为 转储 错误信息。

最后,向 worker 发送一条信息来启动它。

worker中可用的函数和接口

你可以在web worker中使用大多数的标准javascript特性,包括

Navigator

XMLHttpRequest

Array, Date, Math, and String

WindowTimers.setTimeout and WindowTimers.setInterval

在一个worker中最主要的你不能做的事情就是直接影响父页面。包括操作父页面的节点以及使用页面中的对象。你只能间接地实现,通过DedicatedWorkerGlobalScope.postMessage回传消息给主脚本,然后从主脚本那里执行操作或变化。

Worker兼容性

参考文章

https://developer.mozilla.org/zh-CN/docs/Web/API/Web_Workers_API/Using_web_workers

https://caniuse.com/#search=Web%20Worker


来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/3402/viewspace-3151989/ ,如需转载,请注明出处,否则将追究法律责任。

请登录后发表评论 登录
全部评论

注册时间:2019-07-11

  • 博文量
    280
  • 访问量
    20974