flatted 实测:一个 155 行的循环引用 JSON 库藏了四个真实问题
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 由第三方社区贡献者发布,我本文只讨论代码层面观察,不做归属主张。)
- 点赞
- 收藏
- 关注作者
评论(0)