JS 正则表达式:测试器里通过、代码里失败的原因
在测试器里处处匹配、放进代码却失败的模式,多数时候不是水平问题。正则语法并没有统一标准:
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、.NET | V8 与 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:…) 这类作用域标志、属性转义在不同浏览器上的落地时间不同,旧设备至今仍不齐。
要紧的细节,请在你要部署的运行时里验证一次,而不是相信任何一张对照表,包括这一张。