手写JSON.parse:从词法分析到递归下降的完整实现 有件事我一直觉得很可惜技术面试里让候选人手写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 不允许01Number(-)会得到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。除了控制转义之外还有一个容易忽略的约束原始字符串里不能直接出现 U0000 到 U001F 这些控制字符。也就是说真实换行符不能直接出现在 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-抛错返回 NaN1.抛错返回 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 的思路用在自定义格式上它会带来远远超出这道面试题本身的价值。