词法分析器:把源码切成 Token 流

目标

当你写下 var x = 1 + 2 * 3; 时,计算机并不知道这是"代码"。它先要被切成一块块带语义的词(Token)。本节课你亲手写一个 Lexer(词法分析器),理解"字符串 → 词流"这一步到底发生了什么。

运行环境:Node.js v18+(无需任何第三方依赖)。
运行方式:把下面代码保存为 lexer.js,执行 node lexer.js。

// lexer.js —— 把源码字符串切成 Token 流
const KEYWORDS = new Set(['var', 'if', 'else', 'while', 'func', 'return', 'print'])

function tokenize(src) {
  const tokens = []
  let i = 0
  const isDigit = (c) => c >= '0' && c <= '9'
  const isAlpha = (c) => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_'

  while (i < src.length) {
    const c = src[i]
    // 1) 跳过空白
    if (c === ' ' || c === '\n' || c === '\t' || c === '\r') { i++; continue }
    // 2) 数字(连续数字拼成一个 NUMBER)
    if (isDigit(c)) {
      const start = i
      while (i < src.length && isDigit(src[i])) i++
      tokens.push({ type: 'NUMBER', value: Number(src.slice(start, i)) })
      continue
    }
    // 3) 标识符 / 关键字(字母或下划线开头,后接字母数字)
    if (isAlpha(c)) {
      const start = i
      while (i < src.length && (isAlpha(src[i]) || isDigit(src[i]))) i++
      const word = src.slice(start, i)
      if (KEYWORDS.has(word)) tokens.push({ type: 'KEYWORD', value: word })
      else tokens.push({ type: 'IDENT', value: word })
      continue
    }
    // 4) 单字符运算符
    const one = {
      '+': 'PLUS', '-': 'MINUS', '*': 'STAR', '/': 'SLASH',
      '(': 'LPAREN', ')': 'RPAREN', '=': 'EQ', ';': 'SEMI',
    }[c]
    if (one) { tokens.push({ type: one, value: c }); i++; continue }
    throw new Error('无法识别的字符: ' + c + ' (位置 ' + i + ')')
  }
  tokens.push({ type: 'EOF', value: null }) // 哨兵:表示源码结束
  return tokens
}

// ===== 调用示例 =====
const code = 'var x = 1 + 2 * 3;'
console.log(JSON.stringify(tokenize(code)))

运行后你会看到一串 Token:KEYWORD(var)、IDENT(x)、EQ(=)、NUMBER(1)、PLUS(+)……这正是编译器读你代码时"看到"的东西。

名词解释

Token(词法单元):源码里不可再分的最小语义单位,比如一个数字 42、一个变量名 x、一个运算符 +。它通常带 type(是什么种类的词)和 value(具体值)。
Lexer / 词法分析器:把原始字符流切成 Token 流的程序。它解决"哪里是一个词的边界"的问题,但不关心词与词之间的语法关系(那是 Parser 的活)。
常用方法:tokenize(src) 输入源码、输出 Token 数组;很多实现还会顺手跳过空白与注释(本例跳过空白)。

课后练习

练习:给 Lexer 增加对 ==(双等号相等判断)的识别,使其输出一个 OP(==) 的 Token。

答案:把 = 单独处理,遇到 == 合并成一个 Token;下面是可独立运行的小例子:

function tokenizeWithEq(src) {
  const tokens = []; let i = 0
  while (i < src.length) {
    const c = src[i]
    if (c === '=') {
      if (src[i + 1] === '=') { tokens.push({ type: 'OP', value: '==' }); i += 2; continue }
      tokens.push({ type: 'EQ', value: '=' }); i++; continue
    }
    tokens.push({ type: 'CHAR', value: c }); i++; continue
  }
  return tokens
}
console.log(tokenizeWithEq('a == b'))

总结

词法分析是所有编程语言处理的第一道门。它看似只是"切字符串",却奠定了对整个语言的第一个判断:哪些字符组合是合法的"词"。现实中你几乎不会手写 Lexer——会用正则工具(如 Flex)或现成的 tokenizer,但亲手写一遍的价值在于:你会真正理解"为什么 1..0 在某些语言里是语法错误""为什么注释能被安全删掉而不影响语义"。Token 流是把"给人读的文本"翻译成"给机器处理的结构"的关键一步,没有它,后面的语法树就无从建起。