JavaScript 中的负向回顾后发断言

178
有没有一种方法可以在JavaScript正则表达式中实现类似于负向后查找的效果? 我需要匹配不以特定字符集合开头的字符串。

似乎我找不到一个正则表达式能够做到这一点,而且如果匹配部分出现在字符串开头时就会失败。 负向后查找似乎是唯一的答案,但JavaScript并没有这个功能。

这是我想要使用的正则表达式,但它不能正常工作:

(?<!([abcdefg]))m

所以它将匹配'jim'或'm'中的'm',但不会匹配'jam'。


考虑将正则表达式作为负回顾后缀发布,这可能会更容易做出响应。 - Daniel LeCheminant
1
那些想要追踪lookbehind等的采用情况的人,请参考ECMAScript 2016+兼容性表 - Wiktor Stribiżew
@WiktorStribiżew:正则表达式中的后行断言是在2018规范中添加的。Chrome支持它们,但是Firefox仍未实现该规范 - Lonnie Best
这个需要向后查看吗?(?:[^abcdefg]|^)(m) 呢?就像 "mango".match(/(?:[^abcdefg]|^)(m)/)[1] 这样。 - slebetman
11个回答

110
自2018年起,Lookbehind Assertions已成为ECMAScript语言规范的一部分。
// positive lookbehind
(?<=...)
// negative lookbehind
(?<!...)

2018年以前的回答

由于Javascript支持负向先行断言,一种方法是:

  1. 反转输入字符串

  2. 使用反转的正则表达式进行匹配

  3. 将匹配结果反转并重新格式化


const reverse = s => s.split('').reverse().join('');

const test = (stringToTests, reversedRegexp) => stringToTests
  .map(reverse)
  .forEach((s,i) => {
    const match = reversedRegexp.test(s);
    console.log(stringToTests[i], match, 'token:', match ? reverse(reversedRegexp.exec(s)[0]) : 'Ø');
  });

例子1:

根据@andrew-ensley的问题:

test(['jim', 'm', 'jam'], /m(?!([abcdefg]))/)

输出:

jim true token: m
m true token: m
jam false token: Ø

例子2:

根据@neaumusic的评论(匹配max-height但不匹配line-height,令牌为height):

test(['max-height', 'line-height'], /thgieh(?!(-enil))/)

输出:

max-height true token: height
line-height false token: Ø

38
这种方法的问题在于,当你既有前瞻又有后顾时,它无法起作用。 - kboom
3
可以请你展示一个实例吗?比如说我想匹配 max-height 但不是 line-height,而且我只想匹配 height - neaumusic
1
如果任务是替换两个连续的相同符号(不超过2个),而且这些符号没有前置符号,那么''(?!\()将从另一端替换''(''test'''''''test中的撇号,因此留下(''test'NNNtest而不是(''testNNN'test - Wiktor Stribiżew

90

回顾断言在2018年被接受进入了ECMAScript规范

Positive lookbehind usage:

console.log(
  "$9.99  €8.47".match(/(?<=\$)\d+\.\d*/) // Matches "9.99"
);

Negative lookbehind usage:

console.log(
  "$9.99  €8.47".match(/(?<!\$)\d+\.\d*/) // Matches "8.47"
);

平台支持


苹果终于着手实现反向查找,因此它很快就会出现在iOS和Safari的更新中。 - T.J. Crowder
1
该功能今天已经在Safari 16.4 beta 1中推出。 - Okku

71

假设您想查找所有未在unsigned之前的int:

使用负回顾支持:

(?<!unsigned )int

没有负回顾支持:

((?!unsigned ).{9}|^.{0,8})int

基本思想是获取前n个字符并排除与负向预查的匹配,但也要匹配没有前n个字符的情况。这里的 n 是后置断言的长度。

因此,所讨论的正则表达式为:

(?<!([abcdefg]))m

将会被翻译为:

((?!([abcdefg])).|^)m

你可能需要使用捕获组来找到你感兴趣或想替换特定部分的字符串的确切位置。


3
这应该是正确答案。参见: "So it would match the 'm' in 'jim' or 'm', but not 'jam'".replace(/(j(?!([abcdefg])).|^)m/g, "$1[MATCH]") 返回结果为 "So it would match the 'm' in 'ji[MATCH]' or 'm', but not 'jam'"。 很简单,而且它有效! - Asrail
太棒了!使用负向先行断言作为旧版 JavaScript 的解决方法! - Peter Thoeny
1
能帮我解决这个吗:/\B(?<!.\d*)(?=(\d{3})+(?!\d))/g - TheEhsanSarshar

41

Mijoja的策略对于你具体的情况有效,但不适合所有情况:

js>newString = "Fall ball bill balll llama".replace(/(ba)?ll/g,
   function($0,$1){ return $1?$0:"[match]";});
Fa[match] ball bi[match] balll [match]ama

以下是一个示例,目标是匹配双“l”,但不要在其前面加上“ba”。请注意单词“balll” - 真实的后顾应该抑制了前两个“l”,但匹配了第二对。但通过匹配前两个“l”,然后将其视为误报,正则表达式引擎从该匹配的结尾继续,并忽略误报中的任何字符。


6
啊,你说得对。然而,这比之前要接近得多。在有更好的解决方法出现之前,我可以接受这个方案(例如JavaScript实际上实现了回顾后发断言)。 - Andrew Ensley

33
使用
newString = string.replace(/([abcdefg])?m/, function($0,$1){ return $1?$0:'m';});

10
这并没有实际作用:newString始终等于string。为什么会有这么多赞? - MikeM
@MikeM:因为重点在于展示一种匹配技术。 - bug
59
“@bug. 一个没有任何作用的演示是一种奇怪的演示。答案似乎只是复制粘贴而没有理解它的工作原理。因此缺乏附加说明并未能证明已匹配到任何内容。” - MikeM
3
SO的规则是,如果回答了问题按原文提出的方式,那么就是正确的。原帖并没有指定使用情境。 - bug
7
这个概念是正确的,但演示得不太好。尝试在JS控制台中运行以下代码:"Jim Jam Momm m".replace(/([abcdefg])?m/g, function($0, $1){ return $1 ? $0 : '[match]'; });。它应该返回 Ji[match] Jam Mo[match][match] [match]。但请注意,正如Jason在下面提到的那样,它在某些边缘情况下可能会失败。 - Simon East
我已经写了更全面的答案,其中包含如何使用正向和负向回顾来匹配和替换的详细说明。现在我找到了这个问题,我也将我回答的那个问题标记为这个问题的重复。 - Adam Katz

13

你可以通过否定字符集来定义一个非捕获组:

(?:[^a-g])m

...这将匹配每个未被任何这些字母之一前置的m


2
我认为这个匹配实际上也会包括前一个字符。 - Sam
5
这是正确的。字符类表示...一个字符!你的非捕获组只是不在替换上下文中提供该值。你的表达式并没有说“每个m都不是由这些字母中的任何一个前面”,它是说“每个m前面有一个字符,而这个字符不是这些字母中的任何一个”。 - theflowersoftime
6
为了让答案解决原始问题(字符串开头),它还必须包括一个选项,因此生成的正则表达式将是 (?:[^a-g]|^)m。请参见 https://regex101.com/r/jL1iW6/2 以获取运行示例。 - Johny Skovdal
使用void逻辑并不总是产生期望的效果。 - GoldBishop

2

这是我在 Node.js 8 中实现 str.split(/(?<!^)@/)(因为不支持后顾断言)的方式:

最初的回答:

str.split('').reverse().join('').split(/@(?!$)/).map(s => s.split('').reverse().join('')).reverse()

能用吗?可以(unicode未经测试)。舒适度如何?不太好。

最初的回答:


1

在Mijoja的思路基础上,结合JasonS提出的问题,我有了这个想法;我进行了一些检查,但不确定自己是否正确,因此希望有比我更专业的js正则表达式方面的人进行验证:)

var re = /(?=(..|^.?)(ll))/g
         // matches empty string position
         // whenever this position is followed by
         // a string of length equal or inferior (in case of "^")
         // to "lookbehind" value
         // + actual value we would want to match

,   str = "Fall ball bill balll llama"

,   str_done = str
,   len_difference = 0
,   doer = function (where_in_str, to_replace)
    {
        str_done = str_done.slice(0, where_in_str + len_difference)
        +   "[match]"
        +   str_done.slice(where_in_str + len_difference + to_replace.length)

        len_difference = str_done.length - str.length
            /*  if str smaller:
                    len_difference will be positive
                else will be negative
            */

    }   /*  the actual function that would do whatever we want to do
            with the matches;
            this above is only an example from Jason's */



        /*  function input of .replace(),
            only there to test the value of $behind
            and if negative, call doer() with interesting parameters */
,   checker = function ($match, $behind, $after, $where, $str)
    {
        if ($behind !== "ba")
            doer
            (
                $where + $behind.length
            ,   $after
                /*  one will choose the interesting arguments
                    to give to the doer, it's only an example */
            )
        return $match // empty string anyhow, but well
    }
str.replace(re, checker)
console.log(str_done)

我的个人输出:

Fa[match] ball bi[match] bal[match] [match]ama

原则是在字符串中的任意两个字符之间的每个位置调用checker,每当该位置是以下内容的起始点时:
--- 任何大小不为所需大小(这里是'ba',因此是..)的任何子字符串(如果已知该大小;否则可能更难做到)
--- --- 或小于该大小,如果它是字符串的开头:^.? 然后,在此之后,
--- 所要实际寻找的内容(这里是'll')。
在每次调用checker时,将进行测试以检查ll之前的值是否不是我们不想要的(!== 'ba');如果是这种情况,则调用另一个函数,它必须是这个函数(doer),如果目的是这个,那么它将对str进行更改,或者更一般地说,它将获得输入来手动处理扫描str的结果所需的数据。

在这里,我们更改了字符串,因此需要保留长度差异的跟踪,以便抵消由replace给出的位置,所有这些都是在str上计算的,它本身永远不会改变。

由于原始字符串是不可变的,我们可以使用变量str来存储整个操作的结果,但我认为例子已经被替换复杂化了,使用另一个变量(str_done)会更清晰。

我想在性能方面,这可能非常严格:所有这些毫无意义的''到''的替换,this str.length-1次,加上手动替换,这意味着大量的切片...... 在这个特定的例子中,可能可以通过仅将字符串切成围绕我们要插入的地方的片段,并使用[match]本身进行.join()来进行分组。

另一件事是,我不知道它如何处理更复杂的情况,也就是虚假后顾的复杂值......长度可能是最难处理的数据。

checker中,如果$behind存在多个不想要的值的可能性,我们将不得不使用另一个正则表达式对其进行测试(最好在checker之外缓存(创建),以避免在每次调用checker时创建相同的正则表达式对象),以知道它是否是我们要避免的内容。
希望我表述清楚了;如果没有,请不要犹豫,我会尝试更好地解释。 :)

1
使用您的案例,如果您想要替换m为其他内容,例如将其转换为大写字母M,您可以在捕获组中否定集合。
匹配([^a-g])m,替换为$1M
"jim jam".replace(/([^a-g])m/g, "$1M")
\\jiM jam

([^a-g])将匹配a-g范围之外的任何字符,并将其存储在第一个捕获组中,因此您可以使用$1访问它。

因此,我们在jim中找到im并用iM替换它,结果为jiM


1
如前所述,JavaScript 现在支持向后查找。在旧版浏览器中,您仍然需要使用解决方法。
我敢打赌,没有办法找到一个不使用向后查找的正则表达式可以精确地提供结果。你能做的就是使用分组。假设你有一个正则表达式 (?<!Before)Wanted,其中 Wanted 是你想匹配的正则表达式,Before 是计算出什么不应该在匹配之前的正则表达式。你所能做的最好的事情就是否定正则表达式 Before 并使用正则表达式 NotBefore(Wanted)。期望的结果是第一个组 $1
在你的情况下,Before=[abcdefg],很容易否定 NotBefore=[^abcdefg]。所以正则表达式将是 [^abcdefg](m)。如果你需要 Wanted 的位置,你必须把 NotBefore 也分组,这样期望的结果就是第二个组。
如果Before模式的匹配具有固定长度n(即,如果该模式不包含重复标记),则可以避免否定Before模式,并使用正则表达式(?!Before).{n}(Wanted),但仍需使用第一组或使用正则表达式(?!Before)(.{n})(Wanted)并使用第二组。在此示例中,模式Before实际上具有固定长度,即1,因此使用正则表达式(?![abcdefg]).(m)(?![abcdefg])(.)(m)。如果您对所有匹配感兴趣,请添加g标志,查看我的代码片段:
function TestSORegEx() {
  var s = "Donald Trump doesn't like jam, but Homer Simpson does.";
  var reg = /(?![abcdefg])(.{1})(m)/gm;
  var out = "Matches and groups of the regex " + 
            "/(?![abcdefg])(.{1})(m)/gm in \ns = \"" + s + "\"";
  var match = reg.exec(s);
  while(match) {
    var start = match.index + match[1].length;
    out += "\nWhole match: " + match[0] + ", starts at: " + match.index
        +  ". Desired match: " + match[2] + ", starts at: " + start + ".";   
    match = reg.exec(s);
  }
  out += "\nResulting string after statement s.replace(reg, \"$1*$2*\")\n"
         + s.replace(reg, "$1*$2*");
  alert(out);
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接