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 与二进制 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。