大家好,很高興又見面了,我是"進階前端進階",由我帶着大家一起關注前端前沿、深入前端底層技術,大家一起進步,也歡迎大家關注、點贊、收藏、轉發,您的支援是我不斷創作的動力。
前言
在建構 Web 應用程式時,編寫 CPU 密集型代碼一直存在諸多困難,比如:難以獲得跨浏覽器和 JavaScript 引擎的可預測運作時間、以不同的方式優化代碼路徑、不幹擾使用者體驗。
2010 年,Web Workers 橫空出世,其允許将 CPU 密集型的程式轉移到單獨的線程上,進而保持主線程空閑。 這幾年随着 WebAssembly (WASM) 的快速發展,這種新的 Web 代碼類型也備受關注。
WebAssembly 提供了緊湊的二進制編譯目标格式,允許開發人員從選擇 C/C++ 和 Rust 等強類型語言以及 Go 和 TypeScript 等語言開始 Web 開發。 WebAssembly 解決了第一個核心問題,即跨浏覽器和環境來獲得可預測的、接近本機的性能。
同時,開發者還可以将 Web Workers 和 WebAssembly 結合起來,以獲得 WebAssembly 的一緻性和潛在性能優勢,以及與 Web Workers 在單獨線程中工作的優勢。
1.為什麼将 WebAssembly 代碼放入 Web Worker 中
将 WebAssembly 子產品放入 Web Worker 的關鍵收益是:消除了從主線程 fetch、編譯和初始化 WebAssembly 子產品以及調用子產品中給定函數的開銷, 進而主線程可以專心渲染和處理使用者互動。 考慮到 WebAssembly 通常用于處理 CPU 密集型代碼,将其與 Web Workers 結合可以獲得不錯的收益。
當然,任何事情都有兩面性,将 WebAssembly 代碼移到 Web Worker 也是如此。比如:
- 将資料從主線程傳輸到 Web Worker 的成本可能會很高,具體取決于相關資料的大小
- 在 Web Worker 中使用 WebAssembly 時,還需要處理額外的複雜性和邏輯。
- 将 WebAssembly 子產品放置在 Web Worker 中會使得主線程與 WASM 子產品互動方式變成異步,因為消息傳遞機制利用了事件偵聽器和回調。而且,Web Worker 也無法通路 DOM 等。
當然,這些都不是本文的讨論重點,本文将重點關注 WebAssembly 和 Web Worker 技術的結合。
2.将 WebAssembly 與 Web Worker 結合使用
假設有一個簡單的 WebAssembly 電腦子產品,可以使用輸入執行基本數學運算。 主線程和 Web Worker 之間的通信是通過傳遞消息進行,如主線程通過消息将資料傳遞給 Worker,在本例中是要操作的數字,然後将結果傳回到主線程。
在用戶端,主線程和工作線程都使用 postMessage 方法來傳遞消息。 下面是初始化和使用工作線程來托管 WebAssembly 子產品的代碼:
// worker.js
// 為缺少instantiateStreaming方法的浏覽器添加Polyfill
if (!WebAssembly.instantiateStreaming) {
WebAssembly.instantiateStreaming = async (resp, importObject) => {
const source = await (await resp).arrayBuffer();
return await WebAssembly.instantiate(source, importObject);
};
}
// 建立 Promise 來處理 Worker 調用
// 子產品仍在初始化
let wasmResolve;
let wasmReady = new Promise((resolve) => {
wasmResolve = resolve;
});
// 處理消息
self.addEventListener(
"message",
function (event) {
const { eventType, eventData, eventId } = event.data;
if (eventType === "INITIALISE") {
WebAssembly.instantiateStreaming(fetch(eventData), {}).then(
(instantiatedModule) => {
// instantiatedModule包含兩個屬性
// 1)module:表示已編譯的 WebAssembly 子產品的 WebAssembly.Module 對象, 該子產品可以再次執行個體化或通過 postMessage() 共享
// 2)instance:包含所有導出的 WebAssembly 函數的 WebAssembly.Instance 對象
const wasmExports = instantiatedModule.instance.exports;
// 解決消息何時導出的問題
// 執行函數過來
wasmResolve(wasmExports);
// 将初始化消息發送回主線程
self.postMessage({
eventType: "INITIALISED",
eventData: Object.keys(wasmExports),
});
}
);
} else if (eventType === "CALL") {
wasmReady
.then((wasmInstance) => {
const method = wasmInstance[eventData.method];
const result = method.apply(null, eventData.arguments);
self.postMessage({
eventType: "RESULT",
eventData: result,
eventId: eventId,
});
})
.catch((error) => {
self.postMessage({
eventType: "ERROR",
eventData:
"An error occured executing WASM instance function: " +
error.toString(),
eventId: eventId,
});
});
}
},
false
);
下面對代碼中的關鍵方法進行說明:
- WebAssembly.instantiate(bufferSource, importObject) :允許開發者編譯和執行個體化 WebAssembly 代碼。
- WebAssembly.instantiateStreaming(source, importObject):直接從流式底層源編譯并執行個體化 WebAssembly 子產品,是加載 Wasm 代碼的最有效、最優化的方式。其中 source 表示想要流式傳輸、編譯和執行個體化的 Wasm 子產品的底層源。
在第一個代碼塊中為 instantiateStreaming 提供了一個基本的 polyfill,這是推薦的擷取、編譯和初始化 WebAssembly 程式的方法。 然後,繼續為工作線程添加一個事件監聽器,它監聽兩個事件 INITIALISE 和 CALL。 INITIALISE 運作 WASM 初始化步驟,CALL 運作帶有參數的給定函數。
現在對于主線程,假設它包含在 main.js 中。接着發送一條 INITIALISE 消息并偵聽一條 RESULT 消息,同時在相應的 Promise 中解析該消息:
// main.js
function wasmWorker(modulePath) {
// 建立一個稍後與之互動的對象
const proxy = {};
// 跟蹤正在發送的消息
// 這樣我們就可以正确地解決它們
let id = 0;
let idPromises = {};
return new Promise((resolve, reject) => {
const worker = new Worker("worker.js");
worker.postMessage({ eventType: "INITIALISE", eventData: modulePath });
// 發送INITIALISE消息,modulePath為子產品位址
worker.addEventListener("message", function (event) {
const { eventType, eventData, eventId } = event.data;
if (eventType === "INITIALISED") {
const methods = event.data.eventData;
// wasm導出的所有方法
methods.forEach((method) => {
proxy[method] = function () {
return new Promise((resolve, reject) => {
worker.postMessage({
eventType: "CALL",
eventData: {
method: method,
arguments: Array.from(arguments),
// arguments is not an array
},
eventId: id,
});
idPromises[id] = { resolve, reject };
id++;
});
};
});
resolve(proxy);
return;
} else if (eventType === "RESULT") {
if (eventId !== undefined && idPromises[eventId]) {
idPromises[eventId].resolve(eventData);
delete idPromises[eventId];
}
} else if (eventType === "ERROR") {
if (eventId !== undefined && idPromises[eventId]) {
idPromises[eventId].reject(event.data.eventData);
delete idPromises[eventId];
}
}
});
worker.addEventListener("error", function (error) {
reject(error);
});
});
}
主線程代碼的目的是處理來自正在處理 WASM 代碼的 Worker 的消息發送和接收。 是通過一個從主線程互動的代理對象,而不是直接與 WASM 執行個體對象互動。 而 ID 用于跟蹤請求和響應,以確定對正确 Promise 的調用。 這個抽象公開了一個可以與之互動的對象,就像原始導出對象的異步版本一樣。
然後可以繼續使用上面定義的 wasmWorker 方法:
// main.js
wasmWorker("./calculator.wasm").then((wasmProxyInstance) => {
wasmProxyInstance
.add(2, 3)
.then((result) => {
console.log(result);
// 輸出 5
})
.catch((error) => {
console.error(error);
});
wasmProxyInstance
.divide(100, 10)
.then((result) => {
console.log(result);
// 輸出 10
})
.catch((error) => {
console.error(error);
});
});
3.使用 Inline Web Workers
Inline Web Workers 表示一種方法,其可以聲明 Web Worker 代碼,而無需将該代碼放入單獨的腳本檔案中。 本質是通過建立 Blob 對象來實作的,是一種将 JavaScript 代碼轉換為虛拟 URL 的方法,然後在建立 Worker 對象時可以使用該虛拟 URL 來代替 URL 參數。
const blob = new Blob([
`onmessage = function(e) {
console.log('Message received from main script: ' + JSON.stringify(e.data));
}`,
]);
const blobURL = window.URL.createObjectURL(blob);
const myInlineWorker = new Worker(blobURL);
myInlineWorker.postMessage({ name: "test1", text: "hello inline worker" });
Blob 方法嘗試将建立的函數體作為字元串(使用 toString),開發者可以将其傳遞給 createObjectURL 方法。接下來讓我們嘗試内聯上面的示例代碼, 請注意:本示例的目标不是編寫生産級内聯 Web Worker,而是示範它們是如何工作的!
function wasmWorker(modulePath) {
let worker;
const proxy = {};
let id = 0;
let idPromises = {};
// 為缺少instantiateStreaming的浏覽器添加Polyfill
if (!WebAssembly.instantiateStreaming) {
WebAssembly.instantiateStreaming = async (resp, importObject) => {
const source = await (await resp).arrayBuffer();
return await WebAssembly.instantiate(source, importObject);
};
}
return new Promise((resolve, reject) => {
worker = createInlineWasmWorker(inlineWasmWorker, modulePath);
// 建立inline worker
worker.postMessage({ eventType: "INITIALISE", data: modulePath });
worker.addEventListener("message", function (event) {
const { eventType, eventData, eventId } = event.data;
if (eventType === "INITIALISED") {
const props = eventData;
props.forEach((prop) => {
proxy[prop] = function () {
return new Promise((resolve, reject) => {
worker.postMessage({
eventType: "CALL",
eventData: {
prop: prop,
arguments: Array.from(arguments),
},
eventId: id,
});
idPromises[id] = { resolve, reject };
id++;
});
};
});
resolve(proxy);
return;
} else if (eventType === "RESULT") {
if (eventId !== undefined && idPromises[eventId]) {
idPromises[eventId].resolve(eventData);
delete idPromises[eventId];
}
} else if (eventType === "ERROR") {
if (eventId !== undefined && idPromises[eventId]) {
idPromises[eventId].reject(event.data.eventData);
delete idPromises[eventId];
}
}
});
worker.addEventListener("error", function (error) {
reject(error);
});
});
function createInlineWasmWorker(func, wasmPath) {
if (!wasmPath.startsWith("http")) {
if (wasmPath.startsWith("/")) {
wasmPath = window.location.href + wasmPath;
} else if (wasmPath.startsWith("./")) {
wasmPath = window.location.href + wasmPath.substring(1);
}
}
// 確定wasm路徑是絕對路徑并轉為IIFE
func = `(${func.toString().trim().replace("WORKER_PATH", wasmPath)})()`;
const objectUrl = URL.createObjectURL(
new Blob([func], { type: "text/javascript" })
);
const worker = new Worker(objectUrl);
URL.revokeObjectURL(objectUrl);
return worker;
}
// inlineWasmWorker方法定義
function inlineWasmWorker() {
let wasmResolve;
const wasmReady = new Promise((resolve) => {
wasmResolve = resolve;
});
self.addEventListener(
"message",
function (event) {
const { eventType, eventData, eventId } = event.data;
if (eventType === "INITIALISE") {
WebAssembly.instantiateStreaming(fetch("WORKER_PATH"), {})
.then((instantiatedModule) => {
const wasmExports = instantiatedModule.instance.exports;
wasmResolve(wasmExports);
self.postMessage({
eventType: "INITIALISED",
eventData: Object.keys(wasmExports),
});
})
.catch((error) => {
console.error(error);
});
} else if (eventType === "CALL") {
wasmReady.then((wasmInstance) => {
const prop = wasmInstance[eventData.prop];
const result =
typeof prop === "function"
? prop.apply(null, eventData.arguments)
: prop;
self.postMessage({
eventType: "RESULT",
eventData: result,
eventId: eventId,
});
});
}
},
false
);
}
}
這種方法非常有效,開發者可以使用與上面相同的代碼來使用此抽象(即接口沒有改變)。
4.wasm-worker 讓一切變得容易
wasm-worker 的目标是将 WebAssembly 子產品轉移到單獨的線程中,同時讓 WebAssembly 和 worker 的結合更加容易。
Move a WebAssembly module into its own thread
wasm-worker 目前僅支援浏覽器環境,因為它底層使用 fetch、Worker 和大量的 WebAssembly API,雖然受到大多數主流浏覽器的支援,但是對于舊版浏覽器依然需要使用 polyfill。
if (!window.fetch || !window.Worker || !window.WebAssembly) {
...
} else {
...
}
同時,為了在 NodeJS 環境中使用 wasm-worker,Web Workers 必須使用像 node-webworker 這樣的 polyfill 庫進行填充。為了使用 wasm-worker,需要首先安裝它:
npm install --save wasm-worker
如果沒有在項目中使用 npm,則可以使用 UMD build 将 wasmWorker 包含在帶有 <script> 标記的 dist 檔案夾中。安裝了 wasm-worker 後,假設有 CommonJS 環境,就可以按照下面的方式導入并使用:
import wasmWorker from "wasm-worker";
// 假設有一個“add.wasm”子產品導出單個函數“add”
wasmWorker("add.wasm")
.then((module) => {
return module.exports.add(1, 2);
})
.then((sum) => {
console.log("1 + 2 = " + sum);
})
.catch((ex) => {
// ex 是表示異常的字元串
console.error(ex);
});
// 也可以在worker内部運作js函數
// 例如:通路 importObject
wasmWorker("add.wasm")
.then((module) => {
return module.run(
({
// module,
// importObject,
instance,
params,
}) => {
// 同步運作
const sum = instance.exports.add(...params);
return "1 + 2 = " + sum;
},
[1, 2]
);
})
.then((result) => {
console.log(result);
});
5.本文總結
本文主要和大家介紹如何将 WebAssembly 和 Web Worker做結合。相信通過本文的閱讀,大家對 如何在 WebAssembly 場景使用 Web Worker 會有一個初步的了解,同時也會有自己的看法。
因為篇幅有限,文章并沒有過多展開,如果有興趣,可以在我的首頁繼續閱讀,同時文末的參考資料提供了大量優秀文檔以供學習。最後,歡迎大家點贊、評論、轉發、收藏!
參考資料
https://www.sitepen.com/blog/using-webassembly-with-web-workers
https://github.com/mbasso/wasm-worker
https://developer.mozilla.org/en-US/docs/WebAssembly/JavaScript_interface/instantiate
https://www.codemag.com/Article/2101071/Understanding-and-Using-Web-Workers
https://blog.csdn.net/weixin_33962621/article/details/88832260
https://developer.mozilla.org/en-US/docs/WebAssembly/JavaScript_interface/Instance
https://www.hongkiat.com/blog/web-workers-javascript-api/
https://soshace.com/web-workers-concurrency-in-java-script/
https://blog.cloudflare.com/webassembly-on-cloudflare-workers/