2. 构建简单的词法分析器:定义Token类型、编写正则规则、实现分词函数、处理边界情况

好,咱们正式开始动手了。

上一章我们聊了词法分析是干啥的——说白了,就是把源代码这串长长的字符,拆成一个个有意义的「词法单元」,也就是 Token。这一章,我们就亲手搭一个最简单的词法分析器出来。

别怕,代码量不大,核心逻辑就三块:定义Token类型 → 写正则规则 → 实现分词函数。再加上处理边界情况,基本就齐活了。

2.1 定义Token类型

Token 是什么?你可以把它想象成「带标签的单词」。每个 Token 有两个属性:type(类型)和 value(值)。

比如 let x = 42; 这句话,拆开来看:

  • let → 关键字
  • x → 标识符
  • = → 操作符
  • 42 → 数字字面量
  • ; → 分隔符

嗯,这就是我们要定义的 Token 类型。我个人习惯用枚举或者常量对象来定义,清晰明了。

// Token 类型定义
const TokenType = {
  KEYWORD: 'KEYWORD',       // 关键字:let, const, function...
  IDENTIFIER: 'IDENTIFIER', // 标识符:变量名、函数名
  NUMBER: 'NUMBER',         // 数字:整数、浮点数
  STRING: 'STRING',         // 字符串:'hello', "world"
  OPERATOR: 'OPERATOR',     // 操作符:+ - * / = ==
  PUNCTUATION: 'PUNCTUATION', // 标点:; , ( ) { }
  WHITESPACE: 'WHITESPACE', // 空白:空格、换行(通常忽略)
  EOF: 'EOF'                // 文件结束
};
我的小建议: 实际项目中,我还会加一个 COMMENT 类型来处理注释。虽然注释在语法分析阶段会被忽略,但词法分析阶段最好还是识别出来,方便做代码格式化或者统计。

2.2 编写正则规则

Token 怎么识别?靠正则表达式。每个 Token 类型对应一个或多个正则模式。

举个例子:

  • 数字:/\d+(\.\d+)?/ —— 匹配整数或浮点数
  • 标识符:/[a-zA-Z_$][a-zA-Z0-9_$]*/ —— 变量名规则
  • 关键字:其实可以先按标识符匹配,再查表判断是不是关键字

这里有个坑,我踩过。正则的顺序很重要!比如 ===,如果你先匹配 =,那 == 就被拆成两个 = 了。所以规则要按「最长匹配优先」来排。

// 正则规则表(按优先级从高到低)
const rules = [
  { type: TokenType.NUMBER,      regex: /^\d+(\.\d+)?/ },
  { type: TokenType.IDENTIFIER,  regex: /^[a-zA-Z_$][a-zA-Z0-9_$]*/ },
  { type: TokenType.OPERATOR,    regex: /^(==|!=|<=|>=|&&|\|\||[+\-*/=<>!])/ },
  { type: TokenType.PUNCTUATION, regex: /^[;,\\(\\)\\{\\}]/ },
  { type: TokenType.WHITESPACE,  regex: /^\s+/ },
];
关键点: 每个正则前面都加了 ^,表示从当前位置开始匹配。这是分词的核心技巧——我们只关心「当前指针指向的字符」能匹配什么。

2.3 实现分词函数

分词函数的核心逻辑其实很简单:

  1. 初始化一个指针 pos = 0,指向源码开头
  2. 循环,直到 pos 到达源码末尾
  3. 在当前位置,依次尝试每个正则规则
  4. 如果匹配成功,生成一个 Token,指针后移匹配的长度
  5. 如果所有规则都匹配失败,报错

来看代码:

function tokenize(sourceCode) {
  const tokens = [];
  let pos = 0;

  while (pos < sourceCode.length) {
    let matched = false;

    for (const rule of rules) {
      const match = sourceCode.slice(pos).match(rule.regex);
      if (match) {
        // 跳过空白符
        if (rule.type !== TokenType.WHITESPACE) {
          tokens.push({
            type: rule.type,
            value: match[0],
            position: pos
          });
        }
        pos += match[0].length;
        matched = true;
        break;
      }
    }

    if (!matched) {
      throw new Error(`词法错误:位置 ${pos} 处无法识别的字符 '${sourceCode[pos]}'`);
    }
  }

  // 添加结束标记
  tokens.push({ type: TokenType.EOF, value: null, position: pos });
  return tokens;
}

你看,核心代码不到 30 行。但就是这 30 行,撑起了整个前端语法高亮的基石。

2.4 处理边界情况

写代码最怕什么?边界情况。我当年第一次写词法分析器时,就被几个边界问题坑得不轻。这里列几个常见的:

边界情况 问题描述 解决方案
空字符串 输入为空时,直接返回空 Token 列表 在循环前加 if (!sourceCode) return []
连续操作符 ++-- 被拆成两个单字符 优先匹配多字符操作符(如 ++
字符串字面量 "hello world" 中的空格不能当空白处理 字符串匹配要单独处理,遇到引号后一直匹配到下一个引号
注释 // 这是一行注释 中的内容不该被解析 添加注释规则,匹配后直接跳过
Unicode 字符 中文变量名 let 变量 = 1 标识符正则加上 \uXXXX 范围
我曾经踩过的坑: 有一次处理 JavaScript 模板字符串 `hello ${name}`,里面的 ${} 会被误识别成操作符和标点。后来我加了一个「模板字符串状态」,遇到反引号就切换到特殊模式,直到匹配到结束的反引号才切回来。这种「状态机」的思路,后面章节会详细讲。

2.5 知识体系总览

这一章的内容,我用一张图帮你理清脉络:

词法分析器构建流程 源代码字符串 步骤1:定义 Token 类型 步骤2:编写正则规则 步骤3:实现分词函数 边界情况处理 • 空字符串 • 连续操作符 • 字符串字面量 • 注释跳过 • Unicode 字符 • 模板字符串

说白了,词法分析器就是一个「模式匹配器」。它拿着你定义好的正则规则,一行一行、一个字符一个字符地去比对。匹配上了就生成 Token,匹配不上就报错。

嗯,这一章的内容就到这里。代码虽然简单,但它是后面所有高级功能的基础。你想想看,如果没有这一步,语法高亮、智能提示、代码补全……全都无从谈起。

下一章,我们会在这个基础上,加入「状态机」的概念,让词法分析器能处理更复杂的语言结构,比如嵌套的括号、多行注释、模板字符串等。到时候你会发现,今天的这些基础,会像积木一样被一块块搭起来。


专注资料整理