JavaScript 正则表达式迷你书(1.1版)

coderljw 2024-10-13 大约 13 分钟

# 1. 两种模糊匹配

  • 横向模糊匹配。

    • 横向模糊指的是,一个正则可匹配的字符串的长度不是固定的,可以是多种情况的。
    • 其实现的方式是使用量词。譬如 {m,n},表示连续出现最少 m 次,最多 n 次。
  • 纵向模糊匹配。

    • 纵向模糊指的是,一个正则匹配的字符串,具体到某一位字符时,它可以不是某个确定的字符,可以有多种可能。
    • 其实现的方式是使用字符组。譬如 [abc],表示该字符是可以字符 "a"、"b"、"c" 中的任何一个。

# 2. 字符组

  • 范围表示法。

    • 因为连字符有特殊用途,那么要匹配 "a"、"-"、"z" 这三者中任意一个字符,该怎么做呢?
    • 可以写成如下的方式:[-az] 或 [az-] 或 [a-z]。
    • 即要么放在开头,要么放在结尾,要么转义。
  • 常见的简写形式。

    如果要匹配任意字符怎么办?可以使用 [\d\D]、[\w\W]、[\s\S] 和 [^] 中任何的一个。

# 3. 量词

  • 贪婪匹配与惰性匹配。

    通过在量词后面加个问号就能实现惰性匹配。

    // 贪婪匹配
    var regex = /\d{2,5}/g
    var string = '123 1234 12345 123456'
    console.log(string.match(regex))
    // => ["123", "1234", "12345", "12345"]
    
    // 惰性匹配
    var regex = /\d{2,5}?/g
    var string = '123 1234 12345 123456'
    console.log(string.match(regex))
    // => ["12", "12", "34", "12", "34", "12", "34", "56"]
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11

# 4. 正则表达式字符匹配攻略 - 案例分析

  • 匹配 16 进制颜色值。

    • 要求匹配:#ffbbad、#Fc01DF、#FFF、#ffE。
    • 分析:表示一个 16 进制字符,可以用字符组 [0-9a-fA-F]。其中字符可以出现 3 或 6 次,需要是用量词和分支结构。使用分支结构时,需要注意顺序。
    var regex = /#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})/g
    var string = '#ffbbad #Fc01DF #FFF #ffE'
    console.log(string.match(regex))
    // => ["#ffbbad", "#Fc01DF", "#FFF", "#ffE"]
    
    1
    2
    3
    4
  • 匹配时间。

    • 要求匹配:23:59、02:07。
    • 分析:共 4 位数字,第一位数字可以为 [0-2]。当第 1 位为 "2" 时,第 2 位可以为 [0-3],其他情况时,第 2 位为 [0-9]。第 3 位数字为 [0-5],第 4 位为 [0-9]。
    var regex = /^([01][0-9]|[2][0-3]):[0-5][0-9]$/
    console.log(regex.test('23:59'))
    console.log(regex.test('02:07'))
    // => true
    // => true
    
    1
    2
    3
    4
    5
  • 匹配日期。

    • 要求匹配:2017-06-10。
    • 分析:年,四位数字即可,可用 [0-9]{4}。月,共 12 个月,分两种情况 "01"、"02"、…、"09" 和 "10"、"11"、"12",可用 (0[1-9]|1[0-2])。日,最大 31 天,可用 (0[1-9]|[12][0-9]|3[01])。
    var regex = /^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$/
    console.log(regex.test('2017-06-10'))
    // => true
    
    1
    2
    3
  • window 操作系统文件路径。

    var regex = /^[a-zA-Z]:\\([^\\:*<>|"?\r\n/]+\\)*([^\\:*<>|"?\r\n/]+)?$/
    console.log(
      regex.test('F:\\study\\javascript\\regex\\regular expression.pdf')
    )
    console.log(regex.test('F:\\study\\javascript\\regex\\'))
    console.log(regex.test('F:\\study\\javascript'))
    console.log(regex.test('F:\\'))
    // => true
    // => true
    // => true
    // => true
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
  • 匹配 id。

    惰性匹配会有回溯问题,效率比较低。

    var regex = /id=".*?"/
    var string = '<div id="container" class="main"></div>'
    console.log(string.match(regex)[0])
    // => id="container"
    
    1
    2
    3
    4

    优化。

    var regex = /id="[^"]*"/
    var string = '<div id="container" class="main"></div>'
    console.log(string.match(regex)[0])
    // => id="container"
    
    1
    2
    3
    4

# 5. 如何匹配位置呢?

  • ^(脱字符)匹配开头,在多行匹配中匹配行开头。$(美元符号)匹配结尾,在多行匹配中匹配行结尾。

    var result = 'hello'.replace(/^|$/g, '#')
    console.log(result)
    // => "#hello#"
    
    1
    2
    3
  • \b 是单词边界,具体就是 \w 与 \W 之间的位置,也包括 \w 与 ^ 之间的位置,和 \w 与 $ 之间的位置。

    var result = '[JS] Lesson_01.mp4'.replace(/\b/g, '#')
    console.log(result)
    // => "[#JS#] #Lesson_01#.#mp4#"
    
    1
    2
    3
  • \B 就是 \b 的反面的意思,非单词边界。例如在字符串中所有位置中,扣掉 \b,剩下的都是 \B 的。具体说来就是 \w 与 \w、 \W 与 \W、^ 与 \W,\W 与 $ 之间的位置。

    var result = '[JS] Lesson_01.mp4'.replace(/\B/g, '#')
    console.log(result)
    // => "#[J#S]# L#e#s#s#o#n#_#0#1.m#p#4"
    
    1
    2
    3
  • (?=p),其中 p 是一个子模式,即 p 前面的位置,或者说,该位置后面的字符要匹配 p。

    var result = 'hello'.replace(/(?=l)/g, '#')
    console.log(result)
    // => "he#l#lo"
    
    1
    2
    3
  • 而 (?!p) 就是 (?=p) 的反面意思。

    var result = 'hello'.replace(/(?!l)/g, '#')
    console.log(result)
    // => "#h#ell#o#"
    
    1
    2
    3

# 6. 正则表达式括号的作用 - 相关案例

  • 不匹配任何东西的正则。

    因为此正则要求只有一个字符,但该字符后面是开头,而这样的字符串是不存在的。

    var regex = /.^/
    
    1
  • 数字的千位分隔符表示法。

    把 "12345678",变成 "12,345,678"。

    var regex = /(?!^)(?=(\d{3})+$)/g
    var result = '12345678'.replace(regex, ',')
    console.log(result)
    // => "12,345,678"
    result = '123456789'.replace(regex, ',')
    console.log(result)
    // => "123,456,789"
    
    1
    2
    3
    4
    5
    6
    7

    把 "12345678 123456789" 替换成 "12,345,678 123,456,789"。

    var string = '12345678 123456789',
      regex = /\B(?=(\d{3})+\b)/g
    var result = string.replace(regex, ',')
    console.log(result)
    // => "12,345,678 123,456,789"
    
    1
    2
    3
    4
    5

    1888 格式化成 $ 1888.00。

    function format(num) {
      return num
        .toFixed(2)
        .replace(/\B(?=(\d{3})+\b)/g, ',')
        .replace(/^/, '$$ ')
    }
    console.log(format(1888))
    // => "$ 1,888.00"
    
    1
    2
    3
    4
    5
    6
    7
    8
  • 验证密码问题。

    • 要求:密码长度 6-12 位,由数字、小写字符和大写字母组成,但必须至少包括 2 种字符。
    • 下面的正则看起来比较复杂,只要理解了第二步,其余就全部理解了。/(?=.[0-9])[1]{6,12}$/对于这个正则,我们只需要弄明白 (?=.[0-9])^ 即可。分开来看就是 (?=.[0-9]) 和 ^。表示开头前面还有个位置(当然也是开头,即同一个位置,想想之前的空字符类比)。(?=.[0-9]) 表示该位置后面的字符匹配 .*[0-9],即,有任何多个任意字符,后面再跟个数字。翻译成大白话,就是接下来的字符,必须包含个数字。
    var regex = /((?=.*[0-9])(?=.*[a-z])|(?=.*[0-9])(?=.*[A-Z])|(?=.*[a-z])(?=.*[A-
    Z]))^[0-9A-Za-z]{6,12}$/;
    console.log( regex.test("1234567") ); // false 全是数字
    console.log( regex.test("abcdef") ); // false 全是小写字母
    console.log( regex.test("ABCDEFGH") ); // false 全是大写字母
    console.log( regex.test("ab23C") ); // false 不足6位
    console.log( regex.test("ABCDEF234") ); // true 大写字母和数字
    console.log( regex.test("abcdEF234") ); // true 三者都有
    
    1
    2
    3
    4
    5
    6
    7
    8

    “至少包含两种字符” 的意思就是说,不能全部都是数字,也不能全部都是小写字母,也不能全部都是大写字母。

    var regex = /(?!^[0-9]{6,12}$)(?!^[a-z]{6,12}$)(?!^[A-Z]{6,12}$)^[0-9A-Za-z]{6,12}$/
    console.log(regex.test('1234567')) // false 全是数字
    console.log(regex.test('abcdef')) // false 全是小写字母
    console.log(regex.test('ABCDEFGH')) // false 全是大写字母
    console.log(regex.test('ab23C')) // false 不足6位
    console.log(regex.test('ABCDEF234')) // true 大写字母和数字
    console.log(regex.test('abcdEF234')) // true 三者都有
    
    1
    2
    3
    4
    5
    6
    7

# 7. 分组引用

  • 提取数据。

    提取出年、月、日。

    var regex = /(\d{4})-(\d{2})-(\d{2})/
    var string = '2017-06-12'
    regex.test(string) // 正则操作即可,例如
    //regex.exec(string);
    //string.match(regex);
    console.log(RegExp.$1) // "2017"
    console.log(RegExp.$2) // "06"
    console.log(RegExp.$3) // "12"
    
    1
    2
    3
    4
    5
    6
    7
    8
  • 替换。

    把 yyyy-mm-dd 格式,替换成 mm/dd/yyyy。

    var regex = /(\d{4})-(\d{2})-(\d{2})/
    var string = '2017-06-12'
    var result = string.replace(regex, '$2/$3/$1')
    console.log(result)
    // => "06/12/2017"
    
    1
    2
    3
    4
    5

# 8. 反向引用

  • 要写一个正则支持匹配如下三种格式。

    • 要求:2016-06-12、2016/06/12、2016.06.12。
    • 注意里面的 \1,表示的引用之前的那个分组 (-|/|.)。不管它匹配到什么(比如 -),\1 都匹配那个同样的具体某个字符。
    var regex = /\d{4}(-|\/|\.)\d{2}\1\d{2}/
    var string1 = '2017-06-12'
    var string2 = '2017/06/12'
    var string3 = '2017.06.12'
    var string4 = '2016-06/12'
    console.log(regex.test(string1)) // true
    console.log(regex.test(string2)) // true
    console.log(regex.test(string3)) // true
    console.log(regex.test(string4)) // false
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
  • \10 表示什么呢?

    • 另外一个疑问可能是,即 \10 是表示第 10 个分组,还是 \1 和 0 呢?
    • 答案是前者,虽然一个正则里出现 \10 比较罕见。
    var regex = /(1)(2)(3)(4)(5)(6)(7)(8)(9)(#) \10+/
    var string = '123456789# ######'
    console.log(regex.test(string))
    // => true
    
    1
    2
    3
    4
  • 引用不存在的分组会怎样?

    因为反向引用,是引用前面的分组,但我们在正则里引用了不存在的分组时,此时正则不会报错,只是匹配反向引用的字符本身。例如 \2,就匹配 "\2",注意 "\2" 表示对 "2" 进行了转义。

    var regex = /\1\2\3\4\5\6\7\8\9/
    console.log(regex.test('\1\2\3\4\5\6\789'))
    console.log('\1\2\3\4\5\6\789'.split(''))
    
    1
    2
    3
  • 分组后面有量词会怎样?

    分组后面有量词的话,分组最终捕获到的数据是最后一次的匹配。

    var regex = /(\d)+/
    var string = '12345'
    console.log(string.match(regex))
    // => ["12345", "5", index: 0, input: "12345"]
    
    1
    2
    3
    4
  • 非捕获括号。

    • 之前文中出现的括号,都会捕获它们匹配到的数据,以便后续引用,因此也称它们是捕获型分组和捕获型分支。
    • 如果只想要括号最原始的功能,但不会引用它,即,既不在 API 里引用,也不在正则里反向引用。此时可以使用非捕获括号 (?:p) 和 (?:p1|p2|p3)。

# 9. 正则表达式位置匹配攻略 - 相关案例

  • 字符串 trim 方法模拟。

    第一种,匹配到开头和结尾的空白符,然后替换成空字符。

    function trim(str) {
      return str.replace(/^\s+|\s+$/g, '')
    }
    console.log(trim(' foobar '))
    // => "foobar"
    
    1
    2
    3
    4
    5

    第二种,匹配整个字符串,然后用引用来提取出相应的数据。这里使用了惰性匹配 *?,不然也会匹配最后一个空格之前的所有空格的。当然,前者效率高。

    function trim(str) {
      return str.replace(/^\s*(.*?)\s*$/g, '$1')
    }
    console.log(trim(' foobar '))
    // => "foobar"
    
    1
    2
    3
    4
    5
  • 将每个单词的首字母转换为大写。

    思路是找到每个单词的首字母,当然这里不使用非捕获匹配也是可以的。

    function titleize(str) {
      return str.toLowerCase().replace(/(?:^|\s)\w/g, function(c) {
        return c.toUpperCase()
      })
    }
    console.log(titleize('my name is epeli'))
    // => "My Name Is Epeli"
    
    1
    2
    3
    4
    5
    6
    7
  • 驼峰化。

    其中分组 (.) 表示首字母,单词的界定是,前面的字符可以是多个连字符、下划线以及空白符。正则后面的 ? 的目的,是为了应对 str 尾部的字符可能不是单词字符,比如 str 是 '-moz-transform '。

    function camelize(str) {
      return str.replace(/[-_\s]+(.)?/g, function(match, c) {
        return c ? c.toUpperCase() : ''
      })
    }
    console.log(camelize('-moz-transform'))
    // => "MozTransform"
    
    1
    2
    3
    4
    5
    6
    7
  • 中划线化。

    驼峰化的逆过程。

    function dasherize(str) {
      return str
        .replace(/([A-Z])/g, '-$1')
        .replace(/[-_\s]+/g, '-')
        .toLowerCase()
    }
    console.log(dasherize('MozTransform'))
    // => "-moz-transform"
    
    1
    2
    3
    4
    5
    6
    7
    8
  • HTML 转义和反转义。

    其中使用了用构造函数生成的正则,然后替换相应的格式就行了。

    // 将HTML特殊字符转换成等值的实体
    function escapeHTML(str) {
      var escapeChars = {
        '<': 'lt',
        '>': 'gt',
        '"': 'quot',
        '&': 'amp',
        "'": '#39',
      }
      return str.replace(
        new RegExp('[' + Object.keys(escapeChars).join('') + ']', 'g'),
        function(match) {
          return '&' + escapeChars[match] + ';'
        }
      )
    }
    console.log(escapeHTML('<div>Blah blah blah</div>'))
    // => "&lt;div&gt;Blah blah blah&lt;/div&gt";
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18

    倒是它的逆过程,使用了括号,以便提供引用。通过 key 获取相应的分组引用,然后作为对象的键。

    // 实体字符转换为等值的HTML。
    function unescapeHTML(str) {
      var htmlEntities = {
        nbsp: ' ',
        lt: '<',
        gt: '>',
        quot: '"',
        amp: '&',
        apos: "'",
      }
      return str.replace(/\&([^;]+);/g, function(match, key) {
        if (key in htmlEntities) {
          return htmlEntities[key]
        }
        return match
      })
    }
    console.log(unescapeHTML('&lt;div&gt;Blah blah blah&lt;/div&gt;'))
    // => "<div>Blah blah blah</div>"
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
  • 匹配成对标签。

    匹配一个开标签,可以使用正则 <[^>]+>,匹配一个闭标签,可以使用 </[^>]+>,但是要求匹配成对标签,那就需要使用反向引用

    var regex = /<([^>]+)>[\d\D]*<\/\1>/
    var string1 = '<title>regular expression</title>'
    var string2 = '<p>laoyao bye bye</p>'
    var string3 = '<title>wrong!</p>'
    console.log(regex.test(string1)) // true
    console.log(regex.test(string2)) // true
    console.log(regex.test(string3)) // false
    
    1
    2
    3
    4
    5
    6
    7

# 10. 正则表达式回溯法原理

  • 其实回溯法,很容易掌握的。简单总结就是,正因为有多种可能,所以要一个一个试。直到,要么到某一步时,整体匹配成功了。要么最后都试完后,发现整体匹配不成功。

  • 既然有回溯的过程,那么匹配效率肯定低一些。相对谁呢?相对那些 DFA 引擎, DFA 是 “确定型有限自动机” 的简写。

  • 而 JavaScript 的正则引擎是 NFA,NFA 是 “非确定型有限自动机” 的简写。大部分语言中的正则都是 NFA,为啥它这么流行呢?

    答:你别看我匹配慢,但是我编译快啊,而且我还有趣哦。

# 11. 结构和操作符

结构 说明
字面量 匹配一个具体字符,包括不用转义的和需要转义的。比如 a 匹配字符 "a",又比如 \n 匹配换行符,又比如 . 匹配小数点
字符组 匹配一个字符,可以是多种可能之一,比如 [0-9],表示匹配一个数字,也有 \d 的简写形式,另外还有反义字符组,表示可以是除了特定字符之外任何一个字符,比如 [^0-9],表示一个非数字字符,也有 \D 的简写形式
量词 表示一个字符连续出现,比如 a{1,3} 表示 "a" 字符连续出现 3 次。另外还有常见的简写形式,比如 a+ 表示 "a" 字符连续出现至少一次
匹配一个位置,而不是字符。比如 ^ 匹配字符串的开头,又比如 \b 匹配单词边界,又比如 (?=\d) 表示数字前面的位置
分组 用括号表示一个整体,比如 (ab)+,表示 "ab" 两个字符连续出现多次,也可以使用非捕获分组 (?:ab)+
分支 多个子表达式多选一,比如 abc|bcd,表达式匹配 "abc" 或者 "bcd" 字符子串。反向引用,比如 \2,表示引用第 2 个分组
操作符描述 操作符 优先级
转义符 \ 1
括号和方括号 (…)、(?:…)、(?=…)、(?!…)、[…] 2
量词限定符 {m}、{m,n}、{m,}、?、*、+ 3
位置和序列 ^、$、\元字符、一般字符 4
管道符(竖杠) | 5

# 12. 正则表达式的拆分 - 案例分析

  • 身份证。

    • 正则表达式是:/^(\d{15}|\d{17}[\dxX])$/。
    • 因为竖杠 | 的优先级最低,所以正则分成了两部分 \d{15} 和 \d{17}[\dxX]。\d{15} 表示 15 位连续数字。\d{17}[\dxX] 表示 17 位连续数字,最后一位可以是数字,可以大小写字母 "x"。
  • IPV4 地址。

    • 正则表达式是:/^((0{0,2}\d|0?\d{2}|1\d{2}|2[0-4]\d|25[0-5]).){3}(0{0,2}\d|0?\d{2}|1\d{2}|2[0-4]\d|25[0-5])$/。
    • 这个正则,看起来非常吓人。但是熟悉优先级后,会立马得出如下的结构:((…).){3}(…)其中,两个 (…) 是一样的结构。表示匹配的是 3 位数字。因此整个结构是 3 位数.3 位数.3 位数.3 位数。
    • 然后再来分析 (…):(0{0,2}\d|0?\d{2}|1\d{2}|2[0-4]\d|25[0-5])它是一个多选结构,分成 5 个部分:
    • 0{0,2}\d,匹配一位数,包括 "0" 补齐的。比如,"9"、"09"、"009"。
    • 0?\d{2},匹配两位数,包括 "0" 补齐的,也包括一位数。
    • 1\d{2},匹配 "100" 到 "199";
    • 2[0-4]\d,匹配 "200" 到 "249"。
    • 25[0-5],匹配 "250" 到 "255"

# 13. replace 是很强大的

  • replace 有两种使用形式,这是因为它的第二个参数,可以是字符串,也可以是函数。当第二个参数是字符串时,如下的字符有特殊的含义。
属性 描述
$1,$2,…,$99 匹配第 1-99 个 分组里捕获的文本
$& 匹配到的子串文本
$` 匹配到的子串的左边文
$' 匹配到的子串的右边文本
$$ 美元符号

把 "2,3,5",变成 "5=2+3"。

var result = '2,3,5'.replace(/(\d+),(\d+),(\d+)/, '$3=$1+$2')
console.log(result)
// => "5=2+3"
1
2
3

把 "2,3,5",变成 "222,333,555"。

var result = '2,3,5'.replace(/(\d+)/g, '$&$&$&')
console.log(result)
// => "222,333,555"
1
2
3

把 "2+3=5",变成 "2+3=2+3=5=5"。要把 "2+3=5",变成 "2+3=2+3=5=5",其实就是想办法把 = 替换成=2+3=5=,其中,$& 匹配的是 =, ˋ配的是2+3\`匹配的是 2+3,' 匹配的是 5。因此使用 "&`&'$&" 便达成了目的。

var result = '2+3=5'.replace(/=/, "$&$`$&$'$&")
console.log(result)
// => "2+3=2+3=5=5"
1
2
3
  • 当第二个参数是函数时,我们需要注意该回调函数的参数具体是什么?
'1234 2345 3456'.replace(/(\d)\d{2}(\d)/g, function(
  match,
  $1,
  $2,
  index,
  input
) {
  console.log([match, $1, $2, index, input])
})
// => ["1234", "1", "4", 0, "1234 2345 3456"]
// => ["2345", "2", "5", 5, "1234 2345 3456"]
// => ["3456", "3", "6", 10, "1234 2345 3456"]
1
2
3
4
5
6
7
8
9
10
11
12

  1. 0-9A-Za-z ↩︎

以父之名
周杰伦.mp3