正则表达式入门:语法、字符类、量词、分组与捕获

说实话,很多前端开发者对正则表达式又爱又恨。爱的是它处理字符串时那种「一刀切」的快感,恨的是那堆符号看着像天书。我当年刚接触时也这样,直到在写词法分析器时被逼着啃透了它——嗯,后来发现这东西其实没那么玄乎。

正则表达式,说白了就是一套描述字符串模式的规则。你给它一个模式,它去文本里找匹配的内容。咱们做语法分析器,第一步就是靠它来识别各种 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. 知识体系总览

下面这张图是我自己梳理的正则表达式知识结构,你可以把它当作学习地图:

正则表达式 字符类 [abc] [^abc] [a-z] \d \w \s . 量词 * + ? {n,m} 贪婪 vs 非贪婪 分组与捕获 ( ) 捕获分组 (?: ) 非捕获 位置锚点 ^ $ \b \B 在词法分析器中的应用 识别关键字 | 匹配标识符 | 解析数字常量 | 跳过注释

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,先想想能不能用正则一把梭。


专注资料整理