"我能写出 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 都带 line 和 col。当 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