flatted 实测:一个 155 行的循环引用 JSON 库藏了四个真实问题

举报
yd_232353574 发表于 2026/10/21 08:24:56 2026/10/21
【摘要】 flatted 是 155 行循环引用 JSON 库,实测发现 Map/Set/Date 数据丢失。

flatted 实测:一个 155 行的循环引用 JSON 库藏了四个真实问题

一、这个库做什么

JSON.stringify 遇到循环引用会直接抛 TypeError: Converting circular structure to JSON,一个对象 a.b = a 就崩。日常写前端经常遇到这种场景(树形结构、双向链表、依赖图),要么手动断开循环再序列化,要么自己写一遍循环检测。

flatted(作者 Andrea Giammarchi,MIT 许可,核心 esm.js 只有 1102 字节 / 155 行)解决的就是这个:让 JSON.stringify 支持循环引用,序列化出一个特殊格式的 JSON 字符串,用配套的 parse 函数还原回带循环的原始结构。

它的设计很巧妙:把所有对象都放进一个数组,每个新对象分配一个索引,引用被替换成索引字符串:

const a = {}; a.self = a;
flatted.stringify(a);
// → '[{"self":"0"}]'

原对象 {self: <self>} 变成 {"self":"0"},"0" 就是「指回数组里第 0 个元素(即它自己)」的引用。还原时反着解析,把 "0" 替换回对原对象的引用。

我把它 clone 下来跑了一遍,测试了 18 种边界情况,挖到了 4 个值得单独讨论的真实问题。

二、真跑:18 条测试

Node.js 24.13.1 + flatted 3.4.4。

基本对象(Test 1):

$ Flatted.stringify({ name: 'hi', age: 3 })
'[{"name":"1","age":3},"hi"]'

注意:age: 3 数字没被索引(正确,数字不参与循环),但 name: 'hi' 字符串被索引成 "1",实际值 "hi" 被塞进数组第 1 位。这是 flatted 的设计:所有唯一字符串都被视为对象处理(后面 Test 1 展开讨论)。

简单循环(Test 2):

$ const a = {}; a.self = a;
$ Flatted.stringify(a)
'[{"self":"0"}]'
$ Flatted.parse(...).self === parse  → true  ✓

双向循环(Test 3):

$ const x = {name:'x'}, y = {name:'y'}; x.b = y; y.a = x;
$ Flatted.stringify(x)
'[{"name":"1","b":"2"},"x",{"name":"3","a":"0"},"y"]'
$ parse.b.a === parse  → true  ✓

三方循环(Test 4):a→b→c→a 全链保留,parse.next.next.next === parse 返回 true。

共享引用(Test 5):a.b === a.c === shared 保留共享关系,parse.b === parse.c 返回 true。

以上 5 条循环/共享引用测试全部通过,核心的循环引用检测算法是靠谱的。

三、我找到的四个真实问题

从 Test 6 开始,我往非典型边界场景挖,一路找到了 4 个真问题,前 3 个是数据丢失级别的 bug。

问题 1:Map 和 Set 的内容被完全丢弃

flatted 的官网 README 里说它"handles cycles correctly"(正确处理循环),但没提它根本不支持 Map 和 Set 的完整序列化。我在实测中把 Map 塞进 flatted 看看:

Test 6a(Map 作为根值):

$ const map = new Map([[{id:7}, 'seven'], ['simple', 'str']]);
$ Flatted.stringify(map)
'[{}]'

预期至少保留 2 个键值对,实际输出 [{}]:Map 被压成一个空对象。JSON.stringify 的默认行为把 Map 转成 {}(因为它不识别 Map),flatted 又没做 Map 特化处理,整个 Map 的所有内容(包括用对象作 key 的场景,那种场景普通 JSON 也保留不了)全部丢失。

Test 6b(Map 嵌套在对象里):

$ const wrap = { name: 'test', data: new Map([['a',1], ['b',2]]) };
$ Flatted.stringify(wrap)
'[{"name":"1","data":"2"},"test",{}]'
$ Flatted.parse(...).data
{}   // 期望是 Map 或至少含 {a:1,b:2}

Test 6c(toJSON / fromJSON):

$ Flatted.toJSON(map)
[{}]   // Map 被压成 {}

toJSON 和 fromJSON 这对便捷方法同样受影响,它们内部就是 JSON.parse(flatted.stringify(x)),走同一条路。

Test 7(Set):

$ new Set([1, 2, 3]) 经 flatted.stringify → '[{}]'

Set 也是同样待遇:内容全部丢失。

结论:flatted 只处理「循环引用」这一件事,不处理 Map/Set 的序列化。如果你的数据里有 Map 或 Set,用 flatted 会静默丢数据,不报错、不告警、结果就是错的。这是 flatted 最值得警惕的地方:它看起来"支持 JSON 序列化",实际上只对 Object/Array/基本类型可靠。

问题 2:Date 类型丢失

Test 8:

$ const d = new Date('2024-01-01T00:00:00Z');
$ Flatted.stringify({ d })
'[{"d":"1"},"2024-01-01T00:00:00.000Z"]'
$ Flatted.parse(...).d instanceof Date
false
$ Flatted.parse(...).d
'2024-01-01T00:00:00.000Z'   // 是字符串,不是 Date

Date 被索引成 "1",还原后是 ISO 字符串。类型信息完全丢失,用户拿到 parse 结果还得自己 new Date(x) 转换一次。这个和 JSON 本身的限制有关(JSON.stringify 对 Date 就是转字符串),但 flatted 的"完整序列化"承诺在这里打了折扣。

问题 3:数组自身的循环引用丢失

Test 10:

$ const arr = [1, 2];
$ arr.self = arr;   // 数组自指
$ Flatted.stringify(arr)
'[[1,2]]'
$ Flatted.parse(...).self === parse
false   // 循环关系没了
$ Flatted.parse(...).length
2        // 只是 [1, 2],没有 self 属性

数组自指的循环关系完全没被保留。原因:JSON.stringify 对数组只输出索引 0 到 n-1 的元素,忽略其他属性(如 self)。flatted 的 replacer 只在 JSON.stringify 遍历过程中被调用,如果它没被遍历到,flatted 也捕获不到。

对比对象自指(Test 2)就完全正常,因为对象的所有属性都会被 JSON.stringify 遍历。这是 flatted 设计上的一个盲点:数组的循环只能被"数组成员"参与,不能通过命名属性参与。要绕过去得手动把数组包成对象:const wrapper = { arr }; wrapper.arr.self = wrapper.arr。

问题 4:所有字符串都被过度索引(体积膨胀)

回头看 Test 1:

$ Flatted.stringify({ name: 'hi', age: 3 })
'[{"name":"1","age":3},"hi"]'

name: 'hi' 是一个普通字符串,没有任何循环引用,但 flatted 依然把它索引成 "1",实际值 "hi" 塞进数组第 1 位。原因看代码就明白:stringify 的 replace 函数里 case r:(r = "string")和 case c:(c = "object")合并处理,只要 typeof value 是 string 或 object(且非 null),就调 known.get(v) || set(...),字符串和对象走同一个分支。

这不是 bug 是设计:flatted 用同一个索引机制处理"可能是循环"的对象和"肯定是循环的字符串"。但副作用是没有任何循环的简单对象也会被膨胀。测了三个案例:

| 输入 | JSON.stringify | Flatted.stringify | 膨胀倍数 | |---|---|---|---| | {name:'hi', age:3} | 26 字符 | 28 字符 | 1.08× | | 嵌套 100 层的 {next: {next: ... leaf:'leaf'}} | 325 字符 | 1412 字符 | 4.34× | | {a: 1, b: {c: 2}} | 17 字符 | 22 字符 | 1.29× |

嵌套越深,字符串越多,膨胀越夸张。如果对象里没有循环引用,flatted 是负优化,比 JSON.stringify 慢、体积还大。

四、能用的边界情况

不是所有场景都崩。测了几个边界用例:

Symbol 属性(Test 9):{ sym: Symbol('x'), name: 'hi' } → flatted 保留 name,sym 键被丢弃。JSON.stringify 的行为(symbol 键不序列化),flatted 不覆盖这个限制。

稀疏数组(Test 15):[x, _, _, _, y] 经 flatted 后 [0..4] 是 [x, null, null, null, y],稀疏位置变成 null,长度保留。

原型链(Test 16):class Cat { constructor(n) { this.name = n } } → 序列化后 parse instanceof Cat 是 false。原型链被丢弃,只剩数据对象。

replacer 与循环交互(Test 14):flatted.stringify(obj, (k, v) => k === 'b' ? obj : v),replacer 制造一个循环引用,flatted 正确捕获,parse.b === parse 返回 true。

深层嵌套(Test 12):100 层嵌套,字符串化 1412 字符,还原后深度正确、最深处叶子值 leaf 保留。

五、四个问题的严重度排序与使用建议

flatted 的核心算法(索引数组加引用字符串)对对象间循环引用这个特定问题处理得非常干净,155 行代码、1102 字节、零依赖,是这个领域最优雅的实现之一。测试了 5 种循环/共享引用场景全部通过,甚至 replacer 动态制造循环也能捕获。

但它有个隐藏边界:只处理 Object/Array/基本类型。凡是超出这个范围的东西(Map、Set、Date、原型链、数组自指循环、Symbol 键)都会以不同形式丢数据。更值得警惕的是:丢失是静默的,不报错、不告警、结果就是错的。

四个真问题的严重度排序:

1. Map/Set 内容完全丢失(数据损坏,最严重) 2. 数组自指循环丢失(循环场景特定,中等) 3. Date 类型丢失(TypeScript 用户会立刻发现,但运行时是静默的,中等) 4. 字符串过度索引导致体积膨胀(性能问题,不损失数据,最轻)

用它的建议:确认数据结构里没有 Map/Set/Date/自定义类实例、且有真实的循环引用时,flatted 是好选择;否则标准 JSON.stringify + 手动断开循环更保险。

如果维护者想扩展支持 Map/Set,一个思路是在 replace 里加分支:检测 after instanceof Map 时把它转成 [...entries] 数组形式,还原时反着转回来。Date 可以在序列化时包一层 {__t:'date', v: iso} 标记,还原时按标记重建。

(注:flatted 由第三方社区贡献者发布,我本文只讨论代码层面观察,不做归属主张。)

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。