首页 / 资讯中心 / 文章详情

JSON.stringify 深入解析:从参数规则到工程化封装

JSON.stringify 深入解析:从参数规则到工程化封装 ★ FEATURED ARTICLE
不知道有多少人和我一样最开始用 JSON.stringify 就只记一个用法JSON.stringify(data)把对象变成字符串完事。后来在项目里被各种诡异数据坑过几轮才意识到这个 API 的规则比想象中多得多——不是所有数据都能被序列化也不是所有字段都会原样输出。接口返回的对象里有 Date存到本地再读出来变成了字符串日志里打出去的 Error 对象只有一个{}参数里带了 BigInt一调用 JSON.stringify 直接抛错对象互相引用时抛经典的Converting circular structure to JSON。这篇文章就把 JSON.stringify 从参数到边界规则再到工程化封装一次性聊透适合写业务代码的前端、做数据处理的 Node 开发者以及所有想搞清对象到字符串背后到底发生了什么的人。1. 三个参数拆开看value、replacer、space1.1 value 参数什么样的数据才算可序列化JSON.stringify 的第一个参数没什么好说的就是你要序列化的数据。但能传进去和能被正常序列化是两回事。基础类型里 string、number、boolean、null 都没问题真正出问题的集中在 undefined、function、symbol、bigint 这些特殊类型上。直接看结论JSON.stringify(hello); // hello注意带双引号 JSON.stringify(123); // 123 JSON.stringify(true); // true JSON.stringify(null); // null JSON.stringify(undefined); // undefined不是字符串 JSON.stringify(function() {}); // undefined JSON.stringify(Symbol(a)); // undefined JSON.stringify(1n); // 直接抛 TypeError这里最容易踩的第一个坑就是JSON.stringify 在某些输入下返回的不是字符串而是 undefined 本身。比如你写JSON.stringify(obj)obj 恰好是 undefined页面上渲染出来的不是空而是 undefined 这个单词。后续如果再对这个结果做 JSON.parse还会二次报错。这个特点在 4.3 里我会专门展开。对于 number 类型NaN 和 Infinity 在 JSON 标准里没有对应的值JSON.stringify 会把它们统一变成 null。所以JSON.stringify(NaN)返回nullJSON.stringify(Infinity)也是null。这个设计虽然看起来粗暴但 JSON 社区基本都是这么处理的不用纠结。1.2 replacer过滤字段和改写数据都靠它第二个参数 replacer可以传数组也可以传函数是整个 API 里最容易被忽略但实际最有用的功能。传数组时的行为是白名单过滤数组里写哪些属性名序列化结果就只保留哪些属性。这个逻辑用在从一个对象里抽几个字段的场景非常顺手const user { name: 张三, age: 30, password: 123456, address: { city: 北京, detail: xx路xx号 } }; JSON.stringify(user, [name, age]); // {name:张三,age:30}但数组白名单有一个非常隐蔽的坑它会作用到数组元素上。假设你有一个对象数组想过滤每个元素里的字段const list [{ name: 张三, age: 30 }, { name: 李四, age: 20 }]; JSON.stringify(list, [name]); // 结果是 [null,null]而不是你预期的 [{name:张三},{name:李四}]原因在于数组序列化时每个元素对应的键是索引 0、1白名单里没有这些索引所以整个元素被判定为不输出最后用 null 占位。这个行为我当初排查了十分钟才反应过来。想过滤数组里的对象字段正确做法是用函数形式的 replacer。传函数时每次序列化一个键值对都会调用一次这个函数函数返回什么值该属性就序列化成什么返回 undefined则表示把这个属性从结果里删掉JSON.stringify(user, (key, value) { if (key password) return undefined; if (key age) return value 1; // 顺手改数据也行 return value; });需要注意两点。第一函数会先以空字符串 key 调用一次传入整个根对象所以如果你要做全局判断记得在这个入口返回原值否则整个序列化会直接变成 undefined。第二replacer 函数中的 this 指向当前正在处理的对象但这个绑定对箭头函数不生效所以如果你在函数里依赖 this请用普通函数const data { name: 张三, tag: vip, nested: { tag: normal } }; JSON.stringify(data, function(key, value) { if (key tag this this.name 张三) { return value.toUpperCase(); } return value; });这样嵌套对象里 tag 的值就不会被误改因为内层对象的 name 属性不存在。1.3 space格式化输出不只是调试工具第三个参数 space 控制缩进可以传数字也可以传字符串。传数字表示缩进几个空格最大 10传字符串则直接用该字符串作为缩进符最长取 10 个字符。JSON.stringify({ name: 张三, age: 30 }, null, 2); // 输出 // { // name: 张三, // age: 30 // } JSON.stringify({ a: { b: 1 } }, null, \t); // 输出带制表符缩进的多行文本这个参数最典型的用途是把 JSON 写进配置文件、生成格式化文档、或者在控制台打印出来肉眼检查。但注意生产环境的日志上报和接口返回最好不要开缩进它会让字符串体积变大不少。一个 1MB 的 JSON 加两层缩进膨胀到 1.5MB 很正常。另外space 传 true、传对象都会被忽略不如直接传数字更可控。2. 序列化规则哪些类型会被吞、哪些会变形2.1 数字、日期、包装对象的行为都不直觉JSON.stringify 的序列化过程不是简单的把属性遍历一遍它对每种类型都有一套自己的规则很多都不符合直觉。先看 Date。Date 对象没有 enumerable 属性但 Date.prototype 上定义了 toJSON 方法内部调用 toISOString所以JSON.stringify(new Date(2025-01-01T00:00:00Z)); // 2025-01-01T00:00:00.000Z注意序列化出来是字符串这意味着任何用JSON.parse(JSON.stringify(obj))做深拷贝的对象只要里面嵌套了 Date拷贝出来的就不再是 Date 对象而是一个普通字符串。后续代码如果调用.getTime()直接报错。再看包装对象JSON.stringify(new Number(1)); // 1 JSON.stringify(new String(a)); // a JSON.stringify(new Boolean(true)); // true包装对象在序列化时会被拆成原始值这是规范里的 ToPrimitive 转换。但 RegExp、Map、Set、Error 这些就没有这么好的待遇了它们没有自定义 toJSON序列化结果基本是空对象JSON.stringify(/abc/g); // {} JSON.stringify(new Map([[a, 1]])); // {} JSON.stringify(new Set([1, 2])); // {} JSON.stringify(new Error(出错啦)); // {}我猜很多人第一次看到 Error 序列化成 {} 的时候都懵了一下明明有 message 和 stack怎么一个字段都不剩原因就是这两个属性是定义在 Error.prototype 上的不可枚举属性而 JSON.stringify 只遍历对象自身的可枚举属性。这个特点在做日志采集的时候尤其致命第 3.2 节会给出解决方案。2.2 undefined、函数、Symbol对象里被丢弃数组里变 null这三个类型在对象属性和数组元素中的待遇完全不同这是整个序列化规则里最容易被误解的部分。在对象里属性值是 undefined、函数或 Symbol 时该属性会被直接丢弃JSON.stringify({ a: undefined, b: function() {}, c: Symbol(s), d: 1 }); // {d:1}在数组里同样的值会被序列化成 null 占位JSON.stringify([undefined, function() {}, Symbol(s), 1]); // [null,null,null,1]数组用 null 占位是为了保持索引位置不变否则解析回来后数组长度就变了。这一点和对象属性的删除策略形成鲜明对比如果你写数据清洗逻辑一定要记住这个差异。Symbol 作为对象的属性名时也会被跳过哪怕这个属性是可枚举的const obj {}; obj[Symbol(hidden)] secret; JSON.stringify(obj); // {}包括用 Object.defineProperty 显式可枚举的 Symbol 属性JSON.stringify 同样不认。所以如果你有需要落地的敏感数据千万不要藏在 Symbol 属性里序列化这关就过不去。2.3 键顺序、toJSON 与 getter 的隐式影响对象的键顺序问题经常被忽略但它直接影响序列化结果的稳定性。规范规定属性名如果可以被解析成整数索引比如 0、1按数字大小升序排列其余的字符串键保持插入顺序。Symbol 键不参与。const obj { b: 1, 1: 2, a: 3, 0: 4, c: 5 }; JSON.stringify(obj); // {0:4,1:2,b:1,a:3,c:5}这个行为在普通场景下没什么影响但当你用 JSON.stringify 的字符串结果做缓存 key 或者做两个对象是否相等的粗比较时就会出问题。两个内容完全一样的对象因为键的插入顺序不同序列化出来的字符串不一样比较就失败了。解决办法是把键先排序再序列化后面 5.3 会专门讲。toJSON 是另一个隐藏入口。只要对象上有 toJSON 方法JSON.stringify 会优先调用它把返回值作为新的序列化目标然后再交给 replacer 处理。Date 就是靠这个机制工作的。你也可以在业务类里主动定义 toJSON实现这个对象序列化时只输出我想要的部分比在每次调用时写 replacer 更内聚const order { id: A001, amount: 99.9, discount: 0.8, toJSON() { return { id: this.id, realAmount: (this.amount * this.discount).toFixed(2) }; } }; JSON.stringify(order); // {id:A001,realAmount:79.92}还有一个隐式规则序列化过程中访问到的属性如果是 gettergetter 会被真实调用。这意味着 JSON.stringify 不是纯读操作它可能触发副作用也可能因为 getter 里抛异常而中断const data { get computed() { console.log(getter 被调用了); return 42; } }; JSON.stringify(data); // 控制台会打印 getter 被调用了数据来源不可控时最好在 try/catch 里调用 JSON.stringify。3. 日常开发用得上的一些实战场景3.1 深拷贝用 JSON 方案前先看清楚这些坑JSON.parse(JSON.stringify(obj))大概是前端圈最常见的深拷贝写法很多初学者把它当成万能方案但它只对纯 JSON 数据有效。我列一个清单方便你对照使用函数、undefined、Symbol会被丢弃或变成 null拷贝结果和原对象结构不一致。Date变成字符串类型丢失。RegExp、Map、Set、Error变成{}数据基本没了。BigInt直接抛错。循环引用直接抛错。对象的原型和不可枚举属性全部丢失。所以我的建议是如果数据结构里只有普通对象、数组、字符串、数字、布尔值、nullJSON 深拷贝完全够用而且性能不错一旦出现 Date、Map、Set 这些东西优先考虑浏览器的 structuredCloneconst copy structuredClone(obj);structuredClone 能正确处理 Date、Map、Set、ArrayBuffer 等类型并且能识别循环引用。它不是所有环境都有但现代浏览器和 Node 17 基本都支持。实在需要兼容老环境可以用 lodash 的 cloneDeep注意这个方案同样不保证函数的拷贝语义函数本身就是引用共享。3.2 日志上报与错误数据的格式化把 Error 对象直接 JSON.stringify得到的只有{}这在日志系统和监控上报里是个大坑。我一般会封装一个专门的错误格式化函数function formatError(err) { if (err instanceof Error) { return { name: err.name, message: err.message, stack: err.stack, ...(err.cause ? { cause: formatError(err.cause) } : {}) }; } return err; } JSON.stringify(formatError(new Error(数据库连接失败))); // 输出包含 name、message、stack 的完整对象你也可以给 Error.prototype 挂一个 toJSON让所有 Error 对象序列化时自动输出结构化字段。但不建议在生产代码里直接修改内置原型因为那是全局影响万一第三方库也改了行为就不可控了。我更推荐序列化前先转换这种显式做法谁调用谁负责逻辑清楚。除了 Error还有一类容易被吞的数据是自定义类实例。比如 class User 里的字段如果定义在实例上序列化没问题但如果定义在原型上比如 get name() 放在 class 里实例本身没有可枚举属性序列化结果就会变成{}。所以做数据上报时建议先转成普通对象再交给 JSON.stringify。3.3 localStorage 缓存与读取的标准姿势localStorage 只能存字符串对象要存进去必须经过 JSON.stringify。这个场景有两个高频坑一个是存储满了会抛 QuotaExceededError另一个是读取到的字符串可能不是合法 JSON。我习惯的封装长这样const storage { set(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); return true; } catch (e) { console.warn(数据存储失败, key, e); return false; } }, get(key, fallback null) { try { const raw localStorage.getItem(key); return raw ? JSON.parse(raw) : fallback; } catch (e) { localStorage.removeItem(key); return fallback; } } };get 里为什么要在 parse 失败时 removeItem因为数据一旦损坏每次读取都会重复报错不如及时清掉让下一次写入重新覆盖。这个细节能帮你避免很多线上本地缓存炸掉之后反复报错的尴尬。另外localStorage 的 key 设计也值得注意。我见过很多项目key 直接写死一个字符串比如 user_info结果功能迭代后数据结构变了旧缓存还在前端代码拿到旧结构直接崩。更稳妥的做法是把业务版本号并到 key 里比如 user_info_v2或者写到 value 里再校验。这里不展开但和 JSON.stringify 配套使用时一定要有这个意识。3.4 数据脱敏用 replacer 在序列化阶段打码日志、埋点、接口调试常常需要把对象打出来看但 password、token、身份证号又不能明文落库。用 replacer 函数可以在序列化阶段统一处理比在业务代码里到处脱敏要省事得多const maskSensitive (key, value) { if ([password, token, secret].includes(key)) { return undefined; // 直接删除 } if (key phone typeof value string) { return value.replace(/^(\d{3})\d{4}(\d{4})$/, $1****$2); } return value; }; JSON.stringify(user, maskSensitive, 2);这里有个细节要提醒replacer 返回 undefined 是删除属性的意思但这条规则只对对象属性有效。如果被过滤的字段出现在数组里比如你有一个 token 数组那数组里对应位置会变成 null而不是被删掉。所以设计数据结构时尽量避免在数组里放需要脱敏的敏感项或者在 replacer 里针对数组类型写专门的返回逻辑。更进阶一点脱敏和 toJSON 可以配合在业务类上定义 toJSON直接把敏感字段排除在外这样所有出口的序列化结果都是安全的。缺点是不够灵活如果同一份数据在某个场景需要带 password 输出这种写法就堵死了。所以我更推荐用 replacer 函数做临场脱敏把控制权留在调用方。4. 报错与边界这些数据会让 JSON.stringify 直接罢工4.1 循环引用最常见的炸点对象自己引用自己或者两个对象互相引用会触发无限递归JSON.stringify 直接抛错const a {}; a.self a; JSON.stringify(a); // TypeError: Converting circular structure to JSON循环引用在真实项目里一点也不罕见比如树形组件、链表结构、双向指针、状态管理里维护的引用关系都可能把循环引用的对象传到序列化流程里。处理循环引用的核心思路是记录已经访问过的对象再次遇到就不再深入。但要注意在 JSON.stringify 的 replacer 里做循环检测有一个天然缺陷你只能在一个键值对进入时做判断没有一个离开对象的回调让你把记录清掉。所以基于 WeakSet 的常见写法会把重复引用也误判成循环引用function safeStringify(value) { const seen new WeakSet(); return JSON.stringify(value, (key, val) { if (typeof val object val ! null) { if (seen.has(val)) { return [Circular]; } seen.add(val); } return val; }); } const shared { name: 共享对象 }; const obj { a: shared, b: shared }; safeStringify(obj); // 结果里 b 会被替换成 [Circular]因为 shared 出现过两次WeakSet 方案能保证不炸但会牺牲一些正常重复引用的数据。如果你的数据里大量复用同一对象这个方案就不合适了需要用自定义递归遍历实现真正的只检测祖先链路上的循环。这种工具在 npm 上有现成的也可以在内部封装一个但我个人建议多数场景下用 [Circular] 替换并接受误判比引入一套完整序列化器要务实得多。4.2 BigInt新类型带来的新问题BigInt 是 ES2020 引入的类型JSON.stringify 至今没有对它提供原生支持碰到就直接抛错JSON.stringify({ big: 10n }); // TypeError: Do not know how to serialize a BigInt这个报错本身很直白就告诉你我不知道怎么处理 BigInt。但线上数据不会因为报错就消失尤其是后端接口返回大整数 ID 时前端很多团队用 BigInt 接收然后序列化上传到日志一炸一个准。处理 BigInt 最简单的方式是在 replacer 里转成字符串JSON.stringify({ big: 10n }, (key, value) { return typeof value bigint ? value.toString() : value; }); // {big:10}转成字符串有个问题反序列化回来后无法区分 10 到底是字符串还是 BigInt。如果业务强依赖类型可以在字符串后面加个标记比如10n然后写一个配套的 reviverconst stringifyWithBigInt (value) JSON.stringify(value, (key, v) typeof v bigint ? v.toString() n : v ); const parseWithBigInt (str) JSON.parse(str, (key, v) typeof v string /^\dn$/.test(v) ? BigInt(v.slice(0, -1)) : v ); const obj { big: 10n }; const str stringifyWithBigInt(obj); // {big:10n} parseWithBigInt(str).big; // 10n类型恢复这个方案注意误伤普通字符串比如用户输入的 2026n 也会被转成 BigInt。所以我只在内部数据链路里用这招跨系统传输还是统一用字符串更省心。4.3 返回 undefined 与 parse 的不对称性前面提到过JSON.stringify 对 undefined、function、symbol 作为入参时会直接返回 undefined。这个返回 undefined不是字符串 undefined而是真的 undefined 值。如果你写了这种代码const str JSON.stringify(undefined); console.log(typeof str); // undefined const parsed JSON.parse(str); // 这里会抛Unexpected token u in JSON类似的情况还有对象里某个键是可选的、值恰好是 undefined序列化时该键直接消失但数组里的 undefined 会变成 null。两种行为不一致写序列化工具的时候一定要记牢。JSON.parse 和 JSON.stringify 也不是完全对称的。最典型的例子就是 DateDate 序列化成字符串反序列化回来还是字符串不会还原成 Date。NaN、Infinity 序列化成 null反序列化也是 null不会还原成原值。所以JSON.parse(JSON.stringify(x)) 能还原出相同数据这个说法只对纯 JSON 数据成立。每次用 JSON 拷贝数据之前先看一眼数据结构里有没有非 JSON 原生类型这能省掉大量排查 bug 的时间。5. 工程化封装与性能心得5.1 我把 safeStringify 封装成了这样把前面聊到的坑集中起来我在项目里通常会放一个统一的序列化工具默认处理 BigInt、循环引用、Error并保留传入 replacer 的能力function safeStringify(value, userReplacer null, space 0) { const seen new WeakSet(); return JSON.stringify(value, (key, val) { if (val instanceof Error) { return { name: val.name, message: val.message, stack: val.stack }; } if (typeof val bigint) { return val.toString() n; } if (typeof val object val ! null) { if (seen.has(val)) { return [Circular]; } seen.add(val); } if (typeof userReplacer function) { return userReplacer(key, val); } return val; }, space); }这个封装谈不上完美比如 WeakSet 会误判重复引用、BigInt 标记可能误伤字符串但它覆盖了 90% 的线上场景。工具的价值不是解决所有哲学问题而是让调用方不用每次都写一遍相同的边界处理。实际项目里还可以在这个基础上加一个脱敏 hook或者支持数组白名单按团队需求扩展就行。有一点要提醒safeStringify 里的 userReplacer 是在内置处理之后执行的意味着它拿到的是已经处理过 BigInt、Error、循环标记之后的值。如果你的业务 replacer 需要基于原始 BigInt 做判断顺序上要想清楚。我个人倾向在工具内部先做安全兜底再做业务定制因为兜底优先级更高。5.2 序列化性能哪些优化有意义、哪些是自我感动JSON.stringify 的性能在绝大多数业务场景里都不是瓶颈因为执行它的代价远小于一次网络请求。但有几个点值得注意。第一replacer 函数会拉低性能。V8 对纯 JSON.stringify 有深度优化一旦传了 replacer 函数每个键值对都会走一遍 JS 回调数据量一大差距就出来了。如果只是过滤字段优先用数组白名单虽然数组对数组元素有坑但对普通对象性能更好。第二space 参数在生产环境不要用。缩进意味着生成更多字符字符串拼接、存储、传输都更慢。我们曾经有个接口把 JSON 缩进开到 4单个响应体从 800KB 干到 1.4MB后来去掉了缩进接口耗时肉眼可见下降。这个优化收益非常明显。第三避免在热循环里反复序列化同一个对象。如果你有一个大对象多次序列化的结果相同那就把它缓存起来。有同事写过一段代码在 for 循环里对同一个固定对象调用 JSON.stringify 做日志拼接一次循环 1000 条每条多 3ms总计浪费了 3 秒。这种场景不是 JSON.stringify 慢是调用方式有问题。5.3 用序列化结果做比较与缓存 Key 的注意事项把对象序列化成字符串然后做相等判断或当作 Map 的 key是一种很常见的技巧。JSON.stringify(a) JSON.stringify(b)这个写法对键插入顺序一致的对象有效但一旦两个对象键顺序不同结果就不可靠。比如{a:1,b:2}和{b:2,a:1}内容一样序列化结果却不同。如果确实要做这种比较先统一键顺序再序列化function sortStringify(obj) { if (Array.isArray(obj)) { return JSON.stringify(obj.map(sortStringify)); } if (obj typeof obj object) { const sorted Object.keys(obj) .sort() .reduce((acc, key) { acc[key] sortStringify(obj[key]); return acc; }, {}); return JSON.stringify(sorted); } return JSON.stringify(obj); }这个函数简单递归把每层对象的键排序后再序列化。注意它同样只适用于纯 JSON 数据类型遇到 Date、Map 这些还是要先做转换。另外用序列化字符串做缓存 key 时要小心数据体积。一个 200KB 的对象序列化成字符串当作 key 塞进 Map内存开销是非常可观的。如果只是接口参数的缓存匹配建议选几个关键字段拼接而不是把整个对象都序列化进去。我见过有人把完整请求体当缓存 key接口一多内存直接吃紧这属于把 JSON.stringify 用过头了。我个人实际用下来的感受是JSON.stringify 这套规则早年在文档里看一遍根本留不下什么印象只有在线上被真实数据坑过一次才会牢牢记住。所以不管项目大小我都会在 utils 里放一个 safeStringify 这样的统一入口让日志、缓存、埋点都走同一套兜底逻辑。下次你再遇到这个对象打印出来怎么是空对象或者本地缓存读出来数据不对的诡异问题起码能第一时间想到序列化规则是不是又搞我了这层原因排查方向就多一条。
阅读完成 · 觉得有帮助?
咨询建站