16.1 代码组织与类型安全

第 2 章把模块边界写成了一张可校验的配置表,但声明会漂移:有人新增一条跨模块 import,表还写着合规。本节把校验对象从声明换成真实源码,用一段 P 层 TypeScript 解析各模块的 import 声明,断言依赖不越界、无环,并顺带扫描 enum、namespace、参数属性、装饰器这些不可擦除构造。

站内有一整套 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 写个状态」都会在提交前被挡下。

三条纪律,一条断言

代码组织到这一节可以收成三条可执行的纪律:

  1. 边界用真实源码校验,不用声明。配置表负责「该怎么分」,源码解析负责「实际怎么连」。两者对不上时,以源码为准、报警告。
  2. 只写可擦除子集。禁 enum / namespace / 参数属性 / 装饰器——因为 Node 26 strip-only 与浏览器单文件打包都只做类型擦除。用 const 对象加联合类型替代 enum,用纯函数加命名空间式的目录结构替代 namespace。
  3. 把纪律写成会红的断言。可擦除扫描、越界检查、环检测都在 node --test 里跑;边界不是文档里的一句话,而是一旦写错就失败的一条测试。

到这里,代码的「形状」被守住了。但小游戏工程里还有一半的「真源」不在 .ts 里,而在配置表里——关卡数值、合成链、掉落权重都写在 JSON 中。下一节就把配置表本身的校验补齐:既然配置是唯一真源,那配置必须先被校验。

阅读导航:上一节:兼容性与真机测试矩阵 · 下一节:配置驱动与数据表 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制