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

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相关内容

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

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

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

2. worker

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

js 复制代码
[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的继承关系。

首先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() 构造函数即可,它接收两个参数

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

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

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

js 复制代码
// 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
js 复制代码
// 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,并且被直接传递回来,接收到的也不是原来的那个值了。

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

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

3.3 监听错误信息

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

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

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

js 复制代码
// 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)
});
js 复制代码
// 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 线程的影响略有不同。

js 复制代码
// main.js(主线程)
const myWorker = new Worker('/worker.js'); // 创建worker
myWorker.terminate(); // 关闭worker
js 复制代码
// 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

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

js 复制代码
// 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');
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');
        }
    } 
});

运行结果如下:

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

在 worker 线程内部关闭 worker

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

js 复制代码
// main.js(主线程)
const myWorker = new Worker('/worker.js'); // 创建 worker
myWorker.addEventListener('message', e => {
    console.log(e.data);
});
myWorker.postMessage('Greeting from Main.js');
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');
        }
    }
});

运行结果如下:

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

3.5 Worker 线程引用其他js文件

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

js 复制代码
// utils.js
const add = (a, b) => a + b;
js 复制代码
// 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 线程!

js 复制代码
// main.js(主线程)
const worker = new Worker('/worker.js', {
    type: 'module'  // 指定 worker.js 的类型
});
js 复制代码
// utils.js
export default add = (a, b) => a + b;
js 复制代码
// 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 错误。

js 复制代码
// 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,进行并行计算,当所有线程处理完成后,再进行主线程的下一步处理。这样能缩短处理时间,更进一步的提升用户体验

场景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

相关推荐
猿饵块5 分钟前
cmake--get_filename_component
java·前端·c++
大表哥616 分钟前
在react中 使用redux
前端·react.js·前端框架
十月ooOO21 分钟前
【解决】chrome 谷歌浏览器,鼠标点击任何区域都是 Input 输入框的状态,能看到输入的光标
前端·chrome·计算机外设
qq_3391911421 分钟前
spring boot admin集成,springboot2.x集成监控
java·前端·spring boot
pan_junbiao29 分钟前
Vue使用代理方式解决跨域问题
前端·javascript·vue.js
明天…ling1 小时前
Web前端开发
前端·css·网络·前端框架·html·web
ROCKY_8171 小时前
web前端-HTML常用标签-综合案例
前端·html
海石1 小时前
从0到1搭建一个属于自己的工作流站点——羽翼渐丰(bpmn-js、Next.js)
前端·javascript·源码
Q186000000001 小时前
在HTML中添加图片
前端·html
傻虎贼头贼脑1 小时前
day21JS-npm中的部分插件使用方法
前端·npm·node.js