4

最近这段时间帮同学处理一些文档, 涉及到一些结构化文档的工作大部分都得使用正则表达式, 之前对于正则的认识大多来源于语言书上那几页的介绍, 自己也没有用过几次。这里将我之前感到模糊的概念作个整理。因为对JS了解多点,所以也将JS中相关的正则特性归纳下。注意本文将正则与JS中的正则分开讨论。

正则引擎

正则表达式的解释引擎只有两种,字符驱动(text-directed)和正则驱动(regex-directed),基于这两种解释引擎算法的不同,它们有时也被称为DFA(Deterministic finite automaton 确定型有穷自动机)与NFA(Non-deterministic finite automaton非确定型有穷自动机),当前大部分现代语言的正则解释器采用“正则驱动”引擎,这是因为NFA运行的回溯算法可以实现诸如‘惰性匹配(lazy quantifiers )’和‘后部引用(backreferences)’ 等非常有用的特性, 而DFA算法也就是字符驱动型引擎并不支持这些特性,当然复杂的算法必须付出性能的代价,字符驱动引擎的性能要强于正则驱动。各种语言中由于实现正则引擎的具体算法以及调用的正则库都有不同,所以对于正则的支持也不同,关于这些概念的参考mark在这里:
?Regex Engine Internals
?正则表达式匹配解析过程探讨分析(正则表达式匹配原理)

零宽断言

零宽断言(zero-length assertions)这概念直译过于术语化(都有点像咒语了,囧rz), 逻辑不是太复杂,但是容易混淆模糊,在此记个笔记。零宽的意思是指该位置是不占宽度的,也就是只作断言判断,但不匹配实际的内容,比如\d(?=\.)这个正向先行断言(这概念也归纳在后)就只匹配点号之前的数字,但是它并不会匹配到这个点号,这个\d(?=\.)括号中的匹配内容也就是零宽了。
零宽断言分为四种,分别是:正向先行(Positive Lookahead),正向回顾(Positive Lookbehind),负向先行(Negative Lookahead),负向回顾(Negative Lookbehind)。正向和负向的意思是断言括号中的内容是匹配还是不匹配,比如上面栗子?中的(?=...)就是正向先行断言的写法,该断言括号中的内容必须出现, 而负向也就是断言括号中的内容不能出现,负向先行的写法是这样:(?!...)。 先行与回顾的意思是实际匹配的内容在断言内容的前面还是后面,以下将逐一罗列这四种概念:

正向先行

直接地说“零宽正向先行断言”所匹配的就是必须出现的断言内容的前面的内容,之前的点号前数字的栗子?\d(?=\.)已经比较直观的展示了这个概念,但是需要注意的是:这里的先行或者回顾是基于位置,而不是字符!也就是说从点号的位置开始向前匹配, 比如我们把这个正则表达式改成这样:(?=\.).注意这里并没有把实际要匹配的内容放在断言括号的前面,而是放到了后面, 这就匹配了从这个点号位置开始的任意字符(除换行符外),也就匹配到了这个点号, 当然通常这样写没什么意义, 但便于理解位置的含义。

正向回顾

理解了前面这个“零宽正向先行断言”,随之而来的就比较好理解了。依照这个逻辑,零宽正向回顾断言所匹配的是必须出现的断言内容之后的内容,它的语法是:(?<=...) 比如(?<=\.)\w所匹配的就是点号之后的ASCII字符。

负向先行

负向与正向意思相反, 正向是断言内容必须出现,而负向则是断言内容必须不出现。比如JavaScript and Java这句话中使用Java(?!Script)就只会匹配到Java,因为该正则断言Java后面不能出现Script

负向回顾

正负向与先行回顾的概念都已在前面列出, 负向回顾的理解应该就很顺了。负向回顾的语法是:(?<!...)。 比如(?<!Java)Script该正则只匹配不是JavaScriptScript,它就能正确匹配ECMAScriptScript

这里需要注意:在大多数语言中,先行断言中可以有任意的正则语法,比如可以用+或者*这种不确定重复的匹配,但是回顾断言中的限制就比较多,很多语言的回顾断言中不支持诸如+*这种不定重复的匹配和后部引用,因为正则解释引擎在匹配回顾断言的时候,当回顾断言中的内容匹配成功后再去匹配断言后的实际内容时,它需要能计算出后退多少步才能判断出当前匹配是否与这个正则完全匹配。有些语言压根就不支持回顾断言,比如JS;而 PHP, Delphi, R和 Ruby只支持在回顾断言中加入选择匹配,但选择匹配的内容长度必须相同,形如[ab|ba|cd];Java回顾断言限制不多,但无法在回顾断言中加入不定数量的重复,也就是不能在回顾断言中使用+; .Net支持在回顾断言中使用任意正则语法。(以上内容参考自下面的第一个mark参考中,可能存在时效的问题, 如有错误还请大神指出。

正则零宽断言更多参考mark:
?Lookahead and Lookbehind Zero-Length Assertions

各种语言对于正则不同支持参考:
?Comparison of regular expression engines

单行模式与多行模式

通过设置正则表达式后的修饰符(flag)可开启对应的匹配模式:s(单行模式)和m(多行模式)。单行模式与多行模式这两名字容易让人误以为两者是互相排斥的, 也就是误以为开启了多行模式,那么就不是单行模式,而实际上单行模式或多行模式的开关并不会影响另一个,两个模式可以同时用

单行模式

开关单行模式影响的是元字符.的匹配。单行模式开启时,元字符.匹配包括换行符\n在内的任意字符,单行模式关闭时,元字符.匹配不包括换行符\n的任意字符。

多行模式

多行模式影响的是元字符^$。多行模式开启时,元字符^可以匹配字符串开头(字符串的开始位置),也可以匹配行的开头(即换行符\n之后的位置),元字符$ 可以匹配字符串结尾(字符串的结束位置), 也可以匹配行的结尾(即换行符\n之前的位置)。多行模式关闭时,元字符^$只能匹配字符串的开头和结尾,不能匹配换行符\n的之前和之后。也就是说如果没有这两个元字符,即使正则后加多行模式修饰符m也无意义,多行模式的正则必须有这两个元字符中的一个或两个才有意义。
概念参考mark: ?正则表达式的多行模式与单行模式
附一个JS描述的多行模式:?multiline 属性(正则表达式)(JavaScript)

注意:JS中正则并不支持单行模式的开启

JS中的正则

JS中的正则需要注意下正则对象的方法与String对象方法的微妙区别。

RegExp对象

每个正则对象实例有这几项属性global(是否带有修饰符g的布尔值) ignoreCase(是否带有修饰符i的布尔值) lastIndex(对象方法匹配开始的位置索引,初始为0) multiline(是否带有修饰符m的布尔值) source(正则源码文本,注意源码文本不包括修饰符)。

ES6中RegExp对象实例新增的只读属性sticky可以判断正则后是否带修饰符y(新增),设置y修饰符的正则每次匹配,存在lastIndex属性情况下lastIndex不会自动归零,并且在正则表达式中隐式地加入了开头元字符^,这样就使得正则表达式的匹配锚定在lastIndex的位置。这种用法的详情可参考MDN文档的栗子?:?RegExp.prototype.sticky 以及?JavaScript:正则表达式的/y标识

JS中每个RegExp对象实例有两个方法,分别是:exec()test()。这两个方法运行逻辑几乎等价,但是exec要复杂些,匹配失败返回null,匹配成功它将返回一个数组, 数组第一个元素是匹配整个正则的内容,之后的元素是正则中捕获组的匹配(注:使用非捕获组可以获得微弱的性能优势)举个栗子:

//捕获组()
var pattern1 = /(www)\.(baidu)\.(com)/i  
//非捕获组(?:)
var pattern2 = /(?:www)\.(?:baidu)\.(?:com)/i

var str  = 'www.baidu.com'

pattern1.exec(str)
//返回数组["www.baidu.com", "www", "baidu", "com"]
pattern2.exec(str)
//返回数组["www.baidu.com"]

注意:虽然exec()和String的match()方法返回的都是数组的实例,但是他们的返回值都有两个额外的属性:indexinput分别表示匹配项在字符串中的位置和应用正则的整段字符串。

test()方法更为简单,匹配成功返回true,失败返回false。这里需要注意下这两个方法中的lastIndex属性与正则表达式中的全局修饰符g的关系。
当正则中存在全局修饰符g时,RegExp对象的lastIndex属性将保存下一次开始的匹配的位置,比如:

var  pattern = /(www)\.(baidu)\.(com)/g 
var  str2 = 'www.baidu.com www.google.com www.baidu.com'
pattern.exec(str2)
pattern.lastIndex  //13

这意味着下次调用exec方法它将从被匹配字符串索引13的位置开始匹配(也可以手动设置lastIndex的值)。test()方法同理。正因为存在残留的lastIndex,所以使用正则对象的方法可能会导致一些意外的结果,不过在ES5中,正则表达式直接量的每次计算都会创建一个RegExp对象实例,他们各自拥有lastIndex,这就大大降低了意外的概率。
但是String对象中支持正则的方法却与正则对象的方法不同,String方法忽略lastIndex,但是,如果存在全局修饰符g,string方法将有些许不同。

String中的正则方法

String方法中支持正则的有match(),replace(), search(),split()这四个方法。
match()方法接受一个字符串或正则作为参数,当参数是正则时, 正则是否带全局修饰符g将影响该方法的返回值,该方法匹配不带全局修饰符的正则时,与RegExp对象实例方法exec()的返回值一样, 匹配带全局修饰符的正则时,match()方法将返回全局中所匹配到的所有内容而不再将正则中的捕获组编码。比如前面的str2如果使用String的match方法的话其返回的数组中将是["www.baidu.com","www.baidu.com"]

replace(searchValue,replaceValue)方法同样接受字符串或正则作为第一个参数,当搜索值是字符串的时候,它只会匹配一次,很多时候这并非我们要的结果, 一般我们希望全局匹配,于是可以使用带全局修饰符的正则表达式。replace()的替换值中的美元符号$有特殊含义,比如$1表示正则表达式中第一个捕获组的匹配内容。它的意义取自RegExp构造函数的属性(正则表达式每次的操作都会影响构造函数RegExp的属性,该构造函数属性的详细参考可以看下《JS高级程序设计》中文第三版108页中的说明)下面借用《JS权威指南》核心参考中的栗子。

var  name = 'Doe,John'
name.replace(/(\w+)*,(\w+)/,'$2 $1')//$num代表捕获组的编号
"John Doe"  

美元符号的特殊含义归纳:

  • $$ 替换对象$

  • $& 整个匹配的文本

  • $number 分组捕获的文本(从1开始,不是0哦)

  • $` 匹配之前的文本

  • $' 匹配之后的文本

search()方法与String中indexOf类似,接受一个正则或者字符串为参数,匹配成功返回第一个匹配的首字符位置, 失败则返回-1。

split(separator,howmany)方法接受一个必选的分割位置作为第一个参数,第二个参数设置返回数组的最大长度。第一个参数可以是字符串或正则表达式,如果该参数是包含捕获组(带圆括号带子表达式)的正则表达式,那么返回的数组中包括与这些子表达式匹配的字串(但不包括与整个正则表达式匹配的文本)
举个例子?:

'hello world'.split(/(\w)\s/)
//返回["hell", "o", "world"]

可以看到捕获组中的匹配("o")也在返回的数组中,但是整个正则的匹配被作为分隔位置。并非所有浏览器都支持split()这个特性。
MDN文档中并未说明该特性更多细节:?String.prototype.split()

JS中不支持的正则特性

这里也顺便转贴一下《JS高级程序设计》第三版中提到的JS(ES5)正则的局限性:

  • 匹配字符串开始的结尾的A和Z锚(但支持以^和$来匹配字符串的开始的结尾)

  • 向后查找(但支持向前查找)

  • 并集和交集类

  • 原子组

  • Unicode支持(单个字符除外)

  • 命名的捕获组(但支持编号的捕获组)

  • s(single单行)和x(free-spacing无间隔)匹配模式

  • 条件匹配

  • 正则表达式注释

JS的正则库

以上所提到的局限性中,可使用正则库或多或少的抵消一些,比如XRegExp 调用该库之后就可以使JS的正则支持可命名空间的捕获组以及注释等有用的特性, 关于JS的正则库XRegExp,我写了篇大致的介绍:?JavaScript正则库:XRegExp


囧囧
1.9k 声望132 粉丝

蚂蚁金服海外银行诚招前端: