正则这东西不常写,写完三个月就忘。每次用到都是「翻旧项目 → 复制 → 改一半 → 在控制台里猜十分钟」。正则表达式在线测试存在的意义,就是把这个「猜」的过程压缩到几秒钟。
这篇按「最小语法集 → 五个能直接抄的写法 → JS 特有陷阱 → 速查清单」的顺序写,文中的每个表达式都在 Node 22 里跑过,输出结果直接贴在代码注释里。
一、正则表达式在线测试之前,先背下这张表
正则 90% 的需求只用到下面这些符号:
| 类别 | 写法 | 含义 |
|---|---|---|
| 字符类 | \d \w \s |
数字 / 单词字符 [A-Za-z0-9_] / 空白符,大写 \D \W \S 表示取反 |
. |
任意字符(默认不含换行,加 s 标志才含) |
|
[abc] [^abc] |
集合中的任一个 / 非集合中的任一个 | |
[a-z0-9] |
范围,连字符放最前或最后才表示字面量 - |
|
| 量词 | * + ? |
0 次以上 / 1 次以上 / 0 或 1 次 |
{n} {n,} {n,m} |
恰好 n 次 / 至少 n 次 / n 到 m 次 | |
| 锚点 | ^ $ \b |
开头 / 结尾 / 单词边界 |
| 分组 | (...) |
捕获组,可在结果里按 $1 取出 |
(?:...) |
非捕获组,只分组不占编号 | |
(?<name>...) |
命名捕获组,结果里走 m.groups.name |
|
| |
或,优先级最低,常用 (?:a|b) 包起来 |
|
| 转义 | \ |
\$ 匹配字面 $,在字符串里要写成 '\\$' |
记住一条铁律:字符类内部大部分符号不需要转义。写 [/.:] 是合法的,只有 ] \ ^(首位时)-(表范围时)需要处理。
二、贪婪还是惰性,差一个 ?
量词默认贪婪:能匹配多长就匹配多长,然后才回头。加 ? 变成惰性:能匹配多短就匹配多短。
'<a>1</a><b>2</b>'.match(/<.+>/) // '<a>1</a><b>2</b>' ← 一路吃到最后的 >
'<a>1</a><b>2</b>'.match(/<.+?>/g) // ['<a>', '</a>', '<b>', '</b>']
想写「匹配到第一个分隔符为止」,就用 <[^>]+> 这种排除型字符类,它比 <.+?> 更快也更不容易出错——惰性靠回溯实现,排除型字符类不需要回溯。
三、五个高频写法,直接抄
注意:下面每个表达式都写了 ^ 和 $,那是给「整串校验」用的。 如果你想从一段文本里把目标挑出来,把两端的锚点去掉,只留中间部分。
1. 手机号
^1[3-9]\d{9}$
第一位的 1 是号首,第二位限制在 3–9,把 11、12 这类服务号段(110、120)挡在外面,剩下 9 位数字。一共 11 位。
实测:13812345678 通过,12812345678、1381234567、138123456789、+8613812345678 全部拒绝。带国家码要先 replace 掉 +86 或空格再校验。
2. 邮箱
^[\w.+-]+@[\w-]+\.[\w.]+$
邮箱的 RFC 规范复杂到某种程度上无法用正则完整表达(带引号的本地部分、注释、IP 字面量形式)。上面这个覆盖了绝大多数真实地址,包括多级域名。真正验证邮箱存在与否只能发一封确认邮件,不要再往这个表达式里加更多料了。
3. IPv4
^((?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)$
四段拆分是或非关系:25[0-5](250-255)| 2[0-4]\d(200-249)| 1\d{2}(100-199)| [1-9]?\d(0-99,且不接受 01 这种前导零形式)。
这里的 (?:...) 很关键。如果写成捕获组 (...) 再配 {3} 重复,它只会记住最后一次匹配的那一组,$1 拿不到你想要的四段。不需要取值的分组一律写成 (?:),既省内存也避免编号污染。
4. 日期(不做闰年)
^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$
它能卡住月份 01-12、日 01-31,但放行 2026-02-30。跨月边界必须交给 Date 对象或者日期库判断:
const ok = (s) => {
if (!/^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/.test(s)) return false
const [y, m, d] = s.split('-').map(Number)
return new Date(y, m - 1, d).getDate() === d // 30 号会被 2 月纠正成 2 号,于是判假
}
5. 提取 URL 和 URL 参数
https?://[^\s]+
排除型字符类 [^\s] 比 .*? 安全:遇到中文、标点、空格自动停下。要取参数:
const str = 'https://it997.com/tool/regex-test?a=1&b=hello%20world&c='
// 首选:别自己造轮子
const params = Object.fromEntries(new URLSearchParams(str.split('?')[1] || ''))
// 必须用正则时(比如从一段混杂文本里捞)
for (const m of str.matchAll(/[?&]([^=&]+)=([^&]*)/g)) {
console.log(m[1], '=', decodeURIComponent(m[2]))
}
把表达式和测试文本粘进去,右侧会显示命中位置 index 和 $1、$2 各捕获了什么,未命中的组标记为「未匹配」。工具内置了手机号、邮箱、IPv4、日期、中文等八组常用预设,可以点一下直接载入——但请留意:预设里都不带 ^ 和 $,它们是给「从文本中查找」用的,拿去做表单校验要自己两头加锚点。
四、JS 里躲不开的三个坑
坑 1:match 带 g 会丢掉捕获组
'abc123def456'.match(/(\d{3})/) // 有组:['123', '123', index: 3, ...]
'abc123def456'.match(/(\d{3})/g) // 无组:['123', '456'] —— 只剩整串匹配
'abc'.match(/\d/g) // null,别忘判空
带 g 的 match 只返回完整匹配数组,捕获组、index 全部丢失。想同时拿到多次匹配和它们的组,用 matchAll(必须带 g,否则抛 TypeError):
for (const m of 'abc123def456'.matchAll(/(\d{3})/g)) {
console.log(m[0], m[1], m.index)
}
// 123 123 3
// 456 456 9
matchAll 返回迭代器,要数组就包一层 [...]。
坑 2:test 配 g 会忽真忽假
带 g 的正则对象是有状态的,它在自身上记录一个 lastIndex,每次匹配从那里继续:
const re = /\d{3}/g
re.test('abc123') // true lastIndex → 6
re.test('abc123') // false 从 6 继续,找不到,lastIndex 归零
re.test('abc123') // true
这就是生产环境里那种「同样的输入,结果隔一次变一次」的鬼故事。结论:校验用的正则不要加 g,需要全局搜索时用字面量或者每次新建对象,别把带 g 的正则对象缓存起来复用。
坑 3:中文匹配的范围
[\u4e00-\u9fa5] 覆盖的是 CJK 基本区(最常见的两万多汉字),够日常用,但覆盖不了扩展区的生僻字和兼容区的符号。现代浏览器可以用 Unicode 属性转义:
/[\u4e00-\u9fa5]+/.test('你好') // true
/\p{Script=Han}+/u.test('你好世界 OK') // true,需带 u 标志
注意 \w、\d 默认只认 ASCII。要匹配中文用户名,得写成 [\u4e00-\u9fa5\w]+ 这种显式组合。
五、灾难性回溯:一个正则能把 CPU 打满
这是正则最严重的性能问题。看这个表达式:
const input = 'a'.repeat(28) + '!'
/(a+)+$/.test(input) // 本机 Node 22 实测:约 31 秒才返回 false
(a+)+ 是嵌套量词:外层的 + 可以重复内层的 a+,而中间的 a 有指数量级的拆分方式。碰到末尾那个 ! 导致失败时,引擎会穷举所有拆分组合再逐个回溯。
本机 Node 22 实测(a 个数 → 耗时):
| 输入长度 | 耗时 |
|---|---|
| 20 个 a | 16 ms |
| 24 个 a | 260 ms |
| 26 个 a | 1.4 s |
| 28 个 a | 31 s |
每多两个字符,耗时放大十倍量级。用户在表单里粘一段 30 个字符的文本就能把你的 Node 进程单线程卡死,这是一种标准的拒绝服务攻击(ReDoS)。
规避办法:
- 绝不写嵌套量词:
(a+)+、(\d+)*、(.*)+这类结构一律拆开,上面那个表达式的本意其实用/^a+$/就够了 - 能用排除型字符类就别用
.:[^>]+永远比.*?安全 - 给重复定上限:
{1,64}而不是+ - 限制输入长度再喂给正则——这是最有效也最容易被漏掉的一条
- 不可信输入跑复杂正则时,放进有超时或有独立线程池的环境
六、手机号校验别写「万能正则」
^1[3-9]\d{9}$ 只能证明「这个字符串长得像手机号」,证明不了「这是个能收短信的号码」。矛盾在于:
- 工信部每年都会放新的号段(16x、19x、17x 陆续开通),把表写进正则就意味着每年都要改一次代码
- 写得太严(锁定到三号位)会把合法用户挡在门外;写得太松形同虚设
- 前端挡住的永远是诚实的用户,挡不住批量注册
推荐做法是一套白名单前置 + 服务兜底:
| 环节 | 做什么 |
|---|---|
| 前端 | 只做宽松前置:去掉空格与 - 后判断「11 位数字且以 1 开头」,最多再查一遍第二位 |
| 后端 | 同样只做格式检查,不维护完整号段表 |
| 真实校验 | 发一条短信验证码,收得到才算数 |
同理适用于身份证(18 位末位校验位要算校验和,正则算不了)、邮箱(要发确认函)、银行卡号(要走 Luhn 校验)。正则的职责是「快速拦截明显的错误」,不是「证明正确性」。
七、常用表达式速查表
| 需求 | 表达式(带 ^$ 为整串校验,查找时去掉锚点) |
|---|---|
| 手机号 | ^1[3-9]\d{9}$ |
| 邮箱 | ^[\w.+-]+@[\w-]+\.[\w.]+$ |
| IPv4 | ^((?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)$ |
| 日期 YYYY-MM-DD | ^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$ |
| 时间 HH:mm:ss | ^(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d$ |
| 身份证(18 位粗筛) | ^\d{17}[\dXx]$ |
| 中文字符 | [\u4e00-\u9fa5] |
| URL | https?://[^\s]+ |
| HTML 标签 | <[^>]+> |
| 正整数 / 非负整数 | ^[1-9]\d*$ / ^\d+$ |
| 强密码(位置不限含四类) | ^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$ |
最后一行的 (?=...) 是零宽先行断言,不消耗字符,可以叠加多个条件。
八、写完正则的排错清单
- 先确认目的:整串校验加
^$,从文本里查找则不加 - 用带超时和样本的工具跑一遍,样本至少包含三类:应该匹配的、应该不匹配的、边界值(空串、超长、多一个字符)
- 检查有没有嵌套量词
(\w+)+,有就重写 - 校验用正则不带
g;test复用的对象更不能带g - 要取多次匹配就用
matchAll,并且记得它可能返回空迭代器 .不匹配换行,需要跨行时加s标志或写成[\s\S]- JS 字符串里
\d要写成'\\d',否则\d会被当成转义吃掉——推荐直接用字面量/\d+/ - 中文、emoji 相关的都要确认是否开了
u标志 - 输入来自用户的,先限长再匹配
- 看不懂自己写的表达式时,拆成几个小正则分步处理,比一行天书更容易维护
全部纯前端实现,输入的内容不上传服务器。