手写词法分析器完全指南:200 行实现一个能用的 Tokenizer

"我能写出 React,但写不出 webpack。"很多前端工程师有这种感觉 —— 工具链里"编译器/解析器"部分像一道高墙。其实从零写一个词法分析器,门槛比想象低得多。这篇文章带你写一个能解析"小型表达式语言"的词法分析器,代码不到 200 行,但概念全覆盖,看完你就能读懂 babel、postcss、esbuild 的源码思路。

词法分析在编译流程里的位置

典型编译器流程:

源代码 (string)
   ↓ 词法分析(lexer / tokenizer / scanner)
Token 流  [{type: 'NUM', value: 1}, {type: '+'}, {type: 'NUM', value: 2}, ...]
   ↓ 语法分析(parser)
AST (抽象语法树)
   ↓ 语义分析 / 类型检查
带类型 AST
   ↓ 代码生成 / 解释执行
机器码 / 字节码 / 输出代码

词法分析的工作很纯粹:把字符流切成有意义的"词"(Token)。它不关心语法对不对,只把每段字符贴上类型标签:这是数字、那是标识符、这是加号、那是字符串字面量。

要解析的目标语言

为了讲清楚,我们做一个最小语言,支持:

// 表达式
1 + 2 * 3
(1 + 2) * 3
// 标识符与赋值
let x = 42
x = x + 1
// 字符串
"hello world"
// 关键字
if true then x else y
// 注释
// 这是单行注释

Token 设计

// 把语言里能出现的"基本单位"全部枚举
const TokenType = {
    NUMBER:    'NUMBER',
    STRING:    'STRING',
    IDENT:     'IDENT',         // 标识符
    KEYWORD:   'KEYWORD',
    PLUS:      'PLUS',          // +
    MINUS:     'MINUS',         // -
    STAR:      'STAR',          // *
    SLASH:     'SLASH',         // /
    EQ:        'EQ',            // =
    LPAREN:    'LPAREN',        // (
    RPAREN:    'RPAREN',        // )
    EOF:       'EOF',
};

// 关键字集合 —— 形态上是 IDENT,但语义不同,扫到时单独标记
const KEYWORDS = new Set(['let', 'if', 'then', 'else', 'true', 'false']);

function makeToken(type, value, line, col) {
    return { type, value, line, col };
}

注意一个常见设计:关键字本质是特殊的标识符。词法器先按"标识符"的规则识别,得到一段字符,再查它是不是关键字 —— 这比为每个关键字单独写正则要灵活,新加关键字只需在集合里加一项。

核心实现:扫描器

主体是一个循环,每次根据当前字符决定下一步:

class Lexer {
    constructor(source) {
        this.src = source;
        this.pos = 0;
        this.line = 1;
        this.col = 1;
        this.tokens = [];
    }

    // 工具方法:看当前字符但不前进
    peek(offset = 0) {
        return this.src[this.pos + offset];
    }
    // 前进一格
    advance() {
        const ch = this.src[this.pos++];
        if (ch === '\n') { this.line++; this.col = 1; }
        else { this.col++; }
        return ch;
    }
    // 期望特定字符,不匹配就报错
    expect(ch) {
        if (this.peek() !== ch) {
            throw new Error(`line ${this.line}: expected '${ch}', got '${this.peek()}'`);
        }
        this.advance();
    }

    tokenize() {
        while (this.pos < this.src.length) {
            const ch = this.peek();

            // 跳过空白
            if (/\s/.test(ch)) { this.advance(); continue; }

            // 注释:// 到行尾
            if (ch === '/' && this.peek(1) === '/') {
                while (this.pos < this.src.length && this.peek() !== '\n') this.advance();
                continue;
            }

            // 数字
            if (/\d/.test(ch)) { this.tokens.push(this.readNumber()); continue; }
            // 字符串
            if (ch === '"')   { this.tokens.push(this.readString()); continue; }
            // 标识符或关键字
            if (/[a-zA-Z_]/.test(ch)) { this.tokens.push(this.readIdent()); continue; }

            // 单字符运算符
            const startLine = this.line, startCol = this.col;
            const single = {
                '+': TokenType.PLUS, '-': TokenType.MINUS,
                '*': TokenType.STAR, '/': TokenType.SLASH,
                '=': TokenType.EQ,
                '(': TokenType.LPAREN, ')': TokenType.RPAREN,
            };
            if (single[ch]) {
                this.advance();
                this.tokens.push(makeToken(single[ch], ch, startLine, startCol));
                continue;
            }
            throw new Error(`line ${this.line}: unexpected character '${ch}'`);
        }
        this.tokens.push(makeToken(TokenType.EOF, null, this.line, this.col));
        return this.tokens;
    }

    readNumber() {
        const startLine = this.line, startCol = this.col;
        let s = '';
        while (/\d/.test(this.peek())) s += this.advance();
        // 小数
        if (this.peek() === '.' && /\d/.test(this.peek(1))) {
            s += this.advance();   // 吃掉 '.'
            while (/\d/.test(this.peek())) s += this.advance();
        }
        return makeToken(TokenType.NUMBER, parseFloat(s), startLine, startCol);
    }

    readString() {
        const startLine = this.line, startCol = this.col;
        this.expect('"');
        let s = '';
        while (this.peek() !== '"') {
            if (this.pos >= this.src.length) throw new Error('unterminated string');
            // 转义
            if (this.peek() === '\\') {
                this.advance();
                const esc = this.advance();
                s += { 'n': '\n', 't': '\t', '"': '"', '\\': '\\' }[esc] ?? esc;
            } else {
                s += this.advance();
            }
        }
        this.expect('"');
        return makeToken(TokenType.STRING, s, startLine, startCol);
    }

    readIdent() {
        const startLine = this.line, startCol = this.col;
        let s = '';
        while (/[a-zA-Z0-9_]/.test(this.peek())) s += this.advance();
        // 是关键字吗?
        const type = KEYWORDS.has(s) ? TokenType.KEYWORD : TokenType.IDENT;
        return makeToken(type, s, startLine, startCol);
    }
}

// 试用
const code = `
let x = 42
if x > 10 then "big" else "small"
`;
const tokens = new Lexer(code).tokenize();
console.log(tokens);

扫描的几个关键决策

1. 前瞻(lookahead):看多远

大多数 token 用单字符前瞻就够(+(),但有些要看两个字符:// 是注释起始(不是除法),== 是判等(不是赋值),>= 是大于等于。所以扫描器一般实现 peek(0)peek(1)。看更多字符也行,只是会慢一点。

// 加上 == 和 !=
const ch = this.peek();
if (ch === '=' && this.peek(1) === '=') {
    this.advance(); this.advance();
    this.tokens.push(makeToken('EQEQ', '=='));
    continue;
}
if (ch === '!' && this.peek(1) === '=') {
    this.advance(); this.advance();
    this.tokens.push(makeToken('BANGEQ', '!='));
    continue;
}

2. 最长匹配原则

=== 都合法时,词法器应该匹配更长的。这是几乎所有词法分析器的默认规则。注意上面代码里我们先判断 ==(2 字符)再判断 =(1 字符),顺序不能反。

3. 错误信息要带位置

没人愿意看"syntax error",大家要的是 line 12, col 7: unexpected '$'。所以每个 token 都带 linecol。当 parser 后续报错时,把这两个字段一起报出来,用户体验立刻不一样。

用正则一次性切?可以,但要谨慎

有些教程用一个大正则把所有 token 切出来:

const re = /(\d+(?:\.\d+)?)|("[^"]*")|([a-zA-Z_]\w*)|(==|!=|<=|>=|[+\-*\/=<>()])|(\/\/[^\n]*)|(\s+)/g;
let m;
while ((m = re.exec(code))) {
    if (m[1]) tokens.push({ type: 'NUMBER', value: parseFloat(m[1]) });
    else if (m[2]) tokens.push({ type: 'STRING', value: m[2].slice(1, -1) });
    else if (m[3]) tokens.push({
        type: KEYWORDS.has(m[3]) ? 'KEYWORD' : 'IDENT', value: m[3]
    });
    else if (m[4]) tokens.push({ type: 'OP', value: m[4] });
    // m[5] 注释,m[6] 空白:跳过
}

这种方式短,但有两个问题:行列号难以维护;错误位置不准。生产级词法器都是手写 + 状态机,不靠正则。

parser:把 token 流变成 AST

词法分析的产物喂给 parser。下面用递归下降方法实现一个简单的表达式 parser,演示词法和语法的衔接:

// 文法:
// expr   := term (('+' | '-') term)*
// term   := factor (('*' | '/') factor)*
// factor := NUMBER | IDENT | '(' expr ')'

class Parser {
    constructor(tokens) { this.tokens = tokens; this.pos = 0; }
    peek()    { return this.tokens[this.pos]; }
    advance() { return this.tokens[this.pos++]; }
    eat(type) {
        if (this.peek().type !== type) throw new Error(`expected ${type}, got ${this.peek().type}`);
        return this.advance();
    }

    parseExpr() {
        let node = this.parseTerm();
        while (['PLUS', 'MINUS'].includes(this.peek().type)) {
            const op = this.advance();
            const right = this.parseTerm();
            node = { type: 'BinaryExpr', op: op.value, left: node, right };
        }
        return node;
    }
    parseTerm() {
        let node = this.parseFactor();
        while (['STAR', 'SLASH'].includes(this.peek().type)) {
            const op = this.advance();
            const right = this.parseFactor();
            node = { type: 'BinaryExpr', op: op.value, left: node, right };
        }
        return node;
    }
    parseFactor() {
        const t = this.peek();
        if (t.type === 'NUMBER') { this.advance(); return { type: 'Number', value: t.value }; }
        if (t.type === 'IDENT')  { this.advance(); return { type: 'Ident', name: t.value }; }
        if (t.type === 'LPAREN') {
            this.advance();
            const node = this.parseExpr();
            this.eat('RPAREN');
            return node;
        }
        throw new Error(`unexpected token ${t.type}`);
    }
}

// 完整流程
const tokens = new Lexer('1 + 2 * (3 + 4)').tokenize();
const ast = new Parser(tokens).parseExpr();
console.log(JSON.stringify(ast, null, 2));

这种"先 lexer 再 parser"的分工是经典的、被工业级编译器普遍采用的设计。也有些项目把它们合在一起(scannerless parser),但只在特定文法下才值得。

用 token 流做语法高亮

词法分析的另一个常见用途:语法高亮。每个 token 是什么类型,你就给它套一个 CSS 类。下面这段代码足以做一个最简单的高亮器:

function highlight(code) {
    const tokens = new Lexer(code).tokenize();
    return tokens.filter((t) => t.type !== 'EOF').map((t) => {
        const cls = ({
            NUMBER:  'token-number',
            STRING:  'token-string',
            KEYWORD: 'token-keyword',
            IDENT:   'token-ident',
        })[t.type] || 'token-op';
        const text = t.type === 'STRING' ? `"${t.value}"` : String(t.value);
        return `<span class="${cls}">${text}</span>`;
    }).join(' ');
}

VSCode、Prism.js、highlight.js 的工作机制本质就是这套,只是更复杂(支持几百种语言、处理大文件性能优化、增量更新)。

生产级词法器的额外考量

  • 性能:对大文件,正则版会慢得明显。手写状态机 + 字符表分发(switch case)能跑得很快。esbuild 用 Go 写就是看中这一点。
  • 错误恢复:碰到非法字符不应该立刻 throw,而是产生一个 ERROR token 继续扫描,让 parser 一次性报告多个错误。IDE 体验靠这个 —— 一行写错不会让整个文件失去高亮和提示。
  • Unicode:标识符可以含中文吗?字符串能放 emoji 吗?现代语言通常用 \p{ID_Start} / \p{ID_Continue} 规则,涵盖 Unicode 各类字符。
  • 增量词法:IDE 里改一行代码不应该让整个文件重新词法分析。Tree-sitter 这类增量解析器是这个方向的代表。

写在最后

词法分析不是高深魔法 —— 它是"按规则识别字符片段"的机械工作。掌握 200 行的入门版后,你能立刻看懂 esbuild / babel / postcss 的 scanner 模块,也能为你自己的 DSL、模板引擎、配置语言写一个 parser。

给学习者一个具体练习:把上面这个 Lexer + Parser 跑起来,然后扩展成完整的小语言 —— 加 if/else 的语法树、加变量赋值、加函数调用,最后写一个 evaluator 让 AST 能执行。整个过程不超过 500 行,但你会完整地走过编译器前后端,以后看到"编译原理"四个字不再觉得遥远。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

垃圾回收机制详解:从引用计数到三色标记的完全指南

2026-5-15 11:12:41

技术教程

RSA 加密算法详解:从数学原理到生产级代码

2026-5-15 11:21:09

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索