正则表达式实战
正则表达式写起来短,出起问题来却很隐蔽:
- 手机号校验明明写了,
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|b | a 或 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更快也更好读。 - 规则复杂、需要给出不同错误提示:拆成几个小判断写成函数,比一个巨型正则好维护。
- 邮箱、身份证这类“完美校验”:前端校验到“格式看起来对”就够了,真正是否有效,要靠发验证邮件、短信,以及后端的完整校验。
判断标准:一周后你自己都看不懂的正则,就该拆成函数,并配上测试用例。
排查思路
- 把正则和输入单独拿出来测:用在线正则调试工具(如 regex101,选择 JavaScript 语法)逐步查看匹配过程和回溯步数。
- 准备一张边界输入表,每次修改正则都跑一遍:
| 输入 | 用来验证 |
|---|---|
| 空字符串 | 是否误通过 |
| 前后带空格、带换行 | ^$ 和 m 标志是否正确 |
| 中文、emoji | 是否需要 u 标志 |
特殊字符 . + * ( [ | 拼接时是否转义 |
| 超长字符串 | 是否有回溯爆炸风险 |
- 同一输入结果不稳定:先检查是否带了
g标志并被重复使用。
检查清单
- [ ] 校验类正则都有
^和$ - [ ] 需要支持中文、emoji 的规则使用了
u标志和\p{...} - [ ] 只做判断的正则没有
g标志 - [ ]
new RegExp(变量)的变量都经过转义 - [ ] 没有嵌套量词,用户输入有长度上限
- [ ] 复杂正则有对应的测试用例
延伸阅读
- 表单设计与校验:前端校验和后端校验怎么分工
- 浏览器与 DOM 操作:为什么高亮结果不要拼成 HTML 字符串
- LLM 应用安全:用户输入永远不可信的另一种体现