独立游戏的 WebAssembly 包体优化:从 50MB 压缩到 5MB 的工程学 WebAssembly Bundle Size Optimization for Indie Games
玩家不是在下载你的游戏,他是在等一个网页——包体优化是 Wasm 游戏的第一道生死线
为什么包体是 Wasm 游戏的第一块基石
说个我亲历的场景。几年前一个朋友的像素游戏上线 itch.io,封面做得漂亮,第一天带来两千多次点击,留存惨不忍睹。他以为是玩法不够抓人,把新手教程改了三版,加了开场动画,还重做了抽卡演出。后来我拿手机热点点了他的链接:黑屏,屏幕中间一行小字「Downloading 38.4 MB」,进度条在 7% 的位置趴了十几秒。我自己也按了返回键——和他九成访客做了同一个动作。
浏览器里没有「安装」这回事。玩家不是在下载你的游戏,他是在等一个网页。这个认知差别,决定了 Wasm 游戏和 Steam 游戏是两种完全不同的生物:原生游戏的首包体积只影响一次性的下载时长,而 Wasm 游戏的体积直接写在你和玩家之间的信任账户上,冷启动慢一次,他就再也不来了。
更扎心的是分配问题。很多独立团队会把三个月砸在把帧率从 45 提到 60,却没意识到六成玩家根本没等到第一帧。你优化的是那个已经进来的四成人,而包体优化决定的是剩下六成人有没有机会进这个门。这笔账算不过来,后面所有技术投入都打折。
这篇要给的是一条从 50MB 到 5MB 的可执行路径。它不是一个「记得开压缩」的清单,而是四层结构:先解剖那 50MB 里装了什么,再谈编译器和链接器能替你砍掉多少,然后过一遍传输管道上的压缩与缓存账,最后拆包——把「一次性整包」改成「按需取用」。每一层我都给出可量的参数区间和踩过的坑。
先泼一盆冷水:这活儿没有一劳永逸的开关。你会看到一堆「据说能省一半」的手段,实测下来只省 8%。真正能从上往下砍掉一个量级的,永远是架构级的决定,而不是工具链的档位。
第 1 层:先解剖——那 50MB 到底装了什么
要砍体积,第一件事是别猜。我见过太多人打开构建产物目录,看到 wasm 文件 50MB,第一反应是「代码太多了」,然后去删注释——注释在编译产物里一个字节都没有。方向错了,后面全白干。
得先理解一个结构性事实:Wasm 是静态链接的终点站。JavaScript 你可以按需拉模块,一个页面用到哪个 chunk 才拉哪个。Wasm 默认是反过来的:所有被你链接进来的东西,会在玩家第一次点击时,全部打包塞进他的浏览器。没有「按需」「懒加载函数」这种奢侈。这个差异是后面所有痛苦的源头。
那 50MB 一般摊在这五个抽屉里:
| 抽屉 | 典型来源 | 在总体积里的常见占比 |
|---|---|---|
| 引擎运行时 | 渲染后端、物理、音频混音、脚本 VM、UI 系统 | 30%~50% |
| 标准库与第三方库 | C++ 标准库、压缩库、字体栅格化、图片解码、数学库 | 10%~25% |
| 你的游戏逻辑 | 玩法代码、关卡脚本、状态机 | 5%~15% |
| 元数据 | 函数名表、调试信息、类型信息、异常表、静态初始化表 | 0%~60%(看构建配置) |
| 资源 | 首包贴图、音频、字体、关卡数据 | 视引擎而定,常常是大头 |
注意最后一行的括号。元数据那一栏的波动是所有抽屉里最离谱的:同一个项目,debug 构建和 release 构建能差三倍体积,代码一行没改。调试信息(DWARF,记录源码行号、变量名的元数据)在没剥离的情况下经常占四成以上,函数名表再吃掉百分之几到十几个点。玩家不会用到这些东西,你的构建脚本却可能默认把它们全带上。
第二个容易被忽略的胖子是抽象税。C++ 的模板是编译期展开的,每个不同的类型组合都会生成一份独立代码。你随手用了一个字符串格式化库、一个正则库、一套输入输出流,体积轻松几十上百 KB 起步。更狠的是静态初始化:每一个全局对象都要有一个构造函数,而这些构造函数编译器不敢删——它们有副作用。于是你的二进制里躺着一大堆「程序还没开始跑,就得先执行完」的代码,玩家永远感知不到它们的存在,但它们确实占着字节。
第三层是玩家侧的体感错位。体积大不只是「下载久」。下载是线性的,可以预期,进度条走得稳;编译是看脸的,取决于设备。低端安卓机上,一个十兆的 Wasm 模块编译可能要一两秒,而且这期间主线程被占着,页面会僵。所以你的体感公式大概是:等待时间 = 下载时间 + 解码时间 + 编译时间 + 初始化时间,四项各有各的锅,别只盯着第一项。
给个粗略的参考:移动网络下有效带宽按 5~8 Mbps 算,30MB 的包体纯下载就是 30 秒往上,还没算上服务端排队、TLS 握手、DNS。而 itch.io 这类平台玩家是随手点开的,心理预算大概是「八秒没动静就换下一个」。所以你真正的时间预算是 8 秒,不是 30 秒。这就是为什么我说 5MB 不是审美追求,是算术结果。
动手之前先建一张表:把你项目当前的产物按上面五个抽屉拆一遍,每个抽屉填上实测字节数。没有这张表,后面所有优化都是瞎砍——你会花两天剥调试信息,结果发现自己这个项目最大的问题是把 20MB 的音频塞进了首包。
第 2 层:动刀——编译器和链接器能替你砍掉多少
体积裁剪有三个战场,性价比从高到低排:链接期 > 二进制期 > 源码期。很多人反过来做,先去抠源码,其实最肥的肉在链接器那边。
链接期是性价比之王。核心动作是让链接器知道「这个函数没人用」。做法是函数级分节(每个函数单独放一段),再开链接期垃圾回收,同时配 LTO(跨模块的全局优化)。这套组合的效果,在一个典型的 C++ 引擎项目里通常能砍 15%~35%,而且几乎零风险——因为它只是删掉了本来就没被引用到的东西,不改变行为。我见过不少独立项目压根没开这几项,白扔掉三分之一体积。
再加一个常被忽视的开关:符号可见性。默认情况下,很多符号是「外部可见」的,链接器不敢断定它不被用,只能保守保留。把内部实现标记成隐藏,链接器的判断就能更狠。
然后是二进制期,也就是 wasm-opt 这个工具干的活。它本质上是把 Wasm 再当成中间表示优化一遍。关于档位,说几句实在话:
| 优化档位 | 取向 | 典型体积变化 | 什么时候用 |
|---|---|---|---|
| 不优化 / 最低档 | 只做必要整理 | 基准 | 调试期 |
| 均衡档 | 性能与体积折中 | 比基准小 5%~12% | 默认起点 |
| 性能优先档 | 多内联、多展开 | 可能比均衡档更大 | 体积不敏感时 |
| 体积优先档(-Os) | 偏体积 | 比均衡档小 3%~10% | Release 常选 |
| 极致体积档(-Oz) | 能不内联就不内联 | 再小 2%~8% | 体积吃紧时 |
这里有个反直觉的地方:如果你的产物本来就是用最激进的体积档编出来的,再跑一遍 wasm-opt 通常只有 3%~10% 的收益,不要指望它当救命稻草。反过来,如果一个项目一直用性能优先档构建,切到体积档加 wasm-opt,可能直接掉 20%。所以「wasm-opt 能省多少」这个问题没有统一答案,答案取决于你之前有多浪费。
还有个坑:极致体积档偶尔会让二进制变大。因为它关掉了一些函数复制与去重相关的优化,重复的字节模式变多了。别信档位,做 A/B,看实测。
现在说你大概最关心的那个问题:为什么我明明没用到物理引擎,物理引擎还在包里?这就是死代码消除(DCE,把所有「不可能被执行到」的代码删掉)的死角——函数表。
Wasm 里调用函数有两种方式:直接调用(目标写死在指令里)和间接调用(走一张函数表,按索引取)。直接调用的目标,优化器一清二楚,没人调就能删。但只要一个函数的地址被塞进了函数表,优化器就失去了推理能力——它无法证明这个索引不会被某个运行时计算出来的值命中,只能举手投降,把整个表里的函数全留下。函数表是体积的黑洞。
而游戏代码里到处都是把函数地址塞进表里的模式:组件工厂注册表、事件回调表、脚本绑定表、序列化类型表、状态机转移表。这些模式写起来舒服——新增一个类型,往注册表加一行就行;但它们把可裁剪性彻底交付出去了。这就是为什么很多现代 C++ 引擎库的「Hello World」就能有十几兆,而 Quake 3 那种老派 C 代码的 Wasm 移植只有一两兆:老派代码没有模板税、没有 RTTI(运行时类型识别,用于类型转换查询)、没有异常、不用标准模板库的字符串和容器,全靠显式的 switch 和函数指针,链接器能把它削得干干净净。这个反差值得每个做引擎选型的人认真看一遍。
案例:Godot 4 的 Web 导出模板。Godot 的 Web 导出体积被社区骂了很久,一个空 2D 项目导出来,未压缩的 Wasm 也在二十兆量级,压缩后五到八兆。原因不神秘:官方发布的标准导出模板是「全都要」——3D 渲染后端、物理、导航网格、XR、各种平台适配层全编译在里面。Godot 4.3 之后给出了自定义构建模板这条路(官方叫 build profile),你可以在编译引擎时勾掉用不到的模块,只保留 2D 相关的那部分。社区实测砍掉一半以上是常态。这个设计意图很清楚:把「可裁剪性」这个决定权交还给开发者,代价是你得自己装编译环境、维护一条引擎构建流水线。绝大多数 Web 小游戏压根用不到 3D 和 XR,但默认模板全带着——这就是「默认值不是为你的项目设的」这句话最典型的例子。
案例:Unity WebGL 的剥离等级。Unity 这边的路径完全不同:它先用 IL2CPP 把托管代码转成 C++,再交给 C++ 工具链编译。所以它体积控制的核心战场同样在 C++ 链接期,靠三件事:托管代码剥离等级、剥离引擎代码开关、以及一张手动维护的保留清单(link.xml)。最后那个文件是 Unity 开发者的集体痛点——因为 Unity 大量依赖反射,反射调用在链接期是「看不见」的引用,剥离器一裁,运行时就会报「某类型不存在」。于是你会看到这类荒诞场面:明明项目里不用任何物理功能,物理模块还是躺在包里,因为某个第三方插件通过反射引用了它。Unity 的解法是让你手动写清单保护,但这份清单没人愿意维护,最后大多数人的选择是「把剥离等级调低一点,稳一点」。稳的代价就是体积。这里我想说的是:反射与体积优化在架构层面是天生对立的,你选了动态派发的灵活性,就得接受裁剪器不敢动你的代码。
所以 DCE 这件事,最终不是一个工具问题,是一个架构原则问题:能被裁掉的前提是「引用关系在编译期就能被完整看见」。少用全局注册,多用显式调用点;必须动态创建的地方,承认它裁不掉,但给它设一个体积预算,超了就要还债。
第 3 层:过管道——Brotli、Gzip 和编译缓存的三角账
代码裁剪完,接下来是把字节塞进管道。这一步经常被简化成「开 Brotli 就完事了」,但里面有三笔账要一起算:压缩率、解压成本、编译缓存。
先看数据。一个已经剥离过的 Wasm 二进制,几种编码下的典型体积:
| 编码 | 相对未压缩 | 服务端 CPU | 浏览器解压 | 备注 |
|---|---|---|---|---|
| 不压缩 | 100% | 无 | 无 | 仅在本地调试用 |
| Gzip 最高档 | 28%~33% | 低 | 快 | 兼容性兜底 |
| Brotli 高质量档 | 22%~27% | 高(预压缩可摊掉) | 中 | 主流选择 |
| Brotli + 内建静态字典 | 21%~26% | 同上 | 中 | 对 Wasm 收益有限,字典是给文本设计的 |
这里有个反直觉现象,我踩过一次:你把调试信息剥离之后,压缩率会变差。比如原来 50MB 压到 10MB(20%),剥离后 20MB 压到 6MB(30%)——比例难看了,但绝对体积少了四兆。为什么?因为调试信息重复度极高,压缩器极爱吃;剥掉之后剩下的全是高熵的指令流,压缩器咬不动。所以别拿「压缩比」当北极星指标,看的是压缩后的字节数。这个坑我跟人解释过不止一次。
再说编译缓存。这是整个包体优化里最被低估的杠杆。主流浏览器会把编译好的 Wasm 机器码缓存到本地(V8 那一套叫 code cache),玩家第二次打开你的游戏,下载可能还要一点,但编译几乎白给,直接秒开。这意味着你的指标必须拆成两个:首次访问体验和回访体验。首次访问的大头是下载和编译,回访的大头几乎只剩网络往返。
但缓存有个娇贵的脾气:它跟资源的 URL 和内容绑定,内容差一个字节,缓存就作废,全部重编译。于是产生了一个很多人想不到的取舍——压缩参数的调整会让所有老玩家的编译缓存失效。你今天为了省 200KB 改了下压缩档,明天几十万回访玩家集体重编译。所以压缩策略定下来就别乱动,跟着大版本发;平时的小更新走资源层,别碰模块本体。
还有一个隐形杀手:流式编译与 MIME 类型。浏览器支持边下边编译,但这个能力有前提条件——服务端必须把 Wasm 以正确的类型(application/wasm)返回,并且压缩是在 HTTP 编码层声明的,让浏览器先解一层再喂给编译器。如果 CDN 把它当成二进制流返回,浏览器就只能先整包下载、再拷贝进内存、再编译,多一次内存拷贝,慢且吃内存。这个配置错误我在三个项目上见过,每次都是「以为是代码问题,查了两天才发现是 CDN 的头写错了」。
另外提醒一个双重压缩的经典事故:你已经在构建时生成了预压缩的文件,CDN 那头又开了一层自动压缩,结果体积不降反升,甚至解压出错。上线前用命令行工具确认一下响应头,五分钟的事。
最后说个念想,别抱太大希望:业界讨论过给 Wasm 训练专用的 Brotli 字典,实测收益也就 2%~5%。而且共享字典牵扯跨站点指纹与隐私,浏览器厂商很谨慎。这条路短期内指望不上,老老实实把裁剪做好,比等新特性靠谱得多。
第 4 层:拆包——把「一次性整包」变成「按需取用」
前三层都是在「把整包变小」,这一层是改游戏规则:不一定要一次给完。
前面说过,Wasm 单模块不支持懒加载函数。那代码分割在 Wasm 里到底可不可行?可行,但要付工程代价。我把手段按「收益/风险比」从高到低排一下,你可以照着优先级走。
第一优先:资源分离。这是收益最大、风险最低的一刀,而且很多项目压根没做。你打开产物目录,会发现那个「Wasm 包体」其实包含或者紧挨着一大坨资源:首包贴图、音频、字体、关卡数据。把它们从主模块里剥出去,主模块只留启动必需的一小份,其余的走 CDN 按需拉。Unity 有 Addressables,Godot 的 .pck 可以分卷,自研引擎就自己写一个虚拟文件系统按需挂载。这一步的收益常常比前面所有代码裁剪加起来还大——因为资源是熵最低的东西,压缩率好,而且天然可以延后。
第二优先:渐进式首屏。思路很简单:先用一个极小的启动器(几百 KB 量级)把加载动画、设置菜单、语言选择、资源预取这些跑起来,玩家两秒内看到画面,主模块在后台实例化。体感上,等待从「三十秒白屏」变成了「两秒进菜单,然后转个圈」。这不是自欺欺人——人对「有反馈的等待」和「无反馈的等待」的容忍度差好几倍。这是我在实际项目里见过回报最高的一个小改动。
第三优先:模块拆分。这才是硬核的部分。社区里有工具能做「基于运行时采样」的模块切分:先跑一遍游戏,记录哪些函数是热路径,然后把冷函数拆到二级模块里,主模块对应位置留一个占位桩;玩家第一次触发到那个功能时,再去把二级模块拉下来接上。适合什么?编辑器功能、存档兼容层、过场动画的脚本逻辑、教学关的特效——启动时用不到,但一旦用到就得在。代价也很实在:跨模块的接口要手工维护,共享内存得显式声明成外部导入,跨模块调用的开销比模块内高,加载失败还得有一套兜底方案(降级、提示重试、或者干脆把功能关掉)。这套东西值得做的前提是:你有一个明确的、体积占比 20% 以上的「冷功能块」。
第四优先:多份构建。按模式或关卡出不同的构建产物,比如 demo 版和完整版各一份。维护成本飙升,独立团队慎用,但如果你的 demo 是用来拉新的,那它值得单独优化一次——demo 的体积阈值比正作严苛得多。
案例续:Godot 单线程模板与多线程模板。Godot 的 Web 导出分单线程和多线程两套模板,多线程那套体积明显更大,因为它要带上一整套线程支持与共享内存的基础设施。如果你的游戏不需要并行物理或后台资源解码,选单线程模板就能白省几兆。这是个特别典型的「默认值陷阱」:多数教程都让你开多线程(性能更好嘛),但代价藏在体积里,而体积是你最缺的那个资源。同类现象在 Unity 那边是另一副面孔——启用线程支持会连带影响服务端的安全响应头配置,配不好整个功能都用不了,回退又是一次决策。
把四层走完,我们做个账。假设起点是一个 50MB 未压缩、带完整调试信息、资源全塞首包的工程,目标 5MB(Brotli 后):
| 动作 | 典型节省 | 风险 |
|---|---|---|
| 剥离调试信息与函数名表 | 15~25MB | 极低(记得归档符号文件) |
| 开链接期优化与垃圾回收 | 3~6MB | 低 |
| 关掉异常与运行时类型识别 | 1~3MB | 中(要把依赖它们的代码改掉) |
| 引擎模块裁剪(自定义构建) | 3~8MB | 中(要维护构建流水线) |
| 资源分离出首包 | 看项目,常见 5~20MB | 低 |
| 体积档 + 二进制优化 | 1~3MB | 低(要做 A/B) |
| Brotli 预压缩 | 把剩余压到 22%~27% | 低(注意服务端配置) |
你会发现最猛的那一刀(剥调试信息)几乎不要成本,而所有人都在讨论的 wasm-opt 只是最后一勺子。顺序搞反,时间全浪费。
参数化设计工具:把体积当预算来管
优化做到最后,靠的不是激情,是预算表。我建议每个 Wasm 项目在 CI 里挂一张这样的表,每次构建自动填,超预算就报警。下面三张表可以直接抄。
表 A:5MB 目标的预算分配(Brotli 后)
| 科目 | 预算 | 超支时的第一反应 |
|---|---|---|
| 引擎/运行时核心 | 1.8MB | 换自定义构建模板,砍模块 |
| 标准库 + 第三方库 | 1.0MB | 查链接期开关是否全开,换轻量替代品 |
| 游戏逻辑 | 1.2MB | 查注册表模式的滥用 |
| 启动器与 UI 壳 | 0.3MB | 拆成独立小模块先行加载 |
| 预留给版本迭代 | 0.7MB | 不许动,动了就代表你下次必超 |
表 B:手段收益区间与代价
| 手段 | 体积收益区间 | 代价 | 推荐度 |
|---|---|---|---|
| 剥离调试信息/名字节 | 20%~50% | 线上崩溃定位变难,需归档符号 | 必做 |
| 链接期垃圾回收 + 跨模块优化 | 15%~35% | 构建时间变长 | 必做 |
| 隐藏内部符号可见性 | 3%~10% | 改编译配置,可能触发链接错误 | 强烈推荐 |
| 关闭异常与类型识别 | 5%~15% | 要改代码,第三方库可能不兼容 | 推荐 |
| 引擎模块裁剪 | 30%~55% | 维护引擎构建流水线 | 中型以上项目推荐 |
| 资源移出首包 | 看项目,常见 30%~60% | 要设计加载与失败重试 | 必做 |
| 体积档 + 二进制优化 | 5%~20% | 可能损失少量运行性能 | 推荐 |
| 模块拆分(冷功能) | 10%~25% | 接口维护 + 兜底逻辑 | 按需 |
表 C:冷启动看板指标(建议每周看一次)
| 指标 | 健康区间 | 为什么看它 |
|---|---|---|
| 首次访问总下载量(压缩后) | < 5MB(移动端)/ < 15MB(桌面) | 直接决定跳出率 |
| 首字节到首帧的时间 | < 8 秒(4G 网络) | 玩家的心理预算线 |
| 编译耗时(中端安卓) | < 1.5 秒 | 常被忽略的隐形成本 |
| 回访命中编译缓存比例 | > 80% | 衡量压缩策略是否稳定 |
| 首包中资源占比 | < 30% | 超了说明资源没分离 |
最后提一个容易被忽略的科目:JavaScript 胶水层。不管你是 Emscripten 还是 Rust 那套绑定工具,生成的那份 JS 有时也有几百 KB,压缩后几十 KB 到一百多 KB。别当它不存在,它跟 Wasm 一起算进首屏预算里。
双路径:你该从哪儿开始
初级路径:一周内能出效果的五件事。如果你现在手头就有一个体积失控的项目,别慌,按顺序做,一周能砍掉大半。
第一,把构建配置切成 release,确认调试信息真的没进产物——这一步经常就省掉一半。第二,打开链接期垃圾回收、函数级分节、跨模块优化这三件套,重编一次,量一下。第三,找一遍那个最蠢的问题:有没有把大音频、大贴图直接打进首包?有就挪出去。第四,用最高质量的 Brotli 预压缩,服务端设好编码头,验证浏览器确实走了流式编译。第五,用 wasm-opt 跑体积档,和上一版做 A/B 对比。
这五步走完,通常能从 50MB 掉到 10MB 上下。你已经赢过一大半同类项目了。
进阶路径:把体积变成一条不可突破的线。做到某个阶段你会发现,光靠事后优化是追不上代码增长的——每加一个功能就胖一点,半年后又回去了。这时候要做的是制度化。
具体三件事。一是把上面那张预算表写进 CI,每次构建比对基线,超阈值直接红灯,谁提交谁负责。二是把「裁剪友好」写进代码规范:新增全局注册表要写理由,新增反射式动态创建要在清单里登记。三是把调试符号归档做成流程,每次发版自动把符号文件传到私有存储,崩溃报告里能还原调用栈——这样你才敢放心剥掉调试信息。四是给资源管线加上「首包预算」,美术出资源的时候就按体积配额来,别等打完包再砍。
这套制度听着很重,其实落地就是几个脚本加一张表。真正难的是那句潜台词:你得接受体积和玩法之间存在一次真实的交换。想多放三个音轨,就得砍掉某个特效;想上一个全屏后处理,就得换个更小的字体库。这个账,做独立游戏的人应该最熟。
争议与常见坑
坑一:只算 Wasm,不算总账。最常见的自欺欺人。你的 Wasm 只有 4MB,看着很漂亮,但资源包另算 30MB,加起来还是慢。指标要定在「玩家完成首次进游戏所消耗的全部字节」,不是某个文件的大小。
坑二:剥了调试信息,线上崩溃就抓瞎。我翻过这车。上线后一个只在特定机型复现的崩溃,报告里全是地址偏移,还原不出函数名,查了三天。解法不难,但要提前做:每次发版把符号文件按版本号归档,配一个符号化的脚本。省下来的几个兆,值不值三天的排查时间,你自己权衡——但那三天真的很难熬。
坑三:过度裁剪,只有某一关崩。剥离器和垃圾回收都是按「引用可见性」判断的,反射、动态加载、拼字符串构造的类型名,它们看不见。你以为删掉的是死代码,其实是第 27 关的 Boss 战逻辑。安全做法是:每次裁剪后跑一遍全流程冒烟测试,别只测第一关。
坑四:迷信档位,不信实测。「-Oz 一定最小」「-O4 一定最快」这类说法,在具体项目上经常翻车。二进制优化不是单调的,不同编译器版本、不同代码结构,结果能差一截。花半天做一次 A/B,比看十篇经验帖有用。
再说一个真争议:5MB 是不是伪目标?我的观点是有条件的真目标。桌面宽带用户,20MB 也能忍;但 Web 游戏的流量大头在移动端和社交平台的随手点开,那里的心理预算是八秒,5MB 是能算出来的数。更现实的做法是分平台定阈值:移动端卡死 5~8MB,桌面放宽到 15~20MB,然后在首次访问时按设备做降级(低端机拉低清资源包)。
另一个争议是体积优化的投入产出比。一个五人的独立团队,把两周花在包体上,可能让转化率翻倍;但也可能那两周本该用来修一个致命的玩法缺陷。这笔账没有标准答案,但有个判断依据:如果你的项目在平台上跳出率高于 60%,先去查包体;如果玩家都能进来但玩十分钟就退,那问题不在体积,别逃避。
FAQ
Q1:我的 Wasm 只有 3MB,为什么玩家还抱怨加载慢?
先看总下载量,八成是资源包在背后拖后腿。排除了资源之后看三件事:一是服务端有没有正确声明 Wasm 的 MIME 类型,配错了浏览器就走不了流式编译,得多一次内存拷贝;二是 CDN 是不是在已经预压缩的文件上又压了一遍;三是首次访问和回访的差距有多大——如果回访也很慢,说明编译缓存没生效,可能是资源 URL 每次发版都变,或者响应头里有禁止缓存的字段。
Q2:wasm-opt 的档位到底怎么选,能不能无脑用最强体积档?
不能。体积档会牺牲内联,某些数值密集的代码可能慢 5%~15%。而且它在已经用体积档编译出来的产物上收益很小,偶尔还会因为少了去重优化导致体积反涨。正确做法是拿三个档位各构建一次,实测体积和关键场景的帧时间,选那个「体积达标且帧率没掉出预算」的。这是个工程取舍,不是查表题。
Q3:为什么我把调试信息剥了,压缩率反而变差了?
因为调试信息重复度极高,压缩器特别爱吃;剥掉之后剩下的全是高熵的指令流。压缩比下降不代表你做错了,你要看的是压缩后的绝对字节数。这个现象在数据压缩里很普遍,别被百分比骗了。
Q4:能不能让 Wasm 只加载用到的函数?
单模块做不到,这不是工具不够好,是 Wasm 的模块模型就是这样:实例化就是整模块。要么拆成多个模块(共享内存要显式声明、接口手工维护),要么把「不常用的大块」做成二级模块按需拉。前者成本高,后者有现成工具支持但要设计兜底。大多数项目的正确顺序是:先把资源分离出去,再考虑拆代码。
Q5:手机上 Brotli 解压会不会卡?
会慢一点,但通常不是瓶颈。解压是几十毫秒级,编译是几百毫秒到几秒级,差一个数量级。别为了省这点解压时间去换 Gzip,丢掉的 5%~8% 体积更值钱。真要抠,去抠编译时间和首屏结构。
最后抛个问题给你:如果你的游戏必须在 5MB 里跑起来,你会砍掉哪个系统?我猜大多数人第一反应是音效,第二是粒子。但说句实话,这两样恰恰是「廉价的高级感」。真正该被重新审视的,往往是那些你从模板里继承下来、从没质疑过要不要的东西——那个内置的关卡编辑器、那套没人用的多语言字体、那个「万一以后要加」的联网模块。体积优化的终点,其实是重新问一遍「这个游戏到底需要什么」。