接口返回订单详情时抛错,错误信息是 Converting circular structure to JSON。数据是从数据库查出来拼的对象,里面有个指向父节点的字段形成了环,直接序列化就会失败。
常见的处理是给 JSON.stringify 传一个 replacer,用 WeakSet 记下访问过的对象,再遇到就替换成占位串。这个写法很普遍,先看它在真循环上跑得怎么样。
// 验证脚本:处理循环引用的几种写法在同一份输入上的表现
const 造输入 = () => {
const 根 = { 名称: '根', 子节点: [] };
const 叶 = { 名称: '叶', 父节点: 根 };
根.子节点.push(叶);
return 根;
};
const 甲 = (值) => JSON.stringify(值);
const 乙 = (值) => {
const 已访问 = new WeakSet();
return JSON.stringify(值, (键名, 当前值) => {
if (typeof 当前值 === 'object' && 当前值 !== null) {
if (已访问.has(当前值)) return '[循环引用]';
已访问.add(当前值);
}
return 当前值;
});
};
const 丙 = (值) => {
const 副本 = JSON.parse(JSON.stringify(值, (键名, 当前值) => {
if (键名 === '父节点') return undefined;
return 当前值;
}));
return JSON.stringify(副本);
};
console.log('=== 写法一:不处理 ===');
try {
console.log(甲(造输入()));
} catch (错误) {
console.log('抛出', 错误.message.split('\n')[0]);
}
console.log('\n=== 写法二:WeakSet 打占位 ===');
console.log(乙(造输入()));
console.log('\n=== 写法三:摘掉父指针 ===');
console.log(丙(造输入()));
实际输出:
=== 写法一:不处理 ===
抛出 Converting circular structure to JSON
=== 写法二:WeakSet 打占位 ===
{"名称":"根","子节点":[{"名称":"叶","父节点":"[循环引用]"}]}
=== 写法三:摘掉父指针 ===
{"名称":"根","子节点":[{"名称":"叶"}]}
三种写法都能让序列化跑通。写法二保留了 父节点 这个键,值换成占位串;写法三直接把键去掉。到这里 WeakSet 的写法看起来是够用的。
问题出在没有环的数据上。同一个对象被两处引用属于很常见的结构,比如订单里的账单地址和收货地址本来就该是同一个对象。这种数据可以直接序列化,根本不需要任何处理。
// 验证脚本:WeakSet 去环写法对非循环重复引用的影响
const 去环 = (值) => {
const 已访问 = new WeakSet();
return JSON.stringify(值, (键名, 当前值) => {
if (typeof 当前值 === 'object' && 当前值 !== null) {
if (已访问.has(当前值)) return '[循环引用]';
已访问.add(当前值);
}
return 当前值;
});
};
const 地址 = { 省: '江苏', 市: '南京', 详址: '某路 1 号' };
const 订单 = { 单号: 'A001', 账单地址: 地址, 收货地址: 地址 };
console.log('=== 原始序列化(不处理) ===');
console.log(JSON.stringify(订单));
console.log('\n=== 用 WeakSet 去环之后 ===');
console.log(去环(订单));
console.log('\n=== 反序列化后两个地址是否还相等 ===');
const 还原 = JSON.parse(去环(订单));
console.log('账单地址', JSON.stringify(还原.账单地址));
console.log('收货地址', JSON.stringify(还原.收货地址));
console.log('两者相等', JSON.stringify(还原.账单地址) === JSON.stringify(还原.收货地址));
实际输出:
=== 原始序列化(不处理) ===
{"单号":"A001","账单地址":{"省":"江苏","市":"南京","详址":"某路 1 号"},"收货地址":{"省":"江苏","市":"南京","详址":"某路 1 号"}}
=== 用 WeakSet 去环之后 ===
{"单号":"A001","账单地址":{"省":"江苏","市":"南京","详址":"某路 1 号"},"收货地址":"[循环引用]"}
=== 反序列化后两个地址是否还相等 ===
账单地址 {"省":"江苏","市":"南京","详址":"某路 1 号"}
收货地址 "[循环引用]"
两者相等 false
收货地址被替换成了字符串。这份数据原本没有任何循环,序列化本来就能成功,是去环逻辑自己制造了损坏,而且不会报错。
根因是 WeakSet 判定的对象是「被访问过」而不是「在祖先链上」。前者把共享引用和真循环混为一谈,后者才是循环的定义。用一个能否直接序列化成功来区分这两类输入,差别很清楚:
// 验证脚本:区分真循环与共享引用
const 一律判重 = (值) => {
const 已访问 = new WeakSet();
return JSON.stringify(值, (键名, 当前值) => {
if (typeof 当前值 === 'object' && 当前值 !== null) {
if (已访问.has(当前值)) return '[重复]';
已访问.add(当前值);
}
return 当前值;
});
};
const 地址 = { 省: '江苏' };
const 菱形 = { 账单: 地址, 收货: 地址 };
const 环 = { 名称: '根' };
环.自己 = 环;
const 试序列化 = (值) => {
try {
JSON.stringify(值);
return '可直接序列化,无真循环';
} catch {
return '序列化失败,存在真循环';
}
};
console.log('=== 菱形共享输入 ===');
console.log('一律判重 ', 一律判重(菱形));
console.log('原始序列化', JSON.stringify(菱形));
console.log('\n=== 真循环输入 ===');
console.log('一律判重 ', 一律判重(环));
console.log('\n=== 判定依据 ===');
console.log('菱形共享:', 试序列化(菱形));
console.log('真循环: ', 试序列化(环));
实际输出:
=== 菱形共享输入 ===
一律判重 {"账单":{"省":"江苏"},"收货":"[重复]"}
原始序列化 {"账单":{"省":"江苏"},"收货":{"省":"江苏"}}
=== 真循环输入 ===
一律判重 {"名称":"根","自己":"[重复]"}
=== 判定依据 ===
菱形共享: 可直接序列化,无真循环
真循环: 序列化失败,存在真循环
菱形共享的输入序列化完全正常,一律判重 依然把第二处替换掉了。这两类输入在数据形状上都是「同一对象出现两次」,靠形状分不出来。
如果数据类型自己知道哪个字段不该出现,写法三更稳。父指针、指向所属集合的反向引用这类字段是明确的,按字段名摘掉,不会碰到地址这种需要保留正常重复引用的结构。
字段名不固定、无法预先枚举的时候,可以做一次祖先链判定:进入一个对象时把它压入链,离开时弹出,只有当前对象出现在自己的祖先链上才算真循环。JSON.stringify 的 replacer 没有提供「离开」回调,靠 this 也拿不到可靠的分支结束时机,所以这条路径通常要自己写递归遍历,而不是套在 replacer 上。自己写遍历能拿到正确的祖先链,代价是要手动处理数组、Date、Map 这些类型的还原,比调一个 replacer 繁琐得多,值不值得取决于数据里有多少种类型。
到这里更倾向于优先找那个不该出现的字段。序列化失败几乎总能定位到一个具体的反向引用,摘掉它就结束了,比引入一个会改写共享引用的通用去环函数风险小。只有当数据是外部传入、结构完全不可控时,通用去环才有存在价值,而且这种情况下也应该把替换标记写成一眼能看出是异常的形态,别用普通字段值。
写法三要预先知道字段名。字段是动态拼出来的,或者同一份代码要处理多种不同结构,摘字段的做法就得跟着变,维护成本落在调用方身上。两种写法的取舍取决于字段名是否可控,这一点没法一概而论。