JSON 格式化的三个易错点:大整数、重复键与错误定位
整理接口返回值时,最顺手的写法通常是 JSON.stringify(JSON.parse(source), null, 2)。对普通业务对象,这样做很方便;但如果工具的目标是“只调整排版,保留输入内容”,解析后重新序列化就可能带来意料之外的变化。
下面结合一个浏览器端 JSON 工具的实现,说明三个容易忽略的问题。文中的运行结果使用 Node.js v24.14.1 验证,核心代码与测试可在文末的公开仓库中查看。
一、大整数没有报错,内容却变了
先看一段符合 JSON 数字语法的输入:
const source = '{"id":900719925474099312345,"amount":1.2500,"tiny":1e-9999,"big":1e9999,"zero":-0,"word":"\\u4f60\\u597d"}';
console.log(JSON.stringify(JSON.parse(source)));
实际输出:
{"id":900719925474099300000,"amount":1.25,"tiny":0,"big":null,"zero":0,"word":"你好"}
这里同时发生了几种变化:
id超过 JavaScript 安全整数范围,转成Number时已经丢失精度。1.2500被重新写成1.25;如果要保留输入的小数位写法,这个信息已经消失。1e-9999在此运行环境中下溢为 0;1e9999转为Infinity,随后被JSON.stringify序列化为null。-0被输出成0。- Unicode 转义被解码后重新输出成汉字,字符串值相同,文本写法不同。
这些变化中,既有数值精度或范围变化,也有数值相等但表示形式变化。对于日志排查、接口签名材料核对、订单编号检查,这种区别很关键。
RFC 8259 没有把 JSON 数字限制为 JavaScript 的 Number,但接收端可以有自己的范围与精度限制。跨系统可靠交换整数时,常用的精确互操作范围是 -(2**53)+1 到 (2**53)-1。业务中的长编号可以在接口约定中直接使用字符串;格式化工具则应尽量保留用户原本输入的数字文本。
二、重复键应提示,也应保留证据
再看这个输入:
const source = '{"a":1,"\\u0061":2,"__proto__":3}';
console.log(JSON.stringify(JSON.parse(source)));
// {"a":2,"__proto__":3}
"a" 和 "\u0061" 解码后是同一个属性名。在 JavaScript 的普通解析结果中,前一个成员被后一个覆盖,重新输出时已经无法看到重复出现过。
RFC 8259 对对象成员名的要求是 SHOULD 唯一。不能把所有重复键一概说成 JSON 语法错误;不同接收端可能保留最后一个、拒绝输入,或者保留全部成员。因此,排查工具更有用的做法是提示风险,同时保留原始成员和顺序。
重复检测还需要关注两个细节:
- 比较解码后的键名,才能发现
"a"与"\u0061"的重复。 - 每个对象分别维护键集合,避免把不同层级中同名的字段误报为重复。
实现中可以为每个对象创建 Set。读取一个属性名后,只解码这个字符串,用于判重;展示和输出仍使用包含双引号与转义的原始 token。Set 也能正常处理 __proto__ 这样的字符串键名。
对于示例输入,工具会提示 1 次重复,压缩输出仍保留全部三个成员:
{"a":1,"\u0061":2,"__proto__":3}
这能保留问题现场,但不会替用户决定最终应该采用哪个值。接收接口需要唯一键时,仍应修正数据来源。
三、先校验语法,再围绕原始 token 调整空白
为保留原始表示,可以把处理过程分成两步:先校验 JSON 语法并收集原始 token,再根据 {、[、,、: 等结构符号组织缩进。
数字识别的关键是记录原文,而不是马上调用 Number():
const number = source.slice(pos).match(
/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/
);
if (number) {
pos += number[0].length;
tokens.push(number[0]);
}
这只是完整解析器中的数字分支。对象、数组、字符串、转义、分隔符和输入结束位置仍需逐项校验。例如 01 虽然可以先匹配到 0,完整校验还会拒绝后面多余的 1,不能因为正则匹配成功就接受整份输入。
字符串也保留从开始双引号到结束双引号的原始片段。扫描器在字符串内部处理反斜杠与转义序列,因此字符串中的逗号、括号不会被误当作结构分隔符。这也是不能简单用正则替换逗号来添加换行的原因。
仓库中的核心接口如下:
import { parseJson, formatTokens } from './json-core.mjs';
const source = '{"id":900719925474099312345,"amount":1.2500}';
const parsed = parseJson(source);
console.log(formatTokens(parsed.tokens));
console.log(parsed.tokens.join(''));
格式化结果:
{
"id": 900719925474099312345,
"amount": 1.2500
}
压缩结果:
{"id":900719925474099312345,"amount":1.2500}
结构之外的空白可以重新组织,数字写法、字符串转义、重复成员与成员顺序都来自原始 token。这里保留的是文本表示,并没有提供任意精度数值运算能力。
现代 JavaScript 引擎还可以通过 JSON.parse 的 reviver 第三个参数中的 context.source 取得部分原始值的源码,具体需核对目标运行环境的支持情况。普通两参数 reviver 执行时,数字已完成转换;而要完整保留重复对象成员和整体词法表示,仍需选择适合该目标的解析结构。
四、错误位置要说明坐标规则
只显示“JSON 格式错误”,用户通常需要重新检查整段内容。更实用的错误信息应包括原因、行号、列号和原文偏移。
以末尾多余逗号为例:
parseJson('{\n "a": 1,\n}');
// JsonInputError
// message: JSON 不允许末尾多余的逗号。
// position: 11
// line: 3
// column: 1
这个实现用 JavaScript 字符串索引记录偏移,position 从 0 开始,行列从 1 开始;换行以 \n 统计。列号按 UTF-16 代码单元计算,不等于屏幕上的字形宽度或用户感知字符数。例如部分 emoji 占两个代码单元,制表符的显示宽度也取决于编辑器。
前端高亮或跳转时,必须和编辑器约定同一种坐标规则,否则提示位置正确,光标却可能落在旁边。
五、用回归样例明确能力边界
这个工具的测试覆盖了普通结构、大整数与指数写法、重复键、非法语法、错误位置、输入限制与缩进。实际运行的 6 组测试均通过。对于格式化器,最重要的验收条件之一是:格式化后再次校验,得到的 token 序列应与原输入一致。
当前实现将输入限制为 UTF-8 编码后的 1 MiB,并设置 200 层的嵌套深度保护。它会把完整 token 数组保存在内存中,适合浏览器中的日常接口数据整理;超大文件需要另行设计流式处理。它也只校验 JSON 语法,不负责 JSON Schema、字段类型约定或业务有效性检查。
选择工具时,可以用本文中的大整数、重复键和多余逗号三个样例做一次快速检查。能否保留原始信息、能否指出具体错误,往往比缩进样式有多少种更影响排查效率。
代码与参考资料
- 工具源码与测试:Clear JSON Workbench
- 作品介绍:JSON 校验与格式化工具
- RFC 8259,第 4 节 Objects、第 6 节 Numbers。
- MDN JavaScript 参考:JSON.parse、JSON.stringify、Number.MAX_SAFE_INTEGER。
- 点赞
- 收藏
- 关注作者
评论(0)