先记住这个答案
JSON.stringify 递归遍历对象时,如果某个属性又指回了当前路径上的祖先,比如 user.self = user,遍历就会无限进行下去。引擎检测到这种情况会抛 TypeError,Chrome 和 Node 里的提示是 Converting circular structure to JSON。解决办法取决于目的:打日志,用 replacer 把环替换成标记,接受信息有损;深拷贝,用 structuredClone,它原生支持循环引用;通过网络传完整的对象图,就要设计 $id 和 $ref 这样的引用协议,因为 JSON 本身只能表示树。
- 循环引用让
JSON.stringify抛TypeError WeakSet版 replacer 简单,但会把共享引用误当成环- 只是深拷贝就用
structuredClone,它支持循环引用 - JSON 只能表示树,要传图得自己定引用协议
最常见的写法:WeakSet 记录见过的对象
日志上报、错误监控里经常要把一个复杂对象打出来,里面可能有组件实例、Express 的 req 这类带循环引用的对象,比如 req.res.req 又绕回了自己。先让它不报错再说。
WeakSet 版 replacerJavaScriptconst user = { name: 'Ada' }
user.self = user // 指回自己
try {
JSON.stringify(user)
} catch (err) {
console.log(err.name)
}
const seen = new WeakSet()
const json = JSON.stringify(user, (key, value) => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) return '[Circular]' // 见过的对象不再展开
seen.add(value)
}
return value
})
console.log(json)查看输出与解释
TypeError
{"name":"Ada","self":"[Circular]"}replacer 会对每个属性调用一次。第一次遇到一个对象就记下来,第二次再遇到就返回一个字符串标记,不再往里展开,遍历就能结束了。
用 WeakSet 而不是 Set,是因为它只存对象的弱引用,不会阻止对象被垃圾回收,语义上也正好是「见过这个对象」。不过这次 stringify 结束后 seen 就没用了,两者在这里差别不大。
要注意的是,输出里的 [Circular] 只是一个普通字符串,JSON.parse 之后不会重新连回 user。所以这种做法只适合日志这类「能看就行」的场景。
按目的选方案
先问清楚为什么要序列化,再选方案。
- 只是想深拷贝:
structuredClone - 很多人遇到这个报错,是因为在用
JSON.parse(JSON.stringify(obj))做深拷贝。structuredClone(obj)是浏览器和 Node 17 起内置的深拷贝函数,能正确处理循环引用,拷贝出来的self指向拷贝后的新对象。它还能拷贝 Date、Map、Set 这些 JSON 处理不了的类型。函数和 DOM 节点不能拷贝,会抛DataCloneError。 - 要通过接口传完整对象图:引用协议
- JSON 没有引用语法,要传图结构只能自己编码:第一次出现的对象带一个
$id,之后出现的地方写成{ "$ref": "1" }。接收端先把所有对象建出来,再把$ref换成真实引用。这需要前后端共同约定,不能只在前端偷偷做。
容易答错的地方
- 用 JSON 往返做所有深拷贝
- 除了循环引用会报错,JSON 往返还会把 Date 变成字符串,把 Map、Set 变成空对象,把
undefined属性直接删掉。深拷贝优先用structuredClone,它处理不了的类型再按业务单独处理。 - 看到对象第二次出现就认定是环
- 共享引用和循环引用是两回事。
WeakSet版的写法会把共享的那份数据丢掉,只能用于日志。需要尽量保留数据时,要用祖先栈这种只看当前路径的写法。
面试官还会怎么问?
Vue 或 React 的状态对象打日志为什么经常报这个错?
框架内部的对象本身就有互相指向的属性:Vue 组件实例有 $parent 和 $root,React 的 Fiber 用 return 和 child 连成树,还会通过 __reactFiber$ 开头的属性挂在 DOM 节点上。把 ref、事件对象或者整个组件实例直接 stringify,就会碰到环。打日志时应该只挑需要的字段,而不是把整个对象扔进去。
有没有现成的库?
flatted 可以把带循环的对象编码成能还原的字符串,适合需要往返的场景。只是打日志的话,Node 的 util.inspect() 和浏览器的 console.log 本身就能显示循环引用,会标成 [Circular *1] 这样的记号,不需要先转成 JSON。
JSON.stringify 为什么选择报错,而不是自动跳过环?
规范要求序列化时维护一个当前正在处理的对象栈,遇到栈里已经有的对象就抛 TypeError。选择报错而不是跳过,是因为跳过会悄悄丢数据,调用方根本不知道输出不完整。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。