有件事我一直觉得很可惜:技术面试里让候选人手写JSON.parse,几乎是我最常用的压轴题,但很多人听到题目后第一反应是“背 API”,第二反应是“用 eval 糊弄过去”。真正能写出一个不依赖任何外部库、不调用原生JSON.parse、又能正确拒绝非法输入的递归实现,我这些年遇到的候选人一只手数得过来。原因不是题目本身有多难,而是它背后串着词法分析、语法分析、运行时构造、异常处理四条线,任何一条线断裂,边界用例都会立刻现形。
如果你正在准备面试,这篇文章会给你一套能直接拿出来讲的实现路线;如果你平时要处理日志、配置文件、协议报文,需要解析那些“差一点标准”的数据,把底层实现搞清楚之后,改起来会顺手非常多。下面我按自己实际写 parser 的顺序,从 token 化开始一步步拆,每一步都尽量说清楚“为什么这么做”,而不是只给一段能跑的代码。
1. 面试官挖的坑:这道题考察的不只是"能解析"
1.1 表面在考 API,实际在考编译原理基础
JSON.parse是运行时提供的高频 API,原生实现大部分用 C/C++ 完成,把一段字符串变成 JavaScript 对象。你在浏览器里调用它只需要一行,但这一行的背后是一整套最小化的编译过程:先把字符流拆成词法单元,也就是 token;再根据 JSON 的语法规则判断 token 的排列是否合法;最后把合法的结构转换成内存中的对象、数组、字符串、数字、布尔值。面试官真正想看的,是你有没有能力独立完成这三个环节,而不是你记不记得参数列表。
很多候选人会在开头陷入两个误区。第一个误区是“我可以背出 JSON 的完整语法定义”,但真让他写,他从字符串解析开始就乱了;第二个误区是“直接正则表达式匹配整个 JSON”,结果嵌套结构一到两层就崩。两个误区的根源都在于没有把词法分析和语法分析分开。词法分析解决的是“这个字符序列能不能拆成合法 token”,语法分析解决的是“这些 token 按什么顺序出现才是合法的 JSON”,两者一旦混在一起,后面的错误边界和扩展点全都会糊掉。
这道题还有一个隐蔽的考察点:工程习惯。同样写一个解析器,有的人会在数字解析时顺手把01、1.、-这些非法输入全部放进来,有的人会考虑__proto__引起的原型污染,有的人会在错误信息里给出行列号。这些细节不会出现在任何公开的 JSON 语法教程里,但它们实实在在决定了一个解析器能不能上生产。
1.2 三条常见路线,先看它们的命运
在纸上写实现时,候选人通常走三条路。我先把结论放在表格里,后面再逐条展开:
| 路线 | 实现难度 | 基本功能 | 边界正确性 | 面试表现 |
|---|---|---|---|---|
| 正则匹配 + 字符串替换 | 低 | 部分 | 差 | 追问几轮就废 |
| Function/eval 包装 | 很低 | 看起来好 | 差 | 有严重安全风险 |
| 手写递归下降 | 中 | 完整 | 好 | 能展示完整系统设计能力 |
正则路线之所以是面试重灾区,是因为 JSON 是递归嵌套结构,而 JavaScript 的正则表达式并不支持真正意义上的递归匹配。你可以用正则去匹配一个单层对象,甚至可以匹配简单数组,但一旦出现{"a":{"b":{"c":1}}}这种嵌套,正则就无能为力了。你可能会说 PCRE 支持递归子模式,但 JS 引擎的正则至今没有这个能力,所以“纯正则解析 JSON”在工程上不成立。
eval 路线也很经典。早期没有原生JSON.parse的时候,确实有人用(new Function("return " + json))()做降级方案。这条路线表面上能解析很多数据,但它根本不管 JSON 标准:undefined、NaN、单引号字符串、注释全部会被放进来,最致命的是执行了任意代码。所以它只能作为讨论的起点,不能作为答题的终点。真正的面试亮点是第三条路:自己写一个迷你的递归下降解析器,这也是我在后面几节要展开的完整方案。
2. token 化:先学会把 JSON 字符串切成可信的碎片
2.1 JSON 词法单元的类型
拿到一段字符串以后,第一步不是直接去找花括号,而是先想清楚“合法 token 有哪几类”。JSON 的 token 种类非常少,用一条规则就能列完:
- 标点符号:
{、}、[、]、:、, - 字符串:以双引号开头,以匹配的双引号结尾
- 数字:整数、小数、指数,可以带负号
- 三个关键字:
true、false、null,小写且不能有后缀
除了这四类,JSON 里还允许空白字符出现在任意 token 之间。注意是“允许”,不是“必须”。JSON 规范的空白只有四个字符:空格、水平制表符\t、换行\n、回车\r。这里有个很容易被忽略的细节:不要直接调用字符串的trim()方法处理空白,因为trim()会去掉行首行尾的 Unicode 空白字符,比如\uFEFF,而这些字符在 JSON 规范里并不属于合法空白。
我写的 token 化并不打算把 token 全部存成一个数组,而是采用“边扫描边解析”的方式,这和很多工业级解析器的做法一致。省内存,也不需要额外维护 token 列表的状态。但“跳过空白”这个工具函数是必须的,它在每个语法单元的入口都会被调用:
function skipWhitespace() { while (i < src.length) { const c = src[i]; if (c === ' ' || c === '\t' || c === '\n' || c === '\r') { i++; } else { break; } } }这个函数本身没什么技术含量,但它守住了整个解析过程的第一个边界:所有 token 都必须先经过它,再进到下一层判断。
2.2 识别起始符号与关键字边界
拿到第一个非空字符后,要决定当前这个 token 属于哪一类。这个分发逻辑看起来只是一串if/else,但里面藏着一个容易踩的坑:关键字的判断不能只靠startsWith。
假设你拿到了truely,原生JSON.parse会在第一个字符处直接抛错,因为t是一个非法 token。如果你用src.startsWith('true')直接返回true,解析器就会把truely拆成true加上一个游离的ly,后面那一部分要么被当作多余数据报错,要么在一些复杂语法里被误判成下一个 token。虽然最后大概率还是抛错,但报错的位置和信息已经不对了,更重要的是这暴露了你对“token 边界”这个概念没有把握。
所以我会为关键字单独写一个专用的消费函数,消费之前先确认“下一个字符”是合法的分隔符:
function consumeKeyword(word, value) { if (!src.startsWith(word, i)) { throw syntaxError('Unexpected token', i); } const end = i + word.length; const next = src[end]; if ( next !== undefined && '{}[]:,'.indexOf(next) === -1 && !(next === ' ' || next === '\t' || next === '\n' || next === '\r') ) { throw syntaxError(`Unexpected token after ${word}`, i); } i = end; return value; }这里的白名单是严格模式:true后面只允许出现}、]、:、,、空白或者字符串结束。干了这一手之后,true1、truefalse、truely这些输入全部会被卡在关键字这一关,不会流到后面的语法分析里去。每次提到“token 化”的时候不要只想到跳过空格,token 边界校验同样属于词法分析的一部分。
3. 递归下降:用最直白的代码把 JSON 变回 JS 对象
3.1 解析器骨架与整体结构
递归下降解析器这个名字听起来吓人,核心思路其实很简单:为 JSON 语法的每个规则写一个函数,函数之间互相调用,token 从左到右被消费,遇到嵌套结构就递归往下走。JSON 的 value 既可以是基本类型,也可以是对象和数组,对象和数组内部又包含 value,所以函数之间的调用天然形成了递归树。
我习惯把整套解析逻辑封装在一个parseJson函数里,内部共享两个状态:原始字符串src和当前扫描位置i。这样各个子函数之间不需要来回传 cursor,代码读起来会清爽很多。
function parseJson(input) { const src = input; let i = 0; function syntaxError(message, pos) { const s = src.slice(0, pos); const lines = s.split('\n'); return new SyntaxError( `${message} at ${lines.length}:${lines[lines.length - 1].length + 1}` ); } // ... 其余函数定义省略,下面逐个展开 }最上层的入口函数parseValue决定当前该调用哪个子函数。它先跳过空白,然后看第一个字符:
function parseValue() { skipWhitespace(); if (i >= src.length) { throw syntaxError('Unexpected end of input', i); } const c = src[i]; if (c === '{') return parseObject(); if (c === '[') return parseArray(); if (c === '"') return parseString(); if (c === '-' || (c >= '0' && c <= '9')) return parseNumber(); if (src.startsWith('true', i)) return consumeKeyword('true', true); if (src.startsWith('false', i)) return consumeKeyword('false', false); if (src.startsWith('null', i)) return consumeKeyword('null', null); throw syntaxError('Unexpected token', i); }解析完顶层 value 之后,千万不能直接返回结果。skipWhitespace()之后要继续检查删掉空白后的i是否等于字符串长度,否则1 2、{}x这样的输入会被当成合法数据放过去。这段收尾代码放在整个parseJson的底部,是最后一道防线:
const result = parseValue(); skipWhitespace(); if (i !== src.length) { throw syntaxError('Unexpected data after JSON', i); } return result; }3.2 数字解析:最容易写错的部分
JSON 的 number 语法比很多人想象的严格,它可以用一条正则描述:-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?。翻译成人话就是:负号最多一个,整数部分要么是单个0,要么以 1 到 9 开头后面跟任意数字,不能出现前导零;小数部分必须由点和至少一位数字组成;指数部分必须由e或E开头,后面可以有符号,但至少有一位数字。
写解析函数的时候,最容易犯的错是“一顿操作把所有数字字符扫进来,最后用Number()兜底”。Number('01')会得到 1,但 JSON 不允许01;Number('-')会得到NaN,但 JSON 不允许只有负号。所以正确做法是按语法小节一只一只地消费字符,每一节都确认自己没有落空:
function parseNumber() { const start = i; if (src[i] === '-') i++; if (src[i] === '0') { i++; if (src[i] >= '0' && src[i] <= '9') { throw syntaxError('Invalid number', start); } } else if (src[i] >= '1' && src[i] <= '9') { while (src[i] >= '0' && src[i] <= '9') i++; } else { throw syntaxError('Invalid number', start); } if (src[i] === '.') { i++; const fracStart = i; while (src[i] >= '0' && src[i] <= '9') i++; if (i === fracStart) { throw syntaxError('Invalid number', start); } } if (src[i] === 'e' || src[i] === 'E') { i++; if (src[i] === '+' || src[i] === '-') i++; const expStart = i; while (src[i] >= '0' && src[i] <= '9') i++; if (i === expStart) { throw syntaxError('Invalid number', start); } } return Number(src.slice(start, i)); }这个函数写出来以后,我想特别聊一下“合法数字”和“可被 Number 解析的数字”之间的差别。在 JSON 里,01是不合法的,但在 JavaScript 的 Number 转换里它是合法的。所以解析器必须做语法层面的限制,不能把合法性判断全部甩给Number()。另外,Number('1e400')会得到Infinity,原生JSON.parse('1e400')也返回Infinity,这是规范允许的,不需要额外拦截。
3.3 字符串解析:转义是主战场
字符串解析是手写JSON.parse时工作量最大的地方,因为它既要有转义处理,又要拒绝不该出现的内容。字符串以双引号开头,内部既可以有普通字符,也可以有反斜杠转义。JSON 规定转义字符只有八个:\"、\\、\/、\b、\f、\n、\r、\t,外加统一进制的\uXXXX。
除了控制转义之外,还有一个容易忽略的约束:原始字符串里不能直接出现 U+0000 到 U+001F 这些控制字符。也就是说,真实换行符不能直接出现在 JSON 字符串里,必须写成\n这种转义形式。原生JSON.parse('"\n"')会报错,就是因为这里有一个真实的换行控制字符。
function parseString() { i++; let out = ''; while (i < src.length) { const c = src[i]; if (c === '"') { i++; return out; } if (c === '\\') { i++; const esc = src[i]; switch (esc) { case '"': out += '"'; break; case '\\': out += '\\'; break; case '/': out += '/'; break; case 'b': out += '\b'; break; case 'f': out += '\f'; break; case 'n': out += '\n'; break; case 'r': out += '\r'; break; case 't': out += '\t'; break; case 'u': { i++; const hex = src.slice(i, i + 4); if (!/^[0-9a-fA-F]{4}$/.test(hex)) { throw syntaxError('Invalid unicode escape', i); } out += String.fromCharCode(parseInt(hex, 16)); i += 3; break; } default: throw syntaxError('Invalid escape', i); } i++; continue; } if (c.charCodeAt(0) <= 0x1f) { throw syntaxError('Unescaped control character', i); } out += c; i++; } throw syntaxError('Unterminated string', src.length); }注意\uXXXX分支里面的i += 3。很多人在这里会写i += 4或者直接i++,结果跳过的字符数量不对。原因是switch分支末尾还有一个统一的i++,为了配合它,前 4 位十六进制字符只往前推进 3 个位置,最终正好越过 4 位十六进制数字。这个细节没有任何文档会写,但少了它解析"\u0041"就会偏离一个字符。我在实际面试里见过好几个候选人在这一步卡住,这个细节值不值得记,你自己判断。
3.4 对象和数组:递归嵌套真正发生的地方
对象和数组是所有边界条件的汇聚点。空对象和空数组要先特判,否则后面的 while 循环会在}或]上给出错误信息。对象的 key 必须是双引号字符串,:之后才是 value;数组的元素直接就是 value。
function parseObject() { i++; const obj = {}; skipWhitespace(); if (src[i] === '}') { i++; return obj; } while (true) { skipWhitespace(); if (src[i] !== '"') { throw syntaxError('Expected string key', i); } const key = parseString(); skipWhitespace(); if (src[i] !== ':') { throw syntaxError('Expected colon', i); } i++; const value = parseValue(); Object.defineProperty(obj, key, { value, writable: true, enumerable: true, configurable: true }); skipWhitespace(); if (src[i] === ',') { i++; continue; } if (src[i] === '}') { i++; return obj; } throw syntaxError('Expected , or }', i); } }数组的逻辑几乎和对象一一对应,只是少了 key 和冒号:
function parseArray() { i++; const arr = []; skipWhitespace(); if (src[i] === ']') { i++; return arr; } while (true) { arr.push(parseValue()); skipWhitespace(); if (src[i] === ',') { i++; continue; } if (src[i] === ']') { i++; return arr; } throw syntaxError('Expected , or ]', i); } }这里有个特别值得讲的设计:给对象赋值时,我没有用obj[key] = value,而是用了Object.defineProperty。原因很简单,obj['__proto__'] = value会触发原型 setter,直接改变对象的原型,这是很多手写 parser 会中招的原型污染点。原生JSON.parse('{"__proto__": {"x": 1}}')返回的是一个带有自有属性__proto__的普通对象,而不是修改了原型的对象。用Object.defineProperty就能精准创建一个自有属性,既保留了行为,又避免和原型链互相干扰。这一手在面试里说出来,分量完全不同。
4. 边界与反例:为什么"看起来能用"的解析器上不了台面
4.1 一组必测用例
一个解析器写出来,先别急着说“完成”。我习惯把一组必测用例立刻跑一遍,在浏览器里直接和原生JSON.parse对照结果。下面这张表里的输入,每一行都是一个面试官经典提问点:
| 输入 | 原生结果 | 宽松/正则实现可能给的结果 |
|---|---|---|
'01' | 抛错 | 返回 1 |
'-' | 抛错 | 返回 NaN |
'1.' | 抛错 | 返回 1 |
'[1,]' | 抛错 | 返回 [1] |
'{"a":1,}' | 抛错 | 返回 {a:1} |
'{"a":undefined}' | 抛错 | 接受 undefined |
'{"a":"\\v"}' | 抛错 | 保留\v或解析成奇怪字符 |
'{"a":"\\u0041"}' | 返回{a:"A"} | 原样保留\\u0041 |
'{"__proto__":{"x":1}}' | 自有属性,原型不变 | 原型被污染 |
表格里的每一行背后都有实际意义。01和1.对应数字语法边界,[1,]和{"a":1,}对应尾逗号必须被拒绝,undefined对应 eval 路线会把非法类型放进来,\v对应超出 JSON 转义表的字符,\u0041对应 Unicode 转义是否真的被解出来了,__proto__对应对象构造是否安全。这些用例只要有一条没过,面试官就有充足理由不给你通过。
4.2 正则解法的典型死法
在上面这些用例里,最能让正则解法现原形的是嵌套结构。正则是一种用来匹配“规则字符串”的工具,但 JSON 需要的匹配深度是未知的,它依赖一个栈来记录当前嵌套层级。JavaScript 的正则没有栈,也没有递归子模式,所以纯正则无法判断一段任意嵌套的 JSON 字符串是不是合法的。这句话我要说得绝对一点:在 JS 的正则能力范围内,纯正则不可能完整解析 JSON。
有人说那我可以把正则当 tokenizer 用,再用代码维护栈。这就对了,但你的正则只是词法分析的一部分,剩下的是你在手写语法分析,和“用正则解析 JSON”已经不是一回事。面试时最尴尬的场面是:候选人在白板上写了半篇正则,被问“如果数组里有数组,你的正则怎么办”,然后开始逐层加括号,最后整个正则长到没人看得懂。与其这样,不如一开始就按 parser 的思路来。
正则解法还有一种变体:用字符串替换一层一层剥掉最内层对象。比如str.replace(/\{[^{}]*\}/g, '')循环处理。这个方法在处理“没有花括号参与的字符串时”勉强能跑,但一旦字符串内容里恰好包含{、}或者转义引号,整个替换逻辑立刻崩坏。字符串内容应该在词法分析阶段就被当作不可分割的整体,而不是拿正则去找“看起来像括号”的字符。
4.3 与原生 JSON.parse 的几处行为差异
手写实现和原生实现至少有四个细节值得单独对照。
第一是错误信息。原生JSON.parse的报错绝大多数是“Unexpected token u”这类信息,没有行列号。我自己的实现在 error 生成函数里切分了几行几列,这是生产级 parser 和 demo parser 的分水岭。第二是 BOM 头。开头有一个\uFEFF的输入,原生会直接抛错,我的skipWhitespace也不会跳过它,行为一致。第三是重复键。{"a":1,"a":2}原生保留最后一个值,我的实现里Object.defineProperty重复定义同一个 key 也是后者覆盖前者,行为一致。第四是深层嵌套。两个解析器都有递归调用,遇到特别深的嵌套都会抛 RangeError,原生 JSON.parse 的深层限制来自 V8 的栈大小,手写实现同样受制于 JavaScript 的调用栈,很难在这个点上完全避开。
这些差异不是为了让手写实现和原生一模一样,而是提醒你:一个 parser 的正确性判断标准不是“能输出一个对象”,而是“对合法输入输出正确对象,对非法输入给出明确拒绝”。这个标准写进测试用例里,比背十遍 JSON 语法定义都有用。
5. 错误信息、非标准扩展和性能,三个容易翻车的细节
5.1 错误信息与错误定位
原生JSON.parse的错误信息对调试极不友好。你在一个几千行的 JSON 配置里缺了一个逗号,它会告诉你“Unexpected token”,但没有行列号,你只能靠肉眼去扫。真实项目里,解析器最值钱的能力之一就是会报错、能定位。
我之前在实现错误定位的时候,用了一个非常直接的办法:从字符串开头切到出错位置,统计里面有多少个换行符,最后一个换行之后还有多少个字符。展开就是行列号:
function describePosition(pos) { const before = src.slice(0, pos); const lines = before.split('\n'); return `${lines.length}:${lines[lines.length - 1].length + 1}`; }如果src很长,这个函数每次报错都做一次切片和 split,效率不高,但解析报错本来就不是高频路径,可读性优先完全没问题。如果你追求极致性能,可以在解析过程中维护当前行号和列号,每消费一个字符更新一次;不过我实际写下来,发现维护开销比不过报错时才计算的便利。这个取舍本身也经常被面试官追问:你愿意为“更好的错误信息”付出多少性能成本。
5.2 非标准扩展:尾逗号、注释、key 不带引号
JSON 是标准格式,但现实中有很多变体,比如 JSON5 允许单引号字符串、不带引号的 key、十六进制数和尾逗号;JSONC 允许注释。写这些扩展并不难,有时候只差一个判断,但真正的难度在于“开了口子就收不回来”。
拿尾逗号举例。标准 JSON 里[1,]必须报错,因为逗号和]之间缺少元素;JSON5 则允许[1,]。实现方式是在 parseArray 的 while 循环里,遇到逗号之后先看一下下一个字符是不是],如果是就接受。这个逻辑本身只有三行,但它会让整个解析器进入“宽容模式”。一旦宽容了一个点,后面就会有人往里塞注释、塞省略号、塞各种稀奇古怪的写法。
以我多年的实际经验,生产环境解析外部第三方数据,千万不要默认开扩展。宁可报错也不要包容。你一旦允许尾逗号,线上配置里就会长出几十种不同风格的逗号;你一旦允许注释,配置文件的内容就开始变得不可预测。如果要做扩展格式,正确的方式是明确声明“我在解析 JSONC,不是在解析 JSON”,然后给扩展功能单独写状态分支,而不是在标准 parser 上随手打补丁。
5.3 为什么不建议用 Function/eval 实现 JSON.parse
每次聊到这道题,都绕不开 eval 这条“捷径”。有人觉得return new Function("return " + json)()一行代码就搞定了,何必写这么长。这个想法放在面试里是致命的,因为 eval 有四个绕不开的问题。
第一是安全。传入的字符串会被当作代码执行,如果输入来自不可信源,恶意代码会在当前上下文里运行。这是反模式中的反模式。第二是语义偏离。undefined、NaN、Infinity、单引号字符串、十六进制数、尾逗号统统会被 JavaScript 引擎当合法内容接受,这些都不在 JSON 标准里。第三是错误信息混乱。new Function的报错来自 JS 语法解析器,它给出的错误信息完全不会告诉你 JSON 是在第几行第几列出错的。第四是性能。new Function需要触发一次完整的 JS 编译过程,分摊到频繁调用的小对象解析上,并没有优势。
所以即使在面试开头抖了一个 eval 机灵,也必须立刻说明它的缺陷,然后把答案拉回递归下降解析器。面试官想听的不是一个花哨的 trick,而是一个能自圆其说的完整方案。
6. 面试结束后的下一步:这道题还能怎么追问
6.1 连环追问:reviver、原型污染、栈溢出
写完基础 parser 之后,面试官往往不会停手。第一个高频追问是:“如果让你支持JSON.parse的第二个参数 reviver,你会怎么设计?”原生 reviver 的调用次序是从最内层 child 开始,逐层向外,每层调用时this指向当前所在的 holder 对象。你可以先递归清洗整个 value,然后在返回前调用reviver.call(holder, key, value)。注意这不是一句“加个回调函数”就能糊弄过去的,它要处理 key 为空串的根节点,要处理数组索引作为 key 的情况,还要保证 reviver 返回undefined时该字段被删除。这个追问能很自然地把话题引向你对现有 API 的深度理解。
第二个高频追问是原型污染。如果我在简历上写“熟悉 JSON.parse 原理”,面试官就会问:为什么直接用obj[key] = value会有问题?这个问题接住之后,他会继续追问“那对象手写属性里如果有constructor或者prototype会怎样”。关键结论是constructor和prototype只是普通字符串 key,只要你不主动触发原型 setter,就不会污染原型链;真正的坑集中在__proto__这个特殊 key 上。
第三个高频追问是栈溢出。递归下降 parser 在嵌套特别深的时候会爆调用栈,原生JSON.parse也一样会抛 RangeError。面试官想听你说是“引擎栈大小限制”而不是“JSON 数据太大”。进阶的解法是改成显式栈的迭代 parser,但那会让代码量翻倍,而且复杂度上升明显。我在真实处理超大文件时,反而更倾向于先用一个极快的守卫函数判断嵌套深度,超过阈值直接拒绝,而不是把整个 parser 改写成迭代模式。
6.2 从手写 parser 到生产级工具的最后一公里
如果你只是为了让面试官点头,写到第四章的代码已经够用了。但如果你要把这个思路用到真实项目里,我还有最后一公里的建议:差分测试和兼容性清单。
最简单有效的差分测试,是准备一组合法的 JSON 和非法的“类 JSON”字符串,分别用原生JSON.parse和手写parseJson去跑。合法输入比较结果,非法输入比较是否都抛错。为了比较对象,最好用 Node 的assert.deepStrictEqual,或者自己写一个递归对比函数。我在本地调试时,会把这一组用例做成一个脚本:
const testCases = [ 'null', 'true', 'false', '{}', '[]', '{"a":1}', '[1,2,3]', '"hello\\nworld"', '"\\u0041"', '-0.5e2', '01', '[1,]', '{"a":1,}', '{"a":undefined}', '{"a":"\\v"}', '{"__proto__":{"x":1}}', '{"a":1} extra', '\uFEFF{}', '' ]; for (const t of testCases) { let nativeResult, myResult; try { nativeResult = JSON.parse(t); } catch (e) { nativeResult = 'ERR'; } try { myResult = parseJson(t); } catch (e) { myResult = 'ERR'; } if (JSON.stringify(nativeResult) !== JSON.stringify(myResult)) { console.log('diff:', t, nativeResult, myResult); } }这个脚本的价值不在于覆盖所有情况,而在于让 parser 的迭代过程可回归。以后你每加一个非标准扩展,跑一遍脚本就知道哪些标准行为被你破坏了,非常省心。
我在真实项目里写解析器,很多时候并不是为了替代原生JSON.parse,而是要做配置格式、协议补丁、日志预检这类事情。标准 JSON 解析器收敛、严格,但在应对“差一点标准”的数据时反而显得僵硬。把递归下降这套思路吃透以后,你看到的不再是一堆语法规则,而是一套可以随意组合的状态机。原生JSON.parse我建议你永远别在生产里替换它,但如果你能把这套手写 parser 的思路用在自定义格式上,它会带来远远超出这道面试题本身的价值。