Skip to content

第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.lockwasm-bindgen 的精确版本号,然后检查本地安装的 wasm-bindgen CLI 是否为同一版本。如果不匹配,wasm-pack 会自动安装正确版本的 CLI。

步骤二:cargo build

bash
cargo build --target wasm32-unknown-unknown --release

wasm-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 初始化方式
webES Module (import)直接在浏览器 <script type="module"> 中使用import init from './my_lib.js'; await init();
bundlerES Module + bundler 提示webpack / Rollup / Vite 等打包工具打包工具自动处理
nodejsCommonJS (require)Node.js 环境const wasm = require('./my_lib.js');
no-modulesIIFE (全局变量)不支持 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 的初始化方式:

  • webbundler: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.wasm

wasm-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 做的优化包括:

  1. 死代码消除:删除未被任何导出函数可达的代码——这是最有效的体积缩减手段,Rust 编译器的 LTO 虽然也做 DCE,但不如 Binaryen 的精准
  2. 函数内联:小函数内联到调用者中,消除调用开销
  3. 常量折叠:编译期可计算的表达式直接求值——如 4 * 1024 变为 4096
  4. 重复函数合并:签名不同但实现相同的函数合并为一个,减少代码重复
  5. 名称段剥离:删除调试用名称(如函数名 my_lib::greet::h12345),减小体积
  6. 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.instantiateStreamingimports 对象的组装;my_lib_bg.wasm 是编译后的二进制模块。用户代码只需要 import init, { greet } from 'my-lib',然后 await init()——my_lib_bg.jsmy_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 环境)

测试框架的工作原理

具体流程:

  1. wasm-pack test 调用 cargo test --target wasm32-unknown-unknown,但不是真正运行测试——而是编译测试代码为 .wasm
  2. wasm-bindgen CLI 把测试 .wasm 转换为可在浏览器中加载的 JS + WASM
  3. 启动一个临时的 HTTP 服务器(随机端口),提供测试页面
  4. 自动打开浏览器访问测试页面(--headless 模式下使用 headless Chrome)
  5. 测试页面加载 WASM,执行所有 #[wasm_bindgen_test] 标注的函数
  6. 测试结果通过 WebSocket 回传给命令行
  7. 命令行显示结果,根据 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

这条命令做三件事:

  1. 运行 wasm-pack build:确保 pkg/ 目录是最新的
  2. 检查 package.json:确认名称、版本、必填字段齐全
  3. 执行 npm publish:把 pkg/ 目录发布到 npm registry

发布前可以用 --dry-run 预览:

bash
wasm-pack publish --dry-run

package.json 的自定义

wasm-pack 自动生成的 package.json 通常是足够的,但有时需要添加额外的字段(如 repositorykeywordshomepage)。有两种方式:

方式一:在 Cargo.toml 中配置

toml
[package.metadata.wasm-pack.publish]
# 这些字段会被合并到 package.json 中
registry = "https://registry.npmjs.org/"
access = "public"

方式二:在项目根目录放置 package.json 模板

如果项目根目录有 package.jsonwasm-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.jsonfiles 字段只包含 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-packCargo.tomlversion 字段自动生成 package.jsonversion,所以只要 Cargo.toml 的版本号正确,npm 包的版本号就是对的。但 crates.ionpm 是两个独立的 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.copymemory.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 模块。instantiateStreaminginstantiate 快约 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 publish

CI 配置的关键注意事项

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

  2. Binaryen 版本wasm-opt 的版本需要和 wasm-bindgen 兼容。wasm-bindgen 0.2.100 需要 Binaryen version_119+(支持 WASM 特性提案的版本)。

  3. 浏览器缓存--headless --chrome 模式下,Chrome 的用户数据目录是临时的——每次 CI 运行都是全新的浏览器环境,不会有缓存干扰。

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

  5. npm 认证wasm-pack publish 需要 npm 的认证 token。在 GitHub Actions 中通过 secrets.NPM_TOKEN 注入,不要把 token 提交到仓库中。npm token 的创建方式是在 npm 网站上生成一个 "Automation" 类型的 access token——这种 token 可以绕过 npm 的双因素认证(2FA)要求,适合 CI 自动发布。

  6. 并发发布保护:如果多个 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.jsonfiles 字段没有包含 .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'); // OK

WASM 模块必须先初始化(编译+实例化),然后才能调用任何导出函数。wasm-bindgen 生成的 init() 函数是幂等的——多次调用不会重复初始化,第一次调用后的调用会立即返回。

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

wasm-pack build --target <target> 是最容易选错的参数——四个 target 生成的胶水代码差异显著,错配会导致运行时报错或体积浪费。

8.9.1 四种 target 的本质差异

targetJS 模块格式WASM 加载方式体积(典型)适用场景
webESMfetch + instantiateStreaming浏览器直接 import,无打包工具
bundlerESM(特殊)由打包工具处理最小Webpack/Vite/Rollup 项目
nodejsCommonJSfs.readFileSyncNode.js 服务端
no-modulesIIFE 全局变量fetch + 内联最大不能用 ESM 的旧浏览器 / 嵌入页面

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

webbundler 看起来相似——都生成 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-node

package.jsonexports 字段做条件解析:

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 直接 importweb不需要打包工具
Next.js / SvelteKitbundler这些框架支持 .wasm import
Node.js 服务nodejs同步加载,CommonJS
同构(浏览器 + Node.js)双 target + exports 字段各自最优
老浏览器(IE11)兼容no-modules唯一选择
Cloudflare Workers / Denowebbundler视具体平台支持

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-componentwasm-pack 的差异:

维度wasm-packcargo-component
目标wasm-bindgen 模块组件模型组件
ABIwasm-bindgen 协议Canonical ABI
接口定义Rust 类型WIT 文件
多语言互操作仅 Rust ↔ JS任意支持 WIT 的语言
部署目标浏览器 / Node.jswasmtime / wasmer / 浏览器(部分)

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

8.10.3 Trunk:Yew/Leptos 的全栈构建

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

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

Trunk 集成了:

  • WASM 编译 + wasm-bindgen
  • HTML/CSS 资源管道
  • 静态资源 hash 命名
  • 开发服务器 + HMR
  • 生产环境的 brotli 压缩

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

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.json

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

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

8.11.2 与 Turborepo / Nx 集成

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

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

每个 WASM 包的 package.jsonwasm-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-bindgenjs-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-build

sccache 把 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-bindgen1-3 s解析 .wasm,生成 .js + .ts
wasm-opt5-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=sccache

sccache 把每个 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 build95 s基线
+ sccache32 s66%
+ cargo-chef (CI)28 s71%
+ dev profile(无 wasm-opt)18 s81%
+ 增量 hot build5 s95%

把这套优化做完后,开发循环的瓶颈从"等构建"变成"想下一步写什么"——这是工具链该有的样子。

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.js

package.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.rs

wasm-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-templateYew 项目10 分钟Trunk 构建 SPA
leptos-rs/startLeptos 项目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.liquid

cargo-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 = false
toml
# 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
没有打包工具的纯静态 HTMLweb
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.logDevTools
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.comDocker Hub / 私有 registry
版本号semvertag + digest
部署目标浏览器 / Node.jsK8s / 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.0

wkg 比 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.0

8.16.8 镜像大小的优化

WASM OCI 镜像的优势是体积——一个完整应用通常 < 1MB,比传统容器小 100-1000 倍。但仍有优化空间:

优化节省
FROM scratch 而非 alpine50-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)
turbopackVercelNext.js 标配实验性
oxcOXC 团队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 525 s3 s
rspack4 s0.5 s
Vite6 s0.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-packcargo buildnpm publish.wasm + .js
cargorustccrates.io.rlib / .so
Viteesbuild/Rollupnode_modules.js + .css
webpackacorn/tersernode_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 模块更小、更快。

基于 VitePress 构建