刚入行时用 JSON,基本就是 JSON.parse 一把梭。直到业务中遇到了解析报错需要定位位置时,才真的认真研究了下怎么实现,自己动手写一个 JSON 解析器。
最开始觉得——不就花括号加冒号嘛,能有多复杂,结果写了整整一周~
词法分析就是切词
解析器的第一阶段叫词法分析。lexer 做的事情就是把原始字符串拆成一串有意义的 token。
举个例子:
{ "name": "张三", "age": 25 }
经过 lexer 处理后,每个片段都被贴上了标签:
{ → LBRACE
"name" → STRING
: → COLON
"张三" → STRING
, → COMMA
"age" → STRING
: → COLON
25 → NUMBER
} → RBRACE
空白字符被跳过了,字符串内容被保留下来,数字从字符串转成了数值。true、false、null 各自标记。
tokenizer 的实现就是一个沿字符走的状态机。每读一个字符判断当前应该进入什么模式。
但字符串解析那块最容易翻车。特别是转义字符的处理。
{ "msg": "他说:\"你好\"" }
lexer 读到反斜杠 \,必须看一眼后面跟的是什么。如果是 ",不是字符串结束,要记录一个双引号字符。如果是 n,是换行符。如果是 t,是制表符。如果是 u,后面跟四个十六进制字符拼成一个 Unicode 码点。
我第一次写的 lexer 没处理这个。测试用例里有 \",我的解析器直接把第二个双引号当成字符串结尾了。后面那个逗号变成了非法字符,报错。debug 了半个多小时才发现是字符串解析函数里没加转义判断。
\uXXXX 的处理更坑。一开始我以为 \u 后面随便跟四个字符就行。测试 \u4E2D 过了(这是"中"字),\u4e2d(小写十六进制)也过了。但 \u4E(不足四位)没有报错,而是返回了一个错误的值。又修了一次才把边界情况覆盖全。
递归下降构建语法树
token 序列准备好了,下一步是语法分析。JSON 的语法树节点类型就几种:对象、数组、字符串、数字、布尔值、null。
最常见的做法是递归下降解析。每种语法规则写一个函数:
parseValue → 看当前 token 是什么
如果是 '{' → 调 parseObject
如果是 '[' → 调 parseArray
如果是字符串 → 调 parseString
如果是数字 → 调 parseNumber
如果是 true/false/null → 直接返回值
parseObject 的逻辑大致这样:
function parseObject():
consume('{')
while (peek() != '}') {
key = parseString()
consume(':')
value = parseValue()
if (peek() == ',') consume(',')
}
consume('}')
解析 {"a": {"b": [1, 2]}} 时,函数调用栈长这样:
parseValue
→ parseObject
→ parseString ("a")
→ consume (:)
→ parseValue
→ parseObject
→ parseString ("b")
→ consume (:)
→ parseValue
→ parseArray
→ parseNumber (1)
→ parseNumber (2)
→ 返回 [1, 2]
→ 返回 {"b": [1, 2]}
→ 返回 {"a": {"b": [1, 2]}}
调用栈的层次和 JSON 的嵌套结构完全一致。递归下降之所以叫"递归"就是这个原因。
但这个方案有硬伤。遇到深度嵌套的 JSON 时可能栈溢出。正常配置文件写不出几千层嵌套,但攻击者可以构造深度嵌套的数据来触发 crash。
之前碰到一个用户 POST 了深度 5000+ 层的 JSON,Node.js 服务直接报 RangeError: Maximum call stack size exceeded。后来在解析器里加了嵌套深度计数器,超过 200 层就拒绝解析。同时加了友好的中文错误提示,不让用户直接看到 V8 的原始异常堆栈。
数字解析的坑
JSON 的数字格式支持整数、小数、科学计数法和负数。规范的定义如下:
number = [负号] int [ frac ] [ exp ]
int = 数字 / 一到九 数字*
frac = . 数字+
exp = (e/E) [+/-] 数字+
几个容易踩的点:
前导零。 01 不合法,0 合法,0.5 合法。因为 int 的规则只有一位时才能是 0。
小数点后必须有数字。 1. 不合法,1.0 合法。
科学计数法的指数。 1e5、1E-3、1.5e+10 都合法。
在这里踩过坑的都知道,自动化部署工具生成的配置文件里包含 3e-4(一个很小的概率阈值),我们用的旧版 JSON 解析器看到 e 就当成非法字符,直接返回 NULL。配置文件加载失败,服务启动到一半就挂了。
查了快两个小时才定位到问题。因为那个自动化工具只在特定条件下才输出科学计数法格式,平时测试值都是整数,从来没有暴露过这个 bug。修复方式不复杂——在数字解析分支里加一段科学计数法的处理逻辑。但排查过程很痛苦,你不会第一时间想到是数字格式的问题。
大整数精度丢失。 JSON 规范没有限制数字精度。但 JavaScript 的 Number 是 64 位双精度浮点数,超过 2^53 的整数会丢失精度。比如 12345678901234567890,JSON.parse 不会报错,但结果变成了 12345678901234567000。如果你在做支付系统,订单号正好超过这个范围,就会出大问题。正确做法是把大整数当字符串传,或者用自定义 reviver 函数处理。
容错策略
标准 JSON 解析器遇到格式错误就该抛异常。但用户实际写的 JSON 经常不标准:
{ "name": "张三", "age": 25 }
键名没加双引号,末尾多了一个逗号。人一眼能看懂这是什么意思,标准解析器会直接报错。
JSON 格式化工具 做了容错处理。核心思路是:解析出错时尝试几种常见的修复策略。
- 遇到非法字符 → 跳过
- 键名没加引号 → 自动补上
- 末尾多了逗号 → 忽略
- 用了单引号 → 当双引号处理
一开始觉得容错越多越好。直到有用户反馈说他的 JSON 被"修坏了"。
用户的输入是 {"flag": ture},他想写 true 但拼错了。容错解析器看到 ture 不认识,但没有报错——它把 ture 当成了一个合法的裸字面量保留了下来。序列化输出时变成了一段乱码。
谁能想到 ture 也能混进容错逻辑啊。后来加了一层拼写检查:遇到 ture 提示是不是想写 true,遇到 flse 提示是不是想写 false,遇到 nul 提示是不是想写 null。编辑距离小于 2 的候选词就弹出警告。
这套逻辑不算复杂,但让我意识到一件事:容错不是越高越好。过度容错会掩盖真正的错误,让用户更难定位问题。
完整的解析链路
上图中从左到右展示了完整的解析流程。原始字符串经过词法分析拆成 token 序列,再进入语法分析阶段递归下降构建语法树,最后转换成内存中的数据结构。
这个架构和编译器的前端是一样的。JSON 解析器本质上就是一个针对特定数据格式的微型编译器。
性能观察
解析性能在大数据量场景下会成为瓶颈。之前做 API 网关时,JSON 解析占了 CPU 的 30% 以上。做了几个优化:
跳过空白不产生对象。 Lexer 遇到空白直接移动指针,不创建临时字符串。很多 JSON 文档里空白占了 10-20% 的体积,这个微优化效果很明显。
字符串驻留。 对象的键名通常重复出现。每次都创建新字符串对象会增加 GC 压力。把已经见过的键名缓存起来,相同的键复用同一块内存。
按需解析。 如果只需要读取某个字段,用 streaming 方式边读边处理,不用构建完整的语法树。读到目标字段就停下来。
不过对于大部分场景用不上这些优化。标准库的性能已经足够好。V8 的 JSON.parse 是 C++ 实现的,纯 JavaScript 写的解析器不可能比它快。先测量再优化,不要预判性能问题。
写在最后
写 JSON 解析器的经历让我理解了一件事——计算机怎么把一段文本变成它能操作的数据结构。词法分析切出有意义的片段,语法分析按规则组合成结构,每一层都在做信息提取和验证。
如果平时只是调 JSON.parse,这些细节不会帮你写出更快的代码。但在排查一些奇怪的 bug 时——比如某个 JSON 突然解析失败、浮点数精度对不上、嵌套太深导致栈溢出——你会比别人更快地找到问题根源。理解底层机制的价值在这里,而不是在"能手写一个解析器"这件事上。