零宽匹配:正则在字符之间的「位置」上移动
"good".match(/o*/g) 的结果是 ["", "oo", "", ""]——明明只有一段 oo,
却凭空多出三个空串。要解释它,得先放下「正则在匹配字符」这个直觉:正则引擎真正站立的地方,是字符之间的位置。
长度为 n 的字符串有 n+1 个位置;引擎从某个位置出发尝试匹配,有的写法会消耗字符往前走,
有的写法只校验当前位置、一个字符都不吃(零宽匹配)。这页把 token 按「消不消耗字符」分成三类,
单步看一次全局扫描在每个位置产出了什么,最后用千分位逗号演示「在位置上插入」这一零宽匹配的实战用途。
空串从哪来:位置,而不是字符
* 的含义是「前面的元素出现零次或多次」,所以 o* 既能匹配 oo,也能匹配
零个 o——也就是空串 ""。把 "good" 摊成位置看:
位置: 0 1 2 3 4 字符: g o o d
带 g 的全局匹配会从位置 0 起一路扫到末尾。在位置 1,o* 一口气吃下 oo;
而在位置 0(g 前)、位置 3(d 前)、位置 4(串尾),它都匹配不到 o,
于是退而求其次匹配「零个 o」= 空串。四个位置各产出一项,正好凑成 ["", "oo", "", ""]。
把 o* 换成 o+(要求至少一个),不允许空匹配,结果就只剩 ["oo"]。
单步看扫描:每个位置产出什么
下面对任意 pattern 跑满 exec 循环。编号方块是字符之间的位置,高亮位置
是当前扫描点。每一步要么消耗若干字符(高亮字符格),要么是一次零宽命中(位置上出现 ∅);
右侧逐项累积出 match() 的最终数组。
g
位置标尺(▣ = 当前扫描位置 · ∅ = 这里发生零宽命中 · 绿格 = 被消耗的字符)
match() 命中(虚线 ∅ = 空串命中 · @ 后是命中位置)
lastIndex 状态问题)是有状态的全局匹配页的主题,
本页只关心一个更基础的问题:哪些位置会产出命中、命中消不消耗字符。
消耗字符 vs 匹配位置:把写法分三类
把常见 token 放到同一个样本上跑全局匹配,按「会不会吃字符」分成三类。注意第三类(纯位置 · 零宽断言) 命中的全是空串——它们只校验「这个位置满不满足条件」,从不把字符纳入命中:
^ $ \b \B 与环视 (?=…) (?<=…) 是纯粹的位置断言,
永远零宽;\d [a-z]+ 这类一定消耗至少一个字符;而带 * / ? 的量词
下限为 0,所以可能零宽——能匹配字符时消耗、匹配不到时退化成空串。"good".match(/o*/g) 的空串,
正是 o* 落在第三类、又恰好没 o 可吃的结果。
零宽匹配的实战:在位置上插入
零宽不是只会制造意外。给数字加千分位逗号的经典写法 str.replace(/\B(?=(\d{3})+(?!\d))/g, ",")
全靠零宽匹配:\B 锁定「两个数字之间」的位置,环视 (?=(\d{3})+(?!\d)) 进一步要求
「从这个位置往右数,剩下的数字个数恰是 3 的整数倍」。所有命中都是空串,replace 不替换任何原字符,
只是在这些位置插入逗号:
命中位置(, = 零宽命中处,逗号将插入到这里)
o* 的空串、\b 切出来的空匹配、
lookaround 不吃字符,都成了同一件事的不同表现。至于这些空匹配在全局模式下如何推进 lastIndex、
为什么 matchAll 强制要 g,见有状态的全局匹配页。