ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JS正则表达式实战指南:从字符串匹配到避坑技巧

JS正则表达式实战指南:从字符串匹配到避坑技巧 写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时同时匹配JavaScriptg全局匹配找所有结果而不是第一个提替换所有匹配项、循环提取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 ab; 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{ScriptHan}后者在现代浏览器里已经可以放心用了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 classafirst/div div classbsecond/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); // falseincludes是大小写敏感的 /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和查询串中提取参数前端经常要处理这样一个需求把?page2size10keywordjs转成一个对象。正则配合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(?page2size10keywordjs); // 输出 {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/h2p正文/ph2标题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); // truelastIndex变为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写个小脚本反复验证。每次看到连续高亮过界的现象先怀疑贪婪和回溯基本八九不离十。在我个人的习惯里凡是长度超过一行的正则一定会拆成函数配上注释写明每个分段的含义并且在关键边界条件上补测试用例。这次写下来发现真正能稳定产出价值的正则往往不是那些一眼看上去炫技的模式而是那些读起来像平实说明文、每段都有明确职责、放在工具模块里沉淀了许久的模式。在需求进入后期维护阶段时这种正则带给团队的安心感远远超过一行代码解决所有问题的瞬间快感。
返回列表