React中使用多線程—Web Worke
前言
作為一個前端開發,如果你還停留在每天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/