零宽匹配:正则站在字符之间
"good".match(/o*/g) 的结果是 ["", "oo", "", ""]——明明只有一段 oo,却凭空多出三个空串。要解释它得先放下「正则在匹配字符」这个直觉:引擎真正站立的地方是字符之间的位置。长度为 n 的字符串有
n+1 个位置;引擎从某个位置出发尝试匹配,有的写法会消耗字符往前走,有的只校验当前位置、一个字符都不吃。
1 · 空串从哪来
* 的含义是「前面的元素出现零次或多次」,所以 o* 既能匹配 oo,也能匹配零个 o,也就是空串。把 "good" 摊成位置来看:位置 0 到 4 共五个,字符夹在它们之间。带 g 的全局匹配从位置 0 一路扫到末尾,在位置 1 一口气吃下
oo;而在位置 0、位置 3 与位置 4 都匹配不到 o,于是退而求其次匹配零个 o。四处各产出一项,正好凑成那个四元素数组。把 o* 换成 o+,要求至少一个,不允许空匹配,结果就只剩 ["oo"]。
注 · 零宽命中后扫描位置若原地不动,就会在同一处反复命中空串、陷入死循环。规范的破法是命中空串后把位置强制加一。这套推进规则以及它引出的 lastIndex 状态问题,是 有状态的全局匹配 的主题;本页只关心哪些位置会产出命中、命中消不消耗字符。
2 · 三类写法
把常见写法放到同一个样本上跑全局匹配,按会不会吃字符分成三类。锚点 ^ $ \b \B 与环视是纯粹的位置断言,永远零宽,命中的全是空串;\d 与 [a-z]+ 这类一定消耗至少一个字符;而带 * 或 ? 的量词下限为
0,所以可能零宽——能匹配字符时消耗,匹配不到时退化成空串。o* 的那些空串,正是第三种情形。
3 · 在位置上插入
零宽不是只会制造意外。给数字加千分位逗号的经典写法 str.replace(/\B(?=(\d{3})+(?!\d))/g, ",") 全靠它:\B 锁定「两个数字之间」的位置,环视进一步要求从这个位置往右数、剩下的数字个数恰是 3 的整数倍。所有命中都是空串,replace
不替换任何原字符,只是在这些位置插入逗号。
建议 · 正则匹配的从来不是「一个字符」,而是「从某个位置出发能否找到符合模式的内容」,而符合的内容允许是零长度。记住这点,o* 的空串、\b 切出的空匹配、环视不吃字符,就都是同一件事的不同表现。这些空匹配在全局模式下如何推进 lastIndex,见
有状态的全局匹配。
4 · 参考文献
- Ecma International. ECMA-262: RegExpBuiltinExec. 全局匹配的推进规则与空匹配的处理,
lastIndex加一的出处。tc39.es - MDN. Lookahead assertion. 环视的语义与零宽含义;同目录下另有输入边界与词边界两条。developer.mozilla.org
- MDN. String.prototype.match(). 带
g与不带g时返回值的差别。developer.mozilla.org