JSON 这玩意儿,平时写业务代码的人几乎天天见——JSON.parse()一调,字符串进去,对象出来,方便得像拧开水龙头就有水。可一旦有人问你"它内部到底怎么把{"a":1}变成一个对象的",很多人就卡壳了。我当初也是这样,直到某次排查一个诡异的解析报错,才下决心把 JSON 解析器的实现从头到尾啃了一遍。这篇就是我精读一份手写 JSON Parser 实现后的完整笔记,把词法分析、递归下降、错误定位这些平时被JSON.parse藏起来的东西全摊开讲。
适合谁看?写过 JavaScript、知道什么是递归、但没亲手实现过解析器的朋友;也适合那些想搞懂"编译器前端到底在干嘛"、却一直被各种术语劝退的人。我会尽量用生活化的类比把原理讲透,再配上可直接抄的代码骨架和踩坑记录。读完你至少能做到两件事:一是能手写一个能跑通绝大多数场景的 JSON 解析器,二是看到任何解析报错都能大致判断问题出在词法层还是语法层。
1. 为什么值得亲手写一个 JSON 解析器
1.1 从一次"莫名其妙"的报错说起
早些年我处理一份接口返回的数据,JSON.parse直接抛了Unexpected token之类的错误,但肉眼盯着那段字符串看了半天,愣是没看出哪里不对。后来把字符串按字符打印出来,才发现某个字段的值里混进了一个不可见的控制字符。这件事让我意识到:只要你不懂解析器的工作方式,你面对报错就只能靠猜。而一旦你理解了它是一步步"读字符、认 token、按语法规则拼结构"的,报错信息里的位置、token 类型立刻就有了意义。
这也是我精读手写 JSON Parser 的第一个动机——把黑盒变成白盒。JSON.parse是浏览器或运行时给你的一个黑盒,它快、它稳,但它不告诉你过程。手写一遍,你会亲眼看到每个字符是怎么被消费掉的,逗号、冒号、括号各自承担什么职责,错误又是在哪一步被抛出来的。
1.2 JSON 语法其实小得惊人
很多人一听"写解析器"就觉得是高不可攀的编译原理。其实 JSON 的语法规则少得可怜,用一段类似 BNF 的形式就能写完:
value := object | array | string | number | 'true' | 'false' | 'null' object := '{' (pair (',' pair)*)? '}' pair := string ':' value array := '[' (value (',' value)*)? ']'就这么几条。它没有变量、没有运算符优先级、没有函数调用、没有作用域——这些让真正编程语言解析器变复杂的东西,JSON 一个都没有。所以 JSON 解析器是学习解析技术最理想的练手项目:语法足够小,一天能写完;但又足够真实,词法分析、递归下降、错误处理这些核心套路一个不少。
我个人的判断是:如果你能独立写出一个健壮的 JSON 解析器,那你对"解析"这件事的理解就已经跨过了入门门槛,之后去看任何配置文件格式(YAML、TOML)、任何表达式引擎,都会觉得眼熟。
1.3 手写解析器能反哺日常开发
别以为这只是玩具。实际工作里,手写解析器的思路能帮你解决不少问题。比如你需要解析一种自定义的模板语法、需要从一段非标准格式的日志里提取结构化数据、需要实现一个轻量的查询表达式——这些场景下,现成的JSON.parse帮不上忙,但你脑子里的"词法 + 语法"两段式框架可以直接套用。
还有一个隐性收益:你会对数据格式的边界变得敏感。写过解析器的人,在定义接口字段时会更谨慎——知道哪些字符需要转义、知道数字的精度边界在哪、知道嵌套过深会有什么后果。这种敏感度是看多少篇文档都换不来的。
2. 词法分析:把字符流切成有意义的 token
2.1 词法分析器到底在做什么
解析的第一步,是把一长串字符切成一个个有意义的"词",这个过程叫词法分析(Lexing / Tokenizing)。你可以把它想象成读一句英文:The cat sat这串字符,你的大脑会自动切成The、cat、sat三个词,而不是逐字母去理解。词法分析器干的就是这个切词的活。
对 JSON 来说,token 的种类非常有限:
| Token 类型 | 示例 | 说明 |
|---|---|---|
| 标点符号 | {}[]:, | 结构分隔符,单个字符即一个 token |
| 字符串 | "hello" | 带引号,内部可能有转义 |
| 数字 | 42-3.141e10 | 需要处理正负号、小数、指数 |
| 字面量 | truefalsenull | 关键字,整体识别 |
词法分析器的输出是一个 token 序列,每个 token 至少包含两个信息:类型和值(以及它在原字符串中的位置,用于报错)。这个序列交给下一阶段的语法分析器去消费。
2.2 一个字符一个字符地"吃"
我精读的那份实现里,词法分析器的核心是一个游标pos,从头到尾扫一遍字符串。每读一个字符,根据它是什么决定接下来怎么走。这种"看当前字符决定分支"的写法,本质是一个状态机,但用if/switch写出来比画状态图直观得多。
先看骨架。下面这段是我按理解重写的词法分析主循环,语言用 JavaScript,因为 JSON 本身就是 JS 的子集,写起来最自然:
function tokenize(input) { const tokens = []; let pos = 0; while (pos < input.length) { const ch = input[pos]; // 跳过空白字符 if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') { pos++; continue; } // 结构符号,单字符成 token if ('{}[]:,'.includes(ch)) { tokens.push({ type: 'punct', value: ch, pos }); pos++; continue; } // 字符串 if (ch === '"') { const { token, next } = readString(input, pos); tokens.push(token); pos = next; continue; } // 数字 if (ch === '-' || (ch >= '0' && ch <= '9')) { const { token, next } = readNumber(input, pos); tokens.push(token); pos = next; continue; } // 字面量 true / false / null if (ch === 't' || ch === 'f' || ch === 'n') { const { token, next } = readLiteral(input, pos); tokens.push(token); pos = next; continue; } throw new SyntaxError(`Unexpected character '${ch}' at position ${pos}`); } return tokens; }这段代码的逻辑非常直白:跳过空白 → 判断当前字符属于哪一类 → 调用对应的读取函数 → 推进游标。整个词法分析器就是这么一个循环,直到字符串读完。
2.3 字符串读取:转义是最大的坑
字符串的读取是词法分析里最需要小心的地方,因为 JSON 字符串支持转义序列。"a\"b"这个字符串里,\"是一个整体,不能被当成字符串的结束引号。所以读取字符串时不能简单地找下一个",而要逐字符扫描,遇到反斜杠就特殊处理。
function readString(input, start) { let pos = start + 1; // 跳过开头的引号 let value = ''; while (pos < input.length) { const ch = input[pos]; if (ch === '"') { // 找到结束引号 return { token: { type: 'string', value, pos: start }, next: pos + 1 }; } if (ch === '\\') { // 处理转义 const esc = input[pos + 1]; const map = { '"': '"', '\\': '\\', '/': '/', 'b': '\b', 'f': '\f', 'n': '\n', 'r': '\r', 't': '\t' }; if (esc === 'u') { // Unicode 转义 \uXXXX const hex = input.slice(pos + 2, pos + 6); value += String.fromCharCode(parseInt(hex, 16)); pos += 6; continue; } if (!(esc in map)) { throw new SyntaxError(`Invalid escape '\\${esc}' at ${pos}`); } value += map[esc]; pos += 2; continue; } value += ch; pos++; } throw new SyntaxError(`Unterminated string starting at ${start}`); }这里有几个我踩过的细节值得单独拎出来说。第一,\uXXXX的处理:JSON 规范里 Unicode 转义是四位十六进制,parseInt(hex, 16)之后用String.fromCharCode转成字符。但要注意,如果遇到代理对(surrogate pair,比如 emoji),单个\uXXXX只能表示半个字符,需要两个连续转义拼起来才是完整字符。我第一版实现就漏了这个,导致解析带 emoji 的字符串时出现乱码。
第二,非法转义要报错。JSON 规范只允许上面那几种转义,\x这种是不合法的。很多手写实现图省事直接忽略,结果就是错误数据被静默吞掉,后面排查起来更痛苦。我的建议是该报错就报错,而且报错信息里带上位置。
第三,未闭合字符串。如果扫到字符串末尾还没遇到结束引号,必须抛错,不能默默返回。这是健壮性的基本要求。
2.4 数字读取:别用正则一把梭
数字的读取,很多人第一反应是写个正则/-?\d+(\.\d+)?([eE][+-]?\d+)?/去匹配。正则确实能匹配,但有两个问题:一是性能,在大输入上正则的回溯可能拖慢速度;二是错误定位,正则匹配失败时你很难告诉用户具体哪个字符错了。
我精读的实现用的是手写状态推进,虽然代码长一点,但每一步都清清楚楚:
function readNumber(input, start) { let pos = start; let isFloat = false; if (input[pos] === '-') pos++; // 整数部分 while (pos < input.length && input[pos] >= '0' && input[pos] <= '9') pos++; // 小数部分 if (input[pos] === '.') { isFloat = true; pos++; while (pos < input.length && input[pos] >= '0' && input[pos] <= '9') pos++; } // 指数部分 if (input[pos] === 'e' || input[pos] === 'E') { isFloat = true; pos++; if (input[pos] === '+' || input[pos] === '-') pos++; while (pos < input.length && input[pos] >= '0' && input[pos] <= '9') pos++; } const raw = input.slice(start, pos); return { token: { type: 'number', value: isFloat ? parseFloat(raw) : parseInt(raw, 10), pos: start }, next: pos }; }这里有个容易忽略的规范细节:JSON 不允许前导零。01在 JSON 里是非法的,但0和0.5合法。上面这段代码其实没有严格校验前导零,属于"宽松实现"。如果你追求规范严格性,需要在整数部分读完后检查一下:如果读到的整数部分长度大于 1 且以0开头,就该报错。我在自己的版本里补了这个检查,因为宽松解析会让一些本该暴露的数据问题被掩盖。
还有一个精度问题值得提:JavaScript 的Number是双精度浮点,超过2^53的整数会丢精度。如果你的 JSON 里有大整数(比如某些 ID),解析成Number就会出错。这也是为什么有些库提供了bigint或字符串保留选项。手写解析器时,你可以选择把超范围整数保留为字符串,这个决策要在设计阶段就想清楚。
2.5 字面量识别:短小但要防越界
true、false、null这三个字面量的识别相对简单,直接比对字符串即可。但要注意越界检查——如果输入是tru就结束了,你不能直接slice然后比对,得先确认剩余长度够。
function readLiteral(input, start) { const candidates = ['true', 'false', 'null']; for (const word of candidates) { if (input.startsWith(word, start)) { return { token: { type: 'literal', value: word === 'null' ? null : word === 'true', pos: start }, next: start + word.length }; } } throw new SyntaxError(`Unexpected literal at ${start}`); }startsWith自带边界检查,比手动slice安全。这种小地方看着不起眼,但正是这些细节决定了你的解析器在畸形输入面前是崩溃还是优雅报错。
3. 递归下降:用函数的调用栈表达语法结构
3.1 为什么递归下降是 JSON 的天然选择
拿到 token 序列之后,下一步是语法分析(Parsing),也就是按照语法规则把 token 拼成树形结构。JSON 的语法是高度嵌套的——对象里可以有对象,数组里可以有数组,这种"自己包含自己"的结构,用**递归下降(Recursive Descent)**来解析是最自然的。
递归下降的核心思想一句话就能说清:为语法里的每个非终结符写一个函数,函数之间互相调用,用调用栈来表达嵌套。JSON 的非终结符就那么几个:value、object、array、pair。所以你需要四个函数,它们互相调用,嵌套结构就自然地在调用栈上展开了。
我特别喜欢用一个类比来解释这件事:想象你在读一份嵌套的清单。读到{你就知道"哦,进入一个对象了",于是你开始读键值对;读到值又是一个{,你就"再进入一层",等这一层读完(遇到对应的}),你退回到上一层继续。递归下降就是把这个"进入—退出"的过程用函数调用表达出来,函数调用返回的那一刻,就是"退出这一层"。
3.2 四个核心函数的职责划分
先看整体结构。语法分析器持有一个 token 数组和一个游标idx,每个函数负责消费掉自己那部分 token:
class Parser { constructor(tokens) { this.tokens = tokens; this.idx = 0; } peek() { return this.tokens[this.idx]; } next() { return this.tokens[this.idx++]; } parse() { const value = this.parseValue(); if (this.idx < this.tokens.length) { throw new SyntaxError(`Unexpected token after value at ${this.peek().pos}`); } return value; } parseValue() { const token = this.peek(); if (!token) throw new SyntaxError('Unexpected end of input'); if (token.type === 'punct' && token.value === '{') return this.parseObject(); if (token.type === 'punct' && token.value === '[') return this.parseArray(); if (token.type === 'string') { this.next(); return token.value; } if (token.type === 'number') { this.next(); return token.value; } if (token.type === 'literal') { this.next(); return token.value; } throw new SyntaxError(`Unexpected token '${token.value}' at ${token.pos}`); } }parseValue是分发的入口,它看当前 token 是什么,决定走对象、数组还是直接返回标量。这种"看头一个 token 决定分支"的模式,在解析器里叫预测分析,因为 JSON 的语法足够简单,看一眼就能确定走哪条路,不需要回溯。
3.3 对象解析:逗号与冒号的节奏控制
对象解析是四个函数里最容易写错节奏的。JSON 对象的语法是{ pair (, pair)* },注意那个(, pair)*——逗号是分隔符,不是结尾符。这意味着你不能写成"读一个 pair,读一个逗号,循环",因为最后一个 pair 后面没有逗号。正确的节奏是"读一个 pair,然后看有没有逗号,有就继续读下一个 pair,没有就期待}"。
parseObject() { this.next(); // 消费 '{' const obj = {}; // 空对象 if (this.peek() && this.peek().value === '}') { this.next(); return obj; } while (true) { const keyToken = this.peek(); if (!keyToken || keyToken.type !== 'string') { throw new SyntaxError(`Expected string key at ${keyToken ? keyToken.pos : 'EOF'}`); } this.next(); const key = keyToken.value; const colon = this.next(); if (!colon || colon.value !== ':') { throw new SyntaxError(`Expected ':' after key at ${colon ? colon.pos : 'EOF'}`); } obj[key] = this.parseValue(); const sep = this.peek(); if (sep && sep.value === ',') { this.next(); continue; } if (sep && sep.value === '}') { this.next(); break; } throw new SyntaxError(`Expected ',' or '}' at ${sep ? sep.pos : 'EOF'}`); } return obj; }这段代码里有个细节我当初琢磨了很久:为什么空对象要单独判断?因为如果按上面的循环走,空对象{}进来,第一个peek就是},而循环体期待的是字符串 key,会直接报错。所以要么单独判断空对象,要么把循环改成"先判断是不是},不是才进循环"。两种写法都行,我倾向于单独判断,因为逻辑更清晰,读代码的人一眼就知道"空对象是合法特例"。
还有一个坑:重复键怎么处理。JSON 规范没有明确规定重复键的行为,但主流实现(包括JSON.parse)是"后者覆盖前者"。上面这段代码用obj[key] = ...赋值,天然就是后者覆盖,符合主流行为。如果你想要更严格的行为(比如重复键报错),需要额外加一个Set记录已出现的键。
3.4 数组解析:和对象几乎同构
数组的语法是[ value (, value)* ],和对象的结构几乎一模一样,只是元素前面没有 key 和冒号。所以parseArray和parseObject长得非常像:
parseArray() { this.next(); // 消费 '[' const arr = []; if (this.peek() && this.peek().value === ']') { this.next(); return arr; } while (true) { arr.push(this.parseValue()); const sep = this.peek(); if (sep && sep.value === ',') { this.next(); continue; } if (sep && sep.value === ']') { this.next(); break; } throw new SyntaxError(`Expected ',' or ']' at ${sep ? sep.pos : 'EOF'}`); } return arr; }看到这里你可能会问:既然对象和数组这么像,能不能合并成一个函数?技术上可以,但我不建议。分开写虽然重复,但每个函数的语义单一,调试时一眼就知道问题出在对象还是数组。解析器这种代码,可读性比 DRY 更重要,因为出问题时你需要快速定位。
3.5 递归下降的边界:嵌套深度与栈溢出
递归下降有个天然的软肋:嵌套太深会爆栈。因为每一层嵌套对应一次函数调用,而调用栈是有大小限制的。一个恶意构造的输入,比如几万个连续的[,就能让你的解析器栈溢出崩溃。
// 这种输入会让递归下降解析器爆栈 '['.repeat(100000) + ']'.repeat(100000)JSON.parse本身也有这个限制,不同运行时的阈值不同,通常在几千到几万层之间。如果你手写的解析器要处理不可信输入,必须加一个深度计数器,超过阈值就主动报错,而不是等它爆栈:
parseValue(depth = 0) { if (depth > MAX_DEPTH) { throw new SyntaxError(`Nesting too deep at ${this.peek()?.pos}`); } // ... 后续调用时传 depth + 1 }这个细节是我在实际项目里被坑过才记住的。当时一个接口返回了异常深的数据,解析直接崩了,日志里只有一句Maximum call stack size exceeded,完全看不出是数据问题。后来加了深度限制,报错信息立刻变得可读。
4. 错误处理:解析器好不好用,全看报错信息
4.1 报错信息里必须带位置
一个解析器能不能用,很大程度上取决于它报错时报得清不清楚。同样是解析失败,Unexpected token和Unexpected token '}' at position 42是天壤之别。前者让你大海捞针,后者让你直接定位。
所以我在设计 token 结构时,每个 token 都带pos字段记录它在原字符串里的起始位置。语法分析阶段抛错时,把当前 token 的pos带上。这样用户拿到报错,可以直接input.slice(pos - 10, pos + 10)看看出错点附近是什么内容。
更进一步,如果你能同时给出行号和列号,体验会更好。因为 JSON 经常是多行格式化的,光有字符偏移量,用户还得自己数行。计算行列很简单:解析前先扫一遍,记录每个换行符的位置,报错时二分查找一下就知道在第几行第几列。
function getLineCol(input, pos) { let line = 1, col = 1; for (let i = 0; i < pos; i++) { if (input[i] === '\n') { line++; col = 1; } else col++; } return { line, col }; }4.2 区分词法错误和语法错误
排查问题时,知道错误发生在哪一层非常关键。词法错误意味着字符本身就不该出现,比如字符串里有个非法转义、或者出现了@这种 JSON 里根本不存在的字符。语法错误意味着字符都合法,但组合顺序不对,比如{"a" 1}少了冒号、[1,2,]多了个尾逗号。
我在实现里给两类错误用了不同的错误类型和消息前缀,这样用户一看就知道往哪个方向查:
| 错误类型 | 典型场景 | 排查方向 |
|---|---|---|
| 词法错误 | 非法转义\x、未闭合字符串、非法字符 | 检查原始字符,可能有隐藏字符或编码问题 |
| 语法错误 | 缺冒号、缺逗号、括号不匹配、尾逗号 | 检查结构,通常是手写或拼接 JSON 时漏了符号 |
这个区分在实际排查中特别有用。比如你从某个系统拿到一段 JSON 报"词法错误",那八成是数据里混进了不可见字符或者编码不对;如果报"语法错误",那多半是拼接逻辑有问题。
4.3 尾逗号:最容易被宽松实现放过的坑
JSON 规范不允许尾逗号,[1,2,]和{"a":1,}都是非法的。但 JavaScript 的对象字面量允许尾逗号,所以很多人写 JSON 时会下意识带上,然后被严格的解析器拒绝。
我精读的那份实现里,尾逗号是被正确拒绝的——因为它的循环逻辑是"读一个值,看分隔符,是逗号就继续读下一个值",如果逗号后面直接是],下一轮parseValue会看到]然后报错。这个行为是对的,但报错信息可能不够友好,用户看到的是Unexpected token ']',不一定能立刻联想到是尾逗号的问题。
我的改进是在读到逗号后,先 peek 一下下一个 token,如果是闭合符号就给出更明确的提示:
if (sep && sep.value === ',') { this.next(); const after = this.peek(); if (after && (after.value === ']' || after.value === '}')) { throw new SyntaxError(`Trailing comma is not allowed at ${sep.pos}`); } continue; }这种"针对常见错误给专门提示"的做法,是提升解析器可用性的关键。用户不需要去查规范,报错直接告诉他问题在哪。
5. 从能跑到好用:性能与健壮性的进阶打磨
5.1 两遍扫描 vs 一遍扫描
前面讲的架构是两遍扫描:先tokenize生成完整 token 数组,再parse消费这个数组。这种架构清晰、易调试,但有个性能代价——要为所有 token 分配对象,内存占用翻倍。
另一种做法是一遍扫描:词法分析器不生成数组,而是提供一个nextToken()接口,语法分析器需要时现取。这样内存占用小,但代码耦合度高,调试时看不到完整 token 流。
我的建议是:学习和中小规模输入用两遍扫描,追求极致性能或处理超大输入时再考虑一遍扫描。对绝大多数场景,两遍扫描的性能完全够用,清晰度带来的维护收益远大于那点内存开销。我实测过一个几 MB 的 JSON 文件,两遍扫描的耗时在几十毫秒量级,和原生JSON.parse差距不大,瓶颈根本不在架构选择上。
5.2 用基准测试说话,别凭感觉优化
说到性能,我必须强调一点:优化前先测量。我见过太多人凭直觉去优化解析器,结果改了一堆代码,性能没提升反而引入了 bug。正确的做法是准备几组有代表性的输入——小对象、大数组、深嵌套、长字符串——然后用performance.now()或专门的基准工具跑一跑,看时间花在哪。
function bench(name, fn, iterations = 1000) { const start = performance.now(); for (let i = 0; i < iterations; i++) fn(); const end = performance.now(); console.log(`${name}: ${((end - start) / iterations).toFixed(3)} ms/op`); }我自己的实测结论是:字符串读取和数字读取是两个热点。字符串读取里,逐字符拼接value += ch在长字符串上会有性能问题,因为字符串在 JS 里是不可变的,每次拼接都可能创建新字符串。优化手段是用数组收集字符片段,最后join一次。数字读取的热点则在parseInt/parseFloat的调用上,这个不太好优化,除非你自己实现数字转换。
5.3 健壮性清单:那些必须处理的畸形输入
一个"能跑"的解析器和"好用"的解析器,差距往往在畸形输入的处理上。我整理了一份自己实现时逐条验证过的清单:
- 空输入:
""应该报错,而不是返回undefined。 - 只有空白:
" "同样应该报错。 - 多个顶层值:
"1 2"应该报错,JSON 只允许一个顶层值。 - 未闭合的括号:
{"a":1应该报错,且报错信息指出缺少}。 - 括号类型不匹配:
{"a":1]应该报错。 - 键不是字符串:
{a:1}应该报错,JSON 的键必须是双引号字符串。 - 单引号字符串:
{'a':1}应该报错,JSON 只认双引号。 NaN/Infinity:这些不是合法 JSON 值,应该报错。- 控制字符:字符串里未转义的控制字符(如换行)应该报错。
每一条我都写了对应的测试用例。说实话,写测试用例花的时间比写解析器本身还多,但这是值得的——正是这些用例让我发现了自己实现里好几个想当然的错误。
5.4 一个容易被忽略的细节:BOM 头
从某些系统导出的 JSON 文件,开头可能带一个 BOM(字节顺序标记,\uFEFF)。这个字符肉眼看不见,但会让解析器在第一个字符就报错。原生JSON.parse遇到 BOM 也会报错,所以很多工具在解析前会先trim掉 BOM。
我的处理方式是在tokenize开头主动跳过 BOM:
if (input.charCodeAt(0) === 0xFEFF) { pos = 1; }这个细节看着微不足道,但实际工作中因为 BOM 导致的"JSON 解析失败"我至少遇到过三次,每次都要排查半天。写进解析器里,一劳永逸。
6. 精读之后,我对"解析"这件事的理解变了
把这份手写 JSON Parser 从头到尾啃完,最大的收获其实不是"我会写解析器了",而是我对所有数据格式的解析过程都有了统一的心理模型。现在我看到任何格式——不管是配置文件、模板语法还是查询表达式——脑子里都会自动拆成"词法层切 token"和"语法层拼结构"两段,然后判断它属于哪种语法类型(是递归嵌套,还是线性序列,需不需要回溯)。这个模型一旦建立,学新格式的速度会快很多。
另外一个体会是:解析器的质量,八成体现在错误处理上。功能正确只是及格线,真正区分好坏的是"出错时能不能告诉用户错在哪、为什么错"。我后来在别的项目里写自定义格式解析时,都会先把错误信息设计好,再写正常路径——这个习惯就是从精读这份实现时养成的。
如果你也想动手,我的建议是别一上来就追求完整和性能。先写一个只支持对象、数组、字符串、数字的最小版本,跑通{"a":[1,2,{"b":true}]}这种嵌套输入,然后再逐步补字面量、转义、错误处理、深度限制。每补一个特性就加一组测试,这样你随时知道自己的实现处于什么状态。等最小版本稳了,再去考虑一遍扫描、性能优化这些进阶话题。手写解析器这件事,慢就是快,把每一层都吃透,比抄一份完整代码然后似懂非懂要有价值得多。