JavaScript 中的多线程 -- Web Worker

3,285 阅读7分钟
原文链接: qiutc.me

Web Worker 介绍

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

如我们所知,JavaScript 一直是属于单线程环境,我们无法同时运行两个 JavaScript 脚本; 但是试想一下,如果我们可以同时运行两个(或者多个)JavaScript 脚本,一个来处理 UI 界面(一直以来的用法),一个来处理一些复杂计算,那么程序的整个架构将会发生很多变化,我们的任务将更有区分性和条理性,同时可以更充分利用设备的硬件计算能力(多核运算),这将大大有利于提高我们的页面性能。

在 HTML5 的新规范中,实现了 Web Worker 来引入 JavaScript 的 “多线程” 技术,他的能力让我们可以在页面主运行的 JavaScript 线程中加载运行另外单独的一个或者多个 JavaScript 线程;

当然 Web Worker 提供的多线程编程能力并不像我们传统意义上的多线程编程,它不像其他的多线程语言(Java、C++ 等),主程序线程和 Worker 线程之间,Worker 线程之间,不会共享任何作用域或资源,他们间唯一的通信方式就是一个基于事件监听机制的 message(下文将具体描述);

同时,这并不意味着 JavaScript 语言本身就支持了多线程,对于 JavaScript 语言本身它仍是运行在单线程上的, Web Worker 只是浏览器(宿主环境)提供的一个能力/API。

上手使用

实例化一个 Worker

(以下例子统一约定:main.js 为页面运行的主要脚本文件,workder.js 为 Web Worker 脚本的文件)

实例化运行一个 Worker 很简单,我们只需要 new 一个 Worker 全局对象即可:new Worker(filepathname), 接受一个 filepathname String 参数,用于指定 Worker 脚本文件的路径;

// main.js
        var worker = new Worker('./worker.js');
        
// worker.js
        console.log('WORKER TASK: ', 'running');
        

用浏览器打开,我们可以看到 console 答应出来:

WORKER TASK:  running          worker.js:1
        

说明我们加载并且执行到了这个 Worker 脚本。

数据通信

当实例运行了一个 Worker 线程之后,两个线程是运行在完全独立的环境中,他们之间的通信是通过基于事件监听机制的 message 来实现的,new Worker() 之后会返回一个实例对象,它包含一个 postMessage 方法,可以通过调用这个方法来给 Worker 线程传递信息;同时我们可以给这个对象监听事件,这样,就能在 Worker 中触发事件通信的时候接收到数据了;具体实现:

// main.js
        var worker = new Worker('./worker.js');
        // 监听事件
        worker.addEventListener('message', function (e) {
          console.log('MAIN: ', 'RECEIVE', e.data);
          });
        // 或者可以使用 onMessage 来监听事件:
        // worker.onmessage = function () {
        //  console.log('MAIN: ', 'RECEIVE', e.data);
        //};
        // 触发事件,传递信息给 Worker
        worker.postMessage('Hello Worker, I am main.js');
        

在 Worker 的脚本中,我们可以调用全局函数 postMessage 和给全局的 onmessage 赋值来发送和监听数据和事件:

// worker.js
        console.log('WORKER TASK: ', 'running');
        // 监听事件
        onmessage = function (e) {
          console.log('WORKER TASK: ', 'RECEIVE', e.data);
          // 发送数据事件
          postMessage('Hello, I am Worker');
          }
        // 或者使用 addEventListener 来监听事件
        //addEventListener('message', function (e) {
        //  console.log('WORKER TASK: ', 'RECEIVE', e.data);
        //  ...
        //});
        

可以在 console 中看到,我们完成了一次两个线程之间的数据通信。

当然,这里传递的是一个 String 类型的数据,实际上,它支持 JavaScript 中所有类型的数据传递,可以传递一个 Object 数据;然而,值得注意的是,这里的数据传递(主要是 Object 类型)并不是共享,而是复制,发送端的数据和接收端的数据是复制而来,并不指向同一个对象,并且,这里的复制不是简单的便利拷贝,而是通过两端的序列化/解序列化来实现的,一般来说浏览器会通过 JSON 编码/解码;当然,这里的更多细节部分会由浏览器来处理,我们并不需要关系,只需要明白两端的数据是复制而来,互相独立的。

终止 worker

如果在某个时机不想要 Worker 继续运行了,那么我们需要终止掉这个线程,可以调用 workerterminate 方法 :

var worker = new Worker('./worker.js');
        ...
        worker.terminate();
        

处理错误

当我们需要监听 worker 出现运行时错误的时候,可以在 worker 对象监听 error 事件:

// main.js
        var worker = new Worker('./worker.js');
        // 监听消息事件
        worker.addEventListener('message', function (e) {
          console.log('MAIN: ', 'RECEIVE', e.data);
          });
        // 或者可以使用 onMessage 来监听事件:
        // worker.onmessage = function () {
        //  console.log('MAIN: ', 'RECEIVE', e.data);
        //};
        // 监听 error 事件
        worker.addEventListener('error', function (e) {
          console.log('MAIN: ', 'ERROR', e);
          console.log('MAIN: ', 'ERROR', 'filename:' + e.filename + '---message:' + e.message + '---lineno:' + e.lineno);
          });
        // 或者可以使用 onMessage 来监听事件:
        // worker.onerror = function () {
        //  console.log('MAIN: ', 'ERROR', e);
        //};
        // 触发事件,传递信息给 Worker
        worker.postMessage({
          m: 'Hello Worker, I am main.js'
          });
        
// worker.js
        console.log('WORKER TASK: ', 'running');
        // 监听事件
        onmessage = function (e) {
          console.log('WORKER TASK: ', 'RECEIVE', e.data);
          // 发送数据事件
          // 注意:这里的 hhh 变量在 worker.js 中并未定义,所以这里执行过程中会错处
          postMessage( hhh );
          }
        

运行程序可以才 console 看到,main.js 中接收到来自 worker.js 中的一个运行错误,在监听事件的函数中接受一个参数 event 这个事件对象中有几个比较重要的参数需要我们注意:

  • event.filename: 导致错误的 Worker 脚本的名称;
  • event.message: 错误的信息;
  • event.lineno: 出现错误的行号;

Worker 的环境与作用域

如前文所述,在 Worker 线程的运行环境中没有 window 全局对象,也无法访问 DOM 对象,所以一般来说他只能来执行纯 JavaScript 的计算操作。

但是,他还是可以获取到部分浏览器提供的 API 的:

  • setTimeout(), clearTimeout(), setInterval(), clearInterval():有了设计个函数,就可以在 Worker 线程中执行定时操作了;
  • XMLHttpRequest 对象:意味着我们可以在 Worker 线程中执行 ajax 请求;
  • navigator 对象:可以获取到 ppName,appVersion,platform,userAgent 等信息;
  • location 对象(只读):可以获取到有关当前 URL 的信息;

在 Worker 中加载外部脚本

可以通过 Worker 环境中的全局函数 importScripts() 加载外部 js 脚本到当前 Worker 脚本中,它接收多个参数,参数都为加载脚本的链接字符串,比如:

// main.js
        var worker = new Worker('./worker1.js');
        
// worker1.js
        console.log('hello, I,m worker 1');
        importScripts('worker2.js', 'worker3.js');
        // 或者
        // importScripts('worker2.js');
        // importScripts('worker3.js');
        
// worker2.js
        console.log('hello, I,m worker 2');
        
// worker3.js
        console.log('hello, I,m worker 3');
        

在这里,我们在 main.js 中运行了 worker1.js 线程,然后在 worker1.js 中加载了 worker2.js 和 worker3.js,在 console 中,可以看到他们全部执行了。

subworker – Worker 中的 Worker

我们可以在一个 Worker 脚本中去实例化另一个 Worker,这成为子 Worker,但是这个特性目前大部分浏览器还未实现,所以不展开阐述。

SharedWorker

对于 Web Worker ,一个 tab 页面只能对应一个 Worker 线程,是相互独立的;

而 SharedWorker 提供了能力能够让不同标签中页面共享的同一个 Worker 脚本线程;

当然,有个很重要的限制就是它们需要满足同源策略,也就是需要在同域下;

在页面(可以多个)中实例化 Worker 线程:

// main.js
        var myWorker = new SharedWorker("worker.js");
        myWorker.port.start();
        myWorker.port.postMessage("hello, I'm main");
        myWorker.port.onmessage = function(e) {
          console.log('Message received from worker');
          }
        
// worker.js
        onconnect = function(e) {
          var port = e.ports[0];
          port.addEventListener('message', function(e) {
            var workerResult = 'Result: ' + (e.data[0] * e.data[1]);
            port.postMessage(workerResult);
          });
          port.start();
          }
        

在 SharedWorker 的使用中,我们发现对于 SharedWorker 实例对象,我们需要通过 port 属性来访问到主要方法;

同时在 Worker 脚本中,多了个全局的 connect() 函数,同时在函数中也需要去获取一个 post 对象来进行启动以及操作;

这是因为,多页页面共享一个 SharedWorker 线程时,在线程中需要去判断和区分来自不同页面的信息,这是最主要的区别和原因。

遗憾的是,对于 SharedWorker,兼容性现在而言也是大部分浏览器还未实现。

Web Worker 的兼容性

Web Worker

caniuse.com/#feat=webwo…

Web Worker

应用

Web Worker 的实现为前端程序带来了后台计算的能力,可以实现主 UI 线程与复杂计运算线程的分离,从而极大减轻了因计算量大而造成 UI 阻塞而出现的界面渲染卡、掉帧的情况,并且更大程度地利用了终端硬件的性能;

同时把程序之间的任务更清晰、条理化;

其主要应用有几个场景:

  • 对于图像、视频、音频的解析处理;
  • canvas 中的图像计算处理;
  • 大量的 ajax 请求或者网络服务轮询;
  • 大量数据的计算处理(排序、检索、过滤、分析…);

reference