regex 总览待审核
位置 · 零宽匹配 · 锚点与 lookaround

零宽匹配:正则在字符之间的「位置」上移动

"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() 命中(虚线 ∅ = 空串命中 · @ 后是命中位置)

零宽命中后,扫描位置若原地不动就会在同一处反复命中空串、陷入死循环;规范的破法是命中空串后把扫描位置强制 +1。这套推进规则(以及它引出的 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,见有状态的全局匹配页。