10.2 大型项目模块化
一个项目在 20 个脚本时不需要架构,在 200 个脚本时架构决定生死。崩掉它的往往不是某个函数写错了,而是依赖关系长成了毛线球:core 里不小心 import 了一个玩法模块,ui 直接读了存档,game 反过来又依赖 ui。到这一步,改任何一处都可能牵动全身,测试写不了,模块也拆不出去。
卷二 1.2 已经把 assets/scripts/ 分成 core / game / ui / platform 四层。这一节不再重复那四个目录怎么建,而是回答三个更深的问题:依赖方向为什么必须是单向的、怎么用工具把它钉死、以及当依赖已经乱了,怎么把它揪出来。
10.2.1 分层不是画图,是约束依赖方向
很多团队的分层只活在架构文档的一张图里,代码里没人遵守。原因很简单:「应该单向依赖」是一句愿望,而 import 是一行谁都能敲的代码。 只要没有工具拦截,跨层 import 一定会发生——不是因为开发者不专业,而是因为「这里正好要用到」是最省事的写法。
所以模块化的第一原则是:把依赖方向写成可检查的规则,而不是文档里的一句话。 规则要满足两个条件才有用:一是具体到能被机器判定(「core 不能 import cc」是具体的,「保持低耦合」不是);二是违反时会失败(CI 变红、提交被拒),而不是靠 code review 里某个人记得。
10.2.2 四层的依赖规则
四层的依赖方向是严格单向的,只有向下,不能向上,也不能横向绕:
ui ──┐
├──▶ game ──▶ core
platform ──┘ ▲
└──────────────┘
规则展开成一张表,比看图更清楚:
| 层 | 可以依赖 | 绝不依赖 | 能否 import cc | 能否单测 |
|---|---|---|---|---|
core | 无(只依赖标准库) | game / ui / platform / cc | ❌ | ✅ 直接测 |
game | core / platform | ui | ✅ | 需 mock |
ui | core / game / platform | — | ✅ | 需 mock |
platform | core | game / ui | ✅ | 需 mock |
三条硬约束:
core是地基。 它只能依赖标准库,连cc都不能碰——这样它才能在 Node 里跑单测。这条纪律同时是 10.3 会用 ESLint 钉死的第一条规则。ui是屋顶。 它可以看下面所有层,但没有任何层能反过来依赖它。game与platform互不依赖。 玩法不该知道「存档是怎么落盘的」,平台层也不该知道「这一关有几枚金币」;两者要通信,走core里定义的接口。
10.2.3 平台层:用接口隔离三端差异
platform/ 存在的原因是 H5、微信小游戏、原生三端的存档、分享、广告 API 各不相同。如果 game 直接调 wx.setStorageSync,这套玩法代码就绑死在微信上,H5 和原生版本全得改。 正确做法是:在 core/ 里定义接口,在 platform/ 里给三端各写一份实现。
先看 core/ 里的接口(纯 TypeScript,不 import cc):
// assets/scripts/core/platform/storage.ts
export interface Storage {
read(key: string): string | null;
write(key: string, value: string): void;
}
// 业务侧只依赖接口,不知道背后是 localStorage 还是 wx.setStorageSync
export function saveScore(storage: Storage, score: number): void {
storage.write('blockdash.save.v1', JSON.stringify({ score }));
}
再看 platform/ 里基于 cc 的 sys 的实现。sys.localStorage 在各端由引擎统一适配,是这里最省事的选择:
import { sys } from 'cc';
import { Storage } from '../core/platform/storage';
export class SysStorage implements Storage {
read(key: string): string | null {
return sys.localStorage?.getItem(key) ?? null;
}
write(key: string, value: string): void {
sys.localStorage?.setItem(key, value);
}
}
依赖方向在这里体现得很干净:platform 依赖 core(实现它的接口),game 只依赖 core(用它的接口)。三端差异被关在 platform/ 里,玩法代码一行都不用改。 这也是「接口定义在 core、实现在 platform」这条约定的价值——它让依赖方向不因平台适配而破例。
10.2.4 Bundle 与代码模块的对应关系
代码分成四层,资源也要分成包。两者不是一回事,但要对齐:代码是编译进主包的(脚本默认都打进主包,除非你手动处理),资源才由 Bundle 决定进主包还是分包。
《方块冲刺》的约定是:关卡资源放 assets/bundle-levels/,运行时用 assetManager.loadBundle('bundle-levels', ...) 按需加载。这个 Bundle 与代码层的对应关系是:
| 资源 | 归属 | 对应代码层 |
|---|---|---|
| 启动场景、主菜单 | 主包 | ui |
| 玩家、金币、通用图集 | 主包 | game |
| 关卡地图、关卡专属贴图 | bundle-levels 分包 | game(逻辑在主包,资源在分包) |
| 存档、埋点配置 | 主包 | platform |
关键认知:分包分的是资源,不是代码。 关卡玩法脚本仍在主包的 game/ 里,进分包的是它用到的贴图与配置。想靠 Bundle 来「懒加载代码模块」是行不通的——那是打包器(bundle 工具)的事,不是引擎 Bundle 的职责。两者的名字撞车,是本领域最容易混淆的一点。
10.2.5 分包的 .meta 长什么样
让一个目录变成 Bundle,靠的是编辑器里的一次勾选,它会写进该目录的 .meta。以下是你会在编辑器里看到的配置状态:
以下配置需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现:
选中 assets/bundle-levels 文件夹 → 属性检查器
├── Is Bundle ✅ 勾选
├── Bundle Name bundle-levels
├── Priority 1
├── Compression Merge Deps
└── Is Remote ❌ 未勾选
在编辑器里选中这个文件夹,属性检查器顶部会多出「配置为 Bundle」的一组选项。Is Bundle 勾上后,Bundle Name 才会出现,默认取目录名 bundle-levels;Priority 控制加载优先级,多个分包时数字小的先加载。没勾这一步,运行时 loadBundle('bundle-levels') 会报「找不到 Bundle」——这是新手最常见的坑,分包靠目录 + 勾选实现,不是靠代码里写个名字。
10.2.6 循环依赖:为什么它会咬人
模块化里最难查的问题是循环依赖:A 依赖 B,B 又依赖 A。它不一定立刻报错,而是表现为一些诡异现象——某个模块的导出在别处是 undefined、初始化顺序敏感、打包器警告「circular dependency」。
在 TypeScript / ESM 里,循环依赖的后果是模块求值顺序被打破:当 A 求值时需要 B 的某个导出,而 B 还没求值完,拿到的是 undefined。Cocos 的脚本编译同样按模块图求值,所以这条坑在引擎里照样成立。最麻烦的是它经常「本地能跑、某个平台挂」——因为不同打包路径的求值顺序可能不同。
排查循环依赖,靠人肉看 import 是不现实的——200 个文件的图,肉眼追不出环。正确做法是写个工具自动扫。下面这段就是那个工具,它属于 core/(或更准确地说,属于工程脚手架),纯 TypeScript,不 import cc。
10.2.7 写一个纯 TS 的循环依赖扫描器
扫描器只做两件事:从源码里抽出相对 import 建成有向图,再用深度优先搜索找出所有环。
// scripts/analyze/find-cycles.ts —— 纯 TypeScript,不 import 'cc'
const IMPORT_RE = /import\s[^;]*?from\s+['"]([^'"]+)['"]/g;
export function extractRelativeDeps(source: string): string[] {
const deps: string[] = [];
for (const match of source.matchAll(IMPORT_RE)) {
const spec = match[1];
if (spec.startsWith('.')) deps.push(spec);
}
return deps;
}
export function findCycles(graph: Map<string, string[]>): string[][] {
const cycles: string[][] = [];
const state = new Map<string, number>(); // 0 未访问 / 1 在栈上 / 2 已完成
const stack: string[] = [];
const visit = (node: string): void => {
state.set(node, 1);
stack.push(node);
for (const next of graph.get(node) ?? []) {
const s = state.get(next) ?? 0;
if (s === 1) {
const start = stack.indexOf(next);
cycles.push(stack.slice(start).concat(next));
} else if (s === 0) {
visit(next);
}
}
stack.pop();
state.set(node, 2);
};
for (const node of graph.keys()) {
if ((state.get(node) ?? 0) === 0) visit(node);
}
return cycles;
}
findCycles 接收一张已经解析好的有向图(键是文件路径,值是它依赖的文件路径),返回每个环的路径序列。注意它只看相对 import——import { Component } from 'cc' 这类裸模块不是循环依赖的来源,跳过它们能少一堆噪音。
10.2.8 用 vitest 验证扫描器
扫描器本身也是纯逻辑,同样该被测试。构造一个三节点环和一个无环图:
import { describe, expect, it } from 'vitest';
import { extractRelativeDeps, findCycles } from '../scripts/analyze/find-cycles';
describe('findCycles', () => {
it('识别出 core → game → ui → core 的环', () => {
const graph = new Map<string, string[]>([
['core/a.ts', ['game/b.ts']],
['game/b.ts', ['ui/c.ts']],
['ui/c.ts', ['core/a.ts']],
]);
expect(findCycles(graph)).toEqual([
['core/a.ts', 'game/b.ts', 'ui/c.ts', 'core/a.ts'],
]);
});
it('无环图返回空数组', () => {
const graph = new Map<string, string[]>([
['ui/c.ts', ['game/b.ts']],
['game/b.ts', ['core/a.ts']],
['core/a.ts', []],
]);
expect(findCycles(graph)).toEqual([]);
});
it('只抽取相对 import', () => {
const src = "import { Component } from 'cc';\nimport { World } from '../core/ecs/world';";
expect(extractRelativeDeps(src)).toEqual(['../core/ecs/world']);
});
});
把这三条断言放进 CI,findCycles 一旦返回非空就 fail——循环依赖从「上线前才发现」变成「提交前就拦下」。 这段代码的价值不在于算法多高明,而在于它把一条团队纪律变成了自动检查。
10.2.9 用 loadBundle 按需加载分包
最后把 Bundle 用起来。下面这个组件负责异步加载 bundle-levels 并读一份关卡配置,它属于 game/ 层:
import { _decorator, Component, assetManager, AssetManager, JsonAsset, warn } from 'cc';
const { ccclass } = _decorator;
@ccclass('LevelBundleLoader')
export class LevelBundleLoader extends Component {
private bundle: AssetManager.Bundle | null = null;
async loadLevelConfig(): Promise<JsonAsset | null> {
const bundle = await new Promise<AssetManager.Bundle | null>((resolve) => {
assetManager.loadBundle('bundle-levels', (err, loaded) => {
if (err) {
warn('[block-dash] 分包加载失败:', err.message);
resolve(null);
return;
}
resolve(loaded);
});
});
if (!bundle) return null;
this.bundle = bundle;
return new Promise<JsonAsset | null>((resolve) => {
bundle.load('configs/levels', JsonAsset, (err, asset) => {
if (err) {
warn('[block-dash] 关卡配置加载失败:', err.message);
resolve(null);
return;
}
resolve(asset);
});
});
}
}
assetManager.loadBundle 加载整个分包,回调拿到一个 AssetManager.Bundle;再用 bundle.load 从里面取具体资源。引擎运行时其实知道每个分包的依赖列表——AssetManager.Bundle 上有一个 deps 只读属性,返回该 Bundle 依赖的其他 Bundle 名(定义在 cocos/asset/asset-manager/bundle.ts,tag 3.8.8)。Bundle 之间的依赖,和代码模块之间的依赖,是同一套「有向图不能成环」的思想——只不过一个由引擎在加载时解析,一个得靠你自己扫。
10.2.10 工程决策
- 依赖方向写成规则,不写成文档。 四层单向依赖,违反时 CI 必须变红。
core绝不 importcc。 这是可测性的地基,也是 10.3 静态检查的第一道门。- 平台差异关在
platform/里。 接口定义在core,实现在platform,玩法只认接口。 - Bundle 分的是资源,不是代码。 别指望用分包来懒加载模块。
- 循环依赖要自动扫。 两百个文件靠人眼看环是自欺欺人,写个扫描器放进 CI。
模块化把「代码怎么分」讲完了,但光有结构还不够——没有检查的结构,三个月后就会退化回去。 下一节把 ESLint、TypeScript 严格模式和类型门禁组合起来,让上面这些纪律真正「钉死」在工具里。
阅读导航:上一节:10.1 ECS 与数据驱动架构 · 下一节:10.3 工程规范与静态检查 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。