Web Worker 学习笔记

90 阅读3分钟

概念

Web Worker 使得在一个独立于 Web 应用程序主执行线程的后台线程中运行脚本操作成为可能。这样做的好处是可以在独立线程中执行费时的处理任务,使主线程(通常是 UI 线程)的运行不会被阻塞/放慢。

用法

不能在worker线程中运行的代码

  • 操作dom元素
  • 使用window对象中的某些方法和属性

worker 中可用的函数和接口

专用worker

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <div>
      <input id="first" />
      <input id="second" />
      <div id="result" />
    </div>
    <script>
      if (window.Worker) {
        const myWorker = new Worker('worker.js');

        const first = document.getElementById('first');
        const second = document.getElementById('second');
        const result = document.getElementById('result');
        first.onchange = () => {
          // 在主线程中使用时,`onmessage` 和 `postMessage()` 必须挂在 `worker` 对象上
          myWorker.postMessage([first.value, second.value]);
          console.log('Message posted to worker');
        };
        second.onchange = () => {
          myWorker.postMessage([first.value, second.value]);
          console.log('Message posted to worker');
        };

        // 在主线程中使用时,`onmessage` 和 `postMessage()` 必须挂在 `worker` 对象上
        myWorker.onmessage = (e) => {
          console.log('Message received from worker', e);
          result.textContent = e.data;
        };
      }
    </script>
  </body>
</html>

worker.js

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

在主线程中使用时,onmessage 和 postMessage() 必须挂在 worker 对象上,而在 worker 中使用时不用这样做。原因是,在 worker 内部,worker 是有效的全局作用域。

当一个消息在主线程和 worker 之间传递时,它被复制或者转移了,而不是共享。

终止worker

myWorker.terminate();

worker 线程会被立即终止。不会执行。

处理错误

TODO

生成subworker

引入脚本和库

importScripts(); /* 什么都不引入 */
importScripts("foo.js"); /* 只引入 "foo.js" */
importScripts("foo.js", "bar.js"); /* 引入两个脚本 */
importScripts("//example.com/hello.js"); /* 你可以从其他来源导入脚本 */

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

共享worker

index.html

if (window.SharedWorker) {
    const myWorker = new SharedWorker('worker.js');

    const first = document.getElementById('first');
    const second = document.getElementById('second');
    const result = document.getElementById('result');
    first.onchange = () => {
      myWorker.port.postMessage([first.value, second.value]);
      console.log('Message posted to worker');
    };
    second.onchange = () => {
      myWorker.port.postMessage([first.value, second.value]);
      console.log('Message posted to worker');
    };

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

index2.html

if (window.SharedWorker) {
    const myWorker = new SharedWorker('worker.js');

    const first = document.getElementById('first');
    const second = document.getElementById('second');
    const result = document.getElementById('result');
    first.onchange = () => {
      myWorker.port.postMessage([first.value, second.value]);
      console.log('Message posted to worker');
    };
    second.onchange = () => {
      myWorker.port.postMessage([first.value, second.value]);
      console.log('Message posted to worker');
    };

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

worker.js

onconnect = (e) => {
    const port = e.ports[0];
    port.onmessage = (e) => {
        const wrokerResult = `Result: ${e.data[0] * e.data[1]}`;
        port.postMessage(wrokerResult);
    };
};

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

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

线程安全

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

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

内容安全策略

worker 并不受限于创建它的 document(或者父级 worker)的内容安全策略

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

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

worker中的数据接收与发送

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

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script>
      function QueryableWorker(url, defaultListener, onError) {
        const instance = this;
        const worker = new Worker(url);
        const listeners = {};

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

        if (onError) {
          window.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 = (e) => {
          if (
            e.data instanceof Object &&
            Object.hasOwn(e.data, 'queryMethodListener') &&
            Object.hasOwn(e.data, 'queryMethodArguments')
          ) {
            listeners[e.data.queryMethodListener].apply(
              instance,
              e.data.queryMethodArguments
            );
          } else {
            this.defaultListener.call(instance, e.data);
          }
        };
      }

      const myTask = new QueryableWorker('my_task.js');

      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>

    <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

const queryableFunctions = {
    getDifference(minuend, substrahend) {
        reply('printStuff', minuend - substrahend);
    },

    waitSomeTime() {
        setTimeout(() => {
            reply('doAlert', 3, 'seconds');
        }, 3000);
    },
};

function defaultReply(message) {

};

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

    postMessage({
        queryMethodListener,
        queryMethodArguments,
    });
};

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

通过转让所有权(可转移对象)来传递数据

现代浏览器包含另一种性能更高的方法来将特定类型的对象传递给一个 worker 或从 worker 传回。可转移对象从一个上下文转移到另一个上下文而不会经过任何拷贝操作。这意味着当传递大型数据集时会获得极大的性能提升。

例如,当你将一个 ArrayBuffer 对象从主应用转让到 Worker 中,原始的 ArrayBuffer 被清除并且无法使用。它包含的内容会(完整无差的)传递给 Worker 上下文。

// 创建一个 32MB 的“文件”,用从 0 到 255 的连续数值填充它——32MB = 1024 * 1024 * 32
const uInt8Array = new Uint8Array(1024 * 1024 * 32).map((v, i) => i);
worker.postMessage(uInt8Array.buffer, [uInt8Array.buffer]);

嵌入式 worker

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <script type="text/js-worker">
      const myVar = 'Hello World';
    </script>
    <script>
      function pageLog(sMsg) {
        const frag = document.createDocumentFragment();
        frag.appendChild(document.createTextNode(sMsg));
        frag.appendChild(document.createElement('br'));
        document.querySelector('#logDisplay').appendChild(frag);
      }
    </script>
    <script type="text/js-worker">
      // 该脚本不会被 JS 引擎解析,因为它的 mime-type 是 text/js-worker。
      onmessage = (event) => {
        postMessage(myVar);
      };
      // 剩下的 worker 代码写到这里。
    </script>
    <script>
      const blob = new Blob(
        Array.prototype.map.call(
          document.querySelectorAll('script[type="text\/js-worker"]'),
          (script) => script.textContent,
          { type: 'text/javascript' }
        )
      );

      document.worker = new Worker(URL.createObjectURL(blob));

      document.worker.onmessage = (e) => {
        pageLog(`Received: ${e.data}`);
      };

      window.onload = () => {
        document.worker.postMessage('');
      };
    </script>
  </head>
  <body>
    <div id="logDisplay"></div>
  </body>
</html>

Web Workers 可以使用的函数和类

Worker 上下文和函数

Worker 在另一个全局上下文中运行,与当前的 window 不同!

Window 并不直接在 worker 中可用,其中的很多方法都通过共享的混入(WindowOrWorkerGlobalScope)定义,并通过 worker 派生的 WorkerGlobalScope 上下文提供这些方法:

一些函数在所有的 worker 和主线程中均可用(来自 WindowOrWorkerGlobalScope):

以下函数在 worker 中可用:

Worker 中可用的 Web API