书签

你还没有保存任何书签. 要将某篇文章加入书签, 请点击

  • 从一道题目谈谈 JavaScript 正则里的两个问题

  • 前言

    正则表达式是一种非常强大的工具,本人自认为掌握的还算可以,甚至已经进入了手里拿着锤子,看什么都是钉子的状态,(突然想起来了知乎上之前热议的那位七牛前端,就是那个写了一个又臭又长还不正确的正则来验证端口那个,有兴趣的同学可以点这里围观一下

    最近在看刷各种题目,LeetCode 刷不动就去看各路的前端水平的算法题目,其中有这样一道题:

    将一个任意长的数字变成逗号分割的格式。

    这道题目并不难,网上也有很多解答,即使是正则解法,也有很多,但我今天要谈的主要不是怎么解答,而是解答过程中本人发现的一个问题,个人姑且称之为 BUG,在这个问题之后,本人也将分享一下另一个 JavaScript 正则中令人疑惑的问题。

    为专注本文要说明的问题,本文只考虑整数,如果有小数用split方法先处理一下即可。

    问题重现

    我们很容易得到结论,要将一个数字变成逗号分割的形式,就要从数字的后面开始往前数,每隔三个数字添加一个逗号,最前面不加逗号。那如果用正则做的话,我们需要做的就是匹配出这些三数字组,然后在每一个的前面加一个逗号即可。

    思路很简单,实现应该也不难,但开始我写的正则是这样的:

    const regexp = /(?<!^)(\d{3})*$/
    

    这个正则比较容易理解,意思就是我们要匹配0个或3的整数个数字,这些数字不能是开头,必须是结尾,其中(\d{3})进行了捕获,我很天真的以为,这样就能匹配到每一个三数字组了,当然我并没有考虑具体怎么替换(而且后来我确实发现即使一切如我预期这样做似乎也不大可行或者比较麻烦),但这都与我们要谈的问题无关。

    作为示例,我们取一个数字,我们看一下匹配一下的结果会是啥:

    const num = '1234567890'
    console.log(num.match(regexp))
    

    reg-match-result-1.png

    结果里第一个是匹配到的完整结果,第二个开始是捕获组,我们发现,只有最后一个890被捕获了,前面的234,567则明显被忽略了。

    也就是说,如果是同类型多次匹配,即使我们用括号包裹表明这是一个捕获组,正则引擎也只会给我们留下最后一个,其他的统统忽略,我们举个简单例子说明一下:

    const testStr = 'abc'
    const testReg = /(.)*/
    console.log(testStr.match(testReg))
    

    匹配的结果将是['abc','c']:

    ["abc", "c", index: 0, input: "abc", groups: undefined]
    0: "abc"
    1: "c"
    groups: undefined
    index: 0
    input: "abc"
    length: 2
    __proto__: Array(0)
    

    很明显,只有 c 作为最后一个被捕获的被保存了下来,如果我们换成/(.)(.)(.)/的形式,则每一个都会被捕获:

    const testReg2 = /(.)(.)(.)/
    console.log(testStr.match(testReg2))
    

    匹配的结果将是我们预期的['abc','a','b','c']

    ["abc", "a", "b", "c", index: 0, input: "abc", groups: undefined]
    0: "abc"
    1: "a"
    2: "b"
    3: "c"
    groups: undefined
    index: 0
    input: "abc"
    length: 4
    __proto__: Array(0)
    

    有什么影响

    首先说明一下,本文不探讨怎么更好的匹配 URL,主要演示一下这个问题的影响。

    正则引擎这样处理可能也并没有什么问题,只是从逻辑上讲,我觉得还是每一个都捕获比较合理,我觉得我需要了解一下其他正则引擎是怎么处理的,不知道是不是对正则有更深入的了解后才能明白这样才更合理。至于有什么影响,大概并不会有什么大的影响,只是这不符合一般思维,会有些别扭。

    如果考虑到实际应用的话,我暂时能想到的一个地方就是匹配 URL,比如我们有一个不定个数参数的 URL,我们想要一次匹配就获取到所有参数,举个例子,我们的URL可能是这样的,后面可能有一个两个或者更多个请求参数:

    const url = 'http://www.mysite.com/path/sub/?xxx=xxx&yyy=yyy&zzz=zzz'
    

    我们要写一个正则获取到协议、网址、路径、以及每一个参数,大概会是这样

    const urlRegexp = /(https?):\/\/((?:\w+\.)+\w+)\/((?:\w+\/)*)\?((\w+=\w+&?)*)/
    

    这段正则能够匹配与上面给出 URL 类似格式的网址,域名可以是1个或多个.相隔,路径可以为空或多个,参数也可以为空或多个,并能直接从匹配结果中获取协议名、网址、路径,以及参数,当然只能是整体,上面的正则我没有在最后的参数那里和前面一样添加?:,这表示不捕获,因为添加了这个,所以结果中的网址和路径就只包含整体结果了,在参数这里我没加,主要是用于区分,匹配结果如下:

    console.log(url.match(urlregexp))
    

    reg-match-result-2.png

    并没有办法实现我想象中的那种一个正则直接获取到最终结果的办法,有点不够好对吧。

    这道题目的一个正则解答

    既然已经提到了题目,不给出解答自然是不好的,我使用的正则描述起来是这样的:匹配三个数字,这三个数字要满足两个条件:前面不能是开头,后面要正好是3的整数倍个数字(0个也可以)。

    写起来就是这样的:

    • 不能是开头:(?<!^)
    • 三个数字:(\d{3})
    • 后面要正好是3的整数倍个数字:(?=(\d{3})*$)

    合起来就是 /(?<!^)(\d{3})(?=(\d{3})*$)/g,我们需要一个g表示全局匹配,测试一下:

    const newRegexp = /(?<!^)(\d{3})(?=(\d{3})*$)/g
    const numStr = '1234567890'
    console.log(numStr.match(newRegexp))
    

    reg-match-result-3.png

    这回跟我们的预期就一样了,这个问题答案也就有了:

    function addComma(num) {
      const regexp=/(?<!^)(\d{3})(?=(\d{3})*$)/g
      return String(num).replace(regexp,','+ '$1')
    }
    

    正则中的另一个问题

    了解正则表达式的 lastIndex 属性

    这部分要提的这个问题的来源就是 RegExp 对象中的 lastIndex 属性,它会在每次匹配后更新自己的值,这会导致出现很多让人疑惑不已的结果,下面就演示一下:

    const regexp = /hello/g
    const str = 'helloworld'
    console.log(regexp.test(str))
    console.log(regexp.test(str))
    console.log(regexp.test(str))
    console.log(regexp.test(str))
    console.log(regexp.test(str))
    console.log(regexp.test(str))
    console.log(regexp.test(str))
    console.log(regexp.test(str))
    console.log(regexp.test(str))
    console.log(regexp.test(str))
    

    你没看错,我打了十行 console.log,结果如果你试过就会发现是这样子的:
    reg-match-result4.png

    交替输出truefalse,同一道问题竟然还有不同答案,真是令人诧异,当然了解的朋友就知道,这是正则表达式对象的内置属性 lastIndex 在做怪,我们演示一下:

    const regexp = /hello/g
    const str = 'helloworld'
    console.log(regexp.test(str))
    console.log(regexp.lastIndex)
    console.log(regexp.test(str))
    console.log(regexp.lastIndex)
    console.log(regexp.test(str))
    console.log(regexp.lastIndex)
    console.log(regexp.test(str))
    console.log(regexp.lastIndex)
    

    可以看到 lastIndex 属性也在交替变化,这个属性代表上次匹配成功后本次要开始匹配的位置,这自然有设计合理的地方,但我们这么用的话确实看起来就很奇怪。

    reg-match-result5.png
    有一点必须要了解,那就是这个属性是属于正则表达式对象的,如果你每次都使用不同的正则表达式对象,就不存在这个问题,这可以用一个函数来演示:

    (function regexpTest(){
      const str = 'helloworld'
      for (let i=0;i<10;i++){
        const regexp = /hello/g
        console.log(regexp.test(str))
      }
    })()
    

    这个函数将输出10个true,因为我们每一次循环都创建了一个新的正则表达式对象,从而规避了lastIndex的干扰。

    reg-match-result6.png

    问题演示

    讲到这里其实姑且可以认为这个设计没有什么问题,是我们的使用方式不对,我要说的类似于BUG 的是另一个问题,那就是这个属性是正则表达式对象的属性,但却并不同时记忆上一次匹配的到底是哪个字符串,当你用同一个正则表达式去 test 不同的字符串的时候,就会发生很奇怪的问题,MDN 文档里也特别提及了这一点:

    reg-test-mdn.png

    注意那句:“值得一提的是,当测试不同的字符串时,lastIndex 属性并不会重置。”

    const regexp = /hello/g
    const str1 = 'helloworld'
    const str2 = 'hellokitty'
    console.log(regexp.test(str1))
    console.log(regexp.lastIndex)
    console.log(regexp.test(str2))
    console.log(regexp.lastIndex)
    

    reg-test-lastindex.png

    即使是第一次 test 第二个字符串,依旧因为第一个字符串的干扰而 false 了。

    解决办法

    相同字符串干扰可以理解,但不同字符串干扰就说不过去了,不过我仔细想了一下,似乎问题的根源在于我们本不该用包括参数'g'的正则去 test 一个字符串,所以要规避的办法也很简单,更加合理的使用正则表达式,大概就是这么简单。

    参考文档:

    本文来自于个人笔记,当时参考的内容已不可考,只能空荡荡的感谢一下,深入了解可以参考MDN 文档