JavaScript
Web Worker

众所周知,JavaScript 是单线程的语言。当我们面临需要大量计算的场景时(比如视频解码等),UI 线程就会被阻塞,甚至浏览器直接卡死。现在前端遇到大量计算的场景越来越多,为了有更好的体验,HTML5 中提出了 Web Worker 的概念。Web Worker 可以使脚本运行在新的线程中,它们独立于主线程,可以进行大量的计算活动,而不会影响主线程的 UI 渲染。当计算结束之后,它们可以把结果发送给主线程,从而形成了高效、良好的用户体验。Web Worker 是一个统称,具体可以细分为普通的 Worker、SharedWorker 和 ServiceWorker 等,接下来我们一一介绍其使用方法和适合的场景。

Web Worker 的使用限制

  • 同源限制
    • 分配给 Worker 线程运行的脚本文件,必须与主线程的脚本文件同源。
  • 文件限制
    • Worker 线程无法读取本地文件(file://),会拒绝使用 file 协议来创建 Worker 实例,它所加载的脚本,必须来自网络。
  • DOM 操作限制:Worker 线程所在的全局对象,与主线程不一样,区别是:
    • 无法读取主线程所在网页的 DOM 对象
    • 无法使用 documentwindowparent 这些对象
  • 通信限制
    • Worker 线程和主线程不在同一个上下文环境,它们不能直接通信,必须通过消息完成,交互方法是 postMessageonMessage ,并且在数据传递的时候, Worker 是使用拷贝的方式。
  • 脚本限制
    • Worker 线程不能执行 alert() 方法和 confirm() 方法,但可以使用 XMLHttpRequest 对象发出 AJAX 请求,也可以使用 setTimeout/setInterval 等 API

普通 Worker

  1. 创建 Worker:通过 new Worker() 方法创建 Worker,传入一个 js 文件的路径,该地址必须和其创建者是同源的,这个文件就是 Worker 线程要执行的代码。
const worker = new Worker("./worker.js");
  1. 向 Worker 发送消息:通过 postMessage() 方法向 Worker 发送消息,可以传递任意数据,包括 JavaScript 对象、字符串、Blob、ArrayBuffer 等。
worker.postMessage("hello");
  1. 接收 Worker 发送的消息:通过 onmessage 事件监听 Worker 发送的消息,接收到消息后会触发该事件。
worker.onmessage = (event) => {
  console.log(event.data);
};
  1. DedicatedWorkerGlobalScope (opens in a new tab) 对象接收到无法反序列化的消息时,会在该对象上触发 messageerror 事件,也可以在主线程上监听该事件。
// worker.js
self.onmessageerror = (event) => {
  self.postMessage("Error receiving message");
  console.error(event);
};
  1. 关闭 Worker:通过 terminate() 方法关闭 Worker。
worker.terminate();

Worker 的作用域跟主线程中的 Window 是相互独立的,并且在 Worker 中你无法使用 Window 变量,所以 Worker 中是获取不到 DOM 元素的。Worker 线程内部的全局对象是 DedicatedWorkerGlobalScope,它有一个 self 属性指向自身,所以在 Worker 线程内部,selfthis 都指向 DedicatedWorkerGlobalScope 对象。

Worker 线程中的 importScripts() 方法可以用来加载脚本,这个方法和主线程中的 importScripts() 方法是一样的,区别在于它会在 Worker 线程中加载脚本。

// Worker.js
importScripts("constant.js");
// 下面就可以获取到 constant.js 中的所有变量了
 
// constant.js
// 可以在 Worker 中使用
const a = 111;
 
// 不可以在 Worker 中使用
const b = function () {
  console.log("test");
};
 
// 可以在 Worker 中使用
function c() {
  console.log("test");
}

不能觉得用 Worker 使用多线程就可以在什么地方都使用了,在简单场景下,如果单线程执行时间很短,那么使用 Worker 可能反而会降低性能,因为创建 Worker 也需要开销,所以在使用 Worker 之前,建议在需要消耗比较多的 cpu 运算能力的时候酌情使用。

SharedWorker

SharedWorker 是一个特殊的 Worker,它可以被多个页面实例共享,它的生命周期是持久的,当最后一个页面关闭时,它才会被关闭。它可以同时被多个浏览器环境访问。这些浏览器环境可以是多个 window, iframes 或者甚至是多个 Worker,只要这些 Workers 处于同一主域。为跨浏览器 tab 共享数据提供了一种解决方案。SharedWorker 的使用方法和普通 Worker 基本一致,只是创建的方式不同,它是通过 new SharedWorker() 方法创建的。

  1. 创建 SharedWorker
const sharedWorker = new SharedWorker("./worker.js");
  1. SharedWorker 的方法

SharedWorker 的方法都在 port 上,这是它与普通 Worker 不同的地方。

  • port.onmessage

主线程中可以在 worker 上添加 onmessage 方法,用于监听 SharedWorker 的信息

sharedWorker.port.onmessage = (event) => {
  console.log(event.data);
};
  • port.postMessage

主线程通过此方法给 SharedWorker 发送消息,发送参数的格式不限

sharedWorker.port.postMessage({ type: "fn", payload: { context: 123 } });
  • port.start

主线程通过此方法开启 SharedWorker 之间的通信

sharedWorker.port.start();
  • port.close

主线程通过此方法关闭 SharedWorker

sharedWorker.port.close();

SharedWorker 跟普通的 Worker 一样,可以用 self 来表示全局对象。不同之处是,它需要等 port 连接成功之后,利用 port 的 onmessage、postMessage,来跟主线程进行通信。当你打开多个窗口的时候,SharedWorker 的作用域是公用的,这也是其特点。

ServiceWorker

ServiceWorker 是运行在浏览器背后的独立线程,它独立于当前页面,不会影响页面的性能,同时也不受页面生命周期的影响。它可以用来实现缓存、消息推送、后台自动更新等功能。

  1. 创建 ServiceWorker
// index.js
if ("serviceWorker" in navigator) {
  window.addEventListener("load", function () {
    navigator.serviceWorker.register("./serviceWorker.js", { scope: "/page/" }).then(
      function (registration) {
        console.log("ServiceWorker registration successful with scope: ", registration.scope);
      },
      function (err) {
        console.log("ServiceWorker registration failed: ", err);
      },
    );
  });
}

只要创建了 ServiceWorker,不管这个创建 ServiceWorker 的 html 是否打开,这个 ServiceWorker 是一直存在的。它会代理范围是根据 scope 决定的,如果没有这个参数,则其代理范围是创建目录同级别以及子目录下所有页面的网络请求。代理的范围可以通过 registration.scope 查看。

  1. 安装 ServiceWorker
// serviceWorker.js
const CACHE_NAME = "cache-v1";
// 需要缓存的文件
const urlsToCache = [
  "/style/main.css",
  "/constant.js",
  "/serviceWorker.html",
  "/page/index.html",
  "/serviceWorker.js",
  "/image/131.png",
];
self.oninstall = (event) => {
  event.waitUntil(
    caches
      .open(CACHE_NAME) // 这返回的是promise
      .then(function (cache) {
        return cache.addAll(urlsToCache); // 这返回的是promise
      }),
  );
};

在上述代码中,我们可以看到,在 install 事件的回调中,我们打开了名字为 cache-v1 的缓存,它返回的是一个 promise。在打开缓存之后,我们需要把要缓存的文件 add 进去,基本上所有类型的资源都可以进行缓存,例子中缓存了 css、js、html、png。如果所有缓存数据都成功,就表示 ServiceWorker 安装成功;如果控制台提示 Uncaught (in promise) TypeError: Failed to execute 'Cache' on 'addAll': Request failed,则表示安装失败。

  1. 缓存和返回请求
self.onfetch = (event) => {
  event.respondWith(
    caches
      .match(event.request) // 此方法从服务工作线程所创建的任何缓存中查找缓存的结果
      .then(function (response) {
        // response为匹配到的缓存资源,如果没有匹配到则返回undefined,需要fetch资源
        if (response) {
          return response;
        }
        return fetch(event.request);
      }),
  );
};

在 fetch 事件的回调中,我们去匹配 cache 中的资源。如果匹配到,则使用缓存资源;没有匹配到则用 fetch 请求。正因为 ServiceWorker 可以代理网络请求,所以为了安全起见,规范中规定它只能在 https 和 localhost 下才能开启。

SharedWorker 和 ServiceWorker 的调试方法:在浏览器中查看和调试 ServiceWorker 的代码,需要输入 chrome://inspect/#service-workers

在单页面应用中使用

reactvue 等单页面应用中,webpack/vite 通常会将 js 代码打包成一个 js 文件。因此通过上面的 new Worker('./worker.js') 的方式来新建 worker ,将会报访问不到 worker.js 的错误。

  • 方案 1:既然 webpack/vite 会将 js 的代码打包成一个 js 文件,那咱们不让它打包不就好了。而单页面应用的工程下,通常都是会有一个 public 的静态资源目录,咱们将 worker.js 放入其中即可。
  • 方案 2:webpack4 及以下的版本可以使用 worker-loader
  • 方案 3:webpack5/vite 则可以使用 new Worker(new URL('worker.js', import.meta.url)) 的方式
import React from "react";
export default function WebWorkerTest() {
  const handleClick = () => {
    const number = 1;
    const workerList = [];
    console.log("%c 开始多线程测试 ", "color:#fff; background:#00897b ");
    for (let i = 0; i < number; i++) {
      const workerItem = new Promise((resolve, reject) => {
        const myWorker = new Worker(new URL("../utils/fb.worker.ts", import.meta.url));
        myWorker.postMessage({
          function: "fb",
          data: 43,
        });
        myWorker.onmessage = (e) => {
          resolve(e.data); // 关闭worker线程
          myWorker.terminate();
        };
      });
      workerList.push(workerItem);
    }
    console.time("worker多线程执行时间");
    Promise.all(workerList).then((res) => {
      console.log(res);
      console.timeEnd("worker多线程执行时间");
    });
  };
  return (
    <>
           <button onClick={handleClick}>vite/webpack5</button>   
    </>
  );
}
// fb.worker.ts
// 方法对象
const funcObj = {
  fb: (n: number): number => {
    if (n === 1 || n === 2) {
      return 1;
    }
    return funcObj.fb(n - 1) + funcObj.fb(n - 2);
  },
};
// onmessage事件
onmessage = function (e) {
  const { data } = e;
  const res = funcObj[data.function](data.data); // 将获取的数据通过postMessage发送到主线程
  self.postMessage({
    data: res,
    name: "worker test",
  });
  self.close();
};

总结

普通的 Worker 可以在需要大量计算的时候使用,创建新的线程可以降低主线程的计算压力,不会导致 UI 卡顿。SharedWorker 主要是为不同的 window、iframes 之间共享数据提供了另外一个解决方案。ServiceWorker 可以缓存资源,提供离线服务或者是网络优化,加快 Web 应用的开启速度,更多是优化体验方面的。

参考

https://juejin.cn/post/7091068088975622175 (opens in a new tab) https://juejin.cn/post/7117774868187185188 (opens in a new tab)