Web Worker介绍及使用案例

2022-06-27 19:31:12 浏览数 (2)

简介

JavaScript 语言采用的是单线程模型,也就是说,所有任务只能在一个线程上完成,一次只能做一件事。随着电脑计算能力的增强,尤其是多核CPU的出现,单线程带来很大的不便,无法充分发挥计算机的计算能力。

Web Worker 的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。等到Worker线程完成计算任务,再把结果返回给主线程。这样的好处是,一些计算密集型或高延迟的任务,被 Worker 线程负担了,主线程(通常负责UI交互)就会很流畅,不会被阻塞或拖慢。

Worker 线程一旦新建成功,就会始终运行,不会被主线程上的活动(比如用户点击按钮)打断。这样有利于随时响应主线程的通信。但是,这也造成了 Worker 比较浪费资源,不应该过渡使用,而且一旦使用完毕,就应该关闭。

值得注意的是,Worker 与 JavaScript 的异步编程有着本质区别。异步编程是利用 Event Loop 机制,将异步回调的任务暂时放在后面的任务队列中,等 JavaScript 引擎执行完前面的所有任务之后,再对其进行执行,其本质还是单线程,如果回调任务需要消耗较多资源,一样会阻塞后面等待的任务;但 Worker 可以开启一个独立于主线程的线程,二者互不干扰;Worker 线程执行完任务之后,直接把计算结果返回给主线程即可。下图是 Web Worker 和主线程之间的通信方式:

用途

Web Worker 的意义在于可以将一些耗时的数据处理操作从主线程中剥离,使主线程更加专注于页面的渲染和交互。基于 Web Worker 的特性,以下场景可以考虑使用 Web Worker:

  • 懒加载
  • 文本分析
  • Canvas、WebGL 图形绘制
  • 图像处理
  • 当需要执行一个不断向后台发送更新请求的时候,可以将这个过程放在 Worker 线程处理,只把结果返回主线程

主要API及使用方法

前面铺垫了这么多,那么Web Worker到底怎么用呢?先来个小demo体验下,跟着我做个简易(简陋)的计数器来看看Worker是怎么创建的,以及Worker线程和主线程之间是怎么通信的

1. 在本地新建项目目录

2. 在根目录创建 index.html 作为主页面

代码语言:javascript复制
<!-- index.html -->
<!DOCTYPE html>
<html>
    <head>
        <title>计数器</title>
    </head>
    <body>
        <p>计数: <output id="result"></output></p>
        <button onclick="startWorker()">开始 Worker</button> 
        <button onclick="stopWorker()">停止 Worker</button>
        <br /><br />
        <script>
            var w;
            var workerTimeout;

            function startWorker()
            {
                // 检测浏览器是否兼容 Web Worker
                if(typeof(Worker)!=="undefined") {
                    // 实例化 Worker
                    w=new Worker("/static/worker.js");
                    // 主线程 接受 Worker 传来的信息
                    w.onmessage = function (event) {
                        document.getElementById("result").innerHTML=event.data.num;
                        workerTimeout = event.data.workerTimeout;
                    };
                }
                else {
                    document.getElementById("result").innerHTML="Sorry, your browser does not support Web Workers...";
                }
            }

            function stopWorker()
            { 
                w.terminate();
                clearTimeout(workerTimeout);
            }
        </script>
    </body>
</html>

3. 在根目录创建 static 目录,里面新建 Worker 线程的脚本文件 worker.js:

代码语言:javascript复制
// worker.js
var i=0;

function timedCount (){
  i=i 1;
  var workerTimeout = setTimeout("timedCount()",500);
  // Worker 线程向主线程发送消息
  postMessage({
    num: i,
    workerTimeout: workerTimeout
  });
}

timedCount();

4. 由于实例化 Worker 的时候,不支持传入本地 file:// 路径下的脚本文件,必须读取网络上的文件,因此在这里我们简单地在本地起一个 node 服务来处理 Worker 脚本的读取问题;这里推荐使用 Koa 进行服务搭建—— Koa 是一个新的 web 框架,由 Express 幕后的原班人马打造,致力于成为 web 应用和API开发领域中的一个更小、更富有表现力、更健壮的基石。

  • 首先初始化我们的项目,在根目录执行 npm init -y
  • 安装 koa 和 koa-static( koa-static 用来管理服务端的静态资源):npm install koa koa-static
  • 全局安装 hotnode 模块儿,用于热更新:npm install -g hotnode
  • 在根目录新建 app.js 文件,搭建 node 服务
代码语言:javascript复制
// app.js
const Koa = require('koa');
const app = new Koa();
const path = require('path');
const serve = require('koa-static');

// 管理静态资源
app.use(serve(__dirname))

const fs = require('fs');

app.use(async (ctx, next) => {
    ctx.type = 'text/html';
    // 渲染主页面
    ctx.body = fs.createReadStream('./index.html');
});

app.listen(8000);
  • 在 package.json 里添加启动快捷命令
代码语言:javascript复制
// package.json
{
  "name": "worker-demo-simple",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo "Error: no test specified" && exit 1",
    "start": "hotnode app.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "koa": "^2.13.0",
    "koa-static": "^5.0.0"
  }
}

至此,我们的“计数器”小项目就搭建完成了,赶紧在根目录下执行 npm start 跑起来试试看吧,效果如下:

在本地访问 localhost:8000 即可看到我们用 Web Worker 创建的“计数器”,点击“开始 Worker”,计数值会以大约500ms的时间间隔递增,点击“停止 Worker”即可注销 Worker。

前面的 index.html 和 worker.js 中包含了 Web Worker 最基础的API用法;其中,在主线程使用 new 操作符,调用 Worker() 构造函数,可以新建一个 Worker 线程;Worker() 构造函数的参数是一个脚本文件,该文件就是 Worker 线程所要执行的任务。由于Worker读取的脚本必须来自网络,demo 中的 js 脚本放在本地的 node 服务器中。

Worker 线程调用 postMessage() 方法,可以向主线程发送消息,消息内容可以是各种数据类型,包括二进制数据。同样,主线程也可以调用 worker.postMessage() 方法,向 Worker 线程发送消息。

主线程可以通过 worker.onmessage() 方法监听 message 事件,以获取 Worker 线程传来的消息;同理 Worker 线程也可以使用 self.onmessage() 方法监听 message 事件来获取主线程传来的消息。

如果想关闭 Worker 线程,可以在主线程调用 worker.terminate(),或者在 Worker 线程调用 self.close() 来关闭 Worker。这两种方法是等效的,但比较推荐的用法是在 Worker 线程里通过 self.close() 关闭 Worker,以防止在主线程意外关闭正在运行的 Worker。

有了这几个基本的 API,就可以实现简单的 Worker 线程与主线程之间的通信了,完整的 Web Worker API 请移步 MDN。

在Canvas中的应用

什么?到现在为止还没看到 Web Worker 实际的功效?别急,跟着我再做个demo,咱们一起见证下 Web Worker 强大的多线程功效吧。

之前实习的时候所在团队是做地图 api 的,其中 3D 地图是使用 WebGL 技术进行绘制的。熟悉 WebGL 的应该了解,不论多么复杂的图形或模型,最终都是通过将其分解为若干三角形组成,WebGL 地图也是这样实现的,这个过程称为 Triangulation,即三角形化。然而,在图形元素过多、数据量较大的情况下,分解过程会比较耗时,在 JavaScript 单线程渲染的时候可能会阻塞页面的其他行为,因此 WebGL 地图引擎采用 Web Worker 处理数据。

鉴于 Web Worker 在图形渲染上的妙用,接下来我们用一个 canvas 绘制的例子来直观看一下使用 Web Worker 渲染和主线程直接渲染 canvas 的性能差异,该处用到了 OffscreenCanvas;到目前为止,Canvas 的绘制功能都与 <canvas> 标签绑定在一起,这意味着 Canvas API 和 DOM 是耦合的。而 OffscreenCanvas,正如它的名字一样,通过将 Canvas 移出屏幕来解耦了 DOM 和 Canvas API。由于这种解耦,OffscreenCanvas 的渲染与 DOM 完全分离了开来,并且比普通 Canvas 速度提升了一些,而这只是因为两者(Canvas和DOM)之间没有同步。但更重要的是,将两者分离后,Canvas 将可以在 Web Worker 中使用,即使在 Web Worker 中没有 DOM。这给 Canvas 提供了更多的可能性。

项目结构:

1. 与“计数器”中的 node 服务搭建方式一样,用 koa 搭建 node 服务层

代码语言:javascript复制
// app.js
const Koa = require('koa');
const app = new Koa();
const path = require('path');
const serve = require('koa-static');

app.use(serve(__dirname))

const fs = require('fs');

app.use(async (ctx, next) => {
    ctx.type = 'text/html';
    ctx.body = fs.createReadStream('./OffscreenCanvas.html');
});

app.listen(8000);

2. 搭建 OffscreenCanvas.html 主页面:

代码语言:javascript复制
<!-- OffscreenCanvas.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Offscreen Canvas In Web Workers</title>
    <link rel="stylesheet" href="./OffscreenCanvas_files/style.css">
  </head>
  <body>
    <header class="hide-in-iframe">
      <h1>
        Offscreen Canvas In Web Workers
      </h1>
      <div class="desc">
        离屏 Canvas 允许在屏幕外创建 canvas ,并且也可以用在 web workers 中
      </div>
    </header>
    <main class="supported">
      <section>
        <p class="hide-in-iframe">
          OffscreenCanvas 可以避免由于主线程阻塞引起的动画掉帧
        </p>
        <p class="desc">
          当您点击 "make me busy" 按钮时, 主线程画布上的动画被阻塞,而工作在 worker 线程中的动画仍然可以平稳播放
        </p>
        <button id="make-busy">Make me busy!</button>
        <div id="busy">&nbsp;</div>

      <div class="display">
        <div>
          <h1>
            主线程的 Canvas
          </h1>
          <canvas id="canvas-window" width="400" height="200"></canvas>
        </div>
        <div>
          <h1>
            worker 中的 Canvas
          </h1>
          <canvas id="canvas-worker" width="400" height="200"></canvas>
        </div>
        </div>
      </section>
    </main>
    <script src="./OffscreenCanvas_files/animation.js"></script>
    <!--
      这里是以 Blob 方式引入 worker 线程的方法
    -->

    <!-- <script type="script/worker" id="workerCode">
      let animationWorker = null;
      self.onmessage = function(e) {
        switch (e.data.msg) {
          case 'start':
            if (!animationWorker) {
              importScripts(e.data.origin   'OffscreenCanvas_files/animation.js');
              animationWorker = new Animation(e.data.canvas.getContext('2d'));
            }
            animationWorker.start();
            break;
          case 'stop':
            animationWorker.stop();
            break;
        }
      };
    </script> -->
    <script>
      (() => {

        document.querySelector('#make-busy').addEventListener('click', () => {
          document.querySelector('#busy').innerText = 'Main thread working...';
          requestAnimationFrame(() => {
            requestAnimationFrame(() => {
              Animation.fibonacci(40);
              document.querySelector('#busy').innerText = 'Done!';
            });
          })
        });

        const animationWindow = new Animation(document.querySelector('#canvas-window').getContext('2d'));
        animationWindow.start();

        const worker = new Worker('OffscreenCanvas_files/worker.js')

        // 由于 web worker 无法以本地 file 协议加载文件,因此也可以以 Blob 的形式加载 worker 代码:

        // const workerCode = document.querySelector('#workerCode').textContent;
        // const blob = new Blob([workerCode], { type: 'text/javascript' });
        // const url = URL.createObjectURL(blob);
        // const worker = new Worker(url);

        const offscreen = document.querySelector('#canvas-worker').transferControlToOffscreen();
        const urlParts = location.href.split('/');
        if (urlParts[urlParts.length - 1].indexOf('.') !== -1) {
          urlParts.pop();
        }
        worker.postMessage({ msg: 'start', origin: urlParts.join('/'), canvas: offscreen }, [offscreen]);
        // URL.revokeObjectURL(url); // cleanup
      })();
    </script>
  </body>
</html>

3. 创建 Worker 线程文件 worker.js

代码语言:javascript复制
// worker.js
let animationWorker = null;
self.onmessage = function(e) {
    // console.log(e.data);
    switch (e.data.msg) {
    case 'start':
        if (!animationWorker) {
        importScripts(e.data.origin   'OffscreenCanvas_files/animation.js');
        animationWorker = new Animation(e.data.canvas.getContext('2d'));
        }
        animationWorker.start();
        break;
    case 'stop':
        animationWorker.stop();
        break;
    }
};

4. 创建渲染 canvas 动画的类

代码语言:javascript复制
// animation.js
class Animation {
  constructor(ctx) {
    this.ctx = ctx;
    this.x = ctx.canvas.width / 2;
    this.y = ctx.canvas.height / 2;
    this.rMax = Math.min(this.x - 20, this.y - 20, 60);
    this.r = 40;
    this.grow = true;
    this.run = true;

    this.boundAnimate = this.animate.bind(this);
  }

  static fibonacci(num) {
    return (num <= 1) ? 1 : Animation.fibonacci(num - 1)   Animation.fibonacci(num - 2);
  }

  drawCircle() {
    this.ctx.beginPath();
    this.ctx.arc(this.x, this.y, this.r, 0, 2 * Math.PI, false);
    this.ctx.fill();
  };

  animate() {
    if (!this.run) {
      return;
    }
    this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
    if (this.r === this.rMax || this.r === 0) {
      this.grow = !this.grow;
    };
    this.r = this.grow ? this.r   1 : this.r - 1;
    this.drawCircle();
    requestAnimationFrame(this.boundAnimate);
  }

  stop() {
    this.run = false;
  }

  start() {
    this.run = true;
    this.animate();
  }
}

好了,展示 Canvas 动效的 demo 已经完成,在根目录执行 npm start 把项目跑起来看看吧,在 localhost:8000 访问项目:

在该 demo 中,左下角窗口展示的是主线程的 Canvas 动画,右下角展示的是 Worker 线程的 Canvas 动画;当我们点击“MAKE ME BUSY”按钮时,主线程会执行一次斐波那契数列运算:

代码语言:javascript复制
// animation.js
static fibonacci(num) {
  return (num <= 1) ? 1 : Animation.fibonacci(num - 1)   Animation.fibonacci(num - 2);
}

// OffscreenCanvas.html
document.querySelector('#make-busy').addEventListener('click', () => {
  document.querySelector('#busy').innerText = 'Main thread working...';
    requestAnimationFrame(() => {
        requestAnimationFrame(() => {
          Animation.fibonacci(40);
          document.querySelector('#busy').innerText = 'Done!';
        });
    })
});

该递归运算会占用主线程较多的资源,从而短暂地阻塞主线程后续队列里的任务,导致主线程动画会在斐波那契数列运算的过程中卡住,而与此同时 Worker 线程中的动画则可以流畅运行,丝毫不受到主线程阻塞的影响;由此我们不难看出,当页面需要渲染动画,但主线程上有可能执行一些消耗内容比较大的任务时,将动画绘制逻辑放在 Web Worker 中执行,然后将结果返回主线程,这样可以大大提高动画的渲染性能。

使用限制

Web Worker 在使用中,有以下几个需要注意的点:

  • 同源限制:分配给 Worker 线程运行的脚本文件,必须与主线程的脚本文件同源。
  • DOM限制:Worker 线程所在的全局对象,与主线程不一样,无法读取主线程所在网页的 DOM 对象,也无法使用 document、window、parent 这些对象。但是,Worker 线程可以 navigator 对象和 location 对象。详情请移步 MDN。
  • 通信联系:Worker 线程和主线程不在同一个上下文环境,在 Worker 线程中无法直接访问主线程中的数据,同样主线程也无法直接访问 Worker 线程中的数据,二者必须通过消息API进行通信。
  • 脚本限制:Worker 线程不能执行 alert() 方法和 confirm() 方法,但可以使用 XMLHttpRequest 对象发出 AJAX 请求。
  • 文件限制:Worker 线程无法读取本地文件,即不能打开本机的文件系统(file://),它所加载的脚本,必须来自网络。

Worker 从本地读取脚本的一种实现

Web Worker 无法加载本地文件,但是假如我们没有掌握nodejs技术,或者实在懒得把项目放在服务器上,只想单纯地在本地调试 Web Worker,该怎么实现呢?本文提供一种解决方式:通过 Blob() 方式创建,具体步骤如下:

1. 用 script 标签来包裹Worker线程的逻辑代码,同时绑定 id 属性、type 类型(注意:type 类型必须是 js 无法识别的类型)

代码语言:javascript复制
<!-- 这里是以 Blob 方式引入 worker 线程的方法 -->

<script type="script/worker" id="workerCode">
  let animationWorker = null;
  self.onmessage = function(e) {
    switch (e.data.msg) {
      case 'start':
        if (!animationWorker) {
          importScripts(e.data.origin   'OffscreenCanvas_files/animation.js');
          animationWorker = new Animation(e.data.canvas.getContext('2d'));
        }
        animationWorker.start();
        break;
      case 'stop':
        animationWorker.stop();
        break;
    }
  };
</script>

2. 在主线程脚本里构造 Blob,然后通过 URL.createObjectURL 创建一个表示该 Blob 的 URL,并以此 URL 为参数构建 Worker 实例

代码语言:javascript复制
// 由于 web worker 无法以本地 file 协议加载文件,因此也可以以 Blob 的形式加载 worker 代码:

const workerCode = document.querySelector('#workerCode').textContent;
const blob = new Blob([workerCode], { type: 'text/javascript' });
const url = URL.createObjectURL(blob);
const worker = new Worker(url);

这样即可避免 Worker 直接从本地以 file:// 的形式加载脚本,是不是很方便呢~

怎么样,Web Worker 的功能还是很强大的吧,如果您的项目中有需要前端执行大量运算或者绘制 Canvas、WebGL 等图形的 case,不妨将它们迁移在 Web Worker 中试试。

References

Web Worker 文献综述(全):http://km.oa.com/group/38989/articles/show/428931?kmref=search&from_page=1&no=1

Web Workers API:https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers

Web Workers API:https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers

OffscreenCanvas:https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas

0 人点赞