JSON.stringify 在生产中反复咬人的那些边角案例
一个请求处理器昨天还正常运行,今天却抛出 TypeError: Converting circular structure to JSON。另一个 payload 里字段突然消失,还有用户生成内容让 script 标签提前闭合导致页面渲染成纯文本。这些都不是罕见异常,而是 JSON.stringify 在生产中反复出现的边角案例。
循环引用错误在生产请求中突然触发的原因
循环引用是 JavaScript 对象中最容易在生产环境突然爆发的序列化问题。昨天代码还正常,今天同一个请求处理器却抛出 TypeError: Converting circular structure to JSON,原因往往在于数据结构的演化。
最常见的场景是对象之间相互引用。例如,用户对象包含一个订单列表,而每个订单又持有一个指向用户的引用。当业务逻辑新增了这个反向引用后,原本用于 API 响应的对象就变成了环状结构。JSON.stringify 在遇到循环时会直接抛出错误,不会尝试序列化。
另一个触发点是缓存或单例模式。许多应用会把 Redux store、数据库连接池或 WebSocket 管理器挂在全局对象上,这些对象内部又引用了业务数据。一旦某个序列化函数无意中把全局上下文也打包进去,循环就形成了。
生产中更隐蔽的情况是异步操作导致的引用变化。Promise、事件发射器或 React 的 ref 对象在序列化时刻可能形成临时循环。昨天的代码因为数据量小或路径不同没有触达这个分支,今天流量增大或新增了一个特性,就立刻暴露出来。
这个错误不会在开发环境轻易复现,因为开发者通常只序列化干净的 POJO。真实生产数据往往混杂了框架内部状态、第三方库实例和业务缓存,导致循环在最不该出现的时候出现。开发者通常在日志里看到这个 TypeError 后,才意识到序列化函数被用在了不该用的地方。
解决循环问题不能只靠 catch。根本上需要理解数据流:哪些对象是真正需要序列化的,哪些是运行时状态。很多团队后来引入了结构化克隆或手动清理函数来打破循环,但这也意味着必须在每次新增业务引用时重新检查序列化路径。
字段莫名消失的序列化规则
字段突然从 payload 里消失是另一个高频生产事故。JSON.stringify 对 undefined 的处理规则直接导致了这个问题。
当对象属性值为 undefined 时,JSON.stringify 会直接跳过这个键。数组里的 undefined 则会被转为 null。这两种行为在文档里写得清清楚楚,但开发者往往只记得“它能把对象转成字符串”,忽略了类型丢失。
不可枚举属性也会被 silently 丢弃。Object.defineProperty 设置的 enumerable: false 属性、Symbol 作为键的属性、原型链上的属性,都不会出现在序列化结果中。很多 ORM 返回的模型实例带有大量非枚举的元数据,序列化后只剩下一小部分业务字段,导致下游服务收到残缺 payload。
生产中常见的场景是可选字段。某个接口原本返回 { name: ‘xx’, age: 30 },后来业务允许 age 不填,代码里就变成了 age: undefined。结果 JSON 里 age 这个键彻底不见了,接收端如果依赖这个键做版本兼容或默认值填充,就会出现逻辑错误。
另一个案例是 class 实例。类属性如果没有在 constructor 里赋值,或者用了 getter/setter 而非 data property,也可能在序列化时丢失。很多开发者把 class 当普通对象用,直到上线后才发现部分字段不见了。
这个规则本身没有错,但它打破了“序列化就是深拷贝”的常见误解。字段消失不是 bug,而是 JSON 规范对 JavaScript 类型映射的必然结果。理解这一点才能决定是该用 null 占位,还是在序列化前做一次显式转换。
用户生成内容如何让 script 标签提前闭合
把 JSON 直接嵌入 HTML 的 script 标签是常见优化手段,却也是 XSS 和渲染故障的重灾区。用户生成内容里的特定字符会让 script 标签提前闭合。
最经典的例子是用户输入中包含 字符串。当 JSON.stringify 直接把这个字符串放进 时,浏览器解析 HTML 时会在第一个 处结束 script 块,后面的内容被当作普通文本渲染,整个页面因此出现大片纯文本。
即使经过 JSON.stringify,字符串里的 <、>、& 等字符也需要额外转义。JSON 规范本身不要求对这些 HTML 特殊字符转义,因为 JSON 是独立于 HTML 的格式。但当它被嵌入 HTML 上下文时,就必须进行额外处理。
生产事故通常发生在评论系统、富文本编辑器或配置页面。用户输入看似无害的字符串,经过序列化后嵌入页面,运维半夜收到“页面白屏”报警,才发现是 或者 <!– 导致的解析错误。
防御手段是在 JSON 序列化后再做一次 HTML 转义,把 < 转为 \u003C,> 转为 \u003E,& 转为 \u0026。这样即使字符串里包含 script 标签,浏览器也无法识别为真正的标签。
这个案例说明 JSON.stringify 的输出不能直接用于所有上下文。序列化结果的安全性取决于最终的嵌入环境,忽略这一点就会在生产中反复踩坑。
JSON.stringify 对函数和特殊类型的默认丢弃
很多开发者以为 JSON.stringify “just works”,直到函数、Symbol、正则、Date 等类型被静默丢弃才醒悟。
函数在序列化时会被完全忽略。对象里的方法、事件处理函数、箭头函数属性,经过 stringify 后直接消失。这在序列化配置对象或 Redux action creator 时特别容易出问题。
Symbol 作为属性键时同样被丢弃。Symbol.for(‘key’) 创建的键在 JSON 中没有对应表示形式,因此整个属性对都会不见。BigInt 则会直接抛出 TypeError,因为 JSON 规范不支持这个类型。
Date 对象会被转为字符串,这是少数被保留的特殊类型之一。但正则表达式会被转为 {},Map 和 Set 会被转为普通对象且丢失内部数据,WeakMap、WeakSet 则完全无法序列化。
这些默认行为源于 JSON 规范只支持有限的 JavaScript 子集。stringify 选择静默失败而不是抛错,是为了让大多数常见场景“能跑”。但在生产系统中,这种“能跑”往往意味着数据丢失或下游解析失败。
开发者必须明确:JSON 不是 JavaScript 的通用序列化格式。它是为数据交换设计的,而不是为了保存程序状态。认识到这一点后,才能决定在哪些场景该用 structuredClone,哪些场景该用自定义序列化器。
跨语言序列化在精度和类型上的差异
JSON 在不同语言间的实现差异是另一类生产事故的根源,尤其体现在数值精度和类型映射上。
JavaScript 的 Number 是双精度浮点数。当数字超过 2^53 - 1 这个安全整数范围后,JSON.stringify 仍然会输出完整数字,但其他语言在解析时可能丢失精度。Python 的 json 模块默认把大整数解析为 float,导致尾数丢失。Java 的 Jackson 在处理超大 long 时也需要特殊配置。
小数精度同样麻烦。0.1 + 0.2 在 JavaScript 里是 0.30000000000000004,序列化后这个值会被精确保留。其他语言的反序列化器可能把它解析为 0.3 或保持原样,造成跨服务比较时的不一致。
类型映射差异也很显著。JSON 没有日期类型,所有语言都把 Date 转为字符串,但格式约定各不相同。有的用 ISO 字符串,有的用时间戳。空值处理也不同:JSON 的 null 在 Go 中可能映射为 nil,在 Rust 中可能是 Option::None,处理不当就会出现空指针。
枚举类型是另一个雷区。TypeScript 的 enum 在编译后是数字或字符串,序列化后丢失了枚举含义。接收端的 Java 或 Python 服务如果按字符串匹配,就会出现枚举值不匹配的 bug。
这些差异不是 bug,而是各语言对 JSON 规范的不同实现方式。跨语言团队必须在接口契约中明确数值范围、日期格式、空值语义和精度要求,否则生产环境中就会持续出现“同一个 JSON 在不同服务里行为不一致”的问题。
用 replacer 和防御性检查避免生产事故
避免上述问题的最有效方式是放弃对 JSON.stringify 默认行为的依赖,转而使用 replacer 函数和显式防御性检查。
replacer 是 stringify 的第二个参数,可以是一个函数或数组。函数形式能在序列化每个键值对时介入,允许自定义转换逻辑。例如,遇到 undefined 时可以转为 null,遇到 Date 时统一转为 ISO 字符串,遇到函数时可以转为函数名字符串或直接跳过。
一个典型的防御性 replacer 会同时处理循环引用。它可以维护一个 WeakSet 来记录已经访问过的对象,遇到重复引用时返回一个占位标记如 ‘[Circular]’ 而不是抛错。这样即使数据结构包含循环,序列化也能完成。
针对 HTML 嵌入场景,可以在 replacer 之后再做一次转义步骤,或者直接在 replacer 里把 < > & 转为 Unicode 转义。针对 BigInt,可以在 replacer 中把它转为字符串并加上后缀标记,接收端再根据标记解析。
除了 replacer,序列化前进行结构检查也很重要。可以使用 lodash 的 cloneDeepWith 或手动遍历对象,提前把不可序列化的值替换掉。也可以在 CI 流程中增加测试用例,专门针对生产中常见的循环、undefined、Symbol 等情况进行序列化验证。
跨语言团队应该制定统一的序列化工具库,把 replacer 逻辑、日期格式、数值精度处理封装进去,所有服务都通过这个库进行 JSON 转换。这样能大幅降低因语言差异导致的事故。
生产事故的根本原因不是 JSON 本身,而是开发者把它当作“万能序列化工具”使用。明确它的边界,编写防御性代码,把隐含假设变成显式规则,才能真正减少这些反复出现的边角案例。
(全文约 2150 字)
参考来源
- 原文作者:知识铺
- 原文链接:https://index.zshipu.com/geek001/post/20260904/JSON.stringify-%E5%9C%A8%E7%94%9F%E4%BA%A7%E4%B8%AD%E5%8F%8D%E5%A4%8D%E5%92%AC%E4%BA%BA%E7%9A%84%E9%82%A3%E4%BA%9B%E8%BE%B9%E8%A7%92%E6%A1%88%E4%BE%8B/
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。
- 免责声明:本页面内容均来源于站内编辑发布,部分信息来源互联网,并不意味着本站赞同其观点或者证实其内容的真实性,如涉及版权等问题,请立即联系客服进行更改或删除,保证您的合法权益。转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。也可以邮件至 sblig@126.com