语言与工具链
Q:为什么不能写 enum?
Node 26 直接运行 .ts 时只做类型剥离,不做语法转换。enum 在编译后会生成一个运行时的对象,属于「不可擦除」的语法,因此被拒绝,报 ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX。
替代写法:
// ✗ 不可擦除
enum LogLevel { Debug, Info, Warn, Error }
// ✓ 用 const 对象
const LogLevel = { Debug: 0, Info: 1, Warn: 2, Error: 3 } as const;
type LogLevel = (typeof LogLevel)[keyof typeof LogLevel];
// ✓ 或者用字面量联合类型
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
同样被禁的还有 namespace 和构造函数参数属性:
// ✗ 构造函数参数属性
class Vec3 { constructor(public x: number, public y: number) {} }
// ✓ 显式声明字段
class Vec3 {
x: number;
y: number;
constructor(x: number, y: number) { this.x = x; this.y = y; }
}
Q:tsc 报一堆 TS1295 / TS1287,说文件是 CommonJS?
package.json 缺 "type": "module"。npm init -y 默认生成 "type": "commonjs",必须改成 "module"。运行时也会以 SyntaxError: Cannot use import statement outside a module 的形式暴露同一个根因。
Q:import './arena.ts' 报 TS5097 怎么办?
TS5097 说「只有打开 allowImportingTsExtensions 才能以 .ts 结尾 import」。这是本卷的必需选项——Node 26 直跑时 import 路径必须带扩展名。在 tsconfig.json 里打开它即可,同时 module / moduleResolution 用 NodeNext。
Q:ESM 下 __dirname is not defined 怎么解决?
ESM 模块里没有 __dirname / __filename。用 import.meta.url 换算:
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
不过本卷大部分代码不需要文件系统路径——需要时统一走第 3 章的 IPlatform.file
,而不是直接碰 node:path。
Q:Cannot find name 'process' / Cannot find name 'readFileSync'?
缺 @types/node。npm i -D @types/node 即可。本卷的示例工程为了保持零依赖,用了一个只声明所需宿主 API 的类型垫片(node-shims.d.ts),真实工程直接装 @types/node 更省事。
内存与句柄
Q:我明明删了实体,为什么还能读到它的数据?
这正是第 6 章 要解决的问题。如果你用的是裸索引而不是世代句柄,删除后槽位会被复用,旧索引就指向了新对象——数据「还在」,但已经不是你以为的那个了。
解决方向:用世代句柄。世代号在删除时自增,旧句柄的世代对不上,get() 就返回 undefined,问题立即暴露,而不是静默读到错误数据。具体实现见6.2 slot map 与世代句柄
。
Q:为什么我写的 arena 会「内存泄漏」?
Arena 是只前进不回收的。如果你在循环里反复 alloc 却不 reset(),偏移会一直涨到越界。帧内临时内存应该用 ScratchArena 的 mark/release:
scratch.mark();
// ... 帧内临时分配 ...
scratch.release(); // 偏移回到标记点
判据:一个 arena 的 highWaterMark 应该稳定在一个量级。如果它随帧数线性增长,说明你漏了 reset。
Q:process.memoryUsage().heapUsed 为什么没涨?我的 TypedArray 去哪了?
TypedArray 的底层 ArrayBuffer 不计入 V8 的 JS 堆——它是外部内存。所以 heapUsed 看不到它,要用 process.memoryUsage().arrayBuffers 或 external 字段。
这既是第 4 章 的结论,也是它最大的价值:数据放在 TypedArray 里,GC 根本不扫描它。
Q:SlotMap 和 SparseSet 到底有什么区别,该用哪个?
两者解决同一个问题的两面:
SlotMap管身份——句柄带世代,槽位复用时旧句柄立刻失效,但它按槽位索引排数组,中间可能有空洞。SparseSet管存储——dense 数组紧凑、遍历无空洞,适合每帧全量遍历。
判据:需要「按句柄安全访问、能检测失效」就用 SlotMap;需要「每帧紧凑遍历所有元素」就用 SparseSet。两者在 ECS 里叠加使用——句柄是身份,稀疏集是存储,接缝见6.3
。
Q:get() 返回 undefined,是 bug 还是正常?
要分场景。如果这个句柄本应有效,返回 undefined 说明它已经失效——用 expect(h) 替掉 get(h),让它在逻辑不该发生的地方立刻抛错,而不是把 undefined 一路传下去。如果是在每帧扫描、天然会碰到已销毁对象的场景,get 返回 undefined 就是正常的,跳过即可。
Q:为什么句柄能存进存档,对象引用不行?
句柄是纯整数,可以直接序列化、写进文件,读回来后再用世代校验一次;对象引用一旦写进文件,下次读回来就指向不存在的东西。句柄是「可以存起来、以后再校验」的引用,对象引用是「存起来就坏掉」的引用。 这一特性是引擎能存档、能网络同步的前提,会在第 17 章的序列化 里继续展开。
Q:为什么不能用 instanceof / 构造函数判断组件类型?
因为组件在本卷里是纯数据 + 类型 id,不是类实例。数据存进 Float32Array,或按组件类型分桶进稀疏集后,已经没有原型链可查——instanceof 只会得到 false;就算勉强用构造函数,也会强迫你把数据建模成类,跟12.2 组件存储
「每种组件一个容器」的思路相冲突。
判据是比 id,不是比类型:
// ✗ 依赖原型链,组件是纯数据时必然失败
if (comp instanceof Transform) { /* ... */ }
// ✓ 一次整数比较,id 还能进存档、能跨线程传
if (comp.typeId === TRANSFORM_ID) { /* ... */ }
id 从类型注册表来,见16.2 。
Q:句柄的世代号为什么会溢出?
世代号位宽有限。本卷把句柄切成「低 20 位索引 + 高 12 位世代」,世代是 12 位循环计数,每个槽位回收 4096 次后就回绕——理论上存在「恰好过了 4096 代、旧句柄又对上」的极小概率,那时一个本该失效的老句柄会被误判为有效(6.2 )。
工程上的判断:一帧内同一槽位被复用 4096 次几乎不可能,所以 12 位足够;真要长期运行、逼近回绕点,应在回绕前重建整张表。这也是为什么索引给了 20 位、世代只给 12 位——活跃槽位上限比单个槽位的复用次数更早撞墙。
数学与变换
Q:物体旋转后出现了奇怪的翻转 / 抽搐?
多半是万向节死锁(9.1 )。用欧拉角表示旋转,当某个轴转到 ±90° 时,另外两个轴会重合,丢失一个自由度。
解决方向:内部用四元数存旋转,只在显示给用户时转成欧拉角。
Q:矩阵乘完结果不对,是转置还是顺序问题?
两个都可能是。先确认:
- 主序:你的矩阵是行主序还是列主序?WebGPU / WGSL 的矩阵是列主序。
- 乘法顺序:
A * B通常表示「先 B 后 A」(列向量约定)。
Q:四元数和欧拉角,内部到底该用哪个?
内部一律用四元数,只在边界转欧拉角。 四元数没有万向节死锁、插值走最短弧、复合只需一次乘法;欧拉角唯一的好处是「人能看懂」,所以只在编辑器面板与日志里出现。两者的互转见9.1 ,插值见9.2 。
Q:什么时候才该上定点数?
只有当你要跨平台确定性时——比如联机同步、录像回放要求「同样的输入得到逐比特相同的结果」。浮点在不同 CPU 上的舍入可能不同,定点数(Q16.16)把小数用整数表示,运算完全确定。代价是范围和精度都受限。见10.2 定点数 。如果只是单机游戏,用浮点就好。
Q:浮点数比较为什么不能用 ==?
因为浮点运算有舍入误差,0.1 + 0.2 !== 0.3 是最经典的例子。用带阈值的 approxEqual(a, b, eps),阈值按你的量级取(不要硬编码一个 1e-6 到处用)。数值稳健的工程做法见10.3
。
性能与基准
Q:我的基准数字和书里差很多,是我写错了吗?
大概率不是。V8 的 JIT 与 GC 让微基准非常脆弱:循环顺序、预热次数、工作集大小、机器负载都会显著影响结果。
看方向,不看倍数。 稳定的结论是量级差异(如「对象数组堆增量 62 MB vs TypedArray 0 MB」),不稳定的结论是具体倍数(如「快 1.6 倍」)。测量方法论见附录 C 。
Q:为什么 SoA 在 C++ 里快好几倍,在 JS 里只快一点?
因为 V8 的 JIT 会把热点的对象访问也优化得相当好,加上 JS 里 Float32Array 的元素访问仍有边界检查开销。数据导向在 JS 里的主要收益是内存与 GC 压力,不是访存速度。这一点在11.2
里有详细实测。
Q:为什么指针追逐比顺序遍历慢那么多?
因为硬件预取器。顺序访问时,预取器能识别「一直往前」的模式,提前把下一块数据拉进缓存;指针追逐里下一个地址要等当前访问算出来才知道,预取器完全失效,几乎每次都缓存未命中。这就是11.1 要讲的缓存层级,也是 ECS 要把同类组件紧凑存储的根本动机。
Q:基准跑出来的数字波动很大,算失败吗?
不算。基准门禁是软告警——它提示你复核,不让流程失败。判断波动是否正常,看 IQR:纯计算、无分配的 case,IQR 应该很窄;涉及随机访存或对象布局的,IQR 天然更宽。IQR 宽到把两个方案的范围重叠了,说明这个对比「没测出结论」,而不是「谁对谁错」。
Q:什么时候不该用数据导向?
当对象数量少(几百个以内)、访问模式不规则、或者逻辑复杂到难以批处理时。强行 SoA 会让代码变难读,收益却是零。先测量,再优化。
并发
Q:worker_threads 的代码能在浏览器里跑吗?
不能直接跑。Node 用 worker_threads,浏览器用 Worker,API 不同。本卷的 Job System 抽象成 IWorkerPool 接口,提供两个后端:
NodeWorkerPool:用worker_threads(可在 Node 里测试)WebWorkerPool:用Worker+SharedArrayBuffer(浏览器)
Q:浏览器里 SharedArrayBuffer 是 undefined?
浏览器要求跨源隔离才启用 SharedArrayBuffer。服务端必须发送:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
否则 SharedArrayBuffer 不可用,Job System 只能退化为复制式通信。
Q:多个 worker 能共享同一块 TypedArray 吗?
能,但要显式共享底层 buffer。用 SharedArrayBuffer 建一个 Float32Array,把 buffer 通过 postMessage 传过去,多个 worker 看到的就是同一块内存;普通 ArrayBuffer 传过去会被结构化克隆(复制一份),改不回主线程。共享内存带来的是数据竞争问题——跨线程同步要用 Atomics,见14.2
。
Q:任务图里有环会怎样?
会被检测出来并在构建阶段报错,而不是跑到一半死锁。依赖 DAG 的构建与拓扑执行见15.1 ;和子系统注册表(3.1 )一样,环检测会拼出完整的环路径,方便定位。
Q:为什么 worker 里拿不到 DOM / 主线程对象?
worker 是独立的执行上下文,没有 window、document,也访问不到主线程的变量。跨线程通信只能走 postMessage,数据按结构化克隆传递——只能传可克隆的类型(数字、字符串、TypedArray、普通对象等),函数、类实例、DOM 节点都传不过去,会抛 DataCloneError。
这恰好逼出引擎的正确做法:worker 只处理纯数据,算完再传回来;需要共享的用 SharedArrayBuffer 传同一块内存,而不是传对象引用。见14.2 线程池、任务队列与 Atomics 同步
。
顺带一提:postMessage 的第二个参数可以列出要转移所有权的 ArrayBuffer,转移是零拷贝的(原线程随即失去访问权),比结构化克隆复制一份更快。
Q:Atomics.wait 在主线程为什么报错?
浏览器禁止主线程阻塞:Atomics.wait 一旦睡下去,渲染和事件循环都会停摆,所以主线程调用它会直接抛 TypeError。主线程只能用 Atomics.waitAsync——它返回一个 Promise,不阻塞线程,等共享内存里的值变化后再回调。
判据:worker 里可以放心用同步的 Atomics.wait;主线程一律 Atomics.waitAsync。主线程与任务图的同步见15.3
。
反射与序列化
Q:为什么不用装饰器(@property)做反射?
因为 emitDecoratorMetadata 依赖 TypeScript 的运行时类型元数据,这属于不可擦除的语法转换,Node 26 直接跑 .ts 时不可用。
本卷的方案是显式注册表 + 一次代码生成扫描(16.3 ):用一个构建期脚本扫描源码,生成类型注册表。这比装饰器元数据更可控,也不依赖编译器的额外输出。
Q:反射会不会很慢?
注册表本身只是查表,很便宜。真正要注意的是别在每帧的热路径上走反射——反射适合编辑器、序列化、脚本绑定这些「不每帧跑」的场景。热路径应该用直连的组件存储(12.2 ),把反射留给边界。
Q:JSON 和二进制序列化该用哪个?
JSON 可读、好调试、跨语言,适合配置文件与存档的早期阶段;二进制紧凑、解析快,适合发布后的存档与网络传输。两者由同一份类型信息驱动(17.1 ),切换格式不改业务代码。开发期用 JSON,发布期换二进制是常见路径。
Q:存档在新版本读不出来怎么办?
用 schema 版本号 + 迁移函数(17.3
)。每个存档带 version 字段,加载时逐版本调用迁移函数,直到当前版本。
Q:循环引用会让序列化栈溢出吗?
会,如果序列化器天真地递归。处理方式是给对象编号、用引用表记录已序列化的对象,遇到已见过的就写一个引用(17.2 )。句柄天然规避这个问题——句柄是整数,不存在循环。
怎么调试
Q:句柄失效了,怎么知道它是哪次分配 / 释放出的问题?
用 expect(h) 而不是 get(h)。它抛出的错误里带上了句柄的十六进制值与槽位号(Stale handle: 0x... (slot N)),配合 5.3 节的分配计数快照,可以反查这个槽位是哪一帧被谁释放的。把「失效」从不可观测的状态,变成可观测的信号,是世代句柄的全部价值。
Q:帧率不稳,怎么定位是逻辑、GC 还是别的?
先量,别猜。用2.1 节的 FrameTimer
看帧耗时的 p95 和 max:均值贴着预算但 max 很高,说明问题在长尾——长尾多半来自 GC 停顿或某次偶发的慢操作。再把帧拆成 logic / submit / gpu / present 四段,看是哪一段超支。逻辑段慢,去查 ECS 遍历与分配;GC 相关的尖峰,去查每帧有没有在分配。
Q:怀疑是 GC 在拖后腿,怎么确认?
跑基准时加 --expose-gc,观察 process.memoryUsage() 的 heapUsed 与 external 随时间的变化。如果 heapUsed 在帧循环里持续锯齿状上涨再骤降,那就是 GC 在周期性地工作;如果每帧都在分配临时对象(heapUsed 稳定上涨),那就是分配节奏有问题——该上对象池(5.1
)或 arena 了。
Q:Cannot find name 'GPUBufferUsage',是环境坏了吗?
不是。这是缺 @webgpu/types,而 WebGPU 相关类型是卷二才需要的。本卷不需要装它。看到这个错,先确认你是不是误跑了卷二的代码。
Q:类型检查通过但运行时报错,从哪查?
按这个顺序:① node src/xxx.ts 单独跑那个文件,看是不是 ESM 扩展名或 type: module 的问题;② 看报错栈顶是哪个文件,确认路径与文件名大小写一致(macOS 大小写不敏感,会掩盖问题);③ 如果是运行时 undefined,用 expect() 把「本应有效」的假设显式化,让它更早暴露。类型系统管不到运行期的数据流,expect 就是补这块的。
还找不到答案?
- 先看目录篇 末尾的「常见问题速查」表,它按症状索引到具体章节。
- 再确认附录 B 里的环境配置没问题。
- 数字对不上,去附录 C 看方法论与 IQR。
- 想不起某个类型叫什么,查附录 A 的模块速查。
- 如果是代码跑不通,先跑
npx tsc --noEmit看类型错误,往往比运行时错误更早暴露问题。
阅读上一节:附录 C 实测数据汇总
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。