如何修复无法解析的 JSON(并整理剩下的烂摊子)
报错往往说明输入从一开始就不是严格 JSON。本文教你读懂报错、认出五种最常见的破坏方式,再把结果整理成可读的样子。
同事在群里丢来一行:“这玩意儿为啥崩?”那是他从某个 Python 服务的日志里拷出来的一段,想变成干净的 JSON 好读一读。你把它粘进格式化器,一点格式化,直接飘红。无效 JSON。
真正让人踩坑的地方在这儿:多数时候不是工具错了,而是输入本身有问题。不是坏到无可救药,而是它从一开始就不是严格的 JSON。它可能是一个被打印出来的 Python 字典,或者某人随手 log 出来的 JavaScript 对象,又或者是一个把另一份载荷当成字符串塞在里面的数据。这些东西乍一看都像 JSON,但没有一个能解析。这篇文章按你实际会遇到的顺序,把这摊乱麻一点点理清:读懂报错、搞明白它为什么不是 JSON、处理最阴的那种嵌套情况,然后——等它终于能解析了——把它变得能读。
我们要修的这坨
这就是同事粘过来的东西,和真实场景相去不远:
{
users: [
{'id': 1, 'name': "Ada", 'active': True, 'meta': '{"role":"admin","seen":null}'},
{'id': 2, 'name': "Grace", 'active': False, 'meta': None},
]
}
眯着眼看,它显然是数据。但它在第一个 token 上就挂了,而且短短五行里至少埋了四个各自独立的失败原因。我们会一个一个清掉它们,并把它当作贯穿全文的例子。
第一步,先读报错——它比你以为的有用得多
动手改之前,先看解析器到底说了什么。JSON.parse 不会含糊其辞地失败;它在某个具体字符上失败,而报错会告诉你是哪一个。麻烦在于,你在哪儿运行它,报错长得就不一样,很多人因此犯迷糊。
拿一个更小的坏例子——一个尾逗号——好数清楚位置:
{"name": "Ada",}
较老的 V8 引擎(Node 21 之前、旧版 Chrome)会报:
SyntaxError: Unexpected token } in JSON at position 15
position 15 是从字符串开头算起的字符偏移量,从零开始数,空格和引号都算在内。数到 15,正好落在那个 } 上——解析器在逗号之后期待再来一个键,结果等来了收尾的大括号。这个数字就是关键:它是一个位置,而不是一句含糊的抱怨,通常这就够你找到那个多余的字符了。
较新的 V8(Node 21+、较新的 Chrome)重写了这条消息,而且——挺烦人地——把这个纯偏移量去掉了:
SyntaxError: Unexpected token '}', "{"name": "Ada",}" is not valid JSON
现在你拿到的是一段周边文本的片段,而不是一个数字。而 Firefox 一直以来在三者里最贴心:
SyntaxError: JSON.parse: expected property name or '}' at line 1 column 16 of the JSON data
行号加列号,还告诉你它期待的是什么。三种引擎、三套说法,底层事实是同一个:某个确切位置上有个东西不该出现在那里。当一个格式化器帮你标出报错位置、让你一键跳到那一行那一列时,它替你省掉的就是这一步——你不用再手动数字符,直接去那个点上看。把我们那大段 blob 粘进 JSON 格式化工具,它会停在第一个出错处并指出来;点一下报错,光标就落到那儿。
所以我们这段 blob 里第一个出错的,是最上面 users 里那个 u。这就引出了为什么。
它为什么不是 JSON:五个看着没错、其实不对的地方
严格 JSON 是一种故意做得又窄又无趣的格式——正是这一点让它能跨系统通用。大多数“无效 JSON”其实是有效的别的东西,只是借用了那对花括号。下面这五个最常见,而我们这段 blob 占了其中四个。
键必须用双引号。 {users: [...]} 是一个完全合法的 JavaScript 对象字面量,但它不是 JSON。JSON 要求写成 "users"。这是拷贝来的 JS 对象、或者一次 console.log 的输出无法解析的头号原因——语言允许你省掉引号,交换格式不允许。
字符串必须用双引号,不能用单引号。 'name' 和 'admin' 都出局。JSON 只认 "。Python 的 print()、Ruby 的 inspect 输出、还有一大堆日志格式化器都会吐出单引号,所以从那些地方拷来的东西一到这儿就绊倒。
Python 的布尔值和空值不是 JSON 的。 我们这段里有 True、False 和 None——那些是 Python 字面量。JSON 把它们写成小写的 true、false 和 null。同样的坑还有 NaN 和 Infinity,有些库会吐出它们,而严格 JSON 干脆禁止;JSON 里没有任何合法方式写“不是一个数”,在你指望它能来回转换之前记住这一点。
不允许尾逗号。 最后一个用户后面那个逗号——None}, 接着 ]——在 JSON 里是非法的,尽管几乎每种编程语言现在都允许它。这一条坑得最狠,因为编辑器和 linter 早把我们训练成到处留尾逗号了;JSON 是唯一还在拒绝它的地方。
不允许注释。 我们这段里没有,但属于同一类:// 和 /* */ 都不是 JSON。如果你在编辑一个带注释的 tsconfig.json 或 VS Code 设置文件,那是 JSONC,一个超集——严格解析器会被那些注释卡住。
这些你都能手动改,五行的量还行。再大一点就又烦又容易出错,所以好的格式化器会有一个修复步骤:它先按严格 JSON 试,再逐步放宽——单引号、Python 字面量、尾逗号——并告诉你它不得不做了哪些假设。修复是个便利功能,不是保证;它是用来快速读一行日志的,不是用来给不可信输入盖章放行的。但对我们这段 blob,它会做那件显而易见的事,还回来一份能解析的 JSON。
最阴的那种:JSON 里套着 JSON
把上面四个问题修好,这段就能解析了——但看看 Ada 的 meta 字段:
"meta": "{\"role\":\"admin\",\"seen\":null}"
那个值不是一个对象。它是一个恰好装着 JSON 的字符串,被转义后塞进外层文档里。一旦你开始留意,会发现它无处不在:一个类型是文本、里面存着序列化 JSON 的数据库列,一个把真正的载荷作为字符串嵌套进去的 webhook,一个解出来又是 JSON、还得再解一次的 JWT,一个在写入前把对象字符串化了的日志字段。普通格式化器只会把那个值显示成一长串转义字符串,剩下的丢给你自己收拾。
这里有两招。如果你粘进去的整个东西就是一个 JSON 字符串——整段被引号包着、到处是 \",这正是你拷贝一个序列化值时得到的——那就把它反转义一次,变回一份结构化文档。如果是一份文档、只是某些字段里装着字符串化的 JSON,比如我们的 meta,那你要做的是就地展开嵌套字符串,让 "{\"role\":\"admin\"}" 变成一个能读、能折叠的真正嵌套对象。JSON 格式化工具两样都有,而其中展开嵌套字符串是大家最庆幸能找到的那个功能——它省掉了处理双重编码数据时那套“把这个值拷到另一个标签页、粘上、反转义、再拷回来”的来回折腾。
展开之后,Ada 这条记录终于读起来像数据了:
{
"id": 1,
"name": "Ada",
"active": true,
"meta": { "role": "admin", "seen": null }
}

现在它能解析了:压缩、美化,还是排序键
有效的 JSON 是起跑线,不是终点。接下来做什么,取决于你当初为什么需要它可读。
**美化(pretty-print)**是默认选择——缩进、各归各行、方便扫读。两格缩进是最常见的通用约定,也是多数格式化器产出的样子。当你在读一份响应或调试一份载荷时,你要的就是这个。
**压缩(minify)**正相反:把每一个空格和换行都剥掉,压成一行。当 JSON 是要塞进别的东西里时——一个请求体、一个环境变量、一行日志、另一份 JSON 文档里的一个字段——你要的是这种形态。没人会去读压缩后的 JSON;它是给机器传输用的,不是给你读的。
**排序键(sort keys)**是最被低估的一个。JSON 对象没有规定的键顺序,所以两个服务可以把同一条记录以不同的字段次序吐出来——而一旦你想比较两份载荷,那种不稳定的顺序就会把真正的改动淹没在噪声里。在保存一份测试夹具(fixture)、或做 diff 之前,递归地排序键能让输出稳定下来,于是下一次比较只显示真正变了的东西。(有个提醒:排序会改写次序,所以如果原本的顺序有意义——一份字段被刻意分组的手调配置——先把原件拷一份。)这正好接上比较两个 JSON 文件里讲的噪声问题:先把两边规范化,diff 才会讲真话。
在哪儿格式化:终端、编辑器、代码,还是浏览器
没有唯一正确的地方,假装有才是不诚实。手边有什么就用什么。
终端。 文件在磁盘上的话,jq 很难被超越:jq . file.json 美化它,jq -S . file.json 美化并排序键,jq -c . file.json 压缩它。没装 jq?python -m json.tool file.json 几乎每台机器上都有,还会报出第一处语法错误的行号和列号,急用时是个不错的校验器。但要注意:jq 和 python 都拒绝任何还不是严格 JSON 的东西,所以对我们一开始那堆单引号加 True 的乱码它们帮不上忙——只会报错,不会修复。
编辑器。 VS Code 之类会用内置格式化器格式化一个 JSON 文件(Shift+Alt+F,或保存时格式化),并在你打字时给语法错误画下划线。文件已经打开、而且已经接近合法时,它很好用;但对一段你从别处拷来、又不想先存成文件的零散 blob,就没那么顺手。
代码。 在程序里,无非就是 JavaScript 的 JSON.stringify(value, null, 2),或 Python 的 json.dumps(obj, indent=2, sort_keys=True)。当格式化需要作为脚本或构建步骤的一部分、而不是一次性看一眼时,这才是对的工具。
浏览器。 对于粘进去、看干净、再拿出来这件具体的事——而这恰恰是“格式化 JSON”日常里的大部分含义——浏览器工具在摩擦成本上赢了。不用安装,粘进去就格式化,能修复终端工具拒绝的那些“近乎 JSON”的情况,还能就地处理嵌套字符串那摊乱。它不替代管道里的 jq,它替代的是你只想读点东西时那十几秒的繁文缛节。
大 JSON 为什么会把标签页卡死——以及它本可以不卡
每个跟 JSON 打交道的人都这样干掉过一个浏览器标签页:把几兆的日志导出粘进某个在线格式化器,页面一卡、风扇一转,你只好关掉它退回 jq。这事儿发生得够频繁,以至于不少人已经悄悄把浏览器格式化器从“处理大文件”这件事里划掉了。
通常的祸根是朴素粗暴的渲染方式。很多网页格式化器把整份格式化好的文档一股脑全塞进页面的 DOM——每一行、每个括号、每一段语法高亮的片段——然后在每次按键时把其中一大部分重做一遍。几百行时你不会察觉。到几十万行时,浏览器要布局和绘制的 DOM 节点比任何页面该有的都多,于是它就卡住了。
解法是别再去渲染没人在看的东西。JSON 格式化工具构建在一个编辑器(CodeMirror)之上,它只渲染当前视口里的那些行——一滚动,它就把你刚露出来的行构建出来,把你离开的行丢掉。文档可以巨大,而 DOM 始终很小,因为 DOM 里永远只装着你真正看得到的那一屏。语法高亮也是增量的:一次编辑只重新分析变动的那部分,而不是重解析整个文件,而更重的结构性工作(比如一次性折叠每一个分支)会在一个时间预算内运行、到点就体面地退让,而不是把标签页冻住。再加上折叠摘要——折叠的数组显示 5 elements、折叠的对象显示 10 keys——你就能靠把一切折起来、只打开你关心的那一支来浏览一份巨大的载荷,全程都不必去布局其余部分。
这些都没让 JSON.parse 本身变免费;解析一个真正巨大的文件,前期依然有成本。但“流畅”和“冻住”之间的差别,几乎从来不在解析上。它在于解析之后 DOM 那边发生了什么,而只渲染视口正是让它保持流畅的原因。
把它粘进在线格式化器安全吗
值得停下来想想,因为诚实的答案是“看你粘的是什么”。你想格式化的 JSON,很多恰恰是敏感的那种——一份生产环境的 API 响应、一个解码后的令牌、一份内部配置、一个装着客户数据的 webhook body。如果一个格式化器把那段文本发到服务器去处理,你就等于把一份副本交给了第三方,而“只发了一秒钟”并不能把它收回来。
绕开这件事的办法,不是去信一份隐私政策;而是用一个根本不需要联网的工具。当解析、修复、格式化、折叠和读文件全都用 JavaScript 在浏览器里跑时,你粘进去的文本就留在你自己的机器上——什么都没上传,也就没什么可泄露的。这就是你要找的那个特性,也是为什么对任何你不愿公开发出去的东西,本地优先的格式化器都是那个安全的默认选择。
两个连合法 JSON 也躲不过的坑
有两件事值得知道,因为它们能通过每一个校验器,却照样咬人。
大整数会丢精度。 JavaScript 的数字是 64 位浮点数,而 JSON.parse 会把数字读进它们里。一个 64 位的数据库 ID、或者一个 Twitter 那种雪花 ID,可能超出浮点数能精确表示的范围,于是它悄悄地四舍五入:
JSON.parse('{"id": 9007199254740993}').id
// → 9007199254740992 ← 已经不是你原来那个数了
JSON 是合法的。那个 ID 现在悄无声息地差了 1,也没有任何报错触发。如果你要让大整数 ID 经过任何基于 JavaScript 的东西,把它们当字符串存。
重复键。 {"a": 1, "a": 2} 在技术上是合法 JSON,而规范并没有规定解析器该拿它怎么办。实践中多数解析器保留最后一个,于是这个读回来是 {"a": 2},第一个值一声不吭地消失了。这很少见,但当一份生成的文档里有个字段被重复了,这就是为什么其中一个看起来不见了。
把它拼回去
我们那段群里的 blob,起初是一份带单引号、True/None、一个尾逗号、还有一份藏在字符串里的载荷的 Python 字典打印结果。走出去的路每次都是同一个顺序:读报错找到它在哪儿断掉,认出它为什么不是严格 JSON,展开那个藏着真实数据的嵌套字符串,然后才根据你接下来要拿它做什么去格式化、排序或压缩。
格式化通常是第一步,不是全部工作。等它干净了,比较两个版本是结构化 JSON Diff 的活儿;把一份示例响应变成带类型的接口,是 JSON 转 TypeScript 干的事;不用滚动就从一份很深的载荷里揪出某个字段,是 JSONPath 求值器能做的。活儿不同,但起点都一样——一份真正能解析、你终于读得懂的 JSON。