- 文档
- 教程
- 前端
【免费下载链接】zh.javascript.info
现代 JavaScript 教程(The Modern JavaScript Tutorial),以最新的 ECMAScript 规范为基准,通过简单但足够详细的内容,为你讲解从基础到高阶的 JavaScript 相关知识。
本文围绕 zh.javascript.info(现代 JavaScript 教程)中「排除反向引用」这一经典习题展开,完整讲解
JSON.stringify在面对循环引用时的报错机理、replacer回调函数的调用契约,以及如何通过按值判断而非按名称判断来优雅剔除循环属性,让原本会抛Converting circular structure to JSON的对象结构顺利序列化。读完本文,你将掌握JSON.stringify(value, replacer, space)三参数的全部用法、replacer函数递归调用与首个包装调用{"": root}的细节,并能在实际项目中正确处理任何自引用、双向引用乃至更深层的环状数据结构。
背景:循环引用是 JSON 序列化的硬伤
JSON 方法,toJSON 一文明确指出JSON.stringify支持嵌套对象的自动转换,但有一条重要限制:不得有循环引用。看这个最典型的双向引用例子:
let room = { number: 23 }; let meetup = { title: "Conference", participants: ["john", "ann"] }; meetup.place = room; // meetup 引用了 room room.occupiedBy = meetup; // room 引用了 meetup JSON.stringify(meetup); // Error: Converting circular structure to JSON转换直接失败,因为存在环:meetup.place指向room,而room.occupiedBy又指回meetup。JSON 是语言无关的纯数据格式,它没有引用指针的概念,无法表达“同一个对象被引用两次”这一事实,更不可能表达“对象引用自己”。因此遇到环时,引擎只能抛错终止。
教程用示意图直观展示了这个环(见 json-meetup.svg:meetup.place→room,room.occupiedBy→meetup):
好消息是,JSON.stringify提供了第二参数replacer,允许我们在序列化过程中过滤、替换甚至跳过任何属性——这正是破解循环引用的官方出路。
方案一:用属性数组白名单,粗暴但可行
JSON.stringify的完整语法是:
let json = JSON.stringify(value[, replacer, space])value:要编码的值;replacer:要编码的属性数组,或映射函数function(key, value);space:用于格式化的空格数量。
先看数组白名单方案。把replacer传成属性名数组时,只有这些属性会被编码,而且该列表会作用于整个对象结构(包括嵌套层级):
let room = { number: 23 }; let meetup = { title: "Conference", participants: [{name: "John"}, {name: "Alice"}], place: room // meetup 引用了 room }; room.occupiedBy = meetup; // room 引用了 meetup alert( JSON.stringify(meetup, ['title', 'participants']) ); // {"title":"Conference","participants":[{},{}]}注意participants里的对象变成了{}——因为name不在白名单中。属性列表被递归地应用到整个结构,这正是它的局限:要么列全所有需要的属性,要么漏掉。把name、number也补上,就能得到正确结果:
alert( JSON.stringify(meetup, ['title', 'participants', 'place', 'name', 'number']) ); /* { "title":"Conference", "participants":[{"name":"John"},{"name":"Alice"}], "place":{"number":23} } */除了引发环的occupiedBy之外,其余属性全部保留。但两个问题显而易见:属性列表冗长(每新增一个字段都要同步维护),而且它是按名称过滤——一旦某个名字既出现在环上、又是业务上必须保留的常规属性,白名单方案就会误伤。
方案二:replacer 函数,按值判断才是正解
JSON.stringify的第二个参数也可以是函数。该函数会为每个(key, value)对调用,并返回“已替换”的值;返回undefined则跳过该属性。
先看教程中按名称过滤的初版:
alert( JSON.stringify(meetup, function replacer(key, value) { return (key == 'occupiedBy') ? undefined : value; }));这能解决room.occupiedBy这一个环,但正如任务 task.md 强调的:“有时我们不能只使用名称,因为它既可能在循环引用中也可能在常规属性中使用”。假设某个业务对象恰好有一个名为occupiedBy的常规字段,按名称一刀切就会把有效数据也丢掉。因此任务的进阶要求是——通过属性值来检查属性。
完整解答(来自 solution.md)
let room = { number: 23 }; let meetup = { title: "Conference", occupiedBy: [{name: "John"}, {name: "Alice"}], place: room }; // 循环引用 room.occupiedBy = meetup; meetup.self = meetup; alert( JSON.stringify(meetup, function replacer(key, value) { return (key != "" && value == meetup) ? undefined : value; })); /* { "title":"Conference", "occupiedBy":[{"name":"John"},{"name":"Alice"}], "place":{"number":23} } */核心逻辑一行:
return (key != "" && value == meetup) ? undefined : value;逐段拆解:
value == meetup:只要当前遍历到的属性值恰好就是根对象meetup本身,就返回undefined跳过。这覆盖了两个环:room.occupiedBy(其值正是meetup)和meetup.self(其值也是meetup)。注意这里用的是宽松相等==,对对象而言它和===效果一致(都退化为引用比较),但写法上需与key != ""并列使用。key != "":这是整个解答最容易被忽略、也最关键的一处。为什么必须排除空键?见下文。
为什么必须判断key == ""
replacer函数会获取每个键/值对,包括嵌套对象和数组项,且递归地应用。教程(article.md)特别说明:
第一个调用很特别。它是使用特殊的“包装对象”制作的:
{"": meetup}。换句话说,第一个(key, value)对的键是空的,并且该值是整个目标对象。这个理念是为了给replacer提供尽可能多的功能:如果有必要,它有机会分析并替换/跳过整个对象。
也就是说,序列化开始时引擎会以{"": meetup}的形式调用一次replacer。若不做key != ""判断,这一调用中value == meetup成立,整个根对象都会被undefined跳过,输出将变成空——序列化彻底失败。加上空键判断后,首调用按原值放行,真正的过滤从后续递归遍历才开始。
可以在 replacer 里打印每次调用的键值对,亲眼观察递归顺序:
alert( JSON.stringify(meetup, function replacer(key, value) { alert(`${key}: ${value}`); return (key != "" && value == meetup) ? undefined : value; })); /* 传入 replacer 的 key:value 对: : [object Object] ← 首个包装调用 {"": meetup} title: Conference occupiedBy: [object Object],[object Object] 0: [object Object] name: John 1: [object Object] name: Alice place: [object Object] number: 23 occupiedBy: [object Object] ← 值等于 meetup,被跳过 self: [object Object] ← 值等于 meetup,被跳过 */可见:replacer的this是包含当前属性的对象;数组元素以索引0、1作为键参与回调;place.number这类嵌套属性也会逐一过一遍。最终occupiedBy和self两个“回指 meetup”的属性被剔除,其余数据原样保留,输出干净且无损。
方案对比:白名单 vs 值判断
| 维度 | 属性数组白名单 | replacer 函数(按值判断) |
|---|---|---|
| 过滤依据 | 属性名 | 属性值(可同时结合键名) |
| 维护成本 | 每增删字段都要同步名单 | 无需维护,自动适配结构变化 |
| 误伤风险 | 同名但合法的常规属性会被误删 | 无——仅当值等于目标对象时才剔除 |
| 嵌套对象 | 名单递归作用于全结构,易漏字段 | 递归遍历每个 (key, value),精准 |
| 适用场景 | 结构固定、字段稀少 | 结构复杂、存在多重环/自引用 |
纵深扩展:把环“修剪”成合法的树
理解了按值过滤的原理后,可以推广出几类实战变体,它们都基于“replacer 返回值决定取舍”这一契约:
1. 剔除所有重复引用(去重而非仅删环)
JSON.stringify(data, function(key, value) { if (typeof value === "object" && value !== null && seen.has(value)) { return undefined; // 该对象已被序列化过一次,跳过 } if (typeof value === "object" && value !== null) seen.add(value); return value; });适合“多个属性指向同一共享对象、但你又不想重复展开”的场景(如共享配置、缓存引用)。
2. 用占位值替换环,保留结构信息
JSON.stringify(meetup, function(key, value) { if (value === meetup) return "[Circular]"; return value; });调试日志场景下,用占位字符串代替丢弃,可以让输出自解释“这里有一个回指根对象的引用”。
3. 只回传合法 JSON 类型,主动过滤函数/Symbol/undefined
JSON.stringify默认会静默跳过函数属性、Symbol 键/值以及值为undefined的属性(教程中sayHi、Symbol("id")、something: undefined均被忽略,最终得到{})。若想显式控制,也可以在 replacer 中统一返回undefined:
JSON.stringify(user, function(key, value) { if (typeof value === "function" || typeof value === "symbol" || value === undefined) return undefined; return value; });其他参数回顾:space 与 toJSON 的配合
replacer之外的另两个能力在排查循环引用问题时也常被用到:
space(第三个参数)——美化输出,便于肉眼审查环被剔除后的结果:
let user = { name: "John", age: 25, roles: { isAdmin: false, isEditor: true } }; alert(JSON.stringify(user, null, 2)); /* 缩进 2 空格: { "name": "John", "age": 25, "roles": { "isAdmin": false, "isEditor": true } } */space可以是数字(缩进空格数),也可以是字符串(用该字符串做缩进),仅用于日志与美化输出。
toJSON——对象自定义序列化出口。若对象定义了toJSON()方法,JSON.stringify会自动调用它,返回值作为该对象的序列化结果:
let room = { number: 23, toJSON() { return this.number; } }; let meetup = { title: "Conference", room }; alert( JSON.stringify(room) ); // 23 alert( JSON.stringify(meetup) ); // {"title":"Conference","room":23}利用toJSON也可以从源头“剪断”环——让对象在序列化时只暴露自己的值类型投影,不暴露引用关系。
反方向同样重要:JSON.parse 与 reviver
序列化问题解决后,别忘了配套的反序列化入口JSON.parse(str, [reviver])。reviver函数同样为每个(key, value)对调用,可对值进行转换。典型场景是把服务器返回的时间字符串还原成Date对象:
let str = '{"title":"Conference","date":"2017-11-30T12:00:00.000Z"}'; let meetup = JSON.parse(str, function(key, value) { if (key == 'date') return new Date(value); return value; }); alert( meetup.date.getDate() ); // 30,正常运行它同样递归作用于嵌套结构,例如对schedule.meetups数组内的每个元素还原date字段。stringify的replacer与parse的reviver构成了 JavaScript 中“智能读写 JSON”的完整闭环。
总结
- 循环引用会让
JSON.stringify抛出Converting circular structure to JSON,因为 JSON 纯数据格式无法表达环。 - 用属性数组做白名单可以避开环,但按名称过滤会误伤同名的常规属性,且名单随结构膨胀难以维护。
- 终极解法是按值判断:
replacer函数中对value等于根对象的属性返回undefined,即可精准剔除所有回指根对象的反向引用(双向引用、自引用一并覆盖)。 - 必须同时判断
key != "":replacer的首个调用以包装对象{"": root}发起,空键调用必须放行,否则整个根对象会被跳过、序列化结果为空。 - 配合
space美化输出、toJSON自定义投影、JSON.parse的reviver还原类型,可构建一整套健壮的 JSON 序列化/反序列化方案。
上述全部代码均可直接复制运行;更多完整示例见 article.md、本题任务描述 与 官方解答,配套的入门练习 将对象转换为 JSON,然后再转换回来 及 解答 也一并收录于 JSON 章节 供对照练习。
- 文档
- 教程
- 前端
【免费下载链接】zh.javascript.info
现代 JavaScript 教程(The Modern JavaScript Tutorial),以最新的 ECMAScript 规范为基准,通过简单但足够详细的内容,为你讲解从基础到高阶的 JavaScript 相关知识。
相关推荐
现代 JavaScript 教程:用 replacer 按值排除回引用,优雅解决 JSON.stringify 循环引用序列化
现代 JavaScript 教程:用 replacer 按值排除回引用,优雅解决 JSON.stringify 循环引用序列化 导读 对象图存在循环引用(如 A
文档/教程前端en.javascript.info 循环引用序列化实战:用 JSON.stringify replacer 按值排除 backreference
en.javascript.info 循环引用序列化实战:用 JSON.stringify replacer 按值排除 backreference 本篇文章基于
文档/教程前端JavaScript 循环实战:使用 for 循环输出 2 到 10 的偶数(zh.javascript.info 教程习题详解)
JavaScript 循环实战:使用 for 循环输出 2 到 10 的偶数(zh.javascript.info 教程习题详解) for 循环是 JavaSc
文档教程前端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考