Vue 源码中一行正则让你学会80%正则语法

427 阅读8分钟

最近重看 Vue 源码,发现一段很长很复杂的正则表达式,正则虽然博大精深,但是直接看语法总会不知所谓,所以干脆借着这段代码,重温一下正则表达式语法。

背景

在 HTML 标签中,通常包含各种属性。这些属性主要有四种形式:

  1. 双引号包裹的属性值:<div class="example"></div>
  2. 单引号包裹的属性值:<div class='example'></div>
  3. 无引号的属性值:<div class=example></div>
  4. 无值的布尔属性:<input disabled />

现在需要一个正则,能同时匹配 class="example" class='example' class=exampledisabled 这四种形式,并提取出属性名和属性值。

为了准确匹配这些不同形式的属性,Vue 使用了一个复杂的正则表达式。接下来让我们来分析这个正则表达式:

const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/

看不懂很正常,我们后面会一步一步解析,首先我们给正则上色来更容易看清:

image.png

这个正则表达式可以分解为以下几个部分

第一部分:匹配任意个空白字符

^\s* 表示匹配属性名之前,任意个空白字符。

  • ^ 它是一个锚点(anchor),指定匹配必须从输入字符串的起始位置开始。

    锚字符

    有一些正则表达式的元素不用来匹配实际的字符,而是匹配指定的位置。我们称这些元素为正则表达式的锚。 正则表达式中的锚字符包括:

    • ^ 用来匹配字符串的开始,多行检索时匹配一行的开头。
    • $ 用来匹配字符串的结束,多行检索时匹配一行的结尾。
    • \b 用来匹配单词的边界,就是 \w\W 之间的位置,或者 \w 和字符串的开头或结尾之间的位置。
    • \B 匹配非单词边界的位置。

    例如 /\bJava\b/ 可以匹配 Java 却不匹配 JavaScript

  • \s* 匹配零个或多个空白字符(比如空格、制表符等),表示允许属性之前有空白字符。

    • \s 是一个特殊的字符类,用于匹配任意空白字符。

      字符类

      在正则中,如果不想匹配某一个特定的字符而是想匹配某一类字符,则需要使用字符类。

      通过将直接量字符放入方括号内,可以组成字符类(character class)。一个字符类可以匹配它所包含任意 一个 字符。如 [abc] 可以匹配 a,b,c 中任意一个字符。

      使用 ^ 作为方括号中第一个字符来定义否定字符集,它匹配所有不包含在方框括号内的字符。

      字符类可以使用连字符来表示字符范围。比如匹配小写字母[a-z],匹配任何字母和数字可以用[a-zA-Z0-9]。 一些常用的字符类,在 JavaScript 中有特殊的转义字符来表达它们。

      • [...] 匹配方括号内任意字符
      • [^...] 匹配不在方括号内任意字符
      • . 匹配除了换行符和其他 Unicode 行终止符之外的任意字符
      • \w 等价于 [a-zA-Z0-9_]
      • \W 等价于 [^a-zA-Z0-9_]
      • \s 匹配任何 Unicode 空白符
      • \S 任何非 Unicode 空白符的字符
      • \d 等价于 [0-9]
      • \D 等价于 [^0-9]
    • * 表示匹配前一项 0 次或多次,所以 \s* 表示匹配 0 次或多次空白字符。

      重复

      当一个模式需要被多次匹配的时候,正则表达式提供了表示重复的正则语法。

      • {n,m} 匹配前一项至少 n 次,但不能超过 m 次
      • {n,} 匹配前一项至少 n 次
      • {n} 匹配前一项 n 次
      • ? 匹配前一项 0 次或 1 次,等价于 {0,1}
      • + 匹配前一项 1 次或多次,等价于 {1,}
      • * 匹配前一项 0 次或多次,等价于 {0,}

第二部分:匹配属性名

([^\s"'<>\/=]+) 第一个捕获组,用于匹配属性名。

  • [^...] 是前面说的字符集中的否定字符集(negated character set),表示"匹配任何不在方括号内列出的字符",[^abc] 表示匹配除了 a、b、c 以外的任何字符。这是一种用于排除特定字符的模式匹配方法。
  • () 表示一个分组并默认捕获

    在 JavaScript 正则中,圆括号有两个作用:

    1. 分组(Grouping),圆括号可以把单独的项组合成子表达式,以便可以像一个独立的单元用 |*+ 或者 ? 对单元内的项进行处理

    2. 捕获(Capturing):默认情况下,圆括号会创建"捕获组",将匹配到的内容存储起来供后续使用:

      • 在正则表达式内部,可以通过\1\2等反向引用之前的捕获组
      // 匹配 HTML 标签对,确保开始和结束标签相同
      // \1 是对第一个捕获组的反向引用,确保结束标签与开始标签相同
      const tagRegex = /<([a-z]+)>.*?<\/\1>/;
      /<([a-z]+)>.*?<\/\1>/.test('<div>内容</div>'); // true
      /<([a-z]+)>.*?<\/\1>/.test('<div>内容</span>'); // false
      
      • 在JavaScript代码中,可以通过match()exec()方法的返回数组访问捕获的内容
      // 匹配日期格式:YYYY-MM-DD
      const dateRegex = /(\d{4})-(\d{2})-(\d{2})/;
      const text = "今天是2023-11-25";
      const match = text.match(dateRegex);
      
      console.log(match[0]); // "2023-11-25" (整个匹配)
      console.log(match[1]); // "2023" (第一个捕获组:年份)
      console.log(match[2]); // "11" (第二个捕获组:月份)
      console.log(match[3]); // "25" (第三个捕获组:日期)
      

综上,这段正则表示捕获一个或多个(+)不是空白字符(\s)、引号("')、尖括号(<>)、斜杠(/)或等号(=)的任意字符,确保提取出完整的属性名称如"class""id""disabled"

第三部分:匹配属性值

(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))? 这部分匹配 ="value" ='value' =value 和没有属性值这几种情况。

首先看到这部分最外层是这个形式: (?: ... )?

如上面所说,默认情况下,圆括号会创建"捕获组",将匹配到的内容存储起来供后续使用,而如果我们需要创建一个分组,但是不想它被捕获,可以通过 (?: ... ) 的形式。

非捕获组

非捕获组是正则表达式中的一种特殊分组语法,使用 (?:...) 表示。它与普通的捕获组 (...) 有以下关键区别:

  1. 不存储匹配内容:非捕获组不会将匹配的内容存储在内存中,也不会在结果数组中创建引用。
  2. 语法:使用 (?:pattern) 而不是 (pattern)
  3. 性能优势:由于不需要存储匹配结果,非捕获组通常比捕获组性能更好,特别是在有大量分组的复杂正则表达式中。

在分组后面添加 ?,上面重复一节有说过,表示这段内容是可选的,可以出现也可以不出现。

整个非捕获组用于处理属性值,属性值可以有,也可以没有。

3.1、等号

  • \s*(=)\s* 匹配等号和周围的空白,因为 class="example"class = "example" 都是合法的。
    • \s* 匹配零个或多个空白字符,允许等号左右有空白。
    • (=) 第二个捕获组,匹配属性名和属性值之间的等号。

3.2、属性值

(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)) 这部分用于匹配属性值,不论值是否被引号包围。

  • (?:) 是非捕获分组,上面已经说过了。

  • (?:...|...|...)| 把这个分组分成了三部分,匹配任意一部分即可。

    选择

    字符 | 用于分隔供选择的模式,匹配时会尝试从左到右匹配每一个分组,直到发现匹配项。如 /ab|bc|cd/ 可以匹配字符串'ab''bc' 和 'cd'

    • 选项1: "([^"]*)"+ - 双引号值,整个正则的第三个捕获组
      • 开始的 " 匹配一个双引号
      • ([^"]*) 是捕获组,匹配双引号内的内容(任意个非双引号字符,遇到双引号则证明属性值结束)
      • 结尾的 "+ 匹配一个或多个双引号(理论上这里只需要匹配一个双引号,猜测这里的 + 可能是处理书写不规范的情况)
    • 选项2: '([^']*)'+ - 单引号值,整个正则的第四个捕获组
      • 开始的 ' 匹配一个单引号
      • ([^']*) 是捕获组,匹配单引号内的内容(任意个非单引号字符)
      • 结尾的 '+ 匹配一个或多个单引号
    • 选项3: ([^\s"'=<>`]+) - 无引号值,整个正则的第五个捕获组
      • 这是一个捕获组,匹配一个或多个不是以下字符的任意字符:\s 空白字符," 双引号,' 单引号,= 等号,< 小于号,> 大于号,` 反引号

捕获组

这个正则一共有五个捕获组,分别表示:属性名、等号、双引号属性值、单引号属性值、无引号属性值,我们拿不同的属性来进行测试:

const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
attribute.exec(`class="example"`)
// ['class="example"', 'class', '=', 'example', undefined, undefined]
attribute.exec(`class='example'`)
// ["class='example'", 'class', '=', undefined, 'example', undefined]
attribute.exec(`class=example`)
// ['class=example', 'class', '=', undefined, undefined, 'example']
attribute.exec(`disabled`)
// ['disabled', 'disabled', undefined, undefined, undefined, undefined]

可以看到,不同形式的属性值,都会被匹配,且会匹配到不同的分组。对于无属性值的形式,则只会匹配到属性名。

总结

通过一个正则,我们学习了 “”,“字符类”,“重复”,“分组”,“捕获”,“非捕获组”,“选择”等知识点,不得不说,阅读源码确实能学到东西。

最后,如果对文章有任何问题欢迎留言:D