position 是解析器放弃的位置,不一定是出错的位置
这是读 JSON 报错时唯一需要先建立的概念,其余都是它的推论。看一段只漏了一个逗号的 JSON:
const text = '{\n "a": 1\n "b": 2\n}';
JSON.parse(text);
// SyntaxError: Expected ',' or '}' after property value in JSON at position 13 (line 3 column 3)
报错说位置 13、第 3 行第 3 列。但那里是什么?
text[13]; // '"' —— 第 3 行 "b" 的左引号
text.slice(8, 16); // ' 1\n "b"'
报错指着的是 "b" 的左引号,而你要补的那个逗号在第 2 行行尾,索引 10。 解析器的工作方式决定了这个偏差:它读完 "a": 1 之后处于「一对键值读完了」的状态,接下来合法的只有 , 或 };它跳过空白继续读,读到一个 ",这才第一次确定「不管前面多正常,到这儿走不下去了」。所以 position 是发现无法继续的位置,而真正的问题在它之前——有多远取决于错误的种类。
反过来,有些错误的 position 就精确指在元凶身上。字符串字面量里混进一个裸换行就是这样:
JSON.parse('{"a": "line1\nline2"}');
// SyntaxError: Bad control character in string literal in JSON at position 12 (line 1 column 13)
索引 12 正是那个 \n 本身。JSON 规范不允许原始的控制字符(U+0000 到 U+001F)直接出现在字符串里,换行必须写成 \n 两个字符——所以解析器读到它的当下就能确定错了,不需要再往后看。「向前找」还是「就地看」,取决于报错文字说的是「期待某个东西却没等到」还是「读到了一个不该出现的东西」,这比位置数字本身更值得先读一眼。
同一段 JSON,四个 Node 版本三种说法
报错文字不是规范的一部分,它随引擎和版本变。下面这张表是同一段漏逗号的 JSON(就是上一节那段),在四个 Node 版本上实际跑出来的结果:
| 运行环境 | 报错原文 |
|---|---|
| Node v18.12.1(V8 10.2) | Unexpected string in JSON at position 13 |
| Node v20.0.0(V8 11.3) | Expected ',' or '}' after property value in JSON at position 13 |
| Node v22.17.1(V8 12.4) | Expected ',' or '}' after property value in JSON at position 13 (line 3 column 3) |
| Node v24.19.0(V8 13.6) | Expected ',' or '}' after property value in JSON at position 13 (line 3 column 3) |
四个版本三种措辞:V8 10.x 只说「碰到一个 string」,11.x 开始说清了「期待逗号或右花括号」,12.x 又补上了行列号,13.x 和 12.x 一字不差。position 13 是唯一一个四行都一样的东西——它是解析器的真实状态,措辞只是外包装。所以如果你在网上搜到的排查文章引用的报错和你手上的对不上,先看版本,不要怀疑自己的 JSON。
换一个引擎,差别更大。macOS 上不装任何东西就能跑到 JavaScriptCore(Safari 用的那个引擎),osascript -l JavaScript 走的正是它:
osascript -l JavaScript -e 'try{JSON.parse("{\"a\": 1 \"b\": 2}")}catch(e){console.log(e.message)}'
# JSON Parse error: Expected '}'
同一段 JSON,JavaScriptCore 说的是 JSON Parse error: Expected '}'——没有位置,没有行列,什么都没有。 我把整批测试用例都跑了一遍,结论是一致的:JSC 的 JSON Parse error: 系列全都不带位置信息。几条对照:
| 输入 | V8(Node v22.17.1) | JavaScriptCore(macOS 26.5.2) |
|---|---|---|
{"a":1,} |
Expected double-quoted property name in JSON at position 7 (line 1 column 8) |
JSON Parse error: Property name must be a string literal |
{'a':1} |
Expected property name or '}' in JSON at position 1 (line 1 column 2) |
JSON Parse error: Single quotes (') are not allowed in JSON |
{"a": "x\ny"} |
Bad control character in string literal in JSON at position 8 (line 1 column 9) |
JSON Parse error: Unterminated string |
''(空串) |
Unexpected end of JSON input |
JSON Parse error: Unexpected EOF |
有意思的是第三行:同一个裸换行,V8 说「字符串字面量里有非法控制字符」,JSC 说「字符串没闭合」——从 JSC 的角度这个描述也成立,因为它认为字符串在换行处就该结束了。两边指的是同一个字符,给出的病因却不同。用户报障时说「JSON 解析失败」却给不出位置,多半就是 Safari。 让他换 Chrome 复现一次,或者直接把那段文本要过来自己跑,比继续追问有效。
V8 自己也有两套报错,其中一套不给位置
即使只用 Node,也别默认报错里一定有 position。V8 的 JSON 报错分成两个家族,格式完全不同:
JSON.parse('{"a": 1 "b": 2}');
// Expected ',' or '}' after property value in JSON at position 8 (line 1 column 9)
JSON.parse('{"a": @}');
// Unexpected token '@', "{"a": @}" is not valid JSON
第二种就是那句人人都见过的 Unexpected token。它不给位置数字,改为给一段原文快照。文档一长,快照两端会用 ... 截断:
JSON.parse('{"pad": "' + 'x'.repeat(300) + '", "a": @}');
// Unexpected token '@', ..."xx", "a": @}" is not valid JSON
这个设计对短文档很友好——一眼就看见了。对长文档就很尴尬:... 里那十几个字符可能在文件里出现几百次,你拿它去搜索根本定位不了。判断方法很简单:报错里出现 Unexpected token 'X' 并且跟着一段带引号的原文,就是这个家族,不要去正则里抠 position,抠不到。
把报错换成「第几行第几列 + 附近的码位」
上面这些差异加起来,指向同一个做法:不要用眼睛读报错,写六行代码把它翻译成你能直接动手的东西。
function explain(text) {
try { JSON.parse(text); return '解析成功'; } catch (e) {
const m = /at position (\d+)/.exec(e.message);
const t = /Unexpected token '(.)'/.exec(e.message);
const pos = m ? Number(m[1]) : (t ? text.indexOf(t[1]) : -1);
if (pos < 0) return e.message + '(无法定位)';
const before = text.slice(0, pos);
const line = before.split('\n').length;
const col = pos - before.lastIndexOf('\n');
const codes = [...text.slice(Math.max(0, pos - 4), pos + 4)]
.map(c => 'U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')).join(' ');
return `${e.message}\n -> 第 ${line} 行第 ${col} 列,索引 ${pos}\n -> 附近码位: ${codes}`;
}
}
两个家族它都吃:有 position 就用 position,没有就把 Unexpected token 'X' 里那个字符拿去 indexOf。实测三条输入:
输入 {"a": 1 "b": 2}
Expected ',' or '}' after property value in JSON at position 8 (line 1 column 9)
-> 第 1 行第 9 列,索引 8
-> 附近码位: U+003A U+0020 U+0031 U+0020 U+0022 U+0062 U+0022 U+003A
输入 {"a": “1”}
Unexpected token '“', "{"a": “1”}" is not valid JSON
-> 第 1 行第 7 列,索引 6
-> 附近码位: U+0061 U+0022 U+003A U+0020 U+201C U+0031 U+201D U+007D
输入 {"a":1}\n{"b":2}
Unexpected non-whitespace character after JSON at position 8 (line 2 column 1)
-> 第 2 行第 1 列,索引 8
-> 附近码位: U+003A U+0031 U+007D U+000A U+007B U+0022 U+0062 U+0022
手边没有 Node 的时候,本站的 JSON 格式化工具展示的就是浏览器引擎原样抛出的那条报错,没有做任何加工——所以它也完全受本文这些差异的支配:你用 Chrome 打开它,能看到 position 和行列号;用 Safari 打开同一个页面、粘同一段 JSON,就只剩一句 JSON Parse error。这不是工具的取舍,是引擎的区别。
真正解决问题的是最后那行码位。 第二条输入里的 U+201C 和正常的 U+0022,在报错原文里被并排打了出来("{"a": “1”}"),可它们长得太像——尤其当周围本来就是中文标点的时候,眼睛根本不会在那儿停一下。把可疑区间拆成十六进制码位,看不见的东西就没地方躲了。下一节讲的全是这类东西。
四个在编辑器里看不见的元凶
BOM(U+FEFF)。 文件开头的三个字节 ef bb bf。它最阴险的地方是:fs.readFileSync(f, 'utf8') 不会替你去掉它,于是解析必然失败,而你打开文件看到的是完好无损的 {:
// 文件内容是 BOM + '{"a":1}\n'
fs.readFileSync('bom.json')[0].toString(16); // 'ef' —— 头三个字节是 ef bb bf
fs.readFileSync('bom.json', 'utf8').codePointAt(0); // 65279,即 U+FEFF,它还在
JSON.parse(fs.readFileSync('bom.json', 'utf8'));
// SyntaxError: Unexpected token '', "{"a":1}
// " is not valid JSON
报错里那对单引号中间不是空的,里面就是 U+FEFF——它不占宽度,所以在终端里看上去像一对空引号;文件末尾那个换行也被原样抄进了快照,于是一条报错被撑成了两行。这正是这个报错家族最难读的地方:它把肇事字符原样打出来,而肇事字符恰恰是看不见的那一个。
修法是用 TextDecoder 解码字节而不是让 readFileSync 转字符串。这里有一个名字起反了的参数值得当心:
const raw = fs.readFileSync('bom.json');
new TextDecoder('utf-8').decode(raw).codePointAt(0); // 123,即 '{',BOM 被吃掉了
new TextDecoder('utf-8', { ignoreBOM: true }).decode(raw).codePointAt(0); // 65279,BOM 还在
ignoreBOM: true 的意思不是「忽略 BOM(把它扔掉)」,而是「不把 BOM 当 BOM 看,把它当成普通字符留着」。默认值 false 才是你要的那个行为。这个参数我每次都要重新确认一遍,写在这里也是给自己留个记号。
中文输入法的智能引号(U+201C / U+201D,“ ”)。 从聊天记录、Word 文档、微信里复制来的配置片段最容易带上它。放在键名上和放在值上,V8 的报错还不是同一个家族:
JSON.parse('{“a”: 1}'); // Expected property name or '}' in JSON at position 1 (line 1 column 2)
JSON.parse('{"a": “1”}'); // Unexpected token '“', "{"a": “1”}" is not valid JSON
第一条只说「这里该是属性名」,压根没提引号有什么不对——因为在解析器眼里 “ 就是个不认识的字符,它不知道你以为那是引号。单引号至少还有专门措辞(JSC 会直说 Single quotes (') are not allowed in JSON),智能引号连这个待遇都没有。
不间断空格(U+00A0)。 从网页复制粘贴时最常带入的一个。它有一个必须说清楚的分界:
JSON.parse('{"a": 1}'); // SyntaxError: Unexpected token ' ', "{"a": 1}" is not valid JSON
JSON.parse('{"a": "x y"}'); // 正常解析,得到 { a: 'x y' }
在结构位置上它是致命的,在字符串字面量内部它完全合法。 JSON 规范承认的空白只有空格、制表符、换行、回车四种,U+00A0 不在其列,所以它出现在键值之间就是一个非法 token;但字符串内部除了控制字符之外什么都能放,它在那儿只是一个普通字符。后一种情况不报错,反而更麻烦——数据会带着一个看不出来的字符一路流进数据库,然后在某个 === 比较上悄悄失败。
字符串里的裸控制字符。 第一节已经给过换行的例子,制表符是同一类:
JSON.parse('{"a": "x\ty"}'); // Bad control character in string literal in JSON at position 8 (line 1 column 9)
这种错误几乎都来自手工拼接 JSON 字符串——把一段多行日志直接塞进模板字符串里,而没有走 JSON.stringify()。这也是这四个元凶里最容易从源头根除的一个:不要手写 JSON,让 JSON.stringify() 去转义。 它会把换行变成 \n、制表符变成 \t,一次都不会漏。
顺带澄清:reviver 和错误定位没有任何关系
JSON.parse 的第二个参数经常被误解成某种「解析钩子」,好像能在出错时插一脚。它不能。实测同一段非法 JSON,加不加 reviver 报错一字不差:
JSON.parse('{"a": 1 "b": 2}');
// Expected ',' or '}' after property value in JSON at position 8 (line 1 column 9)
JSON.parse('{"a": 1 "b": 2}', (k, v) => v);
// Expected ',' or '}' after property value in JSON at position 8 (line 1 column 9)
原因是 reviver 在解析全部成功之后才开始工作:解析器先把整个文档变成一个完整的值,然后 reviver 自底向上遍历这个值。我数过调用次数——解析 {"a":1,"b":[2]} 时 reviver 被调用 4 次,key 依次是 "a"、"0"、"b"、""(最后那个空串是根值);而解析 {"a":1,"b":} 这种非法输入时,reviver 被调用 0 次。语法错误面前它根本没有出场机会。reviver 能做的是把 ISO 日期串转成 Date、把大整数转成 BigInt 这类值层面的加工,不是排错工具。
几十万行的文件:二分法,以及朴素二分为什么不行
文件大到一定程度,前面那些办法会同时失效。我造了一个 20 万个元素、6088894 字节、200003 行的 JSON 数组,在第 137001 个元素里塞一个 U+00A0。多行排版时 V8 的报错还算好用:
Expected double-quoted property name in JSON at position 4135908 (line 137002 column 17)
但把同一份数据压成一行(真实接口返回几乎都是压缩的),它就变成了这样:
Expected double-quoted property name in JSON at position 3313904 (line 1 column 3313905)
第 1 行第 3313905 列——这个坐标在任何编辑器里都不好用,何况 Safari 连这个都不给你。这时候就该二分了。但直接把文档对半截断是行不通的,我先把这条路堵上:
JSON.parse(text.slice(0, text.length >> 1));
// Unexpected end of JSON input
JSON.parse(text.slice(text.length >> 1));
// Unexpected non-whitespace character after JSON at position 27 (line 1 column 28)
两半都报错,而且报的都不是那个 U+00A0。前一半缺了收尾的 ],所以是「意外结束」。后一半更有意思:这一刀恰好切在两个元素之间,于是解析器读完开头那个完整的对象(27 个字符)就收工了,紧接着在第 28 个字符上撞见一个逗号,报「JSON 之后还有非空白字符」——而真正的元凶在三万多个元素之外。JSON 是嵌套结构,任何一刀切下去都会破坏配对,所以「哪一半还报错」这个信号恒为「两半都报错」,等于没有信号。
要让二分可用,切口必须落在结构边界上,切完还要把外层容器补回去。对一个数组,就是按元素切、再补一个 ]:
// 这份文件每个元素各占一行,所以元素之间的分隔符是 ",\n"
const items = text.slice(text.indexOf('[') + 1, text.lastIndexOf(']')).split(',\n');
const okPrefix = (k) => {
try { JSON.parse('[' + items.slice(0, k).join(',\n') + ']'); return true; }
catch { return false; }
};
let lo = 0, hi = items.length, steps = 0;
while (lo < hi) { const mid = (lo + hi) >> 1; steps++; okPrefix(mid) ? (lo = mid + 1) : (hi = mid); }
console.log(steps, '次迭代,坏元素下标', lo - 1, items[lo - 1]);
这是在找「让前缀第一次解析失败的那个 k」,而这个判定是单调的——前 k 个元素合法就意味着前 k−1 个也合法,所以二分成立。实测在那个 20 万元素的文件上跑完只要 17 次迭代、471 毫秒,直接命中下标 137000,而那一行里唯一的非 ASCII 码位就是 U+00A0。迭代次数是 log₂(元素个数) 这个量级——元素再多十倍,也只多三四次。
最后是它真正的适用范围,别把它当万能锤。当报错已经给了你 position,就用 position,二分是多余的。 二分值得动用的是这三种情形:引擎压根不给位置(Safari,以及别的语言里一些只说「invalid」的解析器);文档压成了一行,行列号退化成一个七位数的列号,翻不到那儿去;或者你手上根本没有那段文本的原件——它是几十个片段拼出来的、是从网络上流过来的、是某个生成器吐出来的。前两种情形里,二分交给你的是出问题的那个元素,比一个字符偏移量好用得多;第三种情形里,它交给你的是哪一个片段,而那往往才是你真正想知道的东西。