正则表达式入门:语法、字符类、量词、分组与捕获
说实话,很多前端开发者对正则表达式又爱又恨。爱的是它处理字符串时那种「一刀切」的快感,恨的是那堆符号看着像天书。我当年刚接触时也这样,直到在写词法分析器时被逼着啃透了它——嗯,后来发现这东西其实没那么玄乎。
正则表达式,说白了就是一套描述字符串模式的规则。你给它一个模式,它去文本里找匹配的内容。咱们做语法分析器,第一步就是靠它来识别各种 token,比如关键字、标识符、数字常量这些。
核心思想: 正则表达式 = 字符 + 量词 + 位置 + 分组
1. 字符类:匹配「这一类」字符
字符类是最基础的构建块。它告诉引擎:「我要匹配这一类字符中的任意一个」。
| 写法 | 含义 | 示例 |
|---|---|---|
[abc] |
匹配 a、b、c 中的任意一个 | /[aeiou]/ 匹配任意元音字母 |
[^abc] |
匹配除了 a、b、c 之外的字符 | /[^0-9]/ 匹配非数字 |
[a-z] |
匹配 a 到 z 范围内的字符 | /[A-Za-z]/ 匹配任意字母 |
. |
匹配除换行符外的任意字符 | /h.t/ 匹配 hat、hot、hut 等 |
\d |
匹配数字,等价于 [0-9] | /\d+/ 匹配连续数字 |
\w |
匹配单词字符(字母、数字、下划线) | /\w+/ 匹配标识符 |
\s |
匹配空白字符(空格、制表符、换行) | /\s+/ 匹配空白序列 |
我的习惯: 写词法分析时,我通常用
\d 匹配数字字面量,用 \w 匹配标识符开头部分。但要注意,\w 包含下划线,如果你要匹配纯字母,还是用 [a-zA-Z] 更稳妥。
2. 量词:控制「重复多少次」
光有字符类还不够,你得告诉引擎这个字符要出现几次。量词就是干这个的。
| 量词 | 含义 | 示例 |
|---|---|---|
* |
0 次或多次 | /ab*c/ 匹配 ac、abc、abbc... |
+ |
1 次或多次 | /ab+c/ 匹配 abc、abbc... 但不匹配 ac |
? |
0 次或 1 次 | /colou?r/ 匹配 color 或 colour |
{n} |
恰好 n 次 | /\d{4}/ 匹配 4 位数字 |
{n,} |
至少 n 次 | /\d{2,}/ 匹配至少 2 位数字 |
{n,m} |
n 到 m 次 | /\d{1,3}/ 匹配 1 到 3 位数字 |
这里有个坑,我踩过好几次——贪婪匹配。默认情况下,量词是贪婪的,它会尽可能多地匹配字符。比如字符串 "a123b",用 /\d+/ 匹配,它会一口气吃掉 "123"。但如果你用 /\d+?/(加个问号变成非贪婪),它只匹配 "1" 就停了。
我曾经踩过的坑: 在解析 HTML 标签时,用
/<.*>/ 去匹配,结果它从第一个 < 一直吃到了最后一个 >,把整个文档当成一个标签了。后来改成 /<[^>]*>/ 才解决问题。记住:能用字符类解决的问题,别依赖贪婪匹配。
3. 分组与捕获:把匹配结果「打包」
分组用圆括号 () 表示。它有两个作用:一是改变优先级,二是捕获匹配的内容供后续使用。
// 分组改变优先级
/ab+/ // 匹配 a 后面跟 1 个或多个 b
/(ab)+/ // 匹配 1 个或多个 "ab" 整体
// 捕获分组
const str = "2024-01-15";
const regex = /(\d{4})-(\d{2})-(\d{2})/;
const match = str.match(regex);
console.log(match[1]); // "2024"
console.log(match[2]); // "01"
console.log(match[3]); // "15"
在词法分析器里,分组捕获特别有用。比如你要识别一个数字常量,不光要知道它是数字,还得提取出它的整数值。我习惯把关键信息放在分组里,这样后面处理起来方便。
实用技巧: 如果你只想分组但不想捕获(不占用内存),用
(?:...) 非捕获分组。比如 /(?:\d{4})-(\d{2})/,第一个分组只用于匹配,不保存结果。
4. 知识体系总览
下面这张图是我自己梳理的正则表达式知识结构,你可以把它当作学习地图:
5. 实战:在词法分析中应用正则
光说不练假把式。咱们写个简单的 token 识别器,看看正则怎么用在实处:
// 定义 token 模式
const tokenPatterns = [
{ type: 'NUMBER', pattern: /\d+(\.\d+)?/ }, // 整数或浮点数
{ type: 'IDENT', pattern: /[a-zA-Z_]\w*/ }, // 标识符
{ type: 'OP', pattern: /[+\-*/=]/ }, // 运算符
{ type: 'PAREN', pattern: /[()]/ }, // 括号
{ type: 'WS', pattern: /\s+/, skip: true }, // 空白,跳过
];
function tokenize(input) {
let pos = 0;
const tokens = [];
while (pos < input.length) {
let matched = false;
const remaining = input.slice(pos);
for (const { type, pattern, skip } of tokenPatterns) {
const match = remaining.match(pattern);
if (match && match.index === 0) {
if (!skip) {
tokens.push({ type, value: match[0] });
}
pos += match[0].length;
matched = true;
break;
}
}
if (!matched) {
throw new Error(`位置 ${pos} 处无法识别的字符: '${input[pos]}'`);
}
}
return tokens;
}
// 测试一下
const code = "x = 42 + y";
console.log(tokenize(code));
// 输出: [
// { type: 'IDENT', value: 'x' },
// { type: 'OP', value: '=' },
// { type: 'NUMBER', value: '42' },
// { type: 'OP', value: '+' },
// { type: 'IDENT', value: 'y' }
// ]
避坑指南: 写正则匹配时,一定要用
match.index === 0 确保从当前位置开始匹配。我曾经没加这个判断,结果正则从字符串中间找到了匹配,导致 token 错位,排查了半天才发现问题。
你看,正则表达式其实就是这么几板斧:字符类定范围,量词控次数,分组做提取。掌握了这些,写词法分析器的 token 规则就手到擒来了。下回遇到字符串处理,别急着写一堆 if-else,先想想能不能用正则一把梭。