在 Java Web 项目里摸爬滚打一段时间后,你会慢慢发现,前端表单校验、数据提取、字符串替换这些看似琐碎的活儿,写起来最费神的往往不是业务逻辑,而是怎么把一段字符串"收拾"得服服帖帖。JavaScript 中的正则表达式(RegExp)就是干这个的,它是处理字符串的一套独立语法体系,也是 JS 中少有的"学起来费劲、用起来真香"的知识点。这一篇我打算把正则表达式这件事讲透,从基础语法到 Java Web 项目里的真实场景,一次聊明白。
这玩意儿到底解决什么问题?打个比方,字符串就像一整块面团,正则就是一套刻好的模具,你想把面团压成什么形状,直接拿模具往上一扣就行。比如你要判断用户输入的手机号是否合法、从一大段 HTML 里抽出所有图片地址、把富文本里的敏感词替换掉,这些"按规则找内容"的需求,正则都能干。所以说,正则不是 JS 独有的,但 JS 里的正则用法和 Java 后端岗位的匹配度非常高,尤其是前后端联调时,前端做一次预校验、后端再做一次安全校验,两端语法虽然略有差异,核心逻辑却一致,学懂 JS 正则在 Java Web 开发中是必须跨过去的一道坎。
这篇文章适合谁?初学者可以把它当成入门指南,有基础的人也能从后边的案例和排错里找到点平时没留意的东西。我尽量把语法拆开揉碎,配上能直接跑起来的例子,读完之后你至少能独立写出常用的表单校验、数据提取和文本替换正则。好了,废话不多说,直接进入正题。
1. 正则表达式到底是什么:别把它想复杂了
1.1 核心概念:一种"字符串匹配规则"
正则表达式(Regular Expression,简称 regex、regexp 或 RE)本质上是一种描述字符串结构的表达式。它用一堆特殊符号组成"规则",然后拿着这个规则去字符串里寻找符合要求的子串。你可以把它理解成一种极其精简的"搜索条件"。
我先说几个最简单直观的规则:
abc:精确匹配字符串里的 "abc" 这三个连续字符[0-9]:匹配任意一个数字字符[a-z]:匹配任意一个小写字母.:匹配除了换行符以外的任意单个字符\d:匹配任意一个数字,等价于[0-9]\w:匹配字母、数字、下划线,等价于[A-Za-z0-9_]\s:匹配空白字符(空格、制表符、换行等)
举个例子,规则 \d{11} 的意思是"11 个连续的数字",用它可以去字符串里找手机号。规则 \w+@\w+\.\w+ 的意思就复杂一些了,它描述了一个简易邮箱的形态:字母数字下划线组成的字符串 + @ + 字母数字下划线 + 点 + 字母数字下划线。
看到这里你可能会觉得,这跟普通的字符串查找(比如 indexOf)有什么区别?区别大了。indexOf 查找的是"死的字符串",你告诉它找 "abc",它就只找 "abc";而正则找的是"活的规则",你可以告诉它"找一个以字母开头、后面跟着 5~8 个数字、结尾不能是 0 的字符串",这种复杂需求用普通字符串方法是没法表达的。
1.2 正则表达式的组成结构
一个完整的正则表达式通常包含三部分:普通字符、元字符和修饰符。
- 普通字符:就是字面意思,比如
a、b、1、你,它们匹配自身 - 元字符:有特殊含义的符号,比如
\d、\w、^、$、*、+、?、[]、()、{}、|等 - 修饰符:写在正则末尾的标志位,比如
i(忽略大小写)、g(全局匹配)、m(多行匹配)
你可以把正则当成一门"小语言",普通字符是它的单词,元字符是它的语法,修饰符是它的运行参数。学会正则的过程,就是熟悉这套语法的过程,不用背,多写几次自然就记住了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. JavaScript 中正则的两种写法与 RegExp 对象
2.1 字面量与构造函数,两种创建方式
在 JavaScript 里,创建正则有两种方式,先记结论:能用字面量就用字面量。
javascript复制// 方式一:字面量(推荐)
const reg1 = /\d{11}/;
// 方式二:构造函数
const reg2 = new RegExp("\\d{11}");
这两种方式有一个关键区别:字面量里的 \ 就是正则自身的转义符,所以 \d 直接写;而构造函数接收的是字符串,字符串里的 \ 需要先转义成 \\,最终 RegExp 内部收到的才是 \d。这就是为什么 new RegExp("\\d{11}") 要写两个反斜杠。
构造函数的好处在于规则本身可以动态拼接。比如你要根据用户选择的校验类型动态生成正则,那就得用构造函数:
javascript复制let type = "email"; // 这个值可能是动态的
let pattern = type === "email" ? "^\\w+@\\w+\\.\\w+$" : "^\\d{11}$";
let reg = new RegExp(pattern);
但在固定规则场景下,字面量更直观,也避免了字符串转义的坑,性能上通常也更好一些(正则引擎在脚本加载时就会完成编译)。
2.2 RegExp 实例方法:test 和 exec 的差别
RegExp 对象上有两个核心方法:test() 和 exec()。
test() 返回布尔值,用来判断字符串是否符合正则规则,这是表单校验里用得最多的:
javascript复制const reg = /^\d{11}$/;
console.log(reg.test("13812345678")); // true
console.log(reg.test("1381234567")); // false
console.log(reg.test("13812345678a")); // false
exec() 则更进一步,它返回一个数组,包含匹配到的字符串以及捕获组的内容,适合做数据提取:
javascript复制const reg = /(\d{3})-(\d{8})/;
const result = reg.exec("联系电话:010-12345678");
console.log(result[0]); // "010-12345678"
console.log(result[1]); // "010"
console.log(result[2]); // "12345678"
这里要注意,exec() 返回的数组里,第 0 项是完整匹配的字符串,第 1 项及之后是捕获组的内容(后面讲分组时会细说)。
2.3 字符串方法中的正则应用
除了 RegExp 对象自带的方法,字符串本身也有几个方法可以配合正则使用,这个在实际开发中更常用,得重点掌握。
javascript复制const str = "Hello World, welcome to Java Web!";
// 1. match():返回匹配结果数组
const words = str.match(/\w+/g); // ["Hello", "World", "welcome", "to", "Java", "Web"]
// 2. replace():字符串替换
const newStr = str.replace(/World/, "JavaScript"); // "Hello JavaScript, welcome to Java Web!"
// 3. search():返回匹配位置索引,找不到返回 -1
const pos = str.search(/welcome/); // 14
// 4. split():按正则拆分字符串
const parts = "a,b;c|d".split(/[,;|]/); // ["a", "b", "c", "d"]
match 和 exec 都用来获取匹配结果,区别在于:match 是字符串的方法,直接返回所有匹配项(加 g 修饰符时);exec 是正则的方法,需要循环调用才能拿到所有匹配项。日常提取数据优先用 match,需要同时获取捕获组信息时再用 exec。
replace 是实际开发中使用频率最高的一个。除了简单的替换,它还支持用 $1、$2 引用捕获组的内容,比如把日期的格式从 2024-01-15 换成 2024/01/15:
javascript复制const date = "2024-01-15";
const formatted = date.replace(/(\d{4})-(\d{2})-(\d{2})/, "$1/$2/$3");
console.log(formatted); // "2024/01/15"
这一段代码在 Java Web 项目里很常见,比如前端日期组件返回的格式和后端接口要求的格式不一致时,用 replace 一次搞定,不用拆拆拼拼那么麻烦。
3. 正则基础语法速查:从字符到断言
3.1 普通字符与元字符
基础字符就不多啰嗦了,重点说元字符。元字符是正则的骨架,我把最常用的列出来:
| 元字符 | 含义 | 示例 |
|---|---|---|
. |
匹配除换行外的任意字符 | /a.c/ 能匹配 "abc"、"a1c" |
\d |
匹配数字 | /\d/ 能匹配 "5" |
\D |
匹配非数字 | /\D/ 能匹配 "a" |
\w |
匹配字母、数字、下划线 | /\w/ 能匹配 "a"、"5"、"_" |
\W |
匹配非字母数字下划线 | /\W/ 能匹配 "@"、空格 |
\s |
匹配空白字符 | /\s/ 能匹配空格、Tab |
\S |
匹配非空白字符 | /\S/ 能匹配 "a"、"5" |
\n |
匹配换行符 | /\n/ 能匹配换行 |
\t |
匹配制表符 | /\t/ 能匹配 Tab |
^ |
匹配字符串开头 | /^ab/ 能匹配 "abc" 中的 "ab" |
$ |
匹配字符串结尾 | /ab$/ 能匹配 "cab" 中的 "ab" |
\b |
匹配单词边界 | /\bjava\b/ 能匹配 "java web",不匹配 "javascript" 里的 "java" |
[] |
字符集合 | /[aeiou]/ 匹配任意一个元音字母 |
[^] |
否定字符集合 | /[^0-9]/ 匹配任意非数字字符 |
| ` | ` | 或关系 |
() |
分组 | /(ab)+/ 匹配一个或多个 "ab" |
这里特别提一下 ^ 和 $ 的用法。在表单校验里这俩是黄金搭档,/^...$/ 表示从开头到结尾完全匹配,防止出现"包含"的情况。比如校验手机号时,如果用 /\d{11}/,那么 "abc13812345678def" 这个字符串也能通过 test,因为中间包含了 11 位数字;但用 /^\d{11}$/ 就完全不一样了,它严格要求整个字符串就是 11 位数字,多一个字符都不行。这个坑新手特别容易踩,我在项目 Code Review 时见过不少次。
3.2 量词与贪婪/懒惰匹配
量词用来指定前面的字符或分组出现的次数:
| 量词 | 含义 | 示例 |
|---|---|---|
* |
出现 0 次或多次 | /ab*c/ 匹配 "ac"、"abc"、"abbc" |
+ |
出现 1 次或多次 | /ab+c/ 匹配 "abc",不匹配 "ac" |
? |
出现 0 次或 1 次 | /ab?c/ 匹配 "ac"、"abc" |
{n} |
出现恰好 n 次 | /\d{4}/ 匹配 4 位数字 |
{n,} |
出现至少 n 次 | /\d{2,}/ 匹配 2 位及以上数字 |
{n,m} |
出现 n 到 m 次 | /\d{6,12}/ 匹配 6~12 位数字 |
默认情况下,量词是贪婪的,也就是说它会尽可能多地匹配字符。举一个典型的例子:
javascript复制const str = "<div>内容1</div><div>内容2</div>";
// 贪婪匹配
console.log(str.match(/<div>.*<\/div>/));
// 匹配结果是一个大段:"<div>内容1</div><div>内容2</div>"
// 懒惰匹配(在量词后面加 ?)
console.log(str.match(/<div>.*?<\/div>/));
// 匹配结果是第一个:"<div>内容1</div>"
.*? 这个组合在提取 HTML 内容时特别常用,它表示"尽可能少地匹配任意字符",遇到第一个 </div> 就停下来。这个细节没搞清楚的话,提取数据时经常出现"一下子把整页都匹配进去了"的诡异现象,我见过不止一个同事在这个问题上卡了半天。
3.3 分组、捕获与反向引用
括号 () 在正则里有两个作用:一是把多个字符当成一个整体(分组),二是捕获匹配的内容供后续使用。
javascript复制// 分组:把 "ab" 当成整体
const reg = /(ab)+/;
console.log(reg.test("ababab")); // true
// 捕获:提取用户输入中的关键部分
const reg2 = /(\d{4})-(\d{2})-(\d{2})/;
const result = "2024-01-15".match(reg2);
console.log(result[1]); // "2024"
console.log(result[2]); // "01"
console.log(result[3]); // "15"
捕获组还有一个进阶用法叫反向引用,在正则内部用 \1、\2 来引用之前的捕获组。比如判断一个字符串里是否有连续重复的单词:
javascript复制const reg = /(\w+)\s+\1/;
console.log(reg.test("hello hello world")); // true
console.log(reg.test("hello world")); // false
反向引用在实际开发中不算高频,但一旦遇到"匹配成对标签"(比如匹配 <b>...</b>,要求开始和结束标签一致)这种需求时,它就是唯一解法。比如匹配一对 h1 标签:
javascript复制const reg = /<(h1)>(.*?)<\/\1>/;
console.log(reg.test("<h1>标题</h1>")); // true
console.log(reg.test("<h1>标题</h2>")); // false
注意最后一个用 \1 来引用第一个捕获组 (h1),这样结束标签就自动必须和开始标签一致,避免出现 <h1>...</h2> 这种错配。
3.4 修饰符:g、i、m、s、u、y
修饰符写在正则结尾,比如 /abc/i 后面的 i。我按使用频率排序说一下:
i(ignoreCase):忽略大小写。比如校验验证码时用户输入的大小写不影响。
javascript复制const reg = /^[a-z0-9]{6}$/i;
console.log(reg.test("ABC123")); // true
g(global):全局匹配。不加 g 时,match() 只返回第一个匹配项;加了 g 返回所有匹配项。replace() 不加 g 只替换第一个匹配项,加了 g 替换所有。
javascript复制const str = "2024-01-15";
console.log(str.replace(/-/g, "/")); // "2024/01/15"
// 如果不加 g,只会替换第一个: "2024/01-15"
m(multiline):多行模式。默认情况下 ^ 和 $ 匹配的是整个字符串的开头和结尾;加了 m 之后,它们会匹配每一行的开头和结尾。
javascript复制const text = "第一行\n第二行\n第三行";
console.log(text.match(/^第二行$/)); // null
console.log(text.match(/^第二行$/m)); // ["第二行"]
这个在 Java Web 项目里处理多行文本域的输入内容时可能会用到,比如从前端传过来的多行地址里提取指定行。
s(dotAll):让 . 也能匹配换行符。默认 . 不匹配换行,加了 s 之后就可以跨行匹配了,处理富文本内容时会用到。
u(unicode):开启完整的 Unicode 匹配模式,对中文和 emoji 的处理更准确。比如匹配中文字符时,不加 u 的经典写法是 [\u4e00-\u9fa5],加了 u 之后可以用更简洁的 \p{Script=Han},但这个写法的兼容性要注意,我一般还是倾向于写 [\u4e00-\u9fa5],稳妥。
y(sticky):粘性匹配。它和 g 类似是全局的,但它只从 lastIndex 位置开始匹配,且匹配失败时不会向后搜索。这个用得很少,了解即可。
3.5 断言:前瞻与后顾
断言是正则里比较高级的内容,它的特点是只判断位置,不消耗字符。也就是说,你用它来"卡位置",但最终匹配结果里不会包含断言部分的内容。
正向先行断言 (?=...):表示后面必须跟着什么。
javascript复制// 匹配后面跟着 "px" 的数字
const reg = /\d+(?=px)/;
console.log("宽度是120px".match(reg)[0]); // "120"
console.log("宽度是120em".match(reg)); // null
负向先行断言 (?!...):表示后面不能跟着什么。
javascript复制// 匹配后面不跟着 "px" 的数字
const reg = /\d+(?!px)/;
console.log("宽度是120em".match(reg)[0]); // "120"
后行断言 (?<=...) 和 (?<!...):表示前面必须(或不能)是什么。后行断言是 ES2018 才引入的,Chrome 62+ 支持,老版本浏览器不兼容,生产环境使用前要确认一下用户群体。
javascript复制// 匹配前面是 "$" 的数字
const reg = /(?<=\$)\d+/;
console.log("价格是$99".match(reg)[0]); // "99"
断言的典型场景比如密码强度校验:要求密码必须同时包含字母和数字,且长度在 8~20 位之间。这个用断言写起来非常优雅:
javascript复制const reg = /^(?=.*[a-zA-Z])(?=.*\d)[a-zA-Z0-9]{8,20}$/;
console.log(reg.test("abc12345")); // true
console.log(reg.test("abcdefgh")); // false,没有数字
console.log(reg.test("12345678")); // false,没有字母
console.log(reg.test("abc123")); // false,长度不够
这里 (?=.*[a-zA-Z]) 表示"某个位置后面必须有一个字母(允许前面有任意字符)",(?=.*\d) 表示"必须有一个数字",两个断言配合 [a-zA-Z0-9]{8,20} 就实现了密码的复合校验。这种写法比"先判断长度、再用 indexOf 循环判断字符类型"要简洁得多,代码可读性也高一些。
4. Java Web 项目中的典型应用案例
4.1 表单校验:注册页用户名校验
在 Java Web 项目里,用户注册、登录、个人信息修改这些页面,前端几乎都要做表单预校验。后端 Java 也要做同样的校验,但前端先拦截一次、减少无效请求总是好的。
比如用户名校验,要求:只能包含字母、数字、下划线,长度 4~16 位,不能以数字开头。前端 JS 写法如下:
javascript复制function validateUsername(username) {
const reg = /^[a-zA-Z_][a-zA-Z0-9_]{3,15}$/;
return reg.test(username);
}
// 测试
console.log(validateUsername("admin")); // true
console.log(validateUsername("user_123")); // true
console.log(validateUsername("123admin")); // false,不能以数字开头
console.log(validateUsername("ab")); // false,长度不够
另一个常见的场景是手机号校验。国内手机号目前主要是 1 开头的 11 位数字,第一位 1,第二位通常是 3、5、7、8、9 等。匹配规则可以写成:
javascript复制const mobileReg = /^1[3-9]\d{9}$/;
console.log(mobileReg.test("13812345678")); // true
console.log(mobileReg.test("12812345678")); // false
这里第二段 [3-9] 限制了第二位只能是 3 到 9。有些同学会把号段写得很死(比如 [34578]),但号段是不断扩充的(比如 19x 号段已经陆续放号),用范围 [3-9] 更稳妥,避免出现"新号段用户无法注册"的问题。这是我踩过的坑。
4.2 文本处理:从富文本中提取图片地址
Java Web 项目里处理富文本编辑器内容是个高频场景。比如用户提交了一篇带图片的文章,后端存的是 HTML,但前端列表页只需要图片地址来做缩略图;或者后端需要把图片转存到文件服务器,需要先提取出所有 <img> 标签的 src 属性。
用 match 配合正则可以快速提取:
javascript复制const html = `
<p>这是一段介绍文字</p>
<img src="/upload/202401/abc.jpg" alt="图片1">
<p>中间段落</p>
<img src="/upload/202401/def.png" alt="图片2">
`;
// 提取所有 img 标签
const imgTags = html.match(/<img[^>]*>/g);
console.log(imgTags);
// ["<img src=\"/upload/202401/abc.jpg\" alt=\"图片1\">",
// "<img src=\"/upload/202401/def.png\" alt=\"图片2\">"]
// 提取所有 src 属性值
const srcList = [];
const tempReg = /<img[^>]*src=["']([^"']+)["']/g;
let tempResult;
while ((tempResult = tempReg.exec(html)) !== null) {
srcList.push(tempResult[1]);
}
console.log(srcList);
// ["/upload/202401/abc.jpg", "/upload/202401/def.png"]
这里有几个细节值得说一下。第一,<img[^>]*> 里的 [^>]* 表示匹配任意数量非 > 的字符,目的是处理 img 标签上可能还有其他属性(如 style、class)的情况;第二,["'] 匹配单引号或双引号,因为 HTML 属性值可能用任一种引号;第三,src=["']([^"']+)["'] 用捕获组把 src 的值单独取出来,这样就不用再从匹配结果里二次切割了。
循环调用 exec() 是处理"需要提取多个匹配项且要捕获组内容"时的标准写法。每次调用后 exec 会自动更新正则对象的 lastIndex 属性,下次调用从上次匹配结束的位置继续找,直到返回 null 表示找完了。注意这里正则需要加 g 修饰符,否则 exec() 会一直返回第一个匹配结果,导致死循环,这个细节我在项目里真遇到过。
4.3 敏感词过滤与高亮
论坛、评论系统里常需要做敏感词过滤和搜索关键词高亮。前端展示层做高亮,后端做最终过滤,分工明确。
搜索关键词高亮可以这样写:
javascript复制function highlightKeyword(text, keyword) {
// 先对 keyword 做转义,防止用户输入的正则元字符破坏规则
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const reg = new RegExp(`(${escaped})`, "gi");
return text.replace(reg, "<span class='highlight'>$1</span>");
}
const content = "JavaScript 是 Java Web 开发中的重要前端语言";
console.log(highlightKeyword(content, "java"));
// "JavaScript 是 <span class='highlight'>Java</span> Web 开发中的重要前端语言"
这个例子里有个非常重要的细节:用户输入的内容在拼进正则之前必须做转义。如果用户搜索 "a+b" 这种字符串,+ 在正则里是量词,直接拼接会导致语法错误或匹配出意外结果。转义方法就是把所有正则元字符前面加上反斜杠,上面代码里的 /[.*+?^${}()|[\]\\]/g 就是干这个的。
看到这里你可能会问,正则元字符那么多,会不会漏掉?不会,因为 /[.*+?^${}()|[\]\\]/g 已经覆盖了正则语法中的所有特殊字符。如果碰到不确定的,直接把这个转义函数当成工具函数存着,以后任何时候把字符串拼进正则之前都先过一遍,准没错。
4.4 与后端 Java 正则的呼应
既然标题是"Java Web 学习"系列,我有必要提一下 JavaScript 正则与 Java 后端正则的对应关系。后端 Java 里也有 java.util.regex.Pattern 和 Matcher 两个类,用法和 JS 高度相似:
java复制// Java 后端校验手机号
String mobile = "13812345678";
String regex = "^1[3-9]\\d{9}$";
boolean matches = Pattern.matches(regex, mobile);
System.out.println(matches); // true
注意这里 Java 字符串里的 \\d 和 JS 字面量里的 \d 的差别,原因前面说过了——Java 这边是先处理字符串转义,再交给正则引擎解析,所以要多写一层反斜杠。这个不一致是前后端联调时最容易踩的坑,前端写的正则能跑,复制到后端死活不对,多半就是因为 \ 没转对。
前后端校验规则尽量保持一致。如果前端用了宽松规则、后端用了严格规则,会出现"前端校验通过、提交后端被拒"的尴尬情况;反过来更严重,后端规则宽松可能引入脏数据。所以我在项目里通常会把这类公共正则写在一个单独的前端 JS 文件里,后端用对应的 Java 常量类维护一份,两边保持一致,谁改谁通知对方。
5. 常见问题与调试技巧实录
5.1 高频错误与排查思路
我整理了一下实际开发中遇到的高频正则问题,做成一个速查表,方便你对照排查。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 校验总是通过,明明输入不合法 | 没有加 ^ 和 $,导致只做了包含匹配 |
确认是否需要全字匹配,加上 ^...$ |
加了 ^ 和 $ 后合法输入反而不通过 |
字符串首尾有隐藏的空白字符 | 先 trim() 再校验,或在校验规则里加上 \s* |
replace 只替换了第一处 |
忘记加 g 修饰符 |
需要全部替换时加上 g |
match 返回的结果永远只有第一项 |
忘记加 g 修饰符 |
需要所有匹配项时加上 g |
| 正则里有动态拼接的用户输入,结果异常 | 用户输入包含正则元字符 | 对输入先做转义处理 |
| 提取 HTML 内容时把整个页面都匹配进去了 | 贪婪匹配导致 | 把 .* 改成 .*? |
明明写了 \d,却匹配不到任何内容 |
在字符串里写的 \\d 变成字面量反斜杠加 d |
用字面量 /.../ 写正则,不要用字符串拼接 |
| 想要忽略大小写但没生效 | 忘记加 i 修饰符 |
规则末尾加 i |
执行 exec 进入死循环 |
正则没加 g,lastIndex 一直不前进 |
加 g,并处理好空匹配的情况 |
最后一条死循环问题值得展开说一下。exec() 配合 g 修饰符时,如果正则匹配的是空字符串(比如 /(?=a)/g 这种零宽断言),lastIndex 不会自动前进,就可能导致死循环。真实场景里遇到 while (exec() !== null) 卡死,先检查正则是否能匹配空串,能匹配的话在循环体里手动 index++ 或换写法。
5.2 调试工具与技巧
正则调试靠"眼瞪"效率太低了,我平时用这几招:
第一,直接在浏览器控制台跑。 写正则最直接的方式就是在 F12 控制台里写一行测试代码,不用开编辑器,随时随地验证。
第二,用在线正则可视化工具。 这类工具(比如 regex101、regexr 等)会实时展示正则的匹配过程,还会把每个分组用不同颜色标出来,一眼就能看出自己写的规则匹配到了什么。对于复杂正则,可视化这一步能省下大量排查时间。注意别在工具里输入任何敏感的业务数据,公共工具不具备数据安全保障。
第三,把正则拆成小块逐段验证。 一个复杂正则不要一口气写完然后试图一次调通,先验证最内层的部分,比如先验证 [a-zA-Z0-9]{6} 能不能匹配六位字符,再加上前后断言,逐层叠加。这个习惯能帮你快速定位问题出在哪一段。
5.3 性能注意点与最佳实践
性能问题在 Java Web 项目里主要出现在两个地方:一是数据量大时的正则循环,二是灾难性回溯。
先说灾难性回溯。有些正则写法在特定输入下会引发指数级的回溯尝试,导致页面卡死或 CPU 飙高。典型例子是嵌套量词配合重叠模式,比如 /(a+)+b/ 在匹配一段很长的连续 "aaaa" 且最终没有 b 时,引擎会尝试海量的分组方式,性能急剧下降。解决办法是:避免嵌套量词、避免在量词内使用模糊范围过大的模式、优先使用懒惰量词,必要时加超时保护。
再说循环里的正则。提取大量数据时,正则在循环外创建,不要在循环体内重复 new RegExp():
javascript复制// 不推荐:每次循环都创建新的正则
let results = [];
dataList.forEach(item => {
let reg = new RegExp(`\\d{4}`, "g");
results.push(item.match(reg));
});
// 推荐:把正则放循环外
let reg = /\d{4}/g;
let results = [];
dataList.forEach(item => {
results.push(item.match(reg));
});
对于超大文本(比如几 MB 的 HTML),正则提取确实方便,但每一次匹配都是对整段文本的扫描,如果要做多种规则的提取,尽量用一次遍历取到所有需要的结构,而不是每提取一个字段就 match 一次。我之前的做法是先取出大的 HTML 块,再在块级范围内做进一步提取,这样比通篇扫效率高不少。
另外提一个习惯问题:写正则一定要加注释。正则的可读性本身就差,过两周再看自己写的 /^(\d{4})-(\d{2})-(\d{2})$/ 可能还得想一下意图,更别说别人看你的代码了。好在 JS 正则支持注释(使用 (?#...) 或者带 x 修饰符的扩展模式,但 x 修饰符在 JS 里不支持),所以通常的做法是在正则上方写一行注释说明规则:
javascript复制// 校验日期格式:YYYY-MM-DD,年份 1900-2099
const dateReg = /^(19|20)\d{2}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
这个日期正则稍微复杂点,但它把月份在 01~12、日期在 01~31 也做了一层限制,比简单的 \d{4}-\d{2}-\d{2} 严谨得多。如果遇到比这还复杂的正则,我更建议拆开写成多个小函数,各自校验一个维度,比写一个大而全的表达式更好维护。
最后再分享一点我的实际体会
我在 Java Web 项目里写前端校验写了两年多,最大的感受是:正则这东西,不需要把所有语法都背下来,但一定要把最常用的几套(字符匹配、量词、分组、修饰符、断言)练到肌肉记忆,遇到问题时能快速想到"这可以用正则解决",然后翻文档写出正确的表达式。真正的高手不是记住所有写法,而是知道什么时候用正则、什么时候不该用正则。比如解析 XML/HTML 这种结构化的东西,正则只是临时方案,根本上还是得靠 DOM 解析器;而简单的字符串前缀后缀判断,用一个 startsWith 就够了,没必要上正则。工具选对了,问题就解决了一半。
这一篇把 JavaScript 正则的基础和实战串了一遍,里面的例子都是从真实项目里提炼出来的,你照着跑一遍,基本就能应付日常开发了。下一阶段如果有时间,我想聊聊在 Java Web 项目里如何统一管理前端公共校验规则,以及如何把后端 Java 的正则校验和前端做成一套配置,那样前后端就不会再出现"标准不一致"的问题了。
