JSON 决策指南:校验、格式化、结构查询与格式转换
大多数 JSON 事故不是解析器出错,而是在错误的那一层修数据。这篇按四类问题给出判据:什么时候只看 position,什么时候必须补 schema 校验,缩进与键序该由谁定,以及数组改 NDJSON、JSON 转 XML 之后到底有哪些信息再也拿不回来。
先分清四类 JSON 问题,处理方式完全不同
「JSON 报错」这四个字下面压着四件不同的事。第一件是语法:文本不符合 RFC 8259 的 grammar,任何解析器都拒绝接收。第二件是契约:文本能解析,但少了字段、类型不对、取值超界,这是接口双方的 schema 约定,标准不管这些。第三件是表示:能解析、字段也对,只是缩进混乱、键序随机,人读不动、diff 全是噪音。第四件是搬运:要把它变成 XML、YAML、CSV,或者只取出其中一个子树。
判据只有一句:先跑一次 JSON.parse,看结果落在哪一格。抛错就是语法问题,去改文本;解析成功但下游拿到 undefined 或类型错就是契约问题,去补校验;解析成功、校验也过,只是难读或 diff 抖动就是表示问题,只该动空白与键序;目的本身是换格式或复用子树就是转换问题。不可逆点在第二步:把「解析成功」当成「数据正确」,后面的精度丢失、重复键覆盖都会被当成业务 bug 去追。
三种代价并不对等,这决定了必须先分类。语法修复是一次性的,改完即止;表示层的选择会随模板复制进每一个新文件,一年后要统一缩进等于全仓改一遍;转换层的丢失是永久的,命名空间和类型信息被压平成普通对象之后,没有任何工具能把它们还原回去。
| 现象 | 问题类别 | 第一步动作 | 代价 |
|---|---|---|---|
| 解析直接抛 SyntaxError | 语法 | 打 position 附近的上下文 | 一次性,改完即止 |
| 解析成功但字段缺失或类型不符 | 契约 | 加 schema 校验 | 规则要随接口演进 |
| 解析成功、内容正确但难评审 | 表示 | 统一缩进与键序 | 会传播到每个新文件 |
| 同一键出现两次 | 契约与语义 | 确认覆盖策略 | 前值无声消失 |
| 要跨格式或只取子树 | 转换 | 先列丢弃清单 | 不可逆 |
四类问题的判断入口:先看解析结果,再决定改哪一层
校验:为什么报错位置会误导你
语法报错里那个数字不是「第几个字符」,而是 UTF-16 码元的偏移。输入 {"a":"😀",x} 时 V8 报 at position 10,而这个 emoji 占 2 个码元、4 个 UTF-8 字节;如果你按字节偏移去原文标红,标注点会偏到错误位置左侧。所以 position 只对「同一份内存中的字符串」有意义,对任何按字节重新编码过的副本都不成立。
更要紧的是归因。RFC 8259 只规定语法,不管语义:它允许 1e400 这样的数字字面量,而 JSON.parse("1e400") 返回 Infinity,JSON.stringify(Infinity) 又输出 null。它说对象内的名字 SHOULD 唯一,却没规定重复时取哪个,实测 {"a":1,"a":2} 解析为 {"a":2},前一个值无声消失。整数一旦超过 2^53 就在解析那一刻被改写:9007199254740993 变成 9007199254740992。
另一层误导来自指向:position 落在「解析器第一次无法继续」处,不是错误起点。少一个右括号时报错常落在文件末尾或下一个逗号,你会去改一处本来正确的代码。判据是打一窗上下文而不是盯一个点:打印 position 前后各 20 个字符。也要记住哪些东西根本不在语法里——BOM(U+FEFF)、尾逗号、单引号字符串、注释、NaN 与 Infinity 字面量,解析器拒绝它们不是 bug。
还有一种情况让位置彻底失效:文件在到达你的代码之前被按 UTF-8 解码过一次。非法字节会被 TextDecoder 静默替换为 U+FFFD(除非传 fatal: true),原始字节与偏移都已不存在,此时该回到字节层重新取原文。
| 写法 | JSON.parse 的结果 | 风险 |
|---|---|---|
| 1e400 | Infinity | 再序列化变成 null |
| 9007199254740993 | 9007199254740992 | 大整数 ID 撞库 |
| {"a":1,"a":2} | {"a":2} | 前值无声丢失 |
| {a:1} 或 {"a":1,} | 抛 SyntaxError | position 指不到根因 |
| "\uD800" 孤立代理 | 得到孤立代理字符 | 写成 UTF-8 字节后是 3 字节替换字符 |
语法合法的 JSON 里,仍然能让你丢数据的五种写法
格式化:缩进、键序与数字表示到底该定什么
缩进没有标准答案,但代价不对称。RFC 8259 把空白限定为空格、制表、换行、回车四种字符,任意缩进都是等价的 JSON,所以这是仓库存量问题。目录里已有 4 空格的文件时,新文件用 2 空格会让评审时整文件标红,真实改动被埋在噪音里。判据:跟随该目录最近一次提交的缩进;没有先例时用 2 空格并写进编辑器配置,因为 JSON.stringify(value, null, 2) 是最省事的默认值。
键序更不能指望格式化顺手解决。JSON.stringify 按插入顺序输出,但形如数组下标的键会被提前并按数值升序排列:依次插入 "z"、"a"、"1"、"0",输出顺序是 0、1、z、a。要稳定输出就必须显式排序,而用 replacer 数组 JSON.stringify(obj, keys) 只保留列表中的键,且这一过滤对所有嵌套层生效,没列进去的嵌套键会一起消失,要保嵌套就得自己递归重建对象再序列化。
数字表示是格式化里唯一会真正改数据的地方。0.1 加 0.2 序列化为 0.30000000000000004,1e21 写成 1e+21,1e-7 写成 1e-7,-0 写成 0。再叠加值的丢弃规则:值为 undefined、函数、Symbol 的属性整键消失,同名位置在数组里变成 null,NaN 与 Infinity 一律变 null,BigInt 直接抛 TypeError。
所以要把「格式化」重新定义清楚:只改空白,任何键序或数字的变化都按数据变更来审。这是本节唯一的不可逆点——格式化工具顺手做了排序和去重,评审又因为「只是 format 了一下」而放行,改动就混进了主干。
| 值 | 作为对象属性 | 作为数组元素 |
|---|---|---|
| undefined | 键被删除 | null |
| 函数、Symbol | 键被删除 | null |
| NaN、Infinity | null | null |
| -0 | 0 | 0 |
| Date | toJSON 后的字符串 | toJSON 后的字符串 |
| BigInt | 抛 TypeError | 抛 TypeError |
JSON.stringify 的转换规则:格式化前先确认这些位置没有值
结构查询:JSON Pointer、JSONPath 与 jq 的分工
三个名字常被混用,它们的目标不同。RFC 6901 的 JSON Pointer 指向单个位置,写法是以斜杠分段的路径,转义只有两条规则:编码时先把 ~ 写成 ~0、再把 / 写成 ~1,解码时先还原 ~1 再还原 ~0,顺序反了就会把 a~01 解成 a/ 而不是 a~1。它要么命中一个节点,要么失败,没有中间态。
RFC 9535 定义的 JSONPath 返回节点列表,支持通配、过滤表达式与递归下降,天然是多结果语义。jq 谁的标准都不是,它是带管道与算术的表达式语言,能构造出输入里根本不存在的新对象。判据很清楚:路径要写进配置、URL fragment 或跨系统引用时用 Pointer,因为它语义窄到不会产生歧义;要一次取出所有匹配项做展示时用 JSONPath;要在命令行里把多个文件合并成一份报表时用 jq。
不可逆点在「把查询语法当数据存下来」。JSONPath 的 RFC 9535 到 2024 年才发布,此前 Goessner 提案、Jayway、Json.NET 各家在过滤器与递归语法上并不一致,历史数据里的路径换一个库就可能解析失败;Pointer 自 2013 年起没变过,代价是它表达不了「任意层级下的所有 id」这类需求,只能退化成逐个枚举。
性能代价也要算进选型:$.. 这类递归下降遍历整棵树,几十万节点的文档上就是全表扫描;Pointer 只是一串字典查找,代价与深度成正比。失败行为也不同:Pointer 找不到就是失败,JSONPath 返回空列表,与路径写错无法区分,jq 则要用 // 或 try 兜底。
| 方案 | 返回结果 | 标准依据 | 典型代价 |
|---|---|---|---|
| JSON Pointer | 单个节点,或失败 | RFC 6901(2013) | 不能通配与过滤 |
| JSONPath | 节点列表 | RFC 9535(2024) | 历史实现存在方言差异 |
| jq 表达式 | 任意重构后的结果 | 无标准,独立实现 | 外部依赖与学习成本 |
按「返回一个还是多个」「要不要写进配置」来选
大文件:数组与 JSON Lines 的取舍
数组和 JSON Lines 的差别不是风格,是内存模型。JSON.parse 必须先拿到完整字符串,再一次性构造对象图:一个 200 MB 的 UTF-8 文件读成 JS 字符串时,含中文的部分按 UTF-16 存储(每字符 2 字节)就已翻倍,加上对象图开销,峰值常是原文的 3 到 5 倍。JSON Lines(一行一个独立的 JSON 值,常用扩展名 .ndjson 或 .jsonl)允许逐行 parse,峰值只与最大的那一行有关。
代价要说清。NDJSON 没有顶层结构,放不下「版本号加记录数组」这种信封;不支持缩进,一行内不能有裸换行,换行必须写成反斜杠加 n;一行损坏只丢一行,但这也意味着错误被默认放过,坏数据会静默积累。它的真实优势是另外两条:统计条数用 wc -l,追加记录直接 append,不必重写外层括号与逗号。
判据:当只需要逐条处理(导入、过滤、按行切分后并行)且文件明显大于可用内存的十分之一时选 NDJSON;当需要跨记录聚合、需要顶层元数据、或消费者只接受数组时保留数组,改用流式解析器逐个 token 产出,不构造完整对象图。
不可逆点落在文件名和消费者上。把 .json 数组改成 .ndjson 之后,所有按 JSON 打开的脚本、所有按 application/json 返回的接口都会失败;更隐蔽的是只改内容不改后缀,编辑器与 CDN 仍按 JSON 处理,于是症状看起来像「文件损坏」而不是「格式变了」。切换必须在同一次提交里改完所有读写方。
| 维度 | 单个 JSON 数组 | JSON Lines |
|---|---|---|
| 读取方式 | 一次性 JSON.parse | 逐行独立 parse |
| 内存峰值 | 与文件同阶,常 3 到 5 倍 | 与最大单行同阶 |
| 单条损坏 | 整体失败 | 只丢该行 |
| 追加写入 | 要重写外层括号与逗号 | 直接 append |
| 顶层元数据 | 可以放 | 无处安放 |
| shell 工具 | 需要 jq 之类解析器 | wc -l、head、split 可用 |
数组与 NDJSON 的六项差异,前两项决定内存是否够用
表示转换:JSON 与 XML、YAML、CSV 之间丢的是什么
转换的本质是两套模型不重合,不是工具实现差。XML 的元素与属性二分、命名空间、混合内容,在 JSON 里没有对应物;反过来 JSON 的数组、null、数字类型在 XML 里也没有,因为 XML 的一切内容都是文本。所以 JSON 转成 XML 再转回来,数字会变成字符串,null 会消失,往返不闭合是模型属性,换个转换器也一样。
YAML 的问题在另一头,方向也不对称。YAML 1.1 有锚点与别名、自定义 tag、多文档、timestamp 类型;JSON 转 YAML 基本无损(YAML 1.2 是 JSON 的超集),反过来要看是否用到这些扩展——展开别名会把一处引用变成随引用次数增长的重复文本。CSV 最彻底:RFC 4180 只定义一张二维表,单元格全是字符串,类型推断是转换器附加的猜测,嵌套对象只能折成 user.name 这样的列名,或把整段 JSON 塞进一个单元格。
判据是先写丢弃清单再选方向。要人改配置就走 YAML;对接既有 XML 系统时接受文本化数字并显式标注类型,别指望属性与元素的区别能活下来;交给表格软件做透视就走 CSV,并在下游重新校验类型,而不是相信转换器的推断。
编码层的坑独立于格式,却常被算到格式头上。转换链路里只要有一次把字节按 UTF-8 解码,非法序列就会被 TextDecoder 换成 U+FFFD 而不是抛错(除非传 fatal: true)。替换发生在解码阶段,早于任何 JSON 逻辑,看到的替换字符已还原不成原字节。
| 方向 | 一定会丢 | 可靠保留的部分 |
|---|---|---|
| XML 到 JSON | 命名空间、混合内容、注释、属性与元素的区分 | 元素名与文本 |
| JSON 到 XML | 数组语义、null、数字类型 | 键名作标签、字符串值 |
| YAML 到 JSON | 锚点复用关系、自定义 tag、多文档 | 映射、序列、标量值 |
| CSV 到 JSON | 全部类型、嵌套结构 | 行列位置与原始文本 |
四个方向的丢弃清单,先确认这些字段是否被下游依赖
一套可执行的排查顺序
把前面的判断压成一条能照着走的顺序,代价是每步都要停下来确认,收益是错误不会跨层传播。第零步先别解析:确认文件头有没有 BOM,确认值是不是被双重编码(一个 JSON 字符串里装着另一段 JSON),这两类问题会让后面每一步的输出都像随机故障。之后按成本从低到高推进,只读步骤可以重复执行,写操作留到最后。
顺序里有两个不可逆闸口,错过了只能重跑上游。第一个是解析:JSON.parse 一旦执行,超过 2^53 的整数、重复键的前值、-0 的符号就已经丢了,reviver 拿到的已经是改过的值,救不回来——要保住就必须在解析前用字符串接住原文。第二个是格式切换:数组改 NDJSON、.json 改 .jsonl 会让所有消费者同时失效,必须在同一次提交里改完所有读写方。
闸口之外都是可重试的:校验、排版、查询都不改变数据,所以顺序可以按耗时排——parse 一次是毫秒级,schema 校验也是毫秒级,观察体积再决定是否换流式是分钟级,跨格式转换放最后,因为它不可逆。
顺序里每一步都可以跳过,代价不同:跳过 BOM 检查,你会在解析层白耗半小时;跳过契约校验,问题会以业务 bug 的形式在三天后的报表里出现;跳过丢弃清单,丢失要到下游对接那天才被发现,那时已经回不到原文。颠倒顺序的代价,就是把可重试的检查换成了不可重试的返工。
- 确认 BOM、确认是否双重编码,这两项在解析前处理
- 跑一次 parse,抛错就只看 position 前后各 20 个字符
- 解析通过后立刻做契约校验:字段、类型、范围,别读解析器的错误信息
- 精度敏感字段(ID、金额、时间戳)在解析前按字符串保留
- 确认体积,超过可用内存十分之一就换 NDJSON 或流式解析
- 排版分歧按目录约定解决,只改空白;键序与数字变化按数据变更审
- 跨格式转换放到最后,动手前先写下丢弃清单
| 症状 | 最可能的原因 | 第一步 |
|---|---|---|
| 开头就抛 SyntaxError | BOM、尾逗号、单引号、注释 | 打出 position 前后的字符 |
| 字段全是 undefined | 双重编码,值本身是一段 JSON 字符串 | 先解一层字符串再 parse |
| ID 末几位对不上 | 超过 2^53 的整数在解析时被改写 | 确认原文是否在解析前保住 |
| 某字段偶发缺失 | 重复键,后值覆盖前值 | 搜索该键在文本中出现次数 |
| 读取时进程 OOM | 整文件一次性 parse | 换 NDJSON 或流式解析 |
| 数字变成字符串 | 经过 XML 或 CSV 中转 | 检查转换链的丢弃清单 |
从症状反查层次:先定位是哪一类问题,再谈修法
常见问题
- 为什么 JSON.parse 报的位置看起来不对?
- 那是 UTF-16 码元偏移,不是字节偏移,一个 emoji 占 2 个码元但 4 个 UTF-8 字节。它还指向解析器卡住的第一处,而非错误起点。
- JSON 允许重复键吗,取哪一个值?
- RFC 8259 只说名字 SHOULD 唯一,没规定重复时取哪个。实际实现(含 V8)后一个覆盖前一个,前值无声消失,去重或报错都得自己加。
- 为什么 9007199254740993 解析后少 1?
- JSON 数字落到 IEEE 754 双精度,安全整数上限是 2^53 减 1。改写就在解析那一刻,reviver 拿到的已是错值,须在解析前按字符串保住原文。
- NDJSON 和 JSON Lines 是两回事吗?
- 是同一套约定的两个名字,扩展名写 .ndjson 或 .jsonl。每行是一个完整 JSON 值,行内不能有裸换行,每行独立 parse,彼此互不影响。
- 格式化后 diff 变大,数据被改了吗?
- 不一定。先确认差异是否只涉及空白与键序;若数字或 null 也变了,那才是 stringify 的转换规则(NaN、undefined、-0)造成的真实变化。