前端为什么要会正则表达式

在笔者学生时代,一直单纯地认为正则都是后端老哥的事儿,前端只要 split 一把梭就够了。万不得已的时候就网上搜几段正则代码,copy 一下也能用。

这是维基百科上正则表达式的词条,截图只截了一屏,事实上这张正则表达式的表格两个屏幕都装不下。。。我第一次查的时候立马就弃了,于是很长一段时间里都在原地踏步,只会用用 ^ $ . *

最初见到正则表达式是在表单验证里,多少会用些 validate 的库,基本的电话 / 邮箱之类的校验都有现成的,真正自己写正则去校验输入格式的机会并不多。后来渐渐发觉正则表达式的魔力,它可以是前端工程师的一把利器。

日常琐事

1、老项目迁移,所有的 T.dom.getElementById('abc') 代码都要改成新的写法 $('#abc')

2、组件库升级,所有的 <el-dialog v-model="a" 必须改成 <el-dialog :visible.sync="a"

都是真实工作中的脏活累活,故事 1 中的项目有近 100 个页面,由于 T 库弃用了,不仅 T.dom.getElementById 还有 getElementByClass 等等调用都要改成 jquery 的写法。如果完全靠人肉,那是多么的苦力。

故事 2 中的组件库其实就是我们的 Element,我们原先很多项目都是 Element 1.x,要升级到 2.x,这个对话框的 breaking change 影响还挺大的,在 2.x 中通过 v-model 是无法唤起对话框的。因此要确保每个 el-dialog 都检查一遍,而模板代码里 el-dialogv-model 可能不在第一个,属性多的时候还会换行,都需要火眼金睛。

聪明的读者肯定知道,靠人肉是个没有办法的办法,而且看多了也会眼花,最好还要 double check。虽然写正则表达式去找,也不能保证 100% 都覆盖,毕竟老项目里各种迷之代码都有,但正则能帮我们找出大部分,并且 replace 的时候也能避免输入错误,这样可以把精力放在 double check 上。

正则起源

正则表达式源于形式语言与自动机理论,关于形式化的内容不是本文的重点,感兴趣的朋友可以去 wiki 上查。

正则引擎的基础就是状态机,在编译原理中你一定会听说 NFA (非确定有限状态自动机) 与 DFA (确定有限状态自动机),关于 NFA / DFA 通俗的解释可以参考这篇文章

正则表达式最早在 Unix 中的 sed 和 grep 命令中开始普及。如果你用过 sed "s/aaa/bbb/" 的话,其实可以写正则表达式来做更复杂的文本处理。

而在编程语言中,Perl 是第一个实现正则表达式引擎的语言。目前大部分语言都使用基于 NFA 的正则引擎。

怎么学正则

笔者走了一些弯路,先在实战中写正则,不会写就查正则的语法表,渐渐地大部分语法也就都记住了,需要查表的也越来越少了。其实我觉得正确的学习方式应该先完整地看一本入门书籍,对整体有宏观把控后,再在实战中反复练习和查表,回头再重看书,这样的记忆应该最深。

如果你还在为正则表达式的语法而健忘的话,推荐一本《正则表达式必知必会》,这本书只有 100 页,一个周末就能看完。以后再查语法表的时候,就会越来越有规律可循。

正则表达式的语法可分为这几大类

  • 匹配字符
  • 匹配位置
  • 重复与贪婪
  • 分组与捕获
  • 前后断言

本文并不介绍正则表达式语法,只通过实际工作的例子来说明正则的用途。可以先根据以上几个分类,在正则表达式的语法表里再过一遍。

1、URL里的暗号

在公司的开发环境中,一般都会有两套以上的测试环境,如 alpha 和 beta 环境。而在前端页面,我们通常使用不同的 URL 来区分,例如 jack.alpha.domain.comjack.beta.domain.com 分别对应 alpha 和 beta 环境,而 jack.domain.com 则是生产环境。问题来了,前端代码是同一份,它需要根据当前 hostname 所属的环境来调用不同环境的接口。

当然这个问题也是 split 一把梭就能解决的,但这并不优雅。回到正则表达式上,我们先分析需求,假设公司一级域名只有 domain.com 的,那么这个问题就是提取 appid (例子中的 jack) 与 .domain.com 中间的部分。

1
2
3
var reg = /\w+\.(\w+\.)?domain.com/;
var match = 'jack.alpha.domain.com'.match(reg);
console.log(match[1]); // alpha.

如上代码的正则表达式中,关键是 (\w+\.)? 这段,它表示匹配零次或一次,并且小括号是分组,可在 match 的结果中直接捕获到分组里的值。

补充1:分组里拿到的结果是 alpha.,多了个 . 不走心,怎么把它去掉呢?可以改成 ((\w+)\.)? 这样 alpha 就要在第 2 组捕获里拿到了,因为第 1 组仍是 alpha.

补充2:试试 (?:(\w+)\.)? 这里 (?:) 表示不产生分组号,于是仍可在第 1 组捕获中拿到 alpha

补充3:\w 匹配字母数字和下划线,如果你的 hostname 中包含其他字符,比如扩充成 [\w-] 就可以包含中划线 -

补充4:加入公司的一级域名有多个,那也可以对 domain.com 再进行扩充。

1
2
3
4
5
var reg = /\w+\.(?:(\w+)\.)?(domain|abc|xyz).com/;
var match = 'jack.alpha.domain.com'.match(reg);
console.log(match[1]); // alpha
match = 'jack.domain.com'.match(reg)
console.log(match[1]); // undefined (分组捕获不到则表示生产环境)

正则表达式的书写是逐步细化的,还是要先分析好需求,写的不多余。

2、引号替换

在一些老项目里 JavaScript 代码经常存在单引号双引号混用,很不规范,让以后的维护者看着很累。虽然现在我们都会使用 ESLint 来自动修复,还是想举这个例子来作为正则表达式的一个 case

1
2
3
4
5
6
7
8
9
{
"a": {
"a1": "a1",
"a2": {
"b1": 'b1'
}
},
'b': "b"
}

现在希望把这段 JSON 对象改成 key 不带引号,value 都为单引号。观察下这份数据,key 和 value 都有单引号和双引号,于是我的想法是先把所有双引号改成单引号,然后再把 key 的单引号去掉。

双引号 → 单引号,我第一次写出来是这样的:

1
replace(/"(.*)"/g, '$1')

大部分 IDE 的 find-replace 功能都支持写正则,不妨可以试试看,上面这段正则有什么问题。

.* 也匹配了双引号,上面 "a1":"a1"".*" 匹配了,这是典型的过度匹配。解决办法是使用 [^"]* 或者使用 .*? 以表示尽可能少的重复。这就说到了正则表达式中的贪婪与惰性。

贪婪与惰性

图片示例来自《JavaScript 正则表达式迷你书》

上面这张图解释了正则引擎的匹配过程,正则表达式是 ".*",待匹配的字符串是 "abc"de

  • 第 1 步,初始化,当前状态指向正则中的第1个 ",并且开始读取待匹配字符串。
  • 第 2 步,发现正则中的第1个 " 正好能匹配字符串。
  • 第 3 步,当前状态指向正则中的 .*,并且读取字符串的指针也向前移动,巧了,a 也被匹配上了。
  • 第 4 ~ 8 步,真香真香,.* 一直匹配到字符串末尾了!坑了,我这正则 .* 后面还有个 " 要怎么办?
  • 第 9 步,读取字符串的指针回退一格。这就是正则匹配中的回溯。
  • 第 10 步,回退完发现正则最后的 " 还是没法匹配 e,于是继续回退。
  • 第 11 步,回退完还是不能匹配,再回退。
  • 第 12 步,现在读取字符串的指针在 " 上,不容易,终于匹配上了!

上述过程中,.* 是贪婪的,一开始会把字符串里的 abc"de 都匹配进去,然后正则发现这是条死路,再一步步回退,直到正则能够继续匹配下去。这个过程就是 NFA 中的回溯法。

而当使用了惰性模式后,如将 .* 改成 .*?,它会尽可能少地匹配。不妨试下以下两个例子,观察两个分组里捕获到的内容。

1
2
'12345'.match(/(\d{1,3})(\d{2})/)
'12345'.match(/(\d{1,3}?)(\d{2})/)

回溯的深渊

然而正则中的贪婪和回溯也有个天坑,那就是 ReDos,即正则表达式里的 Deny of Service。可以试试如下代码:

1
2
'aaaaaaaaaaaaaaaaaaaaaaaaaaaab'.match(/^(a+)+$/)
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaab'.match(/^(a+)+$/)

当正则表达式中出现有歧义的片段时,如 /(a+)+/,字符串里的 a 既可以被 a+ 匹配,又可以被 (a+)+ 匹配,这就容易产生问题。按照上面回溯法的原理,a+ 会先一路把 a 都匹配完,然后发现糟了,正则表达式最后还有个 +,于是一个个字符回退,直到发现 (a+)+ 能匹配时又会一路把 a 都匹配完,然后发现行尾符匹配不了,会再次回溯尝试。随着字符串长度的增加,回溯次数会呈指数级增长,终将导致 Deny of Service。

ReDos 的内容更多在安全领域,笔者只粗略了解下。平时书写正则表达式的时候尤其要留意嵌套的 *+

3、字符串去重

你可能在算法题里也见过字符串去重,去掉字符串中重复的字符。本文这里不讨论算法,想说明通过正则表达式也能判断用户的输入里有无重复字符。这里需要先学习正则里的几个武器。

反向引用 (back reference)

简单说就是在一个正则表达式中引用一个分组的子表达式。举个 HTML 标签的例子,如果要匹配 <h1></h1> 标签,可以这么写 /<(h[1-6])>[\s\S]*<\/\1>/ 这个正则可以匹配任意合法的 h 标签,其中 \1 就代表第 1 个分组的反向引用,否则就得为 h1 ~ h6 各写一个表达式就很累赘。反向引用的关键在于前面要先有分组,否则 \1 是无效的。

先行断言 (look ahead) / 后行断言 (look behind)

先行断言的语法形式为 /c(?=a)/,表示匹配 a 左边的 c。为了避免与断言中的「前」和 「后」混淆,这里就用「a 左边的 c」来表达。

先行断言有些地方也叫「前瞻断言」或「前向查找」,想象你自己就是一个指针,扫描字符串的过程就像指针在向前移动。

先行断言也有其否定形式,叫做先行否定断言,如 /c(?!a)/,即匹配「非a」左边的 c,其实这与 /c(?=[^a])/ 效果相同,感兴趣的朋友可以自己试试。

所有的正则引擎都支持先行断言和先行否定断言。到了 ES2018 才支持后行断言,细节可参考 ECMAScript 6 入门的正则章节

尤其需要提一点的是,断言中的文本不会包含在最终的匹配结果里,怎么理解呢?/c(?=a)/ 只会匹配 c(?=a) 既不会产生分组,也不会出现在 match 函数的结果中,可以试下 'cat'.match(/c(?=a)/)。容易犯的错误是不小心写成了 /c(?=a)t/ 那就 GG 了,这是个永远不可能匹配的正则表达式。

现在我们回到字符串去重的问题上,用上面的武器很容易想到,用先行断言配合反向引用。直观上的理解就是自己作为一个扫描指针,先往前面看,在远处能不能找到和当前脚下相同的字符。

1
'abcdaccd'.replace(/(.)(?=.*?\1)/g, '') // bacd

上面的字符串用正则替换后为什么是 bacd,不应该是 abcd 么?因为 /(.)(?=.*?\1)/ 匹配的是第1个 .,断言并不会出现在匹配结果里,所以理解起来就是扫描指针在当前位置时,先往前面看,如果有看到相同的字符,就把当前脚下的字符替换成空。

如果想让替换后的结果是 abcd 该怎么写正则呢?显然要用后行断言,就是扫描指针在当前位置时,往身后看,如果有看到相同的字符,就把当前脚下的字符替换成空。

1
'abcdaccd'.replace(/(.)(?<=\1.*?\1)/g, '') // abcd

看到这个正则可能又困惑了,后行断言难道不都是 /(?<=y)x/ 这么写的么?这就是黑科技了,因为用到反向引用,就得保证正则表达式中要先出现分组,所以 /(?<=\1.*?\1)(.)/ 这种表达式里的 \1 是无效的。

注:此法来自正则实现数组滤重

4、定制 .vue 单文件模板

最近在做微信小程序,每个页面都必须写 wxml / wxss / js / json 这 4 个文件,当项目里页面多的时候文件就巨多无比。假如没有用任何开发框架,可以自己定制一个单文件模板,有点类似 .vue 文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<template>
<view></view>
</template>

<script type="json">
{
usingComponents: {
}
}
</script>

<script>
Page({
// ...
});
</script>

<style lang="stylus">

</style>

现在我们的目标是把这个文件拆成模板、样式、js 和 json 配置对应的 4 个文件,抛弃原来的 split 大法或者逐行读文件,正则表达式可以帮我们优雅地解决问题。

修饰符

在前面的正则表达式中见到了 /.*/g,其中的最后那个 g 就是正则表达式的修饰符。

常用的修饰符有

  • i :ignore case
  • m :multiline
  • g :global

很好记,合起来 img,ES6 中又新增了几种修饰符,细节可参考 ECMAScript 6 入门的正则章节

多行模式 (multiline)

上面这个问题需要用到正则表达式中的多行模式,因为要在代码文件中做匹配,肯定涉及到换行。

当使用了 m 修饰符后,多行模式下会更改 ^$ 的含义,使它们分别在任意一行的行首和行尾匹配,而不是整个字符串的开头和结尾。

回到刚才的问题,如何提取 <template></template> 标签之间的内容,首先会想到 /<template>(.*)<\/template>/m,然而可以试试发现不行。这是因为 . 匹配的是除 \r \n 之外的任何单个字符,并不是所有任意字符。所以正则表达式需要改成 /<template>([\s\S]*)<\/template>/m,分组中捕获的就是标签之间的内容。

同理,匹配 <style> 标签里的样式,可以写出 /<style(?:\s+lang="(\w+)")?>([\s\S]*?)<\/style>/mg 其中 <style(?:\s+lang="(\w+)")?> 可以同时匹配 <style><style lang="stylus">,并且可以从分组捕获中拿到 lang 属性的值。另外要提的是,.vue 文件里可能会写多个 <style>,所以这个正则表达式中要使用 mg 两个修饰符,并且在标签内容的里要使用 [\s\S]*? 以表示惰性(尽可能少的匹配)。

最后就是区分 <script> 标签了,在我们定制的模板中,存在着 <script type="json"> 这是我们自己约定的写 JSON 配置的地方,而普通的 <script><script type="text/javascript"> 则是写 JavaScript 代码。用先行否定断言,可以写出 /<script(?!\s+type="json").*?>([\s\S]*?)<\/script>/m 来提取 JavaScript 代码。这里可以体会下,如果前面半段写成 /<script(?!\s+type="json")>/ 的话,就无法匹配到 <script type="text/javascript"> 了。

JavaScript 中的正则

如果读者看到这儿,说明对正则没有失去信心。🌝

JavaScript 中正则相关的函数有 replace / test / match / exec,上文示例代码中也用过一些了。这里强烈推荐下 exec 函数,当使用了 g 修饰符时,如果字符串中有多处匹配,match 函数无法拿到每处匹配中的分组信息,这就需要用 exec 来处理了。

1
2
3
4
5
6
7
8
var reg = /¥(\d+)/g;
console.log('¥1 ¥23 ¥33'.match(reg)); // 无法拿到分组捕获

var res;
while (res = reg.exec('¥1 ¥23 ¥33')) {
console.log(res, res && res[1]); // 可以拿到第1个分组
console.log('reg lastIndex', reg.lastIndex);
}

RegExp 对象里的 exec 函数可以被多次调用,每次只返回一处匹配的详细信息(包括分组捕获),并且把当前处理到的字符串下标存在 RegExp 的 lastIndex 中,这样就可以在 g 全局模式下得到每处匹配的分组信息。

ES6 后的新增特性

ES6 之后对正则表达式的新增了不少特性,主要在对 Unicode 的支持上。如果你去网上搜那些正则表达式大全,很有可能会搜到用 /[\u4e00-\u9fa5]/ 来识别汉字,这是很久以前的做法。现在的字符集更多了,不妨试试 𠮷 或者 😂 呢?先来看个例子:

1
2
console.log('😂'.length); // 2
console.log('\uD83D\uDE02');

可以看到 😂 由 2 个双字节码组成 (UTF-16),可以在 codepoints 上查到它的所有编码格式,显然它已经超出了 /[\u4e00-\u9fa5]/ 的范围。你可能会说,它又不是汉字,当然不能用上面那个正则,那不妨再查下 𠮷 的编码。

ES6 对正则表达式有个 u 修饰符,表示 Unicode 模式。在这个模式下,正则中的 . 可以匹配换行符以外的任意单个字符(包括 Unicode 字符)。

1
2
3
4
5
6
7
8
/./.test('😂');         // true  因为 😂 被认为是2个字符,相当于 '\uD83D\uDE02'
/.{2}/.test('😂'); // true 原因同上
/\S{2}/.test('😂'); // true 原因同上
/^.$/.test('😂'); // false 正则限制了开头和结尾,2个字符当然匹配不上咯
/^.$/u.test('😂'); // true Unicode 模式下,认为 😂 是1个字符
/.{2}/u.test('😂'); // false 原因同上
/\S{2}/u.test('😂'); // false 原因同上
/\u{1F602}/u.test('😂');// true

Unicode 模式下还可以用 \u{unicode值} 来表示码点大于 0xFFFF 的 Unicode 字符。

此外,ES2018 还引入了 Unicode 属性转义,比如可以用 /\p{Unified_Ideograph}/u 来匹配所有汉字。其中 Unified_Ideograph 是一个 Unicode property 表示汉字字符集,可在 tc39 中查到所有属性转义。

当然,ES6 和 ES2018 还增加了另外2个修饰符,以及后行断言和具名分组,出于篇幅原因这里就不多介绍了,有兴趣的可以参考 ECMAScript 6 入门的正则章节

总结

正则表达式是前端工程师的一把利器,也是值得每个程序员掌握的。本文抛砖引玉,主要想说明正则表达式在前端中有着很多应用场景,而不仅仅是纯粹的表单验证。

正则表达式的语法看着多而复杂,但只要从语法的功能分类入手,平时多写多运用,就能很快克服语法的记忆负担。书写正则表达式的过程也体现着思考的过程,正则的细化也就是需求的细化。

最后再次推荐入门书籍《正则表达式必知必会》,以及正则表达式的可视化工具 regulex

参考资料

《JavaScript 正则表达式迷你书》

ECMAScript 6 入门的正则章节

正则实现数组滤重

浅析ReDoS的原理与实践

一个由正则表达式引发的血案(解决版)

一次性搞懂JavaScript正则表达式之引擎