上一篇: Macro task/Micro task下一篇: Maya 制作 iPhone 5s

正则表达式


简介

正则表达式(Regular Expression) 是一种用特定字符定义的匹配规则的字符串, 最早由 Ken Thompson(UNIX内核的核心开发者之一) 在计算机系统中实现, 之后广泛应用在各大编程语言中做字符串匹配.

作用

正则表达式可以对较复杂的字符串做匹配和各种处理的操作, 玩转正则表达式可以事半功倍.

背景

简单字符串匹配可以遍历每个字符, 通过大量 if,else 实现. 复杂匹配可以用 动态规划 把拆分为多个子字符串, 通过大量函数调用也可实现. 对于更为复杂的匹配, 堆积大量函数是可以的, 但复用性低且难维护. 那么可以制定一套规则, 把匹配的各种函数封装起来, 任何地方都可以用此通用的规则匹配不同字符串.

从框架的发展来看, 一直在解决的问题是 提升开发效率降低维护成本, 并且保证性能至少不下降. 但应用日趋复杂, 只用底层api并非不能实现功能, 而是效率极低, 因此有人去总结规律, 将很多功能封装成工具, 库或者框架, 并考虑其兼容性和扩展性. 对于一个只写业务逻辑的程序员, 没有必要自己手动封装一个 ajax 库

同理, 对于复杂匹配无需自己去遍历字符做大量逻辑判断, 只需总结一套规则, 开发一套匹配引擎, 每次要用此规则的时候只需 import 正则引擎即可, 好消息是, 这类引擎 内置于各大编程语言中, 它所对应的规则就是正则表达式.

你可能留意到上面一段的 "这类引擎", 的确可以解析正则表达式的引擎有很多, 每种语言内置的正则引擎不尽相同, 但它们解析的正则表达式语法都几乎无差异. 这就类似于 Gecko , TridentWebkit 是不同的浏览器内核, 它们都能解析HTMLCSS, 最终呈现的页面也基本一致, 但这些内核里面的解析原理有很大的不同.

当然除了这些内置引擎以外, 如果不满足与现有正则语法, 我们可以单独引入一些第三方的正则引擎. 或者自创一些新语法, 自己实现一个匹配引擎.

基础

正则表达式(简称正则)是一套表示规则的字符串, 在JavaScript中内置了正则表达式构造函数, 可以通过new关键字生成, 也可通过字面量的方式声明, 正则被两个/包裹, js中解析时自动生成正则对象, 例如:

正则中特殊符号分为标识符, 量词, 定位符和一些其他特殊修饰符组成. 最简单的正则和要匹配的字符串一样, 比如abcd可以匹配字符串abcd, 但如果我想匹配所有字母呢? 此时就需要标识符, 类似于通配符但比通配符更强大.

常用标识符:

标识符 说明 例子
[] []本身不做匹配, 他内部任何标识符都为"或者", 可用-表示范围 [a-z], 匹配a到z任意字母; [a-g12], 匹配a到g或1或2; [0-25], 匹配0到2,或5; [a-e\-], 匹配a到e或-; [^ab], 匹配非a且非b; 注意, ^[]内部是 的意思
\w 匹配任意字母, 数字和_, 不分大小写 等价于[a-zA-Z0-9_]
\d 匹配一个数字 等价于 [0-9]
. 匹配任何非 \r, \n的字符
\s 匹配空格, \r, \t, \n
[\u4e00-\u9fa5] []\u开头表示utf-8编码, 因为常用汉字的区间是16进制的4e00到9fa5

定位符:

符号 说明 例子
\b 匹配任意边界位置, /c\b/去匹配"abc", "abc d", "abc\nd"都能匹配到. "abcd"则无法匹配
^ []中表"非", 在外面表示字符串头部位置 /^185/匹配所有185开头的字符串
$ 表示字符串末尾位置 /000$/匹配所有000结尾的字符串, /^185\d{8}$/匹配所有185开头的手机号

其他符号

小结:

量词 , 如果没有量词存在, 所有标识符都值匹配满足条件的一个字符, 量词可以描述多个匹配的字符, 常用量词有:

常用正则函数(js为例)

参数

正则声明的时候, 可传输三个参数, 分别是g, i, m. 例如:

正则引擎非常懒, 没有任何参数修饰时, 它只会去匹配满足条件的第一个, 例如:

// 只会匹配第一组连续数字12并用''替换, 而34依然保留
'a12b34'.replace(/\d+/, '');   // 'ab34'
'a12b34'.replace(/\d+/g, '');  // 'ab'
/[a-z]{4}/i.test('aBcD');  // true
`123
456`.match(/^123$\n^456$/);  // false

`123
456`.match(/^123$\n^456$/m); // true

贪婪匹配

正则引擎很贪婪, 它会尽可能匹配最长满足条件的字符串, 例如,

'0a12b34a56b7'.replace(/a.+b/g, '-');  // '0-7'

需要在量词 + 后面添加 ? 关闭贪婪模式, 让其使用满足条件的最短子字符串查找:

'0a12b34a56b7'.replace(/a.+?b/g, '-'); // '0-34-7'

再次强调: ?在量词后表示关闭贪婪模式, 在标识符后表示 01 个的量词. 此外, 单独使用 ?=! 结合的时候, 比如 (?=) , (?!) 还可表示 零宽断言

零宽断言

零宽断言通俗点讲就是只匹配位置不匹配字符, 很多时候我们只想匹配特定条件下的某些字符, 而不想保留这些"条件".

例如:

// 'ab12cd34ef56ca' 中想要将所有后面是'c'的连续数字替换为'-', 
'ab12cd34ef56ca'.replace(/\d+(?=c)/g, '-');  // 'ab-cd34ef-ca', 注意此处未匹配c
// 匹配后面是a的a
'aab'.replace(/a(?=a)/g, '')    // 'ab'

正则引擎对字符串的匹配是 从左到右的 , 所以右边被称为 前向正向 , 当匹配到字符串中间某字符的时候, 会根据正向前瞻里的规则预先匹配一下更右边一些字符, 判断是否当前字符满足条件.

// 使用正向零宽断言给数字加千分号
'12345678890.9098'.replace(/(?!^)(?=(\d{3})+\.\d*$)/g, ',')

"12,345,678,890.9098"

上面的正则中, (?=\d{3}) 匹配后面是三个数字的位置, 将 \d{3} 打组, 使用量词 + 可匹配1到任意个 \d{3} , 因此 (?=(\d{3})+) 实际上匹配的是, 后面存在3, 6, 9, 12, …个数字的位置, 到.结束, 如果是 "123" 中, 1 前面的位置也满足条件(后面有3个数字), 则会被替换成 ,123. 所以需要添加 (?!^) 修饰, 表示并且不是 ^ (字符串首部)这个位置. 最后将所有匹配到的位置用 , 替换.

注意: js的正则没有负向零宽断言, 很多情况要结合分组才能操作

分组

分组在复杂匹配中很重要, 用 () 对匹配符进行打组, 可嵌套多层, 对于多层 () 嵌套的分组, 正则引擎会像解析树的括号结构用深度优先遍历的方式将不同层级子字符串放到对应分组中.

每次捕获到分组中的字符串会按照上图中的顺序放入 $0, 1, 2, ... , 在字符串的replace方法中传入正则, $0, 1, 2, ... 则会作为实参传入回调中. $0 代表这一次查询中整个正则匹配到的字符串.

在js中, RegExp 是构造函数, 可以动态生成正则对象. RegExp 也是对象, 在全局匹配中, 每次分组的$_(代表$0), $1, $2, ...也会作为属性挂到 RegExp上.

// 将querystring解析为对象
let obj = {};
location.href.replace(/([\w%]+)=([^&?#]*)/gi, ($0, $1, $2) => obj[$1] = $2);

console.log(obj);

假设上面代码中的 location.hrefhttps://search.jd.com/search?keyword=6s&qrst=1&rt=1&stop=1&vt=2&wq=6s&cid3=655&psort=4 , 则匹配顺序是:

// 第一次匹配到 'keyword=6s', 并将 'keyword' 作为 $1, '6s' 作为 $2 传入回调并执行
// 第二次匹配到 'qrst=1', 并将 'qrst' 作为 $1, '1' 作为 $2 传入回调并执行
// ...

下面用最短的非正则方式实现此功能:

let obj = {}, tmp
location.href.split('?').pop().split('#').shift().split('&').forEach(str => {
  tmp = str.split('=');
  obj[tmp[0]] = tmp[1] || '';
});

console.log(obj);

其实正则的匹配过程就是字符串的遍历, 有时候只需使用replace提供的遍历功能, 不需要返回结果

// 统计字符串中各个字符出现次数
let str = '5d6tyfubjnklmdaf89aiojlnc afafahipjdafds8ugifnkla';
let obj = {};
str.replace(/(.)/g, ($0, $1) => obj[$1] ? obj[$1]++ : obj[$1] = 1)

逆向引用

如果是要匹配已分组过的字符, 可以用逆向引用. 逆向引用需要先给要引用的字符打组, 然后使用 \1 , 可代表之前打组过的 $1 . \2, \3 以此类推, 对于匹配连续字符很有用, 例如:

// 去掉字符串中的叠词
'让我看看'.replace(/([\u4e00-\u9fe5])\1+/g, '*');

'让我*'

用 ?: 防止被捕获

上面说到, 用 () 可以将正则进行打组, 匹配到的子字符串会被对应的 $n 捕获, 但这很容易和其他带有 () 的用法搞混. 比如 /(abc|def)(ghi)/ 默认会对 abc或def 任意匹配到的进行捕获, 但我此时只想对ghi打组, 而 abc|def 因为用到了 | 分支语句, 不得不用 () 括起来, 这种情况想获得 ghi 虽然用 $2 也行, 但在包含复杂打组的正则中, 可读性就大大降低了, 因此可以使用 (?:abc|def) 其被捕获, 此时要获取 ghi 还是$1

例子: 对"广东省深圳市福田区市民中心花园北4街紫光大厦A座20楼"类似的地址进行匹配, 要求返回对象 {province: '广东省', city: '深圳市', area: '福田区', detail: '市民中心花园北4街紫光大厦A座20楼'}

因为地址可能任意变动, 包括

function parseAddr (str) {
  let res = '';
  str.replace(/(.{2,4}(?:自治区|省))?(.{1,10}?(?:自治州|市))?(.{1,10}?(?:区|县))?(.*)/, ($0, $1, $2, $3, $4) => {
    res = {
      province: $1 || '',
      city: $2 || '',
      area: $3 || '',
      detail: $4
    }
  });
  return res;
}

parseAddr('四川省阿坝藏族羌族自治州市中区高桥镇80号');
/* 
{
  province: "四川省",
  city: "阿坝藏族羌族自治州",
  area: "市中区",
  detail: "高桥镇80号"
}
*/

parseAddr('深圳市福田区市民中心大厦A座302号');
/*
{
  province: '',
  city: '深圳市',
  area: '福田区',
  detail: '市民中心大厦A座302号'
}
*/

实例

// 去掉两边空格
'    abc  '.replace(/^\s+|\s+$/g, '');

严格验证字符串必须在首尾加 ^$ , 例如:

/^[1-9]\d{16}[0-9a-z]/i.test(str); // 验证身份证格式
// 验证手机号
// 首先要搜索到手机号可能的号码段是130-139, 145和147, 150-159(154除外), 176-178, 180-189
/^(13[0-9]|14[57]|15[0-35-9]|17[6-8]|18[0-9])\d{8}$/.test(phone);

/*
 * 如果是'13300000000', 这种号码虽然格式是通过的, 但显然不存在.
 * 所以还可多加一个判断, 判断如果存在4位连续相同数字则不通过
 * 为了防止原正则过于复杂, 可以封装一个函数
 */
function checkPhone (s) {
  return /^(13[0-9]|14[57]|15[0-35-9]|17[6-8]|18[0-9])\d{8}$/.test(s)
      && !/(\d)\1{3}/.test(s)
}
// 验证邮箱格式
// 假设邮箱用户名部分只包含字母数字, '_', '-', '.', 且只能用字母或数字开头, 长度不超过40
// 假设域名部分只有字母数字和'-', 除了顶级域, 每一级最长30
/^[a-z\d][\w\-\.]{0,39}@([a-z\d][\w\-]{0,29})(\.[a-z\d][\w\-]{0,29})*\.(com|cn|net|cc|org|co|io|me)$/i.test(email)

因为正则表达式的匹配都是同步执行的, 因此过于耗时的正则会阻塞页面更新. 说到正则表达式耗时的原因, 不得不说回溯.

回溯

在上一篇基础中说到, 正则表达式有类似于通配符但比通配符更强大的标识符, 比如 . , [] , \w 等, 这些标识符在对字符串进行匹配的时候会不断去 尝试. 从当前index指向字符串位置开始不断地匹配, 如果下一个字符不能匹配正则中对应标识符, 倒退一步, 如果还是不能, 继续倒退, 直到匹配成功为止. 这个 倒退过程 就被称为 回溯.

因此正则内部就是使用的穷举法, 对正则被解析成的树形接口进行遍历, 直到成功为止. 我们知道对深层次树的查找是非常性能的, 所以编写正则也需要考虑到回溯带来的影响.

待续...

局限性

正则表达式不是万能的, 很多复杂情况只用正则表达式是不够的, 还要结合很多字符串方法才能完成. 如果是复杂嵌套字符串是只用正则匹配也无能为力, 例如:

// 去掉代码中(不是字符串中)的整个注释
var str = "this is a /* var str2 = 'good'// test";  /* 这是是注释 */
var str2 = "this //is a */ test"                    // 这里也是注释

// 或者是去掉代码中的函数体
function a () {
  if (1) {
    a = "这里可能包含任何语句, 只用正则表达式没辙, function () { ... }"
  }
}

上面的情况必须逐字解析, 使用多个表示 state 的变量标记当前解析状态(字符串中, 或作用域中, 或注释中, 或括号中), 然后使用栈来实现嵌套的符号解析, 或者是用 解析器 生成 抽象语法树(AST, Abstract Syntax Tree) 然后进行操作.

上一篇: Macro task/Micro task下一篇: Maya 制作 iPhone 5s