JS 正则表达式:测试器里通过、代码里失败的原因

更新于 5 分钟阅读

在测试器里处处匹配、放进代码却失败的模式,多数时候不是水平问题。正则语法并没有统一标准: JavaScript 跟随 ECMAScript,Python 有自己的 re 模块,PHP、Ruby、Java 和多数命令行工具则接近 PCRE。 从测试器或另一种语言复制模式时,它的方言也一起被复制了过来。

$ 在两种引擎里含义不同

不带 m 时,JavaScript 的 $ 只匹配整个输入的结尾,所以 /^a$/.test("a\n") 是 false。同一个模式在 PCRE 和 Python 里能匹配,因为它们的 $ 还接受结尾换行符之前的那个位置。可以记成:JavaScript 里普通的 $ 就是 PCRE 的 \z,绝对结尾,没有余地。把可选换行写进模式即可——/^a(?:\r?\n)?$/ 能匹配 "a\n"。 加上 m 之后,$ 会在每个换行之前以及结尾匹配,/^a$/m.test("a\n") 为 true,但 m 同时重定义了 模式里的所有锚点。

JavaScript 没有 \A,也没有 \Z。不加 u 时 /\A/ 就等价于 /A/,那个转义按字面字母 A 处理; 加了 u,/\A/u 直接是 SyntaxError。整段文本开头的语义,本来就是不带 m 的 ^。

\d 匹配的不是”数字”

JavaScript 里 \d 严格等于 [0-9],只有十个 ASCII 数字,加不加 u 都一样;Python 3 的 \d 默认匹配 Unicode 十进制数字。1 两边都匹配,而 ٥(阿拉伯-印度数字 5,U+0665)和 5(全角 5,U+FF15)在 Python 里匹配、在 JavaScript 里不匹配——这就是同一个校验在测试器里通过、却拒收用户用中文输入法敲出的 号码。JavaScript 里的 Unicode 数字类是属性转义 \p{Nd},它需要 u:/^\d+$/u.test("٥") 为 false, /^\p{Nd}+$/u.test("٥") 为 true。描述汉字可以用 \p{Script=Han},同样依赖 u,而且在较旧的浏览器上 根本不支持。

\w 和 \b 在 u 下仍是 ASCII 语义:汉字不是单词字符,所以 /\b中文\b/ 在 这是中文测试 里永远 匹配不到,因为那里并不存在边界。例外是 \s,它认得 U+3000 全角空格和不换行空格。

JavaScript 没有这些语法

写法来自哪里在 JavaScript 里
(?<name>…)ECMAScript、PCRE、.NET支持;Python 的 (?P<name>…) 和 Perl 的 (?'name'…) 无法编译
(?<=…)、(?<!…)PCRE、Python、.NETV8 与 Firefox 能解析;Safari 最晚跟进,旧浏览器可能直接编译失败
\K仅 PCRE匹配字面字母 K,加了 u 则编译报错
(?>…)、*+PCRE、Java不支持:在当前 V8 里即使加上 v 也是语法错误
(?i)、(?s)PCRE、Python裸行内标志从来不存在;(?i:…) 只在很新的引擎里才有

注意失败的方式:不被支持的写法是在构造正则时抛错,而不是安静地匹配不到。在 PCRE 测试器里”能用”、 贴进页面就崩的模式,从来就不是可移植的。

替换字符串是第二套语法

匹配测试通过,说明不了替换的结果:在传给 String.replace 的替换串里,$ 是转义符。

  • $& 是整个匹配,$` 是它之前的文本,$' 是它之后的文本。
  • $1 到 $9 是编号分组,$<name> 是命名分组,$$ 会缩成一个 $。
  • $0 不是特殊序列,原样保留。
  • 只有一个分组时,$10 是第 1 组再加字符 0,所以 "a1b".replace(/a(\d)/, "$10") 得到 10b。

Python 的 re.sub 用 \1 和 \g<name>,并把 $ 当普通字符,因此往任一方向粘贴都会静默得到错误结果。 替换内容本身就是数据时,改传函数:"abc".replace(/b/, () => "$$1") 返回 $$1,因为回调的返回值不会被 再次解释。

带 g 标志的正则对象有状态

g 或 y 下,.test() 和 .exec() 会读写 lastIndex,共享对象不会两次给出同样的答案:

const re = /a/g;
re.test("ab");   // true
re.test("ab");   // false,上一次调用已经把 lastIndex 前移了
re.test("ab");   // true

答案取决于这个对象此前被用过几次,这正是间歇性 Bug 的来源。函数内部的正则字面量每次求值都会新建对象, 所以麻烦出自提到模块顶层、缓存进变量或挂在类字段上的模式。只是问”是否匹配”就去掉 g,否则先置 re.lastIndex = 0。.match() 与 .replace()(带 g)以及 .matchAll() 不继承旧状态,结束时把 lastIndex 归 0。

手写 exec 循环还有一个坑:零宽匹配不会推进 lastIndex,不加一句 if (re.lastIndex === m.index) re.lastIndex++,循环就永不结束。

u 标志、代理对与”一个字”

字符串是 UTF-16 代码单元的序列,基本平面之外的表情符号占两个单元:"\u{1F600}".length 是 2,不带 u 时一个 . 只消耗一个单元,于是 /^.{2}$/ 把它当成两个字符。加上 u,. 与量词按码位计数, /^.$/u 把它匹配成一个。"\u{1F468}\u200D\u{1F469}\u200D\u{1F467}" 是 8 个代码单元、5 个码位,屏幕上 却是一个图形——.length 和 u 下的 . 都不符合读者的直觉。数码位用 Array.from(s).length, 图形簇用 Intl.Segmenter。

u 还会把宽容变成报错:\A、\K 不再匹配字母,而是编译失败。需要 . 跨行时使用 s 标志,因为这里 没有 (?s)。

取全部匹配项用 matchAll

matchAll 是收集所有匹配的合理写法:它要求 g,内部会克隆正则,因而不会动你对象的 lastIndex, 命名分组挂在 match.groups 上,遇到空匹配也会前进——"ab".matchAll(/(?:)/g) 给出三个位置而不是卡死。

怎么测才不会翻车

在你真正要跑的引擎里测,并把打算上线的标志一起写上:同一个模式带 u 与不带 u 是两个匹配器。替换结果 单独验证,因为它有自己的一套转义。若模式还要在服务端语言里工作,就按锚点、数字类、分组语法逐条改写, 而不是假设某个测试器同时代表两边。页面下方链接的正则测试器跑在你浏览器自带的引擎上,那才是你的页面 唯一能用的方言。

引擎行为一直在变:后行断言、(?i:…) 这类作用域标志、属性转义在不同浏览器上的落地时间不同,旧设备至今仍不齐。 要紧的细节,请在你要部署的运行时里验证一次,而不是相信任何一张对照表,包括这一张。

打开工具: 正则表达式测试工具

返回指南列表

更多指南