一批订单号混在日志里,怎样只取出符合规则的编号?用户粘贴了一段联系方式,怎样提取邮箱并去重?注册表单提交前,又该怎样快速发现明显的格式错误?
这些问题的共同点是:目标文本有稳定的字符形状。正则表达式就是描述这种形状的语言。它不理解“订单”“邮箱”这些业务含义,只检查字符、位置和重复次数是否符合模式。
学习正则时,不要从背符号开始。先把规则写成一句人话,再把句子逐段翻译成模式,最后用应该通过和应该失败的样本一起验证。这样写出的正则更容易解释,也更不容易放过错误输入。
完成本章后,你将能够:
RegExp 构造器之间做选择;lastIndex 带来的状态;test()、match()、matchAll() 与 replace();
假设订单号的规则是:“JS- 开头,后面是 3 位数字。”最小模式可以写成:
const orderPattern = /JS-\d{3}/;
console.log(orderPattern.test("订单 JS-071 已发货"));true两条斜杠包住的是模式主体。这里的 JS- 按字面匹配,\d 表示一位数字,{3} 表示前面的数字恰好出现 3 次。
正则字面量适合代码编写时就已经确定的规则:
const fixedPattern = /^[A-Z]{2}-\d{4}-\d{3}$/;
console.log(fixedPattern.test("JS-2026-071"));
console.log(fixedPattern.test("JS-26-71"));true
false字面量短、直观,而且没有 JavaScript 字符串这一层额外转义。
模式需要拼入变量时,可以使用 RegExp 构造器:
const year = "2026";
const dynamicPattern = new RegExp(
"^[A-Z]{2}-" + year + "-\\d{3}$"
);
console.log(dynamicPattern.test("JS-2026-071"));true构造器接收的是 JavaScript 字符串。字符串先解释一次,正则引擎再解释一次,所以要让最终模式得到 \d,字符串里必须写成 \\d。
如果动态内容只是普通文本,还要转义其中的正则特殊字符。否则用户输入的 .、+ 或 [ 会改变模式,甚至让构造器抛错。
function escapeRegExp(text) {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const keyword = "v1.2";
const safePattern = new RegExp(escapeRegExp(keyword), "g");
console.log("v1x2 v1.2".match[ 'v1.2' ]只有在产品明确提供“高级正则搜索”时,才应该把用户输入直接当作正则。普通关键词搜索应先转义,并限制输入长度。
方括号表示一个位置的候选集合。[ABC] 匹配 A、B、C 中任意一个;[A-Z] 表示一个大写英文字母;[^0-9] 开头的 ^ 位于字符类内部,表示“不是数字”。
const text = "A12 B7 x99 !";
console.log(text.match(/[A-Z]\d+/g));
console.log("room 204".match(/[^0-9]+/)[0]);[ 'A12', 'B7' ]
room 常用字符类可以先记住这些:
点号是最常见的误区。. 是通配符;要匹配普通句点,需要写 \.。
console.log("example.com".match(/./g).length);
console.log("example.com".match(/\./g));11
[ '.' ]\w 不是“所有语言中的文字”。如果明确需要匹配 Unicode 字母,可以使用属性转义并添加 u:
console.log("中文 JavaScript 2026".match(/\p{L}+/gu));[ '中文', 'JavaScript' ]
字符类解决“这个位置放什么”,量词解决“前一部分重复几次”,边界解决“匹配必须发生在哪里”。
量词只控制它左边紧邻的一项。\d{4} 的量词控制 \d,而 (?:ab){3} 的量词控制整个分组。
常用位置边界有三种:
^ 检查字符串开头;$ 检查字符串结尾;\b 检查单词字符与非单词字符之间的位置。如果要验证整个订单号,就要同时加上开头和结尾边界:
const orderPattern = /^[A-Z]{2}-\d{4}-\d{3}$/;
const samples = ["JS-2026-071", "xJS-2026-071", "JS-2026-71"];
console.log(samples.map((value) => orderPattern.test(value)));[ true, false, false ]没有 ^ 和 $ 时,只要输入中某一段符合规则,test() 就会返回 true。这适合“查找”,却通常不适合“整字段校验”。
\b 中的“单词”遵循正则的单词字符规则,适合许多英文标识符场景,但不是通用的中文分词器:
const sentence = "In the end or at the beginning?";
console.log(sentence.match(/\bin/gi));[ 'In' ]这里 in 位于 In 的单词开头,所以匹配;beginning 中的字母两侧都是单词字符,因此那个 in 前面没有单词边界。
圆括号先把一段模式组合成整体,也会默认保存这段匹配结果。下面的模式把订单号拆成前缀、年份和流水号:
const text = "待处理:JS-2026-071";
const match = text.match(
/(?<prefix>[A-Z]{2})-(?<year>\d{4})-(?<id>\d{3})/
);
console.log(match[0]);
console.log(match.groups);JS-2026-071
[Object: null prototype] { prefix: 'JS', year: '2026', id: '071' }match[0] 永远是本次完整匹配。普通捕获组依次放在 match[1]、match[2];命名捕获组 (?<name>...) 还可以通过 match.groups.name 读取,模式变长时更容易维护。
(?:...) 会组合模式,但不会占用捕获结果的位置:
const filePattern = /^report\.(?:csv|xlsx)$/i;
console.log(filePattern.test("report.csv"));
console.log(filePattern.test("report.pdf"));true
false如果没有外层分组,| 会尽量连接左右两边的完整分支。把候选项放进 (?:csv|xlsx),能明确表达“只有扩展名二选一”。
捕获组还可以被后面的模式引用。命名反向引用写成 \k<name>:
const duplicateWord = /\b(?<word>[A-Za-z]+)\s+\k<word>\b/i;
console.log("This is is duplicated.".match(duplicateWord)[0]);is is这不是再次执行“任意单词”规则,而是要求第二个单词与第一次捕获到的内容相同。

flags 写在正则字面量最后一条斜杠后,也可以作为 RegExp 构造器的第二个参数。一个正则可以组合多个不重复的 flag。
下面同时使用 g、i、m。由于有 m,^ 和 $ 检查每一行,而不只检查整段文本的两端:
const logs = "ERROR: disk\ninfo: ok\nError: network";
const errors = logs.match(/^error:.+$/gim);
console.log(errors);[ 'ERROR: disk', 'Error: network' ]s 与 m 处理的是不同问题:s 改变点号能否跨过换行,m 改变 ^ 和 $ 如何判断行边界。
flags 在正则创建后不可修改。需要另一组 flags 时,应创建新的正则:
const base = /javascript/i;
const globalCopy = new RegExp(base.source, "gi");
console.log("JavaScript + JAVASCRIPT".match(globalCopy));[ 'JavaScript', 'JAVASCRIPT' ]这四个 API 都能使用正则,但返回值和适用任务不同。
const source = "JS-071, CSS-204";
console.log(/JS-\d{3}/.test(source));true用于整字段校验时,模式通常要有 ^ 和 $,而且不要加 g,这样不会受到 lastIndex 的状态影响。
const source = "JS-071, JS-204";
const first = source.match(/JS-(\d{3})/);
const all = source.match(/JS-(\d{3})/g);
console.log(first[0], first[1], first.index);
console.log(all);JS-071 071 0
[ 'JS-071', 'JS-204' ]match() 不带 g 时返回第一个匹配的索引和捕获组;带 g 时返回所有完整匹配文本,但不保留每一次的捕获详情。没有结果时,两种情况都返回 null。
const source = "JS-071, JS-204";
const ids = [...source.matchAll(/JS-(?<id>\d{3})/g)]
.map((item) => item.groups.id);
console.log(ids);[ '071', '204' ]传给 matchAll() 的 RegExp 必须带 g,否则会抛出 TypeError。展开运算符把可迭代结果变成数组,之后才能直接使用 map()。
const source = "JS-071, JS-204";
const labeled = source.replace(
/JS-(\d{3})/g,
(fullMatch, id) => "订单#" + id
);
console.log(labeled);订单#071, 订单#204replace() 不会修改原字符串,而是返回新字符串。带 g 才会替换全部匹配;替换函数适合根据捕获内容计算新值。
+、*、{m,n} 等量词默认是贪婪的:在仍能完成整个模式的前提下,它们会尽量多吃字符。
const html = "<b>JS</b><i>Regex</i>";
console.log(html.match(/<.+>/g));
console.log(html.match(/<.+?>/g));[ '<b>JS</b><i>Regex</i>' ]
[ '<b>', '</b>', '<i>', '</i>' ].+ 会从第一个 < 一直扩张到最后一个 >。在量词后再加 ?,例如 .+?,会改成非贪婪:一旦剩余模式能够完成,就尽早停止。
不过,比“少吃一点”更清晰的方案,往往是直接写出不能越过的结束符:
console.log("<b>JS</b><i>Regex</i>".match(/<[^>]+>/g));[ '<b>', '</b>', '<i>', '</i>' ][^>]+ 明确表示“一段不是 > 的字符”。它比 .+? 更能说明边界。但这只适合演示或处理非常受控的片段;完整 HTML 存在注释、属性引号、脚本内容和嵌套结构,应交给 HTML 解析器。

带 g 或 y 的正则会用 lastIndex 记录下一次匹配的起点。重复调用同一个正则对象的 test() 或 exec() 时,这个状态会继续参与计算。
const pattern = /JS/g;
const results = ["JS", "JS", "JS"].map((value) => pattern.test(value));
console.log(results);[ true, false, true ]过程是这样的:
lastIndex 变成 2。2 开始,已经位于字符串末尾,因此失败;失败后 lastIndex 重置为 0。0 开始,所以成功。因此,真假校验通常使用不带 g、y 的正则。如果必须复用全局正则,可以在新一轮任务前显式设置 pattern.lastIndex = 0。
exec() 可以借助这种状态逐个返回详细结果:
const numberPattern = /\d+/g;
const text = "A12 B7";
let match;
while ((match = numberPattern.exec(text)) !== null) {
console.log(match[0], match.index, numberPattern.lastIndex);
}12 1 3
7 5 6g 可以从 lastIndex 开始向后搜索;y 要求匹配必须恰好从 lastIndex 开始。手写 exec() 循环时还要警惕能匹配空字符串的模式,否则游标可能不前进。多数“全部匹配并读取分组”的任务,用 matchAll() 更直接。

“从一段文字中找邮箱”和“判断整个输入是不是允许注册的邮箱”是两个任务。
^、$ 和明确的长度、字符限制。下面先用一个候选模式在文本中扫描,再用较严格的业务模式过滤:
const emailCandidate =
/(?<![A-Za-z0-9._+@-])[A-Za-z0-9]+(?:[._+-][A-Za-z0-9]+)*@(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,63}(?![A-Za-z0-9._+@-])/g;
const businessEmail =
/^(?=.{1,254}$)(?=.{1,64}@)[A-Za-z0-9]+(?:
[ 'lin@example.com', 'mei.work+js@sub.example.org' ]这段代码做了四件事:
g,在句子中寻找所有“像邮箱”的片段。g,可以安全地反复调用 test()。Set 按首次出现顺序去重,?? [] 处理完全没有匹配时的 null。这个业务模式是刻意收窄的规则:它支持常见 ASCII 邮箱,拒绝连续分隔符和域名标签首尾的短横线,但不覆盖带引号的本地部分、国际化邮箱、域名 IP 字面量等所有合法形式。产品如果要支持这些地址,应调整业务规则或采用成熟的邮箱处理方案。
格式通过只说明“长得符合当前规则”,不代表域名存在、邮箱可收信或地址属于当前用户。注册流程仍应在服务端重复校验,并通过验证邮件确认所有权。
浏览器端校验的价值是尽快给用户反馈,不是建立安全边界。下面是一个完整但精简的页面示例:
<form id="email-form" novalidate>
<label for="email">邮箱</label>
<input
id="email"
name="email"
type="email"
required
maxlength="254"
autocomplete="email"
aria-describedby="email-error"
/>
<p id
运行环境:现代浏览器。novalidate 让示例统一展示自定义提示;删除它后,required 和 type="email" 还可以参与浏览器原生约束验证。无论选择哪一种 UI,服务端都不能信任客户端结果。
表单校验应分层处理:
正则不是清理恶意输入的通用工具。数据库写入要使用参数化查询,输出到 HTML 要按上下文编码,这些安全措施不能由邮箱正则替代。
一个正则能匹配成功,不等于它已经正确。至少要准备三类样本:
可以把规则直接做成小型测试表:
const orderPattern = /^[A-Z]{2}-\d{4}-\d{3}$/;
const cases = [
{ value: "JS-2026-071", expected: true },
{ value: "A-2026-071", expected: false },
{ value: "JS-2026-71", expected: false },
{ value: "xJS-2026-071", expected: false }
];
for
JS-2026-071 PASS
A-2026-071 PASS
JS-2026-71 PASS
xJS-2026-071 PASS维护模式时还应遵守几条实用规则:
(a+)+$ 这种嵌套、含义重叠的量词;它可能在不匹配的长输入上产生大量回溯。现在把提取、去重、替换和表单校验组合成一个浏览器小工具。页面包含两个任务:
建立候选提取模式与整字段业务模式,候选模式带 g,业务模式不带 g。
点击“提取并去重”后,用 match() 获得候选,使用 filter() 二次校验,再用 Set 去重。
把结果写入只读文本框;没有结果时显示明确提示。
拦截注册表单提交,更新错误文本与 aria-invalid,通过后只显示“等待服务端校验”,不伪装成真正注册成功。
lin@example.com bad@mail@site lin@example.com,结果只出现一次 lin@example.com。mei.work+js@sub.example.org,可以正确提取并通过注册预检。a@-bad.com、a..b@example.com 或空字符串,注册预检应失败。规则是:两个大写字母、一个短横线、4 位数字,例如 AB-2048。请写出用于整字段校验的正则,并准备两个通过样本、两个失败样本。
RegExp,普通动态文本要先转义。test() 判断有无,match() 获取单次详情或全部文本,matchAll() 保留全部匹配与分组,replace() 生成替换后的新字符串。g 和 y 会让 test()、exec() 受 lastIndex 影响,真假校验通常不应使用它们。这里的 ^ 和 $ 负责整字段边界,[A-Z]{2} 限定两个大写字母,\d{4} 限定 4 位数字。