把 4MB 关卡包塞进构建流程:一次 Vite 插件踩坑记
译注:本文编译自 indiecore.net 博客系列《构建一个单词方块解谜引擎》第 6 篇,原文发布于 dev.to,作者记录了如何用一个 Vite 插件把 4.37 MB 的关卡包压缩到 2.36 MB,以及过程中踩到的两个钩子陷阱。原文链接见文末。
仓库里好看,商店里精简
这个关卡包由 395 个 JSON 文件组成:392 个棋盘、一份目录、一份索引,以及一份构建记录。在仓库磁盘上它占 4.37 MB,而我第一次上架商店的 App 里,每一个字节都被打了进去。
但这些字节本不必全部存在。
棋盘文件从生成器里出来时是格式化过的,也应该如此——它们要在 PR 里被审查、在生成器变更时被 diff、在某个关卡出问题时被人工阅读。一个压缩成 11 KB 单行的棋盘文件,没人看得下去。
但运行时根本不读那些空白字符。它占了整个包的 45%:
stage pack 4.37 MB -> 2.36 MB所以压缩发生在进入构建产物的路上,而不是在 public/ 里。仓库里的副本保持可 diff,发布出去的副本每份只有一行,没人需要记住哪个是哪个。
顺带一提,插件还会删掉 index.json——那是生成器自己的构建记录,92 KB。游戏只会去取 catalog.json 和单个棋盘。为了确认这一点,我把整个引擎 grep 了一遍,这也提醒我们:“这些文件里,App 到底真正打开了哪几个”,是任何内容目录都值得问一遍的问题。
钩子选错,错误被掩盖
插件最初挂在 closeBundle 上。这是错的,而且错了两次。
第一次失败很平常:Vite 可能从磁盘上别处的临时文件加载配置,于是 import.meta.dirname 指向了 node_modules/.vite-temp/。输出目录必须从解析后的配置里取,也就是通过 configResolved。
第二次失败则实实在在浪费了时间。closeBundle 在构建失败时也会触发。于是一个真实的错误——一个无法解析的 import——把 bundle 拆掉了,我的钩子对着一个从未创建过的 dist/ 跑了起来,最后只打印出:
[shrink-stage-pack] ENOENT: no such file or directory,
scandir '…/dist/stages-blocks-en'我花了二十分钟调试这个插件。插件本身没问题,它只是站在了真正错误的上面。
writeBundle 才是正确的钩子,它同时修好了这两个问题:它只在成功写入后运行,而 Vite 会在写入阶段之前把 publicDir 复制进 outDir,所以文件已经就位。
这个 bug 的通用形态值得记住:一个会在失败路径上运行、并且会抛错的清理钩子,会用自身的错误替换掉所有真实错误。 如果你要写这样的钩子,要么加保护,要么选一个在出错时不会运行的钩子。
完整插件(含两处修复):
// Shrink a content pack on its way into dist/.
//
// The level files come out of the generator pretty-printed, and they should:
// they are reviewed in pull requests, diffed when the generator changes, and
// read by hand when a level plays wrong. But nothing reads that whitespace at
// runtime, and it is 45% of the pack — 4.37 MB down to 2.36 MB.
//
// This runs over dist/, never over public/, so the repo copies stay diffable
// and the shipped copies are one line each.
//
// TWO THINGS THAT LOOK LIKE DETAILS AND ARE NOT:
//
// 1. `writeBundle`, not `closeBundle`. closeBundle ALSO fires when the build
// failed, so a cleanup hook there will throw its own ENOENT about a dist/
// that was never created — and replace the real error with it. Vite copies
// publicDir into outDir *before* the write phase, so at writeBundle the
// files are already there.
//
// 2. outDir comes from `configResolved`, not `import.meta`. Vite may load a
// config from a temp file elsewhere on disk, which makes import.meta.dirname
// point at node_modules/.vite-temp/.
import { readdirSync, readFileSync, writeFileSync, statSync, rmSync } from 'node:fs';
import { join } from 'node:path';
/**
* @param {{ content: { stagesDir: string } }} brand
* Which folder to shrink is the game's to declare, not the plugin's to
* hardcode — otherwise every sibling game carries a copy of this plugin with
* its own folder name baked in.
*/
export const shrinkStagePack = (brand) => {
let outDir = 'dist';
return {
name: 'shrink-stage-pack',
apply: 'build',
configResolved(config) {
outDir = join(config.root, config.build.outDir);
},
writeBundle() {
// stagesDir is the URL the app fetches from ('/levels'), which is
// publicDir-relative, so it lands at this path inside dist/.
const dir = join(outDir, brand.content.stagesDir.replace(/^\//, ''));
let before = 0;
let after = 0;
for (const file of readdirSync(dir)) {
if (!file.endsWith('.json')) continue;
const path = join(dir, file);
before += statSync(path).size;
// The generator's own build record. The game only ever fetches the
// catalogue and individual levels — worth grepping for before you
// delete anything from a content folder.
if (file === 'index.json') {
rmSync(path);
continue;
}
const min = JSON.stringify(JSON.parse(readFileSync(path, 'utf8')));
writeFileSync(path, min);
after += min.length;
}
// Printed on every build. This line is how a pack rebuild that quietly
// shipped 30 extra levels got noticed months later. A number printed on
// every build is a regression test that costs nothing.
const mb = (n) => `${(n / 1024 / 1024).toFixed(2)} MB`;
this.info(`stage pack ${mb(before)} -> ${mb(after)}`);
},
};
};哪个文件夹?问 brand
第一版把包目录硬编码了。一个游戏时没问题,两个游戏时就错了——同门产品在另一个文件夹名下发布了不同语言的包。
插件改为从游戏的 brand.json 里取——也就是 App 启动时交给引擎的同一份声明。一个插件放在共享构建配置里,引擎上的每个游戏都能用上,不必各自带一份把文件夹名写死的副本。
它在下载量里值多少
压缩进 Android App Bundle 后,这个包是 0.61 MB,字典另占 0.33 MB。相对于总共 5.88 MB 的下载量,内容约占 16%。
这个比例才是真正有用的信息。在测量之前,我以为关卡数据是问题所在,代码没问题。结果恰好相反,差了四倍——Android 运行时才是问题,那是第 9 篇的内容。压缩关卡包值得做,但它并不是下载体积的去向。
加载:一次一个棋盘
运行时这边刻意做得很无聊。
启动时游戏取 catalog.json——一个小文件,包含每个棋盘的身份和评分,足以在不碰任何棋盘的情况下构建整个战役顺序。棋盘每个约 11 KB,在关卡打开时才取,然后缓存。
目录是取来的,不是 import 进来的。这比听起来更重要:如果打包进来,重建关卡包后 src/ 里可能残留一份过期副本,失败模式就是战役顺序依据的评分已经和实际玩的棋盘对不上了。
字典有 877 KB,是作为字符串读取的,不做解析。它按单词长度每行打包:
3=aahabaabbabcabyaceactadd…
4=…十行。一个单词的开销正好是它的字母数,查找就是一次偏移加切片,不会在玩家等待时于主线程上构建十万个对象。这个格式来自测量:对等价的 object map 做 JSON.parse 曾是启动过程中最慢的一件事。
我会怎么改
两点。
先测量,再优化那个显而易见的东西。 我先压缩了关卡包,因为它是 du -sh 里最大的数字。但 du -sh 里最大的数字,不等于下载里最大的数字,因为 bundle 里的一切都会被压缩,而 JSON 压缩得极好。省下的 2 MB JSON 只变成了约 0.4 MB 的下载节省。
把体积放进流水线。 插件在每次构建时打印前后对比。正是这一行让我在几个月后注意到,一次关卡包重建悄悄多发布了 30 个没人提过的棋盘。一个每次构建都打印的数字,就是零成本的回归测试。
下一篇: 第 7 篇,测试一个不是你写的关卡包——那套测试会在任何棋盘到达玩家之前,把全部 392 个棋盘走到解。