5.1-Web Worker
Create by fall on 22 Nov 2020
Recently revised in 20 Sep 2023
Web Worker
一套 API ,允许一段 JavaScript 运行在主线程之外的线程,线程可以执行任务而不干扰用户界面。
Web Worker 定义了两类工作线程,专用线程(Dedicated Worker)和共享线程(Shared Worker)
- 专用线程:只能为一个页面所使用
 - 共享线程:被多个页面共享
 
浏览器多线程
多线程的方法分为以下三种
- worklet(渲染时可使用的钩子)
 - Service worker(网络代理钩子)
 - Web worker(减轻主线程工作量)
 
Worklet 是浏览器渲染流中的钩子,可以让我们有浏览器渲染线程中底层的权限,比如样式和布局。
Service worker 是浏览器和网络间的代理。通过拦截文档中发出的请求,service worker 可以直接请求缓存中的数据,达到离线运行的目的。
Web worker 是通常目的是让我们减轻主线程中的密集处理工作的脚本。
- 协程是一种比线程更加轻量级的存在,协程可以看成是跑在线程上的任务,一个线程可以存在多个协程,但是同时只能执行一个协程,如果 A 协程启动 B 协程,A 为 B 的父协程;
 - 协程不被操作协同内核所管理,而完全由程序所控制,这样可以实现性能提升;
 
创建 Worker
创建 worker 和向 worker 中发送信息
let worker = new Worker('worker.js')
// worker.js 就是线程需要执行的任务,由于不能读取本地文件,这个文件必须来自网络
// worker.js 中的代码
var i = 0;
function timedCount(){
    i = i+1;
    postMessage(i); // worker内的独有函数,负责和主页面通信
    setTimeout(timedCount, 1000);
}
timedCount();
通过 URL.createObjectURL() 创建 URL 对象,可以实现 worker 的内嵌
var myTask = `
    var i = 0;
    function timedCount(){
        i = i+1;
        postMessage(i);
        setTimeout(timedCount, 1000);
    }
    timedCount();
`
var blob = new Blob([myTask])// 将 myTask 转换为二进制,注意这个中括号
var myWorker = new Worker(URL.createObjectURL(blob));
// 会开启一个线程并且执行 myTask中的代码
线程通信
线程通信通过两种方式实现
- message 事件,
window.addEventListener('message',fun) - postMessage
 
传递的数据是通过拷贝,而不是共享来完成的,只能单纯的传递数据。意味着传递给 Worker 处理的对象需要经过序列化,另一端需要反序列化。并且传递的数据是拷贝生成的副本,修改不会影响另一端接受的数据。
const complexTask = `
	onmessage = funciton(e){
		const data = e.data
		data.push('hello')
		console.log('worker线程的data')
		console.log(data)
		postMessage(data)
}`
const blob = new Blob([myTask])
var myWorker = new Worker(window.URL.createObjectURL(blob))
myWorker.onmessage = function(e){
  const data = e.data
  console.log('page:',data)
  console.log('arr:',arr)
}
const arr = [1,2,3]
myWorker.postMessage(arr)
使用
基本使用
调用 Worker() 构造函数,会构建一个 Worker 线程
var jsUrl = 'worker.js'
var options ={ name : 'myWorker' }
var myWorker = new Worker(jsUrl, options);
接受信息
worker.postMessage('hello world')
// 主线程通过 worker.postMessage 向 worker 发消息
worker.postMessage({method: 'echo', args: ['Work']});
worker.onmessage() 进行接收数据
worker.onmessage() // 用于接收信息
worker.onmessage = function (event) {
  console.log('Received message ' + event.data);
  doSomething();
}
function doSomething() {
  // 执行任务
  worker.postMessage('Work done!');
}
使用完成后进行关闭
worker.terminate()
API
浏览器原生提供Worker()构造函数,用来供主线程生成 Worker 线程。
var myWorker = new Worker(jsUrl, options);
// 第一个参数是脚本的网址,且必须遵守同源政策,且只能加载 JS 脚本(必需)
// 第二个参数是配置对象,该对象可选。它的一个作用就是指定 Worker 的名称,用来区分多个 Worker 线程。
// 示例
var myWorker = new Worker('worker.js', { name : 'myWorker' });
实例方法
let worker = new Worker('worker.js', { name : 'myWorker' });
worker.onerror// 指定 error 事件的监听函数。
worker.onmessage// message 事件的监听函数,发送过来的数据在Event.data属性中。
worker.onmessageerror// 指定 messageerror 事件的监听函数。发送的数据无法序列化成字符串时,会触发这个事件。
worker.postMessage()// 向 Worker 线程发送消息。
worker.terminate()// 立即终止 Worker 线程。
县城上的API
Web Worker 有自己的全局对象,不是主线程的window,而是一个专门为 Worker 定制的全局对象。因此定义在window上面的对象和方法不是全部都可以使用。
self.name           // worker 的名字。该属性只读,由构造函数指定。
self.onmessage      // 指定message事件的监听函数。
self.onmessageerror
// 指定 messageerror 事件的监听函数。发送的数据无法序列化成字符串时,会触发这个事件。
self.close()        //关闭 Worker 线程。
self.postMessage()  //向产生这个 Worker 线程发送消息。
self.importScripts()//加载 JS 脚本。
Worker 线程
// self代表子线程自身,即子线程的全局对象。
self.addEventListener('message',function(ev){
  self.postMessage('you said', ev.data)
},false) 
根据主线程发过来的数据,worker线程可以调用不同的方法
self.addEventListener('message', function (e) {
  var data = e.data;
  switch (data.cmd) {
    case 'start':
      self.postMessage('WORKER STARTED: ' + data.msg);
      break;
    case 'stop':
      self.postMessage('WORKER STOPPED: ' + data.msg);
      self.close(); // Terminates the worker.
      break;
    default:
      self.postMessage('Unknown command: ' + data.msg);
  };
}, false);
加载脚本
如果想要在 service 内部加载脚本,可以使用 importScript()
importScripts('script1.js');
// 可以同时加载多个脚本
importScripts('script1.js', 'script2.js');
错误处理
worker.onerror(function (event) {
  console.log([
    'ERROR: Line ', e.lineno, ' in ', e.filename, ': ', e.message
  ].join(''));
});
// 或者
worker.addEventListener('error', function (event) {
  // ...
});
数据通信
处理 service worker 时,浏览器内部的运行机制是,先将通信内容串行化,然后把串行化后的字符串发给 Worker,后者再将它还原。
通信内容,可以是文本,也可以是对象。但并不是普通的传址,而是拷贝。
var uInt8Array = new Uint8Array(new ArrayBuffer(10));
for (var i = 0; i < uInt8Array.length; ++i) {
  uInt8Array[i] = i * 2; // [0, 2, 4, 6, 8,...]
}
worker.postMessage(uInt8Array);
// Worker 线程
self.onmessage = function (e) {
  var uInt8Array = e.data;
  postMessage('Inside worker.js: uInt8Array.toString() = ' + uInt8Array.toString());
  postMessage('Inside worker.js: uInt8Array.byteLength = ' + uInt8Array.byteLength);
};
拷贝方式发送二进制数据,会造成性能问题。大文件传输的话,需要额外使用线程去解决,并且进行传输。了解决这个问题,JavaScript 允许主线程把二进制数据直接转移给子线程,但是一旦转移,主线程就无法再使用这些二进制数据了,这是为了防止出现多个线程同时修改数据的麻烦局面。这种转移数据的方法,叫做Transferable Objects。这使得主线程可以快速把数据交给 Worker,对于影像处理、声音处理、3D 运算等就非常方便了,不会产生性能负担。
// Transferable Objects 格式
worker.postMessage(arrayBuffer, [arrayBuffer]);
// 例子
var ab = new ArrayBuffer(1);
worker.postMessage(ab, [ab]);
功能
实现 webworker 和 js 在同一个界面
嵌入网页的脚本,指定<script>标签的type属性是一个浏览器不认识的值,然后,读取这一段嵌入页面的脚本,用 Worker 来处理。
<script id="worker" type="app/worker">
    addEventListener('message', function () {
        postMessage('some message');
      }, false);
</script>
先将嵌入网页的脚本代码,转成一个二进制对象,然后为这个二进制对象生成 URL,再让 Worker 加载这个 URL。这样就做到了,主线程和 Worker 的代码都在同一个网页上面。
var blob = new Blob([document.querySelector('#worker').textContent]);
var url = window.URL.createObjectURL(blob);
var worker = new Worker(url);
worker.onmessage = function (e) {
  // e.data === 'some message'
};
worker 实现轮询
需要查看服务器状态,以便第一时间获取状态的改变。
以下代码实现Worker 每秒钟轮询一次数据,然后跟缓存做比较。如果不一致,就说明服务端有了新的变化,因此就要通知主线程。
function createWorker(f) {
  var blob = new Blob(['(' + f.toString() +')()']);
  var url = window.URL.createObjectURL(blob);
  var worker = new Worker(url);
  return worker;
}
var pollingWorker = createWorker(function (e) {
  var cache;
  function compare(new, old) {  }; // {...}
  setInterval(function () {
    fetch('/my-api-endpoint').then(function (res) {
      var data = res.json();
      if (!compare(data, cache)) {
        cache = data;
        self.postMessage(data);
      }
    })
  }, 1000)
})
pollingWorker.onmessage = function () {
  // render data
}
pollingWorker.postMessage('init');
嵌套worker
Worker 线程内部还能再新建 Worker 线程(目前只有 Firefox 浏览器支持)。下面的例子是将一个计算密集的任务,分配到10个 Worker。
var worker = new Worker('worker.js');
worker.onmessage = function (event) {
  document.getElementById('result').textContent = event.data;
};
下面代码 将在 Worker 线程内部新建了10个 Worker 线程,并且依次向这10个 Worker 发送消息,告知了计算的起点和终点。
// worker.js
// settings
var num_workers = 10;
var items_per_worker = 1000000;
// start the workers
var result = 0;
var pending_workers = num_workers;
for (var i = 0; i < num_workers; i += 1) {
  var worker = new Worker('core.js');
  worker.postMessage(i * items_per_worker);
  worker.postMessage((i + 1) * items_per_worker);
  worker.onmessage = storeResult;
}
// handle the results
function storeResult(event) {
  result += event.data;
  pending_workers -= 1;
  if (pending_workers <= 0)
    postMessage(result); // finished!
}
需要执行的代码
// core.js
var start;
onmessage = getStart;
function getStart(event) {
  start = event.data;
  onmessage = getEnd;
}
var end;
function getEnd(event) {
  end = event.data;
  onmessage = null;
  work();
}
function work() {
  var result = 0;
  for (var i = start; i < end; i += 1) {
    // perform some complex calculation here
    result += 1;
  }
  postMessage(result);
  close();
}
高效传输大量数据
Web Worker 与主线程之间通信依赖于 postMessage API。
- 会通过 结构化克隆算法(structured clone algorithm) 将数据从一侧“复制”到另一侧。
 - 对于大对象,这种克隆代价昂贵(CPU 和内存都会飙升)。
 
最佳方案就是使用 Transferable Objects,部分对象,比如 ArrayBuffer、MessagePort、ImageBitmap 可以通过转移,而不是拷贝传输,避免 GC 和内存压力
// 主线程
const worker = new Worker('worker.js');
// 创建一个 10MB 的 buffer
const buffer = new ArrayBuffer(10 * 1024 * 1024);
worker.postMessage(buffer, [buffer]); // ✅ 使用 transfer list
console.log(buffer.byteLength); // 0,buffer 已转移,不再可用
self.onmessage = (event) => {
  const buffer = event.data;
  // buffer 是直接拥有的,不是拷贝的
  // 可以进一步处理,然后再传回主线程
  self.postMessage(buffer, [buffer]); // 再次转移回主线程
};
参考文章
| 作者 | 链接 | 
|---|---|
| 考拉海购前端团队 | https://juejin.cn/post/6844903496550989837 | 
| MDN官方文档 | https://developer.mozilla.org/zh-CN/docs/Web/API/Service_Worker_API/Using_Service_Workers | 
| 张驰Terry | https://juejin.cn/post/6979758006711877640 |