站内有一整套 GitHub Actions 与 CI/CD 专题 ,讲的是 CI/CD 的通用工程实践——工作流语法、矩阵构建、缓存、secrets 与 runner 管理,换成任何项目都能套。本章(第 16 章 工程化与协作)和它不重叠:它讲通用机制,本章讲小游戏项目特有的发布约束——分包体积门禁、配置表校验、可玩原型回归、多平台产物的构建矩阵。这一节先解决其中最底层的一件事:代码怎么组织、类型安全怎么守。
第 2 章已经把《合成小岛》的十个模块画成了一张配置表(config/e02_modules.json),并用一段 P 层内核校验「依赖只能指向更低的层」。那张表解决了「边界该怎么定」,但它有一个前提没被守住:表是声明,源码是事实,两者会漂移。有人在新文件里随手写了一句 import { boot } from "../scenes/boot.ts",配置表还停在上一版,测试全绿,边界却已经破了。所以这一节要做的不是再写一张表,而是把校验对象换成真实源码:直接解析每个模块的 import 声明,再拿它去对边界规则。
声明会漂移,所以校验真实源码
为什么小游戏的代码组织比 App 更要紧?第 2 章给过三个理由(包体预算压首屏、一份逻辑两种宿主、小团队没专职架构师),这里补上第四个,也是本节真正要动手的:类型安全不是编辑器里的提示,而是构建能不能过的硬门槛。
小游戏的构建链有两处会把「类型语法」当「运行时语法」来执行:
- Node 26 原生跑
.ts(本卷 P 层的验证手段)用的是擦除式加载——它只把类型标注删掉,不把 TypeScript 独有的运行时构造翻译成 JavaScript。所以enum、namespace、构造函数参数属性、装饰器这些「有运行时副作用」的语法会直接报错。 - 浏览器单文件原型走的是同一套擦除器(
node:module.stripTypeScriptTypes)加朴素打包。同一段逻辑内联进 HTML 时,能跑的语法集合和 Node 侧完全一致。
两条链指向同一条纪律:只用可擦除子集(erasable subset)。它不是审美偏好,而是「同一份 core/ 既能被 node --test 跑、又能被浏览器打开就玩」的前提。用 enum 写一个状态枚举,编辑器不报错、tsc 也不报错,但它会在这两条链上同时炸掉——这是最典型的一类「本地看着没事、CI 直接红」的缺陷。
下面这段是 P 层内核 core/src/e16/boundary.ts:它做四件事——解析真实源码里的 import / export…from、把 specifier 归到某个模块、断言该模块在允许名单里、在模块级依赖图上找环,最后再扫一遍不可擦除构造。它校验的输入是源码文本,不是配置表,所以能抓住「表没更新但代码已经越界」这一类漂移。
// file: core/src/e16/boundary.ts
// tier: P
// verify: node --test 'core/test/e16_*.test.ts'
//
// 模块边界与依赖方向校验器(《合成小岛》工程化收口):
// 1) 解析真实源码里的 import / export ... from 声明(词法扫描,不是完整语法树);
// 2) 把 specifier 归到某个模块,断言它在该模块的 allowed 名单里,否则判越界;
// 3) 在模块级依赖图上找环;
// 4) 扫描「可擦除子集」违规:enum / namespace / 参数属性 / 装饰器 / import= / export=。
// 全整数、无 Math.random()、只用可擦除语法(禁 enum / namespace / 参数属性 / 装饰器)。
export interface ModuleSpec {
readonly name: string;
readonly layer: number;
readonly dir: string; // 源文件目录前缀(相对 core/src,无首尾斜杠)
readonly allowed: readonly string[]; // 允许 import 的模块名;external 与自身恒允许
}
export interface SourceFile {
readonly path: string; // 相对 core/src 的路径,如 "ui/hud.ts"
readonly text: string;
}
export interface ImportRef {
readonly specifier: string;
readonly kind: "value" | "type" | "side-effect";
}
export type BoundaryReason = "not-allowed" | "upward" | "unknown-module";
export interface BoundaryViolation {
readonly file: string;
readonly from: string;
readonly to: string;
readonly specifier: string;
readonly reason: BoundaryReason;
}
export interface SyntaxIssue {
readonly file: string;
readonly line: number;
readonly rule: string;
readonly text: string;
}
export interface BoundaryReport {
readonly modules: number;
readonly files: number;
readonly imports: number;
readonly edges: string[]; // "a->b" 去重后按字典序
readonly cycles: string[][];
readonly violations: BoundaryViolation[];
readonly syntaxIssues: SyntaxIssue[];
}
/** 《合成小岛》的模块边界表:与第 2 章 D 层声明一致,但这里校验的是真实 import。 */
export const SAMPLE_MODULES: readonly ModuleSpec[] = [
{ name: "core", layer: 0, dir: "core", allowed: [] },
{ name: "platform", layer: 0, dir: "platform", allowed: [] },
{ name: "data", layer: 1, dir: "data", allowed: ["core"] },
{ name: "render", layer: 1, dir: "render", allowed: ["platform"] },
{ name: "input", layer: 1, dir: "input", allowed: ["platform"] },
{ name: "ui", layer: 2, dir: "ui", allowed: ["render", "input", "core"] },
{ name: "scenes", layer: 3, dir: "scenes", allowed: ["core", "render", "data", "ui"] },
];
const RE_FROM = /(?:^|[\n;])\s*(import|export)\s+(type\s+)?[\s\S]*?\bfrom\s*["']([^"']+)["']/g;
const RE_SIDE = /(?:^|[\n;])\s*import\s*["']([^"']+)["']/g;
/** 词法扫描 import / export ... from 与副作用 import,返回去重后的引用表。 */
export function parseImports(source: string): ImportRef[] {
const out: ImportRef[] = [];
const seen = new Set<string>();
let m: RegExpExecArray | null;
RE_FROM.lastIndex = 0;
while ((m = RE_FROM.exec(source)) !== null) {
const kind = m[2] ? "type" : "value";
const key = `${kind}:${m[3]}`;
if (!seen.has(key)) {
seen.add(key);
out.push({ specifier: m[3], kind });
}
}
RE_SIDE.lastIndex = 0;
while ((m = RE_SIDE.exec(source)) !== null) {
const key = `side-effect:${m[1]}`;
if (!seen.has(key)) {
seen.add(key);
out.push({ specifier: m[1], kind: "side-effect" });
}
}
return out;
}
function dirOf(path: string): string {
const i = path.lastIndexOf("/");
return i < 0 ? "" : path.slice(0, i);
}
/** 把 "./a/../b.ts" 这类路径归一化成 "a/b.ts",纯字符串处理,不碰文件系统。 */
export function normalizePath(path: string): string {
const parts: string[] = [];
for (const seg of path.split("/")) {
if (seg === "" || seg === ".") continue;
if (seg === "..") parts.pop();
else parts.push(seg);
}
return parts.join("/");
}
/** 把 specifier 归到某个模块:返回模块名 / "external" / null(相对路径但落不到任何模块)。 */
export function resolveSpecifier(
filePath: string, specifier: string, modules: readonly ModuleSpec[],
): string | "external" | null {
if (!specifier.startsWith(".")) {
const seg = specifier.split("/")[0];
return modules.some((mo) => mo.name === seg) ? seg : "external";
}
const joined = normalizePath(`${dirOf(filePath)}/${specifier}`);
for (const mo of modules) {
if (joined === mo.dir || joined.startsWith(`${mo.dir}/`)) return mo.name;
}
return null;
}
function ownerModule(filePath: string, modules: readonly ModuleSpec[]): ModuleSpec | null {
for (const mo of modules) {
if (filePath === mo.dir || filePath.startsWith(`${mo.dir}/`)) return mo;
}
return null;
}
/** 扫描非可擦除语法:Node 26 strip-only 与浏览器单文件打包都会在这些构造上失败。 */
export function scanErasable(file: string, source: string): SyntaxIssue[] {
const issues: SyntaxIssue[] = [];
const lines = source.split("\n");
const push = (lineNo: number, rule: string, text: string): void => {
issues.push({ file, line: lineNo, rule, text: text.trim() });
};
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
if (/(^|[^.\w$])(const\s+)?enum\s+[A-Za-z_$]/.test(line)) push(i + 1, "enum", line);
if (/(^|[^.\w$])(declare\s+)?(module\s+)?namespace\s+[A-Za-z_$]/.test(line)) push(i + 1, "namespace", line);
if (/^\s*@[A-Za-z_$]/.test(line)) push(i + 1, "decorator", line);
if (/^\s*import\s+[A-Za-z_$][\w$]*\s*=/.test(line)) push(i + 1, "import-equals", line);
if (/^\s*export\s*=/.test(line)) push(i + 1, "export-equals", line);
}
const ctor = /constructor\s*\(([\s\S]*?)\)\s*\{/g;
let m: RegExpExecArray | null;
while ((m = ctor.exec(source)) !== null) {
if (/\b(private|public|protected|readonly)\b/.test(m[1])) {
const lineNo = source.slice(0, m.index).split("\n").length;
push(lineNo, "param-property", m[0].split("\n")[0]);
}
}
return issues;
}
/** 模块级依赖图找环,返回环序列(首尾同节点);无环返回空数组。 */
export function findCycles(edges: readonly string[]): string[][] {
const adj = new Map<string, string[]>();
for (const e of edges) {
const [a, b] = e.split("->");
if (!adj.has(a)) adj.set(a, []);
adj.get(a)!.push(b);
}
const WHITE = 0, GRAY = 1, BLACK = 2;
const color = new Map<string, number>();
const stack: string[] = [];
const cycles: string[][] = [];
const visit = (name: string): void => {
color.set(name, GRAY);
stack.push(name);
for (const nx of (adj.get(name) ?? []).slice().sort()) {
if (color.get(nx) === GRAY) {
cycles.push(stack.slice(stack.indexOf(nx)).concat(nx));
} else if ((color.get(nx) ?? WHITE) === WHITE) {
visit(nx);
}
}
stack.pop();
color.set(name, BLACK);
};
const nodes = [...new Set(edges.flatMap((e) => e.split("->")))].sort();
for (const n of nodes) if ((color.get(n) ?? WHITE) === WHITE) visit(n);
return cycles;
}
/** 全量分析:解析每个文件的 import、判越界、聚合依赖边、找环、扫可擦除违规。 */
export function analyzeProject(
modules: readonly ModuleSpec[], files: readonly SourceFile[],
): BoundaryReport {
const edges = new Set<string>();
const violations: BoundaryViolation[] = [];
const syntaxIssues: SyntaxIssue[] = [];
let importCount = 0;
for (const f of files) {
const owner = ownerModule(f.path, modules);
syntaxIssues.push(...scanErasable(f.path, f.text));
if (!owner) continue;
for (const ref of parseImports(f.text)) {
importCount += 1;
const to = resolveSpecifier(f.path, ref.specifier, modules);
if (to === null) {
violations.push({ file: f.path, from: owner.name, to: ref.specifier, specifier: ref.specifier, reason: "unknown-module" });
continue;
}
if (to === "external" || to === owner.name) continue;
edges.add(`${owner.name}->${to}`);
const target = modules.find((mo) => mo.name === to)!;
if (!owner.allowed.includes(to)) {
const reason: BoundaryReason = target.layer >= owner.layer ? "upward" : "not-allowed";
violations.push({ file: f.path, from: owner.name, to, specifier: ref.specifier, reason });
}
}
}
return {
modules: modules.length,
files: files.length,
imports: importCount,
edges: [...edges].sort(),
cycles: findCycles([...edges]),
violations,
syntaxIssues,
};
}
这里有几处设计值得单独说:
- 解析是词法级的,不是完整语法树。
parseImports用正则扫from "..."与副作用import "...",够用且零依赖——本项目不引入typescript编译器,core/里也不能import "typescript"。代价是它认不出字符串拼接出来的动态 import(那本来也不该出现在模块边界里),这一限制在注释里写明了。 - specifier 归模块靠路径前缀,
../core/merge.ts从data/table.ts出发归一化成core/merge.ts,落进core模块。同目录的./missing.ts归到自身模块(不是未知模块),node:fs这类裸名判external直接放行。 - 越界原因分三档:
upward(依赖了同层或更高层的模块)、not-allowed(依赖了更低层但不在允许名单里)、unknown-module(相对路径落不到任何模块)。分档是为了让报错直接指向修法。 - 可擦除扫描覆盖
enum/namespace/ 装饰器 / 参数属性 /import =/export =六类。它把「纪律」变成一条会红的断言,而不是文档里的一句话。
配套的 P 层测试 core/test/e16_boundary.test.ts 用内联的「迷你工程」源码当输入——一个干净工程加几个故意写坏的工程,逐类验证校验器抓得住;完整源码见附录 C 的文件索引。本机实跑 node --test 'core/test/e16_boundary.test.ts' 的输出如下:
✔ parseImports:值 / 类型 / 副作用三种 import 都能识别
✔ normalizePath:解析 . 与 .. 段
✔ resolveSpecifier:相对路径归模块、同目录归自身、裸名判别名、node: 判 external
✔ 干净工程:越界 0、环 0、可擦除违规 0,依赖边全部向下
✔ 越界可被检出:data 引 scenes 判 upward,ui 引 platform 判 not-allowed
✔ 未知模块:相对路径落不到任何模块被判 unknown-module
✔ 环可被检出:a→b→c→a
✔ 可擦除违规:enum / namespace / 装饰器 / 参数属性 / import= 全部命中
✔ 同一输入两遍结果一致(无隐藏随机)
ℹ tests 9
ℹ pass 9
ℹ fail 0
逐条读这份输出:
- 干净工程越界 0、环 0、可擦除违规 0,聚合出的依赖边是
data->core、render->platform、ui->core、ui->input、ui->render——全部向下,和配置表一致。注意ui->input来自一条副作用 import(import "../input/touch.ts"),它同样计入边界。 - 越界分档可断言:
data引scenes是upward(scenes 在第 3 层),ui引platform是not-allowed(platform 在第 0 层、方向向下,但不在ui的允许名单里)。两种违规在同一份报告里被分开。 - 环检测与允许名单解耦:
a→b→c→a三个模块互相在允许名单里,边界检查放行,但环照样被抓出——因为「允许依赖」和「无环」是两条独立的规则,不能靠一条覆盖另一条。 - 可擦除扫描一次命中六类:
enum、namespace、装饰器、参数属性、import =、export =。把它接到 CI 上,任何一次「顺手用enum写个状态」都会在提交前被挡下。
三条纪律,一条断言
代码组织到这一节可以收成三条可执行的纪律:
- 边界用真实源码校验,不用声明。配置表负责「该怎么分」,源码解析负责「实际怎么连」。两者对不上时,以源码为准、报警告。
- 只写可擦除子集。禁
enum/namespace/ 参数属性 / 装饰器——因为 Node 26 strip-only 与浏览器单文件打包都只做类型擦除。用const对象加联合类型替代enum,用纯函数加命名空间式的目录结构替代namespace。 - 把纪律写成会红的断言。可擦除扫描、越界检查、环检测都在
node --test里跑;边界不是文档里的一句话,而是一旦写错就失败的一条测试。
到这里,代码的「形状」被守住了。但小游戏工程里还有一半的「真源」不在 .ts 里,而在配置表里——关卡数值、合成链、掉落权重都写在 JSON 中。下一节就把配置表本身的校验补齐:既然配置是唯一真源,那配置必须先被校验。
阅读导航:上一节:兼容性与真机测试矩阵 · 下一节:配置驱动与数据表 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。