上手:真实场景与边界
修饰符
它在解决什么
修饰符写在正则字面量末尾那个斜杠的后面(/pattern/gi),
或者作为 new RegExp(pattern, 'gi') 的第二个参数。它们不改变模式本身,
而是改变引擎怎么用这个模式。
这一篇里有一个修饰符值得单独花掉大半篇幅:g。
它是 JavaScript 里最容易写出间歇性 bug 的东西——每隔一次就错一次,
不报错、不抛异常,测试跑一遍还可能是绿的。
六个修饰符
| 字母 | 名字 | 作用 |
|---|---|---|
g |
global | 找完一个继续往后找;引入 lastIndex 状态 |
i |
ignoreCase | 忽略大小写 |
m |
multiline | ^ $ 改成认每行的行首行尾 |
s |
dotAll | 让 . 也匹配换行 |
u |
unicode | 按码点而不是 UTF-16 码元处理;\p{…} 的前提 |
y |
sticky | 只从 lastIndex 那个位置匹配,不往后找 |
m 在锚点那篇已经讲透了,这里不重复。
另外还有两个较新的:d(给结果附上每个组的起止下标)和
v(u 的升级版,字符类里支持集合运算)——用到的时候再查,不影响理解前六个。
🚨 g 有状态:这一篇最该记住的一件事
带 g 的正则对象身上挂着一个 lastIndex,记着「上次找到哪了」。
下一次调用 test() 或 exec() 从那里继续。
于是同一个对象、同一个输入,连续调用的结果会变:
const re = /\d+/g; 然后对 'a1b' 连测两次,得到 [true, false]。
第 1 次 test('a1b') 从下标 0 找 → 找到 '1' → lastIndex = 2 → true
第 2 次 test('a1b') 从下标 2 找 → 后面只剩 'b' → 没找到 → true 变 false
失败时 lastIndex 被重置回 0
第 3 次 test('a1b') 又从 0 开始 → true
真实表现是 true、false、true、false 地交替。 这就是「间歇性」的来源:
// 🚨 模块级常量 + g,是这个 bug 最常见的现场
const HAS_DIGIT = /\d+/g;
function check(s) {
return HAS_DIGIT.test(s); // 每隔一次就返回错误答案
}
写单元测试时如果只调一次,它是绿的。上线之后,同一个输入时对时错。
四条躲开它的办法
1. 判断有没有,就别带 g。 test() 从来不需要 g——
它只回答「有没有」,而 g 的意义是「继续往后找」。去掉即可,
没有 g 时连测两次都是 true。
2. 每次用之前重置。 非要复用一个带 g 的对象,就 re.lastIndex = 0。
3. 别存成常量,每次新建。 正则字面量在 JS 里每次求值都是新对象,
所以把 /\d+/g 直接写在函数体里是安全的——代价是每次重新编译一次。
4. 注意两个取全部匹配的 API 行为不一样。 这条是实测出来的, 和很多人以为的正好相反:
const re = /\d+/g;
re.test('a1b'); // lastIndex 被推到 2
'a1b2'.match(re) // ["1", "2"] ← 不受影响,而且会把 lastIndex 重置为 0
[...'a1b2'.matchAll(re)] // ["2"] ← 🚨 从 lastIndex=2 开始,漏掉了 "1"
match(带 g)会忽略并重置 lastIndex,所以它是安全的;
matchAll 则继承当前的 lastIndex(不过它不污染原对象,用完 lastIndex 还是 2)。
⚠️ 上面这段是实测输出,但它跨了两次 API 调用,本站的示例数据表达不了 (每条示例只跑一次),所以这四行没有闸门盯着——它是全篇唯一这样的地方。 如果哪天 V8 的行为变了,这里不会有人报警。
i:不只是字母大小写
i 最直接的用途不用多说。值得单独提的是它对反向引用同样生效:
/(\w+) \1/i 能从 'The the cat' 里抓出叠词,
而不带 i 时 \1 要求逐字符相同,'The' ≠ 'the',整条模式不匹配。
分组那篇讲反向引用时留的就是这个尾巴——
用正则查文稿里的叠词,i 基本是必须的。
s:. 和换行
默认情况下 . 匹配「除换行外的任意字符」。加 s 之后它连换行也匹配:
/<p>.+<\/p>/s 能匹配跨行的内容,不带 s 时匹配不到。
⚠️ 「我的正则在本地能跑,上线就匹配不到」有相当一部分出在这里—— 测试数据是手写的单行,真实数据里有换行。
u:处理 emoji 和 Unicode 属性
没有 u 时,JS 的正则按 UTF-16 码元工作,而一个 emoji 占两个码元。
于是 . 会把它劈成两半:
/./gu 从 '😀a' 取出 2 项,/./g 取出 3 项——
后者里那两个 \ud83d \ude00 各是半个字符,单独拿出来是乱码。
u 的第二个作用是启用 \p{…} 这套 Unicode 属性转义:
🚨 忘了写 u 不会报错。 在非 u 模式下 \p 被当成字面量 p,
整条模式变成去找 p{Script=Han} 这种字符串——结果是一条都匹配不到,
而且静默。先行与后行那篇说过用 \p{Script=Han}
写中文条件比 [一-龥] 严谨,前提就是这个 u 别忘。
y:不许往后找
普通模式匹配失败时会把起点往后挪一格再试,直到扫完整个串。
y(sticky)不许这么做:只从 lastIndex 那个位置试一次,不成就是不成。
/\d+/y 对 'a1' 返回 false,而不带 y 时会扫到下标 1 找到那个 1。
用得少,但写词法分析器时正合适:它保证「不会跳过任何字符偷偷往后找」, 让你能一格一格地推进并确认每个位置都被正确消费了。
⚠️ y 和 g 一样依赖 lastIndex,所以上面那四条躲坑办法对它同样适用。
怎么选
| 你在做的事 | 修饰符 |
|---|---|
| 判断「有没有」 | 不要 g |
| 取出全部匹配 | g + match 或 matchAll(注意上面那条差异) |
| 逐行处理 | m |
| 内容可能跨行 | s |
内容里有 emoji / 要用 \p{…} |
u |
| 写解析器,要严格逐位推进 | y |
| 大小写不敏感(含反向引用) | i |
一条总的判据:g 和 y 会让正则对象变成有状态的,别把它们存成共享常量。
其余四个都是纯粹的行为开关,随便存。
下一步
《实战:校验、提取、替换》——语法部分到此讲完, 接下来是把它们拼起来解决真实需求,以及一个绕不开的话题: 为什么「完美的邮箱正则」是个陷阱。
本篇示例
下面每一条都由 npm run test:regex 在每次构建前实跑验证, 结果是现算的,不是抄进数据里的副本。正文里的代码块只用来演示匹配过程和写法对照,不进闸门; 凡是「这个模式配这个输入得到这个结果」的断言,只存在于这里。
🚨 带
g的正则有状态:同一个对象连测两次,结果会变- 调用
const re = /\d+/g; [re.test("a1b"), re.test("a1b")]- 结果
[true,false]- 换成
/\d+/ [true,true]
🚨 把
/\d+/g存成常量反复test(),会每隔一次就错一次 —— 不报错,只是间歇性地不对。i让反向引用也忽略大小写- 调用
"The the cat".match(/(\w+) \1/i)- 结果
["The the","The"]- 换成
/(\w+) \1/ null
[分组那篇](/regex/structure/groups/)的叠词检查留过这个尾巴 ——
The the正是靠这个i才抓得到。s让.也匹配换行- 调用
"<p>a\nb</p>".match(/<p>.+</p>/s)- 结果
["<p>a\nb</p>"]- 换成
/<p>.+</p>/ null
⚠️ 「我的正则在本地能跑,上线就匹配不到」有相当一部分是这个 —— 真实数据里有换行,测试数据里没有。
u:没有它,一个 emoji 会被劈成两半- 调用
[..."😀a".matchAll(/./gu)]- 结果
["😀","a"]- 换成
/./g ["\ud83d","\ude00","a"]
对照那行里的
\ud83d\ude00是半个字符 —— 拼回去才是 😀,单独拿出来是乱码。\p{…}必须配u,否则静默失效- 调用
[..."去北京玩 abc".matchAll(/\p{Script=Han}+/gu)]- 结果
["去北京玩"]- 换成
/\p{Script=Han}+/g []
[先行与后行那篇](/regex/structure/lookaround/)说过用它写中文条件比
[一-龥]严谨 —— 前提是别忘了u。y要求「就从这个位置开始」,不往后找- 调用
/\d+/y.test("a1")- 结果
false- 换成
/\d+/ true
用得少,但写分词器/解析器时正合适:它保证「不会跳过任何字符偷偷往后找」。
练习
先自己写,再看答案。读懂和写得出是两件事,而这一节练的是后者。每道题的参考答案都由 npm run test:exercises-regex 实跑验证: 答案必须通过全部用例,「常见错解」必须至少被一条用例抓住, 而且 /.*/ 这类万能写法必须过不了 —— 否则这道题就没有区分度。
取出
<p>…</p>及其内容,内容可能跨行用 输入.match(re) 取结果
输入 期望 "x<p>a\nb</p>y"["<p>a\nb</p>"] "<p>x</p>"["<p>x</p>"] 提示
难点不在 pattern,在修饰符。想想
.默认不包括什么。参考答案
/<p>.+<\/p>/s.默认不匹配换行,加s(dotAll)才匹配。⚠️「本地能跑、上线匹配不到」有相当一部分是这个 —— 测试数据是手写的单行,真实数据里有换行。常见错解
/<p>.+<\/p>/—— 它在"x<p>a\nb</p>y"这条上就错了。把字符串拆成一个个字符,emoji 不能被劈开
用 [...输入.matchAll(re)] 取全部匹配
输入 期望 "😀a"["😀","a"] "ab"["a","b"] 参考答案
/./gu没有
u时正则按 UTF-16 码元工作,而一个 emoji 占两个码元 —— 切出来的\ud83d\ude00各是半个字符,单独拿出来是乱码。常见错解
/./g—— 它在"😀a"这条上就错了。找出重复的相邻单词,忽略大小写(
The the也算)用 输入.match(re) 取结果
输入 期望 "The the cat"["The the","The"] "a A"["a A","a"] 参考答案
/(\w+) \1/ii对反向引用同样生效 —— 不带它时\1要求逐字符完全相同。[分组那篇](/regex/structure/groups/)讲叠词时留的就是这个尾巴。常见错解
/(\w+) \1/—— 它在"The the cat"这条上就错了。