一文彻底读懂Web Workers

363 阅读8分钟

一、Web Worker 的背景

  JavaScript 语言采用的是单线程模型,也就是说,所有任务只能在一个线程上完成,一次只能做一件事。前面的任务没做完,后面的任务只能等着。JavaScript 中耗时的 I/O 操作都被处理为异步操作,它们包括键盘、鼠标 I/O 输入输出事件、窗口大小的 resize 事件、定时器(setTimeout、setInterval)事件、Ajax 请求网络 I/O 回调等。当这些异步任务发生的时候,它们将会被放入浏览器的事件任务队列中去,等到 JavaScript 运行时执行线程空闲时候才会按照队列先进先出的原则被一一执行,但终究还是单线程。
  平时看似够用的异步编程(promise、async/await),在遇到很复杂的运算,比如说图像的识别优化或转换、H5游戏引擎的实现,加解密算法操作等等,它们的不足就将逐渐体现出来。长时间运行的 js 进程会导致浏览器冻结用户界面,降低用户体验。那有没有什么办法可以将复杂的计算从业务逻辑代码抽离出来,让计算运行的同时不阻塞用户操作界面获得反馈呢?
  HTML5 标准通过了 Web Worker 的规范,该规范定义了一套 api,它允许一段 js 程序运行在主线程之外的另一个线程中。工作线程允许开发人员编写能够长时间运行而不被用户所中断的后台程序, 去执行事务或者逻辑,并同时保证页面对用户的及时响应,可以将一些大量计算的代码交给web worker运行而不冻结用户界面。

二、Web Worker 的API

2.1 主线程
浏览器原生提供Worker()构造函数,用来供主线程生成 Worker 线程。

var myWorker = new Worker(jsUrl, options);

Worker()构造函数,可以接受两个参数。第一个参数是脚本的网址(必须遵守同源政策),该参数是必需的,且只能加载 JS 脚本,否则会报错。第二个参数是配置对象,该对象可选。它的一个作用就是指定 Worker 的名称,用来区分多个 Worker 线程。

// 主线程
var myWorker = new Worker('worker.js', { name : 'myWorker' });

// Worker 线程
self.name // myWorker

Worker()构造函数返回一个 Worker 线程对象,用来供主线程操作 Worker。Worker 线程对象的属性和方法如下。

  • Worker.onerror:指定 error 事件的监听函数。
  • Worker.onmessage:指定 message 事件的监听函数,发送过来的数据在Event.data属性中。
  • Worker.onmessageerror:指定 messageerror 事件的监听函数。发送的数据无法序列化成字符串时,会触发这个事件。
  • Worker.postMessage():向 Worker 线程发送消息。
  • Worker.terminate():立即终止 Worker 线程

2.2 Worker 线程
Web Worker 有自己的全局对象,不是主线程的window,而是一个专门为 Worker 定制的全局对象。因此定义在window上面的对象和方法不是全部都可以使用。
Worker 线程有一些自己的全局属性和方法。

  • self.name: Worker 的名字。该属性只读,由构造函数指定。
  • self.onmessage:指定message事件的监听函数。
  • self.onmessageerror:指定 messageerror 事件的监听函数。发送的数据无法序列化成字符串时,会触发这个事件。
  • self.close():关闭 Worker 线程。
  • self.postMessage():向产生这个 Worker 线程发送消息。
  • self.importScripts():加载 JS 脚本。

三、Web Worker 的使用

3.1 Worker通信
主线程 应用文件app.js

// 主线程采用new命令,调用Worker()构造函数。创建 worker 实例
var worker = new Worker('./worker.js'); // 传入 worker 脚本文件的路径即可
// 监听消息
worker.onmessage = function(event){
  // 主线程收到工作线程的消息
  console.log('Received message ' +event.data)//`data`属性可以获取 Worker 发来的数据。
};
// 主线程向工作线程发送消息
worker.postMessage({
  value: '主线程向工作线程发送消息'
});

// Worker 完成任务以后,主线程就可以把它关掉。
worker.terminate();

worker 文件 worker.js

// 监听`message`事件
    // 可通过监听函数
    self.addEventListener('message', function (e) {
      // 向主线程发送消息
      self.postMessage('You said: ' + e.data);//它的`data`属性包含主线程发来的数据
    }, false);
    // 或可以通过self.onmessage监听
    self.onmessage = function(e){ // 工作线程收到主线程的消息 };
    // 上面代码中,`self`代表子线程自身,即子线程的全局对象。因此,等同于下面两种写this.addEventListener('message', function (e) {
      this.postMessage('You said: ' + e.data);
    }, false);
    addEventListener('message', function (e) {
      postMessage('You said: ' + e.data);
    }, false);
// Worker 内部关闭自身
   self.close()

3.2 Worker 加载脚本
Worker 线程能够访问一个全局函数 importScripts() 来引入脚本,该函数接受 0 个或者多个 URI 作为参数来引入资源;

importScripts();                            /* 什么都不引入 */
importScripts('script1.js');                /* 只引入 "script1.js" */
importScripts('script1.js', 'script2.js');  /* 引入两个脚本 */

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

3.3 Worker 错误处理
主线程可以监听 Worker 是否发生错误。如果发生错误,Worker 会触发主线程的error事件。发生 error 事件时,事件对象中包含三个属性:filenamelineno 和 message,分别表示发生错误的文件名、代码行号和完整的错误消息。

worker.addEventListener('error', function (e) {
  console.log('MAIN: ', 'ERROR', e);
  console.log('filename:' + e.filename + '-message:' + e.message + '-lineno:' + e.lineno);
});
// 或者
worker.addEventListener('error', function (e) {
  // ...
});

3.4 Worker 全局作用域
使用 Web Worker 最重要的一点是要知道,它所执行的 js 代码完全在另一作用域中,与当前主线程的代码不共享作用域。在 Web Worker 中,同样有一个全局对象和其他对象以及方法,但其代码无法访问 DOM,也不能影响页面的外观。
Web Worker 中的全局对象是 worker 对象本身,也即 thisself 引用的都是 worker 对象,说白了,就像上一段在 my.worker.js 的代码,this 完全可以换成 self,甚至可以省略。
为便于处理数据,Web Worker 本身也是一个最小化的运行环境,其可以访问或使用如下数据:

  • 最小化的 navigator 对象 包括 onLine, appName, appVersion, userAgentplatform 属性
  • 只读的 location 对象
  • setTimeout(), setInterval(), clearTimeout(), clearInterval() 方法
  • XMLHttpRequest 构造函数

四、Web Worker 的实际使用场景

4.1 Web Worker的最佳使用场景

Web Worker我们可以当做计算器来用,需要用的时候掏出来摁一摁,不用的时候一定要收起来~

  • 加密数据: 有些加解密的算法比较复杂,或者在加解密很多数据的时候,这会非常耗费计算资源,导致UI线程无响应,因此这是使用Web Worker的好时机,使用Worker线程可以让用户更加无缝的操作UI。

  • 预取数据:为了优化网站或者网络应用及提升数据加载时间,你可以使用 Workers 来提前加载部分数据以备不时之需。不像其它技术,Web Workers 在这种情况下是最棒哒,因为它不会影响程序的使用体验。

  • 预渲染: 在某些渲染场景下,比如渲染复杂的canvas的时候需要计算的效果比如反射、折射、光影、材料等,这些计算的逻辑可以使用Worker线程来执行,也可以使用多个Worker线程,这里有个射线追踪的示例

  • 复杂数据处理场景: 某些检索、排序、过滤、分析会非常耗费时间,这时可以使用Web Worker来进行,不占用主线程。

  • 预加载图片: 有时候一个页面有很多图片,或者有几个很大的图片的时候,如果业务限制不考虑懒加载,也可以使用Web Worker来加载图片,可以参考一下这篇文章的探索,这里简单提要一下。

  • 渐进式网络应用: 即使在网络不稳定的情况下,它们必须快速加载。这意味着数据必须本地存储于浏览器中。这时候 IndexDB 及其它类似的 API 就派上用场了。大体上说,一个客户端存储是必须的。为了不阻塞 UI 线程的渲染,这项工作必须由 Web Workers 来执行。呃,当使用 IndexDB的时候,可以不使用 workers 而使用其异步接口,但是之前它也含有同步接口(可能会再次引入 ),这时候就必须在 workers 中使用 IndexDB。 这里需要注意的是在现代浏览器已经不支持同步接口了,具体可查看这里

4.2 Web Worker的实例

4.2.1. 实际工作过程会遇到用户需要通过解析远程图片来获得图片 base64 的案例,那么这时候,如果图片非常大,就会造成 canvas 的 toDataURL 操作相当的耗时,从而阻塞页面的渲染。
所以解决思路即把这里的处理图片的操作交由 worker 来处理。以下贴出主要的代码:

<!DOCTYPE html>
<html lang="zh-cn">
<head>
  <meta charset="UTF-8">
  <title>Canvas to base64</title>
</head>
<body>
  <script>
    function loadImageAsync(url) {
      if (typeof url !== 'string') {
        return Promise.reject(new TypeError('must specify a string'));
      }

      return new Promise(function(resolve, reject) {
        const image = new Image();
        // 允许 canvas 跨域加载图片
        image.crossOrigin="anonymous";
        image.onload = function() {
          const $canvas = document.createElement('canvas');
          const ctx = $canvas.getContext('2d');
          const width = this.width;
          const height = this.height;
          let imageData;
          
          $canvas.width = width;
          $canvas.height = height;
          ctx.drawImage(image, 0, 0, width, height);
          imageData = ctx.getImageData(0, 0, $canvas.width, $canvas.height);
          resolve({image, imageData});
        };

        image.onerror = function() {
          reject(new Error('Could not load image at ' + url));
        };

        image.src = url;
      });
    }
    
    function blobToDataURL(blob) {
      return new Promise((fulfill, reject) => {
        let reader = new FileReader();
        reader.onerror = reject;
        reader.onload = (e) => fulfill(reader.result);
        reader.readAsDataURL(blob);
      })
    }

    document.addEventListener("DOMContentLoaded", function () {
      loadImageAsync('https://cdn-images-1.medium.com/max/1600/1*4lHHyfEhVB0LnQ3HlhSs8g.png')
        .then(function (image) {
          // jpeg-web-worker.js https://github.com/kentmw/jpeg-web-worker
          const worker = new Worker('jpeg-web-worker.js');
          worker.postMessage({
            image: image.imageData,
            quality: 50
          });
          worker.onmessage = function(e) {
            // e.data is the imageData of the jpeg. {data: U8IntArray, height: int, width: int}
            // you can still convert the jpeg imageData into a blog like this:
            const blob = new Blob( [e.data.data], {type: 'image/png'} );
            blobToDataURL(blob).then((imageURL) => {
              console.log('imageUrl:', imageURL);
            })
          }
        })
        .catch(function (err) {
          console.log('Error:', err.message);
        });
    });
  </script>
</body>
</html>

4.2.2 预加载图片


// 主线程
let w = new Worker("js/workers.js");
w.onmessage = function (event) {
  var img = document.createElement("img");
  img.src = window.URL.createObjectURL(event.data);
  document.querySelector('#result').appendChild(img)
}

// worker线程
let arr = [...好多图片路径];
for (let i = 0, len = arr.length; i < len; i++) {
    let req = new XMLHttpRequest();
    req.open('GET', arr[i], true);
    req.responseType = "blob";
    req.setRequestHeader("client_type", "DESKTOP_WEB");
    req.onreadystatechange = () => {
      if (req.readyState == 4) {
      postMessage(req.response);
    }
  }
  req.send(null);
}

在实战的时候注意

  • 虽然使用worker线程不会占用主线程,但是启动worker会比较耗费资源
  • 主线程中使用XMLHttpRequest在请求过程中浏览器另开了一个异步http请求线程,但是交互过程中还是要消耗主线程资源

参考: