4、词法分析器手工实现:手工编写词法分析器、状态转换图、代码实现技巧

好,咱们今天来聊聊手工写词法分析器这件事。

说实话,很多人一听到「词法分析」就觉得高大上,以为非得用 Lex、Flex 这种工具才行。其实不然。我个人的经验是,对于大部分前端项目,手工写一个词法分析器反而更灵活、更好控制。你想想看,一个简单的模板引擎、一个配置文件解析器,犯得着上重型工具吗?

4.1 状态转换图:词法分析器的「地图」

在动手写代码之前,我强烈建议你先画一张图——状态转换图。说白了,它就是词法分析器的「导航地图」。没有这张图,你写代码就像蒙着眼睛走路。

什么是状态转换图?

  • 状态:词法分析器当前处于什么「模式」下。比如:空闲状态、读取数字状态、读取字符串状态、注释状态等。
  • 转换:当读入一个字符时,从当前状态跳转到另一个状态。
  • 动作:到达某个状态后,执行什么操作(比如记录 Token 的开始位置、生成 Token 等)。

举个例子,解析一个简单的算术表达式 3 + 5 * 2,状态转换图大致是这样的:

核心思路: 每个状态只关心「当前字符是什么」,然后决定下一步去哪。不要试图在一个状态里处理所有逻辑。

下面是我用 SVG 画的一张状态转换图,展示了一个简易词法分析器的核心逻辑:

空闲 数字 标识符 字符串 注释 数字 字母 " // 数字 非" 换行/结束 非数字/字母

这张图看着简单,但它是整个词法分析器的骨架。我在实际项目中,每次写新解析器之前,都会先在白板上画这么一张图。画清楚了,代码就是照着图「翻译」一遍的事。

4.2 手工实现的核心技巧

好,图有了,咱们开始写代码。手工实现词法分析器,我总结了几个关键技巧:

4.2.1 用「指针 + 状态」驱动

别搞什么复杂的递归下降,手工词法分析器最朴素也最有效的模式就是:一个指针从头扫到尾,一个状态变量记录当前模式。

// 伪代码,展示核心逻辑
function tokenize(input) {
  let pos = 0;
  let state = 'START';
  let tokens = [];
  
  while (pos < input.length) {
    let ch = input[pos];
    
    switch (state) {
      case 'START':
        if (isDigit(ch)) {
          state = 'NUMBER';
          // 记录起始位置
          start = pos;
        } else if (isLetter(ch)) {
          state = 'IDENTIFIER';
          start = pos;
        } else if (ch === '"') {
          state = 'STRING';
          start = pos;
        } else {
          // 处理单字符 Token
          tokens.push({ type: ch, value: ch });
          pos++;
        }
        break;
        
      case 'NUMBER':
        if (isDigit(ch)) {
          pos++; // 继续读取
        } else {
          // 生成 Token
          tokens.push({ type: 'NUMBER', value: input.slice(start, pos) });
          state = 'START';
        }
        break;
        
      // ... 其他状态类似
    }
  }
  return tokens;
}

我的经验: 状态切换时,一定要处理好「回退」逻辑。比如数字读完后,当前字符不是数字,但可能是运算符。这时候不能直接 pos++,要让它回到 START 状态重新判断。我曾经在这上面栽过跟头——少回退一个字符,整个解析就乱了。

4.2.2 善用「前瞻字符」

有些场景下,只看当前字符不够。比如解析 >=>,你得看下一个字符是不是 =

我习惯用一个 peek() 函数:

function peek(pos, input) {
  return pos + 1 < input.length ? input[pos + 1] : null;
}

这样在遇到 > 时,看一眼 peek() 是不是 =,是的话就一起消费掉。否则只消费 >

4.2.3 错误恢复要「温柔」

词法分析器遇到非法字符怎么办?直接抛异常?我建议不要。更好的做法是:记录错误,然后跳过这个字符继续解析。

case 'START':
  if (isValid(ch)) {
    // 正常处理
  } else {
    // 记录错误,但继续
    errors.push(`第 ${pos} 位置出现非法字符: ${ch}`);
    pos++; // 跳过
  }
  break;

注意: 错误恢复不是万能的。如果错误太多,解析结果可能毫无意义。我一般会设置一个错误数量上限,超过上限就停止解析。

4.3 实战:一个简易 JSON 词法分析器

光说不练假把式。咱们手写一个解析 JSON 的简易词法分析器。JSON 的 Token 类型不多:字符串、数字、布尔值、null、以及各种符号。

function jsonTokenizer(input) {
  let pos = 0;
  let tokens = [];
  let errors = [];
  
  while (pos < input.length) {
    let ch = input[pos];
    
    // 跳过空白
    if (ch === ' ' || ch === '\n' || ch === '\t') {
      pos++;
      continue;
    }
    
    // 字符串
    if (ch === '"') {
      let start = pos;
      pos++; // 跳过开头的引号
      while (pos < input.length && input[pos] !== '"') {
        if (input[pos] === '\\') pos++; // 转义字符
        pos++;
      }
      if (pos >= input.length) {
        errors.push('字符串未闭合');
        break;
      }
      pos++; // 跳过结尾的引号
      tokens.push({ type: 'STRING', value: input.slice(start + 1, pos - 1) });
      continue;
    }
    
    // 数字
    if (ch >= '0' && ch <= '9' || ch === '-') {
      let start = pos;
      if (ch === '-') pos++;
      while (pos < input.length && input[pos] >= '0' && input[pos] <= '9') pos++;
      if (pos < input.length && input[pos] === '.') {
        pos++;
        while (pos < input.length && input[pos] >= '0' && input[pos] <= '9') pos++;
      }
      tokens.push({ type: 'NUMBER', value: parseFloat(input.slice(start, pos)) });
      continue;
    }
    
    // 关键字 true/false/null
    if (ch === 't' && input.slice(pos, pos + 4) === 'true') {
      tokens.push({ type: 'BOOLEAN', value: true });
      pos += 4;
      continue;
    }
    if (ch === 'f' && input.slice(pos, pos + 5) === 'false') {
      tokens.push({ type: 'BOOLEAN', value: false });
      pos += 5;
      continue;
    }
    if (ch === 'n' && input.slice(pos, pos + 4) === 'null') {
      tokens.push({ type: 'NULL', value: null });
      pos += 4;
      continue;
    }
    
    // 符号
    if ('{}[],:'.includes(ch)) {
      tokens.push({ type: 'SYMBOL', value: ch });
      pos++;
      continue;
    }
    
    // 非法字符
    errors.push(`位置 ${pos} 发现非法字符: ${ch}`);
    pos++;
  }
  
  return { tokens, errors };
}

关键点: 注意看代码中每个分支最后都有 continuepos++。这是为了防止死循环。我见过有人忘记写 pos++,结果程序卡在同一个字符上无限循环——嗯,调试起来很酸爽。

4.4 代码实现技巧总结

最后,我把手工实现词法分析器的技巧整理成一张表,方便你对照:

技巧 说明 我的建议
状态驱动 用状态变量控制当前解析模式 状态不要太多,5-8个以内最佳
指针推进 一个 pos 变量从头扫到尾 每次只推进一个字符,除非确定是复合 Token
前瞻字符 用 peek() 看下一个字符 不要滥用,只在必要时使用
错误恢复 记录错误,跳过非法字符 设置错误上限,避免无限循环
先画图再写码 状态转换图是代码的蓝图 画图花10分钟,省下2小时调试时间

嗯,手工写词法分析器其实没那么玄乎。核心就是:画好状态图,管好指针,处理好边界情况。你按这个思路走,基本不会出大问题。

我曾经在一个模板引擎项目里,用这套方法手写了一个词法分析器,处理了变量、循环、条件判断等十几种 Token,总共也就三百多行代码。跑起来比用工具生成的还快——因为手工写的可以针对特定场景做优化。

所以别怕手工实现。它让你对解析过程有完全的控制权,出了问题也知道去哪找。这才是真正的「硬核」能力。


专注资料整理