Rust + WebAssembly 全链路解析

第8章 wasm-pack:构建、测试与发布

作者 杨艺韬 · 22,322 字 · 发布于 · 更新于

"Make the common case fast." — Hennessy & Patterson《计算机体系结构:量化研究方法》中的设计原则,与 Amdahl 定律一脉相承

8.1 为什么需要 wasm-pack

手动发布一个 Rust→WASM→npm 的库需要这些步骤:

flowchart TD
  A["1. cargo build --target wasm32-unknown-unknown"] --> B["2. wasm-bindgen --target web<br/>生成 JS 胶水"]
  B --> C["3. wasm-opt -Oz<br/>优化 .wasm 体积"]
  C --> D["4. 生成 package.json"]
  D --> E["5. 生成 README.md"]
  E --> F["6. npm publish"]
  style A fill:#6366f1,color:#fff
  style F fill:#10b981,color:#333

6 个步骤,每步都有配置选项和潜在陷阱。更关键的问题是:步骤之间存在版本耦合——wasm-bindgen crate 的版本必须和 wasm-bindgen-cli 完全匹配,cargo 的 --target 参数不能遗漏,wasm-opt 的优化级别要和 Cargo.toml 中的 profile 设置协调。手动操作极易出错。一个最常见的新手错误是忘记指定 --target wasm32-unknown-unknown——此时 cargo build 会编译出原生平台的二进制文件(如 x86_64 的 .so 或 .dll),而不是 .wasm 文件。另一个常见错误是 wasm-bindgen-cli 的版本和 crate 版本对不上——CLI 读自定义段时会比对 schema 版本,不一致就直接报错退出(§8.8)。

wasm-pack 把它们压缩为一条命令:

wasm-pack build --target web --release
wasm-pack publish

它的核心价值不是"自动化"(写个 Makefile 也能做到),而是把最容易出错的衔接做对:按 Cargo.lock 拿到版本精确匹配的 wasm-bindgen CLI、从 cargo 的 JSON 输出里找到真正的 .wasm 产物、按 target 生成对应格式的 package.json。它并不做增量追踪——每次 build 都把 cargo、wasm-bindgen、wasm-opt 完整跑一遍(§8.15.5)。

wasm-pack 0.14.0 的源码就摊在仓库根的 src/ 下:子命令在 src/command/(build.rs、test.rs、pack.rs、publish/、login.rs、generate.rs),每一步各占一个模块——src/build/(cargo 构建与 target 检查)、src/bindgen.rs(调 wasm-bindgen CLI)、src/wasm_opt.rs(找/下载并运行 wasm-opt)、src/manifest/(读 Cargo.toml、生成 package.json)、src/npm.rs(npm pack / npm publish / npm login)。六个子命令里,pack、publish、login 都只是转调 npm:pack 在 pkg/ 里跑 npm pack,login 跑 npm login --registry=…(默认 https://registry.npmjs.org/,见 src/npm.rs)。

维护状态与仓库位置(2026-09-24 核对 GitHub):rustwasm 组织已停止维护、旗下仓库陆续归档,wasm-pack 转由社区维护者接手。本专栏对照的 0.14.0(2026-01-20 发布)源码里,仓库地址、文档与安装脚本都指向 drager/wasm-pack 与 drager.github.io/wasm-pack;此后仓库又迁到了 github.com/wasm-bindgen/wasm-pack(rustwasm/wasm-pack 的旧地址现在重定向到这里,文档在 wasm-bindgen.github.io/wasm-pack),drager/wasm-pack 只剩一个写着「This repository has moved to wasm-bindgen/wasm-pack」的跳转页。新仓库已发布 0.15.0(2026-05-15),本章仍以 0.14.0 为准。下文 CI 示例用的是新地址的安装脚本,它装的是最新版;要与本章钉在同一版本,用 cargo install wasm-pack@0.14.0 --locked。

8.2 build 命令的完整流水线

wasm-pack build 执行的完整步骤:

步骤表就是 src/command/build.rs:317 的 get_process_steps() 一行行 push 出来的,顺序照抄如下——注意 pkg/ 目录是在跑 wasm-bindgen 之前就建好的,不是最后才建:

flowchart TD
  A["1. step_check_rustc_version"] --> A2["2. step_check_crate_config"]
  A2 --> A3["3. step_check_for_wasm_target"]
  A3 --> B["4. step_build_wasm<br/>(cargo build)"]
  B --> C["5. step_create_dir<br/>(建 pkg/)"]
  C --> C2["6. step_install_wasm_bindgen"]
  C2 --> D["7. step_run_wasm_bindgen"]
  D --> E["8. step_run_wasm_opt<br/>(--no-opt 可跳过)"]
  E --> G["9. step_create_json"]
  G --> H["10. step_copy_readme<br/>11. step_copy_license"]
  style A fill:#f59e0b,color:#333
  style D fill:#6366f1,color:#fff
  style E fill:#22d3ee,color:#333
  style G fill:#10b981,color:#333

前三步的检查在 --mode force 下会被整组跳过(src/command/build.rs:334);最后三步在 --no-pack 下跳过(:355)。

步骤一:检查工具链

wasm-pack 检查 wasm32-unknown-unknown 目标是否已安装。它不是去解析 rustup target list,而是直接问 rustc 要目标的 libdir 再看目录在不在(src/build/wasm_target.rs:84 的 get_rustc_target_libdir() 跑的是 rustc --target <triple> --print target-libdir,:99 的 does_target_libdir_exist() 判断存在性):

rustc --target wasm32-unknown-unknown --print target-libdir   # 目录存在即认为已安装

目录不存在时还要看 rustc --print sysroot 的路径里含不含 rustup——只有在用 rustup 的情况下才会自动跑 rustup target add <triple>(src/build/wasm_target.rs:135、:156);非 rustup 安装只会报错并指向文档,让你手动装。这一步也会检查 rustc 版本:低于 1.30 直接 bail(src/build/mod.rs:32 的 if mv < 30)。

第二项检查 step_check_crate_config 只看一件事:crate 有没有 cdylib 类型的 lib 目标,没有就报错并提示加上 crate-type = ["cdylib", "rlib"](src/manifest/mod.rs:549 的 check_crate_type())。

wasm-bindgen CLI 的版本匹配不在这一组检查里,而是 cargo 编译之后的第 6 步 step_install_wasm_bindgen(src/command/build.rs:432):先从 workspace 根目录的 Cargo.lock 里取出 wasm-bindgen 的精确版本(src/lockfile.rs;找不到就报错让你加依赖),再交给 install::download_prebuilt_or_cargo_install(src/install/mod.rs:55)按顺序找:

  1. $PATH 上的 wasm-bindgen,跑一次 --version,版本号完全相等才用它;不相等就当它不存在,并不报错;
  2. 从 https://github.com/wasm-bindgen/wasm-bindgen/releases/download/<版本>/wasm-bindgen-<版本>-<平台>.tar.gz 下载预编译包(:192),解出 wasm-bindgen 和 wasm-bindgen-test-runner 两个二进制放进 wasm-pack 的缓存目录(环境变量 WASM_PACK_CACHE 可改位置,src/cache.rs);
  3. 下载失败才退到 cargo install --force wasm-bindgen-cli --version <版本> --root <缓存目录>(:216)。

--mode no-install 下第 2、3 步都不许做,缓存里又没有时就报 Not able to find or install a local wasm-bindgen.。

步骤二:cargo build

# src/build/mod.rs:78 的 cargo_build_wasm() 实际拼出的命令(release 情形)
CARGO_BUILD_TARGET=wasm32-unknown-unknown cargo build --lib --release --message-format=json

目标三元组是通过环境变量 CARGO_BUILD_TARGET 给的,所以你在 wasm-pack build 末尾透传给 cargo 的 -- --target <triple> 仍然优先生效(0.14.0 的 CHANGELOG 把这算作「支持任意 wasm 目标」)。--message-format=json 则是为了下一步从 cargo 的输出里找产物。

wasm-pack 默认使用 --release profile——src/command/build.rs:239 那个 match 里,一个 profile 标志都不给和显式给 --release 落到同一个分支 BuildProfile::Release。可以通过 --dev 标志切换到 debug 构建(不给 cargo 传 --release,用的是 Cargo 的 dev profile;老名字 --debug 仍可用但已标注 Deprecated,见 src/command/build.rs:151);此外还有 --profiling 和 --profile <name>(给 cargo 传 --profile <name>),四者互斥。

--profiling 的帮助文本写的是「Enable optimizations and debug info」,但 0.14.0 的实现并不是这样:src/build/mod.rs:94-:100 里它给 cargo 传的就是 --release(设 RUSTFLAGS=-g 的那行被注释掉了),不会用 Cargo 的 [profile.profiling];随后的 wasm-bindgen / wasm-opt 两步读的是 [package.metadata.wasm-pack.profile.profiling],而它的默认值(src/manifest/mod.rs:308 的 default_profiling())与 release 完全相同。也就是说不做任何配置时,--profiling 与 --release 的产物没有区别(实测两者跑的 cargo、wasm-bindgen、wasm-opt 命令逐字相同),想要调试信息得自己在 profiling 段里配(§8.5、§8.12.2)。

dev 构建的用处是调试:wasm-bindgen 会带 --debug 生成多了断言的胶水(debug-js-glue 在 dev 下默认开),也不跑 wasm-opt,名称段保留下来,panic 时浏览器栈里能看到 Rust 函数名。至于 panic 消息本身,两种构建里都在,但默认都只表现为一个 RuntimeError: unreachable——要把消息打到控制台得装 console_error_panic_hook。不要把 dev 构建发布到生产环境:它没有经过任何优化,体积和性能都明显更差。

自己直接调 cargo 时,--target wasm32-unknown-unknown 是必需的——默认的 cargo build 会编译为宿主平台的原生代码。unknown-unknown 表示不对操作系统和运行时做假设——WASM 模块本身就是操作系统无关的。这个目标三元组的第一部分 wasm32 指定架构(32 位 WebAssembly),第二部分 unknown 指定供应商(无特定供应商),第三部分 unknown 指定操作系统(无特定操作系统)。

Cargo.toml 里的 [profile.release] 由 cargo 自己读取,wasm-pack 不往里加任何东西。对体积敏感的 WASM 项目常用的 release profile:

[profile.release]
opt-level = "z"       # 最大体积优化
lto = true            # 跨 crate 链接时优化
codegen-units = 1     # 单个代码生成单元,更好的优化
strip = "debuginfo"   # 只剥调试信息;别写 strip = true,原因见下
panic = "abort"       # wasm32-unknown-unknown 本来就默认 abort,写出来只是显式

⚠️ 别在 wasm-pack 项目里写 strip = true(或 "symbols")。它让链接器连 target_features 自定义段一起删掉,而下游两个工具都靠这个段判断模块用了哪些提案(实测,Rust 1.96 + wasm-bindgen 0.2.118 + Binaryen 126):wasm-bindgen 读不到 reference-types,退回 JS 侧 heap 数组的老路径(产物里不再有 __wbindgen_externrefs);wasm-opt 则按 MVP 特性集验证,碰到 rustc 默认发出的 memory.copy 直接报 memory.copy operations require bulk memory operations,整个 wasm-pack build 失败。strip = false 或 "debuginfo" 都没有这个问题;名称段反正会被 wasm-opt 丢掉(§8.2 步骤五),strip = true 在走完 wasm-opt 之后本来也省不了多少(第 9 章 §9.3.5)。

这些设置不是 wasm-pack 自动添加的——你需要在 Cargo.toml 中手动配置。wasm-pack 只是忠实地调用 cargo build,不会修改你的编译配置。

步骤三:检测 .wasm 输出

Rust 编译器把 .wasm 文件输出到 target/wasm32-unknown-unknown/release/ 目录下(通常以 crate 名命名,如 my_lib.wasm)。wasm-pack 并不去猜这个路径:它解析 cargo 的 JSON 消息流,取最后一条 compiler-artifact 消息里扩展名为 .wasm 的文件,恰好一个才继续,否则报错(src/build/mod.rs:143-:179)。这个路径随后传给 wasm-bindgen CLI。

如果项目有多个 crate(workspace),wasm-pack 只处理你在命令行指定的 crate——它不会自动构建 workspace 中的所有 crate。

步骤四:wasm-bindgen CLI

# src/bindgen.rs:14 的 wasm_bindgen_build() 拼出的命令(--target web、release,实测)
wasm-bindgen target/wasm32-unknown-unknown/release/my_lib.wasm \
  --out-dir pkg \
  --typescript \
  --target web
# 只有给了 --out-name 才追加 --out-name;--no-typescript 时换成 --no-typescript;
# dev profile 另加 --debug(见 §8.5 的 wasm-bindgen 开关)

--target 决定生成的 JS 胶水代码的模块格式——这是 wasm-pack build 最重要的选项:

wasm-pack 0.14.0 认的取值是五个(enum Target 定义在 src/command/build.rs:51,字符串解析在 :89,CLI 帮助文本在 :147:[possible values: bundler, nodejs, web, no-modules, deno]),默认值是 bundler:

target 值 输出格式 适用场景 WASM 初始化方式
bundler(默认,别名 browser) ES Module + bundler 提示 webpack / Rollup / Vite 等打包工具 打包工具自动处理
web ES Module (import) 直接在浏览器 <script type="module"> 中使用 import init from './my_lib.js'; await init();
nodejs CommonJS (require) Node.js 环境 const wasm = require('./my_lib.js');
no-modules IIFE (全局变量) 不支持 ES Module 的旧浏览器 <script src="./my_lib.js"> 全局变量
deno ES Module (Deno) Deno 运行时 Deno 直接 import;这个 target 不生成 package.json(src/manifest/mod.rs:649 直接 return Ok(()))
graph TD
  A["--target 选择"] --> B{运行环境?}
  B -->|"现代浏览器<br/>无打包工具"| C["web<br/>ES Module 直引"]
  B -->|"Vite / webpack / Rollup"| D["bundler<br/>ES Module + 打包提示"]
  B -->|"Node.js"| E["nodejs<br/>CommonJS"]
  B -->|"旧浏览器"| F["no-modules<br/>IIFE 全局变量"]
  style D fill:#10b981,color:#333
  style C fill:#6366f1,color:#fff
  style E fill:#f59e0b,color:#333
  style F fill:#dc2626,color:#fff

bundler 是默认值,它依赖打包工具支持「把 .wasm 当 ES Module 导入」(WebAssembly ESM Integration 提案):webpack 5 打开 experiments.asyncWebAssembly 即可;Vite(以《Vite 设计与实现》专栏对照的 8.0.3 为准)只内置 ?init 导入,要靠 vite-plugin-wasm 这类插件(§8.6)。web 适合不使用打包工具的场景(CDN 直引、CodePen 等),它用 fetch 自己加载 .wasm,对打包工具没有这项要求。如果你的 WASM 库可能被不同类型的消费者使用,可以分别构建多个 target 的产物,再用 package.json 的 exports 条件字段指定不同入口(§8.9.3)——但这增加了发布复杂度。

--target 影响的不仅是模块格式,还有 WASM 的初始化方式:

  • web:JS 胶水代码默认导出一个初始化函数,调用者需要 await init() 完成编译和实例化
  • bundler:没有 init()——入口文件直接 import * as wasm from "./my_lib_bg.wasm",加载和实例化交给打包工具
  • nodejs:自动在 require() 时同步初始化
  • no-modules:把绑定挂到全局的 wasm_bindgen 上,仍然要显式调用并 await 它,不会自动初始化;0.2.118 的胶水要求传一个对象,如 wasm_bindgen({ module_or_path: './my_lib_bg.wasm' }),直接传字符串能用但会打印 deprecated 警告

步骤五:wasm-opt

wasm-opt 是 Binaryen 工具包的一部分,对 .wasm 做二进制级别的优化:

wasm-opt -Oz pkg/my_lib_bg.wasm -o pkg/my_lib_bg.wasm

wasm-opt 的优化级别:

标志 (optimizeLevel, shrinkLevel) 体积效果 速度效果
-O1 (1, 0) 少量体积缩减 速度优先,"quick&useful opts"
-O2 (2, 0) 中等体积缩减 速度优先,不为体积让步
-O3 (3, 0) 可能增大体积 "spends potentially a lot of time optimizing"
-O4 (4, 0) 可能增大体积 在 -O3 之上再 flatten IR,耗时与内存都明显更高
-O / -Os (2, 1) 显著体积缩减 轻微速度牺牲——两个标志完全等价,binaryen 的帮助文本就写着 "equivalent to -Os"(binaryen-126/src/tools/optimization-options.h:92-101,并有 static_assert 钉死这一等价)
-Oz (2, 2) 最大体积缩减 速度牺牲

wasm-pack 在 release / profiling / 自定义 profile 下默认跑 wasm-opt -O(也就是 -Os,偏体积)——src/manifest/mod.rs:295 的 default_release()(profiling、custom 同)把 wasm_opt 置为 Enabled(true),:413 的 wasm_opt_args() 再把 Enabled(true) 展开成 Some(vec!["-O".to_string()])。dev profile 则完全不跑 wasm-opt::282 的 default_dev() 里 wasm_opt: None,wasm_opt_args() 返回 None 时 step_run_wasm_opt 直接 return Ok(())(src/command/build.rs:466、:473)。另有 --no-opt 标志可以在任何 profile 下跳过这一步(src/command/build.rs:183,它直接把该步骤从流水线里摘掉,见 :352)。对于对体积敏感的应用(如网页加载性能),推荐在 Cargo.toml 中覆盖为 -Oz:

[package.metadata.wasm-pack.profile.release]
wasm-opt = ["-Oz"]

wasm-opt 做的优化包括:

  1. 死代码消除:删除从导出出发不可达的函数、全局等模块项
  2. 函数内联:小函数内联到调用者中,消除调用开销
  3. 常量折叠:编译期可计算的表达式直接求值——如 4 * 1024 变为 4096
  4. 重复函数合并(duplicate-function-elimination):lower 到 wasm 之后完全相同的函数合并为一个——源码层面类型不同的泛型实例常常在这一层变得一模一样
  5. 不写名称段:wasm-opt 输出时只有加了 -g 才保留名称段等调试信息(src/tools/wasm-opt.cpp 里 writer.setDebugInfo(options.passOptions.debugInfo)),所以默认产物里没有 my_lib::greet::h12345 这类函数名
  6. Stack IR 优化:Binaryen 自己的中间表示优化,能发现 LLVM 后端未捕捉到的模式

wasm-pack 对 out_dir 里每一个 .wasm 文件各跑一次 wasm-opt <文件> -o <文件>.wasm-opt.wasm <参数…>,成功后再改名覆盖原文件(src/wasm_opt.rs:23-:35);开了 --reference-types 时参数末尾再补一个 --enable-reference-types。找 wasm-opt 的顺序写在 src/wasm_opt.rs:47 的 find_wasm_opt() 里:先 which::which("wasm-opt") 查 $PATH(:49),找不到就调 install::download_prebuilt 下载官方预编译包。注意这里传进去的版本字符串虽然是 "latest"(src/wasm_opt.rs:54),但对 Tool::WasmOpt 这个参数根本不参与拼 URL——src/install/mod.rs:207 把版本写死成 vers = "version_117",旁边还留着一行注释提醒同步改文档。也就是说 wasm-pack 0.14.0 自动下载的是 Binaryen version_117,不是最新版;想用更新的 Binaryen(比如本专栏基准的 126)就得自己装一份放进 $PATH。只有两种情况会真的跳过并打印提示——用了 --mode no-install(Status::CannotInstall),或者当前平台没有预编译包(Status::PlatformNotSupported,见 src/wasm_opt.rs:56-:63);下载本身失败则直接报错,src/command/build.rs:484-:487 还会在错误信息末尾提示可以用 wasm-opt = false 关掉这一步(原文见 §8.8 问题二)。CI 里如果不想每次都下载,自己装一份 binaryen 放进 $PATH 即可:

# macOS
brew install binaryen

# Ubuntu/Debian(发行版仓库里的版本通常落后较多,先 wasm-opt --version 确认)
apt install binaryen

# 或从源码
git clone https://github.com/WebAssembly/binaryen && cd binaryen && cmake . && make install

步骤六-八:生成 pkg/ 目录

wasm-pack 在 crate 目录下创建 pkg/ 目录(--out-dir 可改)。step_create_dir 调用的 create_pkg_dir()(src/command/utils.rs:39)只做三件事:删掉上一次的 package.json、建目录、写一个内容为 * 的 .gitignore——它不清空目录,旧文件只会被同名新文件覆盖。--target bundler 下的产物(实测):

pkg/
├── .gitignore          # 内容就是一个 *,让 pkg/ 整体不进 git
├── my_lib.js           # 入口:import .wasm、把它交给 _bg.js、再转出导出
├── my_lib_bg.js        # 各导出函数的 JS 包装(字符串编解码等胶水),只有 bundler 才有
├── my_lib_bg.wasm      # 经 wasm-bindgen 处理、再经 wasm-opt 优化的 .wasm
├── my_lib.d.ts         # TypeScript 声明
├── my_lib_bg.wasm.d.ts # .wasm 模块的 TypeScript 声明
├── package.json        # npm 包描述
├── README.md           # crate 有 README 时才复制
└── LICENSE             # Cargo.toml 写了 license(复制 LICENSE*)或 license-file 时才复制

package.json 的关键字段由 wasm-pack 自动生成:

字段集合由 src/manifest/npm/esmodules.rs:6 的 ESModulesPackage 结构体(bundler/web)和同目录的 CommonJSPackage(nodejs,没有 type 字段)、NoModulesPackage(no-modules,入口字段叫 browser 而不是 main)决定,--target bundler 下大致长这样:

{
  "name": "my-lib",
  "type": "module",
  "description": "取自 Cargo.toml 的 description",
  "version": "0.1.0",
  "license": "MIT",
  "repository": { "type": "git", "url": "取自 Cargo.toml 的 repository" },
  "files": ["my_lib_bg.wasm", "my_lib.js", "my_lib_bg.js", "my_lib.d.ts"],
  "main": "my_lib.js",
  "types": "my_lib.d.ts",
  "sideEffects": ["./my_lib.js", "./snippets/*"]
}

三个容易记错的点:

  1. 入口字段是 main 不是 module——ESModulesPackage 里根本没有 module 字段,它靠 "type": "module" 声明这是 ES Module 包(src/manifest/mod.rs:773 的 ty: "module".into())。
  2. sideEffects 不是 false 而是一个数组:bundler 目标下是 vec![format!("./{}", data.main), "./snippets/*"](src/manifest/mod.rs:786),web 目标下只有 vec!["./snippets/*"](:819)。也就是说它显式声明「入口文件和 inline-JS snippets 有副作用,别删」,而不是宣称整个包无副作用。
  3. files 里没有 *_bg.wasm.d.ts:src/manifest/mod.rs:659 的 npm_data() 只往 files 里塞 {name}_bg.wasm、{name}.js、(bundler 才有的){name}_bg.js、{name}.d.ts,外加 out_dir 下所有以 LICENSE 开头但不叫 LICENSE 的文件(:702-:703)。README.md 也不在 files 里——npm 本来就会自动带上它。更要命的是 snippets/ 目录也不在 files 里:用了 inline_js 或 #[wasm_bindgen(module = "/js/foo.js")] 的 crate,wasm-bindgen 会把这些 JS 写进 pkg/snippets/…,入口文件也会去 import 它们,但 npm pack --dry-run 实测打出的包里没有这个目录(sideEffects 里倒是写了 ./snippets/*)。这类包发布前要手动把 "snippets/" 加进 files。

description、license、repository、homepage、keywords、collaborators 全部取自 Cargo.toml(分别是 description、license/license-file、repository、homepage、keywords、authors),没有别的入口。

pkg/ 目录中的文件分工明确(以 bundler 为例):my_lib.js 是用户直接 import 的入口文件,只有几行——import * as wasm from "./my_lib_bg.wasm",调 __wbg_set_wasm(wasm) 把实例交给 my_lib_bg.js,执行 wasm.__wbindgen_start(),再 export { greet } from "./my_lib_bg.js";my_lib_bg.js 里是每个导出函数的 JS 包装(字符串编解码、调 __wbindgen_malloc/__wbindgen_free 等)以及 .wasm 需要的 JS 导入实现;my_lib_bg.wasm 是二进制模块。所以用户代码只写 import { greet } from 'my-lib',.wasm 由打包工具加载。web 目标没有 _bg.js,胶水全部在 my_lib.js 里,外加一个用 fetch + instantiateStreaming 加载 .wasm 的默认导出 init()。

8.3 测试:wasm-bindgen-test

测试框架的必要性

Rust 的 #[test] 宏在 wasm32-unknown-unknown 目标上不能直接运行——因为这个目标没有操作系统:没有标准输出、没有进程退出码、没有文件系统。wasm-bindgen-test 框架的解决方案是:在宿主环境(浏览器或 Node.js)中加载并执行 WASM 测试代码,再由命令行侧把结果取回来。

wasm-pack test --node                 # 在 Node.js 中运行
wasm-pack test --chrome               # 不启动浏览器:在 127.0.0.1:8000 起服务,你自己打开页面看结果
wasm-pack test --firefox              # 同上
wasm-pack test --headless --chrome    # CI 用法:--headless 必须搭配某个浏览器标志

src/command/test.rs:23-:66 里的标志是 --node / --firefox / --chrome / --safari / --headless,外加 --geckodriver / --chromedriver / --safaridriver 三个指定 driver 路径的选项(这三个各自 imply 对应的浏览器标志)。--headless 单独用会直接报错(src/command/test.rs:145)——它只是给浏览器模式加一个「无界面」开关;四个运行环境标志一个都不给也会报错(:141)。另外 wasm-pack test 的默认 profile 与 build 相反:默认 dev,要 release 得显式加 --release(src/command/test.rs:72)。

测试框架的工作原理

sequenceDiagram
  participant CLI as wasm-pack test → cargo test<br/>→ wasm-bindgen-test-runner
  participant S as HTTP Server
  participant B as 浏览器
  participant W as WASM 测试
  CLI->>CLI: cargo build --tests<br/>→ test.wasm
  CLI->>CLI: runner 内部跑 wasm-bindgen<br/>生成测试胶水
  CLI->>S: 启动临时服务器 :port
  CLI->>B: 打开 http://localhost:port
  B->>S: 请求测试页面
  S-->>B: HTML + JS + .wasm
  B->>W: 加载并执行测试
  W-->>B: 结果写进页面的 #output 元素
  CLI->>B: WebDriver 轮询 #output 的文本
  CLI->>CLI: 显示结果 + 退出码
  CLI->>S: 关闭临时服务器

具体流程(wasm-pack 一侧见 src/command/test.rs:190 的 get_process_steps(),测试的实际执行在 wasm-bindgen 0.2.118 的 crates/cli/src/wasm_bindgen_test_runner.rs):

  1. 先跑 cargo build --tests(CARGO_BUILD_TARGET=wasm32-unknown-unknown,默认 dev,加 --release 才传 --release),把测试编成 .wasm
  2. 读 Cargo.lock:必须同时有 wasm-bindgen 和 wasm-bindgen-test,然后按 wasm-bindgen 的精确版本拿到同一个 CLI 包里的 wasm-bindgen-test-runner(取法同 §8.2 步骤一)
  3. 再跑 cargo test --target wasm32-unknown-unknown,同时把环境变量 CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER 设成 runner——cargo 对每个测试 .wasm 都去调用它。--node 另设 WASM_BINDGEN_TEST_ONLY_NODE=1;浏览器模式设 WASM_BINDGEN_TEST_ONLY_WEB=1 和 CHROMEDRIVER/GECKODRIVER/SAFARIDRIVER,没加 --headless 时还会设 NO_HEADLESS=1
  4. runner 对测试 .wasm 跑一遍 wasm-bindgen 生成胶水;Node 模式直接交给 node 执行,浏览器模式则起一个临时 HTTP 服务器提供测试页面(headless 用随机端口,否则固定 127.0.0.1:8000)
  5. headless 下通过 WebDriver 协议(chromedriver / geckodriver / safaridriver)新建一个 session 并导航到该页面(Chrome 会带上 headless 启动参数);wasm-pack 在 driver 不在 $PATH 且未用 --chromedriver/--geckodriver 指定时会自动下载一份。非 headless 时 runner 根本不启动浏览器:它打印「Interactive browsers tests are now available at http://127.0.0.1:8000」,等你自己打开,测完 Ctrl-C 结束
  6. 测试页面加载 WASM,执行所有 #[wasm_bindgen_test] 标注的函数,把结果写进页面里 id 为 output 的元素
  7. runner 用 WebDriver 定期抓取 #output 的文本(crates/cli/src/wasm_bindgen_test_runner/headless.rs:client.text_content(&id, "#output", offset)),边抓边流式打印,直到看到 test result: 为止——没有 WebSocket,就是轮询
  8. 根据 pass/fail 设置退出码,cargo 和 wasm-pack 依次把失败向上传

测试跑在哪里,由测试二进制自己声明,不由命令行标志决定。 runner 读 .wasm 里的 __wasm_bindgen_test_unstable 自定义段:没写 wasm_bindgen_test_configure!(run_in_browser); 的测试默认只在 Node.js 里跑(crates/cli/src/wasm_bindgen_test_runner.rs:245 起的 match:自定义段里没有声明时,再看 WASM_BINDGEN_USE_* 这组环境变量,都没设就落到 TestMode::Node)。这时 wasm-pack test --headless --chrome 不会报错,而是打印「This test suite is only configured to run in node.js, but we're only running browser tests so skipping」后直接跳过;反过来声明了 run_in_browser 的测试,--node 会跳过它们。

编写 WASM 测试

WASM 中的测试需要 wasm_bindgen_test::wasm_bindgen_test 宏(不是标准的 #[test]),wasm-bindgen-test 要作为 dev-dependency 加进 Cargo.toml(与 wasm-bindgen 0.2.118 同批发布的是 0.3.68)。下面两例放在 crate 的集成测试目录 tests 下,wasm-pack test --node 实测通过:

use wasm_bindgen::JsValue;
use wasm_bindgen_test::*;

#[wasm_bindgen_test]
fn simple_test() {
    assert_eq!(2 + 2, 4);
}

#[wasm_bindgen_test]
async fn async_test() {
    // Promise 必须先过 JsFuture 才能 await;直接对 Promise 调 as_f64 拿不到值
    let promise = js_sys::Promise::resolve(&JsValue::from(42));
    let value = wasm_bindgen_futures::JsFuture::from(promise).await.unwrap();
    assert_eq!(value.as_f64().unwrap() as i32, 42);
}

#[wasm_bindgen_test] 和 #[test] 的关键区别:

维度 #[test] #[wasm_bindgen_test]
目标平台 原生(x86_64/aarch64) wasm32-unknown-unknown
执行方式 生成原生可执行文件 生成 WASM 导出函数,由 JS 测试驱动器调用
异步支持 #[tokio::test] 直接写 async fn(也接受显式的 #[wasm_bindgen_test(async)])
JS 互操作 不支持 可以调用 js_sys/web_sys
输出 直接 stdout 写进测试页面,由 CLI 经 WebDriver 抓回来

异步测试走的是 crates/test-macro/src/lib.rs 里的另一条分支:生成 cx.execute_async(...) 而不是 cx.execute_sync(...),测试驱动器会等这个 future 完成。WASM 中的异步不是线程异步(WASM 是单线程的),而是基于 JS 的事件循环——Future::poll 被调度到微任务队列中执行。

测试 JS 互操作

wasm-bindgen-test 的核心价值在于测试 JS 互操作——验证 Rust 和 JS 之间的类型转换是否正确。注意第三个测试要访问 DOM,文件里必须有 wasm_bindgen_test_configure!(run_in_browser);,否则它会被放到 Node.js 里跑,web_sys::window() 返回 None,unwrap() 直接 panic(实测);加上之后整个文件都只在浏览器里跑。web-sys 还要打开 Window、Document、HtmlElement、Node 这几个 feature:

use wasm_bindgen_test::*;
use wasm_bindgen::JsValue;
use js_sys;

wasm_bindgen_test_configure!(run_in_browser);

#[wasm_bindgen_test]
fn test_string_roundtrip() {
    let rust_str = "杨艺韬";
    let js_str = JsValue::from_str(rust_str);
    let back = js_str.as_string().unwrap();
    assert_eq!(rust_str, &back);
}

#[wasm_bindgen_test]
fn test_js_error() {
    let result = js_sys::eval("1 + 1");
    assert_eq!(result.unwrap().as_f64().unwrap(), 2.0);
}

#[wasm_bindgen_test]
fn test_dom_access() {
    let doc = web_sys::window()
        .unwrap()
        .document()
        .unwrap();
    let body = doc.body().unwrap();
    assert!(body.node_type() == 1); // Element node
}

这些测试在原生 #[test] 下无法运行——它们依赖 JS 运行时,最后一个还依赖浏览器 DOM。在 wasm-bindgen 生态里,wasm-bindgen-test 是把 Rust 测试放进真实浏览器里跑的标准方案。

一个实用的测试策略是:把纯 Rust 逻辑的测试用 #[test](原生运行),把 JS 互操作相关的测试用 #[wasm_bindgen_test]。这样纯逻辑测试可以在本地快速迭代,只有涉及 JS 的测试才需要 Node.js 或浏览器。在源码里可以用 cfg 条件编译分离两类测试——WASM 那一组必须同时带上 test,因为 wasm-bindgen-test 是 dev-dependency,普通的 wasm-pack build 里根本没有它:

#[cfg(test)]
mod tests {
    // 纯 Rust 逻辑测试——可以在原生环境快速运行
    #[test]
    fn test_pure_rust_logic() {
        assert_eq!(2 + 2, 4);
    }
}

#[cfg(all(test, target_arch = "wasm32"))]
mod wasm_tests {
    use wasm_bindgen_test::*;

    // JS 互操作测试——需要在浏览器环境运行
    #[wasm_bindgen_test]
    fn test_js_interop() {
        let val = js_sys::eval("1 + 1").unwrap();
        assert_eq!(val.as_f64().unwrap(), 2.0);
    }
}

测试在 CI 中的配置

# GitHub Actions 示例
- name: Install wasm-pack
  run: curl https://wasm-bindgen.github.io/wasm-pack/installer/init.sh -sSf | sh

- name: Install Chrome
  uses: browser-actions/setup-chrome@v1

- name: Run WASM tests
  run: wasm-pack test --headless --chrome

--headless 让浏览器在无界面模式下运行,适合 CI;不加它,runner 会停在「等你手动打开页面」那一步,CI 就挂住了。别忘了被测文件里要有 wasm_bindgen_test_configure!(run_in_browser);,否则这一步会把测试全部跳过而照样成功退出。ChromeDriver 不用自己装:src/command/test.rs:39-:44 的帮助文本写着,chromedriver 不在 $PATH 且没用 --chromedriver 指定时,wasm-pack 会下载一份本地副本;geckodriver 同理(:27-:32)。⚠️ safaridriver 是例外——:51-:55 明确写着 "wasm-pack cannot download the safaridriver WebDriver client for you",它必须已在 $PATH 或用 --safaridriver 显式指出。浏览器本身则一律要你自己装。

8.4 npm 发布

wasm-pack publish

它比想象中做得少。src/command/publish/mod.rs:17 的 publish() 全部逻辑是:

  1. 找 pkg/ 目录(find_pkg_directory,默认名由 --pkg-dir 决定)。
  2. 找不到才构建——而且是交互式地弹一个确认框问你「包还没构建,要构建吗」,你确认后它还会再问 out_dir 和 target 两个问题(Input + Select,target 候选只有 bundler/nodejs/web/no-modules,没有 deno),才跑一次 Build(src/command/publish/mod.rs:34-:66);已经存在 pkg/ 时它不会重新构建,也就是说它不保证 pkg/ 是最新的,改完代码要自己先跑 wasm-pack build。
  3. 执行 npm publish:src/npm.rs:20 的 npm_publish() 在 pkg/ 目录里起一个 npm publish 子进程,按需追加 --access 和 --tag。

它没有独立的「校验 package.json 必填字段」步骤——字段完整性由 npm publish 自己把关(wasm-pack build 阶段倒是有个 check_optional_fields()(src/manifest/mod.rs:856)会对缺失的 description/repository/license 发警告)。

wasm-pack publish 的选项只有 --target、--access、--tag、--pkg-dir 和路径(src/command/mod.rs:60-:82 的 Publish 变体);没有 --dry-run。其中 --target 是个死参数——publish() 的第一个形参就叫 _target(src/command/publish/mod.rs:18),函数体里从头到尾没用过它,只有上面那条交互式构建路径里 Select 选出来的 target 才真正生效。想预览就到 pkg/ 目录里自己跑 npm publish --dry-run。

package.json 的自定义

wasm-pack 自动生成的 package.json 通常是足够的,但有时需要添加额外的字段(如 repository、keywords、homepage)。唯一的入口是 Cargo.toml 的标准字段。src/manifest/mod.rs:659 起的 npm_data() / to_esmodules() 是逐字段从 cargo metadata 里抄的,所以想让 npm 包带上这些信息,就写在 [package] 里:

[package]
name = "my-lib"
version = "0.1.0"
description = "Image processing in Rust + WASM"
license = "MIT OR Apache-2.0"
repository = "https://github.com/user/my-lib"   # → package.json 的 repository.url
homepage = "https://my-lib.dev"                 # → package.json 的 homepage
keywords = ["wasm", "rust", "image-processing"] # → package.json 的 keywords
authors = ["杨艺韬"]                             # → package.json 的 collaborators

⚠️ 两个流传较广的说法在 0.14.0 上都不成立:

  • 没有 [package.metadata.wasm-pack.publish] 这个段。src/manifest/mod.rs:64 的 struct CargoWasmPack 只有一个字段 profile,别的键会被 serde_ignored 收集进 unused_keys,然后打印 "<key>" is an unknown key and will be ignored.(:504、:527)——写了不报错,但也完全不生效。
  • 项目根目录的 package.json 不会被当成模板合并。src/manifest/mod.rs:633 那段"合并已有 package.json"读的是 out_dir/package.json(也就是 pkg/package.json;上一次构建留下的那份已在 step_create_dir 里删掉,所以这里只可能是本次 wasm-bindgen 写出来的),它把整份文件反序列化成 HashMap<String, String>,当作 dependencies 写进新文件,其余一律覆盖重写。这条路径在 0.14.0 + wasm-bindgen 0.2.118 上是坏的:crate 根目录放了带 dependencies 的 package.json、又用 #[wasm_bindgen(module = "left-pad")] 这类写法导入 npm 包时,wasm-bindgen(crates/cli-support/src/lib.rs:735-:751)写出的 pkg/package.json 只有一个 dependencies 键,它的值是一个对象而不是字符串,wasm-pack 在生成 package.json 这一步直接失败:Error: invalid type: map, expected a string at line 2 column 18(实测)。需要 npm 依赖的包,只能 --no-pack 后自己写 package.json,或在构建后改写。

发布策略

npm 上的 WASM 包有几种发布策略:

graph TD
  A["WASM npm 包发布策略"] --> B{策略选择}
  B --> C["策略一: 构建产物发布<br/>最简单,最常用"]
  B --> D["策略二: CDN 分离<br/>.wasm 放 CDN,npm 只放 JS"]
  B --> E["策略三: 双包发布<br/>crates.io + npm"]
  C --> C1["优点: 用户不需要 Rust<br/>缺点: npm 包较大"]
  D --> D1["优点: npm 包极小<br/>缺点: 依赖 CDN 可用性"]
  E --> E1["优点: Rust 和 JS 用户各得其所<br/>缺点: 维护两个包"]
  style C1 fill:#10b981,color:#333
  style D1 fill:#f59e0b,color:#333
  style E1 fill:#6366f1,color:#fff

策略一:构建产物发布(推荐)

package.json 的 files 字段只包含 pkg/ 下的文件。用户 npm install 后直接使用构建产物——不需要本地编译 Rust。这是最简单、最可靠的策略,绝大多数 WASM 库使用这种方式。

策略二:CDN 分离

JS 胶水代码从 CDN 加载 .wasm:

// my_lib.js
const wasmUrl = 'https://cdn.example.com/my_lib_bg.wasm';
const { instance } = await WebAssembly.instantiateStreaming(fetch(wasmUrl), imports);

优点:npm 包极小(只有 JS 胶水)。缺点:运行时依赖 CDN 可用性,且 CDN 的 CORS 策略需要正确配置。

策略三:双包发布

Rust 用户通过 cargo 使用,JS 用户通过 npm 使用。两个包的 API 相同,但入口不同。这是 wasm-bindgen 生态的常见模式——wasm-bindgen 本身就是这种策略:crates.io 上的 wasm-bindgen crate + npm 上的 wasm-bindgen 包(内部使用的 JS 文件)。

版本号同步

如果使用策略三,需要确保 crates.io 上的版本号和 npm 上的版本号一致——否则用户会混淆。wasm-pack 从 Cargo.toml 的 version 字段自动生成 package.json 的 version,所以只要 Cargo.toml 的版本号正确,npm 包的版本号就是对的。但 crates.io 和 npm 是两个独立的 registry——发布顺序上没有原子性保证,需要手动确保两者同步。

8.5 配置:Cargo.toml 中的 metadata

wasm-pack 支持在 Cargo.toml 中自定义构建配置,通过 [package.metadata.wasm-pack.profile.*] 段:

[package.metadata.wasm-pack.profile.release]
wasm-opt = ["-Oz", "--enable-bulk-memory"]

[package.metadata.wasm-pack.profile.release.wasm-bindgen]
demangle-name-section = true
split-linked-modules = false

[package.metadata.wasm-pack.profile.dev]
wasm-opt = false  # debug 构建跳过 wasm-opt

可配置的就两块(src/manifest/mod.rs 的 CargoWasmPackProfile):

配置项 类型 默认值 说明
wasm-opt 布尔或字符串数组 release/profiling/custom 为 true(展开成 ["-O"]),dev 为不配置即不跑 true 等价于 ["-O"],false 跳过,数组则原样传给 wasm-opt(写了数组就完全替换默认的 -O,不是追加)
wasm-bindgen 表 见下 不是参数数组,而是五个具名布尔开关

wasm-bindgen 子表的五个字段与它们在 release profile 下的默认值:debug-js-glue = false(dev 下 true,对应 CLI 的 --debug)、demangle-name-section = true(为 false 时传 --no-demangle)、dwarf-debug-info = false(为 true 时传 --keep-debug)、omit-default-module-path = false、split-linked-modules = false(profiling、custom 的默认值与 release 完全相同)。想给 CLI 传任意别的参数,wasm-pack 没有提供入口。

两个容易忽略的细节:一是 --profile <name> 不管 <name> 叫什么,读的都是同一个 [package.metadata.wasm-pack.profile.custom] 段(src/manifest/mod.rs:539);二是 metadata.wasm-pack 下写错的键不会报错,只打印一条 "<key>" is an unknown key and will be ignored. 警告(实测 [package.metadata.wasm-pack.publish] 就是这样被忽略的)。

--enable-bulk-memory

这是传给 wasm-opt 的开关,允许 Binaryen 使用 Bulk Memory Operations 提案的指令(memory.copy、memory.fill 等)。这些指令能把 memcpy/memset 从循环压成单条指令,但让 rustc 生成它们的是另一回事,见下段。

注意 wasm-bindgen crate 没有 bulk-memory 这类 feature(它对外只有 default/std/enable-interning/serde-serialize/spans/strict-macro,见 §6.9)。真正决定 rustc 生成不生成 memory.copy/memory.fill 的是 target-feature,而 bulk-memory 从 Rust 1.87(LLVM 20)起已经在 wasm32-unknown-unknown 的默认集里(§5.10)——多数项目什么都不用配。这里给 wasm-opt 传 --enable-bulk-memory,只是允许 Binaryen 在优化时也使用这些指令。

浏览器支持:Chrome 75、Firefox 78、Safari 15.1(mdn/browser-compat-data 的 webassembly/bulk-memory-operations.json)。

--reference-types

启用 Reference Types 提案(externref 类型),允许 WASM 直接持有 JS 对象引用而不经过 JS 侧那个 heap 数组——省掉对象栈的簿记。浏览器支持:Chrome 96、Firefox 79、Safari 15.1(mdn/browser-compat-data 的 webassembly/reference-types.json)。

需要提醒的是:wasm-bindgen 的 --reference-types 标志早在 0.2.96 就被废弃了(PR #4237),0.2.118 改为读 .wasm 的 target-features 段自动决定;而 reference-types 从 Rust 1.82 起就在 wasm32 目标的默认 feature 集里。也就是说用近几年的工具链,这条路径默认已经开着,wasm-pack build 上那个 --reference-types 标志(src/command/build.rs:142 仍然保留着,作用是给 wasm-bindgen 和 wasm-opt 各加一个开关——后者见 src/command/build.rs:476 的 args.push("--enable-reference-types".into());wasm-pack test 上并没有这个标志)没什么必要再加。

启用后的效果:

  • JS 对象以 WASM 的 externref 类型过边界,存进模块自己的 __wbindgen_externrefs 表,Rust 侧的 JsValue 持有的是这张表的槽位下标,而不是 JS 侧 heap 数组的下标
  • 槽位的分配与回收都在 WASM 内部完成:JsValue drop 时 CLI 已把原来的 __wbindgen_object_drop_ref 导入改写成 WASM 内部的 __externref_table_dealloc,产物导入段里不再有它(§6.6)
  • 槽位清空之后,JS 对象本身何时回收交给引擎的 GC

同样地,wasm-bindgen crate 里没有 enable-reference-types 这种 feature——这条路径由 .wasm 的 target-features 段决定,不由 Cargo feature 决定。

省掉的是 JS 侧 heap 数组的簿记:只有在对象栈(heap slab)路径下,每次 JsValue drop 才要跨一次边界回 JS 释放槽位;0.2.118 配 Rust 1.96 默认走 externref,drop 已经在 WASM 内完成。收益随 JsValue 的使用密度变化,没有通用百分比——要量就用 §6.11 的方法。

需要注意的是,启用这些特性后生成的 .wasm 使用了较新的指令和类型——不支持的运行时在编译/验证阶段就会失败(浏览器里表现为 WebAssembly.CompileError,而不是运行到某处才出错)。如果你的目标环境包括旧版浏览器或旧版 Node.js,需要先确认它们的支持情况再决定是否启用。Can I Use 网站(caniuse.com)提供了各浏览器对 WASM 特性的支持矩阵,建议在启用前查阅。

8.6 与打包工具集成

Vite 集成

Vite 内置的 .wasm 处理只有 ?init 后缀导入这一种写法(packages/vite/src/node/plugins/wasm.ts,以《Vite 设计与实现》专栏对照的 8.0.3 为准):默认导出一个初始化函数,由你传 importObject 手动实例化。--target bundler 产物用的却是 import * as wasm from "./my_lib_bg.wasm" 这种 ESM Integration 写法,Vite 8.0.3 的文档(docs/guide/features.md:685)明说不支持,要借 vite-plugin-wasm 之类的社区插件(下文)。Vite 官网当前的文档(2026-09-24 核对)已写成可直接导入 .wasm 并按命名导出暴露,具体以你所用 Vite 版本的文档为准。插件配好后,导入方式如下:

// 注意 --target bundler 的产物里
// **没有** init() 默认导出——.wasm 由打包工具负责加载和实例化
import { greet } from 'my-lib';

console.log(greet('World'));

需要显式 await init() 的是 --target web 和 --target no-modules(§8.9)。

Vite 的处理流程:

flowchart LR
  A["import from 'my-lib'"] --> B["Vite 解析 node_modules"]
  B --> C["发现 my_lib_bg.wasm"]
  C --> D{开发模式 or 生产模式?}
  D -->|"开发"| E["WebAssembly.instantiateStreaming<br/>流式加载 .wasm"]
  D -->|"生产"| F["复制到 dist/assets/<br/>添加内容哈希"]
  E --> G["WASM 模块就绪"]
  F --> G
  style E fill:#6366f1,color:#fff
  style F fill:#10b981,color:#333

Vite 处理 WASM 的关键细节(以内置的 ?init 路径为例):在开发模式下,Vite 把 .wasm?init 变成一个 ES Module 包装——这个包装内部 fetch 模块并调用 WebAssembly.instantiateStreaming 流式编译和实例化(服务器没返回 application/wasm 时退回 WebAssembly.instantiate)。instantiateStreaming 的优势在于它可以在下载 .wasm 的同时并行编译,不必等全部字节到齐再开始——省下来的时间等于下载耗时与编译耗时的重叠部分,模块越大、网络越慢,省得越多。在生产构建中,Vite 把 .wasm 文件复制到 dist/assets/ 目录,添加内容哈希(如 my_lib_bg-abc123.wasm),并在 JS 胶水代码中更新引用路径;小于 build.assetsInlineLimit 的文件则内联成 base64。

要直接导入 --target bundler 产物,用社区插件:

// vite.config.js
import wasm from 'vite-plugin-wasm';
import topLevelAwait from 'vite-plugin-top-level-await';

export default {
  plugins: [
    wasm(),
    topLevelAwait(), // 支持顶层 await
  ],
};

vite-plugin-wasm 把 .wasm 的 ESM 导入改写成带顶层 await 的实例化代码;构建目标不支持顶层 await 时,再由 vite-plugin-top-level-await 把它转译掉。

webpack 集成

webpack 5 内置了 WASM 支持,但配置比 Vite 更复杂:

// webpack.config.js
module.exports = {
  experiments: {
    asyncWebAssembly: true,
  },
  module: {
    rules: [
      {
        test: /\.wasm$/,
        type: 'webassembly/async',
      },
    ],
  },
};

使用时(--target bundler 产物没有 init,asyncWebAssembly 会把 .wasm 的导入变成异步模块,直接用导出即可):

import { greet } from 'my-lib';

console.log(greet('World'));

要留意的是 webpack 的 Asset Modules:如果某条规则把 .wasm 当成 asset(而不是上面的 webassembly/async),小于 parser.dataUrlCondition.maxSize(默认 8KB)的文件会被内联成 base64 data URL——.wasm 被内联后就没法走 instantiateStreaming 了。显式声明 type: 'asset/resource' 可以强制它始终产出独立文件:

module.exports = {
  experiments: { asyncWebAssembly: true },
  module: {
    rules: [
      // 若确实需要以资源方式引用 .wasm,强制单独出文件、不内联
      { test: /\.wasm$/, type: 'asset/resource' },
    ],
  },
};

Rollup 集成

Rollup 不内置 WASM 支持,需要使用 @rollup/plugin-wasm:

// rollup.config.js
import wasm from '@rollup/plugin-wasm';

export default {
  input: 'src/index.js',
  plugins: [wasm()],
  output: {
    dir: 'dist',
    format: 'esm',
  },
};
// 这种 import init 的写法对应的是 --target web 的产物(默认导出初始化函数);
// --target bundler 的产物没有 init 默认导出
import init, { greet } from 'my-lib';

await init();
console.log(greet('World'));

跨专栏关联:与 Vite 的深度协作

与《Vite 设计与实现》专栏第 7 章「HMR 热更新」的关联——Vite 的 HMR 面向的是「改一个 JS 模块、毫秒级推送补丁」,而 .wasm 这一侧根本不在它的模块图里:改 Rust 源码后必须重跑一次完整的 wasm-pack build(cargo 编译 + wasm-bindgen + wasm-opt,见 §8.12),产出新文件后 Vite 才有东西可监听。这中间的等待由 Rust 编译决定,不是 Vite 能优化的。

社区有 vite-plugin-wasm-pack 尝试自动化这个流程——监听 Rust 源码变化,自动触发 wasm-pack build,然后通过 Vite 的 HMR 通知浏览器刷新。但 Rust 的编译速度使得真正的热替换不现实——即使自动触发构建,用户仍然需要等待编译完成。

一种实用的折中方案是在 vite.config.js 中配置自定义的 WASM 构建命令:

// vite.config.js
import { defineConfig } from 'vite';
import wasm from 'vite-plugin-wasm';

export default defineConfig({
  plugins: [wasm()],
  server: {
    // 监听 pkg/ 目录变化,自动刷新
    watch: {
      ignored: ['!pkg/**'],
    },
  },
});

然后在另一个终端运行 cargo watch -s 'wasm-pack build --target bundler',让 cargo-watch 监听 Rust 源码变化并自动构建。构建完成后,Vite 的文件监听会检测到 pkg/ 目录的变化,触发浏览器刷新。

更深入地看,Vite 和 wasm-pack 的协作还涉及一个容易忽视的问题——WASM 模块的重复初始化。在 Vite 的 HMR 期间,模块会被重新导入,但 WASM 模块不应该被重复编译和实例化——编译和实例化的开销随模块体积增长,大模块上不可忽视。--target web 生成的 init() 函数是幂等的——它内部用一个模块级变量记录是否已初始化,多次调用 init() 只会执行一次真正的实例化。但在 Vite 的 HMR 场景下,旧模块被卸载、新模块被加载——全局变量可能被重置,导致重新初始化。解决方案是在 HMR 边界上缓存 WASM 实例——通常是在一个不会被 HMR 替换的共享模块中保存对 init() 返回值的引用。

8.7 CI/CD 配置

在 CI 环境中构建和发布 WASM 包需要解决几个问题:Rust 工具链安装、Binaryen 安装、浏览器驱动配置、npm 认证。

GitHub Actions 完整配置

name: WASM CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install Rust
        uses: dtolnay/rust-toolchain@stable
        with:
          targets: wasm32-unknown-unknown

      - name: Install wasm-pack
        run: curl https://wasm-bindgen.github.io/wasm-pack/installer/init.sh -sSf | sh

      - name: Install Binaryen (wasm-opt)
        run: |
          wget https://github.com/WebAssembly/binaryen/releases/download/version_126/binaryen-version_126-x86_64-linux.tar.gz
          tar xf binaryen-version_126-x86_64-linux.tar.gz
          sudo cp binaryen-version_126/bin/wasm-opt /usr/local/bin/

      - name: Install Chrome
        uses: browser-actions/setup-chrome@v1

      - name: Build
        run: wasm-pack build --target bundler --release

      - name: Test (Node.js)
        run: wasm-pack test --node

      - name: Test (Chrome headless)
        run: wasm-pack test --headless --chrome

      - name: Check package size
        run: |
          SIZE=$(stat -f%z pkg/*.wasm 2>/dev/null || stat -c%s pkg/*.wasm)
          echo "WASM size: ${SIZE} bytes"
          if [ "$SIZE" -gt 500000 ]; then
            echo "WARNING: WASM file exceeds 500KB"
          fi

  publish:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    steps:
      - uses: actions/checkout@v4

      - name: Install Rust
        uses: dtolnay/rust-toolchain@stable
        with:
          targets: wasm32-unknown-unknown

      - name: Install wasm-pack
        run: curl https://wasm-bindgen.github.io/wasm-pack/installer/init.sh -sSf | sh

      # NODE_AUTH_TOKEN 要靠 setup-node 写出的 .npmrc 才会被 npm 读到
      - uses: actions/setup-node@v4
        with:
          registry-url: 'https://registry.npmjs.org'

      # publish 只在找不到 pkg/ 时才(交互式地)构建,CI 里没有终端,必须先显式构建
      - name: Build
        run: wasm-pack build --target bundler --release

      - name: Publish to npm
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
        run: wasm-pack publish --access public

CI 配置的关键注意事项

  1. wasm-bindgen 版本锁定:CI 中必须使用 Cargo.lock 锁定的版本,避免 wasm-bindgen crate 和 CLI 的版本不匹配。wasm-pack 会自动处理这一点——它从 Cargo.lock 读取精确版本号。

  2. Binaryen 版本:wasm-pack 在 $PATH 里找不到 wasm-opt 时会自己下载一份,而下载哪一版是写死在源码里的——src/install/mod.rs:207 拼 URL 时用 vers = "version_117"(src/wasm_opt.rs:54 传进去的 "latest" 对这个工具并不生效)。所以自动下载路径本身是确定的,但它跟着 wasm-pack 版本走:换一个 wasm-pack 版本就可能换一个 Binaryen 版本,而且默认拿到的是 117 而不是你以为的最新版。要让产物字节可复现、也想用上更新的优化,就自己钉一个版本装进 $PATH(本专栏基准是 Binaryen 126,find_wasm_opt 会优先用它)。另外 wasm-opt 必须认识产物里用到的提案指令,否则会直接报错——例如产物启用了 SIMD 或 tail-call,而 wasm-opt 太旧不认。

  3. 浏览器环境:CI runner 本身每次都是全新的,--headless --chrome 也不会复用你本机的浏览器配置,不存在缓存干扰;要担心的反而是 Chrome 与 chromedriver 的版本对不上——wasm-pack 只在 $PATH 里没有 chromedriver 时才自己下载。

  4. 体积检查:在 CI 中加入 .wasm 体积检查是一个好习惯——防止意外的体积回归。设置一个合理的阈值(如 500KB),超过时发出警告。体积回归通常是以下原因导致的:引入了新的 Vec/String 操作(增加了 alloc 的代码路径)、启用了新的 web_sys 特性(引入了大量的 DOM API 绑定)、或者 LTO 配置被意外修改。体积检查可以及早发现这些问题。

  5. npm 认证:wasm-pack publish 只是在 pkg/ 里起一个 npm publish,认证完全是 npm 自己的事。在 GitHub Actions 中通过 secrets.NPM_TOKEN 注入,不要把 token 提交到仓库中。注意 npm 的 token 体系在 2025 年底改过(2026-09-24 核对 GitHub Changelog):包括 "Automation" 在内的 classic token 在 2025 年底已全部吊销(GitHub Changelog 2025-12-09),现在只能建 granular access token,CI 用的要勾选 Bypass 2FA;而 npm 已宣布这类 bypass-2FA token 约在 2027 年 1 月失去直接发布能力,推荐改用 trusted publishing(OIDC)或带人工 2FA 审批的 staged publishing。

  6. 并发发布保护:如果多个 CI 任务同时运行 wasm-pack publish,可能导致 npm 上的版本冲突。建议在 publish job 中使用 concurrency 约束,确保同一时间只有一个发布流程。或者使用 npm publish --tag next 先发布到 next 标签,手动验证后再 promote 到 latest。

8.8 常见问题与调试

问题一:版本不匹配

it looks like the Rust project used to create this Wasm file was linked against
version of wasm-bindgen that uses a different bindgen format than this binary:

  rust Wasm file schema version: 0.2.xxx
     this binary schema version: 0.2.118

(0.2.118 的原文,crates/cli-support/src/wit/mod.rs:1824 起。)CLI 比对的是 wasm_bindgen_shared::SCHEMA_VERSION(0.2.118 里是 "0.2.118",但它只在格式变化时才改,并不总等于 crate 版本号,见 §6.10),宏把它写进自定义段开头那段 JSON。

解决方案:这个错误只会出现在你手动调用 wasm-bindgen CLI 的时候。wasm-pack 按 Cargo.lock 取版本,$PATH 上那个 CLI 版本不对时它会直接忽略、另外下载一份同版本的(§8.2 步骤一),所以用 wasm-pack build 就不会撞上。坚持手动调 CLI 的话,按报错里给的两条命令之一对齐版本:

cargo install -f wasm-bindgen-cli --version <Cargo.lock 里的版本>
# 或者反过来把依赖钉到 CLI 的版本
cargo update -p wasm-bindgen --precise <CLI 的版本>

问题二:wasm-opt 失败

$PATH 上没有 wasm-opt 时 wasm-pack 会去 GitHub 下载 Binaryen version_117(§8.2 步骤五),离线或网络不通时这一步失败,报错末尾带着一句:

To disable `wasm-opt`, add `wasm-opt = false` to your package metadata in your `Cargo.toml`.

另一类失败是 wasm-opt 自己退出非零(例如版本太旧、不认识产物里的提案指令),报 failed to execute `wasm-opt`: exited with … 并附上完整命令(src/child.rs)。

解决方案:自己装一份足够新的 Binaryen 放进 $PATH,或者在 Cargo.toml 中禁用 wasm-opt(临时跳过也可以用 --no-opt):

[package.metadata.wasm-pack.profile.release]
wasm-opt = false

问题三:pkg/ 目录要不要进 git

pkg/ 是构建产物,不应提交到版本控制。wasm-pack 每次构建都会在 pkg/ 里写一个内容为 * 的 .gitignore(src/command/utils.rs:42),所以默认它就不会被 git 跟踪;这个 .gitignore 不影响 npm publish,package.json 的 files 白名单照样生效(npm pack --dry-run 实测)。在仓库根的 .gitignore 里再写一行只是双保险。另外要知道 wasm-pack 不会清空 pkg/,只覆盖同名文件,换过 --out-name 或删过导出之后,旧文件会留在里面——发布前 rm -rf pkg 最稳妥。

# .gitignore
pkg/
target/

问题四:发布后 npm 包缺少 .wasm 文件

这通常是因为 package.json 的 files 字段没有包含 .wasm 文件。wasm-pack 自动生成的 files 字段应该包含它——如果你手动修改了 package.json,确保 files 数组包含 *_bg.wasm。

问题五:init() 到底要不要调

这件事完全由 --target 决定,两边搞混是最常见的报错来源:

// --target web:必须先 init,否则调用导出函数会失败
// (no-modules 同理,只是要 await 的是全局的 wasm_bindgen(...))
import init, { greet } from './pkg/my_lib.js';
await init();
greet('World');

// --target bundler:产物里没有 init 默认导出,
// .wasm 的加载由打包工具完成,直接调即可
import { greet } from 'my-lib';
greet('World');

// --target nodejs:require 时就已经同步实例化完毕
const { greet } = require('my-lib');
greet('World');

web 目标下 wasm-bindgen 生成的 init() 是幂等的——内部用一个模块级变量记录状态,重复调用直接返回已有实例(生成代码里那句 if (wasm !== undefined) return wasm;)。

8.9 构建目标:web / bundler / nodejs / no-modules 的差异

wasm-pack build --target <target> 是最容易选错的参数——不同 target 生成的胶水代码差异显著,错配会导致运行时报错或体积浪费。下面比较浏览器/Node 侧最常用的四个;第五个 deno 面向 Deno 运行时,且不生成 package.json(§8.2)。

8.9.1 四种 target 的本质差异

graph TB
  subgraph "wasm-pack target 决策"
    A{"如何加载?"} --> B["浏览器 ESM<br/>--target web"]
    A --> C["打包工具处理<br/>--target bundler"]
    A --> D["Node.js<br/>--target nodejs"]
    A --> E["全局 script<br/>--target no-modules"]
  end
  B --> B1["生成 init() 函数<br/>用 fetch 加载 .wasm<br/>可直接用 ESM import"]
  C --> C1["假设打包工具会处理 .wasm<br/>用 import 引入<br/>胶水里没有加载逻辑"]
  D --> D1["用 fs.readFileSync 加载<br/>导出 CommonJS 模块"]
  E --> E1["生成全局变量<br/>无 ESM 依赖<br/>需手动调用初始化"]
  style B fill:#10b981,color:#333
  style C fill:#6366f1,color:#fff
  style D fill:#f59e0b,color:#333
  style E fill:#dc2626,color:#fff
target JS 模块格式 WASM 加载方式 胶水里的加载逻辑 适用场景
web ESM fetch + instantiateStreaming 有(init) 浏览器直接 import,无打包工具
bundler ESM(import .wasm) 由打包工具处理 无 Webpack/Vite/Rollup 项目
nodejs CommonJS fs.readFileSync 几行同步加载 Node.js 服务端
no-modules IIFE 全局变量 手动调用全局的 wasm_bindgen(...) 加载 有,外加 IIFE 包装 不能用 ESM 的环境 / 直接嵌进页面

.wasm 本体不随 target 变:前文那个只导出 greet 的示例,四个 target 的 my_lib_bg.wasm 都是 15217 字节(实测)。差别只在 JS 胶水:同一个例子,bundler 是 9 行入口加 111 行 _bg.js,nodejs 116 行,web 209 行,no-modules 207 行——多出来的就是加载逻辑,百行量级。

8.9.2 web vs bundler:最容易混淆的一对

web 和 bundler 看起来相似——都生成 ESM——但底层 import 语法不同:

// --target web 生成的 my_lib.js(节选,wasm-bindgen 0.2.118 实测)
async function __wbg_init(module_or_path) {
    if (wasm !== undefined) return wasm;
    // ...(兼容旧的参数形式)
    if (module_or_path === undefined) {
        module_or_path = new URL('my_lib_bg.wasm', import.meta.url);
    }
    // ... fetch + instantiateStreaming ...
}
export { initSync, __wbg_init as default };

// --target bundler 生成的 my_lib.js(全文,略去首行 @ts-self-types 注释;模板见
// crates/cli-support/src/js/mod.rs:1050 的 `import * as wasm from "./{module_name}_bg.wasm";`)
import * as wasm from "./my_lib_bg.wasm";  // ← 直接 import .wasm
import { __wbg_set_wasm } from "./my_lib_bg.js";

__wbg_set_wasm(wasm);
wasm.__wbindgen_start();
export {
    greet
} from "./my_lib_bg.js";

bundler 模式假设打包工具能 import 一个 .wasm 文件——这是 Webpack 5+、Vite、Rollup 的能力,但纯浏览器原生 ESM 不支持。如果你不用打包工具直接在浏览器里 import './my_lib.js',bundler 模式必然失败——浏览器原生 ESM 没有「把 .wasm 当模块 import」这回事,只会得到一个模块解析/MIME 类型错误。这种场景必须用 web 模式。

反过来,如果你在打包工具项目里用了 web 模式,结果可以工作,只是胶水里多带一份 init() 加载逻辑,还得自己在入口处 await init();.wasm 靠 new URL(..., import.meta.url) 被当成静态资源处理。

8.9.3 nodejs target 的特殊性

Node.js 的 WASM 加载和浏览器不同——它用 fs.readFileSync 同步读文件:

// --target nodejs 生成的 my_lib.js(节选,wasm-bindgen 0.2.118 实测)
function greet(name) {  }
exports.greet = greet;
// ……
const wasmPath = `${__dirname}/my_lib_bg.wasm`;
const wasmBytes = require('fs').readFileSync(wasmPath);
const wasmModule = new WebAssembly.Module(wasmBytes);
let wasm = new WebAssembly.Instance(wasmModule, __wbg_get_imports()).exports;
wasm.__wbindgen_start();

注意:这是同步加载——require('my-lib') 时 WASM 已经实例化完毕。这和浏览器的 async init() 模式不同——Node.js 用户期望同步导入。

如果同时支持浏览器和 Node.js,需要发布两个版本:

wasm-pack build --target bundler --out-dir pkg-web
wasm-pack build --target nodejs --out-dir pkg-node

package.json 的 exports 字段做条件解析:

{
  "name": "my-lib",
  "exports": {
    ".": {
      "browser": "./pkg-web/my_lib.js",
      "node": "./pkg-node/my_lib.js",
      "default": "./pkg-web/my_lib.js"
    }
  }
}

8.9.4 选择决策表

场景 推荐 target 理由
Vite/Webpack/Rollup 项目 bundler 胶水最短;webpack 开 asyncWebAssembly 即可,Vite 需插件(§8.6)
静态 HTML 直接 import web 不需要打包工具
Next.js / SvelteKit bundler 按底层打包工具配置 wasm 支持(Next.js 见 §8.17.4,SvelteKit 走 Vite 插件)
Node.js 服务 nodejs 同步加载,CommonJS
同构(浏览器 + Node.js) 双 target + exports 字段 各自最优
不支持 ESM 的环境 / 直接塞进 <script> 的页面 no-modules 唯一选择(注意 IE11 根本不支持 WebAssembly,不在讨论范围内)
Deno deno 专门的 target,不生成 package.json
Cloudflare Workers 等边缘平台 web 或 bundler 视具体平台支持

8.10 替代工具与互补关系

wasm-pack 不是唯一选择——根据项目的需求,可能有更轻量或更专业的工具。

8.10.1 直接调用 wasm-bindgen-cli

如果不需要 npm 发布,可以跳过 wasm-pack 直接调用底层工具:

# 1. cargo 编译
cargo build --target wasm32-unknown-unknown --release

# 2. wasm-bindgen 生成胶水
wasm-bindgen --target web \
  --out-dir ./dist \
  target/wasm32-unknown-unknown/release/my_lib.wasm

# 3. wasm-opt 优化(可选)
wasm-opt -Oz dist/my_lib_bg.wasm -o dist/my_lib_bg.wasm

何时这样做:

  • 需要自定义构建步骤(如插入水印、改写元数据)
  • CI 中已有完整的 cargo 构建链路,不想再引入一个工具
  • 调试 wasm-pack 的行为(先用底层工具复现问题)

代价:版本管理变成手工的——必须保证 wasm-bindgen crate 版本和 wasm-bindgen-cli 版本一致,否则报错。

8.10.2 cargo-component(组件模型)

wasm-pack 是为 wasm-bindgen + npm 设计的。如果使用 WebAssembly Component Model(第 14 章),用 cargo-component:

cargo component new my-component --lib
cargo component build --release
# 输出 target/wasm32-wasip1/release/my_component.wasm(component 格式)
# 注意目标名是 wasm32-wasip1——2024 年 3 月改的名(见 rustc book 的
# src/doc/rustc/src/platform-support/wasm32-wasip1.md),旧名 wasm32-wasi
# 在 Rust 1.84.0 被移除(Rust 官方博客 2024-04-09「Changes to Rust's WASI
# targets」),1.96.0 的目标列表(rustc_target/src/spec/mod.rs)里已没有它

cargo-component 和 wasm-pack 的差异:

维度 wasm-pack cargo-component
目标 wasm-bindgen 模块 组件模型组件
ABI wasm-bindgen 协议 Canonical ABI
接口定义 Rust 类型 WIT 文件
多语言互操作 仅 Rust ↔ JS 任意支持 WIT 的语言
部署目标 浏览器 / Node.js wasmtime 等组件运行时;浏览器需先用 jco 转译

两个工具不是替代关系——是不同生态的入口。

8.10.3 Trunk:Yew/Leptos 的全栈构建

如果用 Yew/Leptos 写整个前端("UI 框架内核"模式),Trunk 比 wasm-pack 更合适:

trunk serve  # 开发模式(热重载)
trunk build --release  # 生产构建

Trunk 集成了:

  • WASM 编译 + wasm-bindgen
  • 以 index.html 为入口的资源管道(SASS/CSS、图标、静态文件拷贝)
  • 静态资源 hash 命名
  • 开发服务器 + 改动后自动刷新
  • --release 下自动接上 wasm-opt

wasm-pack 只产出 npm 包——你还需要 Vite/Webpack 集成到 HTML 中。Trunk 是一站式:从 .rs 到 dist/ 完整可部署网站。

8.10.4 工具选择决策图

flowchart TD
  A{"项目目标?"} --> B["发布 npm 包"]
  A --> C["完整前端应用"]
  A --> D["多语言组件"]
  A --> E["Cargo 工具链定制"]
  B --> F["wasm-pack"]
  C --> G{"框架?"}
  G -->|Yew/Leptos| H["Trunk"]
  G -->|JS 框架 + WASM 模块| F
  D --> I["cargo-component"]
  E --> J["直接调用 wasm-bindgen-cli"]
  style F fill:#10b981,color:#333
  style H fill:#6366f1,color:#fff
  style I fill:#f59e0b,color:#333
  style J fill:#dc2626,color:#fff

8.11 Monorepo 中的 wasm-pack 工程化

中大型项目通常用 monorepo 管理多个 npm 包。WASM 模块在 monorepo 中的引入和分发需要专门的工程设计——否则构建时间和缓存策略会失控。

8.11.1 monorepo 中 WASM 包的位置

典型布局:

my-monorepo/
├── packages/
│   ├── web-app/              # JS 应用,消费 WASM
│   ├── web-utils/            # 纯 JS 包
│   └── wasm-image-filter/    # WASM 包
│       ├── Cargo.toml
│       ├── src/
│       └── pkg/              # wasm-pack 产物(gitignore)
├── pnpm-workspace.yaml       # 或 turbo.json / nx.json
└── package.json

WASM 包用 wasm-pack build 生成 pkg/,然后通过 workspace 协议被其他包引用:

// packages/web-app/package.json
{
  "dependencies": {
    "@my-org/wasm-image-filter": "workspace:^"
  }
}

8.11.2 与 Turborepo / Nx 集成

构建编排工具需要知道 wasm-pack 是 web-app 的依赖任务。turbo.json 配置:

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", "pkg/**"]
    },
    "wasm-build": {
      "outputs": ["pkg/**"],
      "inputs": ["src/**", "Cargo.toml", "Cargo.lock"]
    }
  }
}

每个 WASM 包的 package.json 加 wasm-build 脚本:

{
  "scripts": {
    "build": "wasm-pack build --release --target bundler",
    "wasm-build": "wasm-pack build --release --target bundler"
  }
}

Turbo 根据 inputs 决定是否触发重建——只有 Rust 源码或 Cargo.toml 变化才会重新调用 wasm-pack,纯 JS 改动不会触发 WASM 重编译。

8.11.3 Cargo workspace 与 npm workspace 的协作

如果有多个 WASM crate,用 Cargo workspace 共享依赖编译缓存:

# 根 Cargo.toml
[workspace]
members = [
  "packages/wasm-image-filter",
  "packages/wasm-crypto",
  "packages/wasm-pdf",
]

[workspace.dependencies]
wasm-bindgen = "0.2"
js-sys = "0.3"
web-sys = "0.3"

每个 WASM crate 引用 workspace 依赖:

# packages/wasm-image-filter/Cargo.toml
[dependencies]
wasm-bindgen.workspace = true
js-sys.workspace = true

收益:所有 WASM 包共享一份 target/ 目录——wasm-bindgen、js-sys、web-sys 这些又大又公共的依赖只编译一次,而不是每个包各编一遍。包越多、公共依赖越重,省得越多。

8.11.4 CI 中的 WASM 增量构建

CI 中 WASM 编译往往是最慢的一步——Rust 要做单态化、LLVM 优化和链接,跟 JS/TS 的转译不是一个量级的工作量。两个加速手段:

手段一:Rust 编译缓存(sccache)

# GitHub Actions
- name: Setup sccache
  uses: mozilla-actions/sccache-action@v0.0.4

- name: Configure cargo
  run: |
    echo "RUSTC_WRAPPER=sccache" >> $GITHUB_ENV
    echo "SCCACHE_GHA_ENABLED=true" >> $GITHUB_ENV

- name: Build WASM
  run: pnpm turbo wasm-build

sccache 把 Rust 编译产物缓存到 GitHub Actions 缓存——二次构建只编译变化的 crate。收益完全取决于依赖占编译量的比重:依赖越重、业务代码越少,命中缓存后的加速越明显。

手段二:pkg/ 缓存策略

- name: Cache wasm-pack output
  uses: actions/cache@v4
  with:
    path: |
      packages/*/pkg
    key: wasm-pkg-${{ hashFiles('packages/*/src/**', 'packages/*/Cargo.toml', 'Cargo.lock') }}

如果源码没变,直接复用上次的 pkg/ 目录——完全跳过 wasm-pack 调用。Hash 必须覆盖所有可能影响产物的输入:源码、Cargo.toml、Cargo.lock。漏掉任何一项都可能导致 stale 产物。

8.12 wasm-pack build 的剖析与提速

wasm-pack build 是开发循环中最频繁的命令——每次代码变更都跑一次。理解每个阶段的耗时来源,才知道该优化哪一步。

8.12.1 构建阶段的时间分布

flowchart LR
  A["wasm-pack build"] --> B["1. cargo build<br/>压倒性的大头"]
  B --> C["2. wasm-bindgen<br/>次要"]
  C --> D["3. wasm-opt<br/>次要,-Oz 时不容忽视"]
  D --> E["4. 生成 package.json"]
  E --> F["5. 复制资源"]
  style B fill:#dc2626,color:#fff
  style C fill:#f59e0b,color:#333
  style D fill:#6366f1,color:#fff

各阶段在做什么、什么时候变慢:

阶段 主要动作 什么会让它变慢
cargo build (cold) 编译所有依赖 + 业务代码 依赖数量、web-sys 开的 feature 数、lto/codegen-units=1
cargo build (hot) 增量编译变化的 crate 改动是否命中了被大量依赖的 crate
wasm-bindgen 解析 .wasm、跑描述符解释器、生成 .js + .d.ts 导出/导入项的数量
wasm-opt 二进制级优化 优化级别(-Oz 显著慢于 -O)和模块大小
包装步骤 写 package.json、复制 README/LICENSE 基本恒定,可忽略

cargo build 是绝对的大头,其中又以依赖编译为主。所以优化手段几乎都围绕"少跑 cargo"——具体数字请在自己项目上用 cargo build --timings 量。

8.12.2 Profile 配置:开发与生产的两套构建

wasm-pack 通过 [package.metadata.wasm-pack.profile.<name>] 配置不同 profile:

# Cargo.toml
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1

[profile.dev]
opt-level = 1  # 比 0 快显著,dev 仍可用

[package.metadata.wasm-pack.profile.dev]
wasm-opt = false  # dev 不优化二进制

[package.metadata.wasm-pack.profile.profiling]
wasm-opt = ['-O', '-g']  # 保留名称段等调试信息(cargo 侧是 --release,默认本就没有 DWARF)

[package.metadata.wasm-pack.profile.release]
wasm-opt = ['-Oz', '--strip-debug']

调用:

wasm-pack build --dev        # 用 dev profile:不优化、不跑 wasm-opt,增量最快
wasm-pack build --profiling  # cargo 侧仍是 --release;按上面的配置跑 wasm-opt -O -g,保留名称段
wasm-pack build --release    # 完整优化

工程纪律:开发循环用 --dev(快),CI 上跑 --release(产物正确)。混用会导致"开发机能跑、生产挂掉"的诊断地狱。

8.12.3 三个有效的提速手段

手段一:sccache 缓存依赖编译

cargo install sccache --locked
export RUSTC_WRAPPER=sccache

sccache 把每个 crate 的编译产物缓存到本地或 S3——同样的依赖跨项目复用。第一次构建依然慢,之后的「冷」构建实际只需重编未命中的部分。

手段二:cargo-chef 让 Docker 缓存依赖层

CI 中用 Docker 构建时,普通 Dockerfile 的依赖层缓存策略很差——COPY . 把源码和 Cargo.toml 一起复制,源码变了就重新编译所有依赖。cargo-chef 把依赖编译独立成一层:

FROM rust:1.96 AS chef
WORKDIR /app
RUN rustup target add wasm32-unknown-unknown   # 官方镜像默认只有宿主 target,cook 那一步就要用
RUN cargo install cargo-chef wasm-pack@0.14.0 --locked

FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json

FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --target wasm32-unknown-unknown --release --recipe-path recipe.json

COPY . .
RUN wasm-pack build --release

效果:依赖变化时才重编依赖层(慢,但触发频率低),日常的源码变化只重编业务层。改一行业务代码不再等整棵依赖树重编,这是收益的全部来源。

手段三:跳过 wasm-opt 的开发循环

开发时完全不需要 wasm-opt。好消息是 wasm-pack 的 dev profile 默认就没开它(src/manifest/mod.rs 里 default_dev() 的 wasm_opt 是 None)——只要别在 [package.metadata.wasm-pack.profile.dev] 里手动打开就行;显式写 wasm-opt = false 可以防止被继承的配置改掉。

8.12.4 增量构建与 watch 模式

# 安装 cargo-watch
cargo install cargo-watch

# 监听文件变化自动重建
cargo watch -s 'wasm-pack build --dev --target bundler'

配合 Vite 的文件监听——Rust 代码改了 → wasm-pack rebuild → Vite 检测到 pkg/ 变化 → 浏览器刷新。一轮循环的耗时由增量 cargo 编译决定,项目越大越慢,要在自己的项目上量。

8.12.5 build 性能监控

CI 中应该跟踪构建时间——任何回归都立即可见:

# GitHub Actions
- name: Build with timing
  run: |
    start=$(date +%s)
    wasm-pack build --release
    end=$(date +%s)
    duration=$((end - start))
    echo "::notice::wasm-pack build took ${duration}s"
    if [ $duration -gt 120 ]; then
      echo "::warning::Build exceeded 2 minute budget"
    fi

设阈值(如 2 分钟)触发警告——超时通常意味着新引入了重依赖或缓存失效。早发现早处理,避免开发者集体抱怨"CI 慢"。

8.12.6 提速手段小结

四个手段各自消掉的是不同的一块时间,叠加使用才有意义:

手段 消掉的是哪一块 生效场景
sccache 依赖的重复编译(跨项目、跨 CI 运行) 本地和 CI 都有效
cargo-chef Docker 层缓存里被源码改动误伤的依赖层 只在 Docker 构建中有意义
--dev profile LLVM 优化 + wasm-opt 只用于开发循环,不能用于发布
增量构建(cargo watch) 未变化 crate 的重编译 本地开发循环

把这套做完后,开发循环的瓶颈从"等构建"变成"想下一步写什么"。要验证到底省了多少,用 cargo build --timings 和 CI 上的构建耗时记录来对比,不要相信任何文章给的秒数。

8.13 高级配置:构建钩子与产出定制

wasm-pack build 默认行为覆盖 80% 场景——但生产中经常需要定制:插入水印、修改生成的 .js、注入版本号、生成额外的资源。理解 wasm-pack 的扩展点是高级用法的关键。

8.13.1 构建流程的可扩展点

flowchart LR
  A["cargo build<br/>预处理"] --> B["cargo build<br/>编译"]
  B --> C["wasm-bindgen<br/>胶水生成"]
  C --> D["wasm-opt<br/>优化"]
  D --> E["package.json<br/>包装"]
  E --> F["输出 pkg/"]
  G["pre-build hook"] -.-> A
  H["post-build hook"] -.-> F
  I["自定义 wasm-opt 参数"] -.-> D
  J["自定义 metadata"] -.-> E
  style G fill:#10b981,color:#333
  style H fill:#10b981,color:#333
  style I fill:#6366f1,color:#fff
  style J fill:#6366f1,color:#fff

wasm-pack 没有内置 hook 机制——但可以通过 build script 和 cargo 的 [package.metadata] 实现等价能力。

8.13.2 注入构建时元数据

业务常需要在 WASM 内嵌入版本号、构建时间、git commit hash:

// build.rs(chrono 要加进 [build-dependencies])
use std::process::Command;

fn main() {
    let git_hash = Command::new("git")
        .args(&["rev-parse", "--short", "HEAD"])
        .output()
        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
        .unwrap_or_else(|_| "unknown".to_string());

    println!("cargo:rustc-env=GIT_HASH={}", git_hash);
    println!("cargo:rustc-env=BUILD_TIME={}", chrono::Utc::now().to_rfc3339());
}
// lib.rs
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn version_info() -> String {
    format!(
        "{} (git: {}, built: {})",
        env!("CARGO_PKG_VERSION"),
        env!("GIT_HASH"),
        env!("BUILD_TIME")
    )
}

JS 侧调用 version_info() 拿到完整版本——便于线上排错(用户报问题时知道用哪个 commit)。

8.13.3 自定义 wasm-opt 参数

wasm-pack 默认调 wasm-opt -O——但可以通过 Cargo.toml 完全定制:

[package.metadata.wasm-pack.profile.release]
wasm-opt = [
    '-Oz',                          # 体积优先
    '--enable-bulk-memory',         # 启用 bulk-memory 提案
    '--strip-debug',                # 移除 debug info
    '--strip-producers',            # 移除 producers 段
    '--vacuum',                     # 二次清理
]

[package.metadata.wasm-pack.profile.profiling]
wasm-opt = ['-O', '-g']             # 保留名称段,性能分析时能看到函数名

[package.metadata.wasm-pack.profile.dev]
wasm-opt = false                    # dev 跳过 wasm-opt

每个 profile 一套参数——通过 wasm-pack build --release / --dev / --profiling 切换。

8.13.4 post-build 脚本:注入水印与签名

构建完成后做后处理——比如签名 .wasm、生成 SRI hash、插入水印:

#!/bin/bash
# scripts/post-build.sh
set -e

WASM_FILE="pkg/my_lib_bg.wasm"

# 1. 计算 SHA-256
HASH=$(sha256sum "$WASM_FILE" | awk '{print $1}')
echo "WASM SHA-256: $HASH"

# 2. 生成 SRI(用于 HTML integrity 属性)
SRI=$(cat "$WASM_FILE" | openssl dgst -sha384 -binary | openssl base64 -A)
echo "{\"sri\":\"sha384-$SRI\"}" > pkg/integrity.json

# 3. 用 cosign 签名
cosign sign-blob --output-signature="$WASM_FILE.sig" "$WASM_FILE"

# 4. 注入版本元数据到 package.json
node -e "
  const pkg = require('./pkg/package.json');
  pkg.wasmHash = '$HASH';
  pkg.signedAt = new Date().toISOString();
  require('fs').writeFileSync('./pkg/package.json', JSON.stringify(pkg, null, 2));
"

包装成 npm script:

{
  "scripts": {
    "build": "wasm-pack build --release && bash scripts/post-build.sh",
    "publish": "npm run build && wasm-pack publish"
  }
}

8.13.5 生成额外资源:TypeScript 类型补全

wasm-pack 生成的 .d.ts 是基础——某些场景需要扩展(如自定义错误类型、union types):

// pkg/extra.d.ts
export * from './my_lib';   // 让它能充当整个包的类型入口

// 扩展 wasm-pack 生成的类型
declare module './my_lib' {
    interface User {
        // 添加 wasm-pack 没生成的方法(运行时存在但类型缺失)
        toJSON(): { name: string; age: number };
    }

    // 自定义错误 union
    type AppError =
        | { kind: 'NotFound'; id: string }
        | { kind: 'Validation'; field: string };
}

然后在 package.json 里把类型入口换成它,并把它加进 files:

{
  "types": "extra.d.ts",
  "files": ["my_lib_bg.wasm", "my_lib.js", "my_lib_bg.js", "my_lib.d.ts", "extra.d.ts"]
}

别用 typesVersions 把两个文件并列写成 ["my_lib.d.ts", "extra.d.ts"]:那是「依次尝试、取第一个存在的」回退列表,my_lib.d.ts 存在,extra.d.ts 就永远不会被加载(TypeScript 5 实测:toJSON 与 AppError 都报找不到;改成上面的 types 写法则通过)。

8.13.6 多产物构建:按需打包

发布到 npm 时,可能要同时支持浏览器和 Node.js——分别构建:

#!/bin/bash
# scripts/build-multi.sh

# 浏览器版本(bundler target)
wasm-pack build --release --target bundler --out-dir pkg-web --out-name my_lib

# Node.js 版本
wasm-pack build --release --target nodejs --out-dir pkg-node --out-name my_lib

# 合并到统一 pkg/
mkdir -p pkg
cp -r pkg-web/* pkg/
mkdir -p pkg/node
cp pkg-node/* pkg/node/

# 编辑 package.json 让两个版本通过 exports 字段暴露
node scripts/merge-package-json.js

package.json 的 exports 字段:

{
  "exports": {
    ".": {
      "browser": "./my_lib.js",
      "node": "./node/my_lib.js",
      "default": "./my_lib.js"
    }
  }
}

8.14 项目脚手架与模板生态

从空目录到能跑的 wasm-pack 项目,模板和脚手架省掉的是一整套样板配置。社区留下了几套模板,但其中不少已随 rustwasm 组织归档——理解它们的差异和维护状态,有助于选择合适的起点。

8.14.1 wasm-pack new:默认模板

# 创建一个简单 hello-world 项目
wasm-pack new hello-wasm

# 项目结构
hello-wasm/
├── .appveyor.yml / .travis.yml / .github/dependabot.yml
├── .gitignore
├── Cargo.toml
├── LICENSE_APACHE
├── LICENSE_MIT
├── README.md
├── src/
│   ├── lib.rs
│   └── utils.rs
└── tests/
    └── web.rs

wasm-pack new 内部就是调 cargo-generate(src/generate.rs:cargo generate --git <template> --name <name>),$PATH 上的 cargo-generate 只有版本号与 crates.io 上的最新版完全一致才会被使用,否则 wasm-pack 下载源码里写死的 0.23.7 预编译包(src/install/mod.rs:200)。默认模板在 src/command/mod.rs:51 里写死为 https://github.com/drager/wasm-pack-template——最小的可用项目,适合学习(上面的目录结构按该仓库 2026-09-24 的文件列表)。旧的 rustwasm/wasm-pack-template 已归档;wasm-pack 仓库迁到 wasm-bindgen/wasm-pack 之后,主分支上的默认模板地址也改了,以你所用版本的 wasm-pack new --help 为准。

8.14.2 主流模板对比

graph TD
  A["wasm-pack 模板生态"] --> B["最小项目"]
  A --> C["前端集成"]
  A --> D["全栈框架"]
  B --> B1["drager/wasm-pack-template<br/>纯 Rust → npm"]
  C --> C1["create-wasm-app(已归档)<br/>Webpack 集成"]
  C --> C2["vite-plugin-wasm<br/>Vite 集成"]
  D --> D1["Yew + Trunk<br/>全 Rust 前端"]
  D --> D2["Leptos<br/>Signal-based UI"]
  style B1 fill:#10b981,color:#333
  style C1 fill:#6366f1,color:#fff
  style D1 fill:#f59e0b,color:#333

主流模板特征:

模板 适用 输出格式 维护状态(2026-09-24 核对 GitHub)
drager/wasm-pack-template(0.14.0 的 wasm-pack new 默认) 纯 npm 库 npm 包 未归档,最后提交在 2023 年
rustwasm/create-wasm-app 有 Webpack 的项目 npm 包 + Webpack 集成示例 已归档
rustwasm/rust-webpack-template 完整 Webpack 项目 完整 Web 应用 已归档
yewstack/yew-trunk-minimal-template Yew 项目 Trunk 构建 SPA 维护中
leptos-rs/start-axum Leptos 项目 SSR + 客户端 hydration 维护中(旧的 leptos-rs/start 已改名为 start-actix-0.6)

8.14.3 自定义模板

团队/公司常需要自己的标准模板——预配置 CI、内部依赖、代码规范:

# 用 cargo-generate 创建自定义模板
cargo install cargo-generate

# 从你的模板仓库创建项目
cargo generate --git https://github.com/your-org/wasm-template.git --name my-project

模板仓库结构:

wasm-template/
├── .gitignore
├── cargo-generate.toml      # 模板元数据
├── Cargo.toml.liquid        # 用 Liquid 模板语法
├── src/
│   └── lib.rs.liquid
├── .github/workflows/       # 标准 CI
│   └── ci.yml
└── README.md.liquid

cargo-generate.toml 配置可填写的占位符:

[template]
cargo_generate_version = ">=0.10.0"

[placeholders.author_name]
type = "string"
prompt = "Your name?"
default = "Your Name"

[placeholders.use_simd]
type = "bool"
prompt = "Enable SIMD?"
default = false
# Cargo.toml.liquid 中使用
[package]
name = "{{project-name}}"
authors = ["{{author_name}}"]

{% if use_simd %}
# 注意:simd128 是 rustc 的 target-feature,不是 wasm-pack 的配置项。
# wasm-pack 的 [package.metadata.wasm-pack.profile.*.wasm-bindgen] 只认
# debug-js-glue / demangle-name-section / dwarf-debug-info /
# omit-default-module-path / split-linked-modules 这五个布尔开关。
# 真要开 SIMD,写在 .cargo/config.toml 的 rustflags 里(§5.10)。
[package.metadata.wasm-pack.profile.release]
wasm-opt = ['-Oz', '--enable-simd']
{% endif %}

8.15 wasm-pack 的常见误解与澄清

任何工具用久了都积累一堆"用户期待 vs 工具实际行为"的偏差。wasm-pack 也不例外,下面八条逐一对照 0.14.0 的实际行为。

8.15.1 误解清单

graph TD
  A["wasm-pack 常见误解"] --> B["误解 1:wasm-pack 是必须"]
  A --> C["误解 2:build 自动 strip 全部"]
  A --> D["误解 3:publish 会先重新 build"]
  A --> E["误解 4:cmake/make 风格的增量构建"]
  A --> F["误解 5:必须先装 wasm-bindgen-cli"]
  A --> G["误解 6:所有 target 都用 web"]
  A --> H["误解 7:测试在哪都一样"]
  A --> I["误解 8:pkg/ 该入 git"]
  style A fill:#f59e0b,color:#333

8.15.2 误解 1:wasm-pack 不是必须

很多教程说"wasm-pack 是 Rust + WASM 唯一选择"——错。wasm-pack 是一个便利工具,不是规范的一部分:

  • 直接调 wasm-bindgen-cli 也能产出同样结果
  • 组件模型走的是另一套工具链(cargo-component 或 wasm32-wasip2 + wit-bindgen),根本不经过 wasm-pack
  • trunk 替代 wasm-pack 用于全 Rust 前端

正确认识:wasm-pack 的价值在于"npm 集成 + 标准化产物"——如果你不发布 npm 包,可以不用 wasm-pack。

8.15.3 误解 2:build 不会 strip 所有可剥离的东西

很多人以为 wasm-pack build --release 已经做完所有优化——错。默认配置只调用 wasm-opt -O(即 -Os),不是 -Oz;Cargo 的 [profile.release] 也原样不动(§8.2)。对体积敏感的生产构建要明确配置:

[package.metadata.wasm-pack.profile.release]
wasm-opt = ['-Oz']

-O 与 -Oz 的差距在体积敏感的项目上是实打实的,但幅度完全取决于代码构成——用 twiggy 对比前后产物(第 9 章),别信固定百分比。

8.15.4 误解 3:publish 会先重新 build

wasm-pack publish 做的比想象中少(§8.4,src/command/publish/mod.rs:17):

flowchart LR
  A["wasm-pack publish"] --> B{"找得到 pkg/ ?"}
  B -->|"找得到"| F["cd pkg/ && npm publish<br/>(转发 --access / --tag)"]
  B -->|"找不到"| C["交互式确认 + 选 out_dir 和 target"]
  C --> D["wasm-pack build(默认 release)"]
  D --> F
  style A fill:#6366f1,color:#fff
  style F fill:#10b981,color:#333

它不检查 npm 登录、不校验 package.json、也不就 access/tag 发起交互——这两项只能用 --access、--tag 传,唯一的交互是「pkg/ 不存在时要不要构建」。所以 pkg/ 已存在时,wasm-pack publish 与 cd pkg && npm publish 几乎等价;两种方式都不会替你重新构建,改完代码必须先自己跑一次 wasm-pack build。

8.15.5 误解 4:增量构建不是 cmake 风格

wasm-pack 没有 Makefile 风格的依赖图——它每次都跑完整 cargo build。"增量"完全依赖 cargo 的增量编译(incremental compilation)。

graph LR
  A["改一行 Rust"] --> B["cargo 检测变化"]
  B --> C["仅重编受影响的 crate"]
  C --> D["wasm-bindgen 重新跑(必然)"]
  D --> E["wasm-opt 重新跑(必然)"]
  style D fill:#f59e0b,color:#333
  style E fill:#f59e0b,color:#333

即使只改一行,wasm-bindgen 和 wasm-opt 总是从头跑——没有"哪部分没变就跳过"的机制。所以 wasm-pack build 的最快增量时间有一个下限,永远比纯 cargo build 的增量慢一截;这也是开发循环里第一个该关掉 wasm-opt 的原因。

8.15.6 误解 5:必须装 wasm-bindgen-cli

许多文档说"先装 wasm-bindgen-cli 再用 wasm-pack"——错。wasm-pack 自动管理 wasm-bindgen-cli:

  • 版本取自 Cargo.lock 里 wasm-bindgen 的精确版本
  • $PATH 上恰好有同版本的 CLI 就直接用,版本不同就当没看见
  • 否则第一次 build 时从 GitHub release 下载该版本的预编译包,之后从缓存里拿;没有预编译包才 cargo install

所以手动 cargo install wasm-bindgen-cli 装一个版本并不会干扰 wasm-pack——版本对上了它顺手用,对不上它另拿一份(§8.2 步骤一)。真正会因版本不符报错的,只有你自己直接调 CLI 的时候(§8.8)。

8.15.7 误解 6:不是所有项目都用 --target web

先纠正一个基础事实:wasm-pack build 的默认 target 是 bundler 不是 web(src/command/build.rs:#[clap(long = "target", short = 't', default_value = "bundler")]),wasm-pack publish 的 --target 默认值同样是 bundler。详细见 §8.9。简单总结:

你用什么 推荐 target
没有打包工具的纯静态 HTML web
Vite/Webpack 项目 bundler(胶水里没有加载逻辑)
Node.js 服务 nodejs
老浏览器兼容 no-modules

常见的错配是:明明在 Vite/Webpack 项目里,却手动加了 --target web——能用,只是多带一份 init() 加载逻辑,还得自己记得 await init()。

8.15.8 误解 7:测试在哪都一样

wasm-pack test --node vs --chrome --headless 行为不同:

维度 --node --chrome
启动开销 只起一个 node 进程 还要起 chromedriver 和浏览器,明显更慢
浏览器 API 不可用 完整
调试 console.log 非 headless 时自己开页面,可用 DevTools
CI 兼容 简单 需要安装 Chrome,并加 --headless
跑哪些测试 没有 run_in_browser 声明的 有 run_in_browser 声明的

应该按测试需求选——简单单元测试用 --node(快),需要 web-sys 的测试声明 wasm_bindgen_test_configure!(run_in_browser) 后用 --chrome(§8.3)。

8.15.9 误解 8:pkg/ 该入 git

pkg/ 目录是构建产物,不应该入 git——wasm-pack 自己也是这么认为的:它每次构建都往 pkg/ 里写一个内容为 * 的 .gitignore(§8.8 问题三)。根目录 .gitignore 里的 target/ 还是要自己写:

pkg/
target/

入 git 会让仓库膨胀(每次 build 都改 pkg/),且 release commit 会有大量噪音。pkg/ 内容应该只在发布到 npm 时存在——发布完即可丢。

8.16 wasm-pack 与 OCI 镜像的集成

wasm-pack 主要面向 npm 发布——但服务端/边缘场景的 WASM 部署用 OCI 镜像(Docker/Kubernetes 标准)。先说清一个前提:wasm-pack 的产物放进 OCI 镜像是跑不起来的。它是 wasm32-unknown-unknown 模块,导入的全是 wasm-bindgen 胶水提供的 JS 函数(实测导入模块名是 ./my_lib_bg.js),离开 JS 宿主就无法实例化——直接 wasmtime run 它会报错退出;runwasi / Wasmtime 这类服务端运行时要的是 WASI 模块或组件(第 12—14 章)。所以同一份 Rust 代码要进 OCI,得另按 wasm32-wasip1/wasm32-wasip2 构建一次;本节讲的是这条分发链路本身,以及它与 npm 那条链路怎么并存。

8.16.1 OCI 镜像与 npm 包的差异

graph TD
  A["WASM 分发方式"] --> B["npm 包"]
  A --> C["OCI 镜像"]
  B --> B1["浏览器项目用<br/>JS 胶水 + .wasm"]
  C --> C1["服务端用<br/>仅 .wasm 二进制"]
  style B fill:#10b981,color:#333
  style C fill:#6366f1,color:#fff
维度 npm 包 OCI 镜像
内容 .wasm + .js + .d.ts 仅 .wasm(通常)
大小 几十 KB-几 MB 几 KB-几 MB
注册中心 npmjs.com Docker Hub / 私有 registry
版本号 semver tag + digest
部署目标 浏览器 / Node.js K8s / Wasmtime CLI

8.16.2 OCI 镜像格式中的 WASM

把 .wasm 放进 OCI registry 靠的是 OCI Image Spec 1.1 对「任意 artifact」的支持,而不是 spec 里为 WASM 开的专门条款。具体布局由 CNCF TAG Runtime 的 Wasm 工作组规定(「Wasm OCI Artifact」,2026-09-24 核对):config 的 mediaType 写 application/vnd.wasm.config.v0+json,以此标明这是 Wasm artifact;模块作为 layer,mediaType 写 application/wasm:

{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.wasm.config.v0+json",
    "size": 200,
    "digest": "sha256:..."
  },
  "layers": [
    {
      "mediaType": "application/wasm",
      "size": 102400,
      "digest": "sha256:..."
    }
  ]
}

这种格式让 WASM 在 K8s 中可以像 OCI 容器一样部署——不需要单独的分发机制。

8.16.3 用 Docker 构建服务端镜像

# 阶段 1:构建 WASI 目标的 .wasm(不是 wasm-pack 产物,见本节开头)
FROM rust:1.96-slim AS builder
RUN rustup target add wasm32-wasip2
WORKDIR /work
COPY . .
RUN cargo build --release --target wasm32-wasip2

# 阶段 2:极简运行镜像
FROM scratch
COPY --from=builder /work/target/wasm32-wasip2/release/my_app.wasm /app.wasm
ENTRYPOINT ["/app.wasm"]

注意:FROM scratch 让镜像极小——只含 .wasm 文件本身。节点上的 runwasi(containerd-shim-wasm 及其上的 Wasmtime 等 shim)负责加载执行;这里假设 crate 是一个带 main 的 WASI 程序。

8.16.4 用 oras 工具直接推送 .wasm

如果不需要构建中间 Docker 镜像,用 oras(OCI Registry As Storage)直接推送 .wasm:

# 推送 .wasm 到 OCI registry
oras push registry.example.com/my-wasm:v1.0 \
    --artifact-type application/wasm \
    target/wasm32-wasip2/release/my_app.wasm:application/wasm

# 拉取
oras pull registry.example.com/my-wasm:v1.0 -o ./pkg

这种方式最简洁——.wasm 直接存到 registry,不需要 Dockerfile。

8.16.5 wkg:WASM 的标准化工具

Bytecode Alliance 的 wkg(WebAssembly Package Manager)是为 WASM 专门设计的 OCI 工具:

# 推送到 OCI registry
wkg oci push registry.example.com/my-wasm:1.0 my_lib.wasm

# 拉取
wkg oci pull registry.example.com/my-wasm:1.0

(wkg 还在快速演进,子命令时有调整,用之前先 wkg --help 确认。)

wkg 比 oras 更专门——它理解 WASM 组件、WIT 接口、Component Model 元数据。是组件模型生态的标准包管理工具。

8.16.6 双重发布:npm + OCI

许多 WASM 项目同时面向浏览器和服务端——双重发布模式。两条链路共用 Rust 源码,但产物是两次构建出来的:

flowchart LR
  A1["wasm-pack build<br/>wasm32-unknown-unknown"] --> B["npm 包"]
  A2["cargo build<br/>--target wasm32-wasip2"] --> C["OCI 镜像"]
  B --> D["发布到 npmjs.com"]
  C --> E["发布到 Docker Hub"]
  D --> F["浏览器消费者"]
  E --> G["K8s 部署"]
  style A1 fill:#6366f1,color:#fff
  style A2 fill:#6366f1,color:#fff

CI 配置:

- name: Build
  run: wasm-pack build --release --target bundler

- name: Publish to npm
  run: wasm-pack publish --access public

- name: Build OCI image   # Dockerfile 内按 wasm32-wasip2 另编一次(§8.16.3)
  run: docker build -t registry/my-wasm:${{ github.ref_name }} .

- name: Push OCI
  run: docker push registry/my-wasm:${{ github.ref_name }}

一份源码,两次构建,两种分发——浏览器和服务端各取所需。前提是业务逻辑与宿主接口分层:浏览器侧的 #[wasm_bindgen] 导出和 WASI 侧的入口各写一层薄壳,用 cfg 或拆 crate 隔开。

8.16.7 K8s 部署 WASM 的完整流程

flowchart TD
  A["开发"] --> B["cargo build --target wasm32-wasip2"]
  B --> C["docker build / wkg"]
  C --> D["push to registry"]
  D --> E["K8s pull image"]
  E --> F["containerd-shim-wasm 执行"]
  style F fill:#10b981,color:#333

K8s Deployment 配置:

apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      runtimeClassName: wasmtime  # 关键:指定 WASM runtime
      containers:
      - name: app
        image: registry.example.com/my-wasm:1.0

8.16.8 镜像大小的优化

WASM OCI 镜像的体积优势来自一个结构性事实:镜像里没有操作系统。传统容器至少要带一层发行版根文件系统(alpine 也有几 MB),WASM 镜像可以只有一个 .wasm 文件。可做的优化:

优化 去掉的是什么
FROM scratch 而非 alpine 整个基础镜像层
只 COPY .wasm,不复制源码和构建产物 构建阶段的一切中间物
wasm-opt -Oz 模块自身的体积(幅度见第 9 章)
不打包 .d.ts / .js 胶水(服务端不需要) 面向浏览器才有用的那部分产物

冷启动同理:省掉的是容器 rootfs 挂载和进程启动,剩下的主要是模块编译与实例化时间——具体多少要在目标运行时上测。

8.16.9 工程注意事项

flowchart TD
  A["OCI + WASM 注意"] --> B["1. mediaType 正确<br/>application/wasm"]
  A --> C["2. registry 支持 WASM<br/>测试推送拉取"]
  A --> D["3. K8s runtimeClass 配置"]
  A --> E["4. 监控 WASM 实例化时间"]
  A --> F["5. 镜像签名(cosign)"]
  style A fill:#6366f1,color:#fff

每条都有具体陷阱:

  • mediaType:错配会让某些 runtime 拒绝拉取
  • registry 支持:Docker Hub / GitHub Container Registry / Harbor 支持,但内部 registry 可能需要升级
  • runtimeClass:K8s 节点必须装 containerd-shim-wasm 或 runwasi
  • 监控:WASM 实例化时间不同于容器启动,需要专门指标
  • 签名:与 OCI 容器一样用 cosign,确保供应链安全

8.16.10 OCI + WASM 的未来

可以确认的现状是:OCI Image Spec 1.1 已经允许承载任意 artifact,containerd 侧有 runwasi / containerd-shim-wasm 这条已经可用的路径,K8s 通过 runtimeClassName 把 Pod 调度到 WASM shim 上。至于它什么时候会成为主流部署方式——本专栏不做预测,只指出这条链路今天已经能跑通。

8.17 wasm-pack 与新兴打包工具的集成

§8.6 介绍了 wasm-pack 与传统打包工具(Webpack/Vite)的集成——但 2024-2026 年涌现了一批 Rust 编写的新打包工具(rspack / turbopack / Rolldown)。它们与 wasm-pack 的集成有不同特点。

8.17.1 Rust 打包工具崛起的背景

graph TD
  A["JS 生态打包工具的演进"] --> B["第一代<br/>Browserify"]
  A --> C["第二代<br/>Webpack"]
  A --> D["第三代<br/>Vite/esbuild"]
  A --> E["第四代<br/>Rust 工具"]
  B --> B1["简单合并"]
  C --> C1["插件生态"]
  D --> D1["esbuild 加速"]
  E --> E1["Rust 编译器极速"]
  style E fill:#10b981,color:#333

每一代都在换更快的实现语言:Browserify/Webpack 是纯 JS,esbuild 是 Go,第四代是 Rust。原生代码加上多线程并行解析,是这批工具提速的主要来源;具体快多少随项目而异,见下文 §8.17.3 的说明。

8.17.2 主流 Rust 打包工具

graph TD
  A["Rust 打包工具"] --> B["rspack"]
  A --> C["turbopack"]
  A --> D["Rolldown"]
  A --> E["mako"]
  B --> B1["字节跳动开源<br/>Webpack 兼容"]
  C --> C1["Vercel<br/>Next.js 集成"]
  D --> D1["Rollup 兼容 API<br/>Vite 8 默认构建引擎"]
  E --> E1["蚂蚁集团<br/>umi 团队"]
  style B fill:#10b981,color:#333
  style C fill:#6366f1,color:#fff

每个工具的定位:

工具 维护方 特点 WASM 支持
rspack 字节跳动 Webpack API 兼容 良好(继承 Webpack)
turbopack Vercel Next.js 标配 实验性
Rolldown VoidZero(Vite 团队) Rollup 兼容 API,底层用 oxc 的解析/转换 走 Vite 的 wasm 处理(§8.6)
mako 蚂蚁集团(umi 团队) 主要服务自家框架;umijs/mako 仓库现重定向到 utooland/utoo(2026-09-24 核对) 未特别声明

(oxc 本身不是打包器,而是一组解析器、linter、转换器、压缩器,Rolldown 构建在它之上。)

8.17.3 wasm-pack + rspack 集成

rspack 是 Webpack 的 drop-in 替代——wasm-pack 的 --target bundler 输出能直接用:

// rspack.config.js
module.exports = {
    entry: './src/index.js',
    experiments: {
        asyncWebAssembly: true,  // 启用 WASM 支持
    },
    module: {
        rules: [
            {
                test: /\.wasm$/,
                type: 'webassembly/async',
            },
        ],
    },
};
// 使用 wasm-pack 输出
import { greet } from './pkg/my_lib';
greet('World');

这里不给构建时间的对照表——各家 benchmark 高度依赖项目结构、插件集合和缓存状态,抄来的秒数没有参考价值。要选型就在自己的仓库上跑一遍 time 对比。

不过有一点是确定的:.wasm 那部分的构建时间和打包工具无关——它由 cargo + wasm-bindgen + wasm-opt 决定,换打包工具只影响 JS 侧那一半。

8.17.4 wasm-pack + turbopack(Next.js)

// next.config.js
module.exports = {
    webpack(config) {
        config.experiments = {
            asyncWebAssembly: true,
            layers: true,
        };
        return config;
    },
};

注意这段 webpack() 钩子只在用 webpack 打包时生效。Next.js 16(2025-10-21 发布)起 Turbopack 已是 next dev 与 next build 的默认打包器,有自定义 webpack 配置的项目要用 next dev --webpack / next build --webpack 退回 webpack(Next.js 16 发布博客);Turbopack 自己的配置也从 experimental.turbo 挪到了顶层的 turbopack 键。Turbopack 下 .wasm 的处理方式以你所用 Next.js 版本的文档为准,遇到问题时退回 webpack 是可行的兜底。

8.17.5 集成的工程考虑

flowchart TD
  A["选打包工具 + wasm-pack"] --> B{"项目特征?"}
  B --> C["大型 monorepo"]
  B --> D["Next.js 项目"]
  B --> E["新项目灵活"]
  C --> F["rspack(替换 Webpack)"]
  D --> G["turbopack(默认)"]
  E --> H["Vite(成熟)"]
  style F fill:#10b981,color:#333
  style H fill:#6366f1,color:#fff

8.17.6 兼容性陷阱

graph TD
  A["Rust 工具的兼容性陷阱"] --> B["1. 插件生态不全"]
  A --> C["2. 边缘配置差异"]
  A --> D["3. WASM 加载方式可能不同"]
  A --> E["4. source map 格式微差"]
  style A fill:#f59e0b,color:#333

每条都是真实坑:

  • 插件:rspack 以 Webpack 插件兼容为目标,但覆盖不是 100%——迁移前先把项目实际用到的插件逐个过一遍官方兼容性清单
  • 配置:绝大多数配置项同名同义,边缘选项可能行为微差
  • WASM 加载:不同工具对 import.wasm 的解析略有差异
  • source map:调试体验可能略差

8.17.7 对趋势的一点保留

本专栏不给市场份额预测。可以观察到的事实是:Next.js 16 起 Turbopack 是开发与生产构建的默认打包器,Rspack 已经被多个大型前端项目用于替换 Webpack,Vite 8 已把默认构建引擎换成 Rust 实现的 Rolldown(《Vite 设计与实现》专栏对照的 8.0.3 即如此)。方向是清楚的,时间表不是。

对 WASM 项目而言,这件事的影响其实有限——换打包工具改变不了 .wasm 的产出路径。

8.18 跨专栏关联:构建工具的分层哲学

wasm-pack 的设计和本系列其他构建工具有相同的分层哲学——把"语言编译"和"包管理"分为两个独立层:

工具 语言编译层 包管理层 输出格式
wasm-pack cargo build npm publish .wasm + .js
cargo rustc crates.io .rlib / .so
Vite Rolldown(Vite 8 起;此前开发期用 esbuild、生产用 Rollup) node_modules .js + .css
webpack acorn/terser node_modules .js bundle

每一层只关心自己的职责——cargo 把 Rust 编译为 WASM 字节码,wasm-pack 把字节码包装为 npm 包,Vite/webpack 把 npm 包集成到 Web 应用中。这种分层让每一层可以独立演进——cargo 的优化不影响 wasm-pack 的打包逻辑,Vite 的 HMR 不影响 WASM 的编译。

与《Vite 设计与实现》专栏的关联更具体:Vite 的依赖预构建和生产构建(Vite 8 起都由 Rolldown 完成)都不理解 .wasm 文件的内部结构——它们只把它当作一个二进制资源文件来处理(复制+哈希命名+引用替换,或小文件内联)。.wasm 的优化由 wasm-opt(Binaryen)负责,JS 胶水的优化由 Vite/Rollup 负责。两个优化器在不同层工作,互不干扰。

下一章进入性能优化——如何让 .wasm 模块更小、更快。