提升用户体验方案之Web Worker—Worker1

344 阅读17分钟

1. 什么是WebWorker

众所周知,JavaScript 是单线程的语言。所有代码都运行在一个主线程中,包括处理用户界面,js代码执行和网络请求。当执行耗时操作时,就会导致用户页面的卡顿和不响应,甚至浏览器直接卡死。现在前端遇到大量计算的场景越来越多,为了有更好的体验,HTML5 中提出了 Web Worker 的概念。

知识点回顾:为什么javaScript是单线程语言,多个线程不是更能提高效率吗?

JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?

Web Worker 是一种在浏览器中运行的 JavaScript 脚本,可以在独立的线程中执行,与主线程并行工作,提供了一种在后台执行复杂计算或处理耗时操作的方式,而不会堵塞主线程的执行。它还可以与主线程进行通信,通过信息传递机制来交换数据和结果,从而形成了高效、良好的用户体验。

Web Worker 是一个统称,具体可以细分为普通的 Worker、SharedWorker 和 ServiceWorker 等。

3种worker分别适合不同的场景,普通的 Worker 可以在需要大量计算的时候使用,创建新的线程可以降低主线程的计算压力,不会导致 UI 卡顿。SharedWorker 主要是为不同的 window、iframes 之间共享数据提供了另外一个解决方案。ServiceWorker 可以缓存资源,提供离线服务或者是网络优化,加快 Web 应用的开启速度。

下面用一个表格大概了解一下三种worker的不同,本章重点介绍Worker相关内容

类型WorkerSharedWorkerServiceWorker
通信方式postMessageport.postMessage单向通信,通过 addEventListener 监听 serviceWorker 的状态
使用场景适合大量计算的场景适合跨 tab、iframes 之间共享数据缓存资源、网络优化
兼容性>= IE 10 >= Chrome 4不支持 IE、Safari、Android、iOS >= Chrome 4不支持 IE >= Chrome 40

image.png web workers 已经被大多数浏览器支持,使用上基本不用考虑兼容问题。

注意worker的实际上是一个较重的API,它对浏览器是有一定的负担的,所以建议需要认真分析当前的场景是否是需要使用worker进行业务的实现或者性能的优化。

2. worker

worker使用Worker(...)构造来生成一个worker实例对象,他的定义主要如下

[Exposed=(Window,DedicatedWorker,SharedWorker)]
interface Worker : EventTarget {
  constructor(USVString scriptURL, optional WorkerOptions options = {});

  undefined terminate();

  undefined postMessage(any message, sequence<object> transfer);
  undefined postMessage(any message, optional StructuredSerializeOptions options = {});
  attribute EventHandler onmessage;
  attribute EventHandler onmessageerror;
};
dictionary WorkerOptions {
  WorkerType type = "classic";
  RequestCredentials credentials = "same-origin"; // credentials is only used if type is "module"
  DOMString name = "";
};

enum RequestCredentials { "omit", "same-origin", "include" };
enum WorkerType { "classic", "module" };

Worker includes AbstractWorker;

2.1 构造函数

我们可以看到worker继承于EventTarget,它本身就是采用的浏览器事件接口的。除此之外,构造函数接受两个参数分别是scriptURL以及options

  • scriptURL 用于指定要加载的worker模块的脚本地址,部分浏览器环境支持使用data URI
  • options 对象类型,然后有三个可选的属性值,分别是type,credentials以及name
  • options.type 选择加载类型,可以使用的值有classic以及module,使用module允许使用ES Module对文件进行处理,支持模块导入和导出,提供了更好的模块化支持
  • options.credentials 用于制定加载时文件时,是否携带cookie等身份认证信息,点击查看详细介绍
  • options.name worker的名称,据MDN上说一般用于调试

2.2 Worker的全局对象

首先我们需要知道一下DedicatedWorkerGlobalScope的继承关系。

image.png

首先DedicatedWorkerGlobalScope继承于EventTargetWorkerGlobalScope。可以使用父类的方法。实际上worker也就是一个事件目标对象(EventTarget),它也可以挂载EventListener进行事件监听。那么在了解完继承关系后我们再来梳理一下WorkerGlobalScope提供了什么方法以及属性

EventTarget 是事件目标接口,用于处理事件。WorkerGlobalScope 继承了这个接口,使得 Worker 线程能够处理事件,例如 onmessageonerror

WorkerGlobalScope

WorkerGlobalScope 是 Web Workers 中的全局对象,类似于浏览器中的 window 对象。在这个全局作用域中,可以执行 JavaScript 代码,但是它没有直接访问 DOM 的能力,因为 DOM 是主线程的一部分。

WorkerGlobalScope继承了EventTarget,并且实现了一些其他的接口,包括WindowTimers,WindowBase64,WindowEventHandlersGlobalFetch 接口

  1. WindowTimers 接口: WindowTimers 定义了在定时器方面的方法,如 setTimeoutsetInterval。在 Worker 线程中,由于没有 DOM,定时器方法的实现会有所不同,但仍然提供了类似的功能。WindowTimers.clearInterval()WindowTimers.clearTimeout()WindowTimers.setInterval()WindowTimers.setTimeout()
  2. WindowBase64 接口: WindowBase64 提供了一些用于处理 Base64 编码的方法。在 Worker 线程中,这样的方法仍然可以用于处理数据。WindowBase64.atob()/WindowBase64.btoa()
  3. WindowEventHandlers 接口: WindowEventHandlers 定义了处理事件的方法。虽然 Worker 线程无法直接与 DOM 交互,但它仍然可以处理一些与事件相关的操作。
  4. GlobalFetch 接口: GlobalFetch 提供了在全局范围内进行网络请求的方法,例如 fetch。这允许 Worker 线程进行网络通信,获取数据等。GlobalFetch.fetch()

这些接口的继承和实现使得 WorkerGlobalScope 具有一些全局作用域应该具备的通用特性,同时也适应了 Web Worker 的环境。请注意,在 Worker 线程中,并不是所有的 Window 对象的属性和方法都会被实现,因为 Worker 线程中没有 DOM。sessionStoragelocalStorage也是没有办法在WorkerGlobalScope中使用的。在worker中可以使用的浏览器存储有IndexedDB

它拥有以下属性以及方法:

  • WorkerGlobalScope.caches(只读对象): 返回与当前上下文相关的CacheStorage对象,它主要与缓存相关,一般用于service worker中。

  • WorkerGlobalScope.navigator(只读对象): 返回与worker关联的 WorkerNavigator它是一个特定的导航器对象,适用worker

  • WorkerGlobalScope.self(只读对象): 返回对 WorkerGlobalScope 本身的引用。大多数情况下,它是一个特定的范围,例如 DedicatedWorkerGlobalScope、SharedWorkerGlobalScope (en-US) 或 ServiceWorkerGlobalScope。

  • WorkerGlobalScope.location(只读对象): 返回与worker关联的 WorkerLocation,Worker 线程的位置信息。与浏览器的主线程不同,Worker 线程中的 location 对象是只读的,且只包含 href 属性,适用于worker

  • WorkerGlobalScope.onerror: 用于设置或获取在 Worker 线程中捕获全局错误的事件处理函数。

  • WorkerGlobalScope.close() 丢弃在 WorkerGlobalScope 的事件循环中排队的任何任务,关闭当前作用域,在 Worker 线程中调用这个方法将会终止该线程

  • WorkerGlobalScope.importScripts()可以动态将多个脚本引入当前worker的上下文中

  • 通过其他接口实现的方法

DedicatedWorkerGlobalScope

DedicatedWorkerGlobalScope 接口表示 Dedicated Worker(专用 Worker)中的全局作用域。这个接口继承自 WorkerGlobalScope,所以包括了与 WorkerGlobalScope 相关的属性和方法。以下是 DedicatedWorkerGlobalScope 特有的属性和方法:

DedicatedWorkerGlobalScope.postMessage() Dedicated Worker 全局作用域中的 postMessage 方法,用于向主线程发送消息。该方法可以传递多种类型的message的给到外部的worker实例,通过message事件进行监听。

2.3 worker实例对象

了解完worker内部的全局对象后,我们再来了解一下worker实例。Worker在上文提到过,也是继承于EventTarget所以具备事件目标的相关属性以及方法。除此以外它还具备以下方法以及事件

  • Worker.postMessage() 可以用于跟worker内部的上下文进行通讯,同DedicatedWorkerGlobalScope.postMessage方法参数

  • Worker.terminate() 结束当前worker的行为,不会等待worker完成剩余的操作

  • message 用于接收来自于worker内部上下文的message。

  • messageerrorDedicatedWorkerGlobalScopemessageerror事件,当worker内给外部实例传递一条无法返序列化的数据是有此报错

  • error 当在worker内执行上下文抛出错误时,会触发当前事件

总结: 虽然 Worker 线程是在浏览器环境中被唤起,但是它与当前页面窗口运行在不同的全局上下文中,我们常用的顶层对象 window,以及 parent 对象在 Worker 线程上下文中是不可用的。它有自己的顶层对象,即self。另外,在 Worker 线程上下文中,操作 DOM 的行为也是不可行的,document对象也不存在。但是,locationnavigator对象可以以可读方式访问。除此之外,绝大多数 Window 对象上的方法和属性,都被共享到 Worker 上下文全局对象 WorkerGlobalScope 中。

3. worker的使用

3.1 创建 worker

创建 worker 只需要通过 new 调用 Worker() 构造函数即可,它接收两个参数

const worker = new Worker(path, options);
参数说明
path有效的js脚本的地址,必须遵守同源策略。无效的js地址或者违反同源策略,会抛出SECURITY_ERR 类型错误
options.type可选,用以指定 worker 类型。该值可以是 classic 或 module。 如未指定,将使用默认值 classic
options.credentials可选,用以指定 worker 凭证。该值可以是 omitsame-origin,或 include。如果未指定,或者 type 是 classic,将使用默认值 omit (不要求凭证)
options.name可选,在 DedicatedWorkerGlobalScope的情况下,用来表示 worker 的 scope 的一个 DOMString值,主要用于调试目的。

3.2 主线程与 worker 线程数据传递

主线程与 worker 线程都是通过 postMessage 方法来发送消息,以及监听 message 事件来接收消息。如下所示:

// main.js(主线程)
const myWorker = new Worker('/worker.js'); // 创建worker
myWorker.addEventListener('message', e => { // 接收消息
    console.log(e.data); // Greeting from Worker.js,worker线程发送的消息
});
// 这种写法也可以
// myWorker.onmessage = e => { // 接收消息
//    console.log(e.data);
// };
myWorker.postMessage('Greeting from Main.js'); // 向 worker 线程发送消息,对应 worker 线程中的 e.data
// worker.js(worker线程)
self.addEventListener('message', e => { // 接收到消息
    console.log(e.data); // Greeting from Main.js,主线程发送的消息
    self.postMessage('Greeting from Worker.js'); // 向主线程发送消息
});

好了,一个简单 worker 线程就创建成功了。

postMessage() 方法接收的参数可以是字符串、对象、数组等。具体我们在3.7讨论。

主线程与 worker 线程之间的数据传递是传值而不是传地址。所以你会发现,即使你传递的是一个Object,并且被直接传递回来,接收到的也不是原来的那个值了。

// main.js(主线程)
const myWorker = new Worker('/worker.js');

const obj = {name: '小明'};
myWorker.addEventListener('message', e => { 
    console.log(e.data === obj); // false
});
myWorker.postMessage(obj);
// worker.js(worker线程)
self.addEventListener('message', e => {
    self.postMessage(e.data); // 将接收到的数据直接返回
});

3.3 监听错误信息

web worker 提供两个事件监听错误,errormessageerror。这两个事件的区别是:

事件描述
error当worker内部出现错误时触发
messageerrormessage 事件接收到无法被反序列化的参数时触发

监听方式跟接收消息一致:

// main.js(主线程)
const myWorker = new Worker('/worker.js'); // 创建worker
myWorker.addEventListener('error', err => {
    console.log(err.message);
});
myWorker.addEventListener('messageerror', err => {
    console.log(err.message)
});
// worker.js(worker线程)
self.addEventListener('error', err => {
    console.log(err.message);
});
self.addEventListener('messageerror', err => {
    console.log(err.message);
});

3.4 关闭 worker 线程

worker 线程的关闭在主线程和 worker 线程都能进行操作,但对 worker 线程的影响略有不同。

// main.js(主线程)
const myWorker = new Worker('/worker.js'); // 创建worker
myWorker.terminate(); // 关闭worker
// worker.js(worker线程)
self.close(); // 直接执行close方法就ok了

无论是在主线程关闭 worker,还是在 worker 线程内部关闭 worker,worker 线程当前的 Event Loop 中的任务会继续执行。至于 worker 线程下一个 Event Loop 中的任务,则会被直接忽略,不会继续执行。

区别是,在主线程手动关闭 worker,主线程与 worker 线程之间的连接都会被立刻停止,即使 worker 线程当前的 Event Loop 中仍有待执行的任务继续调用 postMessage() 方法,但主线程不会再接收到消息。

在 worker 线程内部关闭 worker,不会直接断开与主线程的连接,而是等 worker 线程当前的 Event Loop 所有任务执行完,再关闭。也就是说,在当前 Event Loop 中继续调用 postMessage() 方法,主线程还是能通过监听message事件收到消息的。

举例说明:

在主线程关闭 worker

大家可以思考一下,主线程会接收到哪些消息呢,控制台会打印出哪些信息呢?

// main.js(主线程)
const myWorker = new Worker('/worker.js'); // 创建 worker
myWorker.addEventListener('message', e => {
    console.log(e.data);
    myWorker.terminate(); // 关闭 worker
});

myWorker.postMessage('Greeting from Main.js');
// worker.js(worker线程)
self.addEventListener('message', e => {
    postMessage('Greeting from Worker');
    //settimeput添加一个宏任务
    setTimeout(() => {
        console.log('setTimeout run');
        postMessage('Greeting from SetTimeout');
    });
    //promise添加一个微任务
    Promise.resolve().then(() => {
        console.log('Promise run');
        postMessage('Greeting from Promise');
    })
    
    for (let i = 0; i < 1001; i++) {
        if (i === 1000) {
            console.log('Loop run');
            postMessage('Greeting from Loop');
        }
    } 
});

运行结果如下:

image.png

  • 主线程只会接收到 worker 线程第一次通过 postMessage() 发送的消息,后面的消息不会接收到;
  • worker 线程当前 Event Loop 里的任务会继续执行,包括微任务;
  • worker 线程里 setTimeout 创建的下一个 Event Loop 任务队列没有执行。

在 worker 线程内部关闭 worker

对上述例子稍作修改,将关闭 worker 的事件放到 worker 线程内部,大家觉得又会打印出什么呢

// main.js(主线程)
const myWorker = new Worker('/worker.js'); // 创建 worker
myWorker.addEventListener('message', e => {
    console.log(e.data);
});
myWorker.postMessage('Greeting from Main.js');
// worker.js(worker线程)
self.addEventListener('message', e => {
    postMessage('Greeting from Worker');
    self.close(); // 关闭 worker
    
    setTimeout(() => {
        console.log('setTimeout run');
        postMessage('Greeting from SetTimeout');
    });
    
    Promise.resolve().then(() => {
        console.log('Promise run');
        postMessage('Greeting from Promise');
    })
    
    for (let i = 0; i < 1001; i++) {
        if (i === 1000) {
            console.log('Loop run');
            postMessage('Greeting from Loop');
        }
    }
});

运行结果如下:

image.png

与在主线程关闭不同的是,worker 线程当前的 Event Loop 任务队列中的 postMessage() 事件都会被主线程监听到。

3.5 Worker 线程引用其他js文件

总有一些场景,需要放到 worker 进程去处理的任务很复杂,需要大量的处理逻辑,我们当然不想把所有代码都塞到 worker.js 里,那样就太糟糕了。web worker 为我们提供了解决方案,我们可以在 worker 线程中利用 importScripts() 方法加载我们需要的js文件,而且,通过此方法加载的js文件不受同源策略约束

// utils.js
const add = (a, b) => a + b;
// worker.js(worker线程)
// 使用方法:importScripts(path1, path2, ...); 

importScripts('./utils.js');
console.log(add(1, 2)); // log 3

3.6 ESModule 模式

还有一些场景,当你开启一个新项目,用 importScripts() 导入js文件时发现, importScripts() 方法执行失败。仔细一看,发现是新项目的 js 文件都用的是 ESModule 模式。难道要把引用到的文件都改一遍吗?当然不是,还记得上文提到初始化 worker 时的第二个可选参数吗,我们可以直接使用 module 模式初始化 worker 线程!

// main.js(主线程)
const worker = new Worker('/worker.js', {
    type: 'module'  // 指定 worker.js 的类型
});
// utils.js
export default add = (a, b) => a + b;
// worker.js(worker线程)
import add from './utils.js'; // 导入外部js

self.addEventListener('message', e => { 
    postMessage(e.data);
});

add(1, 2); // log 3

export default self; // 只需把顶级对象self暴露出去即可

3.7 主线程和 worker 线程可传递哪些类型数据

很多场景,在调用某些方法时,我们将一些自定义方法当作参数传入。但是,当你使用 postMessage() 方法时这么做,将会导致 DATA_CLONE_ERR 错误。

// main.js(主线程)
const myWorker = new Worker('/worker.js'); // 创建worker
const fun = () => {};
myWorker.postMessage(fun); // Error:Failed to execute 'postMessage' on 'Worker': ()=>{} could not be cloned.

那么,使用 postMessage() 方法传递消息,可以传递哪些数据?

postMessage() 传递的数据可以是由结构化克隆算法处理的任何值或 JavaScript 对象,包括循环引用。

结构化克隆算法不能处理的数据:

  • Error 以及 Function 对象;

  • DOM 节点

  • 对象的某些特定参数不会被保留

    • RegExp 对象的 lastIndex 字段不会被保留
    • 属性描述符,setters 以及 getters(以及其他类似元数据的功能)同样不会被复制。例如,如果一个对象用属性描述符标记为 read-only,它将会被复制为 read-write
    • 原形链上的属性也不会被追踪以及复制。

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

4. woker的实践

前面我们提到了,对于复杂计算和耗时操作,阻塞主线程操作,可以考虑使用woker来解决。让我们一起来结合项目实践思考一下具体的应用场景:

场景1:大文件切片上传

思路分析:

  • step1: 将文件切片,根据文件大小和每个要切多大计算切多少片,用于切片的下标计算
  • step2 使用FileReader 对象来异步读取文件的分片,
  • step3:使用了 SparkMD5 来计算哈希值,以确保文件的完整性。
  • step4:每个分片读取完成后,通过 Promise 的 resolve 方法返回一个包含分片信息的对象,包括分片的起始位置、结束位置、索引、计算的哈希值以及分片的文件对象。
  • step5:切片完成,拿到所有切片信息,进行上传

那么哪个步骤可以使用worker来处理呢

没错。对于切片的计算可以放在worker里处理,也就是step2和step3,处理完后将切片信息放到一个数组里通过postmessage传给主线程,主线程通过onmessage可以拿到所有切片信息。主线程终止worker线程。

你甚至可以拿到用户设备的逻辑处理器核心数量,创建多个并行worker,将切片总量均分到每个worker,进行并行计算,当所有线程处理完成后,再进行主线程的下一步处理。这样能缩短处理时间,更进一步的提升用户体验

image.png

场景2:用户输入的内容重塑

比如有个需求,给了一系列表单,里面有一个文本框,用户输入大段内容,需要前端根据用户输入的内容进行重新整合,比如样式重绘,识别特殊文本高亮等。

思路分析

试想对于超大段的内容处理是不是很费时,很容易造成页面卡顿,这个时候就可以考虑用worker了,把用户的输入value传给worker,在worker里进行各种花样解析,处理完后传给主线程进行渲染展示,worker工作时不影响用户操作别的表单项。oh,多么丝滑的体验。

场景3:table导出大文件Excel

表格是我们经常接触的东西,当系统里有table表格的时候,那么它大概率还会伴着导出excel的需求。当我们扛着40米大刀架到后端脖子上,后端表示:要excel没有,要命一条!ok,关键时候还得靠我们前端拯救世界。

思路分析

  • step1 :通过exceljs构建表格相关的参数
  • step2:传入相关的数据,然后转换为blob流
  • step3:最后通过file-save导出

如果你只是这样吭哧吭哧的做了,那么产品经理一定会举着他们80米的大刀来问你做了个什么玩意儿。好的,坚强的前端仔绝不认输,我们来优化一下。

首先创建worker线程,通过postmessage向worker线程传递相应的excel数据,在worker线程中通过exceljs构建表格相关数据,然后转换为blob流,接着将生成的blob流通过postmessage传回来主线程,最后通过file-save导出

以上列举了3个场景,希望能带给大家一些启发,结合自己的业务场景考虑要不要使用。当然这么纯分析比较抽象,下面让我们结合具体的demo感受一下worker的魅力。当然也不是任何场景都适合worker的,那么什么场景下不适用呢,由于篇幅原因,具体请移步到下一篇文章-提升用户体验方案之Web Worker—Worker2