第19章 - 构建打包与发布
构建是这个项目里最”看起来简单”的部分:vite build 一条命令,输出 apps/site/dist。但真正决定”产物能不能用”的三个决定都不在业务代码里——base: './'、构建期注入的 CSP、以及按关卡切 chunk 的命名规则。这一章从产物结构讲到发布路径,也讲清楚哪些事还没做。
线上试玩地址是 https://znlgis.github.io/mathmindforge/,它托管的就是这份 dist。
19.1 一条命令生成的目录长什么样
pnpm build # 等价于 pnpm --filter @mmf/site build,即 apps/site 下的 vite build
产物落在 apps/site/dist。当前实测 61 个文件 / 1518.9 KB,结构是这样的:
apps/site/dist/
├── index.html
└── assets/
├── index-Cv0-j01P.js # 首屏入口
├── index-B0BnEwRS.css # 首屏样式(Tailwind 产物)
├── ids-2Y72BcFj.js # 常量层,首屏 preload
├── progress-DaN8y-kO.js # 存档层,首屏 preload
├── vendor-phaser-BvwGO2lE.js # 裁剪版 Phaser,220.3 KB gzip
├── level-<id>-<hash>.js # 20 个关卡入口,每关一个
├── content-<id>-<hash>.js # 20 份关卡详情,每关一个
├── pattern-<hash>.js # 模板级共享 chunk
├── rule-<hash>.js
├── construct-<hash>.js
├── experiment-<hash>.js
├── simulate-<hash>.js
├── argue-<hash>.js
├── WorkshopView-<hash>.js # 路由级懒加载页面
├── BadgesView-<hash>.js
├── JournalView-<hash>.js
├── ReportView-<hash>.js
└── src-<hash>.js / runtime-<hash>.js / schemas-<hash>.js / … # 其余共享 chunk
index.html 里只有四个资源引用,多一个都没有:
<script type="module" crossorigin src="./assets/index-Cv0-j01P.js"></script>
<link rel="modulepreload" crossorigin href="./assets/ids-2Y72BcFj.js">
<link rel="modulepreload" crossorigin href="./assets/progress-DaN8y-kO.js">
<link rel="stylesheet" crossorigin href="./assets/index-B0BnEwRS.css">
注意这里没有 Phaser,也没有任何一关的 chunk。 首屏 69.5 KB gzip 就是这四个文件加内联脚本的总和;vendor-phaser 与 20 个关卡要等路由真正进入画布关卡时才下载。这条”外壳不 import Phaser”的纪律,第 13 章从架构角度讲过,这里从产物角度再确认一次:它是一条可以被 ls dist/assets 验证的事实,不是口号。
19.2 chunk 命名不是默认行为,是手写的规则
默认的 Vite/rolldown 产物名是 assets/[name]-[hash].js,看不出哪块是哪关。apps/site/vite.config.ts 里有一段显式的命名函数:
chunkFileNames(chunk) {
const id = chunk.facadeModuleId ?? '';
const level = /[\\/]games[\\/]forge[\\/]src[\\/]levels[\\/]([^\\/]+)\.ts$/.exec(id);
if (level) return 'assets/level-' + level[1] + '-[hash].js';
const detail = /[\\/]content[\\/]src[\\/]details[\\/]([^\\/]+)\.level\.json$/.exec(id);
if (detail) return 'assets/content-' + detail[1] + '-[hash].js';
if (chunk.name.includes('phaser')) return 'assets/vendor-phaser-[hash].js';
return 'assets/[name]-[hash].js';
}
为什么值得为它多写十行:facadeModuleId 是 chunk 的入口模块路径,只有从它出发才能把”文件名”与”关卡 id”绑定。一旦绑上,排查成本立刻下降——
# 某关的 chunk 到底有没有生成?
ls apps/site/dist/assets | grep 'level-k-data-tally'
# 某关的体积是多少?
ls -l apps/site/dist/assets/level-k-data-tally-*.js
# 首屏里有没有偷偷混进 Phaser?
grep -o 'assets/[^"]*\.js' apps/site/dist/index.html
第 17 章把这三条当作”新关卡登记完成”的自查,就是因为它比读构建日志快得多。
同一个配置块里还有一组显式分组,把 Phaser 单独切出来:
codeSplitting: {
groups: [
{
name: 'vendor-phaser',
test: /node_modules[\\/]phaser[\\/]|canvas-kit[\\/]vendor[\\/]phaser-trim/,
},
],
},
不分组也能构建成功,但 Phaser 会和”第一个 import 它的模板 chunk”绑在一起——那就意味着 pattern 关的 chunk 里躺着 849.5 KB 的引擎,而 rule 关又要再下一份逻辑上相同的代码。chunkSizeWarningLimit 设成 1100 也是配套的:默认 500 会为这个已知且可解释的大 chunk 报一条无意义的警告。
19.3 base: './':子目录、U 盘与离线分发的共同前提
export default defineConfig({
base: './',
// ……
});
base 决定产物里资源引用的前缀。设成 '/' 时,index.html 里会写 /assets/index-xxx.js——只有”站点部署在域名根目录”才成立。设成 './' 后是相对路径,于是同一份 dist 可以:
| 部署形态 | 示例 | 为什么能成立 |
|---|---|---|
| 域名根目录 | https://example.com/ |
相对路径解析到同一层 |
| 子路径 | https://znlgis.github.io/mathmindforge/ |
./assets/… 相对当前目录 |
| 本地直接打开 | file:///…/dist/index.html |
不依赖任何绝对路径 |
| U 盘 / 离线拷贝 | 任意目录 | 同上 |
这也解释了 CSP 里为什么留了 file: 的来源(见 19.4)。同时注意路由是 hash 路由(#/forge、#/play/<id>):hash 不参与服务端路径解析,所以静态托管不需要 try_files 或 404 重写,子路径部署也不用改路由。
本站的实际做法是:把 apps/site/dist 整目录复制为站点仓库根下的 mathmindforge/,即
znlgis.github.io/
├── mathmindforge/
│ ├── index.html
│ └── assets/
└── …(Jekyll 教程站点其余部分)
Jekyll 会把 mathmindforge/ 当静态目录原样拷贝,于是 https://znlgis.github.io/mathmindforge/ 就是游戏入口。因为 base: './'、路由是 hash、CSP 不含绝对源,这个复制动作不需要任何构建参数——反过来说,如果哪天把 base 改回 '/',线上就会白屏,而本地 pnpm preview 仍然正常。
19.4 CSP:只能在构建期注入,且必须由 HTML 解析器插入
生产产物的 CSP 不是写在 apps/site/index.html 里的,而是构建时注入的。策略集中在 apps/site/src/shell/csp.ts:
export const CSP_DIRECTIVES = [
`default-src 'self' file:`,
`script-src 'self' file:`,
`style-src 'self' 'unsafe-inline' file:`,
`img-src 'self' data: blob: file:`,
`font-src 'self' file:`,
`connect-src 'none'`,
`media-src 'self' file:`,
`object-src 'none'`,
`base-uri 'self'`,
`form-action 'none'`,
] as const;
export const CSP_POLICY = CSP_DIRECTIVES.join('; ');
每条指令都有理由,注释里写得很清楚:
style-src允许'unsafe-inline':Vue 的:style与打包后的样式块需要;img-src允许data:/blob::图标与画布资源可能是 data URI,但仍不引外网;file:给”直接打开dist/index.html“的离线分发留后路(file://下'self'是不透明源);- 刻意不放
script-src 'unsafe-inline':产物没有内联脚本,加了等于白送一个 XSS 入口; connect-src 'none':产物零联网,禁掉连接才是对的。
而 dev 不注入:HMR 要连 ws://127.0.0.1:5192,connect-src 'none' 会把热更新打断。所以插件带 apply: 'build':
function cspPlugin(): Plugin {
return {
name: 'mmf-csp',
apply: 'build',
transformIndexHtml: () => [CSP_TAG],
};
}
注入用的是标签描述符,不是字符串替换:
export const CSP_TAG = {
tag: 'meta',
attrs: { 'http-equiv': 'Content-Security-Policy', content: CSP_POLICY },
injectTo: 'head' as const,
};
这个选择背后是一次 P0 事故:早期实现用正则替换 </head>,而 index.html 的注释里恰好写了 </head> 字面量,替换命中注释内部,把 <script type="module"> 和注入的 meta 一起塞进注释、同时吃掉了真正的 </head>——产物白屏,而 dev e2e 全绿。改用描述符之后,插入由 Vite 的 HTML parser 负责,文本层面再怎么写都不会错。csp.test.ts 里那条断言就是钉这个的:
it('交给 Vite 的是标签描述符,而不是自己拼字符串替换 head', () => {
expect(CSP_TAG.tag).toBe('meta');
expect(CSP_TAG.injectTo).toBe('head');
expect(CSP_TAG.attrs['http-equiv']).toBe('Content-Security-Policy');
expect(CSP_TAG.attrs.content).toBe(CSP_POLICY);
});
产物里的样子(index.html 的 <head> 末尾,实体是 HTML 序列化后的正常结果):
<meta http-equiv="Content-Security-Policy" content="default-src 'self' file:; script-src 'self' file:; style-src 'self' 'unsafe-inline' file:; img-src 'self' data: blob: file:; font-src 'self' file:; connect-src 'none'; media-src 'self' file:; object-src 'none'; base-uri 'self'; form-action 'none';">
19.5 体积预算:四类对象,八个上限
scripts/check-size.mjs 的预算是文件顶部的常量,任何一项超限就红:
| 预算项 | 上限 | 当前实测 |
|---|---|---|
| 首屏 JS + CSS(gzip) | ≤ 85 KB | 69.5 KB |
| 首屏 JS + CSS(原始) | ≤ 260 KB | 203.6 KB |
| 单个懒加载 chunk(gzip) | ≤ 280 KB | 220.3 KB(vendor-phaser) |
| 单个懒加载 chunk(原始) | ≤ 1100 KB | 849.5 KB(同上) |
| dist 原始大小 | ≤ 4000 KB | 1518.9 KB(61 个文件) |
| 单个非代码资源 | ≤ 200 KB | 0 个 |
| 非代码资源合计 | ≤ 600 KB | 0 个 |
量法的关键是分类:从 index.html 的 src/href 反解出”首屏引用集”,其余 .js/.css 都是懒加载 chunk,再加内联脚本与样式、非代码资源。只量 dist 总量是不够的——把所有代码塞进首屏、总量不变,用户体验会天差地别。
更前面还有一段产物结构体检,在量体积之前跑:
const htmlWithoutComments = html.replace(/<!--[\s\S]*?-->/g, '');
if (!/<script[^>]+type="module"[^>]+src="[^"]+\.js"/.test(htmlWithoutComments)) {
structural.push('index.html 里没有可执行的 module script(可能被注释掉了)');
}
if (!/<meta[^>]+http-equiv="Content-Security-Policy"/.test(htmlWithoutComments)) {
structural.push('index.html 里没有 CSP meta(可能被注释掉了)');
}
先剥注释再查标签,是那次白屏事故留下的直接产物:白屏的产物体积更小,纯体积门禁永远抓不到它。
超预算时脚本的话很直白:
体积超预算:N 项。要么优化,要么调整预算并写明原因。
“写明原因”不是客套——预算表在 docs/02 §13.2 里维护,改数字必须同时改文档。
19.6 preview 与 e2e:dist:唯一能验证生产注入的路径
vite preview 起在 5193,读的就是刚构建的 dist:
preview: { host: '127.0.0.1', port: 5193, strictPort: true },
playwright.preview.config.ts 与开发配置只差 baseURL 和 webServer:前者指向 5193 并跑 pnpm preview,后者指向 5192 并跑 pnpm dev。测试文件是同一套 e2e/*.spec.ts。
pnpm e2e:dist # = pnpm build && playwright test --config playwright.preview.config.ts
它验证的是三条 dev 环境测不到的东西:
- 构建期 CSP 注入后的页面是否还能加载——dev 没有 CSP,
connect-src 'none'也不会生效; - 相对路径的 chunk 是否都能 404 之外地被解析——
base: './'配preview时资源在子路径下; - 懒加载是否真的懒——关卡 chunk 只在进入关卡时请求,首屏不预取。
这三条恰好是”本地全绿、线上白屏”的经典藏身处。所以 docs/09 的阻断规则里写得很硬:
pnpm verify/pnpm e2e/pnpm e2e:dist任一失败(三条都要跑:dev e2e 测不到生产注入)。
19.7 release.yml:现在实际做什么、什么还没跑过
发布流水线在 .github/workflows/release.yml,触发条件是 v* tag,也支持手动 workflow_dispatch:
on:
push:
tags:
- 'v*'
workflow_dispatch:
文件开头的注释先划清边界:
# 打 tag 即发布 Web 静态产物;也可以在 Actions 页面手动 Run workflow,只出产物不发 Release。
# 本项目尚未构建 Tauri/Capacitor 原生壳(缺 Rust 与 Android SDK),因此不会伪造 EXE/APK。
它有四个 job:version(从 tag 解析 SemVer,非 tag 时用 0.0.0-dev)、web(跑 pnpm verify + content:check --require-all,把 apps/site/dist 传成 artifact)、e2e(dev e2e + 生产产物 e2e)、release(只在 tag 上运行,把 web-dist 打成 zip 挂到 GitHub Release)。版本号解析不是走过场,tag 不合 SemVer 会直接失败:
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$ ]]; then
echo "::error::tag 不是 SemVer:v$VERSION"
exit 1
fi
Release 说明里的下载表只有一行,就是 Web zip;正文明确写了”Windows EXE 与 Android APK 尚未包含”。
必须说清楚的事实:这条 tag 发布路径尚未实跑过。仓库至今没有打过一个 v* tag,所以 release job 里的 zip 打包、softprops/action-gh-release@v2 的产物挂载、Release Notes 生成都只有代码没有回执。已经验证的是它依赖的前置步骤——pnpm verify、pnpm e2e、pnpm e2e:dist 在本地与 CI 都跑绿过。写文档时把”代码已就绪”和”流程已实跑”分开陈述,是这份仓库的固定习惯(docs/09 的状态只有”已完成 / 环境阻塞 / 待人工”三种)。
版本号有三个来源,且目前并不一致,发布前必须人工对齐:
| 位置 | 当前值 | 谁在用 |
|---|---|---|
根 package.json |
0.1.0 |
pnpm 自身与 workspace 包 |
CHANGELOG.md |
顶部是两个 ## Unreleased 段,其下才是 ## v1.0.0-rc.1 |
人读的发布历史 |
release.yml 的 version job |
从 tag 解析(如 v1.0.0-rc.1 → 1.0.0-rc.1) |
Release 附件名、发布说明 |
也就是说:代码里的版本号不参与构建产物命名,产物唯一的版本标识来自 tag。这既是简化(不必在两处同步版本),也是陷阱——如果 package.json 停在 0.1.0 而 tag 是 v1.0.0-rc.1,产物内没有任何字段能说明自己是哪一版,出问题只能靠 tag 与 zip 名反推。docs/09 把”版本号与 CHANGELOG 定稿”列在发布操作的第一项,原因就在这里。
发布说明也要跟着数字走。当前 release.yml 里的说明正文仍写着”120 单测”,而这个数字早已被新的测试取代(现在是 369 例)。发布说明是文档的一部分,会和代码一起漂移;它既不被 content:check 扫,也不被任何单测断言,只能靠发布前人工核对。这条在 docs/10 里被归为”文档漂移”,与其它 10 处一起修过一轮,但 release.yml 的这一句显然不在那轮清单里。
19.8 原生壳:目录还不存在,就不要在文档里假装有
计划里是”一份 Web 产物 + 两个原生壳”:Windows 用 Tauri 2(便携 exe,WebView2),Android 用 Capacitor 8(apk,原生 TTS)。仓库现状是:
apps/
├── site/ # 唯一的产品入口,Vue 3 外壳
├── desktop/ # 尚未创建(Tauri)
└── mobile/ # 尚未创建(Capacitor)
pack:check 对此的态度是给提示而不是假装通过:
for (const rel of ['apps/desktop', 'apps/mobile']) {
if (existsSync(join(ROOT, rel))) ok('原生壳目录存在 ' + rel);
else warn('原生壳目录不存在 ' + rel + '(v1.1 计划,不参与本次体检)');
}
注释里那句”检查不存在的文件只会制造’永远红’或’永远假绿’,两者都坏”值得记住。同理,docs/09 把 Tauri 与 Capacitor 两项标成”环境阻塞“(本机无 Rust/cargo、无 Android SDK/adb),而不是”待办”——因为没有工具链时,写”下一步就做”约等于撒谎。
原生壳真正落地时需要关心的点也已经写下来了:Web 端用 Web Speech,Android WebView 未实现该 API,所以要接原生 TTS(走 Capacitor 插件桥接);Electron 方案因为 file:// 下 <script type="module" crossorigin> 不可用而被放弃,改走 Tauri 的自定义协议。这些都记在 docs/02 §9.3 与 docs/09 的发布检查清单里。
小结
构建与发布这条链上,真正需要记住的是四个决定和三条边界:
base: './'让同一份dist能放在域名根、子路径、file://与 U 盘,本站就是把它整目录复制成mathmindforge/;- chunk 命名把”关卡 id”钉进文件名,
level-<id>-[hash].js/content-<id>-[hash].js让产物可自查,也让vendor-phaser能独立成块; - CSP 只在构建期注入(dev 需要 HMR 的
ws://),且必须交给 HTML 解析器插入——正则替换</head>曾经导致生产白屏; - 体积门禁量四类对象、八个上限,并在量体积之前先做产物结构体检,因为白屏产物的体积往往更小。
三条边界:pnpm preview / pnpm e2e:dist 是唯一能覆盖生产注入的路径;release.yml 的 tag 发布路径代码就绪但未实跑;原生壳 apps/desktop、apps/mobile 尚不存在,任何”有 EXE / APK”的表述都不成立。下一章把这些边界背后的具体事故逐条摊开:从生产白屏、判定绕过,到存档丢历史与 i18n 门禁误伤。