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 实现分词函数
分词函数的核心逻辑其实很简单:
- 初始化一个指针
pos = 0,指向源码开头 - 循环,直到
pos到达源码末尾 - 在当前位置,依次尝试每个正则规则
- 如果匹配成功,生成一个 Token,指针后移匹配的长度
- 如果所有规则都匹配失败,报错
来看代码:
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 范围 |
`hello ${name}`,里面的 ${} 会被误识别成操作符和标点。后来我加了一个「模板字符串状态」,遇到反引号就切换到特殊模式,直到匹配到结束的反引号才切回来。这种「状态机」的思路,后面章节会详细讲。
2.5 知识体系总览
这一章的内容,我用一张图帮你理清脉络:
说白了,词法分析器就是一个「模式匹配器」。它拿着你定义好的正则规则,一行一行、一个字符一个字符地去比对。匹配上了就生成 Token,匹配不上就报错。
嗯,这一章的内容就到这里。代码虽然简单,但它是后面所有高级功能的基础。你想想看,如果没有这一步,语法高亮、智能提示、代码补全……全都无从谈起。
下一章,我们会在这个基础上,加入「状态机」的概念,让词法分析器能处理更复杂的语言结构,比如嵌套的括号、多行注释、模板字符串等。到时候你会发现,今天的这些基础,会像积木一样被一块块搭起来。