《游戏引擎实现原理》16.1 引擎为什么需要反射

编辑器要列出属性、存档要读写字段、脚本要绑定对象——这三件事都要求引擎在运行时知道「类型里到底有什么」。本节讲清这份类型信息为什么必须有,交代 Node 26 只做类型剥离、装饰器元数据走不通这个硬约束,并把它讲成一个主动的设计选择:显式注册表。

16.1 引擎为什么需要反射

前面 15 章,helio 已经能把实体、组件、系统、任务图都跑起来了,但它对自己存的数据「一无所知」:它能存一个 Transform,却说不清 Transform 里有哪些字段、每个字段是什么类型、默认值是多少。到了第 17 章要写序列化时,这个缺口会立刻变成拦路石——你没法序列化一个你描述不出来的结构。

所谓「反射」,在引擎语境里就是一句话:让类型信息在运行时仍然可查询。这一节先把「为什么需要它」讲清楚,再讲一个本卷必须正面处理的硬约束,最后交代本卷的方案为什么是「显式注册表」而不是「装饰器元数据」。

先把三个词分清楚:反射、内省、元数据

中文里这三个词经常被混着用,但在引擎讨论里它们指向不同的东西,先分清能省掉很多误解。

  • 内省(introspection):运行时读取已有对象的形状。Object.getOwnPropertyNames、typeof、Array.isArray 都是内省。内省只能看「此刻是什么」,看不了「声明是什么」。
  • 反射(reflection):在读取之上,还能按名字操作类型与成员——按字符串找到类型、按字符串读写字段、按描述符构造对象。引擎要的是这一层。
  • 元数据(metadata):「关于类型的信息」本身,也就是反射读取的那份数据。它可以是运行时对象(本节要造的注册表),也可以是编译期注入的附加信息(装饰器元数据)。

关键结论:JavaScript 原生只有内省,没有反射;反射要靠一份额外的元数据撑起来。 这份元数据从哪来,正是本章全篇要回答的问题。装饰器元数据是「让编译器帮你生成元数据」,显式注册表是「你自己写元数据」,构建期扫描是「让一个脚本帮你写元数据」——三种来源,同一份元数据。

本章与站内既有内容的分工

站内已经有一批把「装饰器 + 元数据」讲得很透的文章,本章不重复它们,而是讲清楚引擎场景下的取舍:

既有文章它负责什么本节只做什么
/typescript-decorators-metaprogramming/TS 5.0 标准装饰器语法、reflect-metadata 反射、手写 DI 容器讲引擎为什么不能依赖装饰器元数据、替代方案怎么落地
/ts-advanced-04-2-reflect-metadata-di/用装饰器 + emitDecoratorMetadata 喂给 DI 容器讲同一条路线在本卷为什么走不通
/ts-advanced-04-3-aop-runtime-type-info/运行时类型信息驱动的 AOP讲显式注册表与 AOP 元数据各自的适用面
/defold-save-serialization/引擎子系统视角的存档与序列化讲从零实现类型注册表与代码生成

一句话概括分工:那些文章讲「装饰器语言特性能做什么」,本节讲「引擎反射不能依赖装饰器元数据,那替代方案长什么样」。读完它们你会写 DI 容器,读完本章你会写引擎的类型注册表——两者目标不同,但共享同一份「运行时到底能看到什么」的底层认知。也要注意别把两者混为一谈:DI 容器关心的是「构造一个对象要注入哪些依赖」,引擎类型注册表关心的是「一个已存在的数据结构有哪些字段、怎么读写」,后者要的是字段级布局信息,比前者细一层。

三个必须知道「类型里有什么」的场景

引擎里对类型信息的刚需集中在三处,每一处都绕不开。

其一,编辑器要列出属性。 属性面板不是写死的。你打开一个 Transform 组件,面板要自动列出 position、rotation、scale 三个字段,给出它们的类型(向量还是浮点)、当前值、可编辑范围。这些信息不来自任何手工枚举——它来自一份类型描述。没有它,每加一个组件就得同步改一遍面板代码。真实的编辑器还会拿这份描述做更多事:判断一个字段该画成滑块还是颜色选择器、给向量字段画出三个数字框、把只读字段灰掉。属性面板本质上是类型描述的一次通用渲染。

其二,序列化要按字段读写。 存档系统拿到一个对象,得知道「有哪些字段要写、按什么顺序写、读回来怎么填」。字段顺序尤其重要:同样的场景存两次,字节流必须一致,否则 diff、校验和、版本迁移全部失效。这份「字段清单 + 稳定顺序」就是类型信息。第 17 章会把这份描述直接喂给 JSON 与二进制两个编码器,本节产出的注册表就是它的输入。

其三,脚本绑定要按名字访问对象。 当策划在脚本里写 entity.get('Health').current = 0,引擎必须在运行时用字符串 'Health' 找到类型、用字符串 'current' 找到字段,再落到具体的内存位置或访问器上。字符串到字段的映射,本身就是一份类型表。

这三件事指向同一个需求:一份在运行时可查询的、稳定的类型描述。注意「稳定」两个字——它排除了「靠遍历对象当前属性凑出来」的做法,因为一个刚创建、还没赋值的对象,属性可能压根不存在。把这三种需求摊开看,它们的共同点非常清楚:

场景需要从类型信息里拿到什么缺失时的后果
编辑器属性面板字段名、类型、默认值、可编辑范围每加一个组件就要手改面板
序列化 / 存档字段清单 + 稳定的读写顺序两次存档字节不一致,迁移失效
脚本绑定字符串名到类型/字段的映射脚本无法按名字访问对象

三种实现路线的对比

要拿到这份描述,业界有三条常见路线。先把它们摆在一起看,你就明白本卷为什么选第三条。

路线类型信息从哪来运行时依赖可控性 / 稳定性
装饰器元数据emitDecoratorMetadata 注入的 design:type需要 reflect-metadata散落在各处,顺序依赖装饰器求值
手写显式注册表开发者手写的描述符数组无集中、顺序逐字可控,但重复劳动
构建期代码生成脚本扫描源码标记,生成注册表无(生成物是普通代码)集中、顺序稳定、零重复劳动

第一条是大多数 DI 框架走的路,也是站内那几篇文章讲的路;第二条是「能跑但难维护」的原型做法;第三条是前两条的合体——用第二条的运行时结构,配第一条的自动化程度,但把「自动化」放在构建期而不是运行时。

这里有一个容易忽略的细节:第一条路线听起来最省事,因为「写个装饰器就自动有元数据了」。但它的省事是有前提的——前提是你的构建链愿意做那套转换。在 tsc + reflect-metadata 的组合里它成立,在「Node 直跑 .ts」的组合里它不成立。换运行时,省事就变成不可用。 这正是本卷选择第三条路线的核心动机:不把类型信息寄托在某个编译器的可选行为上。

本卷 16.2 先把第二条写出来(理解每个字段从哪来),16.3 再补上第三条的生成器(让它可维护)。下面先处理那个把第一条路线判死刑的硬约束。

硬约束:Node 26 只做类型剥离

现在到了本章必须讲透的技术约束。本卷所有代码都用 Node 26 直接跑 .ts。Node 26 的原生 TypeScript 支持是只做类型剥离(type stripping):它把类型标注、interface、import type 这些「纯类型语法」原地删掉,剩下的 JavaScript 原样执行。它不做任何会改变运行时代码的「语法转换」。

装饰器元数据 emitDecoratorMetadata 恰好属于后者。它会在编译期为每个被装饰的成员额外注入一段 design:type / design:paramtypes 的元数据——这不是「删掉类型」,而是「凭空加上一段本来不存在的运行时代码」。类型剥离器没有、也不该有这套转换,所以这条路在 Node 26 上根本不存在。

「只做剥离」和「做转换」的区别,看一个对照就清楚了。左边是源码,中间是类型剥离器给出的结果(删类型、其余不动),右边是 emitDecoratorMetadata 会给出的结果(多出一整段运行时调用):

// 源码                       // 类型剥离(Node 26)        // 装饰器元数据转换
class Health {                class Health {                  class Health {
  current: number = 100;        current = 100;                  current = 100;
}                             }                               }
                                                              __metadata("design:type",
                                                                Object);
                                                              // ↑ 凭空多出来的运行时调用

右侧那段 __metadata(...) 正是 reflect-metadata 读取的东西,也是类型剥离器绝不会生成的代码。剥离只会让代码变短,转换会让代码变长——这就是为什么前者是「擦除语法」,后者不是。理解了这条分界,你就理解了整个约束。

这不是猜测,可以当场验证。下面这段带装饰器的 .ts:

function logged(value: unknown, context: unknown): void { void value; void context; }
class Vec3 { @logged x = 0; }

用 tsc --noEmit 检查它,没有任何报错——装饰器是合法的 TS 语法,erasableSyntaxOnly 也不会拦它(那个开关只管 enum / namespace / 构造函数参数属性)。可一旦真的执行:

SyntaxError: Invalid or unexpected token

它直接挂在 @logged 那一行。编译期放行、运行期拒收——这正是「装饰器元数据路线走不通」最直观的证据。同一份实验里,enum 会被 erasableSyntaxOnly 当场拒掉(error TS1294),而装饰器却一路通过类型检查,直到运行时才爆炸,更隐蔽。

还有第二个证据。就算你手写调用 Reflect.getMetadata('design:type', ...),本机也拿不到东西——Reflect.getMetadata 本身就不存在:

typeof Reflect.getMetadata = undefined

Reflect.getMetadata 来自 reflect-metadata 这个 polyfill,引擎若要用它,就得引入一个运行时依赖、并在所有入口 import 'reflect-metadata'。本卷「零运行时依赖、Node 直跑」的口径不允许这么做。这两条证据合起来,把装饰器元数据路线彻底排除。

可运行产物:探测运行时到底能看到什么

把上面的结论钉成一段可跑的代码。它建一个普通的游戏对象,然后分别尝试取「属性名」「声明类型」「默认值」「偏移」,看看运行时到底给不给你。

// file: src/apps/probe-runtime.ts
// tier: L1
// verify: node src/apps/probe-runtime.ts
//
// 探测「运行时到底能看到什么」:类型标注在运行时全部消失,只剩属性的当前值。
// 这正是引擎不能靠运行时反射、而要靠显式注册表的根本原因。

/** 一个普通的游戏对象:字段的声明类型编译后被抹掉,运行时只剩值。 */
export class Health {
  current = 100;
  max = 100;
  invulnerable = false;
}

/** 运行时能拿到的只有「属性名 + 当前值的 typeof」。 */
export function probeShape(instance: object): string[] {
  const out: string[] = [];
  for (const key of Object.getOwnPropertyNames(instance)) {
    const value = (instance as Record<string, unknown>)[key];
    out.push(`${key}: ${typeof value}`);
  }
  return out;
}

/** 尝试取回「声明类型」——reflect-metadata 没装时恒为 undefined。 */
export function probeDeclaredType(target: object, key: string): unknown {
  const reflect = Reflect as unknown as {
    getMetadata?: (k: string, t: object, p: string) => unknown;
  };
  return typeof reflect.getMetadata === 'function'
    ? reflect.getMetadata('design:type', target, key)
    : undefined;
}

const health = new Health();
console.log('--- 运行时可见的属性(名字 + 当前值类型)---');
for (const line of probeShape(health)) console.log('  ', line);
console.log('--- 运行时想取「声明类型」---');
console.log('   design:type of current =', probeDeclaredType(health, 'current'));
console.log('--- 运行时想取「默认值 / 偏移」---');
const meta = Health as unknown as { defaultValue?: unknown; offset?: unknown };
console.log('   Health.defaultValue =', meta.defaultValue);
console.log('   offset of current =', meta.offset);
console.log('--- reflect-metadata 是否存在 ---');
console.log('   typeof Reflect.getMetadata =', typeof (Reflect as { getMetadata?: unknown }).getMetadata);

本机 node src/apps/probe-runtime.ts 的真实输出:

--- 运行时可见的属性(名字 + 当前值类型)---
   current: number
   max: number
   invulnerable: boolean
--- 运行时想取「声明类型」---
   design:type of current = undefined
--- 运行时想取「默认值 / 偏移」---
   Health.defaultValue = undefined
   offset of current = undefined
--- reflect-metadata 是否存在 ---
   typeof Reflect.getMetadata = undefined

结论很清楚:属性名能看到,声明类型、默认值、偏移全都看不到。而且 current: number 里的 number 只是「当前值的 typeof」——如果某个对象把 current 赋成了 undefined,这里连 number 都不会出现。运行时反射能给你的,永远只是「此刻的值长什么样」,而不是「这个字段被声明成什么」。

把这次探测的结论翻成一张「谁来提供」的清单,本章后面两节的每一块代码,都在补齐这张表:

我们需要的信息运行时能自动给吗只能由谁提供
字段名部分能(getOwnPropertyNames,但实例字段要先赋值)类型描述
声明类型(f32 / vec3 / bool)不能类型描述
默认值不能类型描述
内存偏移 / 访问器不能类型描述(由布局算出)
稳定顺序不能类型描述(注册顺序)

五条里四条是「运行时给不了」,这就是为什么必须有一份外部的类型描述。16.2 就是这张表的右边一列:一个能装下这些信息的 TypeDescriptor,外加一个按名字查询它的注册表。

一个必须守住的边界:反射不在热路径上

在往下走之前,先把一条工程纪律说清楚:反射属于冷路径,绝不能进每帧循环。

上面三件事——编辑器列属性、存档读写、脚本按名访问——全部发生在「用户操作时」「存读档时」「脚本执行时」,而不是「每帧更新每个实体时」。这不是巧合。按名字查字段、按类型分派,本质上是几次字符串哈希或映射查找,比直接访问一个固定偏移慢一到两个数量级。放在每帧 × 每实体的循环里,它会立刻吃掉你前 13 章辛苦省下的全部预算。

引擎里的分工因此非常明确:

  • 热路径(每帧、每实体):用第 11–13 章那套数据导向代码,字段访问写死在循环里,零反射。
  • 冷路径(编辑器 / 序列化 / 脚本):用本节的类型描述,接受它的开销,换回通用性。

判断一个地方该不该用反射,标准很朴素:这段代码一秒钟跑几次? 每帧几千次的循环,一次字符串查找都嫌贵;一次存档、一次属性面板刷新,走几十次描述符遍历毫无感觉。把「类型描述」当成一种冷数据,只在需要通用性的边界上消费它,性能账就算得清。

第 17 章写序列化时,你会看到同一个模式:readAll 遍历描述符读字段,一个对象走一遍——因为它只在一帧里的「存档那一刻」发生,而不是每帧发生。把这条边界守住,反射就不会成为性能的敌人。

四个常见误解

在往下读之前,先拆掉四个几乎每个人都会有的先入之见。

误解一:「JavaScript 有 typeof,这不就是反射吗?」 不是。typeof 给的是当前值的类型,不是声明的类型。一个字段声明成 number,值可能是 undefined;一个字段声明成联合类型 number | string,typeof 只会告诉你此刻是哪一种。反射要的是「声明时写了什么」,typeof 永远给不出。

误解二:「装饰器跑不了,只是 Node 的问题,换 tsc 编译一下不就行了?」 从技术上说是的——tsc 配 emitDecoratorMetadata 能生成元数据。但本卷的整条口径是「Node 26 直跑 .ts、零构建步骤」,一旦为了元数据引入 tsc 编译链和 reflect-metadata 运行时依赖,第 1 章立下的「零运行时依赖」就破了。更要命的是,你的引擎从此绑死在特定编译器开关上。

误解三:「反射慢,引擎里就不该有反射。」 这句话对了一半——反射不该在热路径,但冷路径上它无可替代。编辑器和序列化器如果不用反射,就只能对每个类型手写一遍读写代码,那是另一种更贵的成本。

误解四:「手写注册表太累,不可维护。」 单看 16.2 确实如此:加一个类型就要手写一段描述符。但 16.3 会用构建期扫描把这段手工活自动化——你只在源码里写普通的 interface,注册表由脚本生成。所以「手写」只是理解机制的第一步,不是最终形态。

把约束讲成设计选择

很多人把「不能用装饰器元数据」当成一种遗憾,好像是被迫退而求其次。本卷的立场相反:显式注册表 + 一次构建期扫描,是比装饰器元数据更适合引擎的方案,理由有三条。

第一,更可控、更可审计。 装饰器元数据是「谁被装饰谁就有元数据」,散落在各处,你很难一眼看清「引擎到底认识多少个类型」。显式注册表是一份集中清单——它就是一个可以 git diff 的文件,registry.names() 一调就知道全部类型。类型信息是引擎的公共契约,它值得被放在一个显眼的地方。

第二,不依赖编译器的额外输出。 装饰器元数据绑定在 emitDecoratorMetadata 这个特定编译器开关上,换个构建链、换个运行时(比如 Node 的 type stripping)就没了。显式注册表是普通 TypeScript 代码,tsc 能查、Node 能跑、任何工具链都能处理,没有任何隐性前提。

第三,序列化顺序天然稳定。 装饰器元数据按「装饰器求值顺序」排列,而那个顺序和你的声明顺序、以及编译器的处理细节纠缠在一起。显式注册表的顺序就是你在数组里写下的顺序——逐字可复现。第 17 章要靠这份顺序产出逐位一致的存档,这一条是硬需求。

所以本卷的路线定为:

  • 16.2 先手工把「类型 + 属性描述符 + 注册表」这套数据结构写出来,跑通查询;
  • 16.3 再写一个构建期脚本,扫描源码里带标记的类型定义,把 16.2 的手工登记自动生成出来。

手工写一遍,是为了让你理解每个描述符字段从哪来;自动生成,是为了让它在真实项目里可维护。约束不是障碍,它把方案逼向了一个更结实的设计。

顺带说一句:这套方案并不只对「Node 直跑」有意义。即便你用的是完整 tsc 构建,把类型信息集中成一份可 diff 的注册表,也远比让它散落在装饰器里好维护——只是没有那个硬约束时,你更容易忽略这一点。约束在这里起的作用,是把本来更优、但容易被偷懒绕过的做法,变成唯一可行解。

本卷会造出的反射 API

提前把本章两节的产物亮出来,你读代码时就知道每块拼图在整体里的位置:

API出现在干什么
defineType(name, specs)16.2按字段类型累加偏移,产出一个 TypeDescriptor
TypeRegistry.register(desc)16.2把类型登记进注册表,重名即抛错
registry.get(name)16.2按名字查类型,拿到它的属性描述符列表
readAll(obj, desc)16.2按描述符顺序读出对象全部字段值
GENERATED_TYPES16.3构建期脚本扫源码生成的类型清单

这些 API 合起来,就是一份「运行时可查询的类型描述」。第 17 章会拿 readAll 的输出喂给 JSON 与二进制编码器——序列化器不认识任何具体类型,它只认识 TypeDescriptor。这正是反射给引擎带来的解耦:编码器和游戏逻辑之间,隔着一层与实现无关的类型描述。

记住这个分层:游戏逻辑改字段,只改源码里的 interface;编码器完全不改,因为它只消费 TypeDescriptor;两者之间由注册表连接。下一节就从这个 TypeDescriptor 的字段设计开始。

阅读导航:上一节:目录 · 下一节:16.2 类型注册与属性描述符 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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