2026-07-02 · Calc-Tools 编辑

JSON格式化工具:美化、压缩和校验JSON数据

JSON 是机器与机器交换数据时的默认选项,它同时也是一种"看起来到处都能用、实际上到处都有边界"的格式。格式化工具能回答一个问题——这段文本语法是否合法;它回答不了另外三个更要命的问题:这个结构在跨语言传递时会不会被悄悄改写?它能不能表达我真正需要的类型?数据量涨上来之后,协议层该做什么取舍?

本文只讲 JSON 的工程边界:哪些数字会在解析时失真、哪些字段会在序列化时凭空消失、为什么同一份文件在不同语言里可能得到不同结果,以及超大 payload 到来时的处理策略。文中所有数字都在 Node 22 上实测,可以照着复现。

一、什么时候 JSON 不是答案:五种格式的选型

选数据格式只看四个问题:谁写、谁读、要不要注释、要不要类型。JSON 在"机器写、机器读"这一格几乎没有对手;一旦写的人变成人,或者需要注释与类型信息,它就开始吃紧。

格式注释类型与精度手写友好度典型用途主要代价
JSON不支持只有字符串、数字、布尔、null、对象、数组;数字精度由实现决定低:强制双引号,禁止尾随逗号Web API、消息队列、文档型存储表达力最弱,缺失的部分只能靠约定补
YAML支持有日期、二进制、锚点与引用高Kubernetes 清单、CI 流水线、人工维护的配置缩进敏感;隐式类型推断会咬人,纯日期会被当成日期类型
XML支持全部是文本,类型靠模式文件约束中企业级契约、需要命名空间或混合内容的文档冗余度最高,解析与人力成本都高
TOML支持有日期时间、整数、浮点、数组、表高项目配置,如 Cargo、pyproject不适合承担深层嵌套的数据交换
CSV不支持只有文本,类型完全靠约定中表格数据、报表导出、批量导入没有层级;引号、换行、编码处处是坑

规则可以压缩成三句:程序生成、程序消费的数据用 JSON;人写、需要注释、层级不深的配置用 TOML 或 YAML;扁平表格用 CSV,需要命名空间和强模式校验时才回到 XML。JSON 的优势不在"好写",而在"无歧义":任何语言的解析器读同一段文本,都会得到相同的结构。这既是它被接口层选中的原因,也是它刻意放弃注释与类型换来的结果。

二、数字:JSON 唯一的数值类型就叫"数字"

RFC 8259 只规定了数字的语法——可选负号、整数部分、可选小数部分、可选指数——对取值范围和精度没有任何规定,规范第 6 节甚至明确允许各实现自行设定可接受的数字范围与精度。也就是说,"一个 JSON 数字究竟有多大"不是规范问题,而是实现问题:同一段文本交给不同语言的解析器,得到的数值可能不一样。

JavaScript 是最典型的一种实现:JSON.parse 把每个数字都装进 IEEE 754 双精度浮点数。双精度能精确表示的整数上界是 2^53 − 1,也就是 9007199254740991(约 9.007 × 10^15,16 位十进制数字)。越过这条线之后,相邻可表示值之间出现间隔,数字会被静默舍入到最近的可表示值,不报错、不警告。

下表是在 Node 22 上逐条实测的结果:

原文中的数字经 JSON.parse 之后是否失真原因
90071992547409919007199254740991否恰好等于 2^53 − 1,安全上界
90071992547409929007199254740992否2^53 本身可精确表示
90071992547409939007199254740992是,少了 1落在两个可表示值正中,舍入到偶数尾数
90071992547409959007199254740996是,多了 1同样规则,这次舍入方向相反
12345678901234567891234567890123456800是,末位被改写19 位雪花 ID 的典型失真
98765432109876543219876543210987655000是,后 4 位被改写可表示间隔已经扩大到 1024
99999999999999.9999999999999999.98是,分位少了 116 位有效数字已超出双精度的十进制精度

9007199254740993 这个例子还有一层更隐蔽的后果:失真不只发生在解析时。在 JavaScript 里计算 9007199254740993 - 9007199254740992,结果是 0——因为两个字面量在参与运算之前,就已经是同一个双精度值了。换句话说,你没法在运行时"检测"这次失真,只能在数据进入系统之前就把它挡住。

金额是第二个重灾区,但它的失效方式与整数不同。19.9 这样的十进制小数在二进制里本来就不精确,JSON.parse 给出的确实是"最接近 19.9 的那个双精度数",可是它一旦参与运算就会漂出来:19.9 × 3 = 59.699999999999996,而 0.1 + 0.2 = 0.30000000000000004。业界的通用做法是用最小货币单位的整数承载金额:19.9 元写成 1990 分,乘 3 得 5970,再除以 100 得到 59.70,全程停留在整数域内,不产生任何舍入。

反过来看时间戳,这条边界其实相当宽松。13 位的毫秒时间戳(当前约 1.78 × 10^12)离上界还有三个数量级的余量;16 位的微秒时间戳(约 1.78 × 10^15)也仍在安全区内,按实测推算要到 2255 年才会触顶。真正危险的从来不是时间,而是那些"本质是编码、却被当成数量"的标识符。

由此得到两条硬约定:超过 15 位有效数字的标识符(订单号、雪花 ID、数据库主键)和所有金额,在 JSON 里一律用字符串或整数承载,不要用 JSON 数字。数据库里是 bigint,不等于 JSON 里也能当数字传——中间那一层双精度会替你做出决定,而且不通知你。

三、JSON 没有的三种东西,与工程上的替代方案

JSON 的类型系统只有六种:字符串、数字、布尔、null、对象、数组。工程上高频需要的另外三样——注释、日期,以及"字段根本不存在"这种状态——都必须靠约定表达。

注释:写进数据,还是放在数据之外

标准 JSON 不能有注释,所以注释只有两个去处。一是把说明写成同层的普通字段,惯例是以下划线开头,例如 _comment、_note、_schema。这样做的好处是说明和数据同生共死,复制粘贴不会丢;代价是这些字段会真实存在于数据里,消费端要么显式忽略它们,要么就要面对"多出来的字段算不算契约变更"这个问题。二是把说明放在数据之外:独立的模式文件、接口文档,或者构建期才处理的 JSONC、JSON5。判断标准很简单:这份 JSON 会不会被别的系统原样透传或原样存储?会,就把注释放到外面。至于用双斜杠写注释的写法,它只在你自己的构建链路里成立,一旦这份文件被标准解析器读到,那两个字符就是非法记号。

日期:ISO 8601 字符串,并且带上时区

JSON 没有日期类型,所以日期只能是一种字符串。通行做法是 ISO 8601,并且必须带时区偏移或 Z,例如 2026-09-29T08:30:00Z。实测中 JSON.stringify(new Date()) 输出的正是 2026-09-29T08:30:00.000Z 这种带毫秒的标准 UTC 形式,而把它解析回来得到的是一个字符串,不是日期对象——类型转换的责任始终在应用层。

这里有两个容易忽略的差异。第一是精度:秒级和毫秒级混用会让排序和去重出现意外结果,团队内要统一到毫秒。第二是纯日期字符串:2026-09-29 在 JSON 里就是普通字符串,但在 YAML 里会被隐式推断成日期类型,同一个值跨格式搬运时类型可能突变。需要按"日"而不是按"时刻"表达时,建议显式约定为不含时间的日期字符串,并在模式文件里写清。

null 与缺键:两种完全不同的语义

这是接口设计里最容易含糊的一处。键存在、值为 null,意思是"这个字段被明确置空了";键根本不存在,意思是"这次没有提到这个字段"。两者在数据层面完全可区分——实测中 "nickname" in {"nickname":null} 为 true,而 "nickname" in {} 为 false。

写法语义消费端应当如何处理
{"nickname":"Tom"}显式赋值把字段更新为 Tom
{"nickname":null}显式清空按约定置空;在合并补丁类接口中通常表示删除该字段
{},不含该键本次未提及保持字段原值不变
值来自 JavaScript 的 undefined键在序列化时直接消失效果等同"未提及",接收方无法分辨

最后一行值得单独说:在 JavaScript 里,对象属性值为 undefined 时,JSON.stringify 会把这个键整个丢掉,而不是写成 null。实测 JSON.stringify({a: undefined, b: 1}) 的结果是 {"b":1};如果同样的值出现在数组里,undefined 又会被写成 null。同一个"空",在对象里消失、在数组里变成 null,而顶层直接序列化 undefined 连字符串都不产出。所以"字段没传上来"和"字段传了但值是 undefined"在下游看起来一模一样,只能靠显式写入 null 来区分。

结论:把 null 与省略写进接口契约,不要留给调用方猜。更新类接口尤其要明确:null 是"清空"还是"删除",省略是"不动"还是"取默认值"。

四、序列化过程中的静默改写清单

除了数字精度,序列化环节还会做一批"不改语义、但改文本"的动作。它们单独看都合理,凑在一起就成了线上问题最难排查的部分:数据在两端看起来都"正常",只是不一样。

现象触发条件后果规避方式
整数键被提前并升序排列对象的键名是纯数字字符串,例如 {"2":"b","1":"a","name":"x"}顺序变成 {"1":"a","2":"b","name":"x"},与书写顺序不符不要依赖键顺序表达业务含义;需要顺序就用数组
重复键静默取后者同一对象里出现同名字段,例如 {"a":1,"a":2}解析结果是 {"a":2},不报错合并配置前先校验,避免"手改一处、覆盖另一处"
NaN 与 Infinity 变成 null值来自计算,可能是非有限数序列化后写成 null,与真正的空值无法区分先判有限性,非法值用明确标记或直接拒绝写入
溢出值仍能解析文本里写了 1e999 这种合法但超范围的数字语法合法,解析结果是 Infinity,再序列化又变成 null对数值字段做范围校验,不能只看语法
日期对象变成字符串值来自内置日期类型或日期库写出去是 ISO 8601 字符串,读回来是字符串而不是日期契约里明确日期一律为 ISO 8601 字符串
文件开头带 BOM 时整段报错文件由部分编辑器或表格软件导出,带 UTF-8 BOM解析直接抛错,提示类似 Unexpected token,而文件肉眼完全正常清掉文件头部的不可见字符后再解析
存在循环引用时直接抛错对象属性指回自身抛出 Converting circular structure to JSON,属于硬失败而非静默改写序列化前断开反向引用,或只挑选需要的字段

这份清单里最值得记住的是 BOM 那一条:它是"文件完全正常却解析失败"最常见的成因,而 BOM 恰恰是最难用肉眼发现的一类字符——它和空文件的开头长得没有任何区别。遇到无法解释的解析失败,第一步应该是检查文本头部有没有夹带不可见字符,可以先用文本清洗工具过一遍,再回来格式化。

五、三个可以复现的算例

算例一:从数据库到前端,一个订单号是怎么变的。某订单表主键是 19 位雪花 ID,值 1234567890123456789。接口把它作为 JSON 数字返回,浏览器解析之后这个值变成 1234567890123456800,后两位被改写。用户点开订单详情,前端拿这个 ID 去请求,服务端查不到记录,返回 404;如果服务端做了"就近匹配",还可能返回另一个用户的订单。同一个字段换成 9876543210987654321,失真更明显,后 4 位被改写成 5000。修法只有一条:ID 在 JSON 里始终是字符串,序列化时显式转换,不要依赖默认行为。

算例二:金额计算为什么会差一分钱。商品单价 19.9 元,买 3 件。若 JSON 里写的是数字 19.9,解析后参与乘法得到 59.699999999999996;如果中间又做了四舍五入、累加、再打折,误差会逐步放大,最终对账时出现一分到几分的差额。改成以分为单位的整数:单价 1990,数量 3,小计 5970,折扣按整数比例计算,最后一步才除以 100,全程没有任何浮点误差。涉及税费分摊、优惠券按比例拆分的场景,同理。

算例三:压缩 JSON 到底省了多少。取一个包含 12 条明细的订单对象(含商品名、数量、单价、标签、收货信息),分别用两种形态序列化,再各做一次 gzip:

形态原始字节gzip 之后gzip 压缩率
格式化(2 空格缩进)214139718.5%
压缩(不含任何空白)119234528.9%
压缩相对格式化的节省省 44.3%只再省 13.1%—

结论有点反直觉:在不压缩的通道上,去掉空白能省 44.3%;但在已经启用 gzip 的通道上,同样的操作只能再省 13.1%——397 字节和 345 字节之间只差 52 字节。原因是 gzip 已经把这些规律性极强的空白和重复键名压掉了。换成 5000 行的数组再验证一次:压缩形态 200281 字节,gzip 之后是 24565 字节,只剩 12.3%。所以"为了省流量所以必须压缩 JSON"这个理由,在现代传输链路上收益有限;真正值得优化的是解析后的内存占用与对象数量,而不是原始字节数。

六、大 JSON 该怎么读

先把"大"量化,否则讨论没有意义。上面那段 5000 行、200281 字节的 JSON 数组只有约 196 KB,对任何解析器都不算大;真正的量级线在几十兆到几吉字节。到了那个体量,"一次解析全读进来"会在内存里同时持有原文文本和解析后的对象树,峰值远高于原文字节数,而且这个过程无法回退。

策略做法适用场景代价
全量解析一次性读入并解析几十 KB 到几 MB,需要随机访问内存峰值等于全文加对象树
按行分片每行一个独立 JSON 对象,逐行解析,即 NDJSON 风格日志、批量导出、流式导入无法表达跨行的嵌套文档结构
事件式流解析遇到一个字段就回调一次,不在内存里建整棵树超大文件里只想抽取少数字段代码复杂度显著上升,不能随机回溯
分页或游标接口层限制单次返回量,用游标翻页列表类接口需要服务端支持,调用方要处理翻页状态
字段裁剪只请求需要的字段,从源头减小 payload前端只需要少数字段需要接口支持字段选择

另外两点实测结论可以帮你排优先级:一是嵌套深度通常不是首要瓶颈,在 Node 22 上构造 10000 层嵌套的数组仍然可以正常解析,体量比深度更早出问题;二是分片比压缩更根本,因为 gzip 只能减少传输字节,减少不了解析后的对象数量。需要逐行处理的场景,可以把数据存成 NDJSON,再配合文本对比工具比较两次导出的差异,比整份文件肉眼核对可靠得多。

七、接口契约里值得固定的六个约定

还有两条偏细节的约定:URL 路径习惯用连字符、JSON 的键习惯用驼峰或下划线,两边不要互相传染,URL 里的特殊字符该编码就编码,具体规则见URL 编码工具;字段格式校验(手机号、单号、时间格式)适合先用正则表达式测试器把规则跑通,再落到服务端。

八、常见问题(FAQ)

JSON 里的数字为什么会自己变?
因为 JSON 规范只规定了数字的语法,没有规定精度,而主流实现(最典型的是 JavaScript 的 JSON.parse)用 IEEE 754 双精度承载所有数字。双精度的安全整数上界是 9007199254740991,超过之后就会静默舍入:9007199254740993 会变成 9007199254740992,19 位雪花 ID 会变成末位为零的近似值。解决办法是把这类值当字符串传。
null 和"字段缺失"到底有什么区别?
键存在且值为 null,表示"显式置空";键不存在,表示"本次未提及"。两者在数据层完全可区分,所以接口契约必须分别定义它们的含义。更新类接口尤其要写清:null 是清空还是删除字段,省略是不动还是取默认值。
几十兆的 JSON 文件该怎么读?
优先改变数据形态,而不是换解析器。把整份文档改成每行一个对象的 NDJSON,就能逐行解析、边读边处理,内存占用与文件大小脱钩;如果只能拿到整份文档、又只需要其中少数字段,就用事件式流解析。只有在几十 KB 到几 MB 的量级上,一次性解析才是合理选择。
文件内容看起来完全正常,为什么还是报解析错误?
最常见的原因是文件头部带了 UTF-8 BOM。BOM 肉眼不可见,但它是真实字符,会出现在第一个花括号或方括号之前,解析器读到它就直接报错,提示信息往往只说"意外的记号"。先用清洗工具去掉不可见字符,通常就能通过。
JSON 能直接存日期和二进制吗?
都不能。JSON 的类型系统里没有日期,所以日期只能写成 ISO 8601 字符串,而且建议统一带时区、统一到毫秒;也没有二进制类型,只能用 Base64 编码成字符串,代价是体积膨胀约三分之一。真正大的文件不该走 JSON 通道,而该走独立的二进制传输。

九、三个反直觉的误区

误区一:同一份 JSON 在任何语言里解析结果都一样。文本层面的语法是统一的,数值层面却不是。JSON 规范把数字的范围与精度留给了实现,所以一个 19 位整数在 JavaScript 里会被舍入,在保留任意精度整数的实现里却能原样保留。这意味着一份"在服务端验证通过"的数据,到了浏览器里可能已经不是同一个值。跨端传递时把这类数值一律字符串化,"一样"才真正成立。

误区二:数据库里是数字,JSON 里当然也应该是数字。数据库的 bigint 有自己的取值范围,JSON 数字的取值范围却取决于解析它的语言。把 bigint 直接映射成 JSON 数字,等于把精度决策权交给了消费者,而消费者往往是最不受控的那一端。ID、卡号、单号这类标识符的本质是编码而不是数量,它们本来就不该参与算术运算。

误区三:压缩 JSON 能显著省流量。实测显示,在不压缩的通道上省 44.3%,在已经 gzip 的通道上只能再省 13.1%。现代浏览器、CDN 和网关默认都会对文本响应做压缩,去空白带来的收益因此远比想象中小。相比之下,减少字段、减少记录条数、把大响应改成分页,收益要直接得多。

十、四条可以直接执行的建议

JSON 的边界总结起来只有一句:它擅长承载"结构",不擅长承载"类型"和"精度"。把它当作一层中立的文本传输协议,所有需要精度的东西(ID、金额、时间)都用字符串或整数显式表达,所有需要类型的东西(日期、二进制)都用约定补齐,剩下的问题就只剩数据量——而数据量是分片问题,不是格式问题。

在线使用 JSON格式化

无需下载,打开即用

立即使用 JSON格式化

工具