《游戏引擎实现原理》16.3 显式注册表 + 代码生成扫描

不用装饰器、不依赖 emitDecoratorMetadata:用一个构建期脚本扫描源码里带标记的类型定义,生成一份显式注册表 .ts 文件,再由运行时 import 进注册表。本节真跑一次扫描,把扫过的文件数、抽出的类型数与属性数、生成的代码行数都写进正文。

16.3 显式注册表 + 代码生成扫描

16.2 的注册表能跑,但每一段 defineType(...) 都是手写的,字段名和类型要和源码里的 interface 手工对齐——两处重复,迟早不同步。这一节把这份手工活交给一个脚本:扫源码里带标记的类型定义,生成一份注册表 .ts 文件,再由运行时 import 进注册表。

这一节产出的是一整条流水线:标记 → 扫描 → 生成 → 编译 → 注册。每一步都能在你机器上跑,产物也都真实存在。

为什么是「构建期扫描」而不是「运行时装饰器」

先把方案摆正。16.1 已经说明:运行时装饰器元数据在 Node 直跑这条路上走不通。那么自然的替代就是「构建期做一次静态扫描」,把类型信息在构建时抽出来,写成一个普通的 .ts 文件。

这个替换带来几个实打实的好处,值得逐条说清:

关注点运行时装饰器元数据构建期扫描生成
运行时依赖需要 reflect-metadata无,生成物是普通代码
类型信息在哪散落在每个装饰器上集中在一个生成文件里
顺序依赖装饰器求值顺序按扫描顺序,逐字可复现
出错时机运行时才发现元数据缺失构建期扫描失败即报错
可审计性要跑起来才看得到生成文件可以直接 git diff

最后两条最关键。出错时机:扫描器遇到不认识的字段类型会直接抛错、让构建失败,而不是等到运行时某个类型悄悄没有描述符。可审计性:生成的注册表是一份你能读、能 diff、能 code review 的文件——它进了版本库,就是引擎认识哪些类型的第一手证据。

还有一个隐性好处:扫描器是唯一真源。类型定义的唯一来源是源码里的 interface,注册表由它派生。你不可能「改了 interface 忘了改注册表」,因为注册表是生成出来的、每次构建重生成。

标记约定:用注释声明「这个类型要反射」

扫描器要能区分「哪些类型需要反射」和「哪些只是内部实现细节」。本卷用一个极简的约定:在被扫描的 interface 上方写一行 // @reflect。

选注释标记而不是别的机制,理由和 16.1 一脉相承:它是纯语法,不引入任何运行时构造,Node 的类型剥离器完全无感;tsc 也当它是普通注释。任何工具链都能读它,任何编辑器都能高亮它。

标记长这样:

// @reflect
export interface Vec3 {
  x: number;
  y: number;
  z: number;
}

扫描器只认这种「// @reflect 紧跟一个 export interface」的组合。没有标记的 interface 会被跳过——这就给了你一个开关:不是每个类型都需要进注册表,只有那些要序列化、要编辑器编辑、要脚本访问的才标。

可运行产物:扫描器 gen-registry.mjs

下面是完整的扫描器 tools/gen-registry.mjs。它读 src/game 下所有 .ts,用正则抽出带标记的 interface,解析字段,映射类型,拼出注册表源码,写到 src/generated/type-registry.gen.ts。层级 L1,Node 直跑。

// file: tools/gen-registry.mjs
// tier: L1
// verify: node tools/gen-registry.mjs
//
// 构建期扫描器:读 src/game 下带「// @reflect」标记的 interface,
// 生成 src/generated/type-registry.gen.ts 里的显式类型注册表。
// 只扫一遍、只生成一次,运行时不碰源码。

import { readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';

const SRC_DIR = 'src/game';
const OUT = 'src/generated/type-registry.gen.ts';

// TS 字段类型 -> 引擎字段类型。未登记的映射直接抛错,让扫描在构建期失败。
const TYPE_MAP = { number: 'f32', boolean: 'bool', string: 'handle', Vec3: 'vec3' };
const DEFAULT_VALUE = { f32: 0, bool: false, handle: 0, vec3: [0, 0, 0] };

// 标记 + 紧跟的 export interface。
const TYPE_RE = /\/\/ @reflect\s*\n\s*export interface (\w+)\s*\{([\s\S]*?)\n\}/g;
const FIELD_RE = /^\s*(\w+)\??:\s*([A-Za-z_][\w<>[\]| ]*?);?\s*$/;

function parseSource(text, file) {
  const types = [];
  for (const m of text.matchAll(TYPE_RE)) {
    const name = m[1];
    const fields = [];
    for (const line of m[2].split('\n')) {
      if (!line.trim()) continue;
      const fm = FIELD_RE.exec(line);
      if (!fm) continue;
      const [, fieldName, tsType] = fm;
      const type = TYPE_MAP[tsType.trim()];
      if (!type) throw new Error(`${file}: unknown field type "${tsType}" in ${name}.${fieldName}`);
      fields.push({ name: fieldName, type, defaultValue: DEFAULT_VALUE[type] });
    }
    types.push({ name, fields });
  }
  return types;
}

function generate(types) {
  const out = [];
  out.push('// file: src/generated/type-registry.gen.ts');
  out.push('// tier: L1');
  out.push('// verify: npx vitest run src/generated/type-registry.gen.test.ts');
  out.push('');
  out.push('// AUTO-GENERATED by tools/gen-registry.mjs. DO NOT EDIT BY HAND.');
  out.push("import { defineType, type TypeDescriptor } from '../core/type-registry.ts';");
  out.push('');
  out.push('export const GENERATED_TYPES: readonly TypeDescriptor[] = [');
  for (const t of types) {
    out.push(`  defineType('${t.name}', [`);
    for (const f of t.fields) {
      out.push(`    { name: '${f.name}', type: '${f.type}', defaultValue: ${JSON.stringify(f.defaultValue)} },`);
    }
    out.push('  ]),');
  }
  out.push('];');
  out.push('');
  return out.join('\n');
}

const files = readdirSync(SRC_DIR)
  .filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts'))
  .sort();

let types = [];
for (const f of files) types = types.concat(parseSource(readFileSync(join(SRC_DIR, f), 'utf8'), f));

const code = generate(types);
mkdirSync(dirname(OUT), { recursive: true });
writeFileSync(OUT, code);

const lines = code.replace(/\n$/, '').split('\n').length;
const props = types.reduce((n, t) => n + t.fields.length, 0);
console.log(`scanned ${files.length} files -> ${types.length} types, ${props} properties`);
console.log(`wrote ${OUT} (${lines} lines, ${Buffer.byteLength(code)} bytes)`);

三处设计要点:

  • 类型映射表是硬边界。 number → f32、boolean → bool、string → handle、Vec3 → vec3。遇到没登记的类型直接抛错——这正是「构建期发现问题」的体现。想让某个新类型可反射,你得显式把它加进映射表,而不是让它悄悄漏过。
  • string 映射成 handle。 字符串不进紧凑记录,而是驻留成句柄(见 7.3)。描述符里存的是句柄号,运行时再解引用。这条映射把「字符串怎么进记录」这个问题在生成期就定死了。
  • 生成文件带 DO NOT EDIT 头。 它是派生产物,改它没有意义——下次构建就被覆盖。改类型去改源码 interface。

源文件长什么样

扫描器要扫的源码,就是普通的、带标记的 interface。本机 src/game 下放了两个文件。第一个是几何与变换:

// file: src/game/components.ts
// tier: L1
// verify: node tools/gen-registry.mjs

// @reflect
export interface Vec3 {
  x: number;
  y: number;
  z: number;
}

// @reflect
export interface Transform {
  position: Vec3;
  rotation: Vec3;
  scale: Vec3;
}

第二个是玩法数据:

// file: src/game/gameplay.ts
// tier: L1
// verify: node tools/gen-registry.mjs

// @reflect
export interface Health {
  current: number;
  max: number;
  invulnerable: boolean;
}

// @reflect
export interface Player {
  name: string;
  speed: number;
  grounded: boolean;
}

两个文件一共 4 个带标记的 interface,共 12 个字段。Transform 的字段类型是 Vec3(映射成 vec3),Player 的 name 是 string(映射成 handle)——这两条正是映射表要处理的非平凡情形。

真实产物:生成的注册表

跑一次扫描器:

$ node tools/gen-registry.mjs
scanned 2 files -> 4 types, 12 properties
wrote src/generated/type-registry.gen.ts (29 lines, 1127 bytes)

扫了 2 个文件,抽出 4 个类型、12 个属性,生成 29 行、1127 字节的注册表文件。 这些是真实数字,不是估的。生成的文件长这样:

// file: src/generated/type-registry.gen.ts
// tier: L1
// verify: npx vitest run src/generated/type-registry.gen.test.ts

// AUTO-GENERATED by tools/gen-registry.mjs. DO NOT EDIT BY HAND.
import { defineType, type TypeDescriptor } from '../core/type-registry.ts';

export const GENERATED_TYPES: readonly TypeDescriptor[] = [
  defineType('Vec3', [
    { name: 'x', type: 'f32', defaultValue: 0 },
    { name: 'y', type: 'f32', defaultValue: 0 },
    { name: 'z', type: 'f32', defaultValue: 0 },
  ]),
  defineType('Transform', [
    { name: 'position', type: 'vec3', defaultValue: [0,0,0] },
    { name: 'rotation', type: 'vec3', defaultValue: [0,0,0] },
    { name: 'scale', type: 'vec3', defaultValue: [0,0,0] },
  ]),
  defineType('Health', [
    { name: 'current', type: 'f32', defaultValue: 0 },
    { name: 'max', type: 'f32', defaultValue: 0 },
    { name: 'invulnerable', type: 'bool', defaultValue: false },
  ]),
  defineType('Player', [
    { name: 'name', type: 'handle', defaultValue: 0 },
    { name: 'speed', type: 'f32', defaultValue: 0 },
    { name: 'grounded', type: 'bool', defaultValue: false },
  ]),
];

它就是 16.2 里手写 demo 的自动版本——同样是 defineType(...),只是不再手敲。顺序是 Vec3, Transform, Health, Player,正好等于「文件名排序后、文件内出现顺序」——逐字可复现。同一个源码目录连跑两次,生成文件的 md5 完全一致(本机实测 a3aa5b89df31b548b12ab6ca25596eec),这就是「稳定」的机器证明。

接进注册表:一行 import 的事

生成物是普通 TS 文件,接进注册表只需要 registerAll(GENERATED_TYPES):

// snippet: src/generated/type-registry.gen.test.ts 节选
import { TypeRegistry } from '../core/type-registry.ts';
import { GENERATED_TYPES } from './type-registry.gen.ts';

const reg = new TypeRegistry();
reg.registerAll(GENERATED_TYPES);

expect(reg.size).toBe(4);
expect(reg.names()).toEqual(['Vec3', 'Transform', 'Health', 'Player']); // 扫描顺序

// 扫描器还原出的布局
expect(reg.get('Vec3')!.properties.map((p) => p.offset)).toEqual([0, 4, 8]);

// string 字段被映射成 handle
const player = reg.get('Player')!;
expect(player.properties.find((p) => p.name === 'name')!.type).toBe('handle');

本机 npx vitest run src/generated/type-registry.gen.test.ts 的结果是 4 个用例全过。它验证了三件事:类型数量与顺序和扫描一致、Vec3 的偏移被正确还原、string → handle 的映射生效。加上 16.2 的 7 个用例,本节涉及的测试合计 11 个用例全部通过。

至此整条流水线闭环:源码 interface 是唯一真源,扫描器把它变成注册表,运行时 import 进 TypeRegistry。 加一个新组件,你只写 interface 加一行 // @reflect,其余全自动。

常见坑

用代码生成方案时,几个反复出现的坑:

  • 生成物被手改。 生成的注册表是派生文件,手改它下次构建就被覆盖。所有修改都要回到源码 interface。把 DO NOT EDIT 写在文件头,就是为了拦住忍不住的手。
  • 忘记重跑扫描器。 改了源码但没重新生成,注册表就和源码不一致了。正确做法是把 node tools/gen-registry.mjs 接进构建脚本(本卷是 npm run typecheck 之前的一步),让它每次都跑。
  • 扫描器把非目标 interface 也吃了。 正则必须锚定「// @reflect 紧跟 export interface」这个组合,而不是「任意 interface」。否则内部辅助类型也会进注册表,污染类型空间。
  • 字段类型没进映射表。 这是设计成报错而非静默跳过的:遇到未知类型,扫描器直接抛错、构建失败。宁可构建失败,也不要一个「部分字段没描述符」的注册表偷偷上线。
  • 生成文件的导入路径写错。 生成物在 src/generated/,导入 ../core/type-registry.ts——多一层或少一层都会让 tsc 报模块找不到。路径要跟着输出目录走。

第 2 条在实践中杀伤力最大,因为它是「静默的」:扫描器不报错,只是产物过期,直到你发现某个新类型在存档里丢了才追查回来。把生成步骤接进构建链,是唯一稳妥的做法。

第 16 章小结

回头看,本章走了一条完整的路:16.1 讲清引擎为什么需要类型信息、以及 Node 26 的硬约束把装饰器元数据排除在外;16.2 手工实现了 TypeRegistry 与属性描述符,跑通了按名查询;16.3 用一个构建期扫描器把手写登记自动化,产出逐字可复现的注册表。

这套「显式注册表 + 构建期扫描」不是妥协,而是一个更结实的工程选择:零运行时依赖、顺序稳定、集中可审计、构建期报错。它把类型信息从「散落在装饰器里的隐式副作用」,变成「一份你能读、能 diff、能 review 的显式契约」。

下一章,这份契约会立刻派上用场。第 17 章要写序列化——JSON 与二进制两个编码器都不认识任何具体类型,它们只认识 TypeDescriptor。readAll 的稳定输出,就是存档逐位可复现的地基。

阅读导航:上一节:16.2 类型注册与属性描述符 · 下一节:17.1 反射驱动的序列化:JSON 与二进制 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径