侧边栏壁纸
博主头像
一笑痕

人生若只如初见,
是可喜亦或者是可悲?

  • 累计撰写 117 篇文章
  • 累计收到 7 条评论

用 WeakSet 去循环引用,把一份没环的订单数据也改坏了

2026-9-24 / 0 评论 / 10 阅读

接口返回订单详情时抛错,错误信息是 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 繁琐得多,值不值得取决于数据里有多少种类型。

到这里更倾向于优先找那个不该出现的字段。序列化失败几乎总能定位到一个具体的反向引用,摘掉它就结束了,比引入一个会改写共享引用的通用去环函数风险小。只有当数据是外部传入、结构完全不可控时,通用去环才有存在价值,而且这种情况下也应该把替换标记写成一眼能看出是异常的形态,别用普通字段值。

写法三要预先知道字段名。字段是动态拼出来的,或者同一份代码要处理多种不同结构,摘字段的做法就得跟着变,维护成本落在调用方身上。两种写法的取舍取决于字段名是否可控,这一点没法一概而论。

    🤞 分享