第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 &#39;self&#39; file:; script-src &#39;self&#39; file:; style-src &#39;self&#39; &#39;unsafe-inline&#39; file:; img-src &#39;self&#39; data: blob: file:; font-src &#39;self&#39; file:; connect-src &#39;none&#39;; media-src &#39;self&#39; file:; object-src &#39;none&#39;; base-uri &#39;self&#39;; form-action &#39;none&#39;;">

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 环境测不到的东西:

  1. 构建期 CSP 注入后的页面是否还能加载——dev 没有 CSP,connect-src 'none' 也不会生效;
  2. 相对路径的 chunk 是否都能 404 之外地被解析——base: './' 配 preview 时资源在子路径下;
  3. 懒加载是否真的懒——关卡 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 门禁误伤。