前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Web性能优化之Worker线程(上).md

Web性能优化之Worker线程(上).md

作者头像
前端柒八九
发布2022-08-25 15:00:58
1.2K0
发布2022-08-25 15:00:58
举报
文章被收录于专栏:柒八九技术收纳盒

前言

大家好,我是柒八九

因为,最近有一个需求中,用到了Worker技术,然后经过一些调研和调试,成功的在项目中应用。虽然,有部分原因是出于「技术尝鲜」的角度才选择Worker进行性能优化。但是,「看懂了,会用了,领悟了」。这是不同的技术层面。

所以,打算做一个Worker科普和实际生产应用的文章。

那我们就闲话少叙,开车走起。

文章概要

  1. Worker 线程简介
  2. 专用工作线程Dedicated Worker
  3. 专用工作线程 + Webpack
  4. 共享工作线程Shared Workers

Worker 线程简介

JavaScript 环境实际上是运行在托管操作系统(OS)中的「虚拟环境」

在浏览器中每打开一个页面,就会分配一个它「自己的环境」:即每个页面都有自己的内存、事件循环、DOM。并且每个页面就相当于一个「沙盒」,不会干扰其他页面。

而使用「Worker 线程」,浏览器可以在「原始页面环境之外」再分配一个完全独立的「二级子环境」。这个子环境不能与依赖单线程交互的 API(如 DOM)互操作,但「可以与父环境并行」执行代码。

1. Worker线程 vs 线程

「共同之处」

  • 工作者线程是「以实际线程实现」的:Blink 浏览器引擎实现Worker线程的 WorkerThread 就对应着底层的线程
  • 工作者线程「并行执行」:虽然页面和工作者线程都是「单线程 JS 环境」,每个环境中的指令则可以「并行执行」
  • 工作者线程可以「共享某些内存」:工作者线程能够使用 SharedArrayBuffer 在多个环境间共享内容

「区别」

  • worker线程「不共享全部内存」:除了 SharedArrayBuffer 外,从工作者线程进出的数据需要「复制」「转移」
  • worker线程不一定在同一个进程里:例如,ChromeBlink 引擎对共享worker 线程和服务worker线程使用「独立的进程」
  • 创建worker线程的开销更大:工作者线程有自己「独立的」事件循环、全局对象、事件处理程序和其他 JS 环境必需的特性。创建这些结构的代价不容忽视

2. Worker的类型

Worker 线程规范中定义了「三种主要」的工作者线程

  1. 专用工作线程Dedicated Web Worker 专用工作者线程,通常简称为工作者线程、Web WorkerWorker,是一种实用的工具,可以让脚本「单独创建」一个 JS 线程,以执行委托的任务。 「只能被创建它的页面使用」
  2. 共享工作线程Shared Web Worker :共享工作者线程可以被多个「不同的上下文」使用,包括不同的页面。 任何与「创建」共享工作者线程的脚本「同源」的脚本,都可以向共享工作者线程发送消息或从中接收消息
  3. 服务工作线程Service Worker:主要用途是「拦截」「重定向」「修改页面发出的请求」,充当「网络请求」的仲裁者的角色

3. WorkerGlobalScope

在网页上,window 对象可以向运行在其中的脚本「暴露各种全局变量」

❝在Worker线程内部,没有 window 的概念 ❞

全局对象是 WorkerGlobalScope 的实例,「通过 self 关键字暴露出来」

「WorkerGlobalScope 属性」

「WorkerGlobalScope 方法」

self 上可用的属性/方法是 window 对象上属性/方法的「严格子集」

2.专用工作线程Dedicated Web Worker

专用工作线程是最简单的 Web 工作者线程,网页中的脚本可以创建专用工作者线程来执行在「页面线程之外」的其他任务。这样的线程可以与父页面交换信息、发送网络请求、执行文件输入/输出、进行「密集计算」、处理「大量数据」,以及实现其他不适合在页面执行线程里做的任务(否则会导致页面响应迟钝)。

其实,我们平时在工作中,遇到的最多的也是「专用工作线程」

基本概念

❝把专用工作线程称为后台脚本background script ❞

JS 线程的各个方面,包括「生命周期管理」、代码路径和输入/输出,都由「初始化线程时提供的脚本」来控制。

创建工作线程

创建工作线程最常见的方式是「加载 JS 文件」:即把「文件路径」提供给 Worker 构造函数,然后构造函数再在「后台异步加载」脚本并实例化工作线程。

代码语言:javascript
复制
worker.js
// 进行密集计算 bala bala

main.js
const worker = new Worker( 'worker.js');
console.log(worker); // Worker {} // {3}

这里有几个点需要注意下:

  • 这个文件(worker.js)是在后台加载的,工作线程的初始化完全独立于 main.js
  • 工作线程本身存在于一个「独立的 JS 环境」中,因此 main.js 必须以 Worker 对象「代理」实现与工作线程通信
  • {3}行,虽然相应的工作线程可能还不存在,但该 Worker 对象已在原始环境中可用了

安全限制

工作线程的脚本文件「只能」从与父页面「相同的源」加载。从其他源加载工作线程的脚本文件会导致错误,如下所示:

假设父页面为https://bcnz.com

代码语言:javascript
复制
// 尝试基于 与父页面同源的脚本创建工作者线程
const sameOriginWorker = new Worker('./worker.js');

// 尝试基于 https://wl.com/worker.js 创建工作者线程 (与父页面不同源)
const remoteOriginWorker = 
new Worker('https://wl.com/worker.js');

「创建」remoteOriginWorker时,页面报错。

代码语言:javascript
复制
// Error: Uncaught DOMException: 
// Failed to construct 'Worker':
// Script at https://wl.com/main.js cannot be accessed
// from origin https://bcnz.com 

❝不能使用非同源脚本「创建」工作线程,并不影响「执行」其他源的脚本 ❞

使用 Worker 对象

Worker()构造函数返回的 Worker 对象是与刚创建的「专用工作线程」通信的「连接点」

Worker 对象可用于在「工作线程和父上下文间」传输信息,以及「捕获」专用工作线程发出的事件。

Worker 对象支持下列「事件处理程序属性」:

  • onerror:在工作线程中发生 ErrorEvent 类型的错误事件时会调用指定给该属性的处理程序
    • 该事件会在工作线程中「抛出错误时」发生
    • 该事件也可以通过 worker.addEventListener('error', handler)的形式处理
  • onmessage:在工作线程中发生 MessageEvent 类型的消息事件时会调用指定给该属性的处理程序
    • 该事件会在工作线程向父上下文发送消息时发生
    • 该事件也可以通过使用 worker.addEventListener('message', handler)处理
  • onmessageerror:在工作线程中发生 MessageEvent 类型的错误事件时会调用指定给该属性的处理程序
    • 该事件会在工作线程收到「无法反序列化」的消息时发生
    • 该事件也可以通过使用 worker.addEventListener('messageerror', handler)处理

Worker 对象还支持下列「方法」

  • postMessage():用于通过「异步消息事件」向工作线程发送信息。
  • terminate():用于「立即终止」工作线程。没有为工作线程提供清理的机会,脚本会突然停止

DedicatedWorkerGlobalScope

在专用工作线程内部,全局作用域是 DedicatedWorkerGlobalScope 的实例。

因为这继承自 WorkerGlobalScope,所以包含它的所有属性和方法。工作线程可以通过 self 关键字访问该全局作用域。

代码语言:javascript
复制
globalScopeWorker.js
console.log('inside worker:', self);


main.js
const worker = new Worker('./globalScopeWorker.js');
console.log('created worker:', worker);


// created worker: Worker {}
// inside worker: DedicatedWorkerGlobalScope {}

两个「独立」的 JS 线程都在向一个 console 对象发消息,该对象随后将「消息序列化」并在浏览器控制台打印出来。浏览器从两个不同的 JS 线程收到消息,并按照「自己认为合适的顺序」输出这些消息。

DedicatedWorkerGlobalScopeWorkerGlobalScope 基础上增加了以下属性和方法

  • name:可以提供给 Worker 构造函数的一个可选的字符串标识符。
  • postMessage():与 worker.postMessage()对应的方法,用于「从工作线程内部向父上下文发送消息」
  • close():与 worker.terminate()对应的方法,用于「立即终止工作者线程」。没有为工作者线程提供清理的机会,脚本会「突然停止」
  • importScripts()「:用于向工作线程中」导入任意数量」的脚本

生命周期

❝调用 Worker()构造函数是一个专用工作线程「生命的起点」

调用之后,它会「初始化」对工作线程脚本的请求,并把 Worker 对象「返回给父上下文」。虽然父上下文中可以立即使用这个 Worker 对象,但与之关联的工作线程可能还没有创建,因为「存在请求脚本的网格延迟和初始化延迟」

一般来说,专用工作线程可以非正式区分为处于下列「三个状态」:初始化initializing、激活active和终止terminated。「这几个状态对其他上下文是不可见的」。虽然 Worker 对象可能会存在于「父上下文」中,但也无法通过它确定工作者线程当前是处理初始化、活动还是终止状态。

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

代码语言:javascript
复制
initializingWorker.js
self.addEventListener('message', ({data}) => console.log(data));


main.js
const worker = new Worker('./initializingWorker.js');
// Worker 可能仍处于初始化状态
// 但 postMessage()数据可以正常处理
worker.postMessage('foo');
worker.postMessage('bar');
worker.postMessage('baz');

// foo
// bar
// baz

可以看到,在主线程中,创建了对应工作线程对应的 Worker 对象,在还未知道工作线程是否已经「初始化完成」,便可以直接通过postMessage进行线程之间通信。

创建之后,专用工作线程就会「伴随页面的整个生命期」而存在,除非「自我终止」self.close()) 或通过「外部终止」worker.terminate())。即使线程脚本已运行完成,线程的环境仍会存在。

❝只要工作线程仍存在,与之关联的 Worker 对象就不会被当成垃圾收集掉 ❞

在整个生命周期中,「一个专用工作线程只会关联一个网页」(也称文档)。除非明确终止,否则只要关联文档存在,专用工作线程就会存在。

Worker 选项

Worker()构造函数允许将可选的配置对象作为「第二个参数」

  • name:可以在工作线程中通过 self.name 读取到的字符串标识符。
  • type:表示加载脚本的「运行方式」,可以是classicmodule
    • classic 将脚本作为「常规脚本」来执行
    • module 将脚本作为「模块」来执行
  • credentials:在 type 为module时,指定如何获取与传输「凭证数据」相关的工作线程模块脚本。值可以是omitsame-origninclude。这些选项与 fetch()的凭证选项相同。

行内创建工作线程

基于Blob

专用工作线程也可以基于 Blob 实例创建 URL 对象 在「行内脚本」创建。

代码语言:javascript
复制
// 创建要执行的 JavaScript 代码字符串
const workerScript = `
 self.onmessage = ({data}) => console.log(data);
`;
// 基于脚本字符串生成 Blob 对象
const workerScriptBlob = new Blob([workerScript]);
// 基于 Blob 实例创建对象 URL
const workerScriptBlobUrl = URL.createObjectURL(workerScriptBlob);
// 基于对象 URL 创建专用工作者线程
const worker = new Worker(workerScriptBlobUrl);
worker.postMessage('blob worker script');

// blob worker script 
  • 通过脚本字符串创建了 Blob
  • 然后又通过 Blob 创建了 URL 对象
  • 最后把URL 对象,传给了 Worker()构造函数

基于函数序列化

函数的 toString()方法返回函数代码的字符串,而函数可以「在父上下文中定义」但在「子上下文中执行」

代码语言:javascript
复制
function fibonacci(n) {
 return n < 1 ? 0
 : n <= 2 ? 1
 : fibonacci(n - 1) + fibonacci(n - 2);
}

const workerScript = `
 self.postMessage(
   (${fibonacci.toString()})(9)
 );
`;

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

// 34

像这样序列化函数有个「前提」,就是函数体内不能使用通过「闭包」获得的引用,也包括「全局变量」

动态执行脚本

❝工作线程可以使用 importScripts()方法通过编程方式「加载和执行任意脚本」

这个方法会加载脚本并按照「加载顺序同步执行」

// Worker.js

代码语言:javascript
复制
scriptA.js
console.log('scriptA executes');

scriptB.js
console.log('scriptB executes');


worker.js
console.log('importing scripts');
importScripts('./scriptA.js');
importScripts('./scriptB.js');
console.log('scripts imported'); 

Main.js

代码语言:javascript
复制
const worker = new Worker('./worker.js');

// importing scripts
// scriptA executes
// scriptB executes
// scripts imported

importScripts()方法可以接收「任意数量」的脚本作为参数。「执行」会严格按照它们在参数列表的顺序进行。

❝脚本加载受到常规 CORS 的限制,但在工作线程内部可以「请求来自任何源」的脚本 ❞

在这种情况下,所有导入的脚本也会「共享作用域」

Worker.js

代码语言:javascript
复制
scriptA.js
console.log(`scriptA executes in ${self.name} with ${globalToken}`);

scriptB.js
console.log(`scriptB executes in ${self.name} with ${globalToken}`);

worker.js
const globalToken = 'wl';
console.log(`importing scripts in ${self.name} with ${globalToken}`);
importScripts('./scriptA.js', './scriptB.js');
console.log('scripts imported'); 

main.js

代码语言:javascript
复制
const worker = new Worker('./worker.js', {name: 'foo'});

// importing scripts in foo with wl
// scriptA executes in foo with wl
// scriptB executes in foo with wl
// scripts imported 

与专用工作线程通信

❝与工作线程的通信都是通过「异步消息」完成的 ❞

使用 postMessage()

是使用 postMessage()传递「序列化」的消息。

factorialWorker.js

代码语言:javascript
复制
function factorial(n) {
 let result = 1;
 while(n) { result *= n--; }
 return result;
}

self.onmessage = ({data}) => {
 self.postMessage(`${data}! = ${factorial(data)}`);
}; 

main.js

代码语言:javascript
复制
const factorialWorker = new Worker('./factorialWorker.js');
factorialWorker.onmessage = ({data}) => console.log(data);
// 发送消息
factorialWorker.postMessage(5);
factorialWorker.postMessage(7);
factorialWorker.postMessage(10);
// 5! = 120
// 7! = 5040
// 10! = 3628800

对于传递「简单的消息」,使用 postMessage()在主线程和工作者线程之间传递消息。并且没有 targetOrigin 的限制。

然后还可以使用MessageChannel/BroadcastChannel进行线程之间的通信,这里就不展开说明了。但是大部分,用postMessage()就够用了

数据传输

工作线程是「独立的上下文」,因此在上下文之间传输数据就会产生消耗。

❝在 JS 中,有「三种」在上下文间转移信息的方式:

  • 结构化克隆算法structured clone algorithm、
  • 可转移对象transferable objects
  • 共享数组缓冲区shared array buffers

结构化克隆算法

❝结构化克隆算法可用于在两个「独立上下文间」共享数据 ❞

在通过 postMessage()传递对象时,浏览器会遍历该对象,并在目标上下文中生成它的一个「副本」

结构化克隆算法支持的类型

需要注意的点

结构化克隆算法在对象「比较」复杂时会存在「计算性消耗」。因此,实践中要「尽可能避免过大、过多的复制」

可转移对象

使用可转移对象可以把「所有权」从一个上下文转移到另一个上下文。在不太可能在上下文间复制大量数据的情况下,这个功能特别有用。

可转移对象支持的类型

  • ArrayBuffer
  • MessagePort
  • ImageBitmap
  • OffscreenCanvas

postMessage()方法的「第二个可选参数」是数组,它指定应该「将哪些对象转移到目标上下文」。在遍历消息负载对象时,浏览器根据转移对象数组检查对象引用,并对转移对象进行转移而不复制它们。

ArrayBuffer 指定为可转移对象,那么对缓冲区内存的引用就会「从父上下文中抹去」,然后 分配给工作者线程。

main.js

代码语言:javascript
复制
const worker = new Worker('./worker.js');
// 创建 32 位缓冲区
const arrayBuffer = new ArrayBuffer(32);
console.log(`page's buffer size: ${arrayBuffer.byteLength}`); // 32
worker.postMessage(arrayBuffer, [arrayBuffer]);
console.log(`page's buffer size: ${arrayBuffer.byteLength}`); // 0

worker.js

代码语言:javascript
复制
self.onmessage = ({data}) => {
 console.log(`worker's buffer size: ${data.byteLength}`); // 32
}; 

共享数组缓冲区

「既不克隆,也不转移」SharedArrayBuffer 作为 ArrayBuffer 能够在不同浏览器上下文间共享。

在把 SharedArrayBuffer 传给 postMessage()时,浏览器只会传递原始缓冲区的引用。结果是,「两个不同的 js 上下文会分别维护对同一个内存块的引用」

专用工作线程 + Webpack

假设存在如下的一种文档结构

代码语言:javascript
复制
package.json             
               
src/                    

    app.jsx              // 组件    

    work/ 
        longTime.js   // 计算耗时任务

    store/                    

webpack/                  

    config.js                 

babel.config.js          

.gitignore               

README.md    

行内创建工作线程

就像上面介绍的一样,我们可以借用「行内」方式来创建一个工作线程来,维护一些比较耗时的操作。

longTime.js 中注入一些耗时任务

代码语言:javascript
复制
const workercode = () => {
  self.onmessage = function (e) {
    console.log('来自主线程的消息');
    let workerResult = `主线程消息: ${e.data}`;
    
    console.log('向主线程回传消息');
    self.postMessage(workerResult);
  };
 
  self.postMessage('老表,你好!');
};

// 将函数进行序列化处理(toString())
let code = workercode.toString();
// 将函数体,用{} 包裹起来
code = code.substring(
    code.indexOf('{') + 1, code.lastIndexOf('}'));
 
const blob = new Blob(
          [code], 
          { type: 'application/javascript' }
        );
const worker_script = URL.createObjectURL(blob);
 
export default worker_script;
代码语言:javascript
复制
 
import worker_script from './longTime.js';

const { useEffect } from 'react';

const MainPage = () => {
  
  useEffect(()=>{
    const worker = new Worker(worker_script);
    worker.onmessage = function (event) {
      console.log(`Received message ${event.data}`);
    };
    // worker.postMessage('dadada')
  },[]);
  
  return <>页面内容</>
}

当然,我们可以在利用useRef()来引用worker 引用,然后再其他副作用或者事件函数中触发,worker.postMessage('')

worker 引用node_module中的包

❝通过「行内构建工作线程」有一个弊端,就是无法通过import/require引入一些第三方的包。 ❞

因为,前端框架的特殊性,虽然在worker中可以使用importScripts()加载任意脚本,但是那些都是在worker同目录或者是利用绝对路径进行引用。很不方便。

而大部分前端项目,都是用node_module对项目用到的包进行管理。所以,利用importScripts()这种方式引入包,不满足情况。

既然,不满足,我们就需要将目光移打包框架层面。Webpack作为打包界的扛把子。我们还是需要问问它是否满足这种情况。

巧不了不是,还真有一些类似的loader --worker-loader

进行本地按照

$ npm install worker-loader --save-dev

配置webpack -config.js

代码语言:javascript
复制
module.exports = {
  module: {
    rules: [
      {
        test: /\.worker\.js$/,
        use: { loader: "worker-loader" },
      },
    ],
  },
};

通过如上的配置,我们就可以像写常规的组件或者工具方法一些,「肆无忌惮」的通过import引入第三方包。

longTime.js

代码语言:javascript
复制
const A = require('A')
self.onmessage = function (e) {
     // A处理一些特殊场景
}

关于worker-loader具体使用规范就不在过多解释。


共享工作线程Shared Workers

从行为上讲,共享工作线程可以看作是专用工作线程的一个「扩展」。线程创建、线程选项、安全限制和 importScripts()的行为都是相同的。

共享工作者线程也在「独立执行上下文」中运行,也只能与其他上下文「异步通信」

因为,Shared Worker简单也适用场景有限,所以就不过多介绍了。

❝关于「服务线程」其实可涉及的地方还有很多,打算单写一篇。在这里就不单独介绍了。 ❞

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2022-05-20,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 前端柒八九 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 文章概要
  • Worker 线程简介
    • 1. Worker线程 vs 线程
      • 「共同之处」
      • 「区别」
    • 2. Worker的类型
      • 3. WorkerGlobalScope
      • 2.专用工作线程Dedicated Web Worker
        • 基本概念
          • 创建工作线程
        • 安全限制
          • 使用 Worker 对象
            • DedicatedWorkerGlobalScope
              • 生命周期
                • Worker 选项
                  • 行内创建工作线程
                    • 基于Blob
                    • 基于函数序列化
                  • 动态执行脚本
                    • 与专用工作线程通信
                      • 使用 postMessage()
                    • 数据传输
                      • 结构化克隆算法
                      • 可转移对象
                      • 共享数组缓冲区
                  • 专用工作线程 + Webpack
                    • 行内创建工作线程
                      • worker 引用node_module中的包
                      • 共享工作线程Shared Workers
                      相关产品与服务
                      文件存储
                      文件存储(Cloud File Storage,CFS)为您提供安全可靠、可扩展的共享文件存储服务。文件存储可与腾讯云服务器、容器服务、批量计算等服务搭配使用,为多个计算节点提供容量和性能可弹性扩展的高性能共享存储。腾讯云文件存储的管理界面简单、易使用,可实现对现有应用的无缝集成;按实际用量付费,为您节约成本,简化 IT 运维工作。
                      领券
                      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档