Skip to content

使用 Web Worker

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

Web Worker API

一个 worker 是使用一个构造函数创建的一个对象(例如 Worker())运行一个命名的 JavaScript 文件——这个文件包含将在 worker 线程中运行的代码; worker 运行在另一个全局上下文中,不同于当前的window。因此,在 Worker 内通过 window 获取全局作用域(而不是self)将返回错误。

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

worker 线程中你可以运行任何你喜欢的代码,不过有一些例外情况。比如:在 worker 内,不能直接操作 DOM 节点,也不能使用 window 对象的默认方法和属性。但是你可以使用大量 window 对象之下的东西,包括 WebSockets,以及 IndexedDB 等数据存储机制。查看 Web Worker 可以使用的函数和类获取详情。

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

只要运行在同源的父页面中,worker 可以依次生成新的 worker;并且可以使用 XMLHttpRequest 进行网络 I/O,但是 XMLHttpRequestresponseXMLchannel 属性总会返回 null

专用 Worker

如前文所述,一个专用 worker 仅能被生成它的脚本所使用

worker 特性检测

js
if (window.Worker) {
  // 支持
}

创建 worker

调用 Worker() 构造器,指定一个脚本的 URI 来执行 worker 线程(main.js):

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

传递信息和接收消息

通过 postMessage() 方法和 onmessage 事件处理函数触发 worker 的方法。当你想要向一个 worker 发送消息时,你只需要这样做:

js
worker.postMessage('Hello World')

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

js
onmessage = (e) => {
  console.log('Message received from main script')
  const workerResult = `Result: ${e.data[0] * e.data[1]}`
  console.log('Posting message back to main script')
  postMessage(workerResult)
}

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

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

js
worker.onmessage = (e) => {
  console.log('Message received from worker')
  console.log(e.data)
}

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

终止 worker

worker 不再需要时,最好将其终止。你可以通过 terminate() 方法完成这个操作:

js
worker.terminate()

处理错误

worker 内部抛出的任何异常都会被 worker 内部的 onerror 事件处理函数捕获。你可以通过 worker.onerror 属性来访问这个事件处理函数。

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

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

  • message
    可读性良好的错误消息。

  • filename
    发生错误的脚本文件名。

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

生成 subworker

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

引入脚本与库

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

js
importScripts() /* 什么都不引入 */
importScripts('script1.js')
importScripts('script2.js', 'script3.js')
importScripts('https://example.com/script4.js')

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

共享 Worker

一个共享 worker 可以被多个脚本使用——即使这些脚本正在被不同的 windowiframe 或者 worker 访问。

如果共享 worker 可以被多个浏览上下文调用,所有这些浏览上下文必须属于同源(相同的协议,主机和端口号)。

生成一个共享 Worker

要创建一个共享 worker,你只需要在 Worker() 构造函数中指定一个脚本 URI,并在前面加上 shared 关键字:

js
const worker = new SharedWorker('worker.js')

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

在传递消息之前,端口连接必须被显式的打开,打开方式是使用 onmessage 事件处理函数或者 start() 方法。只有一种情况下需要调用 start() 方法,那就是 message 事件被 addEventListener() 方法使用。

port 对象包含以下属性:

  • port.start()
  • port.postMessage()
  • port.onmessage
  • port.close()
  • port.start()
  • port.onmessageerror

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

共享 Worker 的消息传递

共享 worker 的消息传递机制与专用 worker 相同,只是消息是通过 port 对象传递的,而不是 worker 对象。

js
squareNumber.onchange = () => {
  myWorker.port.postMessage([squareNumber.value, squareNumber.value])
  console.log('Message posted to worker')
}

回到 Worker 中,这里有些不一样

js
onconnect = (e) => {
  const port = e.ports[0]
  port.onmessage = (e) => {
    console.log('Message received from main script')
    const workerResult = `Result: ${e.data[0] * e.data[1]}`
    console.log('Posting message back to main script')
    port.postMessage(workerResult)
  }
}

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

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

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

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

js
myWorker.port.onmessage = (e) => {
  result2.textContent = e.data
  console.log('Message received from worker')
}

关于线程安全

Worker 接口会生成真正的操作系统级别的线程,如果你不太小心,那么并发会对你的代码产生有趣的影响。

然而,对于 web worker 来说,与其他线程的通信点会被很小心的控制,这意味着你很难引起并发问题。你没有办法去访问非线程安全的组件或者是 DOM,此外你还需要通过序列化对象来与线程交互特定的数据。所以你要是不费点劲儿,还真搞不出错误来。

内容安全策略

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

html
<head>
  <meta http-equiv="Content-Security-Policy" content="script-src 'self'" />
</head>

CSP (Content Security Policy) 是一种重要的安全机制,用于防止 XSS 攻击。当设置为 script-src 'self' 时,它限制只能执行与页面同源的脚本。

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

为了给 worker 指定内容安全策略,必须为发送 worker 代码的请求本身设置 Content-Security-Policy 响应标头

有一个例外情况,即 worker 脚本的源如果是一个全局性的唯一的标识符(例如,它的 URL 协议为 datablob),worker 则会继承创建它的 document 或者 workerCSP

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

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

让我们创建一个名为 emulateMessage() 的函数,它将模拟在从 worker 到主页面(反之亦然)的通信过程中,变量的“拷贝而非共享”行为:

js
function emulateMessage(vVal) {
  return eval(`(${JSON.stringify(vVal)})`)
}

// Tests

// test #1
const example1 = new Number(3)
console.log(typeof example1) // object
console.log(typeof emulateMessage(example1)) // number

// test #2
const example2 = true
console.log(typeof example2) // boolean
console.log(typeof emulateMessage(example2)) // boolean

// test #3
const example3 = new String('Hello World')
console.log(typeof example3) // object
console.log(typeof emulateMessage(example3)) // string

// test #4
const example4 = {
  name: 'Carina Anand',
  age: 43,
}
console.log(typeof example4) // object
console.log(typeof emulateMessage(example4)) // object

// test #5
function Animal(type, age) {
  this.type = type
  this.age = age
}
const example5 = new Animal('Cat', 3)
console.log(example5.constructor) // Animal
console.log(emulateMessage(example5).constructor) // Object

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

example.html

js
const myWorker = new Worker('my_task.js')

myWorker.onmessage = (event) => {
  console.log(`Worker said : ${event.data}`)
}

myWorker.postMessage('ali')

my_task.js(worker)

js
postMessage("I'm working before postMessage('ali').")

onmessage = (event) => {
  postMessage(`Hi, ${event.data}`)
}

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

传递数据的例子

传输 JSON 的高级方式和创建一个交换系统

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

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

js
function QueryableWorker(url, defaultListener, onError) {
  const instance = this
  const worker = new Worker(url)
  const listeners = {}

  this.defaultListener = defaultListener ?? (() => {})

  if (onError) {
    worker.onerror = onError
  }

  this.postMessage = (message) => {
    worker.postMessage(message)
  }

  this.terminate = () => {
    worker.terminate()
  }
}

写出新增和删除侦听的方法

js
this.addListeners = (name, listener) => {
  listeners[name] = listener
}

this.removeListeners = (name) => {
  delete listeners[name]
}

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

js
// 该函数至少需要一个参数,即我们想要查询的方法名称。
// 然后我们可以传入方法所需的参数。
this.sendQuery = (queryMethod, ...queryMethodArguments) => {
  if (!queryMethod) {
    throw new TypeError('QueryableWorker.sendQuery takes at least one argument')
  }
  worker.postMessage({
    queryMethod,
    queryMethodArguments,
  })
}

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

js
worker.onmessage = (event) => {
  if (
    event.data instanceof Object &&
    Object.hasOwn(event.data, 'queryMethodListener') &&
    Object.hasOwn(event.data, 'queryMethodArguments')
  ) {
    listeners[event.data.queryMethodListener].apply(instance, event.data.queryMethodArguments)
  } else {
    this.defaultListener.call(instance, event.data)
  }
}

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

js
const queryableFunctions = {
  getDifference(a, b) {
    reply('printStuff', a - b)
  },
  waitSomeTime() {
    setTimeout(() => {
      reply('doAlert', 3, 'seconds')
    }, 3000)
  },
}

function reply(queryMethodListener, ...queryMethodArguments) {
  if (!queryMethodListener) {
    throw new TypeError('reply - takes at least one argument')
  }
  postMessage({
    queryMethodListener,
    queryMethodArguments,
  })
}

// 当主页面直接调用 QueryWorker 的 postMessage 方法时,该方法被调用。
function defaultReply(message) {
  // 做点什么
}

onmessage 方法也就很简单了:

js
onmessage = (event) => {
  if (
    event.data instanceof Object &&
    Object.hasOwn(event.data, 'queryMethod') &&
    Object.hasOwn(event.data, 'queryMethodArguments')
  ) {
    queryableFunctions[event.data.queryMethod].apply(self, event.data.queryMethodArguments)
  } else {
    defaultReply(event.data)
  }
}

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

html
<!doctype html>
<html lang="en-US">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width" />
    <title>MDN Example - Queryable worker</title>
    <script type="text/javascript">
      // QueryableWorker 实例的方法:
      //   * sendQuery(queryable function name, argument to pass 1, argument to pass 2, etc. etc.): 调用一个 Worker 的可查询函数
      //   * postMessage(string or JSON Data): 见 Worker.prototype.postMessage()
      //   * terminate(): 终止 Worker
      //   * addListener(name, function): 添加一个监听器
      //   * removeListener(name): 移除一个监听器
      // QueryableWorker 实例的属性:
      //   * defaultListener: 默认监听器只在 Worker 直接调用 postMessage() 函数时执行
      function QueryableWorker(url, defaultListener, onError) {
        const instance = this
        const worker = new Worker(url)
        const listeners = {}

        this.defaultListener = defaultListener ?? (() => {})

        if (onError) {
          worker.onerror = onError
        }

        this.postMessage = (message) => {
          worker.postMessage(message)
        }

        this.terminate = () => {
          worker.terminate()
        }

        this.addListener = (name, listener) => {
          listeners[name] = listener
        }

        this.removeListener = (name) => {
          delete listeners[name]
        }

        // 这个函数至少需要一个参数,即我们想要查询的方法名称。
        // 然后我们可以传入方法所需的参数。
        this.sendQuery = (queryMethod, ...queryMethodArguments) => {
          if (!queryMethod) {
            throw new TypeError('QueryableWorker.sendQuery takes at least one argument')
          }
          worker.postMessage({
            queryMethod,
            queryMethodArguments,
          })
        }

        worker.onmessage = (event) => {
          if (
            event.data instanceof Object &&
            Object.hasOwn(event.data, 'queryMethodListener') &&
            Object.hasOwn(event.data, 'queryMethodArguments')
          ) {
            listeners[event.data.queryMethodListener].apply(
              instance,
              event.data.queryMethodArguments,
            )
          } else {
            this.defaultListener.call(instance, event.data)
          }
        }
      }

      // 你自定义的 "queryable" worker
      const myTask = new QueryableWorker('my_task.js')

      // 你自定义的 "listeners"
      myTask.addListener('printStuff', (result) => {
        document
          .getElementById('firstLink')
          .parentNode.appendChild(document.createTextNode(`The difference is ${result}!`))
      })

      myTask.addListener('doAlert', (time, unit) => {
        alert(`Worker waited for ${time} ${unit} :-)`)
      })
    </script>
  </head>
  <body>
    <ul>
      <li>
        <a id="firstLink" href="javascript:myTask.sendQuery('getDifference', 5, 3);"
          >What is the difference between 5 and 3?</a
        >
      </li>
      <li>
        <a href="javascript:myTask.sendQuery('waitSomeTime');">Wait 3 seconds</a>
      </li>
      <li>
        <a href="javascript:myTask.terminate();">terminate() the Worker</a>
      </li>
    </ul>
  </body>
</html>

my_task.js(worker 文件):

js
const queryableFunctions = {
  // 示例 1:得到两个数字的差值:
  getDifference(minuend, subtrahend) {
    reply('printStuff', minuend - subtrahend)
  },

  // 示例 2:等待三秒
  waitSomeTime() {
    setTimeout(() => {
      reply('doAlert', 3, 'seconds')
    }, 3000)
  },
}

// 系统函数

function defaultReply(message) {
  // 你的默认 PUBLIC 函数只在主页面直接调用 queryableWorker.postMessage() 方法时执行。
  // 做点什么
}

function reply(queryMethodListener, ...queryMethodArguments) {
  if (!queryMethodListener) {
    throw new TypeError('reply - not enough arguments')
  }
  postMessage({
    queryMethodListener,
    queryMethodArguments,
  })
}

onmessage = (event) => {
  if (
    event.data instanceof Object &&
    Object.hasOwn(event.data, 'queryMethod') &&
    Object.hasOwn(event.data, 'queryMethodArguments')
  ) {
    queryableFunctions[event.data.queryMethod].apply(self, event.data.queryMethodArguments)
  } else {
    defaultReply(event.data)
  }
}