大家好,很高兴又见面了,我是"高级前端进阶",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发,您的支持是我不断创作的动力。
前言
在构建 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/