16.2 类型注册与属性描述符
16.1 把需求钉死了:运行时给不了声明类型、默认值和偏移,这三样必须由引擎自己提供。这一节就把这份「自己提供的类型描述」实现出来——一个 TypeDescriptor 描述符,加一个按名字查询它的 TypeRegistry。
先明确一件事:这一节是手工登记的版本,加一个类型就要手写一段描述符。它看起来笨,但它是理解机制的必经之路——你必须先知道每个描述符字段从哪来,16.3 的自动生成才有意义。
描述符里该放什么
一个属性要能被编辑器、序列化器、脚本三方面通用地读写,描述符至少要回答四个问题:
| 字段 | 回答的问题 | 用在哪 |
|---|---|---|
name | 这个字段叫什么? | 脚本按名访问、编辑器显示标签 |
type | 它是什么类型? | 编辑器选控件、序列化选编码 |
offset | 它在记录里的字节位置? | 紧凑布局的读写 |
defaultValue | 没赋值时是多少? | 反序列化补默认、编辑器「重置」 |
除此之外还有两个隐含但重要的性质。第一是顺序:描述符在数组里的次序就是序列化写字段的次序,必须稳定。第二是访问方式:字段不一定在紧凑记录里,也可能由一个 Map 或 getter 支撑,这时要有一条 accessor 逃生通道。
offset 从哪来?不是手填的,而是按字段类型累加出来的。defineType 拿到一串 { name, type, defaultValue },从左到右累加每种类型的字节数,就得到了每个字段的偏移和整个记录的总大小。类型尺寸表很简单:
| 类型 | 字节 | 说明 |
|---|---|---|
f32 / i32 / u32 / handle | 4 | 单精度浮点、32 位整数、句柄 |
f64 | 8 | 双精度浮点 |
bool | 1 | 布尔 |
vec3 | 12 | 三个 f32 |
注意这里做的是紧凑布局(packed),没有考虑对齐填充——对齐是 4.3 专门讲的话题,本节为了把偏移算得一眼可读,先用累加的方式。真实的记录布局会在此基础上按对齐规则调整,但描述符的结构不变。
可运行产物:TypeRegistry 完整实现
下面是完整可编译的 src/core/type-registry.ts。它包含四件东西:FieldType 与尺寸表、PropertyDescriptor 与 TypeDescriptor 两个描述符接口、把字段规格变成描述符的 defineType、以及注册表 TypeRegistry 本体。层级 L1,Node 直跑。
// file: src/core/type-registry.ts
// tier: L1
// verify: npx vitest run src/core/type-registry.test.ts
//
// helio 内核:显式类型注册表 + 属性描述符。
// 不用装饰器、不依赖 emitDecoratorMetadata——类型信息由代码显式登记,
// 注册顺序即遍历顺序,序列化输出因此稳定可复现。
/** 引擎认得的字段类型,尺寸以字节计。字符串不进紧凑记录,用 handle 指向 intern 表(见 7.3)。 */
export type FieldType = 'f32' | 'f64' | 'i32' | 'u32' | 'bool' | 'handle' | 'vec3';
const FIELD_SIZE: Record<FieldType, number> = {
f32: 4,
f64: 8,
i32: 4,
u32: 4,
bool: 1,
handle: 4,
vec3: 12,
};
/** 单个属性的描述符:名字、类型、在记录里的字节偏移、默认值。 */
export interface PropertyDescriptor {
readonly name: string;
readonly type: FieldType;
readonly offset: number;
readonly defaultValue: unknown;
/** 可选访问器:字段不在紧凑记录里时用它读写(如 Map 支撑的属性)。 */
readonly accessor?: (obj: Record<string, unknown>) => unknown;
}
/** 定义字段时的输入:只需名字、类型、默认值,偏移由布局算出。 */
export interface FieldSpec {
readonly name: string;
readonly type: FieldType;
readonly defaultValue: unknown;
readonly accessor?: (obj: Record<string, unknown>) => unknown;
}
/** 一个已注册类型:名字、紧凑记录大小、属性描述符列表。 */
export interface TypeDescriptor {
readonly name: string;
readonly size: number;
readonly properties: readonly PropertyDescriptor[];
}
/** 按声明顺序累加偏移,得到紧凑布局与属性描述符。 */
export function defineType(name: string, specs: readonly FieldSpec[]): TypeDescriptor {
let offset = 0;
const properties: PropertyDescriptor[] = [];
for (const s of specs) {
properties.push({
name: s.name,
type: s.type,
offset,
defaultValue: s.defaultValue,
accessor: s.accessor,
});
offset += FIELD_SIZE[s.type];
}
return { name, size: offset, properties };
}
/** 显式类型注册表。注册顺序被保留,遍历稳定。 */
export class TypeRegistry {
#types = new Map<string, TypeDescriptor>();
#order: string[] = [];
get size(): number {
return this.#types.size;
}
/** 登记一个类型;重名直接抛错——类型名是全局键,不能悄悄覆盖。 */
register(desc: TypeDescriptor): void {
if (this.#types.has(desc.name)) {
throw new Error(`Duplicate type registration: ${desc.name}`);
}
this.#types.set(desc.name, desc);
this.#order.push(desc.name);
}
registerAll(descs: readonly TypeDescriptor[]): void {
for (const d of descs) this.register(d);
}
get(name: string): TypeDescriptor | undefined {
return this.#types.get(name);
}
has(name: string): boolean {
return this.#types.has(name);
}
/** 按注册顺序返回全部类型名——稳定,可复现。 */
names(): readonly string[] {
return this.#order;
}
/** 按注册顺序遍历类型,供序列化器等按固定次序消费。 */
*[Symbol.iterator](): Generator<TypeDescriptor> {
for (const n of this.#order) yield this.#types.get(n)!;
}
}
/** 读一个属性的当前值:优先用访问器,否则按属性名直接取。 */
export function readProperty(obj: Record<string, unknown>, prop: PropertyDescriptor): unknown {
return prop.accessor ? prop.accessor(obj) : obj[prop.name];
}
/** 全量读取一个对象在某个类型下的所有属性值,顺序即描述符顺序。 */
export function readAll(
obj: Record<string, unknown>,
desc: TypeDescriptor,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const p of desc.properties) out[p.name] = readProperty(obj, p);
return out;
}
几处设计取舍值得点出:
#order与#types分开存。Map保证查询 O(1),但它不承诺「按注册顺序」是你能依赖的契约;显式维护一个#order数组,遍历顺序就完全由你控制。序列化器依赖的就是这个顺序。- 重名抛错。 类型名是全局键。如果允许静默覆盖,一个不小心的重复注册会让某处的类型描述被另一处的顶替,而错误要等到读存档时才暴露。宁可注册时就崩。
defineType而不是手写偏移。 偏移是人算容易错的量。让它从字段类型累加出来,你只声明「有哪些字段」,布局由代码保证一致。
真实输出:查一个 Vec3
光看实现不够,跑一次才算数。下面这个 demo 手工登记 Vec3、Transform、Health 三个类型,然后按名字查询、按描述符读值。
// file: src/apps/registry-demo.ts
// tier: L1
// verify: node src/apps/registry-demo.ts
//
// 手工登记三个类型,再按名字查询属性描述符——16.2 的可运行产物。
import { TypeRegistry, defineType, readAll } from '../core/type-registry.ts';
const reg = new TypeRegistry();
reg.registerAll([
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: [1, 1, 1] },
]),
defineType('Health', [
{ name: 'current', type: 'f32', defaultValue: 100 },
{ name: 'max', type: 'f32', defaultValue: 100 },
{ name: 'invulnerable', type: 'bool', defaultValue: false },
]),
]);
console.log('registered:', reg.size, '->', reg.names().join(', '));
// 按名字查询一个类型的属性描述符列表。
const vec3 = reg.get('Vec3')!;
console.log(`registry.get('Vec3').size = ${vec3.size}`);
for (const p of vec3.properties) {
console.log(` ${p.name}: ${p.type} @${p.offset} default=${JSON.stringify(p.defaultValue)}`);
}
// 注册顺序即遍历顺序——稳定,序列化靠它。
for (const desc of reg) console.log(`${desc.name}: size=${desc.size}`);
// 按描述符读出一个对象的全部字段值。
const hp = { current: 42, max: 100, invulnerable: true };
console.log('readAll(Health, hp) =', JSON.stringify(readAll(hp, reg.get('Health')!)));
本机 node src/apps/registry-demo.ts 的真实输出:
registered: 3 -> Vec3, Transform, Health
registry.get('Vec3').size = 12
x: f32 @0 default=0
y: f32 @4 default=0
z: f32 @8 default=0
Vec3: size=12
Transform: size=36
Health: size=9
readAll(Health, hp) = {"current":42,"max":100,"invulnerable":true}
几个数字都对得上:Vec3 三个 f32 共 12 字节,偏移依次 0 / 4 / 8;Transform 三个 vec3 共 36 字节;Health 两个 f32 加一个 bool 共 9 字节。readAll 输出的键顺序是 current, max, invulnerable——和描述符顺序一致,这就是「稳定顺序」在代码里的样子。
测试:布局、顺序与访问器
上面这些结论都被单测盯住了。挑几个关键断言看:
// snippet: src/core/type-registry.test.ts 节选
const VEC3 = defineType('Vec3', [
{ name: 'x', type: 'f32', defaultValue: 0 },
{ name: 'y', type: 'f32', defaultValue: 0 },
{ name: 'z', type: 'f32', defaultValue: 0 },
]);
// 偏移按字段类型累加,不是手填的
expect(VEC3.properties.map((p) => p.offset)).toEqual([0, 4, 8]);
expect(VEC3.size).toBe(12);
// 重名注册直接抛错,且失败的那次不会污染注册表
const r = new TypeRegistry();
r.register(VEC3);
expect(() => r.register(VEC3)).toThrow(/Duplicate/);
expect(r.names()).toEqual(['Vec3']); // 仍是第一次注册的那份
本机 npx vitest run src/core/type-registry.test.ts 的结果是 7 个用例全过。这些用例覆盖了四类不变式:布局累加正确、重名抛错、注册顺序稳定、访问器优先于同名属性。最后一条容易被忽略——当字段由 getter 计算得出时,readProperty 必须走访问器而不是直接取属性名,否则读到的是另一个东西。
常见坑
实现和使用类型注册表时,几个反复出现的坑:
- 把
offset手写进描述符。 一旦手写,改一个字段类型就会让后面所有偏移错位,而且错得很隐蔽——编译能过、运行读到的却是隔壁字段。偏移永远由defineType算。 - 用
Map的迭代顺序当序列化顺序。Map确实保留插入顺序,但这是实现细节,且一旦中间有删除再插入,顺序就不再是你以为的样子。序列化顺序要显式维护成#order。 - 类型名大小写不一致。
'Vec3'和'vec3'是两个不同的键。注册表不帮你做归一化——这是好事,但要求你在项目里统一命名规范。 - 把默认值当成「字段的初始值」。
defaultValue只用于「反序列化时字段缺失」和「编辑器重置」,它不会自动写进每个新对象。新对象的初始化是另一回事。 - 忘了
accessor这条逃生通道。 有些属性不落在紧凑记录里(比如由句柄间接指向的资源),只能靠访问器读写。描述符里留了accessor字段就是为它准备的。
第 1 条最危险,因为它不会立刻报错,而是让数据悄悄错位。这也是为什么本节的实现把偏移计算完全交给代码——人能少算一步,就少错一步。
手工登记的尽头
到这里,类型描述、注册表、按名查询、按描述符读值,一整套都跑通了。但你一定注意到了:每加一个组件类型,就要手写一段 defineType(...),字段名和类型还要和源码里的 interface 保持同步——两处重复,一处改另一处忘,就是 bug。
这正是 16.1 里第三条路线(构建期代码生成)要解决的问题。16.3 会写一个脚本,扫描源码里带标记的 interface,把你这一节手写的 defineType(...) 自动生成出来。手工这一遍到此为止,接下来交给机器。
阅读导航:上一节:16.1 引擎为什么需要反射 · 下一节:16.3 显式注册表 + 代码生成扫描 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。