上手:真实场景与边界

修饰符

它在解决什么

修饰符写在正则字面量末尾那个斜杠的后面(/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 属性转义:

/\p{Script=Han}+/gu 能取出连续的汉字。

🚨 忘了写 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 在每次构建前实跑验证, 结果是现算的,不是抄进数据里的副本。正文里的代码块只用来演示匹配过程和写法对照,不进闸门; 凡是「这个模式配这个输入得到这个结果」的断言,只存在于这里。

  1. 🚨 带 g 的正则有状态:同一个对象连测两次,结果会变

    调用
    const re = /\d+/g; [re.test("a1b"), re.test("a1b")]
    结果
    [true,false]
    换成 /\d+/
    [true,true]

    🚨 把 /\d+/g 存成常量反复 test(),会每隔一次就错一次 —— 不报错,只是间歇性地不对。

  2. i 让反向引用也忽略大小写

    调用
    "The the cat".match(/(\w+) \1/i)
    结果
    ["The the","The"]
    换成 /(\w+) \1/
    null

    [分组那篇](/regex/structure/groups/)的叠词检查留过这个尾巴 —— The the 正是靠这个 i 才抓得到。

  3. s 让 . 也匹配换行

    调用
    "<p>a\nb</p>".match(/<p>.+</p>/s)
    结果
    ["<p>a\nb</p>"]
    换成 /<p>.+</p>/
    null

    ⚠️ 「我的正则在本地能跑,上线就匹配不到」有相当一部分是这个 —— 真实数据里有换行,测试数据里没有。

  4. u:没有它,一个 emoji 会被劈成两半

    调用
    [..."😀a".matchAll(/./gu)]
    结果
    ["😀","a"]
    换成 /./g
    ["\ud83d","\ude00","a"]

    对照那行里的 \ud83d \ude00 是半个字符 —— 拼回去才是 😀,单独拿出来是乱码。

  5. \p{…} 必须配 u,否则静默失效

    调用
    [..."去北京玩 abc".matchAll(/\p{Script=Han}+/gu)]
    结果
    ["去北京玩"]
    换成 /\p{Script=Han}+/g
    []

    [先行与后行那篇](/regex/structure/lookaround/)说过用它写中文条件比 [一-龥] 严谨 —— 前提是别忘了 u。

  6. y 要求「就从这个位置开始」,不往后找

    调用
    /\d+/y.test("a1")
    结果
    false
    换成 /\d+/
    true

    用得少,但写分词器/解析器时正合适:它保证「不会跳过任何字符偷偷往后找」。

练习

先自己写,再看答案。读懂和写得出是两件事,而这一节练的是后者。每道题的参考答案都由 npm run test:exercises-regex 实跑验证: 答案必须通过全部用例,「常见错解」必须至少被一条用例抓住, 而且 /.*/ 这类万能写法必须过不了 —— 否则这道题就没有区分度。

  1. 取出 <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"这条上就错了。

  2. 把字符串拆成一个个字符,emoji 不能被劈开

    用 [...输入.matchAll(re)] 取全部匹配

    输入期望
    "😀a"["😀","a"]
    "ab"["a","b"]
    参考答案

    /./gu

    没有 u 时正则按 UTF-16 码元工作,而一个 emoji 占两个码元 —— 切出来的 \ud83d \ude00 各是半个字符,单独拿出来是乱码。

    常见错解 /./g —— 它在"😀a"这条上就错了。

  3. 找出重复的相邻单词,忽略大小写(The the 也算)

    用 输入.match(re) 取结果

    输入期望
    "The the cat"["The the","The"]
    "a A"["a A","a"]
    参考答案

    /(\w+) \1/i

    i 对反向引用同样生效 —— 不带它时 \1 要求逐字符完全相同。[分组那篇](/regex/structure/groups/)讲叠词时留的就是这个尾巴。

    常见错解 /(\w+) \1/ —— 它在"The the cat"这条上就错了。