前言
作为一个前端开发,如果你还停留在每天CRUD,还停留在切图/画图,还停留在和后端同学对某个API设计的是否合理而大打出手时,是时候停下来了。我们要变强,我们需要对我们经手的项目进行一番改造和优化。这才是我们能够变强的方式。而不是,沉浸在无休止的争吵和埋怨中。
众所周知,Javascript是一种「单线程语言」。因此,如果我们执行任何耗时任务,它将阻塞UI交互。用户需要等待任务完成才能执行其他操作,这会给用户体验带来不好的影响。
其实,针对此类问题,我们有很多解决方案,
- 例如将耗时任务分割成多个短任务,并让其在多个渲染帧内执行,给UI交互(也就是UI渲染)留有时间,
- 也可以通过回调的方式,在UI交互触发后,在进行耗时任务的操作。
- 亦或者我们可以指定一个「优先队列」,当高优先级任务被执行时,低优先级任务(耗时任务)被降级处理(冷处理),直到高优先级任务被执行后再执行剩余低优先级任务。(这其实就是React并发的核心要点)
- ...等等
上述列举了很多解决方式,他们都有一个共同特点 - 由于JS单线程属性,它们只是将一些耗时任务从一个渲染帧分割或者延后到多个渲染帧内。本质上还是单线程的处理方式。
而,今天我们就介绍一种利用「多线程(Web Worker)处理React中的耗时操作」。我们之前也在前面讲过Web Worker的相关内容。
- Web性能优化之Worker线程(上)
- Web性能优化之Worker线程(下)
今天我们就详细的介绍如何在前端项目中使用Web Worker用于处理耗时任务,然后将长任务利用多线程的分割出主线程,然后给主线程留足时间去回应更紧急的用户操作,优化用户操作。
好了,天不早了,干点正事哇。
我们能所学到的知识点
- Web Workers
- React 的并发模式
React 中使用Web Worker
- useWorker
- Web Worker的注意点
1. Web Workers
虽然,在之前的文章中介绍过Web Worker,但是为了最大限度的兼容大家的学习情况,还是打算简单介绍一些。
图片
如上图所示,JS中存在三中Worker,按照实现可以分为三类。
- Web Worker
- Shared Web Worker
- Service Worker
而我们今天的主角-Web Worker是我们最常见的。
Web Worker是在后台运行的脚本,不会影响用户界面,因为它在「单独的线程中运行」,而不是在主线程中。
因此,它不会导致任何阻塞用户交互。Web Worker主要用于在Web浏览器中执行耗时任务,如对大量数据进行排序、CSV导出、图像处理等。
图片
从上图中,如果耗时任务在主线程中执行会阻塞UI渲染,当用Web Worker代理耗时任务后,主线程并不会发生阻塞,也就是说「它强任它强,老子Web Worker」
2. React 的并发模式
讲到这里,可能有些心细的小伙伴就会产生疑问。既然都是处理耗时任务。那么,React 18的并发渲染也可以达到此种目的。也就是使用React.useTransition()将耗时任务设定为过渡任务,通过对某些操作标记为「低优先级」,在页面渲染过程中给「高优先级」的任务让步。
之前我们在
- React 18 如何提升应用性能
- React 并发原理
中,对React 并发有过介绍。(想了解更多可以翻阅上述文章)。这里我们就简单阐述一下为什么React 并发只是锦上添花,缺不能药到病除。
如果,你仔细看过上面的文章,你就会有有一个清晰的认知:
React并发模式并不会并行运行任务。它会将非紧急任务移动到过渡状态,并立即执行紧急任务。它「使用相同的主线程」来处理它。
下面是之前的一个示例。
图片
使用useTransition只是告知React,有一些操作是不紧急的,如果遇到更高级的任务,不紧急的任务可以不立马显示,而是在处理完高优先级任务后才进行低优先级任务的渲染。
图片
例如,如果一个表格正在渲染一个大型数据集,而用户尝试搜索某些内容,React会将任务切换到用户搜索并首先处理它。
图片
正如我们在图片中看到的那样,
「紧急任务是通过上下文切换」来处理的
React的并发模式,只是让我们的项目「拥有了辨别优先级的能力」,并且在「一定限制条件下」能够快速响应用户操作。但是,但是,但是,如果一个「单个任务已经超过了浏览器一帧的渲染时间」,那虽然设置了startTransition,但是也「无能为力」。如果存在这种情况,那就只能人为的将单个任务继续拆分或者利用Web Worker进行多线程处理了。
当使用Web Worker进行相同任务时,表格渲染会在一个独立的线程中并行运行。
图片
3. React 中使用Web Worker
由于我们在项目开发时,使用不同的打包工具(vite/webpack)。幸运的是,最新版的vite/webpack都支持Web Worker了。
我们可以通过
- new URL()的方式 --vite/webpack都支持
new Worker(
new URL(
'./worker.js',
import.meta.url
)
);
- import方式 只有vite支持
import MyWorker from './worker?worker'
const worker = new MyWorker()
更详细的处理可以参考它们的官网
- vite_web_worker[1]
- webpack_web_worker[2]
当然,我们在项目代码中如何实例化Worker对象也有很多方式。下面就介绍两种。
通过引入文件路径
index.js
// 创建一个新的Worker对象,
// 指定要在Worker线程中执行的脚本文件路径
const myWorker = new Worker(
new URL('./worker.js', import.meta.url)
);
// 向Worker发送消息
myWorker.postMessage(789789);
// 监听来自Worker的消息
myWorker.onmessage = function(event) {
console.log("来自worker的消息: ", event.data);
};
worker.js
// 在Worker脚本中接收并处理消息
self.onmessage = function(event) {
console.log("来自主线程的消息: ", event.data);
// 执行一些计算密集型的任务
let result = doSomeHeavyTask(event.data);
// 将结果发送回主线程
self.postMessage(result);
};
const doSomeHeavyTask = (num) => {
// 模拟一些计算密集型的操作
let result = 0;
for (let i = 0; i < num; i++) {
result += i;
}
return result;
};
Blob 方式
index.js
// 定义要在Worker中执行的脚本内容
const workerScript = `
self.onmessage = function(e) {
console.log('来自主线程的消息: ' + e.data);
self.postMessage('向主线程发送消息: ' + 'Hello, ' + e.data);
};
`;
// 创建一个Blob对象,指定脚本内容和类型
const blob = new Blob(
[workerScript],
{ type: 'application/javascript' }
);
// 使用URL.createObjectURL()方法创建一个URL,用于生成Worker
const blobURL = URL.createObjectURL(blob);
// 生成一个新的Worker
const worker = new Worker(blobURL);
// 监听来自Worker的消息
worker.onmessage = function(e) {
console.log('来自worker的消息: ' + e.data);
};
// 向Worker发送消息
worker.postMessage('Front789');
使用Blob构建方式生成Web Worker有以下几个优势:
优势 | 描述 |
动态生成 | 可以动态地生成 |
内联脚本 | 将 |
便捷性 | 更方便地创建和管理 |
安全性 |
|
总的来说,使用Blob构建方式生成Web Worker可以提供更灵活、便捷和安全的方式来管理和使用Worker实例。
4. useWorker
上面一节中,我们介绍了如何在前端项目中使用Web Worker。无论是使用文件导入的方式还是Blob的方式。都需要写一些模板代码。虽然能解决我们的问题,但是使用方式还是不够优雅。
功能介绍
下面,我们就介绍一种更优雅的方式- 使用useWorker库。
useWorker[3]是一个库,它使用React Hooks在简单的配置中使用Web Worker API。它支持在不阻塞UI的情况下执行耗时任务,支持使用Promise而不是事件监听器。
我们可以从官网看到相关的介绍信息。
图片
其中,WORKER_STATUS用于返回Web Worker的状态信息。
图片
我们可以通过向useWorker中传递一个回调函数,然后该函数就会在对应的Web Worker中执行。
const sortNumbers = numbers => ([...numbers].sort())
const [
sortWorker,
{
status: sortStatus,
kill: killSortWorker
}
] = useWorker(sortNumbers);
大家可以对比之前的用原生构建Web Worker实例。我们可以抛弃冗余代码,并且返回的函数(sortWorker)还支持Promise。
也就意味着我们使用xx.then()或者 await xx()以同步的写法获取异步结果。
import React from "react";
import { useWorker } from "@koale/useworker";
const numbers = [...Array(5000000)].map(
e => ~~(Math.random() * 1000000)
);
const sortNumbers = nums => nums.sort();
const Example = () => {
const [sortWorker] = useWorker(sortNumbers);
const runSort = async () => {
const result = await sortWorker(numbers);
};
return (
<button type="button" onClick={runSort}>
运行耗时任务
</button>
);
};
并且,useWorker是一个大小为3KB的库,我们还不需要有太多的资源负担。既然,有这么多强势的功能,那我们就来看看它到底是何方神圣。
安装依赖
用我们御用脚手架f_cli[4],来构建一个前端项目(npx f_cli_f craete worker_demo)。
要将useWorker()添加到React项目中,请使用以下命令:
npm install @koale/useworker --force
由于useworker源码中使用了peerDependencies指定了React版本为^16.8.0。如果大家在17/18版本的React环境下,会发生错误。所以我们可以使用--force忽略版本限制。(这里大家可以放心使用,它内部的只是用到简单的hook)
安装完包后,导入useWorker()。
import {
useWorker,
WORKER_STATUS
} from "@koale/useworker";
我们从库中导入useWorker和WORKER_STATUS。useWorker()钩子返回workerFn和controller。
- workerFn是一个允许在Web Worker中运行函数的函数。
- controller包含status和kill参数。
status参数返回Worker的状态
kill函数用于终止当前运行的Worker
案例展示
让我们通过一个示例来看看useWorker()。
使用useWorker()和主线程对大数组进行排序
SortingArray
首先,创建一个SortingArray组件,并添加以下代码:
工具代码
// 模拟耗时任务
const bubleSort = (arr: number[]): number[] =>{
const len = arr.length;
for (let i = 0; i < len; i++) {
for (let j = 0; j < len - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
}
}
}
return arr;
}
const numbers = [...Array(50000)].map(() =>
Math.floor(Math.random() * 1000000)
);
主要逻辑
import React,{ useState } from "react";
import {
useWorker,
WORKER_STATUS
} from "@koale/useworker";
function SortingArray() {
const [sortStatus, setSortStatus] = useState(false);
const [
sortWorker,
{
status: sortWorkerStatus
}
] = useWorker(bubleSort);
console.log("WebWorker status:", sortWorkerStatus);
const onSortClick = () => {
setSortStatus(true);
const result = bubleSort(numbers);
setSortStatus(false);
alert('耗时任务结束!')
console.log("处理结果", result);
};
const onWorkerSortClick = () => {
sortWorker(numbers).then((result) => {
console.log("使用WebWorker的处理结果", result);
alert('耗时任务结束!')
});
};
return (
<div>
<section >
<button
type="button"
disabled={sortStatus}
onClick={() => onSortClick()}
>
{sortStatus ?
`正在处理耗时任务...` :
`主线程触发耗时任务`
}
</button>
<button
type="button"
disabled={sortWorkerStatus === WORKER_STATUS.RUNNING}
onClick={() => onWorkerSortClick()}
>
{sortWorkerStatus === WORKER_STATUS.RUNNING
? `正在处理耗时任务...`
: `使用WebWorker处理耗时任务`
}
</button>
</section>
<section>
<span style={{ color: "white" }}>
打开控制台查验状态信息
</span>
</section>
</div>
);
}
export default SortingArray;
我们在SortingArray配置了两个操作
- onSortClick中按照常规处理,也就是在主线程中执行耗时操作
- onWorkerSortClick 中执行useWorker相关逻辑,并传递了bubleSort函数以使用Worker执行耗时的排序操作。
App.js
我们App.js中引入SortingArray组件,并且为了能让UI阻塞看的更明显,我们用JS来操作logo文件,让其不停的转动,每100毫秒旋转一次。
- 如果是一个阻塞主线程的任务,那么logo将会停止
- 如果主线程不阻塞,那logo会一直转动
import React from "react";
import SortingArray from "./SortingArray";
import logo from './assets/react.svg'
import "./App.css";
let turn = 0;
function infiniteLoop() {
const lgoo = document.querySelector(".logo");
turn += 8;
lgoo.style.transform = `rotate(${turn % 360}deg)`;
}
export default function App() {
React.useEffect(() => {
const loopInterval = setInterval(infiniteLoop, 100);
return () => clearInterval(loopInterval);
}, []);
return (
<>
<div >
<h1 >useWorker Demo</h1>
<header>
<img src={logo} className="logo" />
</header>
<hr />
</div>
<div>
<SortingArray />
</div>
</>
);
}
我们来看看分别点击对应按钮会发生啥?
上图是耗时任务在主线程中执行的效果。在执行期间,动画效果是阻塞的,也就意味着在多个帧的时间内,浏览器是无法执行额外的操作的。
我们用Chrome-performance来探查一下性能消耗。
我们可以看到事件:点击任务花费了7.85秒来完成整个过程,并且它阻塞了主线程7.85秒。
图片
而这个图,我们使用了Web Worker,在执行耗时任务的时候,动画还是执行原来的操作。也就是操作不会阻塞。因为useWorker在后台执行排序而不阻塞UI。这使得用户体验非常流畅。
和上面的分析方式一样,打开Performancetab,让我们看看这种方法的性能分析结果。
图片
我们截取主线程的部分数据,发现有任意时间段内,Scripting所占总时间的比例都很少,更大部分都是Idle也就是主线程处于空闲阶段,可以随时响应用户操作。
图片
而在对应的worker中确是一直在执行计算任务,丝毫没有片刻休息。
5. Web Worker的注意点
何时用Worker
我们之前的文章讲过,JS自从引入V8[5]后,在代码执行和内存处理上有了更高的优化。例如使用JIT[6],引入WebAssembly[7],热代码优先编译等。
但是呢,针对一些特殊的场景,上述的方式只能提供简单的优化,这样我们就需要另外的解决方案来处理这些棘手的问题。
当我们遇到如下情景,并有严重的性能问题,那就需要借助Web Worker一臂之力了
- 图像处理
- 对大型数据集进行排序或处理
- 带有大量数据的CSV或Excel导出
- 画布绘制
- 任何CPU密集型任务
Worker的限制
这个在之前介绍Web Worker的文章就介绍过,我们就直接拿来主义了。
- Web Worker无法访问window对象和document。
- 当Worker正在运行时,我们无法再次调用它,直到它完成或被终止。为了解决这个问题,我们可以创建两个或更多useWorker()钩子的实例。
- Web Worker无法返回函数,因为响应是序列化的。
- Web Worker受到终端用户机器可用CPU核心和内存的限制。
Reference
[1]vite_web_worker:https://cn.vitejs.dev/guide/features.html#web-workers
[2]webpack_web_worker:https://webpack.js.org/guides/web-workers/
[3]useWorker:https://github.com/alewin/useWorker
[4]f_cli:https://www.npmjs.com/package/f_cli_f
[5]V8:https://v8.dev/
[6]JIT:https://v8.dev/blog/maglev
[7]WebAssembly:https://webassembly.github.io/spec/core/