前言
浏览器是一个多进程多线程的架构,以 Chrome
为例,每一个 tab
页都是一个单独的渲染进程。在这个渲染进程下,有 JS
执行线程和渲染线程。
JS
执行线程与渲染线程是互斥的,这就导致了我们在执行一些长任务的时候,会阻塞渲染线程,具体的表现可能有:
- 动画卡顿
- 点击按钮/输入框输入没反应等等
在 HTML5
中,浏览器提出了 Web Worker
这种技术,它允许在主线程之外运行脚本,这样就可以在后台执行一些耗时的任务,而不会阻塞 JS
执行线程,从而提高了 Web
应用的性能和响应性。
web worker简介
Web Worker
的一些主要用途和优点包括:
- 并行计算:
Web Worker
提供了一个在后台线程中执行JavaScript
代码的环境,可以在这个环境中进行并行计算,提高页面性能和响应速度。 - 执行耗时任务:对于需要较长时间来完成的任务,比如大量数据处理、复杂算法运算、图片处理等,可以将这些任务放在
Web Worker
中执行,避免阻塞渲染线程。 - 提高用户体验:通过将一些耗时的操作放在
Web Worker
中执行,可以提高页面的响应速度和用户体验,使用户感受到页面更加流畅。 - 并发处理:使用多个
Web Worker
实例可以实现更高级的并发处理,从而更有效地利用多核CPU
。
尽管 Web Worker
提供了很多优点,但也需要注意以下几点:
- 无法访问 DOM:
Web Worker
运行在独立的线程中,无法直接访问DOM
和一些浏览器 API,因此主要用于处理纯粹的计算任务和网络请求。 - 通信开销:由于
Web Worker
与主线程是隔离的,它们之间的通信需要通过消息传递,因此可能会存在一定的通信开销。 - 内存消耗:每个
Web Worker
都会占用一定的内存,如果过多地创建Web Worker
,可能会导致内存消耗过大。
Vite+React 使用 demo
下面以 Vite+React
为例,介绍一下如何使用 Web Worker
,并展示 Web Worker
不阻塞渲染的特性。
首先我们来做两件事情,第一件事情是写一个简单的动画:
1@keyframes ball-animation {
2 0% {
3 top: 200px;
4 }
5 50% {
6 top: 100px;
7 }
8 100% {
9 top: 200px;
10 }
11}
12.ball {
13 width: 100px;
14 height: 100px;
15 top: 200px;
16 left: 200px;
17 background-color: red;
18 position: absolute;
19 border-radius: 50%;
20 animation: ball-animation infinite 1s;
21}
22
可以看到是一个简单的小球跳动的动画,然后我们在组件挂载 3S
之后,执行一段 JS
长任务,这里以从 0
累计到 4000000000
为例:
1 useEffect(() => {
2 setTimeout(() => {
3 console.log("长任务开始");
4 console.time("calculate");
5 let count = 0;
6 for (let i = 0; i < 4000000000; i++) {
7 count++;
8 }
9 console.log("长任务结束");
10 console.timeEnd("calculate");
11 }, 3000);
12 }, []);
可以看到小球在长任务开始之后动画就停止了,整个页面也是处于一个不可交互的状态。这就是我们的JS执行线程阻塞了渲染线程。
然后我们尝试使用 worker
去进行这个计算,看看是什么效果。
首先在 public
目录下新建一个 calculate.worker.js
文件,填入以下的内容:
1self.onmessage = function (e) {
2 const data = e.data;
3 console.log("长任务开始");
4 console.time("calculate");
5 let count = 0;
6 for (let i = 0; i < data; i++) {
7 count++;
8 }
9 console.log("长任务结束");
10 console.timeEnd("calculate");
11 self.postMessage("处理完成");
12};
然后我们在主线程中来调用这个 worker
:
1 useEffect(() => {
2 setTimeout(() => {
3 const worker = new Worker("/calculate.worker.js");
4 worker.postMessage(4000000000);
5 worker.onmessage = (e) => {
6 console.log("收到worker的消息", e.data);
7 };
8 }, 1000);
9 }, []);
然后来看一下执行效果:
可以看到,已经不再阻塞页面的渲染,整个过程十分丝滑。
Worker引入外部函数
那在我们实际的开发过程中,经常是需要引入一些外部第三方库去搭配使用的。在 worker
中,引入第三方库需要使用 importScripts
这个方法。这样的话就需要我们打成一个 umd
的包来引入。
使用示例如下:
1importScripts(
2 "https://cdn.bootcdn.net/ajax/libs/lodash.js/4.17.21/lodash.core.min.js"
3);
4self.onmessage = function (e) {
5 const data = e.data;
6 const res = self._.max(data)
7 self.postMessage(res)
8};
这里先引入 lodash
的 cdn
包,然后用来计算最大值。
主线程发送数据如下:
1 const worker = new Worker("/calculate.worker.js");
2 worker.postMessage([1, 2, 3, 4, 5]);
3 worker.onmessage = (e) => {
4 console.log("收到worker的消息", e.data);
5};
动态封装
下面我们来封装一个 createWorker
方法,可以动态创建 worker
并执行调用。
完整代码如下,先看代码,再来解释:
1export const createWorker = ({ executor, params }) => {
2 return new Promise((resolve, reject) => {
3 const funcStr = executor.toString();
4 const blob = new Blob([
5 `onmessage = function(e) {
6 const func = eval('(' + e.data.funcStr + ')');
7 function convertStringsToFunctions(obj) {
8 function dfs(obj) {
9 for (const key in obj) {
10 if (typeof obj[key] === 'string') {
11 try {
12 obj[key] = eval('(' + obj[key] + ')');
13 } catch (error) {
14 }
15 } else if (typeof obj[key] === 'object' && obj[key] !== null) {
16 dfs(obj[key]);
17 }
18 }
19 }
20 dfs(obj);
21 return obj;
22 }
23 const data = convertStringsToFunctions(e.data.funcData);
24 Promise.resolve(func(data)).then(res=>{
25 postMessage({
26 type: 'success',
27 data: res
28 })
29 }).catch(err=>{
30 postMessage({
31 type: 'error',
32 data: err
33 })
34 })
35}`,
36 ]);
37
38 const url = URL.createObjectURL(blob);
39 const worker = new Worker(url);
40
41 worker.onmessage = function (e) {
42 worker.terminate();
43 URL.revokeObjectURL(url);
44 console.log(e.data.type);
45 if (e.data.type === "success") {
46 resolve(e.data.data);
47 } else {
48 reject(e.data.data);
49 }
50 };
51
52 function convertFunctionsToStrings(params) {
53 function dfs(obj) {
54 for (const key in obj) {
55 if (typeof obj[key] === "function") {
56 obj[key] = obj[key].toString();
57 } else if (typeof obj[key] === "object" && obj[key] !== null) {
58 dfs(obj[key]);
59 }
60 }
61 }
62 dfs(params);
63 return params;
64 }
65 const data = convertFunctionsToStrings(cloneDeep(params));
66 worker.postMessage({ funcStr, funcData: data });
67 });
68};
createWorker
接收两个参数,一个是执行函数,一个是执行函数所需的参数- 我们需要把执行函数放到
worker
执行,使用postMessage
传递,postMessage
是不支持传输函数的,所以需要把函数转成字符串。在worker
接收到之后再使用eval
来调用。由于可能会有深层次的对象函数嵌套,所以这里需要递归。 - 同理,
params
中的函数也需要转成字符串再交给worker
。 - 为了兼容异步函数,这里统一把执行函数都先转成一个
Promise
- 执行函数执行完毕后,通知主线程并返回结果
使用示例
使用示例如下:
1import { useEffect, useState } from "react";
2import * as lodash from "lodash";
3import { createWorker } from "./worker";
4function App() {
5 useEffect(() => {
6 const run = async () => {
7 const res = await createWorker({
8 executor: ({ isEmpty, list }) => {
9 return new Promise((resolve) => {
10 const res = list.map(isEmpty);
11 resolve(res);
12 });
13 },
14 params: {
15 isEmpty: (value) => !!value,
16 list: [0, undefined, null, ""],
17 },
18 });
19 console.log("res", res);
20 };
21 run();
22 }, []);
23}
24
25export default App;
并发控制
上面已经提到过,如果 worker
创建太多也会导致资源占用太多,可能会消耗大量的内存。
所以这里需要做一个并发的控制,可以使用 window.navigator.hardwareConcurrency
来获取 cpu
的数量。
可以实现下面的一个 DynamicWorker
类,同样的先把代码贴出来,再来解释。
1export class DynamicWorker {
2 static getInstance(params = {}) {
3 const { newInstance = false } = params;
4 if (newInstance) {
5 return new DynamicWorker();
6 }
7 if (!DynamicWorker.instance) {
8 DynamicWorker.instance = new DynamicWorker();
9 }
10
11 return DynamicWorker.instance;
12 }
13 core = window.navigator.hardwareConcurrency || 4;
14 runningCount = 0;
15 queue = [];
16 createWorker = ({ executor, params }) => {
17 const createTask = () => {
18 return new Promise((resolve, reject) => {
19 const funcStr = executor.toString();
20 const blob = new Blob([
21 `onmessage = function(e) {
22 const func = eval('(' + e.data.funcStr + ')');
23 function convertStringsToFunctions(obj) {
24 function dfs(obj) {
25 for (const key in obj) {
26 if (typeof obj[key] === 'string') {
27 try {
28 obj[key] = eval('(' + obj[key] + ')');
29 } catch (error) {
30 }
31 } else if (typeof obj[key] === 'object' && obj[key] !== null) {
32 dfs(obj[key]);
33 }
34 }
35 }
36 dfs(obj);
37 return obj;
38 }
39 const data = convertStringsToFunctions(e.data.funcData);
40 Promise.resolve(func(data)).then(res=>{
41 postMessage({
42 type: 'success',
43 data: res
44 })
45 }).catch(err=>{
46 postMessage({
47 type: 'error',
48 data: err
49 })
50 })
51 }`,
52 ]);
53
54 const url = URL.createObjectURL(blob);
55 const worker = new Worker(url);
56
57 worker.onmessage = function (e) {
58 worker.terminate();
59 URL.revokeObjectURL(url);
60 if (e.data.type === "success") {
61 resolve(e.data.data);
62 } else {
63 reject(e.data.data);
64 }
65 };
66
67 function convertFunctionsToStrings(params) {
68 function dfs(obj) {
69 for (const key in obj) {
70 if (typeof obj[key] === "function") {
71 obj[key] = obj[key].toString();
72 } else if (typeof obj[key] === "object" && obj[key] !== null) {
73 dfs(obj[key]);
74 }
75 }
76 }
77 dfs(params);
78 return params;
79 }
80 const data = convertFunctionsToStrings(cloneDeep(params));
81 worker.postMessage({ funcStr, funcData: data });
82 });
83 };
84
85 if (this.runningCount < this.core) {
86 this.runningCount = this.runningCount + 1;
87 return createTask();
88 } else {
89 return new Promise((resolve, reject) => {
90 this.queue.push(() => {
91 this.runningCount = this.runningCount + 1;
92 createTask().then(resolve).catch(reject);
93 });
94 });
95 }
96 };
97 next = () => {
98 if (this.queue.length > 0) {
99 const task = this.queue.shift();
100 task();
101 }
102 };
103}
DynamicWorker
默认是单例模式,当然也可以创建多实例。- 任务池不超过核心数量
- 如果当前任务池已满,则把任务推到队列里面,等待空闲时候再执行。
使用起来的方式也大同小异:
1const run = async () => {
2 const dynamicWorker = DynamicWorker.getInstance();
3 const res = await dynamicWorker.createWorker({
4 executor: ({ isEmpty, list }) => {
5 return new Promise((resolve) => {
6 const res = list.map(isEmpty);
7 resolve(res);
8 });
9 },
10 params: {
11 isEmpty: (value) => !!value,
12 list: [0, undefined, null, ""],
13 },
14 });
15 console.log("res", res);
16};
最后
vite环境可以用如下方式使用worker,可以使用相对路径导入任何位置的js作为worker,并且js内部也可以像普通js文件一样使用import导入任何包new Worker(new URL(’./exampleWorker.js’, import.meta.url), { type: ‘module’ });