一文带你了解 Web Worker - 前端的“多线程”


前言

众所周知,JavaScript 采用的是单线程模型,即所有任务都在一个线程上完成,一次只能做一件事情。但单线程意味着所有的任务都需要排队,前一个任务结束了,才会执行后一个任务。如果一个任务耗费了太长的时间,后一个任务就一直无法执行。体现在浏览器里就是浏览器卡住了,无法操作。


试一下,把下面的代码粘贴到浏览器console里面,会发现浏览器卡住无法操作。

// 计算斐波那契数列
const fibonacci = (n) => {
    count += 1;
    if (n === 0) return 0;
    if (n === 1) return 1;
    if (n > 1) return fibonacci(n - 1) + fibonacci(n - 2)
}
const time0 = new Date().getTime();
console.log('time0', time0);


fibonacci(40);


const time1 = new Date().getTime();
console.log('time1', time1);
const duration = time1 - time0;
console.log('duration', duration);


// const f = (n) => n > 1 ? f(n - 1) + f(n -2) : n


js为什么是单线程的?

JavaScript 可以操纵 DOM ,如果在修改元素属性同时渲染界面,渲染线程前后获得的元素数据可能不一致。为了防止渲染出现不可预期的结果,浏览器设置GUI渲染线程与JS引擎为互斥关系,当JS引擎执行时GUI线程会被挂起,GUI更新则会被保存在一个队列中等到JS引擎线程空闲时立即被执行。


那么问题来了

如果JS引擎的计算量过大,GUI的更新会进入队列,页面无反应,卡顿感就产生了。所以,我们要尽量避免使用JS执行大量计算。


但在日常的需求中我们不可避免的会有js处理大量计算的场景,这时候 Web Worker 就派上了用场。


概述

什么是Web Worker

Web Worker 是HTML5标准的一部分,他定义了一整套的api允许开发者在js线程之外独立出一个单独的线程,处理额外的js代码。


因为是独立的线程,Web Worker 可以和主线程js同时运行,互不影响。我们可以把复杂且耗时的计算交给 Web Worker 进行,待 Worker 计算完成之后,再交由主线程 js 去消费。这样主线程仅需要关心业务逻辑和页面渲染,不需要把时间耗费在计算上,流畅度可以大大提升。


Web Worker 可以干什么,有什么限制

Web Worker 可以认为是一个独立的js环境,你可以在里面运行任何你喜欢的代码, 除了操作dom或者运行 window 对象中的一些方法和属性。


实际上 Web Worker 没有 window 的概念(也没有 document 对象,所以无法操作 Dom),其运行上下文环境是 WorkerGlobalScope 对象的实例,通过 self 关键字暴露出来。


WorkerGlobalScope 对象上的可用属性是 window 对象的子集,其中有些属性和 window 一致,而有些属性则并不完全相同。


Web Worker 专用工作者线程

Worker 线程使用有些注意点

1. 同源限制

Worker 线程执行的脚本文件(即 上述代码的 worker.js)必须和主线程的文件同源,从其他源加载 Worker 脚本文件会报错。


2. 文件限制

Worker 线程无法读取本地文件,文件需要通过主线程读取到文件之后再传输给 Worker。


3. DOM操作限制

上面提到了,Worker 和主线程在不同的上下文环境运行,无法读取主线程所在的 DOM 对象以及 document 和 window 对象,但 Worker 的全局对象 WorkerGlobalScope 提供了对navigator、location、setTimeOut等浏览器API的访问能力,尽管其中的有些API的属性和 window 上并不相同。


4. 通信限制

Worker 和主线程无法直接通信,需要通过 postMessage 或者 BroadcastChannel 进行通信。


创建 Worker

可以通过将文件路径提供给 Worker 构造函数的方式来创建 专用工作者。options是可选的配置,可以配置 Worker 的一些属性。

// 主线程
const worker = new Worker(jsUrl, options);


options 参数

参数名称

描述

类型

name

worker线程的名称,可以在工作者线程中通过 self.name 获取到字符串标识。

string

type

表示加载脚本的方式,可以是 'classic' 或者'module'。'classic'将脚本作为普通脚本来执行,'module'将脚本作为模块来执行。

'classic' | 'module'

credentials

当type为'module'时,指定如何获取与传输凭证数据(cookie)相关的Web Worker脚本,与fetch的 credentials 属性一致。在type为'classic'时默认为'omit'。

'omit' | 'same-origin' | 'include'


关于 Worker 的初始化脚本

如果是普通项目,直接把初始化文件放在一个文件夹下,可以直接创建 Worker。

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


在 Webpack 项目中,我们需要添加各种 loader 支持新技术,创建 Worker 需要使用worker-loader:

// webpack 4.0
import Worker from 'worker-loader!./worker';


const worker = new Worker();


但Webpack 5.0之后,我们不需要 worker-loader了,于是我们可以这么创建:

const worker = new Worker(new URL('./worker.js', import.meta.url));


此处的 new URL(),可以约等于 nodejs 中的 path.resolve(baserul + './worker.js')。


还有一个简单的解决方案:把 worker 脚本放到 public 文件夹下,这样打包产物就和 worker 脚本在同一个文件夹下,可以正常初始化 Worker。


除了使用脚本文件创建 Worker 之外,我们还可以使用 行内js 来创建工作者线程。通过 Blob 对象 URL 我们可以更快的初始化工作者线程,因为没有网络延迟。

// 创建代码字符串
const workerScriptStr = `
  self.onmessage = (e) => {
    console.log(e.data);
    postMessage('get message from main thread');
  }
`;


// 基于脚本字符串生成Blob对象
const workerBlob = new Blob([workerScriptStr]);


// 基于Blob实例创建对象URL
const workerBlobUrl = URL.createObjectURL(workerBlob);


// 基于对象URL创建专用工作者线程
const worker = new Worker(workerBlobUrl);
worker.postMessage('main thread send message');
// main thread send message


上面的例子是把步骤分解开,一步步的创建 Worker,可以写一块:

const worker = new Worker(URL.createObjectURL(new Blob([`self.onmessage = 
({data}) => console.log(data);`])));


worker.postMessage('main thread send message');
// main thread send message


ES Module

在初始化 Worker 时,如果不传第二个配置参数,默认执行脚本的方式为 'classic',此时在脚本里仅可以通过 Worker 的全局对象 WorkerGlobalScope 提供的 importScripts 方法引用在线脚本。


如果使用 import 关键字引入,会报错 Cannot use import statement outside a module 不允许在 module 外使用 import。

// main.js
const worker = new Worker('worker.js');


// worker.js
// import { sum } from 'lodash'; // Error: Cannot use import statement outside a module
importScripts('https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js');
_.sum([1, 2]);
...


但如果在创建时指定了 type 为 'module':

// main.js
const worker = new Worker('worker.js', { type: 'module' });


// worker.js
import { sum } from 'lodash';
sum([1, 2]);
...


则不会报错,从而可以愉快的使用按需导入能力了。


由于 Web Worker 是一个独立的线程,所以理论上,你可以在Web Worker 里再启用一个 Web Worker 子线程,在有多个CPU核心的时候,使用多个子线程可以实现并行计算,这里就不展开了。


与 Web Worker 通信

与工作者线程通信都是通过 postMessage 方法发送消息,通过 onmessage 事件处理函数来接受消息。数据传输的方式是通过 结构化克隆算法 克隆数据,传递数据副本。


浏览器支持另一种性能更好的对象传输方式 可转移对象(Transferable objects) ,通过可转移对象,资源的所有权会从一个上下文直接转移到另一个上下文,而并不会经过克隆。传输后,原始对象将不可用;它将不再指向转移后的资源,并且任何尝试读取或者写入的操作都将抛出异常。


与主线程的数据交互方式如下图所示:



试一下:

// main.js
const worker = new Worker(new URL('worker.js', import.meta.url), { type: 'module' });
worker.onmessage = (e) => {
  // 接收来自 worker 的消息
  setInfo(e.data);
}
// 发送消息给 worker
worker.postMessage('message from main thread');


// 可转移对象
// 创建一个 8MB 的文件并填充
const uInt8Array = new Uint8Array(1024 * 1024 * 8).map((v, i) => i);
console.log(uInt8Array.byteLength); // 8388608
// 将底层 buffer 传递给 worker
worker.postMessage(uInt8Array, [uInt8Array.buffer]);
console.log(uInt8Array.byteLength); // 0




// worker.js
import { sum } from 'lodash';
// 如果是 classic 模式,则需要通过 improtscripts 来引入网络脚本
// importScripts('https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js');


// 接收来自主线程的消息
onmessage = (e) => {
  console.log(e.data);
  const temp = Array.from(e.data).map((e) => +e);
  // 将计算结果发送给主线程
  postMessage(sum(temp));
};


备注:像 Int32Array 和 Uint8Array 等类型化数组(TypedArray)是可序列化的(Serializable object),但是不能转移。然而,它们的底层缓冲区是一个 ArrayBuffer,它是一个可转移对象。我们可以在数据参数中发送 uInt8Array.buffer,但是不能在传输数组中发送 uInt8Array。


除了 postMessage 方法发送消息之外,还有另外一种方式,可以发送消息。


BroadcastChannel

BroadcastChannel 从字面意思上理解是广播频道,他可以让同源页面的浏览器上下文来订阅它。


它允许 同源的 不同浏览器窗口、tab页、frame 或者 iframe 下的不同文档之间互相通信。通过触发 message 事件,消息可以广播到所有监听了该频道的 BroadcastChannel 对象。


此特性在 Web Worker 中可用,由于初始化 Worker 的脚本和主线程是同源的,在 Web Worker 中广播的消息,主线程可以监听到,反之亦然。


试一下:

// 初始化具名频道
const channel = new BroadcastChannel('bm channel');
// 广播消息,发送的消息自己接收不到,其他源可以接收到
channel.postMessage('全场两元,通通两元');
// 接收其他源发送的消息
channel.onmessage = (e) => {
  console.log('get message from other broadcast', e.data);
};


尝试一下:任意打开两个相同的页面,把上面的代码分别粘贴到浏览器的console调试里面,在一个页面调用一下 channel 的 postMessage 方法,在另一个页面看一下,发现消息可以打印出来。


工作者线程的生命周期

1. 初始化

调用 Worker() 构造函数是一个专用工作者线程生命周期的起点。调用之后,它会初始化对工作者线程脚本的请求,并把 Worker 对象返回给父上下文。虽然父上下文中可以立即使用这个 Worker 对象,但与之关联的工作者线程可能还没有创建,因为存在请求脚本的网格延迟和初始化延迟。


初始化时,虽然工作者线程脚本尚未执行,但可以先把要发送给工作者线程的消息加入队列。这些 消息会等待工作者线程的状态变为活动,再把消息添加到它的消息队列。


2. 活动中

创建之后,专用工作者线程就会伴随页面的整个生命期而存在,除非自我终止 self.close() 或通过外部终止 worker.terminate()。即使线程脚本已运行完成,线程的环境仍会存在。只要工作者线程仍存在,与之关联的 Worker 对象就不会被当成垃圾收集掉。


3. 终止

在整个生命周期中,一个专用工作者线程只会关联一个网页(Web 工作者线程规范称其为一个文档)。除非明确终止(通过 self.close() 或者worker.terminate() ),否则只要关联文档存在,专用工作者线程就会存在。如果浏览器离开网页(通过导航或关闭标签页或关闭窗口),它会将与其关联的工作者线程标记为终止,它们的执行也会立即停止。


Shared Worker 共享工作者线程

Shared Worker 与 Web Worker 类似,但可以被多个可信任的执行上下文访问。例如, 同源的两个标签页可以访问同一个共享工作者线程。SharedWorker 与 Worker 的消息接口稍有不同, 包括外部和内部。


共享线程适合开发者希望通过在多个上下文间共享线程减少计算性消耗的情形。比如,可以用一个 共享线程管理多个同源页面 WebSocket 消息的发送与接收。共享线程也可以用在同源上下文希望通过一个线程通信的情形。


从行为上讲,共享工作者线程可以看作是专用工作者线程的一个扩展。线程创建、线程选项、安全限制和 importScripts() 的行为都是相同的。与专用工作者线程一样,共享工作者线程也在独立执行上下文中运行,也只能与其他上下文异步通信。


创建 Shared Worker

Shared Worker 线程的创建和使用与 Worker 类似,事件和方法基本一样。不同点在于主线程与Shared Worker 是通过 MessagePort 建立的链接,数据通讯方法都挂载在 SharedWorker.port上。


另外,如果你采用 addEventListener 来接收 message 事件,那么在主线程初始化SharedWorker() 后,还要调用 SharedWorker.port.start() 方法来手动开启端口。


试一下:

// main.js
const sharedWorker = new SharedWorker('sharedWorker.js', '宝明的 shared worker ~');
// 接收到共享工作者线程消息时触发
sharedWorker.port.onmessage = (e) => {
  console.log('get shared worker message: ', e.data);
}
// 向共享工作者线程发消息
sharedWorker.port.postMessage('message for shared worker');




// sharedWorker.js
onconnect = (e) => {
  // 页面与shared worker 创建链接时触发
  console.log('shared worker connect ~~', e);
  let port = e.ports[0];


  // 接收到页面传入的消息时触发
  port.onmessage = (p) => {
  console.log('shared worker get message', p.data);
  }
}


共享工作者的生命周期

共享工作者线程的生命周期具有与专用工作者线程相同的阶段的特性。不同之处在于,专用工作者线程只跟一个页面绑定,而共享工作者线程只要还有一个上下文连接就会持续存在。


你可以在创建共享工作者线程时,指定不同的线程名,来强制开启多个共享工作者线程。


利用 Shared Worker 手动实现 BroadcastChannel 广播

1. 主线程创建 Shared Worker

// main.js
if (window.SharedWorker) {
  const sharedWorker = new SharedWorker('sharedWorker.js', '宝明的 shared worker ~');
  sharedWorker.port.postMessage('全场2元,通通两元;买不了吃亏,买不了上当');
  sharedWorker.port.onmessage = (e) => {
    console.log('-- 接收到其他页面sharedWorker的广播消息 --',e.data);
  }
}


2. sharedWorker.js 处理连入的线程

因为要向其他连入的线程发送消息,所以要将所有连入的线程全都维护起来。

// sharedWorker.js
/** 创建一个port池,把所有的 port 缓存起来,用于广播消息 */
const portPool = [];


onconnect = (e) => {
  console.log('shared worker connect ~~', e);
  let port = e.ports[0];


  // 将当前 port 缓存进 portPool
  portPool.push(port);


  // 接收到页面传入的消息时触发
  port.onmessage = (p) => {
    // 向自己发消息
    port.postMessage(p.data);
  }
}


3. 向其他页面发送消息

由于是广播消息,所以在发送消息时需要将自身排除在外。

// 向其他页面发送消息
const boradcastMessage = (msg, selfPort) => {
  portPool.forEach((p) => {
    if (p !== selfPort) {
      // 向其他页面广播消息
      p.postMessage(msg);
    }
  });
};


4. 处理失效线程

共享线程与父上下文的启动和关闭不是对称的。每个新 SharedWorker 连接都会触发一个事件,但没有事件对应断开 SharedWorker 实例的连接(如页面关闭)。


在前面的例子中,随着与相同共享线程连接和断开连接的页面越来越多,portPool 线程池中会受到死端口的污染,没有办法识别它们。一个解决方案是在销毁页面时,明确发送卸载消息,让共享线程有机会清除死端口。

// 清空无效的port
if (e.data === 'NEED CLOSE') {
  const index = portPool.findIndex((p) => p === port);
  portPool.splice(index, 1);
}


// main.js 页面关闭时
sharedWorker.port.postMessage('NEED CLOSE');


5. 完整代码

// main.js
if (window.SharedWorker) {
  const sharedWorker = new SharedWorker('sharedWorker.js', '宝明的 shared worker ~');
  sharedWorker.port.postMessage('全场2元,通通两元;买不了吃亏,买不了上当');
  sharedWorker.port.onmessage = (e) => {
    console.log('-- 接收到其他页面sharedWorker的广播消息 --',e.data);
  }
}
document.addEventListener('beforeunload', () => {
  sharedWorker.port.postMessage('NEED CLOSE');
})




// sharedWorker.js
/** 创建一个port池,把所有的 port 缓存起来,用于广播消息 */
const portPool = [];


// 向其他页面发送消息
const boradcastMessage = (msg, selfPort) => {
  portPool.forEach((p) => {
    if (p !== selfPort) {
      // 向其他页面广播消息
      p.postMessage(msg);
    }
  });
};


onconnect = (e) => {
  console.log('shared worker connect ~~', e);
  let port = e.ports[0];


  // 将当前 port 缓存进 portPool
  portPool.push(port);


  // 接收到页面传入的消息时触发
  port.onmessage = (p) => {
    // 向自己发消息
    // port.postMessage(p.data);


    // 向其他页面发送消息
    boradcastMessage(p.data, port);


    // 清空无效的port
    if (e.data === 'NEED CLOSE') {
      const index = portPool.findIndex((p) => p === port);
      portPool.splice(index, 1);
    }
  }
}


调试 Worker

调试 Web Worker

Web Worker 可以在当前页面的 Source 中进行查看。



调试 Shared Worker

Shared Worker 需要在谷歌调试中调试,链接:chrome://inspect/#workers


1. 打开谷歌任务管理器,记录进程id




2. 打开 mac 的活动监视器,找到进程



点击取样



我们可以看到,打开了两个相同的页面,有两个专用工作者线程,而仅有一个共享工作者线程,因为初始化多个同名共享工作者线程,会共享同一个实例。


总结

工作者线程可以运行异步 JavaScript 而不阻塞用户界面。这非常适合复杂计算和数据处理,特别是需要花较长时间因而会影响用户使用网页的处理任务。工作者线程有自己独立的环境,只能通过异步消息与外界通信。


工作者线程可以是专用线程、共享线程。专用线程只能由一个页面使用,而共享线程则可以由同源的任意页面共享。


作者:陈宝明

来源:微信公众号:哈啰技术

出处:https://mp.weixin.qq.com/s/b4GNWSUX6e6-vuZpcJ8AAQ

举报
评论 0