Skip to content

正则表达式实战 ​

正则表达式写起来短,出起问题来却很隐蔽:

  • 手机号校验明明写了,abc13800000000xyz 也能通过;
  • 中文用户名一律提示“格式错误”;
  • 同一个输入,test 一次返回 true、一次返回 false;
  • 用户搜索 c++,页面直接报错;
  • 某个输入让整个服务 CPU 跑满。

这篇先讲正则引擎的工作方式,再逐个拆解这些坑。

读完这篇你会

  • 理解正则的“回溯”,看懂贪婪、懒惰和 ReDoS
  • 写出带边界、支持中文、对用户输入安全的正则
  • 知道什么时候不该用正则

1. 原理:正则引擎在“走迷宫” ​

正则引擎拿着你的模式,从文本开头一个字符一个字符地尝试匹配。遇到有多种可能的地方(比如 * 可以匹配 0 个或多个字符),它先选一条路走;走不通,就退回上一个岔路口换一条路,这叫回溯。

text
模式 /a.*b/   文本 "a1b2b"

a   ── 匹配 a
.*  ── 贪婪:一口气吃到结尾 "1b2b"
b   ── 结尾没有字符了,失败 → 回溯:让 .* 吐出一个字符 "b"
b   ── 匹配成功,结果 "a1b2b"(尽可能长)

默认的量词(*、+、{n,})是贪婪的:先吃尽可能多,不行再吐。加一个 ? 变成懒惰:先吃尽可能少,不够再加。

js
const html = '<b>1</b><b>2</b>'
html.match(/<b>.*<\/b>/)[0]  // '<b>1</b><b>2</b>':贪婪,跨过了中间的标签
html.match(/<b>.*?<\/b>/)[0] // '<b>1</b>':懒惰,最短匹配

回溯是正则灵活的来源,也是后面“卡死服务器”问题的根源。

2. 最常用的语法速查 ​

写法含义例子
^ / $开头 / 结尾/^\d+$/ 整串都是数字
\d / \w / \s数字 / 字母数字下划线 / 空白\w 不包含中文
.除换行外的任意字符
[abc] / [^abc]其中之一 / 都不是[0-9a-f]
* / + / ?0 次以上 / 1 次以上 / 0 或 1 次
{2,20}2 到 20 次
(...)捕获组,可提取
(?:...)只分组,不捕获
(?<name>...)命名捕获组
a|ba 或 b
\b单词边界/\bcat\b/ 不匹配 category

常用标志:g 全局匹配、i 忽略大小写、m 多行模式(^$ 匹配每一行)、u Unicode 模式。

3. 坑一:忘了 ^ 和 $,校验形同虚设 ​

现象:手机号输入框里填 abc13800000000xyz 也能提交成功。

js
/1[3-9]\d{9}/.test('abc13800000000xyz') // true ❌

原因:没有 ^$ 时,test 只要在字符串任意位置找到匹配就返回 true。

修复:校验类正则一定要锚定开头和结尾。

js
/^1[3-9]\d{9}$/.test('abc13800000000xyz') // false ✅

怎么避免再犯:区分两种用途——“校验整个输入”必须加 ^$;“查找文本中的片段”才不加。

4. 坑二:\w 不认中文,emoji 被当成两个字符 ​

现象:用户名规则写成 /^\w{2,20}$/,结果所有中文名都提示格式错误;限制“最多 10 个字符”时,带 emoji 的昵称总被提前截断。

原因:\w 只等于 [A-Za-z0-9_]。另外,JavaScript 字符串按 UTF-16 编码计数,一个 emoji 往往占两个单位:'😀'.length 是 2,不加 u 标志时 . 也只匹配其中一半。

修复:开启 u 标志,用 Unicode 属性来描述“文字”:

js
// 字母(含中文等各国文字)、数字、下划线,2~20 个
const username = /^[\p{L}\p{N}_]{2,20}$/u

username.test('小林_2026') // true
username.test('a')         // false:太短

// 按“用户看到的字符”计数
[...'你好😀'].length // 3,而 '你好😀'.length 是 4

怎么避免再犯:涉及用户可输入的文本时,先问一句“需要支持中文和 emoji 吗?”,需要就用 u 标志和 \p{...}。

5. 坑三:带 g 的正则,test 时真时假 ​

现象:同一个输入,第一次校验通过,第二次就失败,第三次又通过。

js
const hasDigit = /\d/g

hasDigit.test('a1') // true
hasDigit.test('a1') // false ❌
hasDigit.test('a1') // true

原因:带 g 的正则对象会记住上次匹配结束的位置 lastIndex,下一次从那里开始找。第二次从下标 2 开始,后面已经没有数字了,于是返回 false 并把 lastIndex 重置为 0。

修复:只判断“有没有”时不要加 g。确实需要 g 遍历所有匹配时,用 matchAll,它不会污染正则对象的状态:

js
const text = '订单 A12 和 B34'
for (const m of text.matchAll(/[A-Z](\d+)/g)) {
  console.log(m[0], m[1]) // A12 12,然后 B34 34
}

怎么避免再犯:把带 g 的正则当成“有状态的对象”,不要放在模块顶层被多处共用来做 test。

6. 坑四:用户输入直接拼进正则 ​

现象:做搜索关键词高亮,用户输入 c++,页面报错 Invalid regular expression: /c++/: Nothing to repeat;输入 .,整段文字都被高亮了。

js
const re = new RegExp(keyword, 'gi') // ❌ 用户输入被当成正则语法

原因:+、.、*、( 等字符在正则里有特殊含义,用户输入的“普通文字”被当成了模式语法。

修复:拼接前先转义特殊字符,再配合 replace 的回调函数做高亮:

js
function escapeRegExp(text) {
  return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& 代表匹配到的字符本身
}

function highlight(text, keyword) {
  if (!keyword) return [text]
  const re = new RegExp(`(${escapeRegExp(keyword)})`, 'gi')
  return text.split(re) // 奇数下标是命中的关键词,交给模板加高亮样式
}

highlight('C++ 和 c++ 都行', 'c++') // ['', 'C++', ' 和 ', 'c++', ' 都行']

这里返回数组而不是拼接 HTML 字符串,是为了避免把用户内容塞进 innerHTML 带来 XSS 风险。

怎么避免再犯:任何 new RegExp(变量) 都要问:这个变量可能来自用户吗?是的话必须转义。

7. 坑五:一个输入让 CPU 跑满(ReDoS) ​

现象:服务端校验接口偶尔响应几十秒,CPU 占满,其他请求全部超时。排查发现是某个特殊构造的输入触发了一个正则。

js
/^(a+)+$/.test('a'.repeat(30) + '!') // 可能运行数秒甚至更久

原因:回到“走迷宫”。(a+)+ 是嵌套量词,30 个 a 有天文数字种分组方式(aaa 可以是 a|a|a、aa|a、a|aa、aaa……)。末尾的 ! 让每条路最终都失败,引擎只好把所有岔路都试一遍,耗时随长度指数级增长。攻击者利用这一点让服务瘫痪,叫 ReDoS(正则拒绝服务)。

修复:

  • 避免嵌套量词和可以互相重叠的分支,如 (a+)+、(\w+)*、(a|aa)+;
  • 用更明确的字符类和有限次数,如 [a-z]{1,20} 而不是 \w+;
  • 校验前先限制输入长度;
  • 服务端对复杂校验设置超时,或换用不回溯的正则引擎(如 RE2)。
js
/^a+$/.test('a'.repeat(30) + '!') // 立即返回 false

怎么避免再犯:从网上复制来的“万能正则”要特别小心,看到 (...+)+、(...*)* 这类结构就改写。

8. 什么时候不要用正则 ​

  • 解析 HTML、XML、JSON:用 DOMParser、JSON.parse 等专用解析器。正则处理不了任意层级的嵌套。
  • 判断是否包含某个子串:includes、startsWith 更快也更好读。
  • 规则复杂、需要给出不同错误提示:拆成几个小判断写成函数,比一个巨型正则好维护。
  • 邮箱、身份证这类“完美校验”:前端校验到“格式看起来对”就够了,真正是否有效,要靠发验证邮件、短信,以及后端的完整校验。

判断标准:一周后你自己都看不懂的正则,就该拆成函数,并配上测试用例。

排查思路 ​

  1. 把正则和输入单独拿出来测:用在线正则调试工具(如 regex101,选择 JavaScript 语法)逐步查看匹配过程和回溯步数。
  2. 准备一张边界输入表,每次修改正则都跑一遍:
输入用来验证
空字符串是否误通过
前后带空格、带换行^$ 和 m 标志是否正确
中文、emoji是否需要 u 标志
特殊字符 . + * ( [拼接时是否转义
超长字符串是否有回溯爆炸风险
  1. 同一输入结果不稳定:先检查是否带了 g 标志并被重复使用。

检查清单 ​

  • [ ] 校验类正则都有 ^ 和 $
  • [ ] 需要支持中文、emoji 的规则使用了 u 标志和 \p{...}
  • [ ] 只做判断的正则没有 g 标志
  • [ ] new RegExp(变量) 的变量都经过转义
  • [ ] 没有嵌套量词,用户输入有长度上限
  • [ ] 复杂正则有对应的测试用例

延伸阅读 ​

为复用而记录,为理解而整理。