接口返回一坨没有换行的 JSON,想做一次 JSON 在线格式化看清楚结构,结果第一行就红字报 Unexpected token —— 这时候你要的不是「美化」,而是先搞清它为什么解析不了。
格式化、压缩、转义被混为一谈,是排错慢的最大原因:它们的前提条件完全不同。
一、格式化、压缩、转义是三件事
| 操作 | 输入 → 输出 | 数据本身变没变 | 典型用途 |
|---|---|---|---|
| 格式化(美化) | 合法 JSON → 带缩进换行的 JSON | 值不变,只加空白 | 人眼查看、比对 diff |
| 压缩(minify) | 合法 JSON → 去掉所有空白的单行 | 值不变,只删空白 | 传输、写进配置、贴进 curl |
| 转义 | 任意文本 → 能塞进字符串字面量的文本 | 变了,加反斜杠 | 把 JSON 当字符串塞进 shell / Java 代码 |
| 反转义 | 字符串字面量 → 原文 | 变了 | 从日志里还原请求 body |
关键区别:格式化和压缩的前提是这段文本本来就是合法 JSON,它们只重排空白;转义是纯字符替换,不校验合法性。所以「格式化失败」说明你的数据不是合法 JSON,「转义失败」多半是反斜杠已经乱了,两者排查方向不一样。
顺带说一个容易被忽略的用途:格式化真正的价值不在好看,而在统一格式后再比对。两份内容完全相同的配置,一个用 2 空格一个用 Tab,diff 会显示全部行变更;先按同一缩进格式化、再排序 key,diff 出来的才是真实差异。
二、7 个高频报错,先看表
| 报错 / 现象 | 根因 | 怎么修 |
|---|---|---|
Unexpected token o in JSON at position 1 |
拿到的是 [object Object],对象没 stringify 就拼进字符串 |
先 JSON.stringify(obj) |
Unexpected token ' in JSON at position 1 |
用了单引号,JSON 只允许双引号 | 单引号换双引号 |
Unexpected token } in JSON at position N |
尾随逗号 | 删掉最后一项后面的逗号 |
Unexpected token < in JSON at position 0 |
返回的是 HTML(404 页 / 网关错误页) | 先看原始响应体,别直接 parse |
Unexpected token in JSON at position 0 |
文件带 UTF-8 BOM | 见第五节 |
jsonp123({...}) is not valid JSON |
拿到的是 JSONP,不是 JSON | 剥掉外层函数调用 |
| 粘进去页面直接假死 | JSON 太大,浏览器主线程扛不住 | 见第七节 |
三、三个值得单独展开的
单引号不是合法 JSON。 JS 对象字面量和 Python 的 dict 都容忍单引号,JSON 标准(RFC 8259)不允许。从 Python 那边 str(dict) 出来的字符串经常三种错一起犯:
{'ok': True, 'msg': None} // ❌ 单引号 + True + None
{"ok": true, "msg": null} // ✅
让 Python 那边改成 json.dumps(d, ensure_ascii=False),别手改。
尾随逗号。 JSON 不允许对象或数组最后一项后面有逗号,JS 里却完全合法:
{"a":1,"b":2,} // ❌
{"a":1,"b":2} // ✅
注意报错里的 position 通常指向那个右括号而不是逗号本身,往左看一个字符就找到了。
JSONP 不是 JSON。 你拿到的是 jsonp123({"code":0,"data":[]});,真正要的是里面那一段。剥掉外层函数名和分号即可,标准的格式化工具识别不了它,因为它在语法上是一段 JS 调用表达式。
转义的真实场景。 转义不是给 JSON 用的,是给「把 JSON 塞进另一种语言的字符串」用的。比如要把一段 body 拼进 curl -d、写进 Java 的 String body = "..."、或者作为环境变量传进容器,中间的每一个双引号和换行都得变成 \" 和 \n,否则外层语言会提前截断字符串。反过来,从日志里复制出来的请求体往往已经被转义过一次,先反转义再看,别直接拿去 parse。
四、命令行与代码里怎么做(可直接复制)
# curl + jq:格式化接口返回
curl -s https://api.example.com/v1/users | jq '.'
# jq -c 压缩成一行
curl -s https://api.example.com/v1/users | jq -c '.'
# jq -S 按 key 排序输出,比对两份配置前必做
jq -S '.' a.json > a.sorted.json
# 只校验,不看内容(-e 失败时返回非 0)
jq -e '.' a.json > /dev/null && echo OK
# Python:json.tool 就是命令行版的格式化工具
python3 -m json.tool input.json --indent 2 --sort-keys
# 批量找出哪个文件坏了
for f in *.json; do python3 -m json.tool "$f" > /dev/null || echo "坏文件: $f"; done
// Node:JSON.stringify 的第三个参数是缩进
console.log(JSON.stringify(data, null, 2)) // 2 空格缩进
console.log(JSON.stringify(data, null, '\t')) // Tab 缩进
console.log(JSON.stringify(data)) // 不传 = 压缩成一行
// 第二个参数 replacer 配合递归,实现 key 排序
const sortKeys = (v) =>
v && typeof v === 'object' && !Array.isArray(v)
? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeys(v[k])]))
: v
console.log(JSON.stringify(sortKeys(data), null, 2))
JSON.stringify(v, null, space) 的 space 传数字就是空格数(上限 10),传字符串就用该字符串缩进(上限 10 字符)。它不会帮你排序 key,输出顺序保持对象的插入顺序 —— 这是很多人以为「格式化会顺便排序」的误区。
五、BOM:后端返回带 BOM,前端 parse 必挂
UTF-8 BOM 是文件开头的三个字节 EF BB BF,Windows 记事本、Excel 导出、部分老版本 Java / PHP 输出流会带上它。肉眼看不见,但 JSON.parse 会在第 0 位撞上它:
const text = await res.text() // "\uFEFF{\"code\":0}"
JSON.parse(text) // ❌ Unexpected token in JSON at position 0
JSON.parse(text.replace(/^\uFEFF/, '')) // ✅ 先剥掉 BOM
排查:curl -s https://api.example.com/x | xxd | head -1,看前三个字节是不是 efbbbf。根治在服务端 —— 写文件用无 BOM 的 UTF-8,响应头带 Content-Type: application/json; charset=utf-8。
有意思的是,导出 CSV 给 Excel 打开时反而要主动加 BOM(Excel 靠 BOM 认 UTF-8,否则中文全乱码)。这两件事经常出现在同一个项目里,别记反了。
六、JSON5 和 JSONL 是什么
| 名字 | 是什么 | 能被 JSON.parse 直接吃吗 |
|---|---|---|
| JSON | RFC 8259 标准格式 | ✅ |
| JSON5 | 超集:允许注释、单引号、尾随逗号、无引号 key | ❌ 需要 json5 库 |
| JSONL / NDJSON | 一行一个 JSON,多行组成一个文件 | ❌ 要按行逐个 parse |
JSON5 主要用来写人维护的配置文件;JSONL 常见于日志和大批量导出(每行独立,可流式读取,坏一行不影响其他行)。
# JSONL 逐行校验,找出坏的那一行
n=0; while IFS= read -r line; do
n=$((n+1))
printf '%s' "$line" | jq -e . >/dev/null 2>&1 || echo "第 $n 行不合法"
done < data.jsonl
转成标准 JSON 之后要看结构、要缩进、要转义,或者要转成 CSV / XML / YAML 给别的系统用,直接用工具更快:
七、浏览器 console 里几个省事用法
不想在编辑器和浏览器之间来回倒腾的话,DevTools 本身就能干不少事:
// 1. 把接口返回的对象格式化后直接复制到剪贴板
await fetch('/api/users').then((r) => r.json()).then((d) => copy(JSON.stringify(d, null, 2)))
// 2. 拿到响应体原文(能看出有没有 BOM / 是不是 HTML)
const text = await (await fetch('/api/users')).text()
console.log(text.charCodeAt(0)) // 65279 就是有 BOM
JSON.parse(text.replace(/^\uFEFF/, ''))
// 3. 试错:报错信息里的 position 直接定位
JSON.parse(badText) // 看 position N,再从 N-20 开始截一段出来看
badText.slice(N - 20, N + 20)
// 4. 只想看某个字段,不用展开整个对象树
console.table(data.list.slice(0, 20)) // 数组直接出表格
Network 面板里选中请求 → Response 右键「Copy response」拿到的就是原始文本,粘进在线工具比从 console 里复制对象靠谱(后者经常被二次转义)。
八、巨大 JSON 卡死编辑器:在线格式化工具也救不了
一个 200MB 的 JSON 粘进浏览器,JSON.parse 加语法高亮全在主线程跑,基本必卡。超过 10MB 就别粘贴了,改用命令行:
jq '.data[0:10]' big.json # 只取前 10 条出来看
jq 'paths(scalars) | join(".")' big.json | sort -u | head -50 # 列出所有字段路径
jq -e '.' big.json > /dev/null && echo OK # 只确认合不合法
九、排错清单(按顺序过一遍)
| 步骤 | 动作 | 通过标准 |
|---|---|---|
| 1 | 确认拿到的是响应体原文 | 开头是 { 或 [,不是 [object Object] |
| 2 | 去掉首尾空白和 BOM | text.charCodeAt(0) 不等于 65279 |
| 3 | 排除 JSONP / HTML 包裹 | 结尾没有 );,没有 </html> |
| 4 | 引号统一、字面量归一 | 没有 '、没有 True / None / undefined |
| 5 | 检查尾随逗号和注释 | 没有 ,} / ,] / // |
| 6 | 用 jq -e . 或在线工具校验 |
提示「格式合法」 |
| 7 | 比对两份 JSON 前先 jq -S 排序 |
diff 里不再全是顺序差异 |
| 8 | 超过 10MB 走命令行 | 页面不再假死 |
十、速查表
jq '.' f.json # 格式化
jq -c '.' f.json # 压缩成一行
jq -S '.' f.json # key 排序后输出
jq -e '.' f.json > /dev/null # 只校验
python3 -m json.tool f.json --indent 2 --sort-keys
node -e 'console.log(JSON.stringify(require("./f.json"),null,2))'
JSON.stringify(v, null, 2) 第三个参数管缩进,第二个参数 replacer 管过滤和排序 —— 记住这两句,格式化这件事就不用再翻文档了。
相关工具:JSON 格式化|JSON 转 CSV/Excel|Base64 编码解码
全部纯前端实现,输入的内容不上传服务器。