17.2 引用、句柄与循环的处理
17.1 的两个编码器都明确拒绝 ref / array 字段,因为一旦字段指向另一个对象,麻烦就来了:同一个对象可能被两处引用,可能互相指来指去指成一个环。场景树天然是后者——父节点的 children 指向子节点,子节点的 parent 又指回父节点。这一节先让朴素做法把错误犯出来,再给一个带 id 表的序列化器把它解决掉,最后说明句柄为什么是「能存进存档的引用」。
朴素序列化遇环:真实的报错
最省事的写法是直接 JSON.stringify。造一棵最小的父子互指图,本机实跑 node src/core/circular-demo.ts,先看它到底抛什么。
// file: src/core/circular-demo.ts
// tier: L1
// verify: node src/core/circular-demo.ts
//
// 朴素 JSON.stringify 遇环的真实报错文本。
type SceneNode = {
name: string;
parent: SceneNode | null;
children: SceneNode[];
};
const island: SceneNode = { name: 'island', parent: null, children: [] };
const rock: SceneNode = { name: 'rock', parent: island, children: [] };
island.children = [rock];
try {
JSON.stringify(island);
} catch (e) {
const err = e as Error;
console.log('name :', err.name);
console.log('message :', err.message.split('\n')[0]);
console.log('--- full ---');
console.log(err.message);
}
输出如下,报错类型是 TypeError,message 的第一行是 Converting circular structure to JSON,后面几行还贴心地画出了环的路径:
name : TypeError
message : Converting circular structure to JSON
--- full ---
Converting circular structure to JSON
--> starting at object with constructor 'Object'
| property 'children' -> object with constructor 'Array'
| index 0 -> object with constructor 'Object'
--- property 'parent' closes the circle
这段原文值得记住,它是你在真实项目里排查存档崩溃时最常撞见的一行。报错本身没错——JSON.stringify 无法把一个无限递归的结构展开成有限文本,只能拒绝。问题不在 JSON.stringify,在对象引用本身不适合直接落盘。
靠 replacer 去重:不抛错了,但形状丢了
很多人第一反应是给 JSON.stringify 传一个 replacer,用一个 WeakSet 记住「见过的对象」,第二次遇到就写个占位符。它确实不再抛错了,但代价是引用关系被压扁成字符串。本机实跑 node src/core/replacer-demo.ts:
// file: src/core/replacer-demo.ts
// tier: L1
// verify: node src/core/replacer-demo.ts
//
// 用 JSON.stringify 的 replacer + WeakSet 去重:不抛错了,但形状丢了。
type SceneNode = { name: string; parent: SceneNode | null; children: SceneNode[] };
const island: SceneNode = { name: 'island', parent: null, children: [] };
const rock: SceneNode = { name: 'rock', parent: island, children: [] };
island.children = [rock];
const seen = new WeakSet<object>();
const json = JSON.stringify(island, (key, value) => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) return '[circular]';
seen.add(value);
}
return value;
});
console.log(json);
{"name":"island","parent":null,"children":[{"name":"rock","parent":"[circular]","children":[]}]}
rock.parent 变成了字符串 "[circular]"。这份 JSON 能写盘、不抛错,但读回来时你无法知道那个占位符原本指向谁——父子的双向关系退化成了一根单向的 children 边。对一棵场景树来说,这等于丢了一半的结构。结论:去重只能让序列化「跑得通」,不能让对象图「还原得回」。要还原,就得让每个引用带上目标对象的稳定编号。
带 id 表的对象图序列化器
解法只有一句话:不要序列化「引用」,序列化「引用的编号」。给每个对象分配一个整数 id,遇到 ref 字段就写目标对象的 id,遇到 array 就写一组 id。于是环变成「rock.parent = 0」和「island.children = [1]」两条普通边,都能落盘。
// file: src/core/graph-serializer.ts
// tier: L1
// verify: npx vitest run src/core/graph-serializer.test.ts
//
// helio 内核:带 id 表的对象图序列化器。
// 引用被替换成整数 id,循环引用因此变成两次「指向同一 id」,可正常写盘。
import type { FieldDescriptor, TypeRegistry } from './registry.ts';
import { defaultValue } from './json-serde.ts';
export type GraphNode = {
id: number;
type: string;
fields: Record<string, unknown>;
};
export type GraphDocument = {
root: number;
nodes: GraphNode[];
};
/**
* 把以 root 为起点的对象图写成「节点表 + id 引用」。
* 每个对象只在 nodes 里出现一次,第二次遇到就复用它的 id。
*/
export function serializeGraph(
reg: TypeRegistry,
typeName: string,
root: Record<string, unknown>,
): GraphDocument {
const ids = new Map<object, number>();
const nodes: GraphNode[] = [];
const visit = (obj: Record<string, unknown>, tName: string): number => {
const seen = ids.get(obj);
if (seen !== undefined) return seen;
const desc = reg.require(tName);
const id = nodes.length;
ids.set(obj, id);
const fields: Record<string, unknown> = {};
nodes.push({ id, type: desc.name, fields }); // 先占位,再递归,环才走得通
for (const f of desc.fields) {
const v = obj[f.name];
if (v === undefined) continue;
if (f.kind === 'ref') {
fields[f.name] = v === null ? null : visit(v as Record<string, unknown>, f.type!);
} else if (f.kind === 'array') {
fields[f.name] = (v as Record<string, unknown>[]).map((x) =>
x === null ? null : visit(x, f.type!),
);
} else {
fields[f.name] = v;
}
}
return id;
};
const rootId = visit(root, typeName);
return { root: rootId, nodes };
}
/** 两趟重建:先建所有空壳,再按 id 连引用——循环引用靠对象同一性自然复原。 */
export function deserializeGraph(
reg: TypeRegistry,
doc: GraphDocument,
): Record<string, unknown> {
const shells: Record<string, unknown>[] = doc.nodes.map((n) => {
const desc = reg.require(n.type);
const obj: Record<string, unknown> = {};
for (const f of desc.fields) {
if (f.kind === 'ref') obj[f.name] = null;
else if (f.kind === 'array') obj[f.name] = [];
else obj[f.name] = defaultValue(f);
}
return obj;
});
for (const n of doc.nodes) {
const desc = reg.require(n.type);
const obj = shells[n.id];
for (const f of desc.fields) {
const raw = n.fields[f.name];
if (raw === undefined) continue;
if (f.kind === 'ref') {
obj[f.name] = raw === null ? null : shells[raw as number];
} else if (f.kind === 'array') {
obj[f.name] = (raw as number[]).map((id) => shells[id]);
} else {
obj[f.name] = raw;
}
}
}
return shells[doc.root];
}
两个关键点。第一,visit 里先把节点压进 nodes 占位,再递归处理字段——否则递归回到自己时 ids 里还没有它,会无限递归下去。第二,deserializeGraph 分两趟:先为每个节点建一个空壳并按 id 摆好,第二趟才把 id 翻译成对象引用。两趟的意义在于,连引用时目标空壳一定已经存在,环就自然闭合了。
可运行产物:父子互指的图怎么往返
把上面的 SceneNode 注册进描述符,跑一遍序列化再反序列化,打印节点数和文档结构。本机实跑 node src/core/graph-demo.ts:
// file: src/core/graph-demo.ts
// tier: L1
// verify: node src/core/graph-demo.ts
//
// 父子互指的对象图:带 id 表的序列化器怎么把它写下来、再原样读回。
import { TypeRegistry } from './registry.ts';
import { deserializeGraph, serializeGraph } from './graph-serializer.ts';
type SceneNode = { name: string; parent: SceneNode | null; children: SceneNode[] };
const reg = new TypeRegistry();
reg.register({
name: 'SceneNode',
version: 1,
fields: [
{ name: 'name', kind: 'string' },
{ name: 'parent', kind: 'ref', type: 'SceneNode' },
{ name: 'children', kind: 'array', type: 'SceneNode' },
],
});
const island: SceneNode = { name: 'island', parent: null, children: [] };
const rock: SceneNode = { name: 'rock', parent: island, children: [] };
island.children = [rock];
const doc = serializeGraph(reg, 'SceneNode', island as unknown as Record<string, unknown>);
console.log('nodes :', doc.nodes.length);
console.log('doc :', JSON.stringify(doc));
const back = deserializeGraph(reg, doc) as unknown as SceneNode;
console.log('identity:', back.children[0].parent === back);
console.log('name :', back.children[0].name);
nodes : 2
doc : {"root":0,"nodes":[{"id":0,"type":"SceneNode","fields":{"name":"island","parent":null,"children":[1]}},{"id":1,"type":"SceneNode","fields":{"name":"rock","parent":0,"children":[]}}]}
identity: true
name : rock
island 是 id 0,rock 是 id 1;rock.parent 写成 0,island.children 写成 [1]。环被拆成了两条普通的整数边。反序列化之后 back.children[0].parent === back 为 true——不只是字段值相等,而是引用指向了同一个对象,父子关系真的复原了。这份用例也写进了 vitest(npx vitest run src/core/graph-serializer.test.ts,全部通过)。
句柄:能存进存档的引用
对象图序列化解决的是「一次序列化里」的引用。但引擎里还有另一种引用——跨存档、跨帧、甚至跨网络的那一种,它指向的是引擎运行时容器里的一个对象,而不是同一份 JSON 里的另一个节点。这种引用不能用 id 表,得用第 6 章的世代句柄(6.2 slot map 与世代句柄 )。
原因很直接:句柄是纯整数,kind: 'handle' 的字段直接编码成 4 字节 Uint32,加载后拿它去 SlotMap.get 重新解析即可。而对象引用一旦写进文件,下次读回来就指向一个不存在的对象。
// snippet: 句柄随存档往返,实体销毁后旧句柄解析成 undefined
const entities = new SlotMap<{ label: string }>();
const chest = entities.insert({ label: 'chest' });
// 句柄是纯整数,序列化后仍是整数,加载后可以重新解析。
const saved = JSON.parse(JSON.stringify({ owner: chest })) as { owner: number };
entities.get(saved.owner)?.label; // 'chest'
// 实体销毁后,旧句柄的世代对不上,解析成 undefined(防悬垂)。
entities.remove(chest);
entities.get(chest); // undefined
于是「引用」在引擎里分成两种,各有各的存法:同一次序列化内的对象关系用 id 表;跨存档的运行时对象用句柄。前者保的是对象图的形状,后者保的是「指向哪个活着的实体」——而后者能顺带把「实体已经没了」这件事变成一个可检测的 undefined,而不是一个指向幽灵对象的悬垂引用。
常见坑
- 忘了先占位再递归。若在递归字段之后才把对象登记进 id 表,环会在登记前再次进入
visit,直接爆栈。 - 反序列化只走一趟。一趟建对象、边建边连引用,遇到「A 引用 B、B 引用 A」时 B 还不存在,只能连出半个图。必须两趟。
- 拿 id 当稳定标识。id 是一次序列化内的临时编号,同一个对象换一次序列化顺序就可能换 id,别把它当业务主键。
- 数组里混进 null 没处理。
children允许有空位时,null要原样写成null,不能当成「未定义」跳过,否则数组下标会错位。 - 把句柄和对象图 id 混用。句柄属于运行时容器(会随增删改变有效性),id 属于本次文档(只在一份 JSON 内有意义),两者不可互换。
有了对象图和句柄,任意复杂的存档结构都能落盘了。但还有最后一个问题:格式会变。今天写的 hp 字段,明天可能改名叫 health、类型也可能变。这是 17.3 版本化与迁移
要处理的。
阅读导航:上一节:17.1 反射驱动的序列化:JSON 与二进制 · 下一节:17.3 版本化与迁移 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。