WebAssembly 入门
WebAssembly 核心价值、AssemblyScript 手写第一个 WASM 模块、JS 与 WASM 内存交互(指针)、as-bind 高级数据类型与性能边界。
WebAssembly 入门教程:从零到一,手写第一个 WASM 模块
你好!很高兴你想亲手尝试 WebAssembly。作为前端开发者,我们将用最友好的方式——AssemblyScript(它长得像 TypeScript)来编写第一个 WASM 模块,并在浏览器中运行它。整个流程只需几分钟,但能让你彻底理解 WASM 是如何工作的。
为什么选择 AssemblyScript?
- 语法近似 TypeScript:如果你是前端,几乎零学习成本。
- 工具链简单:基于 Node.js/npm,无需安装庞大的 C/C++ 编译器。
- 专注业务逻辑:可以快速验证想法,不用纠结内存管理等底层细节。
第一步:环境准备
确保你已经安装了 Node.js(建议 v16+)和 npm。
打开终端,创建一个新目录并初始化项目:
mkdir my-first-wasm
cd my-first-wasm
npm init -y
第二步:安装 AssemblyScript
AssemblyScript 提供了将 TS 编译为 WASM 的编译器,以及必要的类型定义。
npm install --save-dev assemblyscript
安装完成后,执行初始化命令,它会自动创建推荐的目录结构:
npx asinit .
这个命令会做几件事:
- 创建
assembly/目录,里面有个index.ts(你的源码文件)。 - 创建
build/目录,用于存放编译后的.wasm文件。 - 在
package.json中添加几个常用的脚本命令。
现在你的项目结构应该像这样:
my-first-wasm/
├── assembly/
│ └── index.ts
├── build/
├── node_modules/
├── package.json
└── asconfig.json # AssemblyScript 配置文件
第三步:编写 AssemblyScript 代码
打开 assembly/index.ts,删除默认内容,写入一个简单的加法函数:
// assembly/index.ts
export function add(a: i32, b: i32): i32 {
return a + b;
}
i32是 AssemblyScript 中的 32 位整数类型(对应 WASM 的 i32)。export关键字会将此函数导出,供 JavaScript 调用。
是不是和 TypeScript 一模一样?非常直观。
第四步:编译为 WebAssembly
先看一眼 asinit . 生成的默认 npm 脚本(打开 package.json):
{
"scripts": {
"asbuild:debug": "asc assembly/index.ts --target debug",
"asbuild:release": "asc assembly/index.ts --target release",
"asbuild": "npm run asbuild:debug && npm run asbuild:release",
"test": "node tests"
}
}
🔴 重要坑点(照抄会 404):默认的
npm run asbuild产出的是build/debug.wasm与build/release.wasm(外加untouched.wasm), 并不存在optimized.wasm。而本文后续代码统一fetch('./build/optimized.wasm'), 若不做下面任一步处理,会直接 404 / instantiate 失败。
两种做法,二选一:
方案 A —— 改文档里的文件名:把本文所有 optimized.wasm / optimized.wat 替换成 release.wasm / release.wat。
方案 B —— 改构建脚本(本文后续沿用 optimized.wasm,采用这个):在 package.json 里自定义输出名:
{
"scripts": {
"asbuild": "asc assembly/index.ts -b build/optimized.wasm -t build/optimized.wat --optimize",
"asbuild:debug": "asc assembly/index.ts -b build/debug.wasm -t build/debug.wat --debug",
"test": "node tests"
}
}
然后执行编译:
npm run asbuild
会生成两个文件:
build/optimized.wasm:优化后的生产版本(体积小,运行快)。build/optimized.wat:对应的文本格式(方便人类阅读,可以打开看看)。
编译完成后,build/ 目录下应该出现了 .wasm 文件。(若报错找不到 asc,先 npm i -D assemblyscript;若 Node 版本过新导致 asc 报错,可改用 npx asc 显式调用。)
第五步:在 HTML 中加载并调用 WASM
现在我们来创建一个简单的 HTML 页面,加载这个 WASM 模块并调用 add 函数。
在项目根目录下新建 index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>我的第一个 WASM 应用</title>
</head>
<body>
<h1>WebAssembly 加法示例</h1>
<p>3 + 5 = <span id="result">计算中...</span></p>
<script>
// 使用 WebAssembly.instantiateStreaming 直接从网络流式加载并编译
WebAssembly.instantiateStreaming(fetch('./build/optimized.wasm'))
.then(obj => {
// obj.instance 是模块实例,exports 包含了导出的函数
const result = obj.instance.exports.add(3, 5);
document.getElementById('result').innerText = result;
})
.catch(console.error);
</script>
</body>
</html>
关键 API 解释:
WebAssembly.instantiateStreaming:这是最现代、最高效的加载方式,它会一边下载一边编译。fetch('./build/optimized.wasm'):获取 WASM 二进制文件(注意路径要正确)。obj.instance.exports:包含所有从 WASM 导出的函数(比如我们的add)。
由于浏览器安全限制,直接双击打开 index.html 可能因为跨域问题无法加载本地文件。我们需要通过一个本地服务器来预览。
如果你安装了 VS Code,可以使用 Live Server 插件(右键 index.html -> Open with Live Server)。或者用 Node.js 的 http-server:
npx http-server .
打开浏览器访问 http://localhost:8080(端口可能不同),你应该看到页面上显示 3 + 5 = 8。
🎉 恭喜!你已经成功运行了第一个 WebAssembly 模块!
第六步:深入一点——字符串与内存交互
上面的例子只传递了整数,而字符串、数组等复杂数据需要通过 WASM 的线性内存来共享。AssemblyScript 对此做了很好的封装,我们来看一个返回字符串的例子。
修改 assembly/index.ts:
// assembly/index.ts
export function greet(name: string): string {
return "Hello, " + name + "!";
}
关键前提:必须导出运行时(--exportRuntime),否则 JS 侧拿不到读写字符串的辅助函数 __getString / __newString。在 asconfig.json 的对应 target 里加上:
{
"targets": {
"release": {
"outFile": "build/optimized.wasm",
"textFile": "build/optimized.wat",
"optimizeLevel": 3,
"shrinkLevel": 0,
"exportRuntime": true
}
}
}
或直接在命令行加 --exportRuntime(等价于 --runtime incremental)。然后重新编译:
npm run asbuild
现在修改 index.html 中的 JS 部分——这是一段真正能跑通、并且会打印出 Hello, World! 的代码:
<script type="module">
// ⚠️ 没有胶水代码时,需要自己提供 env.abort(AS 运行时的兜底导入)
const importObject = {
env: {
abort: (msg, file, line, col) => {
throw new Error(`AssemblyScript abort: ${msg} @${file}:${line}:${col}`);
}
}
};
const { instance } = await WebAssembly.instantiateStreaming(
fetch('./build/optimized.wasm'),
importObject
);
// 取出:业务函数 greet + 内存对象 memory + 两个字符串辅助函数
const { greet, memory, __getString, __newString } = instance.exports;
// ① JS 字符串 → WASM:__newString 把 "World" 写进 WASM 线性内存,返回指针
const namePtr = __newString("World");
// ② 调用 greet,返回的是"指向 WASM 内存中结果字符串"的指针(一个数字)
const resultPtr = greet(namePtr);
// ③ WASM 字符串 → JS:__getString 依据内存里的长度前缀 + UTF-16 解码回 JS 字符串
console.log(__getString(resultPtr)); // 输出:Hello, World!
</script>
原理小结(面试可讲):JS 与 WASM 不能直接传字符串——两者内存空间独立。所有复杂类型都要走"写进线性内存 → 传指针 → 按约定解读"这条路:
__newString(str):JS 侧分配内存并写入,返回指针(本质是个整数偏移量);__getString(ptr):按 AssemblyScript 的字符串布局(长度前缀 + UTF-16 数据,前面还有运行时对象头)从内存中解出 JS 字符串;memory.buffer就是那块共享的ArrayBuffer,JS 可以用new Uint8Array(memory.buffer, ptr, len)直接读写。⚠️ 两个实操提醒:
instantiateStreaming要求服务器返回的 MIME 是application/wasm。用file://直接打开 HTML 会失败,起个本地服务器(npx serve/ VSCode Live Server)即可;实在不行用WebAssembly.instantiate(await (await fetch(url)).arrayBuffer(), ...)兜底。- 指针类型在 JS 侧是
number,别当字节偏移直接用——Float32Array这类视图的下标要先把字节偏移除以元素大小。
不过,如果只是想让 WASM 算、JS 拼,还有个更省事的思路:让 WASM 只返回数字,字符串拼接交给 JS。这样完全避开内存操作,代价是多一次跨界调用。选哪种取决于你的性能与可维护性取舍。
JS ↔ WASM 内存交互图解
flowchart LR
subgraph JS["🟨 JavaScript 侧"]
JSHeap["JS 堆<br/>(对象、字符串)"]
View["TypedArray 视图<br/>new Float32Array<br/>(memory.buffer, ptr, len)"]
end
subgraph WASM["🟦 WebAssembly 侧"]
Linear["线性内存 Linear Memory<br/>(一整块 ArrayBuffer,编号从 0 开始)"]
Str["字符串布局:<br/>运行时头 + 长度前缀 + UTF-16 数据"]
Stack["WASM 栈 / 局部变量"]
end
JSHeap -->|"__newString(str)<br/>写入内存,返回指针"| Str
Str -->|"__getString(ptr)<br/>按长度解码,返回 JS 字符串"| JSHeap
View <-->|"共享同一块 ArrayBuffer<br/>零拷贝读写"| Linear
Stack -->|"函数返回值(数值/指针)"| JSHeap
style Linear fill:#dae8fc,stroke:#6c8ebf
style JSHeap fill:#fff2cc,stroke:#d6b656
style View fill:#d5e8d4,stroke:#82b366
一句话总结:JS 与 WASM 内存不互通,唯一的桥是 WASM 线性内存 + 指针(整数偏移量)。 传数值直接传;传字符串/数组则要"写进去 → 传指针 → 对方按约定读出来"。 ⚠️ 两个高频坑:①
memory.grow()之后旧的ArrayBuffer会 detached,缓存的视图失效,必须重新new;② 指针是字节偏移,建Float32Array视图时下标要ptr / 4。
第七步:使用 C 语言(备选方案)
如果你已经熟悉 C/C++,也可以使用 Emscripten 来编译。这里只给出快速步骤,不展开(因为环境配置稍复杂)。
- 安装 Emscripten SDK(参考 官方文档)。
- 编写一个 C 文件
add.c:int add(int a, int b) { return a + b; } - 编译为 WASM:
这会生成emcc add.c -s EXPORTED_FUNCTIONS='["_add"]' -o add.jsadd.js(胶水代码)和add.wasm。 - 在 HTML 中引入
add.js即可使用Module._add()。
总结与下一步
通过这个教程,你学会了:
- 使用 AssemblyScript 编写简单的 WASM 模块。
- 将 TypeScript 风格的代码编译为
.wasm。 - 在浏览器中加载并调用 WASM 函数。
这只是开始,WebAssembly 的世界远不止于此。接下来你可以:
- 学习更多 AssemblyScript 特性:如内存管理、数组、类等。
- 尝试 as-bind 实现 JS 与 WASM 的无缝数据交换。
- 探索真实应用场景:如图像处理、音频编解码、游戏引擎移植。
- 阅读 MDN 文档:WebAssembly 概念 和 使用 WebAssembly 的 JavaScript API。
as-bind 使用教程:在 AssemblyScript 和 JavaScript 之间传递高级数据类型
🔴 本节前置警告:as-bind 已停止维护,请谨慎使用
as-bind 最后活跃时间约为 2021 年(本文示例锁定
as-bind@0.8.0),此后基本停止更新。它与当前版本的 AssemblyScript(0.20+)存在兼容问题——其依赖的--transform as-bind/--exportRuntime机制在新的asc运行时 ABI 下已不适用,照抄本节代码大概率编译失败或运行时出错。因此本节请当作"了解设计思路"来读,不要直接用于生产。 想在 2026 年做字符串 / 数组互传,推荐以下替代方案:
方案 说明 适用场景 手动管理内存(本文上一节已示范) 用 __newString/__getString/memory.buffer自己读写简单类型、追求零依赖 AssemblyScript 官方 loader@assemblyscript/loader提供instantiate与字符串辅助函数多数项目 Emscripten Embind/ WebIDL Binder若用 C/C++ 编写,生态最成熟 C/C++ 项目 wasm-bindgen Rust 生态的标准方案 Rust 项目 面试时如果聊到"JS 与 WASM 怎么传复杂类型",推荐答"手动内存操作 + 指针约定"或"用官方 loader",并主动指出 as-bind 已不维护——这比背一个过时库更能体现技术判断力。
接续上一个教程,当你尝试在 AssemblyScript 中返回字符串时,会遇到内存管理的难题——这就是 as-bind 要解决的问题。as-bind 是 AssemblyScript 生态中专门用于在 JS 和 WASM 之间传递高级数据类型(如字符串、数组) 的库,曾被称作"AssemblyScript 界的 wasm-bindgen"。
1. 安装 as-bind
在上一节创建的 my-first-wasm 项目中,安装 as-bind:
npm install --save as-bind
as-bind 的核心机制是:编译时通过 transform 嵌入类型信息,运行时利用 AssemblyScript Loader 自动处理内存读写。
2. 编写支持字符串的 AssemblyScript 代码
修改 assembly/index.ts,编写一个接收字符串并返回字符串的函数:
// assembly/index.ts
export function greet(name: string): string {
return "Hello, " + name + "!";
}
// 你也可以返回数组或其他复杂类型
export function doubleArray(arr: Int32Array): Int32Array {
const result = new Int32Array(arr.length);
for (let i = 0; i < arr.length; i++) {
result[i] = arr[i] * 2;
}
return result;
}
注意:这里直接使用了 string 和 Int32Array 类型,完全像在 TypeScript 中一样自然。
3. 编译 AssemblyScript(关键步骤)
编译时必须加上 as-bind 要求的两个参数:
npx asc assembly/index.ts \
--target release \
--exportRuntime \
--transform as-bind \
-o build/optimized.wasm
参数说明:
--exportRuntime:必须,导出 AssemblyScript 运行时函数(如__new、__pin),让 as-bind 能在 JS 中分配内存--transform as-bind:必须,在编译时嵌入类型信息,使 as-bind 知道如何转换数据结构
4. 在 JavaScript 中使用 as-bind
修改 index.html,使用 as-bind 加载并调用 WASM 模块:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>as-bind 示例</title>
</head>
<body>
<h1>as-bind 字符串传递示例</h1>
<p id="greeting">计算中...</p>
<p id="array-result"></p>
<!-- 方式1:使用 ES module 导入(推荐) -->
<script type="module">
import * as AsBind from 'https://unpkg.com/as-bind@0.8.0/dist/as-bind.esm.js';
const wasm = fetch('./build/optimized.wasm');
const run = async () => {
// 使用 AsBind.instantiate 替代 WebAssembly.instantiate
const asBindInstance = await AsBind.instantiate(wasm);
// 直接调用导出函数,传递字符串!
const greeting = asBindInstance.exports.greet("WebAssembly");
document.getElementById('greeting').innerText = greeting;
// 传递数组
const originalArray = new Int32Array([1, 2, 3, 4, 5]);
const doubled = asBindInstance.exports.doubleArray(originalArray);
document.getElementById('array-result').innerText =
`原数组: [${originalArray.join(', ')}] → 翻倍后: [${doubled.join(', ')}]`;
};
run().catch(console.error);
</script>
<!-- 方式2:如果不使用模块化,可以用 IIFE 版本 -->
<!--
<script src="https://unpkg.com/as-bind"></script>
<script>
const { AsBind } = AsBindIIFE;
// 同上...
</script>
-->
</body>
</html>
关键变化:
- 使用
AsBind.instantiate(wasm)替代WebAssembly.instantiateStreaming - 返回的实例中
exports已经自动包装,可以直接传递和接收字符串/数组
5. 从 JavaScript 导入函数给 AssemblyScript 调用
as-bind 也支持在 AssemblyScript 中调用 JS 函数,并自动处理参数类型。
修改 AssemblyScript:
// assembly/index.ts
// 声明一个外部函数,将在 JS 中实现
declare function consoleLog(message: string): void;
export function testLog(): void {
consoleLog("Hello from AssemblyScript!");
}
重新编译(命令同上)。
修改 JavaScript:
import * as AsBind from 'https://unpkg.com/as-bind@0.8.0/dist/as-bind.esm.js';
const wasm = fetch('./build/optimized.wasm');
const run = async () => {
// 第二个参数是 importObject,与 WebAssembly.instantiate 格式相同
const asBindInstance = await AsBind.instantiate(wasm, {
// 模块名对应 AssemblyScript 文件(不加路径)
index: { // 这里 'index' 是你的文件名(无扩展名)
consoleLog: (message) => {
console.log('[WASM log]:', message);
document.body.innerHTML += `<p>收到 WASM 消息: ${message}</p>`;
}
}
});
// 调用 WASM 函数,它会回调上面的 consoleLog
asBindInstance.exports.testLog();
};
run();
as-bind 会自动将 AssemblyScript 的字符串转换为 JS 字符串,传递给 consoleLog。
6. 支持的数据类型
根据官方文档,as-bind 支持以下数据类型在 JS 和 WASM 之间自动转换:
| 类型 | 作为导出函数参数 | 作为导出函数返回值 | 作为导入函数参数 | 作为导入函数返回值 |
|---|---|---|---|---|
| 数字 (i32/f32/f64) | ✅ | ✅ | ✅ | ✅ |
| 字符串 | ✅ | ✅ | ✅ | ❌* |
| Int8Array/Uint8Array | ✅ | ✅ | ✅ | ❌ |
| Int16Array/Uint16Array | ✅ | ✅ | ✅ | ❌ |
| Int32Array/Uint32Array | ✅ | ✅ | ✅ | ❌ |
| Float32Array/Float64Array | ✅ | ✅ | ✅ | ❌ |
| Array | ⚠️ 建议用 TypedArray | ⚠️ 建议用 TypedArray | ⚠️ 建议用 TypedArray | ❌ |
*注:导入函数返回值目前只支持数字类型,字符串和数组暂不支持作为导入函数的返回值。
7. 在 Node.js 中使用
如果你需要在 Node.js 环境中使用(比如做后端服务或测试),用法类似:
// 注意:要用 .cjs.js 版本
const AsBind = require("as-bind/dist/as-bind.cjs.js");
const fs = require("fs");
const wasmBuffer = fs.readFileSync("./build/optimized.wasm");
(async () => {
const asBindInstance = await AsBind.instantiate(wasmBuffer);
const result = asBindInstance.exports.greet("Node.js");
console.log(result); // "Hello, Node.js!"
})();
8. 进阶:使用 AssemblyScript 类
as-bind 还支持将 AssemblyScript 中定义的类导出,在 JavaScript 中实例化:
AssemblyScript (assembly/vector.ts):
export class Vector2D {
x: i32;
y: i32;
constructor(x: i32, y: i32) {
this.x = x;
this.y = y;
}
magnitude(): f32 {
return Math.sqrt(<f32>(this.x * this.x + this.y * this.y)) as f32;
}
}
编译(同上)。
JavaScript:
const asBindInstance = await AsBind.instantiate(fetch('./build/optimized.wasm'));
// 可以直接使用导出的类!
const { Vector2D } = asBindInstance.exports;
const vec = new Vector2D(3, 4);
console.log(vec.x, vec.y); // 3, 4
console.log(vec.magnitude()); // 5
注意:类的属性会直接映射为 JS 属性,方法也可以正常调用。
9. 完整可运行示例
我把上述内容整合成一个完整的 HTML 文件,你可以直接复制使用:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>as-bind 完整示例</title>
<style>
body { font-family: Arial; padding: 20px; line-height: 1.6; }
.box { background: #f0f0f0; padding: 15px; border-radius: 8px; margin: 15px 0; }
</style>
</head>
<body>
<h1>📦 as-bind 高级数据类型传递</h1>
<div class="box">
<h3>1. 字符串传递</h3>
<p id="string-result">加载中...</p>
</div>
<div class="box">
<h3>2. 数组传递</h3>
<p id="array-result">加载中...</p>
</div>
<div class="box">
<h3>3. JS 导入函数(WASM 调用 JS)</h3>
<div id="log-area"></div>
</div>
<script type="module">
import * as AsBind from 'https://unpkg.com/as-bind@0.8.0/dist/as-bind.esm.js';
// AssemblyScript 源码(你需要先编译好):
/*
export function greet(name: string): string {
return "Hello, " + name + "!";
}
export function doubleArray(arr: Int32Array): Int32Array {
const result = new Int32Array(arr.length);
for (let i = 0; i < arr.length; i++) result[i] = arr[i] * 2;
return result;
}
declare function logFromJS(message: string): void;
export function callJS(): void {
logFromJS("来自 WASM 的问候!");
}
*/
const wasm = fetch('./build/optimized.wasm');
const run = async () => {
// 准备导入对象
const importObject = {
index: {
logFromJS: (msg) => {
console.log('[WASM]', msg);
document.getElementById('log-area').innerHTML +=
`<p>🔔 ${msg}</p>`;
}
}
};
// 实例化
const asBindInstance = await AsBind.instantiate(wasm, importObject);
// 1. 字符串测试
const greeting = asBindInstance.exports.greet("WebAssembly");
document.getElementById('string-result').innerText = greeting;
// 2. 数组测试
const input = new Int32Array([2, 4, 6, 8]);
const output = asBindInstance.exports.doubleArray(input);
document.getElementById('array-result').innerHTML =
`原始数组: [${input.join(', ')}]<br>` +
`翻倍后: [${output.join(', ')}]`;
// 3. 调用 JS 函数
asBindInstance.exports.callJS();
};
run().catch(err => {
document.body.innerHTML += `<p style="color:red">错误: ${err}</p>`;
console.error(err);
});
</script>
</body>
</html>
总结
通过 as-bind,你可以在 AssemblyScript 和 JavaScript 之间像写普通 TypeScript 一样传递字符串、数组和对象,完全不用手动操作 WebAssembly 的线性内存。它的核心机制是:
- 编译时:
--transform as-bind注入类型信息 - 运行时:
AsBind.instantiate包装导入导出函数,自动处理内存分配和转换
现在你可以专注于业务逻辑,让 as-bind 处理复杂的数据传递了。