Skip to content

第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名称内容常见体积占比
1Type函数签名<1%
2Import导入的函数/内存/表1-3%
3Function函数索引到类型的映射<1%
5Memory内存声明<1%
6Global全局变量<1%
7Export导出项1-2%
9Element表的初始化数据1-5%
10Code函数体(指令流)60-80%
11Data静态数据段10-20%
12DataCount数据段计数<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 = 389 KB基线
panic = "abort", opt-level = 352 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(如 serdechrono)包含大量泛型代码,每个 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),实测变化:

阶段体积相对基线
默认 release187 KB100%
+ panic = "abort"112 KB60%
+ opt-level = "z"94 KB50%
+ lto = true72 KB39%
+ codegen-units = 165 KB35%
+ strip = true58 KB31%

9.4 第二层优化:代码层面——消除不必要的依赖

编译选项解决的是"编译器能看到的冗余",但编译器无法消除你主动引入的依赖。这一层的优化需要理解 Rust 的编译模型:每个 use、每个 trait derive、每个泛型实例化都可能拉入新的代码。

9.4.1 避免 String 格式化

format!() / println!() 是体积杀手——它们拉入 core::fmt 模块,这个模块约 5-10KB。core::fmt 不仅是 format! 本身,还包括 DisplayDebugLowerExp 等多个 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一次性分配场景
无分配器0N/AN/A纯计算、不需要堆

如果 Rust 代码不需要堆分配(纯计算函数),可以完全禁用分配器:

rust
#![no_std]

#[wasm_bindgen]
pub fn pure_compute(input: i32) -> i32 {
    input * 2 + 1  // 纯计算,零堆分配
}

#![no_std] 排除整个标准库,只保留 core——体积可降到 1KB 以下。但 no_std 限制了你能用的类型——没有 StringVecHashMap,只能用 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 }

类似地,ClonePartialEqDisplay 都有各自的代码量。在 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:

  1. 重复函数消除(DuplicateFunctionElimination):两个函数如果指令序列完全相同(可能名字不同),合并为一个。这在 Rust 的单态化场景中很常见——不同类型的 Clone::clone 可能生成相同的代码(例如 Vec<u32>Vec<i32> 的 clone 逻辑相同,只是类型签名不同)。

  2. 栈简化(SimplifyLocals):消除不必要的 local.set + local.get 对——如果值在栈上就直接用,不存到局部变量再取回。LLVM 的寄存器分配器生成 WASM 时,局部变量映射可能有冗余——wasm-opt 在二进制层面清理这些冗余。

  3. 代码折叠(CodeFolding):多个基本块如果末尾有相同的指令序列,合并为一个基本块,用跳转指向。这类似于编译器的 tail merging,但 wasm-opt 可以跨函数做。

  4. 死代码消除(DeadCodeElimination):从导出函数出发,标记所有可达代码,删除不可达的函数和数据。LLVM 的 DCE 在单编译单元内工作,wasm-opt 的 DCE 在整个模块内工作——可以消除 LTO 遗漏的死代码。

  5. 数据段优化(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.copymemory.fill 指令替代逐字节的复制/填充循环。一个 memcpy(1024) 的调用,标量实现需要 30-40 条指令,memory.copy 只需 1 条。这在频繁操作大块数据的场景中节省可观的代码体积。

--enable-reference-types 允许 wasm-opt 使用 ref.nullref.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.wasm

DWARF 调试信息包括源码位置映射、变量类型信息等——可以占 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 --times

9.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-integernum-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 -20
Retained 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_jsonserde_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 --cratescargo tree --duplicates 帮你检查的内容。

9.8 真实项目的优化数据

以一个实际的 Rust→WASM 项目为例:一个 Markdown 解析器(类似 pulldown-cmark 的子集),编译到浏览器中使用。

优化步骤体积累计减少说明
默认 release(opt-level=3, panic=unwind)312 KB0%基线
panic = "abort"178 KB-43%消除 unwind 表
opt-level = "z"152 KB-51%体积优先优化
lto = true118 KB-62%跨 crate DCE
codegen-units = 1108 KB-65%更好的单 crate 优化
strip = true98 KB-69%移除名称段
wasm-opt -Oz76 KB-76%二进制级优化
去掉 format!/Debug derive62 KB-80%消除 core::fmt
换 wee_alloc55 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 MB200 KB83%
文档编辑器(首屏只读)800 KB150 KB81%
数据可视化(首屏只展示)600 KB120 KB80%

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/full

JS 根据用户场景加载对应版本——比模式一简单(不需要跨模块通信),但用户切换功能时可能需要重新加载更大的版本。

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/3

12KB 的 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-bindgenstd 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 用 dlmallocwee_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 体积优化的成本结构

工程时间不只是"写代码的时间"——还包括隐性成本:

优化层显性成本隐性成本
层 11 小时配置几乎无
层 21 天审计维护过滤后的依赖
层 330 分钟wasm-opt 升级跟随
层 43 天重构代码可读性下降
层 51 周失去 std 便利,bug 增加
层 62 周类型不安全,维护噩梦
层 71 月业务功能受限

层级越深,每减一字节带来的代价越大——不是简单的时间投入,还包括代码质量、维护性的损失。

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优化手段
Rust200 KB50 KBwasm-opt + LTO + 优化 profile
C/C++ (Emscripten)800 KB60 KB-Oz + closure compiler
AssemblyScript30 KB15 KB--optimizeLevel 3
Go1.5 MB1.2 MBTinyGo(Go 标准编译器更大)
Python (Pyodide)4 MB+4 MB+含完整 CPython 运行时
Java (TeaVM)500 KB150 KBTeaVM 优化

观察:

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

SIDE_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 运行时的性能特征。

基于 VitePress 构建