导语:用 TypeScript 的写法写 WASM
绝大多数 WASM 开发者是从 Rust 或 C/C++ 进来的,但前端团队往往更希望「用熟悉的手感」写 WASM。AssemblyScript 正是为这个人群设计的:它采用 TypeScript 的子集语法,用 asc 编译到 WASM,产物小、启动快、与 JS 互操作自然。你可以把一段热点函数从 TypeScript 搬到 AssemblyScript,改掉少量类型即可获得接近原生的性能。
但 AssemblyScript 不是 TypeScript 的编译器。它有严格的静态类型(i32/f64/v128)、有自己的内存模型(托管对象 + 引用计数 GC)、有自己的标准库(Math/String 的子集)。把 TS 代码直接改名 .ts 丢给 asc 几乎必然报错。本文把类型系统、内存管理、互操作与工程化一次讲清楚,帮你判断它是否适合你的场景。
目录
- 1. AssemblyScript 定位与编译流程
- 2. 类型系统与数值语义
- 3. 内存管理与 GC 策略
- 4. 与 TypeScript 的互操作
- 5. 导出函数与宿主绑定
- 6. 字符串与数组处理
- 7. 性能优化技巧
- 8. 调试与测试
- 9. 与 Rust 和 C++ 的取舍
- 10. 生产案例与工程化
- 延伸阅读
1. AssemblyScript 定位与编译流程
1.1 它在生态里的位置
AssemblyScript 由 Fastly 团队主导,定位是「前端工程师能上手的 WASM 语言」。它与三条路线形成对照:Rust 性能最好但学习曲线陡;C/C++ 生态最全但工具链最重;AssemblyScript 语法最亲民但生态最薄。适合的场景是中小规模计算模块、边缘函数逻辑、以及从 TS 渐进迁移的性能热点。
1.2 编译流程
npm install --save-dev assemblyscript
npx asinit . # 生成 assembly/ 目录与 asconfig.json
npx asc assembly/index.ts --target release -o build/module.wasm
典型 asconfig.json:
{
"targets": {
"release": { "outFile": "build/module.wasm", "optimizeLevel": 3, "runtime": "incremental" },
"debug": { "outFile": "build/module.debug.wasm", "debug": true, "sourceMap": true }
}
}
--target release 会开启 Binaryen 优化与体积压缩,--target debug 保留符号与断言。
一句话总结:AssemblyScript 用 TS 子集语法经
asc编译到 WASM,适合前端团队快速写出中小型高性能模块,通过asconfig.json管理 debug/release 两套产物。
2. 类型系统与数值语义
2.1 基础数值类型
let a: i32 = 42; // 32 位有符号整数
let b: u64 = 1_000_000n; // 64 位无符号,字面量带 n
let c: f32 = 3.14; // 32 位浮点
let d: f64 = 2.718281828; // 64 位浮点
没有 number 类型,也没有隐式的 i32/f64 转换。i32 + f64 会编译报错,必须显式 f64(a) + d。这看起来繁琐,但正是它能把代码编译成高效 WASM 的原因。
2.2 与 TypeScript 的关键差异
| 特性 | TypeScript | AssemblyScript |
|---|---|---|
| 数值 | 单一 number | i32/u32/i64/u64/f32/f64 |
| 泛型 | 运行时擦除 | 编译期单态化 |
| 联合类型 | 支持 | 不支持 |
| 对象字面量 | 任意 | 必须是 class 实例 |
| any/unknown | 支持 | 不支持 |
// 编译期单态化:泛型函数会为每种类型生成一份
function sum<T>(arr: T[]): T { ... } // 会对 i32[]、f64[] 各生成一份
一句话总结:AssemblyScript 的数值类型是 WASM 原生类型,没有 number、没有联合类型、没有 any;泛型编译期单态化,代码更接近 C 而不是 TS。
3. 内存管理与 GC 策略
3.1 托管对象与引用计数
AssemblyScript 默认提供托管堆:new 出来的对象由运行时用引用计数(RC)管理,__retain / __release 是内部机制,跨边界传递引用时必须成对调用。
class Point { x: i32 = 0; y: i32 = 0; }
export function makePoint(x: i32, y: i32): Point {
return new Point(x, y); // 返回托管引用
}
const p = Module.makePoint(1, 2); // JS 侧拿到的是引用(i32 句柄)
// 用完必须 release,否则 RC 永不归零
Module.__release(p);
3.2 三种 runtime
--runtime full 完整运行时:RC + 完整 stdlib,体积最大
--runtime incremental 增量 RC:不立即释放,延迟回收,吞吐更好(默认)
--runtime minimal 极简:无 GC 追踪,仅保留 malloc/free
--runtime stub 空壳:什么都不带,最小体积
选择策略很明确:需要 class/Array/String 就用 incremental;纯数值计算用 stub 或 minimal 能把产物压到几 KB。
3.3 非托管内存
export function alloc(n: i32): usize { return heap.alloc(n); } // 手动分配
export function free(ptr: usize): void { heap.free(ptr); } // 手动释放
heap.alloc 分配的是不受 RC 管理的裸内存,适合大缓冲区与零拷贝场景,代价是泄漏责任回到你手上。
一句话总结:托管对象靠引用计数,跨边界必须
__retain/__release配对;runtime 从 full 到 stub 逐级瘦身,纯计算模块用 stub 可压到几 KB。
4. 与 TypeScript 的互操作
4.1 不能共用类型定义
AssemblyScript 的 .d.ts 生成(asc --exportRuntime --bindings esm)会为导出函数生成类型声明,但不能把业务 TS 类型直接复用——number、string、数组语义完全不同。
npx asc assembly/index.ts --bindings esm --exportRuntime -o build/module.js
这会产出 module.js(胶水)、module.d.ts(类型)、module.wasm。
4.2 共享类型的正确做法
// 在 assembly/ 内定义一份「契约」类型
export class Payload {
id: u32 = 0;
value: f64 = 0;
}
export function process(p: Payload): f64 {
return p.value * 2;
}
// JS 侧按生成的 d.ts 调用,传递的是引用句柄而非对象副本
const p = new Module.Payload();
p.id = 1; p.value = 21;
const r = Module.process(p);
关键认知:JS 侧看到的 class 实例是「句柄代理」,字段读写每次都跨边界。频繁读写字段比一次性传参数慢得多。
一句话总结:不要幻想复用业务 TS 类型,用
--bindings esm生成契约声明,JS 侧操作的是引用句柄,字段级频繁访问是性能陷阱。
5. 导出函数与宿主绑定
5.1 声明外部导入
// 宿主必须注入这些函数,否则实例化失败
@external("env", "log")
declare function log(ptr: usize, len: i32): void;
@external("env", "now_ms")
declare function nowMs(): f64;
export function run(n: i32): void {
const s = "hello";
log(changetype<usize>(s), s.length);
}
5.2 导出与实例化
export function fib(n: i32): i32 {
return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
import { instantiate } from "./build/module.js";
const { exports } = await instantiate(fetch("./build/module.wasm"), {
env: { log: (p, l) => console.log(decode(p, l)), now_ms: () => Date.now() },
});
console.log(exports.fib(30));
导入函数是唯一的宿主交互通道——没有它,模块连日志都打不出来。这是 WASM 沙箱的必然结果,也让权限边界天然清晰。
一句话总结:用
@external声明导入、用export暴露导出;宿主必须提供全部导入函数才能实例化,这既是约束也是安全边界。
6. 字符串与数组处理
6.1 字符串是 UTF-16
AssemblyScript 的 string 是 UTF-16,每个字符占 2 字节,与 JS 一致但与 WASM 生态常用的 UTF-8 不同。
export function strlen(s: string): i32 { return s.length; } // UTF-16 code unit
export function utf8Len(s: string): i32 { return String.UTF8.byteLength(s); } // 按 UTF-8 计
跨边界传字符串的标准做法是 JS 侧写入 UTF-8 字节,再把指针与长度传给 WASM,避免运行时反复做编码转换与 RC 分配。
6.2 数组与 TypedArray
export function sumF64(data: Float64Array): f64 {
let s = 0.0;
for (let i = 0; i < data.length; i++) s += data[i];
return s;
}
// JS 侧:把数据写进 WASM 线性内存,再以指针构造视图
const ptr = Module.alloc(n * 8);
new Float64Array(Module.memory.buffer, ptr, n).set(input);
console.log(Module.sumF64(ptr, n));
关键陷阱:Module.memory.buffer 在内存增长后会 detach,所有已创建的 TypedArray 视图全部失效,必须重新创建。
一句话总结:AssemblyScript 的 string 是 UTF-16,与 UTF-8 互转要显式处理;大数组走「写入线性内存 + 传指针」零拷贝路径,注意内存增长会让视图失效。
7. 性能优化技巧
7.1 避免隐式装箱
// 慢:泛型容器 + 托管对象,每次都走 RC
let list = new Array<Point>();
list.push(new Point(1, 2));
// 快:结构体数组(SoA),零分配
let xs = new Float64Array(1024);
let ys = new Float64Array(1024);
7.2 编译选项调优
npx asc assembly/index.ts --target release \
--optimizeLevel 3 --shrinkLevel 0 \
--runtime stub \
--noAssert \
--enable simd \
-o build/module.wasm
实测参考(100 万次浮点运算):
纯 f64 循环 原生 1.0x,AS 约 0.85x
托管对象数组遍历 原生 1.0x,AS 约 0.35x
string 拼接 AS 比 JS 慢(UTF-16 + RC 双重开销)
规律很清楚:数值计算接近原生,对象操作与字符串操作是弱项。把热点限制在数值循环里,收益最大。
一句话总结:SoA 优于 AoS、裸数组优于托管对象数组、数值优于字符串;用
--runtime stub --noAssert去掉运行时开销。
8. 调试与测试
8.1 source map 与断点
npx asc assembly/index.ts --target debug --sourceMap \
--debug --exportRuntime -o build/module.debug.wasm
生成的 .map 让 Chrome DevTools 能直接单步 .ts 源码,断点、变量查看与本地调试体验一致。
8.2 单元测试
// assembly/__tests__/fib.spec.ts
import { fib } from "../index";
describe("fib", () => {
it("computes small values", () => {
expect<i32>(fib(10)).toBe(55);
});
});
npx asp --verbose # 官方测试运行器,跑在 Node 的 WASM 引擎上
测试在 WASM 内执行,验证的是真实编译产物,比在 JS 侧做等价实现测试可靠得多。
一句话总结:debug 目标加
--sourceMap即可在 DevTools 单步.ts;用asp在 WASM 内跑单测,保证测的就是最终产物。
9. 与 Rust 和 C++ 的取舍
9.1 横向对比
| 维度 | AssemblyScript | Rust | C/C++ |
|---|---|---|---|
| 学习成本 | 低(TS 基础) | 高 | 中 |
| 产物体积 | 小 | 中 | 大 |
| 生态库 | 少 | 丰富 | 最丰富 |
| 数值性能 | 好 | 最好 | 最好 |
| 字符串性能 | 一般 | 好 | 好 |
| 与 JS 互操作 | 自然 | 需绑定 | 需胶水 |
| 工具链复杂度 | 低 | 中 | 高 |
9.2 选型建议
前端团队 + 中小型计算模块 → AssemblyScript
需要复用 C/C++ 既有库 → Emscripten
追求极致性能 + 复杂数据结构 → Rust
极简裸模块(无 libc、无 GC) → Rust no_std 或 clang freestanding
最忌讳的是「因为语法熟悉就用它写大型应用」——AssemblyScript 的生态与调试工具远不如 Rust 成熟,模块规模一大,维护成本会迅速反超学习成本。
一句话总结:语法门槛低是 AssemblyScript 的最大优势,生态薄弱是最大短板;中小模块选它、大型项目与库复用场景选 Rust/C++。
10. 生产案例与工程化
10.1 构建集成
// package.json
{
"scripts": {
"build:wasm": "asc assembly/index.ts --target release --bindings esm -o build/module.js",
"build": "npm run build:wasm && vite build"
}
}
// 用 Vite 加载:把 wasm 作为资源引入,避免手工 fetch
import { instantiate } from "../build/module.js";
import wasmUrl from "../build/module.wasm?url";
const { exports } = await instantiate(fetch(wasmUrl), { env: { /* ... */ } });
10.2 上线清单
[ ] release 目标开启 optimizeLevel 3
[ ] 按需选择 runtime(stub / incremental)
[ ] 跨边界引用 __retain / __release 配对检查
[ ] 大数组走线性内存零拷贝路径
[ ] 内存增长后重建 TypedArray 视图
[ ] wasm 用 application/wasm MIME 与内容哈希缓存
[ ] 用 asp 覆盖核心算法单测
一句话总结:把
asc挂进 npm scripts,用?url让打包器处理 wasm 资源;上线前重点核对引用计数配对、零拷贝路径与内存增长后的视图重建。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。