Appearance
第8章 wasm-pack:构建、测试与发布
"Make the common case fast." — Amdahl's Law 的工程版本
8.1 为什么需要 wasm-pack
手动发布一个 Rust→WASM→npm 的库需要这些步骤:
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 版本差了一个补丁号——导致自定义段的二进制格式不兼容,报出晦涩的解析错误。
wasm-pack 把它们压缩为一条命令:
bash
wasm-pack build --target web --release
wasm-pack publish这不是简单的脚本包装——wasm-pack 在每个步骤之间做了依赖追踪、缓存管理和错误恢复。它的核心价值不是"自动化"(写个 Makefile 也能做到),而是"正确性保证"——确保每一步的输入输出正确衔接,版本约束得到满足,生成的 npm 包格式符合规范。wasm-pack 的 Rust 源码(crates/wasm-pack/)中,每个步骤都被封装为独立的 Command 结构体,步骤之间通过 PathBuf 传递文件路径,通过 Metadata 结构体传递版本信息——这种模块化设计使得每个步骤可以独立测试和替换。
8.2 build 命令的完整流水线
wasm-pack build 执行的完整步骤:
步骤一:检查工具链
wasm-pack 检查 wasm32-unknown-unknown 目标是否已安装:
bash
rustup target list --installed | grep wasm32-unknown-unknown如果没有,自动执行 rustup target add wasm32-unknown-unknown。这一步也会检查 rustc 版本是否支持 wasm-bindgen 需要的自定义段特性——极旧的 Rust 版本(1.30 之前)不支持。
同时检查 wasm-bindgen-cli 的版本是否与 Cargo.toml 中的 wasm-bindgen crate 版本匹配——版本不匹配是 WASM 开发中最常见的编译错误之一。wasm-pack 会读取 Cargo.lock 中 wasm-bindgen 的精确版本号,然后检查本地安装的 wasm-bindgen CLI 是否为同一版本。如果不匹配,wasm-pack 会自动安装正确版本的 CLI。
步骤二:cargo build
bash
cargo build --target wasm32-unknown-unknown --releasewasm-pack 默认使用 --release profile,因为 debug 构建的 .wasm 体积可达 release 的 10-20 倍(包含调试符号和未优化的代码)。可以通过 --dev 标志切换到 debug 构建。debug 构建的一个特殊用途是错误信息——release 构建会 strip 掉 panic 信息,导致运行时错误只显示 "unreachable" 或 "panic in WASM";debug 构建保留了完整的 panic 消息和调用栈,在开发阶段很有用。但不要把 debug 构建发布到生产环境——不仅体积大,性能也差数倍。
--target wasm32-unknown-unknown 是必需的——默认的 cargo build 会编译为宿主平台的原生代码。unknown-unknown 表示不对操作系统和运行时做假设——WASM 模块本身就是操作系统无关的。这个目标三元组的第一部分 wasm32 指定架构(32 位 WebAssembly),第二部分 unknown 指定供应商(无特定供应商),第三部分 unknown 指定操作系统(无特定操作系统)。
wasm-pack 还会根据 Cargo.toml 中的 [profile.release] 设置传递额外的编译选项。对 WASM 项目推荐的最优 release profile:
toml
[profile.release]
opt-level = "z" # 最大体积优化
lto = true # 跨 crate 链接时优化
codegen-units = 1 # 单个代码生成单元,更好的优化
strip = true # 移除调试符号
panic = "abort" # abort 而非 unwind,减小体积这些设置不是 wasm-pack 自动添加的——你需要在 Cargo.toml 中手动配置。wasm-pack 只是忠实地调用 cargo build,不会修改你的编译配置。
步骤三:检测 .wasm 输出
Rust 编译器把 .wasm 文件输出到 target/wasm32-unknown-unknown/release/ 目录下。wasm-pack 找到这个文件(通常以 crate 名命名,如 my_lib.wasm),准备传给 wasm-bindgen CLI。
如果项目有多个 crate(workspace),wasm-pack 只处理你在命令行指定的 crate——它不会自动构建 workspace 中的所有 crate。
步骤四:wasm-bindgen CLI
bash
wasm-bindgen target/wasm32-unknown-unknown/release/my_lib.wasm \
--target web \
--out-dir pkg \
--out-name my_lib--target 决定生成的 JS 胶水代码的模块格式——这是 wasm-pack build 最重要的选项:
| target 值 | 输出格式 | 适用场景 | WASM 初始化方式 |
|---|---|---|---|
web | ES Module (import) | 直接在浏览器 <script type="module"> 中使用 | import init from './my_lib.js'; await init(); |
bundler | ES Module + bundler 提示 | webpack / Rollup / Vite 等打包工具 | 打包工具自动处理 |
nodejs | CommonJS (require) | Node.js 环境 | const wasm = require('./my_lib.js'); |
no-modules | IIFE (全局变量) | 不支持 ES Module 的旧浏览器 | <script src="./my_lib.js"> 全局变量 |
bundler 是最常用的选项——Vite/webpack 会自动处理 ES Module 的导入和打包。web 适合不使用打包工具的场景(CDN 直引、CodePen 等)。实际项目中,如果你的 WASM 库可能被不同类型的消费者使用,可以分别构建多个 target 的产物,然后在 package.json 中用 browser/main/module 字段指定不同的入口——但这增加了发布复杂度。大多数情况下,bundler 是最安全的选择,因为它兼容性最广——Vite、webpack 5、Rollup、esbuild 都能正确处理 bundler 格式的输出。
--target 影响的不仅是模块格式,还有 WASM 的初始化方式:
web和bundler:JS 胶水代码导出一个init()函数,调用者需要await init()完成编译和实例化nodejs:自动在require()时同步初始化no-modules:把 WASM 绑定挂到全局对象上(如window.wasm_bindgen),初始化是异步的但不需要显式调用
步骤五:wasm-opt
wasm-opt 是 Binaryen 工具包的一部分,对 .wasm 做二进制级别的优化:
bash
wasm-opt -Oz pkg/my_lib_bg.wasm -o pkg/my_lib_bg.wasmwasm-opt 的优化级别:
| 标志 | 含义 | 体积效果 | 速度效果 |
|---|---|---|---|
-O | 平衡优化 | 中等体积缩减 | 兼顾速度 |
-O1 | 轻度优化 | 少量体积缩减 | 速度优先 |
-O2 | 中度优化 | 中等体积缩减 | 平衡 |
-O3 | 激进优化 | 可能增大体积 | 速度优先 |
-Os | 体积优化 | 显著体积缩减 | 轻微速度牺牲 |
-Oz | 最大体积优化 | 最大体积缩减 | 速度牺牲 |
wasm-pack 默认使用 wasm-opt -O(平衡优化)。对于对体积敏感的应用(如网页加载性能),推荐在 Cargo.toml 中覆盖为 -Oz:
toml
[package.metadata.wasm-pack.profile.release]
wasm-opt = ["-Oz"]wasm-opt 做的优化包括:
- 死代码消除:删除未被任何导出函数可达的代码——这是最有效的体积缩减手段,Rust 编译器的 LTO 虽然也做 DCE,但不如 Binaryen 的精准
- 函数内联:小函数内联到调用者中,消除调用开销
- 常量折叠:编译期可计算的表达式直接求值——如
4 * 1024变为4096 - 重复函数合并:签名不同但实现相同的函数合并为一个,减少代码重复
- 名称段剥离:删除调试用名称(如函数名
my_lib::greet::h12345),减小体积 - Stack IR 优化:Binaryen 自己的中间表示优化,能发现 LLVM 后端未捕捉到的模式
wasm-opt 是可选的——如果没有安装 Binaryen,wasm-pack 跳过这一步并输出警告。在 CI 环境中,建议安装 binaryen 以确保优化一致:
bash
# macOS
brew install binaryen
# Ubuntu/Debian
apt install binaryen
# 或从源码
git clone https://github.com/WebAssembly/binaryen && cd binaryen && cmake . && make install步骤六-八:生成 pkg/ 目录
wasm-pack 在项目根目录下创建 pkg/ 目录,包含发布所需的所有文件:
pkg/
├── my_lib.js # JS 胶水代码(ES Module)
├── my_lib_bg.wasm # 优化后的 .wasm 文件
├── my_lib_bg.js # WASM 初始化代码
├── my_lib.d.ts # TypeScript 声明
├── my_lib_bg.wasm.d.ts # .wasm 模块的 TypeScript 声明
├── package.json # npm 包描述
├── README.md # 从项目根目录复制
└── LICENSE # 从项目根目录复制package.json 的关键字段由 wasm-pack 自动生成:
json
{
"name": "my-lib",
"version": "0.1.0",
"module": "my_lib.js",
"types": "my_lib.d.ts",
"sideEffects": false,
"files": [
"my_lib.js",
"my_lib_bg.wasm",
"my_lib_bg.js",
"my_lib.d.ts",
"my_lib_bg.wasm.d.ts"
],
"dependencies": {},
"devDependencies": {}
}sideEffects: false 告诉打包工具(webpack/Tree Shaking)这个包的模块是纯函数——没有副作用的导出可以被安全地移除。这对减小最终 bundle 体积至关重要。如果你的 WASM 模块有初始化副作用(如调用 wasm_bindgen::initialize()),需要把 sideEffects 设为 true。
pkg/ 目录中的文件分工明确:my_lib.js 是用户直接 import 的入口文件,它导出所有 #[wasm_bindgen] 标注的函数和类型;my_lib_bg.js 是 WASM 的初始化代码,负责 WebAssembly.instantiateStreaming 和 imports 对象的组装;my_lib_bg.wasm 是编译后的二进制模块。用户代码只需要 import init, { greet } from 'my-lib',然后 await init()——my_lib_bg.js 和 my_lib_bg.wasm 的加载细节由 JS 胶水代码内部处理。
8.3 测试:wasm-bindgen-test
测试框架的必要性
Rust 的 #[test] 宏在 wasm32-unknown-unknown 目标上不能直接运行——因为这个目标没有操作系统:没有标准输出、没有进程退出码、没有文件系统。wasm-bindgen-test 框架的解决方案是:在宿主环境(浏览器或 Node.js)中加载并执行 WASM 测试代码,通过 WebSocket 把结果回传给命令行。
bash
wasm-pack test --chrome # 在 Chrome 中运行
wasm-pack test --node # 在 Node.js 中运行
wasm-pack test --firefox # 在 Firefox 中运行
wasm-pack test --headless # 无头模式(CI 环境)测试框架的工作原理
具体流程:
wasm-pack test调用cargo test --target wasm32-unknown-unknown,但不是真正运行测试——而是编译测试代码为.wasmwasm-bindgenCLI 把测试.wasm转换为可在浏览器中加载的 JS + WASM- 启动一个临时的 HTTP 服务器(随机端口),提供测试页面
- 自动打开浏览器访问测试页面(
--headless模式下使用 headless Chrome) - 测试页面加载 WASM,执行所有
#[wasm_bindgen_test]标注的函数 - 测试结果通过 WebSocket 回传给命令行
- 命令行显示结果,根据 pass/fail 设置退出码
编写 WASM 测试
WASM 中的测试需要 wasm_bindgen_test::wasm_bindgen_test 宏(不是标准的 #[test]):
rust
use wasm_bindgen_test::*;
#[wasm_bindgen_test]
fn simple_test() {
assert_eq!(2 + 2, 4);
}
#[wasm_bindgen_test(async)]
async fn async_test() {
let promise = js_sys::Promise::resolve(&JsValue::from(42));
let result = JsValue::as_f64(&promise).unwrap();
assert_eq!(result as i32, 42);
}#[wasm_bindgen_test] 和 #[test] 的关键区别:
| 维度 | #[test] | #[wasm_bindgen_test] |
|---|---|---|
| 目标平台 | 原生(x86_64/aarch64) | wasm32-unknown-unknown |
| 执行方式 | 生成原生可执行文件 | 生成 WASM 导出函数,由 JS 测试驱动器调用 |
| 异步支持 | #[tokio::test] | #[wasm_bindgen_test(async)] |
| JS 互操作 | 不支持 | 可以调用 js_sys/web_sys |
| 输出 | 直接 stdout | 通过 WebSocket 回传 |
#[wasm_bindgen_test(async)] 额外生成 JS 侧的 Promise 包装——测试驱动器会 await 这个 Promise。WASM 中的异步不是线程异步(WASM 是单线程的),而是基于 JS 的事件循环——Future::poll 被调度到微任务队列中执行。
测试 JS 互操作
wasm-bindgen-test 的核心价值在于测试 JS 互操作——验证 Rust 和 JS 之间的类型转换是否正确:
rust
use wasm_bindgen_test::*;
use wasm_bindgen::JsValue;
use js_sys;
#[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] 下无法运行——它们依赖浏览器的 DOM 和 JS 运行时。wasm-bindgen-test 是唯一能在真实浏览器环境中验证 WASM 代码行为的方案。
一个实用的测试策略是:把纯 Rust 逻辑的测试用 #[test](原生运行),把 JS 互操作相关的测试用 #[wasm_bindgen_test](浏览器运行)。这样纯逻辑测试可以在本地快速迭代(毫秒级),只有涉及 JS 的测试才需要启动浏览器(秒级)。在 Cargo.toml 中可以通过 #[cfg(target_arch = "wasm32")] 条件编译来分离两类测试:
rust
#[cfg(test)]
mod tests {
// 纯 Rust 逻辑测试——可以在原生环境快速运行
#[test]
fn test_pure_rust_logic() {
assert_eq!(2 + 2, 4);
}
}
#[cfg(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 中的配置
yaml
# GitHub Actions 示例
- name: Install wasm-pack
run: curl https://rustwasm.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 标志让 Chrome 在无头模式下运行——不需要图形界面,适合 CI 环境。wasm-pack 自动找到系统安装的 Chrome,不需要额外配置 ChromeDriver。
8.4 npm 发布
bash
wasm-pack publish这条命令做三件事:
- 运行
wasm-pack build:确保pkg/目录是最新的 - 检查
package.json:确认名称、版本、必填字段齐全 - 执行
npm publish:把pkg/目录发布到 npm registry
发布前可以用 --dry-run 预览:
bash
wasm-pack publish --dry-runpackage.json 的自定义
wasm-pack 自动生成的 package.json 通常是足够的,但有时需要添加额外的字段(如 repository、keywords、homepage)。有两种方式:
方式一:在 Cargo.toml 中配置
toml
[package.metadata.wasm-pack.publish]
# 这些字段会被合并到 package.json 中
registry = "https://registry.npmjs.org/"
access = "public"方式二:在项目根目录放置 package.json 模板
如果项目根目录有 package.json,wasm-pack 会读取它并合并到生成的 pkg/package.json 中——你可以在其中添加任意 npm 字段:
json
{
"repository": {
"type": "git",
"url": "https://github.com/user/my-lib"
},
"keywords": ["wasm", "rust", "image-processing"],
"homepage": "https://my-lib.dev"
}发布策略
npm 上的 WASM 包有几种发布策略:
策略一:构建产物发布(推荐)
package.json 的 files 字段只包含 pkg/ 下的文件。用户 npm install 后直接使用构建产物——不需要本地编译 Rust。这是最简单、最可靠的策略,绝大多数 WASM 库使用这种方式。
策略二:CDN 分离
JS 胶水代码从 CDN 加载 .wasm:
javascript
// 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.*] 段:
toml
[package.metadata.wasm-pack.profile.release]
wasm-opt = ["-Oz", "--enable-bulk-memory"]
wasm-bindgen = ["--reference-types"]
[package.metadata.wasm-pack.profile.dev]
wasm-opt = false # debug 构建跳过 wasm-opt常用配置:
| 配置项 | 默认值 | 说明 |
|---|---|---|
wasm-opt | ["-O"] | 传给 wasm-opt 的参数列表,false 跳过 |
wasm-bindgen | [] | 传给 wasm-bindgen CLI 的额外参数 |
--enable-bulk-memory
启用 Bulk Memory Operations 提案(memory.copy、memory.fill 等指令),Rust 编译器会用这些指令优化 memcpy/memset——体积更小、速度更快。
在 Cargo.toml 中也需要启用对应的 feature:
toml
[dependencies]
wasm-bindgen = { version = "0.2", features = ["bulk-memory"] }Chrome 75+、Firefox 80+、Safari 15.2+ 支持。对于 2026 年的项目,可以放心启用。
--reference-types
启用 Reference Types 提案(externref 类型),允许 WASM 直接持有 JS 对象引用而不经过整数索引——减少对象栈的开销。Chrome 91+、Firefox 79+、Safari 15.2+ 支持。
启用后的效果:
JsValue不再通过对象栈索引传递,而是使用 WASM 的externref类型- 消除了
__wbindgen_object_drop_ref的跨边界调用 - 对象的生命周期由 WASM 引擎管理,而非
wasm-bindgen的手动引用计数
toml
[dependencies]
wasm-bindgen = { version = "0.2", features = ["enable-reference-types"] }这两个提案的组合使用能带来 5-15% 的体积缩减和 10-20% 的跨边界调用加速——对于生产环境的 WASM 模块,强烈推荐启用。
需要注意的是,启用这些特性后生成的 .wasm 文件使用了较新的 WASM 指令——在不支持的运行时中会报 "malformed WASM module" 错误。如果你的目标环境包括旧版浏览器或旧版 Node.js,需要先确认它们的支持情况再决定是否启用。Can I Use 网站(caniuse.com)提供了各浏览器对 WASM 特性的支持矩阵,建议在启用前查阅。
8.6 与打包工具集成
Vite 集成
Vite 从 4.0 开始内置了对 WASM 的支持。使用 wasm-pack 构建的 --target bundler 包可以直接在 Vite 项目中导入:
javascript
// Vite 项目中直接导入
import init, { greet } from 'my-lib';
// 初始化 WASM
await init();
// 调用导出函数
console.log(greet('World'));Vite 的处理流程:
Vite 处理 WASM 的关键细节:在开发模式下,Vite 会拦截对 .wasm 文件的请求,返回一个 ES Module 格式的包装——这个包装内部调用 WebAssembly.instantiateStreaming 流式编译和实例化 WASM 模块。instantiateStreaming 比 instantiate 快约 20-30%,因为它可以在下载 .wasm 的同时并行编译——不需要等待全部字节下载完毕再开始编译。在生产构建中,Vite 把 .wasm 文件复制到 dist/assets/ 目录,添加内容哈希(如 my_lib_bg-abc123.wasm),并在 JS 胶水代码中更新引用路径。
Vite 会在开发模式下使用 WebAssembly.instantiateStreaming 流式加载 .wasm(边下载边编译),生产模式下把 .wasm 文件输出到 dist/assets/ 并处理哈希命名。
对于更复杂的场景(如需要自定义 WASM 初始化逻辑),可以使用社区插件:
javascript
// 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-top-level-await 让你可以在模块顶层使用 await init(),而不需要包装在 async 函数中——这在某些场景下更方便。
webpack 集成
webpack 5 内置了 WASM 支持,但配置比 Vite 更复杂:
javascript
// webpack.config.js
module.exports = {
experiments: {
asyncWebAssembly: true,
},
module: {
rules: [
{
test: /\.wasm$/,
type: 'webassembly/async',
},
],
},
};使用时:
javascript
import init, { greet } from 'my-lib';
async function run() {
const wasm = await init();
console.log(greet('World'));
}
run();webpack 的 WASM 支持有一个已知限制:chunkLimit 默认值可能导致大 .wasm 文件被错误地内联为 base64。如果你的 .wasm 超过 50KB,需要调整配置:
javascript
module.exports = {
experiments: {
asyncWebAssembly: true,
},
optimization: {
splitChunks: {
cacheGroups: {
wasm: {
test: /\.wasm$/,
chunks: 'all',
enforce: true,
},
},
},
},
};Rollup 集成
Rollup 不内置 WASM 支持,需要使用 @rollup/plugin-wasm:
javascript
// rollup.config.js
import wasm from '@rollup/plugin-wasm';
export default {
input: 'src/index.js',
plugins: [wasm()],
output: {
dir: 'dist',
format: 'esm',
},
};javascript
import init, { greet } from 'my-lib';
const wasm = await init();
console.log(greet('World'));跨书关联:与 Vite 的深度协作
与《Vite 设计与实现》的关联——Vite 的 HMR 系统默认不监听 .wasm 文件的变化。修改 Rust 源码后需要手动重新 wasm-pack build,然后刷新浏览器。这个限制的根本原因是 Rust 的编译速度——即使是最简单的 WASM 项目,一次 wasm-pack build 也需要 5-30 秒,这远超 Vite HMR 的目标延迟(<1 秒)。
社区有 vite-plugin-wasm-pack 尝试自动化这个流程——监听 Rust 源码变化,自动触发 wasm-pack build,然后通过 Vite 的 HMR 通知浏览器刷新。但 Rust 的编译速度使得真正的热替换不现实——即使自动触发构建,用户仍然需要等待编译完成。
一种实用的折中方案是在 vite.config.js 中配置自定义的 WASM 构建命令:
javascript
// 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 模块不应该被重复编译和实例化——WebAssembly.instantiateStreaming 是一个昂贵的操作(对于大模块可能需要数百毫秒)。wasm-bindgen 生成的 init() 函数是幂等的——它内部用一个全局变量记录是否已初始化,多次调用 init() 只会执行一次真正的实例化。但在 Vite 的 HMR 场景下,旧模块被卸载、新模块被加载——全局变量可能被重置,导致重新初始化。解决方案是在 HMR 边界上缓存 WASM 实例——通常是在一个不会被 HMR 替换的共享模块中保存对 init() 返回值的引用。
8.7 CI/CD 配置
在 CI 环境中构建和发布 WASM 包需要解决几个问题:Rust 工具链安装、Binaryen 安装、浏览器驱动配置、npm 认证。
GitHub Actions 完整配置
yaml
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://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
- name: Install Binaryen (wasm-opt)
run: |
wget https://github.com/WebAssembly/binaryen/releases/download/version_119/binaryen-version_119-x86_64-linux.tar.gz
tar xf binaryen-version_119-x86_64-linux.tar.gz
sudo cp binaryen-version_119/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://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: wasm-pack publishCI 配置的关键注意事项
wasm-bindgen 版本锁定:CI 中必须使用
Cargo.lock锁定的版本,避免wasm-bindgencrate 和 CLI 的版本不匹配。wasm-pack会自动处理这一点——它从Cargo.lock读取精确版本号。Binaryen 版本:
wasm-opt的版本需要和wasm-bindgen兼容。wasm-bindgen0.2.100 需要 Binaryen version_119+(支持 WASM 特性提案的版本)。浏览器缓存:
--headless --chrome模式下,Chrome 的用户数据目录是临时的——每次 CI 运行都是全新的浏览器环境,不会有缓存干扰。体积检查:在 CI 中加入
.wasm体积检查是一个好习惯——防止意外的体积回归。设置一个合理的阈值(如 500KB),超过时发出警告。体积回归通常是以下原因导致的:引入了新的Vec/String操作(增加了alloc的代码路径)、启用了新的web_sys特性(引入了大量的 DOM API 绑定)、或者 LTO 配置被意外修改。体积检查可以及早发现这些问题。npm 认证:
wasm-pack publish需要 npm 的认证 token。在 GitHub Actions 中通过secrets.NPM_TOKEN注入,不要把 token 提交到仓库中。npm token 的创建方式是在 npm 网站上生成一个 "Automation" 类型的 access token——这种 token 可以绕过 npm 的双因素认证(2FA)要求,适合 CI 自动发布。并发发布保护:如果多个 CI 任务同时运行
wasm-pack publish,可能导致 npm 上的版本冲突。建议在 publish job 中使用concurrency约束,确保同一时间只有一个发布流程。或者使用npm publish --tag next先发布到next标签,手动验证后再 promote 到latest。
8.8 常见问题与调试
问题一:版本不匹配
error: the `wasm-bindgen` crate version (0.2.99) does not match
the `wasm-bindgen-cli` tool version (0.2.100)解决方案:wasm-pack 会自动处理版本匹配——确保使用 wasm-pack build 而非手动调用 wasm-bindgen CLI。如果手动安装了不匹配的 CLI,卸载它:
bash
cargo uninstall wasm-bindgen-cli
# 让 wasm-pack 管理版本
wasm-pack build问题二:wasm-opt 失败
error: failed to execute wasm-opt: No such file or directory解决方案:安装 Binaryen,或者在 Cargo.toml 中禁用 wasm-opt:
toml
[package.metadata.wasm-pack.profile.release]
wasm-opt = false问题三:pkg/ 目录不被 git 忽略
pkg/ 目录应该加入 .gitignore——它是构建产物,不应提交到版本控制。wasm-pack 每次构建都会重新生成 pkg/ 的全部内容。
gitignore
# .gitignore
pkg/
target/问题四:发布后 npm 包缺少 .wasm 文件
这通常是因为 package.json 的 files 字段没有包含 .wasm 文件。wasm-pack 自动生成的 files 字段应该包含它——如果你手动修改了 package.json,确保 files 数组包含 *_bg.wasm。
问题五:Vite 中 WASM 初始化顺序
javascript
// ❌ 错误:在 init() 之前调用导出函数
import { greet } from 'my-lib';
greet('World'); // TypeError: wasm is not initialized
// ✅ 正确:先初始化
import init, { greet } from 'my-lib';
await init();
greet('World'); // OKWASM 模块必须先初始化(编译+实例化),然后才能调用任何导出函数。wasm-bindgen 生成的 init() 函数是幂等的——多次调用不会重复初始化,第一次调用后的调用会立即返回。
8.9 构建目标:web / bundler / nodejs / no-modules 的差异
wasm-pack build --target <target> 是最容易选错的参数——四个 target 生成的胶水代码差异显著,错配会导致运行时报错或体积浪费。
8.9.1 四种 target 的本质差异
| target | JS 模块格式 | WASM 加载方式 | 体积(典型) | 适用场景 |
|---|---|---|---|---|
web | ESM | fetch + instantiateStreaming | 中 | 浏览器直接 import,无打包工具 |
bundler | ESM(特殊) | 由打包工具处理 | 最小 | Webpack/Vite/Rollup 项目 |
nodejs | CommonJS | fs.readFileSync | 中 | Node.js 服务端 |
no-modules | IIFE 全局变量 | fetch + 内联 | 最大 | 不能用 ESM 的旧浏览器 / 嵌入页面 |
8.9.2 web vs bundler:最容易混淆的一对
web 和 bundler 看起来相似——都生成 ESM——但底层 import 语法不同:
javascript
// --target web 生成的 .js(节选)
async function init(input) {
if (typeof input === 'undefined') {
input = new URL('my_lib_bg.wasm', import.meta.url);
}
// ... fetch + instantiate ...
}
// --target bundler 生成的 .js(节选)
import * as wasm from './my_lib_bg.wasm'; // ← 直接 import .wasm
export const greet = wasm.greet;bundler 模式假设打包工具能 import 一个 .wasm 文件——这是 Webpack 5+、Vite、Rollup 的能力,但纯浏览器原生 ESM 不支持。如果你不用打包工具直接在浏览器里 import './my_lib.js',bundler 模式下浏览器会报 Cannot import .wasm files——必须用 web 模式。
反过来,如果你用 Vite 项目并用了 web 模式,结果可以工作但体积更大——web 模式包含完整的 init() 加载逻辑,而 bundler 模式让 Vite 用更高效的方式处理(直接复用 Vite 的 HMR、code splitting)。
8.9.3 nodejs target 的特殊性
Node.js 的 WASM 加载和浏览器不同——它用 fs.readFileSync 同步读文件:
javascript
// --target nodejs 生成的 .js(节选)
const path = require('path').join(__dirname, 'my_lib_bg.wasm');
const bytes = require('fs').readFileSync(path);
const wasmModule = new WebAssembly.Module(bytes);
const wasmInstance = new WebAssembly.Instance(wasmModule, imports);
module.exports.greet = wasmInstance.exports.greet;注意:这是同步加载——require('my-lib') 时 WASM 已经实例化完毕。这和浏览器的 async init() 模式不同——Node.js 用户期望同步导入。
如果同时支持浏览器和 Node.js,需要发布两个版本:
bash
wasm-pack build --target bundler --out-dir pkg-web
wasm-pack build --target nodejs --out-dir pkg-nodepackage.json 的 exports 字段做条件解析:
json
{
"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 | 体积最小,打包工具有原生支持 |
| 静态 HTML 直接 import | web | 不需要打包工具 |
| Next.js / SvelteKit | bundler | 这些框架支持 .wasm import |
| Node.js 服务 | nodejs | 同步加载,CommonJS |
| 同构(浏览器 + Node.js) | 双 target + exports 字段 | 各自最优 |
| 老浏览器(IE11)兼容 | no-modules | 唯一选择 |
| Cloudflare Workers / Deno | web 或 bundler | 视具体平台支持 |
8.10 替代工具与互补关系
wasm-pack 不是唯一选择——根据项目的需求,可能有更轻量或更专业的工具。
8.10.1 直接调用 wasm-bindgen-cli
如果不需要 npm 发布,可以跳过 wasm-pack 直接调用底层工具:
bash
# 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:
bash
cargo component new my-component --lib
cargo component build --release
# 输出 target/wasm32-wasi/release/my_component.wasm(component 格式)cargo-component 和 wasm-pack 的差异:
| 维度 | wasm-pack | cargo-component |
|---|---|---|
| 目标 | wasm-bindgen 模块 | 组件模型组件 |
| ABI | wasm-bindgen 协议 | Canonical ABI |
| 接口定义 | Rust 类型 | WIT 文件 |
| 多语言互操作 | 仅 Rust ↔ JS | 任意支持 WIT 的语言 |
| 部署目标 | 浏览器 / Node.js | wasmtime / wasmer / 浏览器(部分) |
两个工具不是替代关系——是不同生态的入口。
8.10.3 Trunk:Yew/Leptos 的全栈构建
如果用 Yew/Leptos 写整个前端("UI 框架内核"模式),Trunk 比 wasm-pack 更合适:
bash
trunk serve # 开发模式(热重载)
trunk build --release # 生产构建Trunk 集成了:
- WASM 编译 + wasm-bindgen
- HTML/CSS 资源管道
- 静态资源 hash 命名
- 开发服务器 + HMR
- 生产环境的 brotli 压缩
wasm-pack 只产出 npm 包——你还需要 Vite/Webpack 集成到 HTML 中。Trunk 是一站式:从 .rs 到 dist/ 完整可部署网站。
8.10.4 工具选择决策图
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.jsonWASM 包用 wasm-pack build 生成 pkg/,然后通过 workspace 协议被其他包引用:
json
// packages/web-app/package.json
{
"dependencies": {
"@my-org/wasm-image-filter": "workspace:^"
}
}8.11.2 与 Turborepo / Nx 集成
构建编排工具需要知道 wasm-pack 是 web-app 的依赖任务。turbo.json 配置:
json
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", "pkg/**"]
},
"wasm-build": {
"outputs": ["pkg/**"],
"inputs": ["src/**", "Cargo.toml", "Cargo.lock"]
}
}
}每个 WASM 包的 package.json 加 wasm-build 脚本:
json
{
"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 共享依赖编译缓存:
toml
# 根 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 依赖:
toml
# packages/wasm-image-filter/Cargo.toml
[dependencies]
wasm-bindgen.workspace = true
js-sys.workspace = true收益:所有 WASM 包共享一份 target/ 目录——wasm-bindgen、js-sys 等公共依赖只编译一次,节省 50-70% 的总编译时间。
8.11.4 CI 中的 WASM 增量构建
CI 中 WASM 编译往往是最慢的一步(Rust 编译比 JS/TS 慢 5-10x)。两个加速手段:
手段一:Rust 编译缓存(sccache)
yaml
# 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-buildsccache 把 Rust 编译产物缓存到 GitHub Actions 缓存——二次构建只编译变化的 crate。冷启动 8 分钟的 WASM 构建,命中缓存后变成 30 秒。
手段二:pkg/ 缓存策略
yaml
- name: Cache wasm-pack output
uses: actions/cache@v3
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 是开发循环中最频繁的命令——每次代码变更都跑一次。一个 cold build 可能 60-180 秒,hot build 5-15 秒。理解每个阶段的耗时来源有助于把循环时间降到最短。
8.12.1 构建阶段的时间分布
cold build 各阶段的真实耗时(中等项目,~2K 行 Rust + 30 deps):
| 阶段 | 耗时 | 主要动作 |
|---|---|---|
| cargo build (cold) | 80-120 s | 编译所有依赖 + 业务代码 |
| cargo build (hot) | 3-8 s | 增量编译变化的 crate |
| wasm-bindgen | 1-3 s | 解析 .wasm,生成 .js + .ts |
| wasm-opt | 5-15 s | 二进制级优化 -Oz |
| 包装步骤 | 0.3-0.8 s | 写 package.json、复制 README |
cargo build 是最大头——大头中又是依赖编译(占 70-80%)。优化手段几乎都围绕"少跑 cargo"。
8.12.2 Profile 配置:开发与生产的两套构建
wasm-pack 通过 [package.metadata.wasm-pack.profile.<name>] 配置不同 profile:
toml
# 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'] # 保留 debug info
[package.metadata.wasm-pack.profile.release]
wasm-opt = ['-Oz', '--strip-debug']调用:
bash
wasm-pack build --dev # 用 dev profile,~5-8s 增量
wasm-pack build --profiling # 保留 debug info
wasm-pack build --release # 完整优化工程纪律:开发循环用 --dev(快),CI 上跑 --release(产物正确)。混用会导致"开发机能跑、生产挂掉"的诊断地狱。
8.12.3 三个有效的提速手段
手段一:sccache 缓存依赖编译
bash
cargo install sccache --locked
export RUSTC_WRAPPER=sccachesccache 把每个 crate 的编译产物缓存到本地或 S3——同样的依赖跨项目复用。第一次构建依然慢,第二次冷启动从 80s 降到 15s。
手段二:cargo-chef 让 Docker 缓存依赖层
CI 中用 Docker 构建时,普通 Dockerfile 的依赖层缓存策略很差——COPY . 把源码和 Cargo.toml 一起复制,源码变了就重新编译所有依赖。cargo-chef 把依赖编译独立成一层:
dockerfile
FROM rust:1.86 AS chef
WORKDIR /app
RUN cargo install cargo-chef wasm-pack
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效果:依赖变化时重编依赖层(慢但少触发),源码变化时只重编业务层(快)。CI 的典型构建时间从 5 分钟降到 30 秒。
手段三:跳过 wasm-opt 的开发循环
wasm-opt 单独占 5-15 秒——开发时完全不需要。在 dev profile 中 wasm-opt = false,把 release 时的优化保留在 CI。
8.12.4 增量构建与 watch 模式
bash
# 安装 cargo-watch
cargo install cargo-watch
# 监听文件变化自动重建
cargo watch -s 'wasm-pack build --dev --target bundler'配合 Vite 的 HMR——Rust 代码改了 → wasm-pack rebuild → Vite 检测 .wasm 变化 → 浏览器热重载。完整循环 5-10 秒,是体验最好的开发模式。
8.12.5 build 性能监控
CI 中应该跟踪构建时间——任何回归都立即可见:
yaml
# 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 提速效果对比
实测(中等项目,从 cold 状态开始):
| 配置 | 耗时 | 改进 |
|---|---|---|
| 默认 wasm-pack build | 95 s | 基线 |
| + sccache | 32 s | 66% |
| + cargo-chef (CI) | 28 s | 71% |
| + dev profile(无 wasm-opt) | 18 s | 81% |
| + 增量 hot build | 5 s | 95% |
把这套优化做完后,开发循环的瓶颈从"等构建"变成"想下一步写什么"——这是工具链该有的样子。
8.13 高级配置:构建钩子与产出定制
wasm-pack build 默认行为覆盖 80% 场景——但生产中经常需要定制:插入水印、修改生成的 .js、注入版本号、生成额外的资源。理解 wasm-pack 的扩展点是高级用法的关键。
8.13.1 构建流程的可扩展点
wasm-pack 没有内置 hook 机制——但可以通过 build script 和 cargo 的 [package.metadata] 实现等价能力。
8.13.2 注入构建时元数据
业务常需要在 WASM 内嵌入版本号、构建时间、git commit hash:
rust
// build.rs
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());
}rust
// lib.rs
#[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 完全定制:
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'] # 保留 debug info 用于性能分析
[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、插入水印:
bash
#!/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:
json
{
"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):
typescript
// pkg/extra.d.ts
import { User as RustUser } 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 的 types 字段引用合并到 package.json:
json
{
"types": "my_lib.d.ts",
"typesVersions": {
"*": {
"*": ["my_lib.d.ts", "extra.d.ts"]
}
}
}8.13.6 多产物构建:按需打包
发布到 npm 时,可能要同时支持浏览器和 Node.js——分别构建:
bash
#!/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.jspackage.json 的 exports 字段:
json
{
"exports": {
".": {
"browser": "./my_lib.js",
"node": "./node/my_lib.js",
"default": "./my_lib.js"
}
}
}8.13.7 自定义构建管道的工程权衡
工程纪律:自定义构建增加复杂度,必须有明确收益——为了"省 5 秒"加 100 行 bash 不划算。但为了"自动签名 + SRI 校验 + 多产物"等真实需求,自定义投入是必须的。
判断标准:每条自定义步骤都应该能回答"如果没有这一步会发生什么坏事?"——回答不出来就删掉。
8.14 项目脚手架与模板生态
从空目录到能跑的 wasm-pack 项目,模板和脚手架决定了"5 分钟还是 5 小时"。社区维护了一套成熟的模板生态——理解它们的差异有助于选择合适的起点。
8.14.1 wasm-pack new:默认模板
bash
# 创建一个简单 hello-world 项目
wasm-pack new hello-wasm
# 项目结构
hello-wasm/
├── .gitignore
├── Cargo.toml
├── LICENSE
├── README.md
├── src/
│ ├── lib.rs
│ └── utils.rs
└── tests/
└── web.rswasm-pack new 内部用 cargo-generate 拉取模板。默认模板是 rustwasm/wasm-pack-template——最小的可用项目,适合学习。
8.14.2 主流模板对比
主流模板特征:
| 模板 | 适用 | 起步时间 | 输出格式 |
|---|---|---|---|
rustwasm/wasm-pack-template | 纯 npm 库 | 5 分钟 | npm 包 |
rustwasm/create-wasm-app | 有 Webpack 的项目 | 10 分钟 | npm 包 + Webpack 集成示例 |
rustwasm/rust-webpack-template | 完整 Webpack 项目 | 15 分钟 | 完整 Web 应用 |
yewstack/yew-trunk-minimal-template | Yew 项目 | 10 分钟 | Trunk 构建 SPA |
leptos-rs/start | Leptos 项目 | 10 分钟 | SSR + 客户端 hydration |
8.14.3 自定义模板
团队/公司常需要自己的标准模板——预配置 CI、内部依赖、代码规范:
bash
# 用 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.liquidcargo-generate.toml 配置可填写的占位符:
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 = falsetoml
# Cargo.toml.liquid 中使用
[package]
name = "{{project-name}}"
authors = ["{{author_name}}"]
{% if use_simd %}
[package.metadata.wasm-pack.profile.release.wasm-bindgen]
enable-features = ["simd"]
{% endif %}8.14.4 项目脚手架的工程价值
中型团队(10+ 人)几乎一定有自己的内部模板——把所有"团队约定"用模板固化下来,新人一行命令就上手。这比"写一份 onboarding 文档让新人手动配"高效太多。
8.14.5 脚手架的反模式
每条都需要工程纪律避免:
- 模板维护:把模板当独立项目维护,每月 review 一次
- 极简模板:模板只放"必须有"的内容,可选项做成 placeholder
- 模板 README:每行配置都有注释解释"为什么这样"
- 模板版本号:CHANGELOG 跟踪变更,方便老项目对照升级
- 可升级模板:用
cookiecutter-style 设计,模板更新能 patch 到旧项目
8.14.6 一个完整模板的构建流程
从决定建到稳定使用通常需要 1-2 个月——比想象中慢。但一旦稳定,团队效率提升显著。
8.14.7 脚手架与文档的协同
模板和文档要互相引用——不能割裂:
| 关系 | 实现 |
|---|---|
| 模板 → 文档 | 生成的 README 链接到完整文档 |
| 文档 → 模板 | "如何开始"指向 cargo generate 命令 |
| 文档 → 升级指南 | 每次模板大版本发布写迁移指南 |
| CI → 模板 | 模板仓库 CI 自动验证生成的项目能跑 |
这套协同让"开发体验"成为可工程化的资产——不是"老员工口口相传的暗知识"。
8.15 wasm-pack 的常见误解与澄清
任何工具用久了都积累一堆"用户期待 vs 工具实际行为"的偏差。wasm-pack 也不例外——这些误解在团队 onboarding 时反复出现,理解后能避免不必要的踩坑。
8.15.1 误解清单
8.15.2 误解 1:wasm-pack 不是必须
很多教程说"wasm-pack 是 Rust + WASM 唯一选择"——错。wasm-pack 是一个便利工具,不是规范的一部分:
- 直接调
wasm-bindgen-cli也能产出同样结果 cargo-component完全替代 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,不是 -Oz。生产构建必须明确配置:
toml
[package.metadata.wasm-pack.profile.release]
wasm-opt = ['-Oz']否则 .wasm 体积可能比理想大 20-40%。
8.15.4 误解 3:publish 不只是 build + npm publish
wasm-pack publish 内部除了 build 和 npm publish,还做:
如果直接 cd pkg/ && npm publish 跳过 publish 命令,会错过:
- 自动 release profile build
- package.json 验证
- npm access 与 tag 选择交互
CI 中通常用 --access public flag 跳过交互。
8.15.5 误解 4:增量构建不是 cmake 风格
wasm-pack 没有 Makefile 风格的依赖图——它每次都跑完整 cargo build。"增量"完全依赖 cargo 的增量编译(incremental compilation)。
即使只改一行,wasm-bindgen 和 wasm-opt 总是从头跑——没有"哪部分没变就跳过"的机制。这是开发循环 5-15 秒的根本原因(vs 纯 Rust cargo 的 1-2 秒)。
8.15.6 误解 5:必须装 wasm-bindgen-cli
许多文档说"先装 wasm-bindgen-cli 再用 wasm-pack"——错。wasm-pack 自动管理 wasm-bindgen-cli:
- 第一次 build 时自动下载对应版本的 cli
- 之后从缓存里拿
- 版本和 Cargo.toml 中的 wasm-bindgen 自动匹配
如果你手动 cargo install wasm-bindgen-cli 装了一个版本,可能与项目所需版本不匹配——反而出问题。wasm-pack 自己管最好。
8.15.7 误解 6:不是所有项目都用 --target web
web 是默认值——但不一定最优。详细见 §8.9。简单总结:
| 你用什么 | 推荐 target |
|---|---|
| 没有打包工具的纯静态 HTML | web |
| Vite/Webpack 项目 | bundler(更小) |
| Node.js 服务 | nodejs |
| 老浏览器兼容 | no-modules |
很多人默认 web 部署到 Vite——结果体积比 bundler 大 5-10KB。
8.15.8 误解 7:测试在哪都一样
wasm-pack test --node vs --chrome --headless 行为不同:
| 维度 | --node | --chrome |
|---|---|---|
| 启动时间 | < 1 秒 | 3-5 秒 |
| 浏览器 API | 不可用 | 完整 |
| 调试 | console.log | DevTools |
| CI 兼容 | 简单 | 需要安装 Chrome |
应该按测试需求选——简单单元测试用 --node(快),需要 web-sys 的测试用 --chrome。
8.15.9 误解 8:build artifact 都该入 git
pkg/ 目录是构建产物——绝对不应该入 git:
gitignore
pkg/
target/入 git 会让仓库膨胀(每次 build 都改 pkg/),且 release commit 会有大量噪音。pkg/ 内容应该只在发布到 npm 时存在——发布完即可丢。
8.15.10 澄清后的最佳实践
把这些澄清写进项目文档,新人 onboarding 时少走弯路。这套澄清在团队 wiki 中是最常被引用的内容之一。
8.16 wasm-pack 与 OCI 镜像的集成
wasm-pack 主要面向 npm 发布——但服务端/边缘场景的 WASM 部署用 OCI 镜像(Docker/Kubernetes 标准)。理解 wasm-pack 产物如何打包成 OCI 镜像是云原生部署的关键。
8.16.1 OCI 镜像与 npm 包的差异
| 维度 | 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
OCI Image Spec 1.1 引入了对 WASM 的标准化支持——wasm 模块作为镜像层(layer),mediaType 标记为 application/wasm:
json
{
"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 用 wasm-pack 输出 + Docker 构建
dockerfile
# 阶段 1:构建 .wasm
FROM rust:1.86-slim AS builder
RUN cargo install wasm-pack@0.13.0 --locked
WORKDIR /work
COPY . .
RUN wasm-pack build --release --target nodejs
# 阶段 2:极简运行镜像
FROM scratch
COPY --from=builder /work/pkg/my_lib_bg.wasm /app.wasm
ENTRYPOINT ["/app.wasm"]注意:FROM scratch 让镜像极小——只含 .wasm 文件本身。Wasmtime 等 runtime 通过 containerd-shim-wasm 加载执行。
8.16.4 用 oras 工具直接推送 .wasm
如果不需要构建中间 Docker 镜像,用 oras(OCI Registry As Storage)直接推送 .wasm:
bash
# 推送 .wasm 到 OCI registry
oras push registry.example.com/my-wasm:v1.0 \
--artifact-type application/wasm \
pkg/my_lib_bg.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 工具:
bash
# 推送
wkg publish registry.example.com/my-wasm:1.0 my_lib.wasm
# 拉取
wkg pull registry.example.com/my-wasm:1.0wkg 比 oras 更专门——它理解 WASM 组件、WIT 接口、Component Model 元数据。是组件模型生态的标准包管理工具。
8.16.6 双重发布:npm + OCI
许多 WASM 项目同时面向浏览器和服务端——双重发布模式:
CI 配置:
yaml
- name: Build
run: wasm-pack build --release --target bundler
- name: Publish to npm
run: wasm-pack publish --access public
- name: Build OCI image
run: docker build -t registry/my-wasm:${{ github.ref_name }} .
- name: Push OCI
run: docker push registry/my-wasm:${{ github.ref_name }}一次构建,两种分发——浏览器和服务端各取所需。
8.16.7 K8s 部署 WASM 的完整流程
K8s Deployment 配置:
yaml
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
runtimeClassName: wasmtime # 关键:指定 WASM runtime
containers:
- name: app
image: registry.example.com/my-wasm:1.08.16.8 镜像大小的优化
WASM OCI 镜像的优势是体积——一个完整应用通常 < 1MB,比传统容器小 100-1000 倍。但仍有优化空间:
| 优化 | 节省 |
|---|---|
FROM scratch 而非 alpine | 50-100 KB |
| 只 COPY .wasm 不复制源码 | 视项目 |
| wasm-opt -Oz 极致优化 | 10-20% |
| 移除 .d.ts 等元数据(服务端不需要) | 5-10 KB |
最终:典型服务端 WASM OCI 镜像 200-500KB——足够小让冷启动 < 100ms。
8.16.9 工程注意事项
每条都有具体陷阱:
- 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 + WASM 的标准化正在快速推进——2025 年起 KubeCon 等大会持续讨论。预期 2027-2028 年成为主流部署方式。
把 wasm-pack 产物纳入 OCI 镜像生态,是 WASM 走向云原生主流的关键工程基础设施。
8.17 wasm-pack 与新兴打包工具的集成
§8.6 介绍了 wasm-pack 与传统打包工具(Webpack/Vite)的集成——但 2024-2026 年涌现了一批 Rust 编写的新打包工具(rspack / turbopack / oxc)。它们与 wasm-pack 的集成有不同特点。
8.17.1 Rust 打包工具崛起的背景
每代都比上代快 5-10x——Rust 工具的速度优势让大型项目的构建时间从分钟级降到秒级。
8.17.2 主流 Rust 打包工具
每个工具的定位:
| 工具 | 维护方 | 特点 | WASM 支持 |
|---|---|---|---|
| rspack | 字节跳动 | Webpack API 兼容 | 良好(继承 Webpack) |
| turbopack | Vercel | Next.js 标配 | 实验性 |
| oxc | OXC 团队 | linter + bundler | 早期 |
| mako | 字节跳动 | 内部用 | 内部 |
8.17.3 wasm-pack + rspack 集成
rspack 是 Webpack 的 drop-in 替代——wasm-pack 的 --target bundler 输出能直接用:
javascript
// rspack.config.js
module.exports = {
entry: './src/index.js',
experiments: {
asyncWebAssembly: true, // 启用 WASM 支持
},
module: {
rules: [
{
test: /\.wasm$/,
type: 'webassembly/async',
},
],
},
};javascript
// 使用 wasm-pack 输出
import { greet } from './pkg/my_lib';
greet('World');构建时间对比(中型项目,~10K 行 JS + 10K 行 Rust):
| 工具 | 完整构建 | 增量构建 |
|---|---|---|
| Webpack 5 | 25 s | 3 s |
| rspack | 4 s | 0.5 s |
| Vite | 6 s | 0.2 s(HMR) |
rspack 完整构建快 6x——大项目效果显著。
8.17.4 wasm-pack + turbopack(Next.js)
javascript
// next.config.js
module.exports = {
webpack(config) {
config.experiments = {
asyncWebAssembly: true,
layers: true,
};
return config;
},
// turbopack 配置(实验)
experimental: {
turbo: {
rules: {
'*.wasm': {
loaders: ['wasm-loader'],
},
},
},
},
};Next.js 14+ 默认用 turbopack——WASM 集成仍在演进,2026 年部分场景需要 fallback 到 Webpack。
8.17.5 集成的工程考虑
8.17.6 迁移到 Rust 工具的成本
总迁移成本约 1-2 周——但收益是构建时间永久减少 60-80%。中大型项目通常值得。
8.17.7 兼容性陷阱
每条都是真实坑:
- 插件:rspack 80% Webpack 插件兼容——剩 20% 需要适配
- 配置:90% 配置兼容——但某些边缘选项行为微差
- WASM 加载:不同工具对
import.wasm的解析略有差异 - source map:调试体验可能略差
8.17.8 性能对比的真相
Rust 打包工具的速度不是因为"算法更好"——是因为 Rust 的底层效率。WASM 处理速度差异不大(都是调 wasm-bindgen-cli)。
8.17.9 未来 5 年的格局预测
类似 esbuild 替代 Babel 的速度——Rust 打包工具的崛起是不可逆趋势。WASM 与这些工具的集成会越来越成熟。
8.17.10 工程建议
每条都对应实际场景——技术选型从来不是"选最快",而是"选最适合团队和项目"。把这套建议应用到 wasm-pack 项目的打包工具选型,让构建链路成为团队的工程优势而非瓶颈。
8.18 跨书关联:构建工具的分层哲学
wasm-pack 的设计和本系列其他构建工具有相同的分层哲学——把"语言编译"和"包管理"分为两个独立层:
| 工具 | 语言编译层 | 包管理层 | 输出格式 |
|---|---|---|---|
wasm-pack | cargo build | npm publish | .wasm + .js |
cargo | rustc | crates.io | .rlib / .so |
| Vite | 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 的 esbuild 预构建和 Rollup 生产构建都不理解 .wasm 文件的内部结构——它们只把它当作一个二进制资源文件来处理(复制+哈希命名+引用替换)。.wasm 的优化由 wasm-opt(Binaryen)负责,JS 胶水的优化由 Vite/Rollup 负责。两个优化器在不同层工作,互不干扰。
下一章进入性能优化——如何让 .wasm 模块更小、更快。