JavaScript正则表达式匹配规则与性能优化实战
1. 先搞懂 JS 正则的引擎模型,匹配规则才有解释力
正则表达式在 JavaScript 里属于那种“平时不太想起、一想起就头皮发麻”的东西。我做了几年前端,接手过不少别人留下的表达式,也自己写过一堆事后完全看不懂的“字符串黑魔法”。真正让我把 JavaScript 正则表达式匹配规则彻底捋清楚的,是一次线上故障:一个看起来人畜无害的输入校验,把页面卡死了将近四秒,定位到最后就是一行嵌套量词。从那以后我不再把正则当成“网上抄一段能用就行”的玄学,而是当成一门有明确执行模型的迷你语言——规则清楚了,写出来的东西就能预测、能调试、能优化。
这篇内容想做的事很直接:把 JS 正则从引擎模型、字符匹配、量词行为、分组捕获、零宽断言、修饰符状态,一直到实战场景和排查手段,完整拆一遍。适合两类人看——一类是被/^.*$/吓退过、想真正入门的同学;另一类是已经能写,但经常遇到“为什么这里多匹配了一位”“为什么全局匹配结果对不上”的老手。下面所有结论都按 ECMAScript 规范里 Backtracking 引擎的行为来讲,浏览器和 Node 环境下表现一致。
1.1 回溯型引擎决定了你写的每一行规则的成本
JavaScript 用的是回溯型(backtracking)引擎,风格上接近 Perl,不是 DFA 那种“把整个正则编译成状态机一次性推进”的模型。这个区别非常关键:DFA 引擎对每个位置只推进一次,不会重复尝试;回溯引擎在遇到选择分支时会“记住当前位置”,先走一条路,走不通再退回来换下一条。你可以把它想象成走迷宫,每到一个岔路口就插一面小旗,走到死胡同就退回最近的一面旗,换另一条道。
这个机制直接决定了三件事。第一,正则的匹配顺序是从左到右、从外到内,前面的部分先确定,后面的部分才有机会尝试;第二,同一个子表达式在失败时可能被反复执行很多次,这就是性能问题的根源;第三,正则的书写顺序会影响匹配结果,而不是像数学公式那样可以随便交换顺序。很多人写/\d+|\d+\.\d+/想匹配小数,结果整数分支先命中,小数点永远匹配不到,原因就在这里——交替分支是从左往右依次尝试的,先成功的直接返回。
1.2 把“位置”当成第一视角,很多困惑会自动消失
我建议换个角度理解匹配:正则引擎并不是在“字符串里找子串”,而是在字符串的每个位置之间插入一个游标,然后尝试让正则从这个游标位置开始吃掉一串字符。字符串"abc"实际上有四个位置:0(a 前)、1(a 和 b 之间)、2(b 和 c 之间)、3(c 之后)。所谓“匹配成功”,就是游标从某个起点出发,经过一系列消耗动作之后,到达了某个终点位置。
这个视角能解释很多反直觉现象。比如/\b/g在"abc"上执行match,结果是两个空字符串——因为\b是零宽断言,不消耗字符,只检查“当前位置是不是单词边界”,位置 0 和位置 3 满足条件,于是返回两个长度为零的匹配。再比如lastIndex为什么在全局模式下会“卡住不动”,也是因为零宽匹配没有推进游标,需要手动干预。理解“位置”这件事之后,exec、match、replace的行为差异就都能从同一个模型里推出来。
1.3 零宽概念要提前建立,否则断言章节会看懵
**零宽(zero-width)**指的是匹配成功但不消耗字符的表达式,包括锚点^ $、单词边界\b \B、以及四种环视断言。它们的共同特点是:只对当前游标位置做一次“条件判断”,判断通过就继续,不通过就回退。
这个特点带来一个常见误区:有人以为/^/g能匹配每一行的开头,实际上不加m修饰符时^只认整个字符串的起始位置,"a\nb".match(/^/g)只会返回一个结果。加了m之后,^的含义变成“\n之后的位置或者字符串起始位置”,才会返回两个。零宽表达式在整个规则体系里像是“检查站”而不是“路面”,它不占用里程,只负责放行或拦截。
2. 字符与字符类:匹配规则最基础的一层
字符匹配是整条链路的起点。这一层看起来简单,实际上坑最多,因为很多坑不会报错,只会让匹配结果悄悄偏一位或者多吞一段。我把这一层分成三块讲:字面量与元字符的边界、点号和字符类的陷阱、以及简写类和 Unicode 属性转义。
2.1 哪些字符是元字符,哪些需要转义
JS 正则里的元字符一共这么几个:^ $ \ . * + ? ( ) [ ] { } |。它们在任何位置出现都带特殊含义,想匹配字面量必须加反斜杠。剩下的大多数标点,比如!、@、#、%、&、-、_、=、:、;、'、"、,、<、>、/,在字符类外面基本都是普通字符。
这里有个容易忽略的点:/在正则字面量里必须转义成\/,否则会把字面量的结束斜杠提前闭合,导致语法错误。但在new RegExp("...")的字符串形式里,/不需要转义,反斜杠反而要写两遍。我见过不少人把字面量直接复制进new RegExp里用,结果转义层数算错,规则完全失效。我的习惯是:静态规则一律用字面量写法,只有需要拼接变量时才用构造函数,并且拼接前单独处理转义。
// 字面量写法,斜杠需转义 const re1 = /a\/b/; // 构造函数写法,斜杠不用转义,反斜杠要双写 const re2 = new RegExp("a/b"); const re3 = new RegExp("\\d+"); // 等价于 /\d+/注意:从用户输入里取内容拼进
new RegExp之前,一定要先做一次元字符转义,否则用户输入一个.就能匹配任意字符,输入(直接抛语法错误。常见做法是str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")。
2.2 点号、字符类与“看起来一样其实不同”的写法
点号.默认匹配除换行符之外的任意单个字符。注意是“除换行符之外”——\n不匹配,\r在多数规范解读里也被排除,\u2028和\u2029同样不匹配。想要点号匹配一切,得加s修饰符(dotAll),或者老老实实写[\s\S]。这两种写法我都用,前者可读性好,后者兼容性无敌,你按目标环境选就行。
字符类[...]内部规则和外面不一样,这点必须记牢:字符类里绝大多数元字符失效,.就是普通点,*就是普通星号,(就是普通左括号。只有四个字符需要特殊对待:]要转义或放在首位,\要转义,^放在首位表示取反,-放在两个字符之间表示范围、放在首尾则是普通连字符。所以[a-z]是范围,[-a-z]和[a-z-]都把连字符当普通字符。
/[.]/.test("a"); // false,只匹配字面量点 /[.]/.test("."); // true /[a-c]/.test("b"); // true /[-ac]/.test("-"); // true /[^abc]/.test("d"); // true,取反字符类还有一个性能上的隐性优势:[abc]在引擎内部通常被优化成一次字符查表,而(a|b|c)是三个分支依次尝试,前者明显更快。规则里能合并成字符类的,就别写成交替。
2.3 简写字符类只认 ASCII,Unicode 属性转义才是正解
\d等价于[0-9],\w等价于[A-Za-z0-9_],\s包含空格、制表符、换行、回车、换页、垂直制表符以及一堆 Unicode 空白。这三个简写在英文环境里够用,但遇到中文、日文、带重音的拉丁字母就露馅了——\w不会匹配汉字,也不匹配全角字母。
ES2018 引入的 Unicode 属性转义解决了这个问题,但必须搭配u修饰符:\p{Script=Han}匹配汉字,\p{L}匹配任意字母,\p{Nd}匹配任意十进制数字,\p{Emoji}匹配 emoji。反向写法是把p换成P,表示“不属于该属性”。
const han = /\p{Script=Han}+/u; han.test("你好"); // true han.test("hello"); // false const anyLetter = /\p{L}+/u; anyLetter.test("café"); // true,é 也算字母 // 不加 u 修饰符,属性转义会被当成普通字符序列 /\p{L}/.test("a"); // false实操心得:只要规则里可能碰到中文或者国际化内容,一律加
u修饰符。加了u之后,.和量词的处理单位会从“码元”升级到“码点”,emoji 这类由两个码元组成的字符才不会被劈成两半。
3. 量词的三种脾气:贪婪、懒惰与回溯代价
量词是正则里最容易写出性能问题的地方。*、+、?、{n}、{n,}、{n,m}这六个符号背后其实只有两种默认行为:贪婪和懒惰。搞懂它们的推进顺序,你就能预测任何一条规则的执行轨迹。
3.1 贪婪量词先把话说满,再往回收
贪婪是默认行为。a+在"aaa"上会先把三个 a 全吃掉,如果后面还有别的条件(比如a+b),发现吃太饱了匹配不上 b,就往回退一个字符再试,直到退到能满足条件的程度。这个“先吃满再吐出来”的过程就是回溯。
举一个具体例子:/a+b/匹配"aaab"。引擎先让a+吞掉三个 a,游标停在位置 3;接着尝试匹配b,位置 3 正好是 b,成功,结束。整个过程只发生了一次匹配尝试,没有回溯。但如果换成"aaac",a+吞三个 a 后匹配b失败,退回到吃两个 a,位置 2 是 a 不是 b,再失败,退回到吃一个 a,位置 1 还是 a,还是失败,最后整体匹配失败。这时候回溯发生了两次。
"aaab".match(/a+b/); // ["aaab"],无回溯 "aaac".match(/a+b/); // null,回溯两次后失败贪婪量词的直觉是“尽量多”,所以写/<.+>/去抓 HTML 标签是很危险的做法——它会从第一个<一路吞到最后一个>,中间的标签全被吃掉。这就是常说的“贪婪吃过头”。
3.2 懒惰量词从最小开始,一点点往外扩
在量词后面加?就变成懒惰模式:*?、+?、??、{n,m}?。行为正好相反,先尝试最小匹配,满足不了再一个一个往外扩。还是刚才的 HTML 场景,/<.+?>/匹配"<a><b>",会先尝试<a>(点号只吃一个字符),后面紧跟>,成功,返回<a>。这就是大家熟悉的“非贪婪匹配”。
懒惰不是没成本。/a.*?b/匹配"aaaaab",引擎从.*?只吃零个字符开始,每次尝试b失败就多扩一个字符,直到扩到位置 5 才命中。整个过程中.*?被重复检查了六次。贪婪版本/a.*b/在这里反而是先吞满再退一步,只尝试一次。所以“懒惰更快”是个错觉,具体快慢取决于目标字符在字符串里的位置——目标靠后,懒惰慢;目标靠前,贪婪慢。真正稳的做法是让规则尽可能具体,减少不确定的重复。
| 写法 | 匹配"<a><b>"结果 | 回溯特征 |
|---|---|---|
/<.+>/ | <a><b> | 吞满后回溯,一次成功 |
/<.+?>/ | <a> | 从最小扩展,扩展一次成功 |
/<[^>]+>/ | <a> | 无回溯,字符类直接排除 |
第三行是我实际项目里最推荐的写法:用否定字符类替掉懒惰点号。[^>]+明确告诉引擎“我要的是所有不是>的字符”,引擎沿着字符串一路推进,每个字符只判断一次,不存在“先大量匹配再回退”的动作。规则越确定,回溯空间越小。
3.3 嵌套量词是灾难性回溯的温床
真正要小心的是量词嵌套,形如(a+)+、(a*)*、(\w+\s?)*这种结构。它们的回溯次数不是线性增长,而是指数级爆炸。
经典测试:/^(a+)+$/匹配"aaaaaaaaaaaaaaaaaaaaX"(20 个 a 加一个 X)。因为结尾是 X,整体必然失败,但引擎在失败之前会把所有“a 的分组方式”枚举一遍。20 个 a 的分组方式数量是 2 的 19 次方,五十多万种,每种都要试一遍才能确定失败。字符串再长几个字符,耗时直接翻倍。
// 危险写法 const bad = /^(a+)+$/; // 20 个 a + X,实测耗时在几十毫秒到上百毫秒之间波动 bad.test("aaaaaaaaaaaaaaaaaaaaX"); // 安全写法:显式排除不该出现的字符 const good = /^a+$/; good.test("aaaaaaaaaaaaaaaaaaaaX"); // 立即返回 false我在线上遇到的那次卡顿,问题规则是^(\w+\s?)*$,用户在一个输入框里粘贴了一段长文本,末尾带了个特殊符号,整个页面主线程被占住了三秒多。改法很简单:把\s?从重复结构里拿出来,改成^\w+(?:\s\w+)*$,让每次重复都必须消耗一个确定的分隔符,回溯路径一下子就收窄了。
注意:JS 目前没有原子组语法,也没有占有量词。想让某个子表达式“匹配后不再回退”,标准的模拟写法是
(?=(pattern))\1——先用前瞻把内容匹配一遍并捕获,再用反向引用“消费”掉这段内容。这个技巧在处理超长日志、CSV 行解析时非常好用,代价是可读性下降,建议只在确实出现性能瓶颈时才用。
4. 分组、捕获与反向引用:规则里的“记忆”机制
分组不只是把几个字符打包,它还承担着“记住这段内容”的职责。JS 里的分组分成三类:捕获组、非捕获组、命名捕获组,行为差异直接影响match返回值、replace参数以及性能。
4.1 捕获组编号按左括号出现的顺序走
每出现一个(,引擎就分配一个编号,从 1 开始,按左括号出现的先后顺序排。嵌套的时候外层编号小、内层编号大,这一点很多人会记反。
const re = /(\d{4})-(\d{2})-(\d{2})/; const m = "2024-06-15".match(re); // m[0] 完整匹配 "2024-06-15" // m[1] "2024",m[2] "06",m[3] "15"m.index是完整匹配的起始位置,m.input是原始字符串,m.groups只有在用了命名捕获组时才有内容。如果某个可选捕获组没有参与匹配,对应位置是undefined而不是空字符串,这个区别在做条件判断时很容易踩坑——undefined和""在if里的行为完全不同。
4.2?:去掉不需要的记忆,?<name>给记忆起个名字
写了一堆分组但只关心其中一两个,其余的全用非捕获组(?:...)包起来,是我最常做的优化。它不参与编号,不占用返回数组的位置,还能省一点内存。很多库函数里的复杂规则,一半以上的括号其实都该是(?:。
// 只有日期部分需要捕获 const re = /(?:周|星期)([一二三四五六日])/; "星期三".match(re)[1]; // "三"命名捕获组(?<name>...)是 ES2018 加的,好处是取结果不再靠数括号,改规则的时候不怕编号错位。用在replace里尤其舒服,回调参数最后会多一个groups对象。
const re = /(?<year>\d{4})-(?<month>\d{2})/; const m = "2024-06".match(re); m.groups.year; // "2024" "2024-06".replace(re, "$<month>/$<year>"); // "06/2024"4.3 反向引用匹配的是“内容”而不是“模式”
\1、\2、\k<name>表示“这里要和之前第几个捕获组匹配到的内容完全一致”。注意是内容一致,不是模式一致。/(a|b)\1/能匹配"aa"和"bb",但匹配不了"ab"。
这个特性在几种场景下特别好用:检测重复字符(/(.)\1/)、匹配成对的标签(/<(\w+)>.*?<\/\1>/)、校验引号是否闭合(/^(["']).*?\1$/)。我做过一个配置文件的简易校验器,就是靠反向引用确认键值对的引号类型是否一致。
const pair = /<(\w+)>[^<]*<\/\1>/; pair.test("<div>内容</div>"); // true pair.test("<div>内容</span>"); // false注意:反向引用是明确告诉引擎“这一段必须和前面一样”,它能大幅减少无效回溯,但也会让规则变复杂。如果只是检测“任意字符重复”,
(.)\1比写死具体字符要简洁得多;如果是为了配对标签,记得标签名不要用贪婪的.*,否则嵌套标签场景下会出错。
5. 零宽断言:匹配位置而不是匹配字符
断言是让很多人绕不出来的一层。核心认知只有一句话:断言不消耗字符,它只回答“当前位置满不满足条件”。答完就完,游标原地不动。
5.1 锚点与多行模式,别把^当成“行首”默认值
^默认只匹配字符串起始位置,$默认只匹配字符串结尾位置(在m模式下还会匹配\n之前的位置,注意不是“行尾”,是“换行符之前”)。不加m的时候^和$各只有一个合法位置,加m之后位置数量等于行数加一。
"a\nb\nc".match(/^./g); // ["a"],只有开头命中 "a\nb\nc".match(/^./gm); // ["a", "b", "c"] "a\nb\nc".match(/.$/gm); // ["a", "b", "c"]一个常被忽略的细节:$在m模式下匹配的是\n之前的那个位置,如果字符串是"a\r\nb",$会落在\r和\n之间,.是匹配不到\r和\n的,所以.加$在 CRLF 换行的文本里会出问题。处理 Windows 风格换行时,最好显式写\r?$或者先把\r\n统一成\n。
5.2 前瞻断言:条件判断放在匹配之前
(?=...)是正向先行断言,(?!...)是负向先行断言,都写在目标位置的右边,表示“从这个位置往后看,是否满足/不满足某个模式”,但不吃掉这段内容。
最典型的使用场景是密码复杂度校验,用多条前瞻叠加,各个条件互不干扰:
const strongPwd = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{10,}$/;拆开看:^把位置钉死在开头,四条前瞻依次检查“后面是否出现过小写、大写、数字、符号”,每条检查完位置都不动,最后还是停在开头,.{10,}$才真正开始消耗字符。这种写法的好处是每条规则独立,加一条减一条不影响其他条件,比写一个巨型字符类可维护得多。
负向前瞻更常见的用途是“排除某些形式”。比如匹配所有id="xxx"但不是id="_"开头的:
/^id="(?!_)[^"]*"$/5.3 后顾断言:能回头看,但要求定长
(?<=...)和(?<!...)是 ES2018 加的,表示“从当前位置往前看”。它的限制是模式必须是定长或者有明确最大长度的,不能写(?<=a*)这种无界模式。这一点在换语言时经常踩坑——有些语言的实现允许变长后顾,JS 的 V8 不允许。
// 提取价格数字,但不要货币符号 "$12.5".match(/(?<=\$)\d+(\.\d+)?/); // ["12.5"] // 用 replace 实现“只替换但不删除上下文” "100px 200px".replace(/(?<=\d)px/g, "rem"); // "100rem 200rem"后顾断言真正解决的是replace的老大难问题:以前想替换“数字后面的单位”但保留数字,只能写成/(\d)px/g加回调函数拼回去,现在一条后顾搞定,代码干净很多。
5.4 单词边界\b在中文场景里的真实行为
\b判断的是“当前位置一边是\w字符、一边不是”,\w就是[A-Za-z0-9_]。这意味着汉字不算单词字符,英文单词夹在中文里也会被认为是边界。
/\bcat\b/.test("a cat"); // true /\bcat\b/.test("concatenate"); // false /\bcat\b/.test("猫cat狗"); // true,汉字不参与单词字符这个行为在做中英混排的关键词替换时特别容易误伤。"猫cat狗".replace(/\bcat\b/g, "CAT")能得到"猫CAT狗",看起来正常,但如果用户输入"cat_1",\b就不认为有边界,因为下划线属于\w。想按“汉字或英文边界”来判断,得自己写断言,例如(?<=^|[^\w\u4e00-\u9fa5])这类组合。
6. 修饰符与状态:g、y和lastIndex的三角关系
修饰符是全局配置,一个字符就能翻天覆地地改变行为。这一层最容易被误解,因为它牵扯到 RegExp 对象上的隐式状态。
6.1 全局模式下正则对象是“有状态”的
带g的正则对象会持有lastIndex属性,每次exec或test成功之后,lastIndex会更新为本次匹配结束的位置。下一次调用从lastIndex继续。这个设计是为了让你能循环取全部结果,但它带来了一个著名 bug:同一个正则对象反复test同一个字符串,结果会在true和false之间跳。
const re = /a/g; re.test("aa"); // true,lastIndex 变成 1 re.test("aa"); // true,lastIndex 变成 2 re.test("aa"); // false,lastIndex 重置为 0 // 更常见的坑:把带 g 的正则写进函数或组件 function isValid(s) { return /^\d+$/.test(s); // 没有 g,安全 } const re2 = /^\d+$/g; re2.test("123"); // true re2.test("123"); // false,因为 lastIndex 还停在 3处理办法有三个:静态规则不带g;需要写多个结果时用matchAll(它内部会复制一份正则,不碰原始lastIndex);手动循环时老老实实在每次exec后检查返回值是不是null,并在null出现后把lastIndex归零。
注意:连零宽匹配也会更新
lastIndex,但因为它不推进字符,lastIndex可能停在原地,导致死循环。写while ((m = re.exec(s)) !== null)的时候,如果规则能匹配空字符串,一定要在循环体里手动re.lastIndex++,否则浏览器直接挂住。
6.2y不是“更严格的 g”,而是“从指定位置开始的匹配”
y(sticky)的行为是:只从lastIndex指定的位置开始尝试,且必须在该位置匹配成功,不允许引擎往后滑动寻找。很多人以为它和g差不多,实际上用途完全不同。
g是“在字符串里搜索所有匹配”,y是“从当前位置解析”,典型的应用场景是词法分析器、DSL 解析器——你希望按顺序一段一段地吃字符,中间不允许跳过任何内容。
const token = /\s*(\w+)/y; const s = "let x = 1"; token.lastIndex = 0; token.exec(s); // ["let", "let"] token.exec(s); // null,因为位置 3 后面是空格,规则里的 \s* 能吃掉空格但后面必须有 \w+上面的例子说明y的一个细节:它允许从lastIndex开始匹配,但不允许起点之前的内容被跳过。写解析器时必须精确控制lastIndex,每吃掉一个 token 就更新一次,一旦某处不匹配就立刻停下报错,不会产生“静默跳过错误内容”的情况。这正是解析场景想要的严格性。
6.3m、s、u、d、i的组合与优先级
i是忽略大小写,m影响多行锚点,s让.匹配换行,u开启 Unicode 模式,d让匹配结果额外带上indices(每个捕获组的起止位置),这几个可以随意组合。
d修饰符是较新加入的,返回的 match 对象会多一个indices数组,indices[0]是完整匹配的[start, end],indices[1]是第一个捕获组的范围。做语法高亮、代码编辑器里的定位标注时,这个东西能省掉不少手动计算的代码。
const m = "abc123".match(/(?<num>\d+)/d); m.indices[0]; // [3, 6] m.indices.groups.num; // [3, 6]i和u组合时要注意一点:开启u之后,大小写折叠会按 Unicode 规则处理,某些特殊字符(比如开尔文符号K)的行为和不加u时不同。做国际化的日期、单位解析时,这个差异会实际影响结果,建议测试环境里把这两种组合都跑一遍。
7. 字符串方法与正则的配合,返回值差异必须记清
很多“正则不生效”的疑问,问题其实出在调用的String方法上,而不是规则本身。这几个方法的返回结构差别很大,我按使用频率排一下。
7.1match在有没有g时完全是两套行为
不带g:返回和exec一样的结果对象,带index、input、groups。
带g:返回所有匹配到的完整字符串组成的数组,捕获组信息全部丢失,这是最容易踩的坑。想同时拿到所有匹配和每个匹配的捕获组,只有matchAll能做到。
const s = "a1 b2 c3"; s.match(/\d/g); // ["1", "2", "3"],只有内容 s.match(/(\w)(\d)/); // 只返回第一个匹配的详细信息 [...s.matchAll(/(\w)(\d)/g)]; // 返回三个结果对象,每个都有捕获组matchAll要求必须带g,否则直接抛 TypeError。它返回的是迭代器,不是数组,想要数组得用扩展运算符或者Array.from。
7.2replace的替换串占位符和回调参数
替换串里能用的占位符有:$$(字面$)、$&(整个匹配)、$`(匹配之前的内容)、$'(匹配之后的内容)、$1到$99(捕获组)、$<name>(命名捕获组)。用得最多的是$1和$<name>。
用回调函数的时候,参数顺序是(match, p1, p2, ..., offset, string, groups)。注意offset和string一定在捕获组之后,如果你有四个捕获组,回调签名就是 7 个参数。命名捕获组存在时,最后还会多一个groups对象。
"2024-06-15".replace( /(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})/, (...args) => { const { y, m, d } = args.at(-1); return `${d}/${m}/${y}`; } ); // "15/06/2024"实操心得:回调里用
args.at(-1)取 groups 比数参数位置稳得多,尤其是规则后续要加捕获组的时候,不用改回调签名。
7.3split遇到捕获组会把分隔符也塞进结果
这是规范里明确规定的行为,但很多人不知道。split的正则里只要有捕获组,捕获到的内容就会被插入到结果数组中。
"a1b2c".split(/\d/); // ["a", "b", "c"] "a1b2c".split(/(\d)/); // ["a", "1", "b", "2", "c"] // 利用这个特性做带分隔符的解析 "k1=v1;k2=v2".split(/[;=]/); // ["k1", "v1", "k2", "v2"]这个特性在解析配置文件、解析 URL 参数这类场景里特别好用,能一次把键、值、分隔符全拆出来。代价是如果规则里不小心带了括号(比如(?:)忘记写),结果会多出一堆空字符串或分隔符,排查起来很费时间。
8. 实战场景:几个真实规则的写法拆解
规则细节讲完,落到实际项目里看几个高频场景。这几个例子都是我反复写过、也见过别人写错的类型。
8.1 表单校验:不要追求一条规则搞定所有情况
手机号、邮箱、日期这类校验,新手容易陷入“写一条完美规则”的执念。我的经验是:结构校验交给正则,语义校验交给代码。
以日期为例,/^\d{4}-\d{2}-\d{2}$/只能保证格式,2024-99-99也能过。真正的合法性得用Date对象反算:
function isValidDate(str) { if (!/^\d{4}-\d{2}-\d{2}$/.test(str)) return false; const [y, m, d] = str.split("-").map(Number); const dt = new Date(y, m - 1, d); return dt.getFullYear() === y && dt.getMonth() === m - 1 && dt.getDate() === d; }邮箱也是同理。网上流传的那条几百字符的“完整邮箱正则”,我实测下来误判率不低,而且可读性几乎为零。实际项目里用/^[^\s@]+@[^\s@]+\.[^\s@]+$/做基本结构检查,再发一封验证邮件,比死磕正则靠谱得多。正则不是万能的,把它放在该在的位置上。
8.2 文本清洗:replace加后顾断言能少写一半代码
做富文本输入时,经常要清洗掉用户粘贴进来的 HTML 标签但保留内容。最简版本是/<[^>]+>/g直接删掉,注意用否定字符类而不是.*?,避免嵌套尖括号时出问题。
再复杂一点,要保留<br>这种白名单标签,同时又想删掉带属性的标签,可以组合后顾和前瞻:
// 删掉所有标签,但 <br> 和 <br/> 除外 html.replace(/<(?!br\s*\/?>)[^>]+>/gi, "");这里(?!...)的作用就是在<之后立刻判断“后面是不是 br”,不是才继续匹配整个标签。用一条规则解决,比先替换成占位符再还原的写法要干净。
8.3 模板占位符解析:matchAll比exec循环更省心
解析${name}这种占位符,最直观的写法是全局exec循环,但要自己管lastIndex,还要处理零宽匹配。用matchAll一次拿到全部结果,代码量直接砍半:
const tpl = "你好,${name},今天是${date}"; const re = /\$\{(?<key>\w+)\}/g; const vars = [...tpl.matchAll(re)].map(m => m.groups.key); // ["name", "date"] const rendered = tpl.replace(re, (_, key) => data[key] ?? "");注意matchAll返回的每个结果对象里的index仍然有效,做定位高亮的时候可以直接拿来做偏移,不用自己算。
9. 问题排查速查表与性能调优经验
正则出问题的时候,症状通常很模糊——“没匹配到”“多匹配了”“页面卡住了”。这一节把常见症状和对应原因整理成一张表,再加上几条我实际踩出来的经验。
9.1 常见问题速查表
| 症状 | 最可能的原因 | 处理方式 |
|---|---|---|
| 全局匹配结果数量不对 | 正则对象带g且被复用,lastIndex未重置 | 每次使用前re.lastIndex = 0,或改用matchAll |
| 交替分支总是命中第一个 | ` | ` 从左往右依次尝试,先成功即返回 |
| 匹配“吃掉”了不该吃的部分 | 贪婪量词配合宽泛的. | 换成否定字符类,如[^>]+、[^"]* |
| 点号匹配不到换行 | .默认排除换行符 | 加s修饰符,或写[\s\S] |
| 中文匹配失败 | 用了\w、\b等 ASCII 语义的简写 | 改用\p{Script=Han}并加u修饰符 |
捕获组取到undefined | 该组是可选组且本次未参与匹配 | 取值前先判空,或用?? ""兜底 |
替换后出现$1字面量 | 替换串里$未转义 | 写成$$1,或改用回调函数 |
| 长文本匹配卡死 | 嵌套量词导致灾难性回溯 | 用[^x]+确定性写法替代,或加长度上限 |
matchAll报错 | 正则没带g修饰符 | 补上g,或改用exec循环 |
| 大小写忽略失效 | i用在字符类外但被u改变了折叠规则 | 测试特殊字符,必要时显式列举 |
这张表我建议随手存一份,遇到问题先对号入座,比漫无目的地改规则快得多。
9.2 定位回溯问题的三个动作
规则卡住的时候,我一般按这个顺序排查。
第一步,看有没有嵌套量词。(x+)+、(x*)*、(x+)*这类结构出现,基本就是嫌疑对象。把重复结构拍平,或者把内层的+换成确定性的字符类。
第二步,看有没有多条分支共享同一个前缀。/(a|b|c)+/这种在长字符串上也会产生大量回溯路径,能合并成[abc]+就一定合并。
第三步,用短字符串二分。拿一段一千字符的输入卡住,就先切到五百、两百五,看耗时怎么变化。如果是线性增长就还好,如果长度减半耗时降了四倍以上,基本可以确定是指数级回溯,必须改规则。
// 改法示例:把不确定的分组重复改成确定结构 // 改前 /^(\w+\s?)*$/; // 改后:每次重复必须消耗一个空格 + 单词,路径唯一 /^\w+(?:\s\w+)*$/;注意:不要用
Date.now()做微秒级性能测量,精度不够。用performance.now()测单次匹配耗时,重复运行取中位数,才比较可靠。
9.3 我在实际项目里攒下来的几条习惯
写完规则一定要用真实数据测,不能只用自己构造的“标准样例”。真实数据里的全角空格、不可见字符、超长无意义字符串,才是正则崩溃的主要来源。
规则超过一行就该加注释或者拆开写。我处理过一个项目,某条正则写了两百多字符,作者离职后没人敢动,最后整段替换成几个小规则串联,可读性和性能都变好了。
静态规则全部提成模块级常量,不要写在函数体里。除了避免重复编译,更重要的是能集中管理、集中测试,改一处全项目生效。
校验类规则必须配一个反例测试集。正向用例谁都会写,能过正向用例的规则不代表是对的,反例才能真正暴露贪婪、边界、修饰符的问题。
最后分享一个我反复强调的点:正则适合做结构识别,不适合做语义判断。日期是否合法、金额是否超限、编码是否有效,这些交给代码去做,正则只负责在字符串层面上把结构切出来。把职责分清楚,规则会短很多,维护成本也会低很多。这也是我从那次线上事故之后最大的收获——正则写得越“聪明”,出问题时就越难查;写得越“笨”、越确定,反而越稳。