《Cocos 游戏开发入门》17.1 构建流程与产物体积分析

把《方块冲刺》从源码变成可发布的 bundle:用 vite.config.ts 配好 assetsInlineLimit、cssCodeSplit 与按需拆包,再写一个 Node 脚本把 dist 目录按代码/图片/音频/数据分类统计体积,用 zlib 预估 gzip 后大小,最后把体积预算做成 CI 门禁,超限即失败。

17.1 构建流程与产物体积分析

前十六章,《方块冲刺》一直以「源码 + 本地 dev server」的形态跑着:vite dev 起一个热更新服务,改一行代码浏览器立刻刷新。这种形态只适合开发。要发给玩家,必须先把几十个 .ts、一堆 .png 与 .mp3 编译打包成浏览器与各平台能直接加载的产物(artifact)。这一节先讲清构建流程,再解决一个所有发布方都会问的问题:产物到底有多大,钱花在哪。

本节仍属 L1:所有脚本都在 Node 里真跑,没有一行 import 'cc'。Cocos Creator 的构建面板留到 17.3 做对照。

17.1.1 构建到底做了什么

把「源码」变成「产物」,Vite 至少做了四件事,每一件都对应一类线上问题:

  • 类型剥离与语法降级:Node 26 只做类型剥离(erasable syntax),target: 'es2020' 让 Rollup 把可选链、?? 等语法降级到目标运行时认识的形式。类型本身不参与运行,剥离后就是纯 JS。
  • 模块打包:把上百个 import 图合并成少数几个文件,浏览器一次请求就能拿到,避免 HTTP 往返把首屏拖慢。
  • 资源指纹:文件名带上内容哈希(main-8f3a1c.js)。内容一变哈希就变,CDN 可以放心设「永久缓存」,不必担心玩家拿到旧代码。
  • 压缩与内联:小图内联成 base64 省一次请求,大文件保持独立;文本类产物再做一次 minify。

这四件事里,真正容易失控的是产物体积。一个跑得飞快、逻辑正确的游戏,如果首包 12 MB,在小游戏平台上照样上不了架。所以构建流程的最后一步,应该是「量体积」,而不是「打包完就发」。

先看一眼构建后 dist/ 的形状,后面每个脚本都围着它转:

dist/
├── index.html                    # 入口,只引一个带哈希的 JS
└── assets/
    ├── index-4f2a9c.js           # 业务入口
    ├── engine-1c8b70.js          # manualChunks 拆出的引擎 chunk
    ├── gameplay-9d3e12.js        # 玩法 chunk
    ├── boss-level-77a1f0.js      # 动态 import 切出的懒加载 chunk
    ├── index-2b6d41.css          # 样式(cssCodeSplit 关闭后只有一个)
    ├── blocks-8a1e3f.png         # 大图,超过内联阈值,独立成文件
    └── icon-tiny-3c9d2a.svg      # 小图标,若小于 4KB 会被内联,不在此列出

判断一个文件「该不该在这」的标准很朴素:它是不是首屏必需的。index.html 与入口 JS 必须首屏加载;boss-level 只在进 Boss 关时才需要,它出现在这里(作为独立 chunk)正是「懒加载」生效的证据;engine 独立成文件则是为了让它稳定命中缓存。

17.1.2 vite.config.ts:三个关键旋钮

mini2d 用 Vite 作为构建器。配置不多,但有三个旋钮直接决定产物的形状:

// vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  // 相对路径,便于产物放到任意子目录或 CDN 路径下
  base: './',
  build: {
    target: 'es2020',
    outDir: 'dist',
    // 小于 4KB 的资源内联成 base64,省一次 HTTP 请求
    assetsInlineLimit: 4096,
    // 关闭 CSS 代码拆分,样式全部打进一个 css 文件
    cssCodeSplit: false,
    sourcemap: false,
    rollupOptions: {
      output: {
        entryFileNames: 'assets/[name]-[hash].js',
        chunkFileNames: 'assets/[name]-[hash].js',
        assetFileNames: 'assets/[name]-[hash][extname]',
        // 把体积大、变动少的模块拆成独立 chunk,利于缓存复用
        manualChunks: {
          engine: ['./src/core/loop.ts', './src/core/scene.ts'],
          gameplay: ['./src/game/levels.ts', './src/game/loot.ts'],
        },
      },
    },
  },
});

逐条解释这三个旋钮:

assetsInlineLimit。 它决定「多大的资源会被内联成 base64」。设成 4096,意思是小于 4 KB 的图片、字体直接编进 JS/CSS,不再单独发请求。为什么默认就够用?因为内联资源无法被浏览器单独缓存,也无法并行下载;一个 3 KB 的小图标发一次请求,开销远大于它本身。但阈值不能设太大:把一张 200 KB 的背景图内联进去,会让主 JS 膨胀、首屏解析变慢,而且改一次图就要重下整个 bundle。

cssCodeSplit。 默认 true 时,异步 chunk 引用的 CSS 会被拆成独立文件,按需加载。小型游戏只有一个入口,关掉它(false)把所有样式打进一个 CSS,反而更省请求。判断标准很简单:如果你从没用过动态 import,就关掉。

manualChunks。 这是拆包的核心。Rollup 会把列表里声明的模块及其依赖切到一个独立 chunk。把「引擎核心」和「关卡数据」分开的好处是:引擎代码几乎不变,改关卡时哈希不动,玩家的浏览器直接命中缓存。注意 manualChunks 只影响拆分方式,不影响总体积。

至于 code splitting(代码拆分) 本身,Vite 没有一个叫 codeSplitting 的开关——它由动态 import() 自动触发,manualChunks 只是手工补充。当你写下:

// 动态 import 让 Rollup 自动切出一个独立 chunk,按需下载
export async function enterBossLevel(): Promise<void> {
  const mod = await import('./boss-level');
  mod.start();
}

boss-level.ts 及其独占依赖会被切成一个单独的 chunk,只有玩家真的进入 Boss 关时才下载。这才是「首包只装必需、其余按需」的关键手段,比任何配置项都有效。

17.1.3 产物体积构成分析

打包完,dist/ 里躺着一堆文件。想知道「钱花在哪」,得把它们按类别归拢。我们写一个不依赖任何第三方库的 Node 脚本,用标准库 node:fs 遍历目录:

// scripts/analyze-dist.ts
import { readdirSync, statSync, readFileSync } from 'node:fs';
import { join, extname } from 'node:path';
import { gzipSync } from 'node:zlib';

function walk(dir: string, out: string[] = []): string[] {
  for (const name of readdirSync(dir)) {
    const full = join(dir, name);
    if (statSync(full).isDirectory()) walk(full, out);
    else out.push(full);
  }
  return out;
}

// 扩展名到类别的映射,未命中的归入 other
const CATEGORY: Record<string, string> = {
  '.js': 'code',
  '.css': 'code',
  '.png': 'image',
  '.jpg': 'image',
  '.webp': 'image',
  '.mp3': 'audio',
  '.ogg': 'audio',
  '.json': 'data',
  '.atlas': 'data',
};

function categorize(file: string): string {
  return CATEGORY[extname(file).toLowerCase()] ?? 'other';
}

有了遍历和归类,统计就是一次求和。这里同时算原始大小与 gzip 后大小,因为线上传输的是后者:

interface Totals {
  raw: number;
  gzip: number;
}

const groups = new Map<string, Totals>();
let grand: Totals = { raw: 0, gzip: 0 };

for (const path of walk('dist')) {
  const buf = readFileSync(path);
  const gz = gzipSync(buf).length;
  const key = categorize(path);
  const acc = groups.get(key) ?? { raw: 0, gzip: 0 };
  acc.raw += buf.length;
  acc.gzip += gz;
  groups.set(key, acc);
  grand.raw += buf.length;
  grand.gzip += gz;
}

// 按原始体积从大到小排列,最占地方的一眼可见
const rows = [...groups.entries()].sort((a, b) => b[1].raw - a[1].raw);
const kb = (n: number): string => `${(n / 1024).toFixed(1)} KB`;

for (const [name, t] of rows) {
  const share = ((t.raw / grand.raw) * 100).toFixed(1);
  console.log(`${name.padEnd(6)} ${kb(t.raw).padStart(10)}  gzip ${kb(t.gzip).padStart(10)}  ${share}%`);
}
console.log(`total  ${kb(grand.raw).padStart(10)}  gzip ${kb(grand.gzip).padStart(10)}`);

跑一遍,典型的《方块冲刺》产物体积大致是这样一张表:

类别原始体积gzip 后占比说明
image2.4 MB2.3 MB61%图集与背景图,压缩收益低
audio900 KB880 KB23%BGM 与音效
code420 KB130 KB11%引擎 + 玩法 + 关卡
data180 KB40 KB4%关卡 JSON、图集索引
other40 KB38 KB1%字体子集、杂项

这张表本身就是一次诊断。图片占了六成——那就该去压图(改 WebP、降分辨率),而不是继续抠代码;代码虽然原始 420 KB,但 gzip 后只剩 130 KB,因为 JS 是文本、重复模式多,压缩率高达 70%。

四类最常见的「体积陷阱」,对照上表就能定位:

症状病根处方
image 占比 > 50%图集未压、用了 PNG 存照片转 WebP / 按平台出压缩纹理
code 原始值奇高但 gzip 正常把大 JSON 内联进了代码拆成独立 .json,按需 fetch
other 莫名偏大字体未做子集,整包引入用字体子集工具只留用到的字
改动一个关卡就全量重下所有模块挤在一个 chunk用 manualChunks 拆出稳定模块

这张表要配合 CI 一起用:数字超标时,先对照表格定位类别,再决定是压资源还是拆代码,而不是盲目地「把功能删掉凑体积」。

17.1.4 gzip 预估:别被原始体积骗了

上表里最容易被误读的是「原始 vs gzip」的落差。文本类资源(.js/.css/.json)压缩率通常在 65%–75%,因为代码里有大量重复的标识符、缩进和空白,gzip 的 LZ77 + 霍夫曼编码能吃掉绝大部分。而图片、音频本身已是压缩格式,再 gzip 几乎不缩,甚至可能因为容器头而略微变大。

所以我们用 gzipSync 直接量,而不是拍脑袋乘一个系数:

// 一行验证:同一份 JS,gzip 后大概缩到几成
const raw = readFileSync('dist/assets/main-8f3a1c.js');
const gz = gzipSync(raw);
console.log(`raw ${raw.length} -> gzip ${gz.length} (${((gz.length / raw.length) * 100).toFixed(1)}%)`);

必须用 gzip 后体积做预算,原因有二:其一,服务器(Nginx / CDN)默认对文本开 gzip,玩家实际下载的是压缩后的字节;其二,如果拿原始体积定预算,你会把 420 KB 的代码误判成「超标」,从而错误地去砍业务逻辑,而真正的体积大头图片反倒没人管。

顺带一提,比 gzip 更狠的是 Brotli(.br),文本压缩率还能再高 10%–20%,现代 CDN 大多支持。预估时可以先按 gzip 保守估,上线再开 Brotli 白赚一截。

17.1.5 预算门禁:让体积回归 CI

量体积的脚本如果只在你想起来时手动跑,形同虚设。正确做法是把它接进 CI,让「体积超标」像「测试失败」一样把构建拦下来:

// scripts/analyze-dist.ts 末尾追加
const BUDGET_KB: Record<string, number> = {
  code: 200,   // gzip 后
  data: 80,
  image: 2600,
  audio: 1000,
};

let failed = false;
for (const [name, limitKb] of Object.entries(BUDGET_KB)) {
  const usedKb = (groups.get(name)?.gzip ?? 0) / 1024;
  if (usedKb > limitKb) {
    console.error(`[budget] ${name} 超标:${usedKb.toFixed(1)} KB > ${limitKb} KB`);
    failed = true;
  }
}
if (failed) process.exit(1);

接进 CI 只需要两行:

npm run build
node scripts/analyze-dist.ts   # 超标则 exit 1,流水线变红

预算值怎么定?从当前实测值往上留 10%–20% 的余量,而不是拍一个理想数字。第一次定 code: 200 之前,先跑一遍看实际是 130 KB,那 200 就是一个「能拦住明显退化、又不至于天天误报」的阈值。往后每加一个功能,体积若悄然逼近红线,CI 会替你喊停——这正是上一章讲的「确定性回归」在构建层面的延伸。

17.1.6 给体积分析补一个单测

体积脚本本身也是代码,也会出错:类别映射写漏一个扩展名、累加时把某个文件算了两遍,都可能让预算门禁失效。按第 16 章的路子,把纯计算部分抽出来单独测,不必真的去读 dist/:

// scripts/analyze-dist.test.ts
import { describe, expect, it } from 'vitest';

// 把「按扩展名归类」与「按类别汇总」抽成纯函数
function categorize(ext: string): string {
  const map: Record<string, string> = { '.js': 'code', '.css': 'code', '.png': 'image' };
  return map[ext.toLowerCase()] ?? 'other';
}

function sumByCategory(entries: Array<{ ext: string; size: number }>): Record<string, number> {
  const out: Record<string, number> = {};
  for (const e of entries) {
    const key = categorize(e.ext);
    out[key] = (out[key] ?? 0) + e.size;
  }
  return out;
}

describe('体积归类', () => {
  it('未知扩展名归入 other', () => {
    expect(categorize('.wasm')).toBe('other');
  });

  it('大小写不敏感', () => {
    expect(categorize('.PNG')).toBe('image');
  });

  it('按类别正确汇总', () => {
    const total = sumByCategory([
      { ext: '.js', size: 100 },
      { ext: '.css', size: 50 },
      { ext: '.png', size: 300 },
      { ext: '.wasm', size: 20 },
    ]);
    expect(total).toEqual({ code: 150, image: 300, other: 20 });
  });
});

这三条断言锁住了归类逻辑的三处易错点:兜底类别、大小写、累加正确性。把它们跑在 CI 里,体积脚本就不会因为「悄悄少算了一个类别」而给出门禁放行的假信号。

17.1.7 与 Cocos 构建的伏笔

到这里,mini2d 的构建链条已经完整:配置拆包 → 打包 → 量体积 → 门禁把关。Cocos Creator 3.8 的构建面板里,你能找到一一对应的旋钮——资源压缩对应我们的压缩步骤、MD5 Cache 对应我们的内容哈希、Bundle 分包对应我们的 manualChunks 与动态 import。区别只在于:Cocos 把这些做成 GUI 里的勾选项,而我们用配置文件与脚本表达。17.3 会把这张对照表补全。

产物打包好了,下一个问题是「发到哪里去」。H5 丢 CDN、微信小游戏塞进 4 MB 首包、原生套一层 WebView 壳——三种目标各有各的约束。下一节讲多端发布。

阅读导航:上一节:目录 · 下一节:17.2 多端发布:H5 / 微信小游戏 / 原生 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画