☰
JS正则表达式实战指南:从字符串匹配到避坑技巧
2026/10/8 10:59:24 网站建设 项目流程

写JS这么多年,正则表达式一直是我工具箱里最好用也最容易翻车的一把工具。刚带项目的时候,很多同事看到一串/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/就头大,宁可写十行indexOf也不肯碰正则。但真在实战里跑过几个需求之后你会发现,正则远没有想象中那么神秘,它不过是一种"用模式描述字符串"的声明式语言。

这篇文章把我这些年从字符串包含判断、URL校验、查询参数提取、数字格式化到各种踩坑现场的经验都整理一遍,核心语法逐层拆开讲,每个小节配可直接复制的代码。不管是刚接触正则的新手,还是想系统补一遍语法细节的进阶选手,都能从这里拿走能直接用的东西。

1. 先搞清楚:正则表达式在JS里到底是个什么角色

1.1 正则表达式是"字符串匹配的声明式语言"

很多人一开始上手就被一堆符号劝退,其实可以这么理解:正则表达式就是一套描述"我想在字符串里找什么形状的内容"的规则。它不关心内容的具体含义,只关心内容的长相。

举个例子,你要在一段日志里找所有IP地址,用字符串方法一个个判断会写到怀疑人生,而用正则/d{1,3}\.d{1,3}\.d{1,3}\.d{1,3}/g一次就能扫描出所有候选IP。这就是声明式写法的威力——你只需要告诉引擎"我要匹配四个用点分隔的1到3位数字",剩下的事情由引擎处理。

那为什么在JS这块特别值得掌握?因为前端每天处理的几乎都是字符串:表单校验、接口参数拼装、URL解析、模板渲染、日志分析、用户输入清洗。这些场景里正则往往是最高效、最不易出错的手段。反爬、模拟数据生成、富文本处理这些进阶需求,背后也离不开正则的功底。

实战中还有一个容易被忽略的点:正则表达式的执行性能非常稳定,一次匹配的时间基本只跟字符串长度和模式复杂度相关。相比循环遍历加一堆字符串方法组合,正则往往可以用更少的代码和更少的时间复杂度完成同样的事。

1.2 字面量与构造函数:两种创建方式的选型逻辑

JS提供的正则创建方式有两种,看起来只是写法不同,实际选型有讲究。

第一种是字面量写法:

const emailReg = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; const emailRegGI = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/gi;

第二种是构造函数写法:

const emailReg = new RegExp("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"); const emailRegGI = new RegExp("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", "gi");

两者的核心区别在于:字面量在脚本加载时就会被编译,而且写法简洁,反斜杠不用双重转义;构造函数则是在运行时才编译,真正的价值在于支持动态拼接模式。

什么场景必须用构造函数?比如用户在前端输入一个关键词,你希望把它当普通文本去搜索,那么关键词里可能出现的.、*、?这些正则元字符就需要先做转义处理。这种"根据变量动态生成模式"的需求只有构造函数能接得住。

function escapeRegExp(input) { return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } const keyword = escapeRegExp(userInput); const reg = new RegExp(keyword, "gi");

我的建议是:凡是能从代码里静态看出来的固定模式,一律用字面量写法,可读性好、编译早、不容易出错;凡是模式片段来自变量或用户输入的,才用构造函数。这不仅是风格问题,更关系到后期的可维护性。

1.3 五个标志位:i、g、m、s、u怎么选

正则标志位决定了匹配的"工作模式",JS里常用的一共五个,很多新手经常只记得i和g,但其他几个在实际业务里同样高频出现。先看一张速查表:

标志作用典型场景
i忽略大小写搜索"javascript"时同时匹配"JavaScript"
g全局匹配,找所有结果而不是第一个提替换所有匹配项、循环提取
m多行模式,^$匹配每行开头结尾分析多行日志、校验多行表单文本
s让.匹配换行符跨行匹配HTML/JSON片段
u按Unicode规则匹配处理emoji、中文、特殊字符

举个m的例子。不加m时,/^ERROR/只会匹配整个字符串开头的ERROR;加了m后,每一行的ERROR都会被命中:

const log = "INFO start\nERROR db timeout\nINFO retry\nERROR again"; log.match(/^ERROR/gm); // 输出 ["ERROR", "ERROR"]

u标志很多人会忽略,但处理中文和emoji时很重要。不加u时,/\u{1F600}/会被当成普通字符u加十六进制量词,加了u才能正确匹配表情符号:

const smile = "a😀b"; smile.match(/\u{1F600}/u); // 正确匹配 😀

标志位的选择直接影响正则的行为,我在代码评审时看到过好几次g标志引发的"反复匹配跳位"问题,这个坑后面单独讲,你在用.match()做一次提取时记得确认有没有加g,用.test()做判断时反而尽量别加g。

2. 核心语法逐层拆解:从字符到断言

2.1 字符匹配与字符类:先认清"被匹配对象"

正则里最基础的是普通字符匹配。/abc/就匹配连续的abc,这没有任何歧义。真正需要留意的是元字符:.、^、$、*、+、?、(、)、[、]、{、}、|、\。它们像是编程语言里的保留字,你想匹配字面意义就必须用反斜杠转义。

但实战里大家更喜欢用字符类(character class)来划定"允许出现的字符范围"。方括号里的内容是"或"的关系:

  • [abc]匹配 a、b、c 任意一个字符
  • [a-z]匹配任意小写字母
  • [0-9]匹配任意数字
  • [^abc]匹配除了 a、b、c 之外的任意字符

初学的时候容易把[^abc]理解成"不匹配abc这个单词",其实是"不匹配a、b、c这三个字符中的任何一个",注意这个差别。

更常用的是简写类,记住这六个基本就够用了:

简写等价含义等价展开
\d数字[0-9]
\D非数字[^0-9]
\w单词字符[A-Za-z0-9_]
\W非单词字符[^A-Za-z0-9_]
\s空白符[\t\n\f\r ]
\S非空白符[^\t\n\f\r ]

有个比较容易翻车的地方是\w在这个时代有点"不够用"。它只包含英文字母、数字和下划线,匹配中文、日文、韩文时完全不认账。你要是想匹配"中文字符",建议用[\u4e00-\u9fa5]或者搭配u标志的 Unicode 属性简写\p{Script=Han},后者在现代浏览器里已经可以放心用了:

const chineseName = "张三abc李四"; chineseName.match(/[\u4e00-\u9fa5]+/g); // ["张三", "李四"]

2.2 量词:控制匹配次数,也要小心贪婪

如果字符类解决的是"选谁"的问题,那量词解决的就是"选多少次"的问题。四组基础量词记牢:

  • *:0次或多次
  • +:1次或多次
  • ?:0次或1次
  • {n,m}:n到m次,精确写法

比如校验手机号,用\d{11}就能约束恰好11位数字。这里有个初学者非常容易踩的点:\d{11}匹配的是字符串里任意位置连续出现的11位数字,如果用户输入了13位,你拿/^\d{11}$/去测就不通过,但如果你直接str.match(/\d{11}/),它依然能给你返回前11位。所以位置锚点^和$才是精确匹配的关键,这一点在写表单校验时尤其重要。

量词的隐藏属性是"贪婪"。默认情况下量词会尽量匹配最多的字符,这一点在处理HTML、JSON这类有对称结构的文本时经常出现问题。看个经典场景:

<div class="a">first</div> <div class="b">second</div>

用/<\/?div.*>/去匹配整段内容,贪婪模式会从第一个<div一直吞到最后一个>,把两个div全吞进去。解决办法就是在量词后面加一个?变成非贪婪模式/<\/?div.*?>/,让它匹配到最近的>就收手。

反直觉的地方在于:非贪婪并不总是更快,它只是"见好就收"。但如果每个位置都要停下来试探,性能上反而更差。实战中我一般优先用精确的字符类替代过度的量词组合,比如匹配HTML标签就用/<\/?[a-z][^>]*>/i而不是/<.*?>/,逻辑更清楚,性能也更稳。

2.3 分组与捕获:从匹配结果里提取真正要的数据

正则不只是用来做"是或否"的判断,更多时候我们要从一段文本里提取关键信息,这时候圆括号——分组和捕获——就登场了。

最基本的用法是分组。/cat|dog/看到管道符就知道是"或",但如果你想表达"cats或dogs"这种整体逻辑,就需要括号:/cats|dogs/其实等价于/(?:cat|dog)s/。括号把一部分模式"捆"成一个整体,量词和|都能作用于这个整体。

更重要的是捕获。括号里的内容匹配到的子串会被单独记录下来,配合.exec()就能拿到:

const dateStr = "日期:2024-12-25"; const reg = /(\d{4})-(\d{2})-(\d{2})/; const result = reg.exec(dateStr); // result[0] => "2024-12-25" // result[1] => "2024" // result[2] => "12" // result[3] => "25"

当你不关心某个括号里的内容、只是需要用括号做逻辑分组时,用非捕获括号(?:...)更合适,比如上面的/(?:cat|dog)s/。非捕获括号不会产生额外数组元素,在循环匹配场景里能省不少内存和心智负担。

ES2018还引入了命名捕获组,在复杂正则里非常提神:

const reg = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/; const result = reg.exec("2024-12-25"); // result.groups.year => "2024" // result.groups.month => "12"

命名捕获组最大的价值是后期维护。一个正则十几个括号,如果全部靠数字索引,加一个括号改一行代码,后面全得跟着改;有了名字之后,顺序调整不再影响取值逻辑。我在解析URL、日志、复杂文本格式时基本都用命名捕获组。

2.4 断言:不消费字符的前瞻与后顾

断言是正则里最"不直观"也最强大的部分。它的特点是:只判断某个位置是否符合条件,不消费字符。你可以把它想象成一个"站在当前位置往左右看的哨兵"。

四类断言:

  • 正向前瞻(?=...):右边必须匹配
  • 负向前瞻(?!...):右边必须不匹配
  • 正向后顾(?<=...):左边必须匹配
  • 负向后顾(?<!...):左边必须不匹配

比如从字符串单价¥299,优惠价USD 59里提取美元价格,你希望只抓USD后面的数字:

const text = "单价¥299,优惠价USD 59"; text.match(/(?<=USD\s)\d+/); // ["59"]

注意USD\s这部分不会出现在匹配结果里,因为后顾断言是不消费字符的。同理,如果要从px前面的数字里抓取长度数值:

const style = "width: 100px; height: 200px;"; style.match(/\d+(?=px)/g); // ["100", "200"]

断言在反爬规则匹配、模板解析、语法高亮这类"需要上下文但不想要上下文内容"的场景里是真正的救星。用普通分组提取再手动裁剪也能做,但一行断言正则能省掉后面一堆substring逻辑。

有一点要特别注意:旧浏览器对后顾断言支持很差,生产环境用之前一定要确认目标浏览器的兼容性。前端项目一般问题不大,但如果你在写Node脚本或爬虫服务,Node版本低于8.3就不支持后顾断言,升级版本或者换用捕获组方案兜底都是可行路线。

3. 实战案例拆解:字符串判断、URL校验与参数提取

3.1 判断字符串是否包含目标子串:includes、indexOf还是正则

这是JS里被问得最多的问题之一。三种方案各有适用场景,把它们对比清楚比背代码更有用。先说结论:

  • 只是判断是否包含某个固定字符串、且不关心大小写的场景下,includes和indexOf更合适。
  • 需要忽略大小写、需要做模糊匹配、需要匹配一类模式而不是具体字面量时,上正则。

举个例子,判断一个文本里有没有"error"这个单词,而且大小写不敏感:

const log = "FATAL: DB connection lost, ERROR code 500"; log.includes("error"); // false,includes是大小写敏感的 /error/i.test(log); // true,这就是热词里js忽略大小写的核心用法

在i标志生效时,正则引擎会同时匹配小写error、大写ERROR、混合的Error。但includes做不到这一点,非要用它就得先把字符串全部转成小写再判断,遇到长文本或频繁调用时内存开销不如正则友好。

还有一类场景是"含有但不完整",比如判断某段HTML里是否包含onclick属性且后面跟了一段脚本代码,这类模糊模式用includes根本没法描述,正则才是唯一解。反过来,如果只是判断用户输入的昵称是否为"admin"这种完全固定的字面量,用includes性能会更好,正则反而多了一份"模式编译"的开销。

3.2 验证URL有效性:正则方案与API方案的结合

热词里"js验证url有效性"是个经常被搜的话题。先说结论:不要试图用一串特别长的正则通吃所有合法URL,那样写出来的正则难维护、容易误判,一次意外的换行就能让它失效。

我在生产中一般分两层处理。第一层用new URL()做基础解析,能解析出来就说明格式上是合法的URL;第二层再用正则根据业务限定的协议和域名做约束,比如只允许HTTP/HTTPS、必须指向特定域名。写出来的代码反而简洁:

function isValidUrl(input) { try { const url = new URL(input); return /^https?:$/.test(url.protocol); } catch { return false; } }

如果一定要给一个正则参考,可以看下面这个"够用但不过度严格"的版本:

const urlReg = /^(https?:\/\/)?([\w-]+\.)+[a-z]{2,}(\/\S*)?$/i;

拆开讲几句:(https?:\/\/)?允许协议可省;([\w-]+\.)+匹配域名部分;[a-z]{2,}匹配顶级域至少两个字母;(\/\S*)?匹配可选的路径,\S*意味着路径里不允许出现空白。注意i标志让域名和顶级域的大小写都能通过。

这个正则不校验端口、不校验query格式,也不管端口值是否超出范围,那些交给URLAPI更合适。正则负责"大概形状",API负责"真实可解析",两层配合才能覆盖绝大多数业务需求。

3.3 从URL和查询串中提取参数

前端经常要处理这样一个需求:把?page=2&size=10&keyword=js转成一个对象。正则配合exec循环是经典解法,先看代码:

function parseQuery(search) { const result = {}; const reg = /[?&]([^=]+)=([^&]+)/g; let match; while ((match = reg.exec(search)) !== null) { result[decodeURIComponent(match[1])] = decodeURIComponent(match[2]); } return result; } parseQuery("?page=2&size=10&keyword=js"); // 输出 {page: "2", size: "10", keyword: "js"}

这段代码值得仔细拆解。[?&]匹配查询串的起始位置,第一组([^=]+)捕获参数名,=是分隔符,第二组([^&]+)捕获参数值。g标志让正则从头到尾扫描,每次exec返回一个匹配对象和两个捕获组,循环结束条件是exec返回null。

这里有个细节:decodeURIComponent不能省。URL里中文和特殊字符会被百分号编码,比如keyword=%E6%AD%A3%E5%88%99,不解码拿去用就是一堆乱码。

如果你面对的是完整URL而不是纯query,可以先new URL(url)再取url.search或者直接用url.searchParams。原生URLSearchParams已经是现代浏览器的标配,正则方案主要用在:需要兼容非常老的浏览器、需要自行解析非标准URL片段、或者在Node脚本里处理日志中的半截URL时。知道正则会写,再决定什么时候用它,工具箱才算真正充实。

3.4 数字格式化:金额千分位是怎么用一行正则做出来的

展示金额时,用户希望看到1,234,567.89而不是1234567.89。toLocaleString是个简单方案,但正则写法在Node服务端生成报告、模板渲染、或需要控制小数精度时更可控。

下面这个表达式是千分位格式化里的经典写法:

function addThousandsSep(num) { return String(num).replace(/\B(?=(\d{3})+(?!\d))/g, ","); } addThousandsSep(1234567.89); // "1,234,567.89"

这段代码初看会懵,拆成三块解释。\B代表"非单词边界",作用是排除字符串起始位置,避免,123这种把逗号加在开头的情况。(?=(\d{3})+)是正向前瞻,要求当前位置后面有一组或多组恰好为3位的数字。(?!\d)是负向前瞻,确保每组3位数字结束之后不是数字——举个例子,数字1234567,在1和2之间,后面的234是一组3位、567也是一组3位,满足(\d{3})+;但\B本身在\d之间的位置才成立,如果有小数部分,\B会拦掉小数点之后的分组,这也是(?!\d)的另一层保险。

这个正则第一次看确实不那么好消化,但它是典型的"写一次到处用"的工具,建议直接存到项目公共工具函数里,加上单测,后续所有报表、订单、图表金额都不用手动拼接。

4. 高频问题排查与避坑实录

4.1 贪婪匹配把整段HTML吞掉的现场

我最早踩坑是在写一个简单的网页脚本,要从一个HTML片段里把<h2>标题</h2>全部提取出来。当时用的正则长得差不多是这样:

const html = "<h2>标题A</h2><p>正文</p><h2>标题B</h2>"; html.match(/<h2>.*<\/h2>/g);

以为是两段标题,结果只匹配到一段——从第一个<h2>到最后一个<\/h2>的整条完整内容。这就是前面说过的贪婪问题,*会吞掉尽可能多的字符,直到最后一个<\/h2>才收手。

正确的做法有两个方向:

// 方案一:非贪婪 html.match(/<h2>.*?<\/h2>/g); // 方案二:精确排除 html.match(/<h2>[^<]*<\/h2>/g);

方案二用[^<]*更严谨,它明确要求"中间不能出现<",这样即使网页里同一行有多个标签也不会误吞。实战里我的原则是:能写出"负向约束"的,就不要光靠非贪婪量词,因为非贪婪在面对嵌套结构时依然可能停在错误的位置。

4.2 全局匹配的lastIndex游标陷阱

这个坑非常隐蔽,而且一旦触发会让代码看起来"完全不可理喻"。带g标志的RegExp对象是有状态的——它会用一个内部游标lastIndex记录上次匹配结束位置。连续调用test或exec时,下一次匹配从上次的结束位置开始,而不是从头开始。

看个例子:

const reg = /foo/g; const str = "foo foo foo"; reg.test(str); // true,lastIndex变为3 reg.test(str); // true,从索引3继续 reg.test(str); // true,从索引7继续 reg.test(str); // false,游标到末尾了

同一个字符串,同一个正则,调了四次test,结果竟然是true true true false。如果这段代码写在一个循环里,第三次之后所有判断都会异常。

解决方法是注意使用场景:如果你只需要做一次布尔判断,不要给正则加g;如果你需要配合循环exec,记得在每次开始前手动重置reg.lastIndex = 0。封装函数时尤其要小心,外层传入的g标志正则可能在多次调用之间共享了状态:

function containsFoo(str) { const reg = /foo/g; reg.lastIndex = 0; return reg.test(str); }

4.3 对用户输入做正则搜索前,必须先做转义

需求场景经常是这样:用户输入一个关键词,你要在文章列表里高亮所有出现的位置。如果用正则直接拼用户输入:

const keyword = "(C++)"; const reg = new RegExp(keyword, "g"); text.replace(reg, "<mark>$&</mark>");

问题就来了:(和)是正则的元字符,引擎会把(C++)当成分组语法而不是字面文本,轻则匹配结果不对,重则直接抛语法错误。更夸张的输入比如.*或$会让正则行为完全失控。

解决办法是写一个通用的转义函数,在所有动态构建正则前调用一次:

function escapeRegExp(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } const reg = new RegExp(escapeRegExp(keyword), "g");

这看起来是一行再简单不过的代码,但我觉得它是所有正则实战里最值得先封装的基础工具。凡是用户输入、接口返回、配置文件里读取的字符串,都不可信,都必须转义后才能拼进正则。

4.4 嵌套量词引发的灾难性回溯和性能优化

最后一个坑是性能问题,场景虽然不常见,一旦触发就会让页面卡死。看这个模式:

/^(a+)+$/.test("aaaaaaaaaaaaaaaaaaaaaaaaaaaaab");

字符串由一大串a结尾是一个b,正则引擎会尝试把这一大串a用各种方式分配给外层的+和内层的+组合,这个分配组合是海量的。如果字符串长度达到30甚至更多,引擎的回溯次数会爆炸性增长,表现为明显卡顿。这就是灾难性回溯(catastrophic backtracking)。

在写正则时要学会识别风险模式:嵌套量词((a+)+、(a*)*)、交替分支配合量词((a|a)*)、以及结尾带有可选部分的模式都容易踩雷。

优化思路有几个方向:最直接的是改写模式,用更具体的字符类代替宽泛量词;其次是合理使用锚点,让引擎早早排除明显不匹配的字符串;其三,如果确实要匹配"只含某种字符的字符串",直接写成独立字符类加单个量词,几乎可以避免所有回溯问题:

/^[a-z]+$/.test(longString); // 安全可靠

另外,给正则加一些约束条件也能显著减少回溯。比如限定输入长度、提前用str.length做粗筛,让正则只在"值得匹配"的字符串上运行,这比琢磨更复杂的引擎算法要划算得多。

正则的调试我推荐在支持实时高亮的在线工具里做,本地也可以用Node写个小脚本反复验证。每次看到连续高亮过界的现象,先怀疑贪婪和回溯,基本八九不离十。

在我个人的习惯里,凡是长度超过一行的正则,一定会拆成函数配上注释,写明每个分段的含义,并且在关键边界条件上补测试用例。这次写下来发现,真正能稳定产出价值的正则,往往不是那些一眼看上去"炫技"的模式,而是那些读起来像平实说明文、每段都有明确职责、放在工具模块里沉淀了许久的模式。在需求进入后期维护阶段时,这种正则带给团队的安心感,远远超过"一行代码解决所有问题"的瞬间快感。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询