DEVTOOLBOX / GUIDES 本篇

JSON 决策指南:校验、格式化、结构查询与格式转换

大多数 JSON 事故不是解析器出错,而是在错误的那一层修数据。这篇按四类问题给出判据:什么时候只看 position,什么时候必须补 schema 校验,缩进与键序该由谁定,以及数组改 NDJSON、JSON 转 XML 之后到底有哪些信息再也拿不回来。

内容复核 11 分钟阅读

先分清四类 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 的结果风险
1e400Infinity再序列化变成 null
90071992547409939007199254740992大整数 ID 撞库
{"a":1,"a":2}{"a":2}前值无声丢失
{a:1} 或 {"a":1,}抛 SyntaxErrorposition 指不到根因
"\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、Infinitynullnull
-000
DatetoJSON 后的字符串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 的形式在三天后的报表里出现;跳过丢弃清单,丢失要到下游对接那天才被发现,那时已经回不到原文。颠倒顺序的代价,就是把可重试的检查换成了不可重试的返工。

症状最可能的原因第一步
开头就抛 SyntaxErrorBOM、尾逗号、单引号、注释打出 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)造成的真实变化。

动手试试