WebGL Tutorial
and more

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。而不管是否被添加,均监听scriptload事件,并在事件处理器中回调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文件的二进制内容时,比较方便。

将此功能封装进WabtUtilsWasmToWat方法中:

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中使用,但有以下几个问题:

  1. 我们需在命令行下自行编译并部署到Web环境。这要求我们编写额外的代码并临时搭建小型编译环境(参见在NetBeans中一键编译)。
  2. 许多时候,我们并不总是需要从C语言代码中生成.wasm代码。一是我们可能不绑定于C语言的特征,二是生成的代码可能太大,远超出我们的实际需求。

而我们从Wasm Text直接编译为Wasm二进制代码,则是从我们精心定制的、最原始的源头到最终的执行端,没有混杂其他无关的内容。并且,Wasm Text的源码总与JavaScript最终运行的代码放在一起,效果一目了然。

参考资源

W3C

  1. WebAssembly JavaScript Interface

WebAssembly

  1. Wabt
  2. AssemblyScript
  3. webassembly.org
  4. Web Embedding