JSON.stringify深入指南:序列化规则、replacer参数与常见坑

JSON.stringify深入指南:序列化规则、replacer参数与常见坑 在实际开发里JSON.stringify大概是每个前端每天都离不开、却又很少真正读懂它的一个方法。多数人用它的场景就是JSON.stringify(obj)一把梭用来调试日志、存localStorage、深拷贝数据偶尔遇到循环引用报错、BigInt报错、undefined被丢掉才意识到这个东西没那么简单。这篇文章我想把自己踩过的坑和梳理过的规则完整讲一遍涵盖序列化规则、replacer和space参数、toJSON自定义逻辑、常见业务场景下的实现、循环引用与特殊类型问题甚至提一下如果你要自己实现一个简化版JSON.stringify需要哪些关键步骤。适合刚入门前端但已经写过不少JSON.stringify的初中级开发者也适合那些想彻底弄懂序列化边界的老手。很多人以为JSON.stringify就是一个“变字符串”的公共方法其实它内部有非常明确的一套递归序列化算法理解这套算法之后很多“诡异”现象就很自然了。1. 核心规则拆解你到底序列化的是什么1.1 基本用法里的三个参数JSON.stringify(value[, replacer[, space]])我们常用第一个参数后两个经常被忽略。value不用说就是要序列化的对象。replacer可以是函数也可以是数组用来定制序列化过程中哪些属性被包含、哪些被替换。space控制缩进可以是数字最多10也可以是字符串最多10个字符。平时我们可能只是简单调用但要真正用好必须理解参数参与序列化流程的阶段。replacer并没有被“先过滤再序列化”而是在序列化递归过程中每遇到一个键值对就调用一次replacer你可以决定返回什么值来替换原始值。如果返回undefined该键值对会被直接跳过但数组中的undefined元素会变成null因为数组不允许稀疏序列化。space则纯粹是格式化输出。传数字表示每个缩进层级用几个空格传字符串则直接用该字符串缩进。如果传了space序列化的字符串会变成多行带换行和缩进这通常用来输出日志或格式化JSON文件而不适合网络传输体积变大。1.2 属性顺序并不是对象里怎么写就怎么排很多人会误以为JSON.stringify会完全保留对象属性的定义顺序其实它遵循一套特定顺序规则如果属性名是整数形式的字符串符合数组索引的定义如0、1、42会按数字升序排在最前面。其他字符串属性按对象本身的键顺序插入顺序排列。Symbol属性会被完全忽略即使定义了replacer也不会遍历到Symbol键。举个例子const obj { name: 张三, 2: b, 1: a, age: 30, [Symbol(id)]: 100 }; console.log(JSON.stringify(obj)); // {1:a,2:b,name:张三,age:30}Symbol直接没了。这是符合JSON规范的因为JSON只支持字符串键。理解了顺序你才能在序列化结果和预期不一致时判断是不是“顺序问题”而不是“代码问题”。1.3 什么是JSON-safe值什么不是JSON格式能够表达的数据类型有限object、array、string、number、boolean、null。其它类型要么被特殊处理要么被丢弃。下面这个表很重要类型JSON.stringify行为说明undefined属性中直接省略数组中变为null顶层值返回undefined顶层调用JSON.stringify(undefined)结果为undefined而不是字符串function同undefined对象属性被忽略数组项变nullsymbol同undefined对象属性忽略数组项变nullnumberNaN、Infinity、-Infinity序列化为null其它正常string转义特殊字符如双引号、反斜杠、控制字符bigint直接抛TypeError无论在哪一层都会报错Date调用toJSON()后序列化得到ISO字符串因为Date内建了toJSON方法Map/Set空对象{}因为没有可枚举属性RegExp空对象{}同上Error空对象{}原因也是无可枚举属性数组稀疏数组的空位变为nullnew Array(3)序列化为[null,null,null]看到这个表你会明白为什么很多人用JSON.stringify做深拷贝时发现正则对象、Map、Set等类型拷贝出来全是{}。因为序列化阶段就丢失了这些信息。2. 深入解析这些细节80%的人没有注意到2.1 字符串转义为什么反斜杠会被变成两层序列化字符串时会转义双引号、反斜杠、换行符、控制字符等。尤其是遇到\n、\t、\b、\f、\r都会转成对应的转义序列遇到\u0000到\u001f之间的控制字符会统一转成\u00xx形式。因此一个字符串如果里面本来有反斜杠序列化结果里反斜杠会变成\\这样反序列化后才会还原成原来的单反斜杠。我碰过不少人在做“JSON串再嵌套JSON串”时没有注意二次转义导致JSON.parse失败。比如const inner {a:1}; const outer JSON.stringify({ data: inner }); // outer 是 {data:{\a\:1}} // 要还原 inner 需要 JSON.parse(JSON.parse(outer).data)如果不理解转义很容易在控制台看到一串\\\就懵了。2.2 toJSON是优先于replacer的还是反过来toJSON方法和replacer的执行顺序是很多面试题和实际坑的源头。序列化一个对象时引擎会先检查这个对象有没有toJSON方法如果有则调用它并用返回值作为当前值的“序列化候选值”。这个候选值随后再经过replacer。简单说toJSON更接近“值的原型行为”replacer是通用拦截器。Date就是通过toJSON把日期转成字符串的const date new Date(2025-01-01T00:00:00Z); console.log(JSON.stringify(date)); // \2025-01-01T00:00:00.000Z\如果你自定义一个对象const obj { name: test, toJSON() { return { name: this.name.toUpperCase(), extra: 1 }; } }; console.log(JSON.stringify(obj)); // {name:TEST,extra:1}这里toJSON返回了一个新对象序列化器会继续递归新对象。如果此时还有replacerreplacer会作用在这个新对象的键值对上而不是原始obj的键值对。利用这个特性我们可以给特殊类型如Map、Set添加toJSON方法让它们可被正确序列化。2.3 replacer函数中this到底是什么replacer函数接收两个参数key和value其中的this指向当前值所属的对象。不过有两种特殊情况序列化到根对象时key为空字符串this是{: value}这样的包装对象在数组中this是当前数组本身。我之前写过一段代码想用replacer判断是否在顶层结果直接判断this就出错了。正确做法是看key 且value是根值但不一定可靠更稳妥的方式是额外传一个标志变量。replacer返回undefined的键会被删除返回其它值则用该值替换。但要注意如果replacer把某个键的value换成循环引用的对象同样会抛循环引用错误。一个常见场景用replacer过滤敏感字段比如统一把密码、token、手机号字段置为空或删除。const user { name: admin, password: 123456, token: abc, profile: { email: xy.com, password: haha } }; function safeReplacer(key, value) { if (key password || key token) { return undefined; } return value; } console.log(JSON.stringify(user, safeReplacer, 2));输出结果中password、token都没了而且嵌套对象里的password也没了这就是replacer递归生效的效果。2.4 序列化数组时的特殊性数组的序列化规则和对象不同数组的元素会按顺序序列化若元素是undefined、函数或Symbol则在数组里对应位置变成null。稀疏数组的空位也会变成null。数组如果是数组对象但又有自定义属性这些属性不会被序列化。也就是说数组的序列化逻辑只关心索引不关心额外属性。const arr [1, undefined, function(){}, Symbol(x), , ]; console.log(JSON.stringify(arr)); // [1,null,null,null,null]这里末尾多了一个逗号实际长度是6所以输出5个元素后还有一个null。2.5 顶层调用返回undefined的情况JSON.stringify的返回值可能是undefined而不是一个字符串。很多新手会用JSON.stringify(...) undefined判断序列化失败这是合理的但要注意原因顶层值是undefined、函数或Symbol。顶层值虽然是个对象但经过toJSON或replacer后变成了undefined。例如JSON.stringify(undefined); // undefined JSON.stringify(function(){}); // undefined JSON.stringify(Symbol(x)); // undefined所以如果你把JSON.stringify的结果直接传给字符串模板会得到字符串undefined这可能不是你想要的。遇到这种情况先判断结果是否为undefined再拼接。3. 业务场景实战从深拷贝到安全序列化3.1 用JSON实现深拷贝但是要避开这些坑最常见的深拷贝姿势const clone JSON.parse(JSON.stringify(original));这种方案性能好、代码简洁适合纯JSON数据。但有以下缺陷会丢失undefined、函数、Symbol。Date会变成字符串而不是Date对象导致后续无法调用getTime。Map、Set、RegExp、Error等变成空对象。NaN、Infinity变成null。循环引用直接抛错。BigInt直接抛错。原型链丢失实例变成普通对象。所以它只适合“可JSON化的数据”深拷贝。如果你明确知道数据结构里只有普通对象、数组、字符串、数字、布尔值那用它完全没问题而且很快。如果你想要一个更稳妥的深拷贝可以用structuredClone。现代浏览器和Node.js都支持了const clone structuredClone(original);它支持Map、Set、Date、RegExp、ArrayBuffer等也支持循环引用。不过函数和Symbol还是不行这是结构化克隆算法的限制。3.2 序列化Map、Set、RegExp、BigInt的通用方案Map、Set默认序列化结果是{}我们需要给它们加上toJSON方法。在业务里我常用的方式有两种一是在数据源上挂toJSON二是在replacer里拦截。挂toJSON更优雅因为调用JSON.stringify时无需额外参数。Map.prototype.toJSON function () { return [...this.entries()]; }; Set.prototype.toJSON function () { return [...this.values()]; }; RegExp.prototype.toJSON function () { return this.toString(); }; const map new Map([[a, 1], [b, 2]]); const set new Set([1, 2, 3]); const reg /abc/gi; console.log(JSON.stringify({ map, set, reg })); // {map:[[a,1],[b,2]],set:[1,2,3],reg:/abc/gi}注意这是对内置原型的修改如果担心污染可以只对具体实例修改或者采用replacer方式。实际业务中我更推荐replacer因为不会影响全局且可控function stringifyExtended(data) { return JSON.stringify(data, function (key, value) { if (value instanceof Map) { return [...value.entries()]; } if (value instanceof Set) { return [...value.values()]; } if (value instanceof RegExp) { return value.toString(); } if (typeof value bigint) { return value.toString() n; } return value; }); }BigInt要格外小心序列化时如果保留bigintJSON里没有对应类型转换成字符串后反序列化会丢失类型。除非你在reviver中再做一次还原否则只能把bigint转成数字或字符串。3.3 利用replacer做敏感字段过滤与数据脱敏生产环境经常遇到要把用户信息传给前端或日志系统但绝不能把明文密码、支付账号等字段暴露。除了在业务层手动剔除外replacer是一个很优雅的出口。我曾经写过一个工具函数const SENSITIVE_KEYS new Set([password, authorization, token, secret, mobile]); function desensitize(data) { return JSON.parse(JSON.stringify(data, function (key, value) { if (SENSITIVE_KEYS.has(key.toLowerCase())) { if (typeof value string) { return ***; } return undefined; // 非字符串则直接删除 } return value; })); }这个函数可以递归处理嵌套对象和数组比在每层业务里写delete user.password要省心很多。但要注意replacer会遍历所有键如果数据量很大性能会下降。通常我们只在脱敏入口调用一次可接受。3.4 序列化后再格式化space参数的正确用法写日志、配置文件或调试打印时space很有用。比如把请求参数记录下来一行暴力JSON.stringify(data)看起来非常痛苦。使用JSON.stringify(data, null, 2)就能输出带缩进的JSON。space传数字时最大是10超过10按10处理传字符串时如果字符串长度超过10只取前10个字符。这个细节我踩过曾经想用自定义缩进符 两空格没问题但想加注释风格的空格结果被截断了查了文档才明白。另外要注意space生成的字符串包含换行符\n如果你把这个字符串存到localStorage没问题但如果插入到HTML的pre标签中没问题如果要放到某些单行字段中就可能导致格式错乱。3.5 localStorage封装序列化时必须做try-catchlocalStorage只能存字符串。存对象时我们一般写localStorage.setItem(user, JSON.stringify(user));但有没有想过如果user里不小心多了一个BigInt字段JSON.stringify会抛错整个页面后续代码都执行不了。所以封装一个安全的存储函数很有必要function setStorage(key, value) { try { const json JSON.stringify(value); localStorage.setItem(key, json); } catch (error) { console.error(存储失败, error); } } function getStorage(key, defaultValue null) { try { const json localStorage.getItem(key); return json ? JSON.parse(json) : defaultValue; } catch (error) { console.error(读取失败, error); return defaultValue; } }这个封装看似简单但能避免很多运行时崩溃问题。尤其是项目里存在不可控数据结构时try-catch就是底线。4. 如果让你手写一个简化版JSON.stringify有些项目为了包体积、兼容性或者纯粹为了学习会自己实现一个简化版JSON.stringify。手写的过程能帮你从算法层面理解序列化的递归流程这里我给出一个核心骨架不是完全复刻但覆盖了主要分支。4.1 递归序列化的主流程我们可以这样组织function myStringify(value) { const type typeof value; // 原始类型处理 if (type undefined || type function || type symbol) { // 顶层返回 undefined属性中会被过滤但这里先返回一个特殊标记 return undefined; } if (type bigint) { throw new TypeError(Do not know how to serialize a BigInt); } if (value null) { return null; } if (type number) { if (Number.isNaN(value) || !Number.isFinite(value)) { return null; } return String(value); } if (type boolean) { return value ? true : false; } if (type string) { return escapeString(value) ; } // 对象类型数组或其他对象 if (Array.isArray(value)) { const results value.map((item) { const serialized myStringify(item); return serialized undefined ? null : serialized; }); return [ results.join(,) ]; } if (typeof value.toJSON function) { return myStringify(value.toJSON()); } const keys Object.keys(value); // 只处理可枚举字符串键 const parts []; for (const key of keys) { const serialized myStringify(value[key]); if (serialized ! undefined) { parts.push( escapeString(key) : serialized); } } return { parts.join(,) }; }这个版本没有处理replacer、space、循环引用检测也没有完整实现字符串转义但能跑通常规对象。要处理循环引用可以维护一个WeakSet来记录已访问的对象访问到重复对象时直接抛错。源码内部就是这么干的。4.2 转义函数和特殊字符JSON对字符串的要求比JS字符串更严格。最简单的转义实现function escapeString(str) { return str.replace(/[\\\b\f\n\r\t]/g, (char) { switch (char) { case : return \\; case \\: return \\\\; case \b: return \\b; case \f: return \\f; case \n: return \\n; case \r: return \\r; case \t: return \\t; default: return char; } }).replace(/[\u0000-\u001f]/g, (char) { return \\u char.charCodeAt(0).toString(16).padStart(4, 0); }); }这里需要注意/\u0000-\u001f/这个范围会把\n等也匹配到但因为前面的replace先处理了它们此时它们已经变成\\n不会再被第二个正则匹配到。实际手写时可以合并成一个正则但思路要清晰。如果想完全符合规范还需要对可能存在的孤立代理对进行转义这里就不展开了工程上一般不会走到这一步。4.3 手写版本和原生实现的差距原生JSON.stringify比手写复杂得多遍历时会检查[[Proxy]]陷阱原生的执行与[[Get]]有互动。对number的-0会序列化成0。对字符串的代理对、\u2028、\u2029也有处理。对BigInt抛错。对cycle检测使用类似Set的结构会标记“处理中”状态。支持replacer和space。对数组稀疏位置补null。对象属性顺序要完全符合规范。因此大家不要真的在生产环境替换原生实现手写只是学习手段。真实开发中原生API性能更好边界处理更完善。5. 高频问题与排查笔记5.1 循环引用报错Converting circular structure to JSON最常见的是const obj { name: x }; obj.self obj; JSON.stringify(obj); // TypeError: Converting circular structure to JSON排查思路很简单先定位到代码里某个对象存在相互引用。可以尝试用WeakSet记录路径或者写一个safeStringify在出错时打印当前处理路径。我在大型项目里用过一个小工具function safeStringify(data) { const seen []; return JSON.stringify(data, function (key, value) { if (typeof value object value ! null) { if (seen.includes(value)) { return [Circular]; } seen.push(value); } return value; }); }这个方案用数组存储对象引用但要注意速度因为includes是线性查找。更好的方法是使用WeakSet但这要求必须能区分“当前路径上存在的引用”和“已经遍历完可以释放的引用”。如果不要求完全准确直接WeakSet也能用但会漏掉重复对象不是循环而是同一个对象多次出现。最简单可靠的还是维护一个栈。5.2 BigInt错误Do not know how to serialize a BigIntBigInt在数据流里越来越常见比如后端返回的时间戳可能是bigint某些计算也是bigint。直接JSON.stringify会报错。处理方式在replacer中把bigint转换为字符串或数字但要注意精度。在数据结构源头将bigint统一序列化成字符串并在JSON.parse的reviver中判断是否转回bigint。给一个可逆方案const bigIntReviver (key, value) { if (typeof value string /^\dn$/.test(value)) { return BigInt(value.slice(0, -1)); } return value; }; const data { timestamp: 123456789012345678901234567890n }; const json JSON.stringify(data, (key, value) { if (typeof value bigint) { return value.toString() n; } return value; }); // json 是 {timestamp:123456789012345678901234567890n} const back JSON.parse(json, bigIntReviver); // back.timestamp 是 BigInt但这里有个坑如果原始数据里恰好有字符串123n这种格式反序列化时会被误转成BigInt。所以可以给标记加前缀比如__bigint__:123或者用reviver判断键名。工程上我会单独封装一个带标记的序列化协议而不是依赖正则。5.3 undefined、函数、Symbol静默丢失导致数据不完整当你发现序列化后的对象少了某个字段先别急着怀疑代码想想这个字段的值是不是undefined或者函数。const product { name: 电脑, price: undefined, onSale() {}, brand: Symbol(brand), sku: null }; console.log(JSON.stringify(product)); // {name:电脑,sku:null}null会保留undefined、函数、Symbol会消失。很多人在做接口参数签名或日志时因为没有注意这一点导致请求缺参。如果要保留undefined这样的字段只能自己处理比如先转成null或空字符串const fixed Object.fromEntries( Object.entries(product).map(([key, value]) [ key, typeof value undefined ? null : value ]) );5.4 Date被序列化成字符串解析后判断失败很多人用JSON.stringify保存Date后再读出来发现是字符串。正确的做法是如果业务需要保留Date类型用structuredClone或者在reviver里手动转换。const data { now: new Date() }; const json JSON.stringify(data); const parsed JSON.parse(json, (key, value) { if (key now) return new Date(value); return value; });这样恢复的才是Date对象。5.5 序列化一个Proxy对象时的问题Proxy对象经过JSON.stringify时内部会触发get和ownKeys等陷阱。如果Proxy的get返回了循环引用或者自定义toJSON返回了奇怪数据序列化结果可能出乎意料。记住JSON.stringify读取对象属性时用的是[[Get]]所以Proxy的get拦截会生效。这类问题排查起来相对隐蔽我自己踩过一次一个Proxy包装了响应式对象序列化时莫名多了很多字段后来发现是get里做了依赖收集并返回了额外属性。5.6 性能问题大数据量多次序列化JSON.stringify性能通常很好但如果你在循环里对同一个对象进行多次序列化一次又一次地转换堆积起来就慢了。常见的优化手段能缓存就缓存比如数据不变时不要重复stringify。尽量在数据源头组装JSON字符串而不是先构建对象再序列化。避免在replacer中做太复杂的计算因为每个键都会走一次。如果数据量大到序列化耗时过高可以考虑使用流式序列化、分片处理或者在Node.js中使用JSONStream、stream-json等工具。前端场景一般用不到但大数据表格或可视化项目里可能遇到。6. 进阶实践序列化在复杂场景中的坑与巧用6.1 用JSON.stringify做对象哈希在缓存时我们有时候会以序列化后的字符串作为缓存键。比如const cacheKey JSON.stringify({ path, query, body });这样做方便但顺序敏感。顺序不同键不同缓存命中率低。一种解决办法是先用一个排序函数归一化键function canonicalStringify(value) { if (Array.isArray(value)) { return [ value.map(canonicalStringify).join(,) ]; } if (value typeof value object) { const keys Object.keys(value).sort(); return { keys.map(key JSON.stringify(key) : canonicalStringify(value[key])).join(,) }; } return JSON.stringify(value); }这里用JSON.stringify(key)转义键名同时递归处理值。这样同一个对象无论属性顺序如何得到的哈希基础字符串都是一样的。6.2 结合parse实现更智能的解析JSON.parse支持reviver可以和JSON.stringify形成闭环。比如前面提到的BigInt、Date、Map都可以利用reviver还原。我们还可以用reviver做字段改名、数据结构转换。比如后端返回{user_name:x, user_age:y}前端想统一成camelCase可以用reviver逐步处理但要注意reviver是从最内层向外遍历的这个过程容易写出有副作用的代码。更推荐先parse成普通对象再用mapKeys递归转换字段名逻辑更清晰。6.3 安全边界JSON.stringify不是XSS防护经常有人以为把用户输入JSON.stringify后插入HTML就安全了其实不对。JSON.stringify不会转义,,等HTML特殊字符只是负责JSON格式的序列化。如果直接把序列化后的字符串插入到script标签或者HTML中仍有可能被注入。比如const data { name: /scriptscriptalert(1)/script }; const json JSON.stringify(data); // 如果直接拼到script标签里 // scriptconst obj ${json};/script // 会提前闭合script导致XSS正确的做法是如果要在HTML里嵌入JSON数据至少要把转成\u003c把转成\u003e把转成\u0026把转成\u0027。这也可以写进replacerconst safeJson JSON.stringify(data).replace(//g, \\u003c) .replace(//g, \\u003e) .replace(//g, \\u0026) .replace(//g, \\u0027);虽然这个需求不算很常见但在做SSR、服务端注入数据到前端页面时非常关键。6.4 与“JSON.stringify判断对象包含某个字符串”结合很多人搜“js判断字符串是否包含”这其实是两个问题一个是String.prototype.includes另一个是想在对象序列化后的字符串里搜索子串比如const obj { title: JavaScript指南, author: 张三 }; const json JSON.stringify(obj); console.log(json.includes(JavaScript));用includes判断字符串存在性很简单但它的性能是O(n)而且会误匹配键名和值里的任意子串比如你要判断值里是否包含某个关键词结果键名里也有一样的关键词就会得到错误结果。更稳妥的方式是先JSON.parse回来再递归检查值。或者直接用obj.title.includes。序列化后搜索适合快速调试不适合做严谨判断。这一点大家心里有数就行。6.5 扩展用WeakMap记录循环路径的调试技巧当一个大型对象报循环引用时只看到Converting circular structure to JSON完全不知道是哪个属性引起的很痛苦。写一个能打印路径的工具function stringifyWithPath(data) { const stack []; const seen new Set(); return JSON.stringify(data, function (key, value) { if (typeof value object value ! null) { const path stack.join(.) (key ? . key : ); if (seen.has(value)) { console.warn(Circular at:, path); return [Circular]; } seen.add(value); stack.push(key); } return value; }); }这个版本会用Set记录所有见过的对象因此如果一个对象重复出现并非循环引用也会被判成循环。如果想严格检测循环需要记录“当前递归栈”在属性处理完成后删除引用复杂度高一些。但实际调试时能定位到大致路径已经很有帮助。7. 我对JSON.stringify的一些工程建议在实际项目里我建议不要信任任何“裸的”JSON.stringify尤其是在接口数据、存储层这种关键路径上。尽量封装一个统一的序列化工具集中处理BigInt、循环引用、特殊类型、敏感字段等问题。好处是所有项目成员都通过工具方法序列化而不是各自JSON.stringify出了问题也有统一的排查入口。另一个建议是善用replacer而不是反复修改数据源。很多时候我们拿到后端脏数据第一反应是洗数据但洗数据意味着先深拷贝一遍再删字段性能损耗大。如果只是临时输出可以直接用replacer做字段过滤和转换。最后写单元测试时别忘了把Date、Map、Set、BigInt、undefined、函数、Symbol、循环引用这些边界情况都覆盖进去。序列化逻辑不像页面UI平时看不到问题一旦出问题就是线上事故提前用测试锁住行为会安心很多。JSON.stringify表面是一个简单的方法背后却藏着完整的序列化算法规范。真正理解它之后你在做数据存储、接口传参、日志上报、对象比较、状态管理时都会更从容。希望这篇文章能帮你把这块盲区补上至少下次遇到“为什么这个字段没了”“为什么它是null”“为什么报循环引用”时能快速定位而不是干瞪眼。