Appearance
第9章 WASM 模块体积优化
"Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away." — Antoine de Saint-Exupery
9.1 体积为什么重要:下载、解析与编译
在浏览器环境中,.wasm 文件的体积直接影响三个阶段的延迟:网络下载、流式解析和 JIT 编译。这三个阶段串联执行,体积增大导致每一步都变慢。
下载阶段:.wasm 通过 HTTP 传输到浏览器。即使启用了 gzip/brotli 压缩,WASM 二进制的压缩率通常只有 60-70%(因为指令编码已经有熵),远不如文本资源(HTML/JS 压缩率 80-90%)。一个 500KB 的 .wasm 经 brotli 压缩后仍有 150-200KB——在 4G 网络下需要 300-500ms。
解析阶段:浏览器用流式解码器(Streaming Compiler)边下载边解析 .wasm 二进制格式。V8 的 Liftoff 可以在下载完成的同时完成基线编译,但这要求整个模块已经被接收。模块体积越大,流式解码的吞吐瓶颈越明显——Liftoff 的解码速度约 10-20MB/s,一个 1MB 的 .wasm 需要 50-100ms 解码。
编译阶段:TurboFan 的优化编译在后台线程进行,但它需要完整的模块字节码。模块体积越大,TurboFan 需要分析的函数越多,编译时间越长。一个 500KB 的模块 TurboFan 编译可能需要 200-500ms,而 50KB 的模块只需 20-50ms。
体积优化的目标不只是节省带宽——更重要的是缩短用户等待 WASM 模块可用的时间。Google 的 RAIL 模型要求交互响应在 100ms 内,如果 .wasm 的下载+编译就占掉 500ms,页面交互体验必然受损。
体积对移动端的影响尤为严重。移动网络的不稳定性意味着大体积 WASM 更容易因为网络波动而加载失败——如果用户在地铁里打开页面,2MB 的 .wasm 可能因为一次网络中断导致整个模块下载失败,需要从头开始。分块加载(code splitting)在 WASM 生态中还不够成熟(不像 JS 的动态 import()),所以最有效的策略就是减小单模块的体积。
另一个常被忽视的维度是内存占用。WASM 模块加载后,其代码段驻留在浏览器的 WASM 引擎内存中。一个 2MB 的模块加上运行时的线性内存,可能占用 10-20MB 的浏览器进程内存。在内存受限的移动设备上(如 2GB RAM 的低端 Android 手机),多个标签页共享有限的内存,大体积 WASM 模块更容易触发浏览器的 OOM(Out of Memory),导致标签页被强制终止。
9.2 体积从哪来:剖析 .wasm 的组成
优化之前先理解问题。一个典型的 Rust→WASM 项目,未优化的 .wasm 文件可能比预期大 10 倍以上。
用 wasm-opt 的 --print-function-sizes 选项分析各函数体积:
bash
wasm-opt --print-function-sizes input.wasm输出示例:
[1] __rust_alloc 12
[2] __rust_dealloc 10
[3] __rust_realloc 18
[4] core::panicking::panic 234
[5] core::fmt::write 1,892
[6] alloc::alloc::dealloc 156
[7] dlmalloc::dlmalloc::free 2,340
[8] dlmalloc::dlmalloc::malloc 3,120
[9] my_lib::process 480
[10] my_lib::format_output 1,200
[11] <str>::fmt::Display::fmt 640
[12] core::result::unwrap_failed 1,450关键发现:用户代码(process + format_output = 1,680 字节)只占总量的 20%。剩余 80% 是标准库支持代码——panic 处理、格式化、分配器。这个比例在小项目中更极端——一个 50 行的函数可能拉入 50KB 的标准库代码。
WASM 二进制格式本身的组成也值得关注。根据 WebAssembly 二进制编码规范(MVP),一个 .wasm 文件由以下段(section)构成:
| 段 ID | 名称 | 内容 | 常见体积占比 |
|---|---|---|---|
| 1 | Type | 函数签名 | <1% |
| 2 | Import | 导入的函数/内存/表 | 1-3% |
| 3 | Function | 函数索引到类型的映射 | <1% |
| 5 | Memory | 内存声明 | <1% |
| 6 | Global | 全局变量 | <1% |
| 7 | Export | 导出项 | 1-2% |
| 9 | Element | 表的初始化数据 | 1-5% |
| 10 | Code | 函数体(指令流) | 60-80% |
| 11 | Data | 静态数据段 | 10-20% |
| 12 | DataCount | 数据段计数 | <1% |
Code 段和 Data 段合计占 70-95% 的体积——这是优化的主要目标。其他段都是元数据,开销可忽略。
9.3 第一层优化:编译选项——改几行 Cargo.toml
这是最简单、效果最显著的优化层——只改 Cargo.toml 就能减小 50-70% 体积。
9.3.1 panic = "abort":最大单步收益
toml
[profile.release]
panic = "abort"效果:-30% 到 -50% 体积——通常是单步最大收益。
默认的 panic = "unwind" 需要 WASM 异常处理指令和对应的异常表——即使代码中没有任何 catch_unwind,编译器也必须为每个可能 panic 的函数生成展开(unwind)信息。这些信息包括:每个指令位置的栈映射(stack map)、哪些寄存器包含活跃指针、析构顺序。
WASM MVP 没有 try/catch 指令(那是异常处理提案的内容,Chrome 95+ 才支持),所以 Rust 的 panic = "unwind" 在 WASM 上通过 C++ 风格的 invoke/resume 机制模拟——每个可能 panic的调用被包装在 invoke 指令中,panic 时跳到 landing pad。这套机制生成的代码比 abort 方案多得多。
panic = "abort" 让 panic 直接调用 abort()(触发 WASM 的 unreachable 指令,产生 trap),不需要展开表。附带效果:代码中的 drop 守卫不再需要——panic 时直接终止,不需要按顺序析构变量,减少了函数体中的 drop 调用。
实测数据(一个使用 format! 和 Vec 的典型 crate):
| 配置 | .wasm 体积 | 体积变化 |
|---|---|---|
| panic = "unwind", opt-level = 3 | 89 KB | 基线 |
| panic = "abort", opt-level = 3 | 52 KB | -41% |
9.3.2 opt-level = "z" 或 "s"
toml
[profile.release]
opt-level = "z" # 最小体积
# 或
opt-level = "s" # 平衡体积和速度| opt-level | 优化目标 | 体积影响 | 速度影响 |
|---|---|---|---|
3 | 最快速度 | 基准 | 基准 |
2 | 速度 | 基准 | 基准(和 3 差异很小) |
"s" | 平衡 | -5% 到 -15% | -3% 到 -8% |
"z" | 最小体积 | -10% 到 -25% | -5% 到 -15% |
"z" 和 "s" 的区别不在于内联策略(两者都减少内联以缩小代码),而在于 "z" 更激进地合并相同的常量、折叠重复的指令序列、禁用循环展开和向量化。LLVM 的 -Os 对应 Rust 的 opt-level = "s",-Oz 对应 "z"。
"z" 在 WASM 场景中通常比 "s" 额外多减 5-10% 体积,代价是性能下降 5-10%。但考虑到体积减小带来的下载和编译加速,净效果往往是正面的——特别是对首屏加载场景。
9.3.3 LTO:跨编译单元的全局优化
toml
[profile.release]
lto = true # fat LTO
# 或
lto = "thin" # thin LTO,编译更快效果:-10% 到 -30% 体积。
LTO(Link-Time Optimization)允许链接器跨编译单元做优化。Rust 的每个依赖 crate 独立编译为 .o 文件,LTO 让链接器看到所有代码后做全局优化——跨 crate 内联、死代码消除、常量传播。
Thin LTO vs Fat LTO:Thin LTO 在编译速度和优化效果之间取折衷——它对每个编译单元做独立的 LTO 分析,只在模块间交换摘要信息,编译时间只增加 20-50%。Fat LTO 把所有编译单元合并成一个大的 LLVM IR 模块,做完全全局优化,编译时间增加 2-5 倍,但优化效果最好。
| LTO 模式 | 体积减少 | 编译时间增加 | 推荐场景 |
|---|---|---|---|
false | 基准 | 0 | 开发构建 |
"thin" | -10% 到 -20% | +20% 到 +50% | CI 中等项目 |
true (fat) | -15% 到 -30% | +200% 到 +500% | 发布构建 |
LTO 的体积收益主要来自跨 crate 死代码消除。Rust 的标准库和常用 crate(如 serde、chrono)包含大量泛型代码,每个 crate 独立编译时无法确定哪些泛型实例化是死代码——LTO 把它们放在一起后才看得清。
9.3.4 codegen-units = 1
toml
[profile.release]
codegen-units = 1效果:-5% 到 -15% 体积。
Rust 默认把每个 crate 分成 16 个代码生成单元(codegen units)并行编译——这加快编译速度但限制了跨单元优化。codegen-units = 1 强制单单元,允许 LLVM 做更激进的全局优化:更精确的内联决策、更彻底的死代码消除、更好的常量传播。
与 LTO 的关系:codegen-units = 1 优化的是 crate 内部的代码生成质量;LTO 优化的是 crate 之间的代码。两者互补——codegen-units = 1 + lto = true 的组合通常比单独使用任何一项多减 5-10% 体积。
9.3.5 完整的 release profile
toml
[profile.release]
panic = "abort" # 消除 unwind 表,最大单步收益
opt-level = "z" # 最小体积优化
lto = true # 跨 crate 全局优化
codegen-units = 1 # 单代码生成单元,允许全局优化
strip = true # 移除调试符号和名称段这组配置通常能把 .wasm 体积减到未优化版本的 1/4 到 1/3。对一个中等复杂度的项目(使用 wasm-bindgen + serde + js-sys),实测变化:
| 阶段 | 体积 | 相对基线 |
|---|---|---|
| 默认 release | 187 KB | 100% |
| + panic = "abort" | 112 KB | 60% |
| + opt-level = "z" | 94 KB | 50% |
| + lto = true | 72 KB | 39% |
| + codegen-units = 1 | 65 KB | 35% |
| + strip = true | 58 KB | 31% |
9.4 第二层优化:代码层面——消除不必要的依赖
编译选项解决的是"编译器能看到的冗余",但编译器无法消除你主动引入的依赖。这一层的优化需要理解 Rust 的编译模型:每个 use、每个 trait derive、每个泛型实例化都可能拉入新的代码。
9.4.1 避免 String 格式化
format!() / println!() 是体积杀手——它们拉入 core::fmt 模块,这个模块约 5-10KB。core::fmt 不仅是 format! 本身,还包括 Display、Debug、LowerExp 等多个 trait 的实现,以及格式化规范解析器({:.3?} 这种)。
rust
// 拉入 core::fmt(~5KB)
let msg = format!("Error: code {}", code);
// 用静态字符串——零额外体积
let msg = if code == 0 { "OK" } else { "Error" };
// 用 itoa crate(无格式化,~200 字节)
let mut buf = itoa::Buffer::new();
let msg = buf.format(code);itoa 把整数转字符串不需要格式化框架——它直接按位写入 buffer,生成的代码只有几十条 WASM 指令。类似地,ryu crate 处理浮点数转字符串,体积约 1KB,远小于 core::fmt 的 5-10KB。
9.4.2 选择轻量级分配器
dlmalloc 是 Rust 标准库在 wasm32-unknown-unknown 上的默认分配器,功能完整但体积约 5-10KB。替代方案:
rust
// 使用 wee_alloc(~1KB 分配器)
#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;| 分配器 | 体积开销 | 分配速度 | 释放速度 | 推荐 |
|---|---|---|---|---|
| dlmalloc | ~8KB | 快 | 快 | 通用 |
| wee_alloc | ~1KB | 慢(2-5x) | 慢(2-5x) | 体积敏感、分配不频繁 |
| lol_alloc | ~500B | 快 | 不支持 free | 一次性分配场景 |
| 无分配器 | 0 | N/A | N/A | 纯计算、不需要堆 |
如果 Rust 代码不需要堆分配(纯计算函数),可以完全禁用分配器:
rust
#![no_std]
#[wasm_bindgen]
pub fn pure_compute(input: i32) -> i32 {
input * 2 + 1 // 纯计算,零堆分配
}#![no_std] 排除整个标准库,只保留 core——体积可降到 1KB 以下。但 no_std 限制了你能用的类型——没有 String、Vec、HashMap,只能用 alloc crate 的子集或自己管理内存。
9.4.3 减少泛型单态化
Rust 的泛型通过单态化(monomorphization)实现——每个具体类型参数组合生成一份代码。5 个类型参数 × 3 个方法 = 15 个函数实例。在 WASM 中,这些实例的体积直接累加。
rust
// 5 种类型 × 3 个方法 = 15 个函数实例
fn process<T: Into<i64>>(input: T) -> i64 { input.into() }
// 用具体类型——只有 2 个函数实例
fn process_i32(input: i32) -> i64 { input as i64 }
fn process_i64(input: i64) -> i64 { input }更重要的泛型陷阱是 serde 的序列化——serde_json 对每种组合类型生成独立的序列化/反序列化代码。一个包含 10 个字段的结构体,如果每个字段都是泛型容器的嵌套,生成的代码量可能达到 10-20KB。
9.4.4 避免不必要的 trait 实现
Rust 的 #[derive(Debug)] 会为每个字段生成 fmt::Debug 实现——拉入 core::fmt。如果不需要调试输出,去掉 derive:
rust
// 拉入 core::fmt
#[derive(Debug)]
pub struct Config { value: i32 }
// 不 derive Debug——零额外体积
pub struct Config { value: i32 }类似地,Clone、PartialEq、Display 都有各自的代码量。在 WASM 场景中,只 derive 真正需要的 trait——每多一个 derive 可能增加几百字节到几 KB。
9.5 第三层优化:二进制级别——wasm-opt
wasm-opt 在 .wasm 的二进制表示上做优化——这是编译器看不到的层面。wasm-opt 是 Binaryen 工具包的核心组件,它直接操作 WASM 的抽象语法树,做编译器后端无法完成的优化。
9.5.1 优化级别:-Oz vs -O vs -O4
bash
# 最小体积优化
wasm-opt -Oz input.wasm -o output.wasm
# 平衡体积和速度
wasm-opt -O input.wasm -o output.wasm
# 最快速度优化
wasm-opt -O4 input.wasm -o output.wasm| 级别 | 目标 | 典型体积变化 | 优化 pass 数量 |
|---|---|---|---|
-Oz | 最小体积 | -10% 到 -25% | ~40 |
-O | 平衡 | -5% 到 -15% | ~25 |
-O3 | 速度优先 | -2% 到 -5% | ~20 |
-O4 | 极致速度 | +5% 到 +10%(体积可能增大) | ~20 + 循环展开 |
-Oz 启用的关键 pass:
重复函数消除(DuplicateFunctionElimination):两个函数如果指令序列完全相同(可能名字不同),合并为一个。这在 Rust 的单态化场景中很常见——不同类型的
Clone::clone可能生成相同的代码(例如Vec<u32>和Vec<i32>的 clone 逻辑相同,只是类型签名不同)。栈简化(SimplifyLocals):消除不必要的
local.set+local.get对——如果值在栈上就直接用,不存到局部变量再取回。LLVM 的寄存器分配器生成 WASM 时,局部变量映射可能有冗余——wasm-opt 在二进制层面清理这些冗余。代码折叠(CodeFolding):多个基本块如果末尾有相同的指令序列,合并为一个基本块,用跳转指向。这类似于编译器的 tail merging,但 wasm-opt 可以跨函数做。
死代码消除(DeadCodeElimination):从导出函数出发,标记所有可达代码,删除不可达的函数和数据。LLVM 的 DCE 在单编译单元内工作,wasm-opt 的 DCE 在整个模块内工作——可以消除 LTO 遗漏的死代码。
数据段优化(MergeBlocks):合并相邻的数据段、删除全零的数据段。Rust 的静态初始化数据可能分散在多个数据段中——wasm-opt 把它们合并,减少段头的元数据开销。
9.5.2 启用提案指令
bash
# 启用 bulk-memory 提案——减小 memcpy/memset 的代码
wasm-opt -Oz --enable-bulk-memory input.wasm -o output.wasm
# 启用引用类型——减少对象栈开销
wasm-opt -Oz --enable-reference-types input.wasm -o output.wasm
# 启用 SIMD——某些计算密集场景
wasm-opt -Oz --enable-simd input.wasm -o output.wasm--enable-bulk-memory 允许 wasm-opt 使用 memory.copy 和 memory.fill 指令替代逐字节的复制/填充循环。一个 memcpy(1024) 的调用,标量实现需要 30-40 条指令,memory.copy 只需 1 条。这在频繁操作大块数据的场景中节省可观的代码体积。
--enable-reference-types 允许 wasm-opt 使用 ref.null、ref.is_null 等引用类型指令——减少 JS 对象栈管理的辅助代码。
9.5.3 名称剥离
bash
# 剥离所有名称段(函数名、局部变量名等)
wasm-opt -Oz --strip-names input.wasm -o output.wasm名称段对执行没有影响——它只用于调试。剥离后通常减少 5-15% 的体积。Rust 编译器默认在 release 构建中保留函数名(方便 panic 信息),wasm-opt 的 --strip-names 删除这些名称。
wasm-pack 默认在 --release 模式下自动调用 wasm-opt 并剥离名称。如果需要调试,用 --dev 或 --profiling 模式。
9.5.4 strip 调试信息
除了名称,WASM 二进制还可能包含 DWARF 调试信息(如果 Rust 编译时未 strip):
bash
# 剥离 DWARF 调试信息
wasm-opt -Oz --strip-dwarf input.wasm -o output.wasmDWARF 调试信息包括源码位置映射、变量类型信息等——可以占 WASM 文件的 30-50%。在 release 构建中必须剥离。
9.6 依赖审计:找出体积的真正来源
编译选项和代码层面优化之后,如果体积仍然偏大,需要做依赖审计——找出是哪个 crate 或哪个函数占了大头。
9.6.1 cargo bloat
cargo bloat 分析每个 crate 和每个函数在最终 .wasm 中的体积占比:
bash
cargo bloat --target wasm32-unknown-unknown --release -n 20输出示例:
File .text Size Crate Name
==== ===== ==== ===== ====
0.8% 1.2% 3.1KB std core::fmt::write
0.6% 0.9% 2.3KB std dlmalloc::dlmalloc::free
0.8% 1.2% 3.1KB std dlmalloc::dlmalloc::malloc
0.4% 0.6% 1.4KB std core::result::unwrap_failed
0.3% 0.4% 1.2KB my_lib my_lib::format_output
0.2% 0.3% 480B my_lib my_lib::process
...
69.2% 99.8% 49.7KB .text section total
30.8% 22.1KB .data section total关键命令变体:
bash
# 按 crate 分组
cargo bloat --target wasm32-unknown-unknown --release --crates
# 按函数分组(默认)
cargo bloat --target wasm32-unknown-unknown --release -n 50
# 包含泛型单态化信息
cargo bloat --target wasm32-unknown-unknown --release --times9.6.2 cargo tree
cargo tree 展示依赖关系——帮你理解为什么某个 crate 被引入:
bash
# 查看完整依赖树
cargo tree --target wasm32-unknown-unknown
# 反向依赖:谁依赖了某个 crate
cargo tree --target wasm32-unknown-unknown -i serde
# 查看被多个 crate 共享的依赖
cargo tree --target wasm32-unknown-unknown --duplicates一个常见的体积陷阱是:你只 use 了一个函数,但这个函数的 crate 依赖了其他大 crate。例如,chrono 依赖 num-integer 和 num-traits,如果你只需要 chrono::Utc::now(),整个 num-* 族的代码都会被编入 .wasm。用 cargo tree -i num-integer 可以确认这些依赖是否必要。
9.6.3 twiggy:基于调用图的体积分析
twiggy 是 Rust WASM 生态的专用体积分析工具——它构建调用图,标注每个函数和数据的体积占比,还能追踪"谁拉入了这个函数":
bash
cargo install twiggy
twiggy top target/wasm32-unknown-unknown/release/my_lib.wasm输出示例:
Shallow Bytes | Shallow % | Item
------------- | --------- | ------
3,120 | 18.2% | dlmalloc::dlmalloc::malloc
2,340 | 13.6% | dlmalloc::dlmalloc::free
1,892 | 11.0% | core::fmt::write
1,450 | 8.5% | core::result::unwrap_failed
1,200 | 7.0% | my_lib::format_output
640 | 3.7% | <str as fmt>::fmt
480 | 2.8% | my_lib::process
... | ... | ...twiggy dominators 找到体积膨胀的根因——不是"哪个函数最大",而是"哪个函数的存在导致了最多其他代码被引入":
bash
twiggy dominators target/.../my_lib.wasm | head -20Retained Bytes | Retained % | Item
-------------- | ---------- | ------
17,120 | 100.0% | [0] root
8,340 | 48.7% | [1] my_lib::format_output
5,892 | 34.4% | [5] core::fmt::write
3,450 | 20.1% | [9] core::result::unwrap_failed"Retained"表示"如果删除这个函数,同时可以删除的所有依赖代码的总体积"。my_lib::format_output 的 retained 是 8,340 字节——因为删除它后,core::fmt::write 和相关的格式化代码也可以被消除。这是修复体积问题最有效的指标。
9.6.4 per-dependency 体积审计方法
一个系统化的审计流程:
特别需要注意的"体积刺客" crate:serde_json(完整版约 30-50KB)、regex(约 20-40KB)、chrono(约 15-30KB)、reqwest(约 50-100KB)。如果这些 crate 只用了很小一部分功能,考虑用更轻量的替代——serde_json 用 serde_json_core(no_std,~5KB),regex 用手动匹配或 fancy-regex 的子集。
9.7 Tree Shaking:WASM 中的死代码消除
"Tree shaking"一词来自 JS 生态(Rollup/Webpack 的术语),核心思想是:从入口函数出发,只保留可达的代码,删除所有不可达的代码。WASM 的 tree shaking 分为三个层次。
9.7.1 链接时消除(Linker-level DCE)
WASM 的链接语义天然支持 DCE:一个 .wasm 模块的入口是导出函数(exported functions),链接器从导出函数出发遍历调用图,不可达的函数不编入最终模块。
Rust 的链接器在 wasm32-unknown-unknown 目标上默认做函数级 DCE——但粒度有限。一个 pub fn 即使没有被调用,如果它和被调用的函数在同一个 crate 中,链接器可能保留它(因为 pub 意味着可能被外部引用)。
9.7.2 LTO 级别的消除
LTO 把所有 crate 的 IR 合并后,可以做更精确的 DCE——跨 crate 看到调用全貌,能消除单个 crate 内部无法判断的死代码。例如,crate A 导出了 pub fn helper(),crate B 没有调用它——LTO 能看到 B 没有用 A 的 helper,把它删掉。
9.7.3 wasm-opt 级别的消除
wasm-opt 的 DCE 在二进制层面做——它不关心源码的 pub/private 语义,只看 WASM 模块内的实际调用图。从所有导出函数出发,标记可达的函数、表项、数据段,删除不可达的一切。这比 LTO 的 DCE 更激进——因为它看到了最终链接后的完整模块。
三个层次的消除是串联的:链接器 DCE 去掉明显不可达的函数,LTO DCE 去掉跨 crate 的死代码,wasm-opt DCE 去掉二进制层面的残留。全部开启后,效果通常比单独使用任何一层多减 10-20%。
值得注意的是,WASM 的 tree shaking 和 JS 生态的 tree shaking 有本质差异。JS 的 tree shaking(如 Rollup/Webpack)依赖 ES Module 的静态 import/export 语义——编译器可以静态分析哪些导出被使用。WASM 的导出是模块级别的——一个 #[wasm_bindgen] 标注的 pub fn 在 WASM 模块中是一个导出函数,链接器保守地认为它可能被外部调用,不会自动删除。所以 WASM 的 tree shaking 效果更依赖 LTO 和 wasm-opt——它们能看到整个模块的调用图,判断某个导出函数是否真的被需要。
另一个常见误区是认为 #[cfg(feature = "...")] 可以实现 tree shaking。Feature gate 只影响编译时是否包含代码——如果你的 crate 依赖了一个启用了全部 feature 的中间 crate,那些 feature 对应的代码仍然会被编入 .wasm。要真正实现按需裁剪,需要在整个依赖链上统一控制 feature——这正是 cargo bloat --crates 和 cargo tree --duplicates 帮你检查的内容。
9.8 真实项目的优化数据
以一个实际的 Rust→WASM 项目为例:一个 Markdown 解析器(类似 pulldown-cmark 的子集),编译到浏览器中使用。
| 优化步骤 | 体积 | 累计减少 | 说明 |
|---|---|---|---|
| 默认 release(opt-level=3, panic=unwind) | 312 KB | 0% | 基线 |
| panic = "abort" | 178 KB | -43% | 消除 unwind 表 |
| opt-level = "z" | 152 KB | -51% | 体积优先优化 |
| lto = true | 118 KB | -62% | 跨 crate DCE |
| codegen-units = 1 | 108 KB | -65% | 更好的单 crate 优化 |
| strip = true | 98 KB | -69% | 移除名称段 |
| wasm-opt -Oz | 76 KB | -76% | 二进制级优化 |
| 去掉 format!/Debug derive | 62 KB | -80% | 消除 core::fmt |
| 换 wee_alloc | 55 KB | -82% | 替换 dlmalloc |
| brotli 压缩 | 18 KB | -94%(传输体积) | 网络传输 |
关键洞察:前两步(panic = "abort" + opt-level = "z")就占了总优化量的 70%。后续步骤的边际收益递减——但仍有价值,特别是 brotli 压缩让传输体积降到 18KB,这是一个完全可以接受的 WASM 模块大小。
9.9 优化决策流程
核心原则:先测量,再优化。用 twiggy 找到体积大头,针对性优化,不要凭直觉猜测。
体积 vs 性能的权衡
体积优化不是免费的——较小的体积可能意味着较慢的执行。但权衡的天平在 WASM 场景中偏向体积:因为体积影响的是用户可感知的加载延迟,而性能差异通常在 10% 以内(除了 wee_alloc 在分配密集场景下的 30% 回退)。
| 优化 | 体积影响 | 性能影响 | 原因 |
|---|---|---|---|
opt-level = "z" | -20% | -10% | 禁用循环展开、向量化 |
wasm-opt -Oz | -15% | -5% | 更小的代码 = 更少的 i-cache 压力 |
wee_alloc | -7KB | -30%(分配密集场景) | 更简单的分配算法 |
panic = "abort" | -40% | 0 | 只影响 panic 路径 |
| LTO | -20% | +5% | 更多内联 = 更少调用开销 |
| 名称剥离 | -10% | 0 | 名称不影响执行 |
#![no_std] | -50%+ | 0(纯计算场景) | 去除 std 开销 |
一个值得注意的非直觉结果:更小的体积有时反而更快。原因是 i-cache 效应——如果代码能完全放入 L1 指令缓存(32-64KB),执行速度可能比更大的"优化"版本更快。这在 WASM 场景中尤其显著——浏览器的 JIT 编译器需要解码整个 .wasm,更小的文件解码更快。
9.10 代码分割与多模块架构
单体 WASM 模块在体积无法继续压缩时,下一步是把功能拆分到多个模块——按需加载,让用户不为没用的功能买单。
9.10.1 单体模块的极限
一个集成了图像处理、PDF 解析、加密算法的 WASM 模块体积常达 1-2MB。即使做了所有压缩,传输体积仍可能 300-500KB。但用户进入页面时,可能 80% 的功能用不到——这部分代码完全是浪费。
代码分割的核心收益:
| 场景 | 单模块加载 | 分割后首屏加载 | 节省 |
|---|---|---|---|
| 图像编辑器(首屏只看图) | 1.2 MB | 200 KB | 83% |
| 文档编辑器(首屏只读) | 800 KB | 150 KB | 81% |
| 数据可视化(首屏只展示) | 600 KB | 120 KB | 80% |
9.10.2 wasm-bindgen 多模块的工程模式
wasm-bindgen 默认假设单 WASM 模块。多模块的实战路径:
模式一:多 crate 多模块——每个功能编一个独立的 .wasm,JS 侧按需加载:
javascript
let imageModule = null;
async function ensureImageModule() {
if (!imageModule) {
imageModule = await import('./image_wasm/image_wasm.js');
await imageModule.default();
}
return imageModule;
}
// 用户点击"应用滤镜"时才加载
button.addEventListener('click', async () => {
const m = await ensureImageModule();
m.apply_filter(input);
});每个模块独立编译——独立的内存、独立的导出表。模块间的数据传递必须经过 JS(先把 WASM A 的输出读到 JS,再写入 WASM B 的内存)。
模式二:feature gate + 多个构建产物——同一个 Rust crate 通过 feature 控制编译出不同体积的 .wasm:
toml
[features]
default = []
image = ["dep:image"]
pdf = ["dep:pdf-rs"]
all = ["image", "pdf"]构建脚本根据需要生成多个产物:
bash
wasm-pack build --features image --out-dir pkg/image
wasm-pack build --features pdf --out-dir pkg/pdf
wasm-pack build --features all --out-dir pkg/fullJS 根据用户场景加载对应版本——比模式一简单(不需要跨模块通信),但用户切换功能时可能需要重新加载更大的版本。
9.10.3 模块间通信的开销
多模块之所以有代价,是因为模块边界的数据传递需要 JS 中转:
一次 1MB 数据从 A 到 B 的开销:A 导出指针(5ns)+ JS 创建 Uint8Array(memoryA, ptr, len) 视图(10ns)+ JS 拷贝到新 Uint8Array(约 1ms)+ B 分配内存(5μs)+ JS 写入 B 内存(约 1ms)。总计 2-3ms——单次可接受,但高频调用累计开销显著。
如果模块间通信频繁,要么合并这些模块(重新评估分割是否合理),要么用 SharedArrayBuffer 让多个模块共享同一段线性内存(要求 Cross-Origin Isolation)。
9.10.4 共享运行时:减少重复代码
每个 WASM 模块都嵌入一份 wasm-bindgen 的胶水代码(约 5-10KB)。如果有 5 个模块,光胶水就重复 25-50KB。
wasm-bindgen 的 --reference-types + --externref 模式让多个模块共享同一份运行时:
bash
wasm-bindgen --target web --reference-types ...但这要求所有模块使用相同版本的 wasm-bindgen——版本不一致会导致 ABI 不兼容,运行时报错。生产中需要在 CI 中校验所有模块的 wasm-bindgen 版本。
9.11 静态数据的体积优化
WASM 的 Data 段(静态数据)经常被忽略——但在某些场景下它占总体积的 30-50%。优化静态数据需要不同于代码优化的手段。
9.11.1 哪些数据落入 Data 段
Rust 编译时确定的常量数据都进入 WASM 的 Data 段:
- 字面量字符串:
"hello world"等 static变量:static TABLE: [u32; 1024] = [...];- 编译时通过
include_bytes!/include_str!嵌入的资源 - 泛型代码生成的查找表(如某些哈希算法的预计算表)
- panic 信息中的源码位置字符串
#[derive(Debug)]生成的字段名字符串
bash
# 查看 .wasm 各段大小
wasm-objdump -h my_lib.wasm输出示例:
Sections:
1 Type | 120 bytes | function signatures
2 Function | 85 bytes | function declarations
3 Memory | 3 bytes | memory section
4 Export | 128 bytes | exports
5 Code | 38240 bytes | function bodies
6 Data | 12480 bytes | static data ← 接近代码段的 1/312KB 的 Data 段在 50KB 的模块中占 24%——值得优化。
9.11.2 嵌入资源的优化
include_bytes! 嵌入的资源(图标、字体、配置文件)原样进入 Data 段。优化思路:
编译时压缩:用构建脚本预压缩资源,运行时解压:
rust
// build.rs
fn main() {
let raw = std::fs::read("assets/big_icon.png").unwrap();
let compressed = zstd::encode_all(&raw[..], 22).unwrap();
std::fs::write("assets/big_icon.zst", &compressed).unwrap();
}
// lib.rs
const ICON_COMPRESSED: &[u8] = include_bytes!("../assets/big_icon.zst");
#[wasm_bindgen]
pub fn get_icon() -> Vec<u8> {
zstd::decode_all(ICON_COMPRESSED).unwrap()
}代价:拉入 zstd 解压器(~10KB)。只有当压缩节省 > 10KB 时才划算。对小资源(< 5KB),直接嵌入更省。
外部加载替代嵌入:把资源放到 assets/ 目录,由 JS 加载后传给 WASM:
javascript
const iconBytes = await fetch('./assets/icon.png').then(r => r.arrayBuffer());
wasm.set_icon(new Uint8Array(iconBytes));这把资源从 .wasm 中剥离——浏览器可以并发下载多个资源,且资源能被 CDN 独立缓存。
9.11.3 字符串去重
Rust 编译器对相同的字符串字面量会自动去重——但只在同一个 crate 内。跨 crate 的相同字符串(例如多个 crate 都有 "failed to " 前缀)不会自动合并。
wasm-opt --merge-similar-functions 之外,还有 --simplify-globals --simplify-locals pass 可以做数据段的去重——但效果有限,因为数据段的语义不像代码那样明确。
更彻底的手段是在构建时检测重复字符串,重构代码用同一个常量:
rust
// 优化前:每个模块都有自己的错误前缀
const A_ERR: &str = "failed to allocate";
const B_ERR: &str = "failed to allocate";
// 优化后:共享常量
mod errors {
pub const ALLOC_FAILED: &str = "failed to allocate";
}9.11.4 查表 vs 计算
某些算法用预计算表换取运行时速度——CRC32 用 1KB 表,AES 用 4KB 表,三角函数用插值表。WASM 体积敏感场景下,权衡变化:
| 方案 | 体积 | 速度 | 适用 |
|---|---|---|---|
| CRC32 完整表(1KB) | +1KB | 基准 | 体积充裕 |
| CRC32 半表(256B) | +256B | -20% | 中等体积 |
| CRC32 无表(计算) | +0 | -50% | 极致体积 |
对于不在性能热路径的算法,去掉查表换体积是值得的。#[cfg(feature = "small")] 切换实现:
rust
#[cfg(feature = "small")]
fn crc32(data: &[u8]) -> u32 { /* 计算实现 */ }
#[cfg(not(feature = "small"))]
fn crc32(data: &[u8]) -> u32 { /* 查表实现 */ }9.12 极端体积:< 10KB 的实战
把一个 wasm-bindgen 项目压到 10KB 以下需要精确控制每个细节——这是体积优化的极限挑战,能加深对 WASM 体积构成的理解。
9.12.1 起点:默认 wasm-bindgen 项目
最小的 hello-world 项目:
rust
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn add(a: i32, b: i32) -> i32 {
a + b
}默认 release 构建:约 25-35KB。这对一个加法函数来说是天文数字——其中 99% 是基础设施代码。
9.12.2 逐步压缩到 10KB
步骤 1:Cargo.toml 优化(已覆盖)→ 约 12-15KB。
步骤 2:禁用 wasm-bindgen 的 std 功能:
toml
[dependencies]
wasm-bindgen = { version = "0.2", default-features = false }wasm-bindgen 的 std feature 拉入了 String/Vec 的 JS 转换支持。如果只用基础类型(i32/f64),可以禁用——节省 3-5KB。
步骤 3:no_std + 自定义 panic handler:
rust
#![no_std]
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
core::arch::wasm32::unreachable()
}
#[wasm_bindgen]
pub fn add(a: i32, b: i32) -> i32 {
a + b
}去掉 std → 约 8-10KB。但 wasm-bindgen 的核心仍占 5-7KB。
步骤 4:放弃 wasm-bindgen,手写 extern "C":
rust
#![no_std]
#![no_main]
#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
core::arch::wasm32::unreachable()
}
#[no_mangle]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
a + b
}直接编译为 wasm32-unknown-unknown 目标(不用 wasm-pack):
bash
cargo build --target wasm32-unknown-unknown --release
wasm-opt -Oz target/wasm32-unknown-unknown/release/my_lib.wasm -o final.wasm最终体积:约 200-500 字节。比 wasm-bindgen 版本小 50-100 倍——但失去了字符串、对象等高级类型的支持。
9.12.3 体积/便利性的权衡矩阵
| 体积区间 | 适用场景 | 代价 |
|---|---|---|
| < 1KB | 纯计算函数(哈希、数学) | 不能用 String/Vec |
| 1-10KB | 算法库 + 简单 IO | 手写 JS 互操作 |
| 10-50KB | 普通 wasm-bindgen 应用 | 标准开发体验 |
| 50-200KB | 复杂应用 + serde | 需要持续审计依赖 |
| > 200KB | 大型应用 + 多个第三方 crate | 必须做代码分割 |
实际项目几乎不需要 < 1KB——但理解极限有助于判断"我的项目能压到多小"。一个 50KB 的纯计算 WASM 项目几乎一定有冗余——要么 wasm-bindgen 用得太多,要么有不必要的 String 操作。
9.12.4 监控体积回归
体积优化最容易反弹——加一个新依赖、用一次 format!,几个 KB 就回来了。CI 中加入体积守门:
yaml
# .github/workflows/wasm-size.yml
- name: Check WASM size
run: |
SIZE=$(stat -c%s target/wasm32-unknown-unknown/release/my_lib.wasm)
if [ $SIZE -gt 51200 ]; then
echo "WASM size $SIZE exceeds 50KB budget"
exit 1
fi更精细的方案:用 twiggy 对比当前 PR 和 main 分支的体积差异,超过阈值(如 +5KB)时阻止合并。这能让体积问题在引入时立即被发现,而不是等到生产环境用户抱怨加载慢。
9.13 体积监控与持续优化
体积优化做完后只是开始——后续每次 PR 都可能引入新依赖让体积反弹。没有持续监控的体积优化几乎一定会在数月后回到原点。生产团队需要把体积控制嵌入工程流程。
9.13.1 CI 中的体积守门
GitHub Actions 实现:
yaml
- name: Build WASM
run: wasm-pack build --release --target bundler
- name: Compare size with main
run: |
NEW_SIZE=$(stat -c%s pkg/my_lib_bg.wasm)
git fetch origin main
git checkout origin/main -- pkg/my_lib_bg.wasm 2>/dev/null || OLD_SIZE=0
OLD_SIZE=${OLD_SIZE:-$(stat -c%s pkg/my_lib_bg.wasm 2>/dev/null || echo 0)}
if [ $OLD_SIZE -gt 0 ]; then
DELTA=$((NEW_SIZE - OLD_SIZE))
PCT=$(awk "BEGIN { printf \"%.1f\", ($DELTA / $OLD_SIZE) * 100 }")
echo "::notice::WASM size: $OLD_SIZE → $NEW_SIZE bytes (${PCT}%)"
if (( $(echo "$PCT > 5" | bc -l) )); then
echo "::warning::Size increased by ${PCT}% — review required"
if [ "${{ github.event.pull_request.labels[0].name }}" != "size-exempt" ]; then
exit 1
fi
fi
fi体积变化在 PR 评论中显示——reviewer 在 review 时直接看到影响,不需要去构建。
9.13.2 体积预算(size budget)
把体积控制变成数字承诺,写入项目配置:
toml
# size-budget.toml
[budgets]
"pkg/my_lib_bg.wasm" = 51200 # 50KB
"pkg/optional/*.wasm" = 102400 # 100KB
"pkg/total" = 204800 # 总和 200KB
[alerts]
warn_at_pct = 90 # 达到 90% 预算时警告
fail_at_pct = 100 # 超出预算阻止合并这种"数字预算"机制类似前端的 bundle size budget——把质量目标量化。Webpack/Vite 都有同类工具,WASM 项目应该有等价机制。
9.13.3 历史趋势可视化
单点检查不够——需要看趋势:
实战工具:bundle-stats / bundlesize 等支持 WASM。或者自己写脚本:
bash
# 每次合并后追加历史
echo "$(date -Iseconds),$(stat -c%s pkg/my_lib_bg.wasm)" >> size-history.csv把 size-history.csv 提交到独立分支或 S3,Grafana 直接读取展示。
9.13.4 体积审计的自动化报告
每月生成体积健康报告:
rust
// 审计脚本
fn audit() {
let crates = run_cargo_bloat_crates();
let funcs = run_twiggy_top();
let history = load_size_history();
println!("# WASM 体积月报\n");
println!("当前总体积: {} KB", current_size() / 1024);
println!("月环比: {:+.1}%", monthly_change_pct(&history));
println!("\n## Top 5 体积占用 crate\n");
for c in crates.iter().take(5) {
println!("- {} - {} KB ({:.1}%)", c.name, c.size_kb, c.pct);
}
println!("\n## 增长最多的 crate(vs 上月)");
for c in growing_crates(&history).iter().take(3) {
println!("- {} - 增长 {} KB", c.name, c.growth_kb);
}
println!("\n## 建议");
suggest_optimizations();
}这种月报让管理层和工程团队对 WASM 体积健康度有一致的视角——从"个别工程师关心"变成"团队级关注"。
9.13.5 减少回归的工程纪律
持续保持小体积需要纪律:
| 纪律 | 实现 | 频率 |
|---|---|---|
| 体积守门 | CI 阻止 > 5% 增长 | 每 PR |
| 月度审计 | 自动报告 + review 会议 | 每月 |
| 季度优化 | 集中投入 1-2 天做体积优化 | 每季度 |
| 依赖审查 | 引入新依赖必须评估体积影响 | 引入时 |
| 旧代码下线 | 30 天未用的导出函数自动下线 | 持续 |
这套机制让 WASM 体积成为持续被关注的工程指标——而不是"上线前才看一眼"的事后补丁。
9.13.6 反模式:体积优化的常见陷阱
每个反模式的纠正:
- 一次性大优化:要持续维护,不要一次性优化完就抛在脑后
- 只看体积:每次体积优化必须跟一次性能基准对比,确认没退化
- 过度抽象:宁愿多 5KB 也别让代码难以理解——可维护性 > 体积
- 错配 wee_alloc:分配密集场景换 wee_alloc 性能下降 30%,必须有性能监控
- 过早禁用 std:早期项目用 std 简化开发,体积成为问题再优化
体积优化是一个长期工程目标——不是一次性任务。把这套机制建立起来后,WASM 模块的体积健康度就和代码质量一样自然被管理。
9.14 WASM 新提案对体积的影响
WASM 不是静态规范——每年都有新提案进入主线。理解这些提案对体积的影响有助于规划长期的体积策略——某些痛点可能在下一个工具链版本里就消失了。
9.14.1 已落地的体积相关提案
每个提案对体积的影响:
| 提案 | 状态 | 体积影响 | 启用方式 |
|---|---|---|---|
bulk-memory | 稳定 | -5% 到 -15% | RUSTFLAGS +bulk-memory |
sign-extension | 稳定 | -2% 到 -5% | 默认启用 |
multivalue | 稳定 | -3% 到 -8% | Rust 1.82+ 默认 |
nontrapping-fptoint | 稳定 | -1% 到 -3% | RUSTFLAGS |
GC types | 候选 | -20% 到 -40%(特定场景) | 实验工具链 |
tail-call | 候选 | -5% 到 -15%(递归密集) | 实验 |
exception-handling | 候选 | -10% 到 -30%(替代 panic 表) | 实验 |
bulk-memory 是最容易获得的提速——大多数浏览器自 2020 年起支持。一行 RUSTFLAGS 就能减 5-15%。
9.14.2 GC types 提案:消除手写分配器
WASM 当前没有 GC——所有内存管理由 WASM 模块自己实现(Rust 用 dlmalloc 或 wee_alloc)。GC types 提案让宿主提供 GC,WASM 模块直接用:
rust
// 当前:WASM 自带分配器,~5-10KB
let v = vec![1, 2, 3]; // dlmalloc 在线性内存中分配
// GC types 之后:宿主管理
let v: GcVec<i32> = gc_vec![1, 2, 3]; // 在宿主 GC 堆上
// 不需要 dlmalloc,节省 5-10KB体积节省:
- 移除
dlmalloc:~5-10KB - 移除 panic 处理:~2-5KB
- 简化序列化:~1-3KB
总计:单模块可能减 10-20KB——对小项目影响巨大(30KB → 15KB 是 50% 减少)。
代价:需要宿主支持 GC types(V8 支持中、Wasmtime 部分支持)。这是 Web 端和服务器端的渐进特性,不是马上能用。
9.14.3 exception-handling:替代 unwind 表
panic = "unwind" 在当前 WASM 中需要展开表(unwind tables)——占 30-50% 的体积。原因:WASM MVP 没有原生异常处理,必须模拟。
exception-handling 提案引入 WASM 原生 try/catch:
rust
// 当前:unwind 通过模拟实现,体积大
fn might_panic() -> i32 {
if cond { panic!("error") }
42
}
// exception-handling 之后:原生 try/catch,体积小
// LLVM 直接生成 WASM 的 try/catch 指令体积影响:
| 配置 | 当前 | exception-handling 后 |
|---|---|---|
panic = "abort" | 基线 | 基线 |
panic = "unwind" | +40-60% | +5-10% |
启用 exception-handling 后,可以放心用 panic = "unwind"——保留 panic 信息的同时不付出体积代价。
9.14.4 tail-call:消除递归栈
某些算法(解析器、解释器)大量使用递归。WASM 当前的递归会消耗调用栈——深递归直接 stack overflow。tail-call 提案让尾递归被编译为跳转:
rust
// 当前:每次递归消耗栈
fn factorial(n: u64) -> u64 {
if n == 0 { 1 } else { n * factorial(n - 1) }
}
// n=10000 → stack overflow
// tail-call 后:尾递归优化为跳转
fn factorial(n: u64, acc: u64) -> u64 {
if n == 0 { acc } else { factorial(n - 1, n * acc) }
}
// n=10000 没问题,且不增加体积对体积的影响是间接的——tail-call 让递归成为可行的实现方式,避免开发者用复杂的循环+栈手动模拟,间接减少代码量。某些函数式风格的代码可减少 5-15%。
9.14.5 跟踪提案演进
提案的状态用 GitHub issue 跟踪:
跟踪渠道:
- WebAssembly/proposals on GitHub:所有提案的中央索引
- WebAssembly/meetings:CG 会议纪要
- Rust 工具链 release notes:何时支持新 feature
9.14.6 长期体积策略
短期策略主导日常工作——这部分内容前面章节已覆盖。中长期策略需要工程团队跟踪 WASM 路线图——半年一次评审,识别可以采用的新提案。
实际经验:从 2020 年到 2026 年,主流 WASM 项目的体积通常减少 30-50%——一半来自团队的优化努力,另一半来自 WASM 规范演进。两者协同,效果最好。
9.14.7 决策:何时采用新提案
不要追新提案——浏览器支持率不到 90% 的提案不要在生产用。但要"知道"新提案,准备好平台支持时第一时间评估。这是把握技术演进红利的关键。
9.15 体积优化的成本-收益边际
§9.3-§9.13 介绍了 8 层优化手段——但每层投入的工程时间不同,收益也递减。生产团队应该按 ROI 决定投入哪些层级,避免把 80% 时间花在最后 5% 的优化上。
9.15.1 各层优化的 ROI 矩阵
ROI 排序明显——前 3 层(4 小时投入,70% 收益)必做,第 7 层(1 月投入,5% 收益)几乎不值得。
9.15.2 不同业务场景的优化策略
每类项目的合理投入:
- MVP:体积不是核心问题——做层 1 即可(panic=abort + opt-level=z)
- 生产 Web:用户体验重要——层 1-3,5 小时投入换 70% 体积减少
- 公开库:体积影响所有消费者——值得投入到层 5
- 嵌入式:每 KB 都珍贵——做到层 7
9.15.3 体积优化的成本结构
工程时间不只是"写代码的时间"——还包括隐性成本:
| 优化层 | 显性成本 | 隐性成本 |
|---|---|---|
| 层 1 | 1 小时配置 | 几乎无 |
| 层 2 | 1 天审计 | 维护过滤后的依赖 |
| 层 3 | 30 分钟 | wasm-opt 升级跟随 |
| 层 4 | 3 天重构 | 代码可读性下降 |
| 层 5 | 1 周 | 失去 std 便利,bug 增加 |
| 层 6 | 2 周 | 类型不安全,维护噩梦 |
| 层 7 | 1 月 | 业务功能受限 |
层级越深,每减一字节带来的代价越大——不是简单的时间投入,还包括代码质量、维护性的损失。
9.15.4 何时该停止优化
判断标准:用户感知的加载时间是否可接受。Lighthouse / RUM 指标比"WASM 字节数"更重要。如果 P95 加载已经 < 1s,继续优化体积没必要——把工程时间投到别处更有 ROI。
9.15.5 工程团队的优化预算
合理预算:
- 日常:CI 自动监控防回归(投入 0)
- 小优化:每季度花 1 周做一些层 1-3 优化(投入 10%)
- 大优化:每年 1 次系统性重构(业务空窗期)
不要让"体积优化"成为永恒任务——大部分时间应该让自动化监控守门,工程师专注业务。
9.15.6 案例:从 800KB 优化到 200KB 的工程账单
总投入:约 10 工作日。如果继续投入:
- 再花 1 周到 180KB(-10%)
- 再花 1 月到 150KB(-15%)
边际收益快速递减——200KB 是大多数 Web 项目的合理停止点。继续投入应该明确收益(如"低端设备加载时间从 3s 降到 2.5s")。
9.15.7 反模式:完美主义陷阱
每条都是真实陷阱:
- 完美主义:想做到 100KB 时已经牺牲可读性,下一任接手时改回到 200KB 还乐此不疲
- 数字焦虑:1MB 减到 200KB 是大事,200KB 减到 198KB 不是
- 优先级:用户更需要新功能、更稳定,而不是 .wasm 再小 5KB
工程纪律:体积优化是手段,不是目的。目的是用户体验、业务效果。任何时候这两者出现冲突,业务和体验优先。
9.15.8 与其他优化维度的协调
优化维度有时互斥——例如 LTO 减小体积但增加编译时间,wee_alloc 减体积但减性能,no_std 减体积但减可维护性。决策应基于业务需求,不是单一维度的极致。
把"体积优化"放在更大的工程优化框架中——它是一项重要但非唯一的指标。这种平衡观念让团队避免在某个维度的极致优化中迷失。
9.16 不同语言编译到 WASM 的体积对比
WASM 是字节码——但不同源语言编译出的 .wasm 体积差异显著。这一节对比主流语言的编译产物特征,帮助选语言时考虑体积维度。
9.16.1 同等功能下的语言对比
实测:实现一个 SHA-256 哈希函数 + 简单 HTTP 处理,编译到 WASM:
| 源语言 | 未优化 .wasm | 优化后 .wasm | 优化手段 |
|---|---|---|---|
| Rust | 200 KB | 50 KB | wasm-opt + LTO + 优化 profile |
| C/C++ (Emscripten) | 800 KB | 60 KB | -Oz + closure compiler |
| AssemblyScript | 30 KB | 15 KB | --optimizeLevel 3 |
| Go | 1.5 MB | 1.2 MB | TinyGo(Go 标准编译器更大) |
| Python (Pyodide) | 4 MB+ | 4 MB+ | 含完整 CPython 运行时 |
| Java (TeaVM) | 500 KB | 150 KB | TeaVM 优化 |
观察:
- AssemblyScript 体积最小——专为 WASM 设计
- Rust 与 C/C++ 接近——LLVM 后端
- Go 较大——标准库带 GC 等
- Python 巨大——CPython 不是为 WASM 设计
9.16.2 各语言的体积来源分析
每种语言的体积"地板"不同——某些语言天生大(带运行时),某些天生小(直接编译)。
9.16.3 选语言的体积考量
9.16.4 AssemblyScript 的体积优势
typescript
// AssemblyScript - TypeScript 子集,专为 WASM 设计
export function add(a: i32, b: i32): i32 {
return a + b;
}编译后:500 字节 .wasm。同样的 Rust 代码(带 wasm-bindgen)至少 5KB。
AssemblyScript 的优势:
- 无运行时(直接编译为 WASM 指令)
- 无 GC(用引用计数)
- 类型系统设计为 WASM 友好
代价:生态远不如 Rust,复杂项目维护性差。
9.16.5 Rust 的体积工程
Rust 是体积优化最讲究的语言(前面章节涵盖)——典型项目可优化到 20-100KB 范围:
所有手段叠加可减 70-90% 体积——但需要工程投入。
9.16.6 C/C++ 的优势与陷阱
C/C++ 的 WASM 编译靠 Emscripten——优势:现有代码库直接编译。陷阱:默认带很多 POSIX shim,体积大。
bash
# 优化的 Emscripten 命令
emcc input.c \
-Oz \ # 体积优先
-s WASM=1 \ # 输出 WASM
-s EXPORTED_FUNCTIONS='["_main"]' \
-s SIDE_MODULE=2 \ # 不带运行时(极小)
-o output.wasmSIDE_MODULE=2 是关键——不带 Emscripten 的 polyfill,输出极小但功能受限(无 stdio 等)。
9.16.7 Go 的特殊处境
Go 的标准编译器产出的 WASM 太大——生产几乎只用 TinyGo。但 TinyGo 有兼容性限制——某些 Go 标准库用不了。
9.16.8 Python 的现实
Python 的 WASM(Pyodide)实际是"在 WASM 里跑 CPython 解释器"——所以体积是 4MB+。无法显著优化。
如果体积敏感,Python 不是合适选择——除非接受这是"只能在 PoC 用"的限制。
9.16.9 多语言项目的体积策略
如果项目多语言并存,让 Rust/C++ 写核心模块(小),Python 写非热路径(大但不影响体验)——这种分层让多语言的体积代价可控。
9.16.10 体积选语言的工程清单
体积是技术选型的重要维度——这套清单帮助避免"选了 Go 才发现体积不可接受"的尴尬。
把这套体积特征表 + 决策清单放进项目立项文档,技术选型从一开始就考虑体积维度。
9.17 跨书关联:与 Rust 编译器优化的对照
WASM 的体积优化手段和《Rust 编译器源码解析》第 14 章描述的 LLVM 优化 pass 有直接的对应关系:
- panic = "abort" 对应 LLVM 的
-disable-fp-elim选项的关闭——停止生成帧指针和 unwind 信息 - LTO 对应 LLVM 的
lto.cpp中的 ThinLTO 和 FullLTO 实现——跨模块的 IPO(Inter-Procedural Optimization) - codegen-units = 1 对应 LLVM 的模块分区策略——单分区允许全局的寄存器分配和指令调度
- wasm-opt -Oz 对应 Binaryen 的优化 pass——这是 LLVM 之外的独立优化器,利用 WASM 二进制格式的特殊性质(栈式指令、类型化操作数)做 LLVM 无法做的优化
理解底层原理有助于做出正确的优化决策——知道每个选项"为什么有效",而不是"照抄配置"。
下一章从体积转向速度——WASM 运行时的性能特征。