写JSON.stringify()这篇文章之前我犹豫过要不要写。网上教程一搜一大把但大部分只是摆一下语法、贴两个例子就完了。真正用了七八年这方法之后我发现它身上的细节特别多踩过的坑也不少。很多看起来“诡异”的结果其实都是序列化规则导致的。这篇文章我打算把这些年遇到的情况一次性讲透从底层规则到实战技巧包括那些文档里不会明确告诉你的事。这不是一篇教你“按F12在控制台里跑两行代码”的入门笔记而是把一个函数彻底掰开揉碎看完你会知道它什么时候听话什么时候闹脾气以及怎么让它按你的想法干活。1. 从零认识JSON.stringify()语法与三个参数1.1 最基础也是最重要的你到底在用什么JSON.stringify()是JavaScript内置的一个静态方法作用就是把JavaScript的值转换成JSON字符串。只要你在前端项目里待过几天就一定会碰到它。最常见的用法是只有一个参数const user { name: 张三, age: 30, isVip: true }; const jsonString JSON.stringify(user); console.log(jsonString); // 输出: {name:张三,age:30,isVip:true}看起来简单对不对但这只是最浅的一层。完整的语法长这样JSON.stringify(value[, replacer[, space]])三个参数里第一个是必传的另外两个都是可选参数。很多人从入行到写进阶可能都没碰过后两个参数。但恰恰是这两个参数把JSON.stringify()从“会说话”变成了“会办事”。replacer参数可以是数组也可以是函数用来控制序列化的过程比如过滤字段、修改值。space参数则是控制输出格式的让它变成人类友好或者机器友好的格式。这两个参数配合起来能做的事远超你想象。1.2 replacer参数两个形态两种玩法replacer作为数组时效果是白名单过滤。数组里的每个字符串代表一个键名序列化的时候只保留这些键对应的字段。const product { id: 1, name: 机械键盘, price: 299, stock: 500, internalNote: 内部备注不能对外展示 }; // 只保留 id、name 和 price const filtered JSON.stringify(product, [id, name, price]); console.log(filtered); // 输出: {id:1,name:机械键盘,price:299}这种写法在接口对接时特别实用。后端接口有时返回一大堆字段但前端只需要其中几个用于日志上报或数据透传直接过滤掉多余的省得后面的人接手代码时看花眼。replacer作为函数时就是完全不同的玩法了。函数会接收到两个参数key和value。你对value做任意处理后返回这个返回值就会成为序列化后的值。const order { id: A10001, total: 88.5, secret: s3cr3t }; const jsonString JSON.stringify(order, (key, value) { // 过滤掉敏感字段 if (key secret) { return undefined; } return value; }); console.log(jsonString); // 输出: {id:A10001,total:88.5}这里有个很多人不清楚的点replacer函数的this指向的是当前处理的那个对象root对象调用时this指向一个包装对象。你可以通过this来拿到当前所在对象的上下文这在对嵌套结构做处理时很有用。1.3 space参数格式化输出的细节控制space参数控制输出字符串的缩进格式。它可以是数字也可以是字符串。如果传数字它代表当前层级缩进多少个空格但最大不超过10超过10也会被当成10处理。const config { app: { name: my-app, debug: true, env: { node: 18.16.0, npm: 9.5.1 } } }; console.log(JSON.stringify(config, null, 2));输出结果就会变成一个好读的缩进版本{ app: { name: my-app, debug: true, env: { node: 18.16.0, npm: 9.5.1 } } }如果传的是字符串比如\t那就会用这个字符串作为缩进占位符。传递两个空格的缩写形式是不生效的它会被当成没有传一样处理。传空字符串也是同样的结果得到的仍然是一行压扁的JSON。space参数在哪些场景有用一是写配置文件备份或者说想给用户展示一个可读的JSON结构时二是把它写到文件里给别人查看三是查问题时格式化打印比压缩成一行舒服得多。我个人调试时固定用JSON.stringify(obj, null, 2)打印出来的结构一清二楚。2. 序列化黑盒哪些值保留哪些值消失2.1 基础类型序列化规则没你想的那么“忠实”JSON是一种基于文本的交换格式它只认有限的类型。JavaScript里的很多类型在序列化时会“变形”。先说基础类型字符串原样输出但引号会被统一成双引号。数字正常输出。但NaN和Infinity会变成null。布尔值true变truefalse变false。null原样输出为null不会消失。undefined、函数、Symbol在对象里会整个键值对被跳过在数组里会变成null。很多人在数组里遇到undefined就困惑比如const arr [1, undefined, 3, function() {}]; console.log(JSON.stringify(arr)); // 输出: [1,null,3,null]对象里是直接丢掉数组里是补一个null占位。这两者的不对称性正是“坑”的来源。日常开发中经常有人用数组塞数据时漏了某些项结果序列化之后才发现长度对不上折腾半天才找到原因是某个值是undefined。2.2 undefined、函数、Symbol序列化世界的“隐形人”再展开说一下如果一个对象里的某个属性值是undefined或者是一个函数或者是一个Symbol那这个键值对会被整体忽略。注意是整个键值对消失而不是那个值变成null。const demo { name: test, callback: () {}, symbolValue: Symbol(foo), un: undefined }; console.log(JSON.stringify(demo)); // 输出: {name:test}这个行为在多数情况下是合理的因为JSON格式里根本没有函数、undefined和Symbol的位置。但如果你指望序列化后的结构和你源码里的对象结构完全一致就大失所望了。所以做数据持久化、深拷贝的时候务必先确认对象里有没有这三种类型的值。2.3 循环引用唯一会主动报错的场景JSON.stringify()遇到循环引用时不像上面那些情况一样“温和地跳过”而是直接抛出一个TypeError。循环引用就是对象属性直接或间接地指向自身const obj {}; obj.self obj; try { JSON.stringify(obj); } catch (e) { console.log(e.message); // Converting circular structure to JSON }这个报错信息极容易让人一头雾水错误里并不会告诉你到底是哪个属性引发了循环只提示“转换循环结构到JSON”失败。排查这种问题最笨也最有效的方式是如果数据结构比较复杂你可以深度优先遍历对象用一个WeakSet记录已经访问过的引用一旦发现有对象已经在集合里就说明有循环引用把那个路径打出来。我遇到过的情况是前端表格组件里某个字段把组件的实例本身挂了上去一序列化直接崩。因为组件实例内部有大量互相引用的对象整个链是网状结构几乎无法用JSON序列化。这时候不要硬用JSON.stringify处理老老实实做一个白名单字段提取或者用其他方案比如手动构建一个纯数据的DTO。3. 实战场景从深拷贝到日志脱敏3.1 深拷贝最常用的用法却有一身暗病JSON序列化经常被用来做“深拷贝”写起来很方便const original { a: 1, b: { c: 2 } }; const copy JSON.parse(JSON.stringify(original));这种方式在对象结构简单、字段类型温顺的时候是对的。但要命的是这个“深拷贝”是不完整的。你拷贝出来的结果已经偏离了原始数据如果对象里有function拷贝结果里function直接消失。如果有Date对象序列化后变成字符串反序列化后它是一个字符串不再是Date实例。如果有undefined字段直接消失。如果有RegExp类型的值会被转成空对象{}。如果有Map、Set序列化结果只有{}里面的键值对全丢。比如const data { name: 测试, createdAt: new Date(), log: () {} }; const copy JSON.parse(JSON.stringify(data)); console.log(copy); // 输出: { name: 测试, createdAt: 2026-01-01T08:00:00.000Z } // log 字段没了createdAt 从 Date 变成了字符串所以我在团队里的规矩是只允许对纯数据对象用这个方式做深拷贝凡是含非JSON安全类型的对象一律用structuredClone现代浏览器和Node.js都支持或者手写递归拷贝。structuredClone能处理Date、RegExp、Map、Set这些类型也比JSON两件套可靠得多。3.2 localStorage数据存储别忽略状态恢复浏览器localStorage只能存字符串所以往里面放对象数据时必然会用到JSON.stringify()。这是个老套路了但有几个非常容易踩的坑。第一写入之前一定要判断一下对象里有没有循环引用。有些状态管理库会把大对象、工具函数挂到state上一不小心就整个崩溃。第二localStorage有5MB左右的容量限制JSON.stringify()会把对象压缩成一行但中文字符在JSON里可能被转成\uXXXX格式占用额外空间。第三读取时要包一层try/catch因为数据可能被手动改过、清理过或者用户清除了浏览器缓存JSON.parse()遇到脏数据会直接抛异常不加保护整个应用白屏。一个看起来不显眼但很实用的建议写数据时给存储值加一个版本号字段比如{ version: 1, data: {...} }读取时检查版本号方便以后做迁移。这比直接把raw data塞进去靠谱得多。3.3 接口报文打印与日志脱敏开发调试时打印接口数据是家常便饭。直接console.log(object)在控制台里看着是树形结构很直观但如果你要把数据发送到日志平台或者记录到本地文件里就必须要靠JSON字符串。这时候replacer参数就能派上用场。一个很实用的场景是在把日志上报到第三方平台前把敏感字段密码、token、手机号脱敏const masked JSON.stringify(userInfo, (key, value) { if (key password || key token) { return ***; } if (key phone) { return value.replace(/(\d{3})\d{4}(\d{4})/, $1****$2); } return value; }); console.log(masked);这样日志里就不会泄露真实密码。我见过有一些马虎的团队直接把包含密码字段的对象原样打印上去导致登录态被截获这个问题在前后端联调时非常常见。4. 高阶玩法toJSON、replacer函数与性能优化4.1 自定义toJSON方法把序列化控制权握在自己手里JSON.stringify()在处理一个对象时会先检查这个对象上有没有toJSON方法。如果有它会调用这个方法把返回值作为要序列化的对象。利用这个机制你可以对一个类或一个实例做定制化的序列化。class Money { constructor(amount, currency CNY) { this.amount amount; this.currency currency; } toJSON() { return { value: this.amount.toFixed(2), currency: this.currency }; } } const price new Money(99.9); console.log(JSON.stringify(price)); // 输出: {value:99.90,currency:CNY}这个方法特别适合一些数据模型对象。我项目里通常会为日期范围、金额、枚举值各自实现toJSON。这样只要负责数据模型的人写好toJSON所有调用方就都不需要关心底层数据结构怎么转换只要忠实调用JSON.stringify()就行了。JavaScript内置的Date对象本身就有一个toJSON实现它返回的是ISO 8601格式的字符串所以JSON.stringify(new Date())得到的是带T和Z的字符串——这就解释了为什么Date会变成字符串。4.2 replacer函数实战字段过滤与数据映射replacer函数除了过滤字段还能做数据映射。比如从接口拿到了数据处理后想把某些字段名改成前端友好的形式不需要写一堆循环和映射逻辑在replacer里面一边序列化一遍改名就行。const apiData { user_id: 1, user_name: 李四, user_email: lisiexample.com }; const transformed JSON.stringify(apiData, (key, value) { if (key user_id) return this.userId value; // 这里不能用 this 直接赋值实际中更多用外部变量收集 return value; });说实话replacer函数做“改名”体验一般不如直接先用mapKeys之类的函数处理对象再序列化更清晰。但replacer函数在“根据值的类型做处理”这个场景就很顺手。比如你有一个对象里面有些字段值特别大或者长度超限你可以在replacer里做截断处理const bigData { title: 一个特别长的标题.repeat(50), summary: 短内容 }; const truncated JSON.stringify(bigData, (key, value) { if (typeof value string value.length 20) { return value.slice(0, 20) ...; } return value; });这个方法的执行顺序是从最外层开始对每个key/value对都调用一次函数然后递归处理下一层。如果想要弄清楚执行顺序可以打印key来看往往会发现它先处理外层再深入内层。所以如果你在replacer里给某层对象的value动了手脚影响会传入更深层。4.3 大数据量序列化的性能优化思路JSON.stringify()的性能在大对象上其实不差但一旦对象有几千层嵌套或者有上万个字段还是会出现可感知的卡顿。处理大数据量时我一般遵循几个原则第一个原则是能过滤就先过滤。用replacer把用不到的字段提前剔除减少序列化负担。第二个原则是尽量避免在序列化过程中调用特别耗时的函数。replacer和toJSON里都要保持轻量不要写循环查表、网络请求之类的逻辑。第三个原则是如果数据结构非常规整自己组装字符串可能比JSON.stringify()更快。我记得有个项目中需要从10000条记录里提取若干字段并转成JSON数组上报用JSON.stringify()整体转换上限延时可观换成遍历拼字符串之后速度快了将近三倍。当然这属于极端优化绝大多数场景用不着但了解这个思路没坏处。还有一个值得提的点在Node.js环境或浏览器环境里序列化后导入导出数据可以开启sonic-stringify这类快速序列化库性能提升明显。但常规项目用原生的就足够了不要盲目换库增加依赖复杂度和维护负担。5. 避坑指南高频踩坑问题速查5.1 中文被转成\uXXXX的编码问题这是在日志里最显眼的坑。你用JSON.stringify()处理一段含中文的对象结果字符串变成了一堆\u5f20\u4e09这样的Unicode转义。值确实是等价的JSON.parse()之后也能还原但可读性极差日志系统里看到的就是半人半鬼的内容。造成这个现象的原因是JSON规范允许把字符转义成Unicode形式JavaScript的引擎实现默认就做了这个转换。如果你不想看到这种编码可以给space参数传一个普通值或者在序列化后做一次替换const jsonString JSON.stringify(obj, null, 2) .replace(/\\u([\da-f]{4})/gi, (_, hex) String.fromCharCode(parseInt(hex, 16)));但要注意这个替换方案可能会对字符串里原本的\uXXXX字面量造成重复解码务必确认数据里没有这类字面量再用。更稳妥的做法是写自定义的替换逻辑首先JSON.stringify()生成字符串然后只把替换结果的\uXXXX转回来。在发送给日志平台或打印本地文件时如果属于人读的场景这个处理很值得做。5.2 日期对象序列化的小坑JSON.stringify({ time: new Date() })输出的time字段是ISO格式字符串。这个字符串本身没有问题问题是反序列化之后你拿到的是一个字符串而不是Date对象。所以在解析日期字段时const data JSON.parse(jsonStr); const realTime new Date(data.time);如果你在前端渲染一个时间字段时直接data.time.toLocaleString()就会报错因为字符串根本没有toLocaleString方法。排查半天才发现数据早就被序列化成字符串了。规范做法是后端接口里Time字段约定为ISO字符串前端拿到后统一转成Date实例或者使用dayjs等库库内部会做兼容。5.3 深拷贝丢失undefined、函数、Date、RegExp等类型前面已经反复强调了这里用一张表看明白类型序列化结果反序列化结果undefined对象中键被删除数组中变null可能字段消失function对象中键被删除数组中变null字段消失Symbol对象中键被删除数组中变null字段消失DateISO字符串变成字符串RegExp{}变成空对象Map/Set{}变成空对象或空数组NaN/Infinitynullnull循环引用抛TypeError无法解析如果数据里混合了这些类型深拷贝要用structuredClone或者自己写递归去拷贝。前端常见的表单数据里偶尔会混入Date对象尤其是一些日历控件提交数据前要把日期转成字符串整体检查一遍再序列化。6. 几个值得收藏的组合技巧6.1 捕获序列化异常保证程序不崩溃JSON.stringify()虽然只有循环引用会抛异常但在项目里因为它崩溃的情况却不少。原因是对象结构可能动态变化某个依赖注入的实例不小心被塞进了对象里引发循环引用。为了在关键链路上不因为数据异常而中断我习惯写一个安全包装function safeStringify(data) { const seen new WeakSet(); try { return JSON.stringify(data, (key, value) { if (typeof value object value ! null) { if (seen.has(value)) { return [Circular]; } seen.add(value); } return value; }, 2); } catch (e) { return [StringifyError]: e.message ; } }用WeakSet来跟踪已经处理过的对象一旦发现重复引用就替换成一行文字而不是直接抛错。这个方案在打印日志时很管用至少不会让日志上报把整个链路给带崩了。6.2 JSON.stringify()与JSON.parse()的组合应用配置持久化与控制反转前端有个经典场景把配置对象序列化后存到localStorage或后端配置中心下次再通过JSON.parse()恢复。组合起来几乎是无敌的便利。但是在参数校验和默认值处理上要多花心思解析时永远要做好失败兜底用默认配置替代。存储前要对配置做白名单过滤避免把运行时变量比如网络请求实例、缓存对象也存进去。如果要支持配置版本迁移务必在对象里放一个version字段read时根据version走不同的迁移逻辑。我做过一个数据可视化大屏项目里面所有图表配置就是靠这种策略存储的。每个图表节点导出成一个JSON对象保存时用JSON.stringify()做成快照恢复时用JSON.parse()读出来。这个方案简单可靠出错率低而且每份配置可以复制给人协作。只要别在配置对象里塞函数和Date稳得一批。6.3 后置一个小技巧如何在控制台快速可视化嵌套JSON排错时最痛苦的是看一堆嵌套几百层的JSON字符串。现在我一般直接复制JSON到浏览器控制台用JSON.stringify配合space参数再加一个自定义高亮函数打印效果会好看很多。虽然现代浏览器的控制台对对象直接打印也有样式但碰到纯字符串格式的JSON报文时用这个方法比肉眼硬看强得多。console.log(JSON.stringify(data, null, 2)); // 结构清晰层级分明如果还需要交互式展开折叠直接把字符串JSON.parse()一下再用console.table()之类的命令渲染便于快速定位字段值。结合实践说点实在的JSON.stringify()属于那种“刚开始学觉得简单用久了才发现套路深”的方法。它远不止“对象转字符串”这一个功能三个参数组合出来的能力覆盖字段过滤、脱敏、格式化、定制化输出等场景完全称得上“前端必会的基础函数”。但它的局限也很明显类型覆盖有限、循环引用会炸、深拷贝不彻底。我个人在实际项目里的习惯是先评估数据里有没有“非JSON友好”的类型再决定用原生JSON.stringify()、定制toJSON还是换成structuredClone。遇到循环引用我不会硬写复杂递归去强行序列化而是尽量从源头避免——封装数据对象时就不让自引用出现。最后再分享一个小建议如果你经常调试Node.js或浏览器里的数据建议在你的工具函数库里专门维护一个safeStringify工具把异常捕获和循环引用处理都收敛到一个函数里。一句能代替十句的调试代码用起来是真的舒服。
阅读完成 · 觉得有帮助?