☰
JSON.stringify()深度解析:从底层规则到实战避坑指南
2026/10/3 4:34:28 网站建设 项目流程

写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变"true",false变"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: 'lisi@example.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工具,把异常捕获和循环引用处理都收敛到一个函数里。一句能代替十句的调试代码,用起来是真的舒服。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询