Wat to Wasm
撰写时间:2025-01-11
修订时间:2025-01-23
概述
本章以Wasm Text为视角,利用Wabt这个利器,先跳过.wasm文件的环节,直接与Wasm最后环节打交道。
在上一章,我们已使用Wabt工具将Wasm Text编译为Wasm二进制格式。但因为libwabt.js文件很大(共106,038行代码,没错,超过10万行,占用7.2m的磁盘空间),并且不支持JavaScript模块,因此在编写模块化的代码时带来了不小的麻烦。
在这节中,我们将通过特定的技术,将其转换为支持模块化的Promise。
共分为2步骤。
动态加载.js文件
创建ScriptUtils.js文件,内容如下:
export class ScriptUtils {
static LoadScript(url, callback) {
let script = ScriptUtils.GetExistedScript(url);
if (!script) {
script = document.createElement('script');
script.src = url;
document.head.appendChild(script);
}
script.addEventListener('load', () => {
callback();
});
}
static GetExistedScript(urlStr) {
let href = new URL(urlStr, window.location).href;
let scripts = document.querySelectorAll('script[src]');
for (let script of scripts) {
if (script.src === href) {
return script;
}
}
return undefined;
}
}
静态方法LoadScript先调用GetExistedScript来检查特定的.js文件是否已添加至网页中的head标签中。如果未曾添加,则创建一个script标签,其src指向参数url。而不管是否被添加,均监听script的load事件,并在事件处理器中回调callback函数。
需注意的是,LoadScript方法是可重用的,即在同一页面中可多次调用。而在添加事件处理器时,须使用:
script.addEventListener('load', () => {
...
});
的方式,而不是:
script.onload = () => {
...
};
的方式。后者无论LoadScript方法被调用多少次,均只安装一次事件处理器;而前者可保障每次调用均能安装一次事件处理器。下面代码验证了这一点:
const { ScriptUtils } = await import('/js/esm/ScriptUtils.js');
ScriptUtils.LoadScript('examples/wabt/js/libwabt.js', () => {
pc.log('ready');
});
ScriptUtils.LoadScript('examples/wabt/js/libwabt.js', () => {
pc.log('ready');
});
这样做的好处是,特定的.js文件无须绑定在HTML页面中显式加载,可置于网页的任意部分予以多次动态加载。
此外,通过加入关键字export,加载动作将得以在JavaScript模块中完成,尽管libwabt.js原本就不支持JavaScript模块。
下面通过LoadScript方法来动态加载libwabt.js文件并对Wasm Text进行编译及运行:
const { ScriptUtils } = await import('/js/esm/ScriptUtils.js');
let src = `
(module
(func (export "sum") (param i32) (param i32) (result i32)
local.get 0
local.get 1
i32.add
)
)
`;
ScriptUtils.LoadScript('examples/wabt/js/libwabt.js', () => {
WabtModule().then((wabtModule) => {
let module = wabtModule.parseWat('test.wabt', src);
module.resolveNames();
module.validate();
let binaryOutput = module.toBinary({log: false});
let binaryBuffer = binaryOutput.buffer;
pc.log(binaryBuffer);
let wasmModule = new WebAssembly.Module(binaryBuffer);
const wasmInstance = new WebAssembly.Instance(wasmModule, {});
const {sum} = wasmInstance.exports;
let a = 10;
let b = 20;
let c = sum(a, b);
pc.log(c);
});
});
LoadScript被设计为一个通用的方法,只负责加载特定的.js文件,至于加载完毕后,当前环境中多出了什么对象,只有客户端才清楚,客户端在回调函数中可自行引用。
上面客户端代码通过它来加载libwabt.js文件后,在回调函数中,就可以放心地引用相关对象了:
WabtModule().then(...);
封装WabtUtils
上面的步骤,libwabt.js已经支持模块化,下面,我们将其编译细节封装起来,在客户端只保留入口及出口,并将结果转换为Promise。并且,实现支持多次安全调用的缓存机制。
存在的问题
上节已解决了多次安全加载特定脚本文件的问题,但对于多次加载像libwabt.js这么大的文件来说,仍有一个问题非常突出:
ScriptUtils.LoadScript('examples/wabt/js/libwabt.js', () => {
WabtModule().then((wabtModule) => {
...
}
};
每次加载脚本,都须运行WabtModule函数并等待其运行完毕,而运行该函数所需时间必定不少。理想的状况是,不管加载脚本多少次,该函数只应运行一次。
很明显,应将wabtModule缓存起来。
实现缓存
要实现缓存,需考虑3种情况。
第一种情况,当已有缓存时,直接返回缓存。
第二种情况,尚未建立缓存时,加载脚本,并返回注入了wabtModule的一个Promise。
第三种情况,较为复杂,也较为容易疏忽。假设同一网页中共有两个地方先后需要使用wabtModule,当第一个已在加载脚本但又尚未加载完毕时,同一网页中第二个使用需求就出现了。此时,第二个使用需求须等待第一次的加载完毕后,才能进行下一步的工作。但我们这里提出了额外的苛刻要求:无论如何,不能再重复调用WabtModule函数了。由于该函数返回一个Promise,则不调用该函数就无法获取其加载完毕的状态。此时,可在第一次的加载完毕后,触发一个自定义事件,而其后的使用需求监听该事件即可。
因此,初步编写WabtUtils.js的代码如下:
import { ScriptUtils } from './ScriptUtils.js';
const LibWabtURL = '/docs/javascript/webassembly/examples/wabt/js/libwabt.js';
export class WabtUtils {
static #wabtModuleCache;
static #isLoadingStarted = false;
static #isLoaded = false;
static Load() {
WabtUtils.#isLoadingStarted = true;
return new Promise((resolve) => {
ScriptUtils.LoadScript(LibWabtURL, () => {
WabtModule().then((wabtModule) => {
WabtUtils.#isLoaded = true;
WabtUtils.#wabtModuleCache = wabtModule;
resolve(WabtUtils.#wabtModuleCache);
window.dispatchEvent(new CustomEvent('wabtModule-loaded', { detail: WabtUtils.#wabtModuleCache }));
});
});
});
}
static GetWabtModule() {
if (WabtUtils.#isLoaded) {
return Promise.resolve(WabtUtils.#wabtModuleCache);
}
if (WabtUtils.#isLoadingStarted === false) {
return WabtUtils.Load();
}
return new Promise((resolve) => {
window.addEventListener('wabtModule-loaded', (evt) => {
resolve(evt.detail);
});
});
}
...
}
下面客户端首次调用:
const { WabtUtils } = await import('/js/esm/WabtUtils.js');
let wabtModule = await WabtUtils.GetWabtModule();
pc.assert(wabtModule);
当前网页中第二次调用:
const { WabtUtils } = await import('/js/esm/WabtUtils.js');
let wabtModule = await WabtUtils.GetWabtModule();
pc.assert(wabtModule);
这样就建立起了较为完善的缓存机制。
编译为WasmModule
export class WabtUtils {
...
static async WatToWasmModule(watStr) {
let wabtModule = await WabtUtils.GetWabtModule();
let module = wabtModule.parseWat('test.wabt', watStr);
module.resolveNames();
module.validate();
let binaryOutput = module.toBinary({log: false});
let binaryBuffer = binaryOutput.buffer;
let wasmModule = new WebAssembly.Module(binaryBuffer);
return wasmModule;
}
...
}
客户端代码:
const { WabtUtils } = await import('/js/esm/WabtUtils.js');
let watSrc = `
(module
(func (export "sub") (param i32) (param i32) (result i32)
local.get 0
local.get 1
i32.sub
)
)
`;
let wasmModule = await WabtUtils.WatToWasmModule(watSrc);
const wasmInstance = new WebAssembly.Instance(wasmModule);
const {sub} = wasmInstance.exports;
pc.log(sub(30, 10));
我们将不重要、不会变化的细节封装了起来,在客户端只保留了经常发生变化的部分。这样,我们的关注点就完全转移到如何编写Wasm Text源代码,以及在JavaScript客户端如何运行相应Wasm的细节上了。
直接运行Wat源代码
隐藏更多细节:
export class WabtUtils {
...
static async RunWat(watSrc, importObj) {
let wasmModule = await WabtUtils.WatToWasmModule(watSrc);
const wasmInstance = new WebAssembly.Instance(wasmModule, importObj);
return wasmInstance.exports;
}
...
}
客户端代码:
let watSrc = `
(module
(func (export "sum") (param i32 i32) (result i32)
local.get 0
local.get 1
i32.add
)
)
`;
const { sum } = await WabtUtils.RunWat(watSrc);
pc.log(sum(5, 3));
编写Wat代码,运行代码,取出并调用导出的sum函数。
以上这些环节,为下面更好地研究、掌握Wasm的更多知识打下了良好的基础。
Wasm To Text
Wabt还可以将.wasm文件的二进制内容反编译为Wasm Text格式的内容,当我们要查看第三方的.wasm文件的二进制内容时,比较方便。
将此功能封装进WabtUtils的WasmToWat方法中:
export class WabtUtils {
...
static async WasmToWat(wasmFile) {
let wabtModule = await WabtUtils.GetWabtModule();
return fetch(wasmFile)
.then(response => response.arrayBuffer())
.then(arrayBuffer => {
let tarr = new Uint8Array(arrayBuffer);
let module;
try {
module = wabtModule.readWasm(tarr, {readDebugNames: false, ...wabtModule.FEATURES});
module.generateNames();
module.applyNames();
let wat = module.toText({foldExprs: false, inlineExport: true});
return wat;
} catch (e) {
console.error(e);
} finally {
if (module) {
module.destroy();
}
}
});
}
}
客户端代码:
const { WabtUtils } = await import('/js/esm/WabtUtils.js');
let wat = await WabtUtils.WasmToWat('examples/compile-streaming/simple.wasm');
pc.log("%s", wat);
小结
Emscripten固然可以将C语言代码编译为.wasm文件并在JavaScript中使用,但有以下几个问题:
- 我们需在命令行下自行编译并部署到Web环境。这要求我们编写额外的代码并临时搭建小型编译环境(参见在NetBeans中一键编译)。
- 许多时候,我们并不总是需要从C语言代码中生成.wasm代码。一是我们可能不绑定于C语言的特征,二是生成的代码可能太大,远超出我们的实际需求。
而我们从Wasm Text直接编译为Wasm二进制代码,则是从我们精心定制的、最原始的源头到最终的执行端,没有混杂其他无关的内容。并且,Wasm Text的源码总与JavaScript最终运行的代码放在一起,效果一目了然。