
1. 为什么正则表达式总是“一学就会一写就废”我接触 JavaScript 这么多年几乎每个项目里都能碰到正则表达式。说句实话这玩意儿是真难记但也是真绕不开。表单验证要它、URL参数提取要它、日志分析要它、字符串清洗还要它。你去看招聘 JD但凡写着“熟悉正则表达式”基本就意味着你进了公司要处理各种乱七八糟的字符串需求。但大多数人学正则的体验是这样的看教程的时候觉得也就那么回事\d匹配数字、\w匹配字母下划线结果一到自己写就卡住了。尤其是写邮箱验证网上复制一段正则跑起来发现有的能过有的不能过又不知道问题出在哪。再比如提取 URL 里的参数很多人第一反应是用split(?)再接split()但遇到 URL 编码、重复参数、特殊字符就直接翻车。这篇文章我不会给你念文档也不会罗列一堆背不完的元字符表。我只讲两个真实业务里最高频的场景——邮箱格式验证和URL参数提取把正则表达式的核心逻辑拆开揉碎再给出一套可以直接抄进项目的代码。适合刚入门 JavaScript 想搞懂正则的初学者也适合写了好几年业务但正则始终靠百度的朋友。先说一个心态问题。正则表达式不是背出来的是“拆”出来的。你看到一个复杂正则第一反应不应该是“这谁写的看不懂”而是“它由哪几个组成部分拼接而成”。当你学会把复杂问题拆成小模块正则基本上就通了一半。2. 正则基础知识不背语法学会查表2.1 字符匹配的底层逻辑正则说白了就干一件事按照某种规则在字符串里找符合规则的内容。关键在于“规则”怎么描述。我们人眼看到一串字符比如abc123能立刻告诉你有三个字母和三个数字。正则要表达这个意思就需要有一套描述语言。这套语言里最小的单位叫“元字符”你可以把每个元字符理解成一个“占位符”它描述的是“这个位置应该是什么类型的字符”。举个例子\d表示“此处需要一个数字”[a-z]表示“此处需要一个小写字母”.表示“此处可以是任意字符”。当这些元字符组合起来就形成了规则。比如\d{3}-\d{4}的意思是“三位数字、一个连字符、四位数字”这正好匹配电话号码的中间段格式。很多人记不住元字符是因为硬背。我的方法是把它们分类理解字符类\d数字、\w字母数字下划线、\s空白符、.任意字符。这类元字符是“单个字符”的类型占位符。量词*0次或多次、1次或多次、?0次或1次、{n,m}n到m次。它们修饰前面那个字符或组出现的次数。位置锚点^字符串开头、$字符串结尾、\b单词边界。它们不匹配任何字符只匹配位置。分组与逻辑(abc)分组、|或关系、[]字符集合。这样一分正则语法就有了骨架。你不需要背只需要知道“描述一个位置用什么、描述次数用什么、描述范围用什么”需要的时候查表即可。2.2 贪婪匹配与懒惰匹配最容易踩坑的隐蔽逻辑这一小节必须单独讲因为我在实际项目里见过太多人栽在这里。默认情况下量词是“贪婪”的也就是它会尽可能多地匹配字符。举个例子字符串是div内容1/divdiv内容2/div表达式div.*/div会匹配到什么新手以为是第一个div内容1/div其实是整个字符串因为.*贪婪地把中间所有字符都吞了直到最后一个/div才停下。如果只想匹配到第一个/div就停需要把量词变成“懒惰”的写法是在量词后面加一个?即div.*?/div。这个?在这里是“懒惰标记”不是“0次或1次”的意思。同一个符号位置不同含义不同这正是正则容易混乱的原因。判断一个表达式是贪婪还是懒惰就记住一句话贪婪是往右吃到底懒惰是吃到第一个能停的地方就停。实际工作中处理 HTML 片段、JSON 片段绝大多数情况应该用懒惰匹配否则你会拿到一堆不想要的内容。2.3 分组捕获与反向引用不只是为了“把结果包起来”括号在正则里有两个作用一是分组把多个字符当成一个整体二是捕获把匹配到的内容单独存下来供后续使用。比如提取 URL 参数时我们需要把keyvalue中的 key 和 value 分别取出来就要用括号([^])([^])。这两个括号里的捕获组会在匹配成功后通过match结果的数组索引拿到具体值。还有一种用法叫反向引用指的是在正则内部引用前面已经捕获到的内容用\1、\2表示第一组、第二组。比如判断一个字符串里是否有连续重复的字母可以用([a-z])\1。这个技巧用在数据清洗、敏感词替换场景里非常实用值得花点时间理解。3. 邮箱验证实战从“能用”到“够用”3.1 先从业务需求出发搞懂验证边界我先问你一个问题用户注册时邮箱验证到底要解决什么问题是想保证这个邮箱真实存在做不到。正则能做的仅仅是“格式看起来合法”比如abcexample.com这种结构。真实性的验证需要发激活邮件做回环测试正则管不了。是想完全匹配 RFC 5322 规范理论上可以写一个巨长的、能匹配所有合法邮箱的正则但那样做毫无意义。因为真正的用户不会给你提交那种极端格式的邮箱反而可能因为正则太严格把正常邮箱拦在外面。所以业务场景下的邮箱验证核心目标就三条有符号且前后都有非空内容后面有域名部分域名有点号分隔不包含明显的非法字符空格、中文、连续点号等明白边界之后再写正则思路就清晰了验证的是“看起来差不多像个邮箱”不是“全宇宙最严谨的邮箱格式”。3.2 一步步拆解一个可用的邮箱正则我不用那些网上流传的、上百个字符的“严格模式邮箱正则”那种正则既难维护又难理解。我自己在项目里常用的是下面这个const emailRegex /^[a-zA-Z0-9._%-][a-zA-Z0-9.-]\.[a-zA-Z]{2,}$/;别急着直接用先看我拆解^匹配字符串开头表示后面必须从第一个字符开始[a-zA-Z0-9._%-]邮箱的 local 部分 前面允许字母、数字、点、下划线、百分号、加号、连字符出现一次或多次。表示至少一个字符字面量邮箱必须有 [a-zA-Z0-9.-]域名主体部分允许字母、数字、点、连字符\.注意这里有个反斜杠点号在正则里是“任意字符”的意思要匹配真正的点号必须转义[a-zA-Z]{2,}顶级域部分比如 com、cn、org 都是 2 个字母以上$字符串结尾保证整个字符串都参与匹配拆完你会发现这个正则翻译成人话就是“以非空的合法字符开头中间有个 后面是域名和顶级域最后以顶级域结束”。逻辑清晰每部分都能解释出了问题也能快速定位。3.3 为什么不能用.*.*这种简单写法有些偷懒的写法长这样/.*.*/意思是“任意字符 任意字符”。这能通过ab这样的输入因为它只要求有 就行不管 前后是不是合法格式。还有些人用/^\S\S\.\S$/意思是“非空非空 非空非空点非空”。比.*.*好一点但\S匹配任何非空白字符会放过ab..c、ab.这种残缺格式。问题就出在“太宽松”。过度宽松的正则在业务中的隐患是用户填了abcdef前端提示“格式正确”结果后端发邮件一直失败用户等半天收不到验证码最后流失。这种体验层面的损失远比多写几个字符的代价大。3.4 加入 JavaScript 方法封装与异常处理正则只是规则真正使用时要结合 JS 方法。我一般写成一个独立的校验函数方便在多个表单里复用function isValidEmail(email) { const emailRegex /^[a-zA-Z0-9._%-][a-zA-Z0-9.-]\.[a-zA-Z]{2,}$/; if (typeof email ! string) { return false; } if (email.length 254) { return false; } return emailRegex.test(email.trim()); }这里有三个细节值得说第一先判断传入类型是不是字符串。JS 是弱类型语言接口返回的数据字段有可能是null、undefined、数字直接调用.test()可能会隐式转换类型导致意外结果。第二加了长度判断。RFC 标准里邮箱总长度上限是 254 个字符。虽然正常人用不到那么长但防止有人故意塞一大段字符串进来后端接口会有传输压力。前端拦一道不用花钱但能省后面的麻烦。第三使用了.trim()去掉首尾空格。用户从输入框复制邮箱经常带前后空格如果不去掉正则必然匹配失败用户会以为系统坏了。这是我在真实项目中踩过坑之后的教训。关于test()方法有一个隐藏的坑如果正则表达式带了全局标志g反复调用test()会因为你上次匹配到的位置不同而得到不同结果。用test()验证邮箱时不要加g标志否则第二次校验同一个邮箱可能神奇地返回 false。4. URL参数提取从“能用”到“抗造”4.1 你写的第一个提取函数大概率有 bugURL 参数提取是前端里非常常见的需求。比如拿到一个分享链接要读取?id123namelucy里的参数比如做活动页埋点要统计utm_source、utm_medium这些渠道参数再比如处理支付回调要解析orderId、sign等参数。很多新手第一次写提取函数是这样的function getParam(url, key) { const arr url.split(?)[1].split(); for (let i 0; i arr.length; i) { const kv arr[i].split(); if (kv[0] key) { return kv[1]; } } }这个函数在“参数规规矩矩”的情况下能跑通但真实世界的 URL 没这么讲道理。下面这些情况它都处理不了URL 里没有问号直接arr[1]报错参数值里有号比如nameabsplit()会得到三段kv[1]只有a参数值经过 URL 编码比如名字是“张三”传到 URL 里就变成了%E5%BC%A0%E4%B8%89直接拿回来没法用同一个 key 出现多次比如?tagatagb传统写法读出来只有最后一个URL 里有 hash比如example.com/path?id1#/homehash 部分被吞进参数值里4.2 正则提取 URL 参数的完整方案先明确一点浏览器环境里原生有URLSearchParamsAPI 可以用const params new URLSearchParams(?id1namelucy); params.get(name); // lucy但如果你在 Node.js 环境里的旧版本、或者需要处理一些特殊场景或者你想彻底搞懂底层原理正则仍然是有价值的。而且面试的时候手写 URL 参数解析几乎是必考题只回答“用 URLSearchParams”会给面试官留下“知其然不知其所以然”的印象。用正则提取 URL 参数核心表达式是这样的/([^?])([^?]*)/g拆解一下([^?])第一个捕获组匹配参数名。[^?]表示“不是问号、不是与符号、不是等号的任意字符”表示至少一个。这样可以确保参数名不会把分隔符吃掉字面量等号([^?]*)第二个捕获组匹配参数值。用*而不是是因为有些参数可能没有值比如?flagid1flag的值是空字符串/g全局匹配把 URL 里所有参数都找出来有了正则再配合matchAll或exec循环就能拿到所有参数function parseUrlParams(url) { const params {}; const regex /([^?])([^?]*)/g; const matchAll url.matchAll(regex); for (const match of matchAll) { const key decodeURIComponent(match[1]); const value decodeURIComponent(match[2]); params[key] value; } return params; }注意matchAll要求表达式必须有g标志否则会抛异常。然后我用decodeURIComponent对 key 和 value 都做了一次解码这一步很关键。如果 URL 里的值是中文编码成%E5%BC%A0%E4%B8%89不解码拿到的就是乱码业务里根本没法用。4.3 如何处理重复参数与哈希串上面这个函数能解决 90% 的问题。但如果遇到?tagatagb这种重复参数直接赋值params[tag] value会把前面的覆盖掉。业务里重复参数很常见比如多选筛选条件?categorybookcategoryphone。我的做法是检测到 key 已存在时把值聚合成数组function parseUrlParams(url) { const params {}; const regex /([^?])([^?]*)/g; const matches url.matchAll(regex); for (const match of matches) { const key decodeURIComponent(match[1]); const value decodeURIComponent(match[2]); if (Object.prototype.hasOwnProperty.call(params, key)) { if (Array.isArray(params[key])) { params[key].push(value); } else { params[key] [params[key], value]; } } else { params[key] value; } } return params; }这段代码判断 key 是否已存在如果存在且已经不是数组就把原来的值和新值合成数组如果已是数组直接 push。逻辑不复杂但考虑得全面放到生产环境里不容易出幺蛾子。再说 hash 的问题。假设 URL 是https://example.com/page?id1#/home/abc正则里的[^?]不会匹配到#和/所以它能正常解析出id1#/home/abc会被忽略。但如果是?redirect/path?frompage这种嵌套结构参数值里的?会导致解析错乱。这种极端情况就不建议用正则硬解了要么约定好编码方式要么换更强大的 URL 解析库。4.4 从URL中提取单个参数的轻量函数有时候我们只需要取单个参数不需要完整解析所有参数。那可以写一个更轻量的版本function getUrlParam(url, key) { const escapedKey key.replace(/[.*?^${}()|[\]\\]/g, \\$); const regex new RegExp([?] escapedKey ([^#]*)); const match url.match(regex); return match ? decodeURIComponent(match[1]) : null; }这段代码里有三个值得学习的点第一new RegExp()动态构造正则。当 key 是变量时不能直接写/.../字面量必须用构造函数动态生成。第二escapedKey做了特殊字符转义。如果 key 本身包含.、*、?这类正则保留字符不对它转义的话正则就会被破坏。这里的写法是标准做法把 14 个特殊字符全部加上反斜杠。第三表达式开头用[?]而不是直接用?。这样保证匹配到的 key 一定是参数边界不会把别的参数值里的相似文本误匹配。比如?nameabcnicknamelucy如果要取name直接写name会把nickname里的name匹配到。加一个[?]就能避免这个问题因为nickname前面是但更关键的是正则继续向后匹配要去掉其他字符。还有一个细节([^#]*)这个捕获组把值的范围限定为“不是 不是 # 的任何字符”这样参数值不会吞掉 hash 部分。match方法返回的数组里match[1]就是第一个捕获组的内容也就是参数值。4.5 用URLSearchParams做兜底对比我前面强调正则是有用的但不代表要排斥原生 API。真实项目里我的建议是如果只处理当前浏览器窗口的 URL用new URLSearchParams(window.location.search)最省事如果处理的是任意一段 URL 字符串且需要兼容老环境用正则方案如果参数结构非常复杂嵌套对象、数组、特殊编码建议用成熟的库如qs或query-stringURLSearchParams和正则方案的主要区别在于它严格遵循 WHATWG 规范对号会解析成空格因为 URL 编码规范里表示空格而正则方案如果不手动处理会把原样返回。从 W3C 标准的角度URLSearchParams更准确但很多内部约定直接往 URL 里塞作为加号这种场景正则方案反而更直观。两者没有绝对优劣选合适你业务的就行。5. 常见问题与调试技巧正则写错了你是怎么发现的5.1 三个高频报错场景与定位方法第一类是“整个表达式匹配不到任何东西”。这种问题通常出在量词用错了位置比如[a-z]{2,}写成了[a-z]2,少了花括号表达式意思就变了。排查方法是把正则分段测试先测第一段能不能匹配再逐步加长。第二类是“匹配到的内容比预期多”。这是贪婪匹配搞的鬼解决方法是给量词加?。比如解析 HTML 里的属性href.*可能一直匹配到最后一个引号而href.*?只匹配到第一个引号。第三类是“正则里写了中文或特殊字符导致报错”。JavaScript 正则字面量里能直接写中文但如果你用new RegExp()动态拼接要注意字符串转义。比如要匹配反斜杠\正则字面量里写/\\/字符串里要写\\\\这里面的层级关系很容易搞混。排查正则有一个好习惯用可视化工具把表达式转成图形比如 RegExr、regex101它们会把分组、量词、字符类用不同颜色标出来一眼就能看出哪里结构不对。我每次写复杂正则都会先丢进工具里验证再上代码能省很多调试时间。5.2 全局匹配 g 标志引发的隐蔽 Bug前面提到过用test()配合带g的正则会有问题。我再详细解释一下因为这是真实项目里最隐蔽的坑。看这段代码const regex /^\d$/g; console.log(regex.test(123)); // true console.log(regex.test(123)); // false ?!同一个正则同一个字符串两次结果不一样。原因是带g的正则对象是有状态的它有一个lastIndex属性记录上一次匹配结束的位置。第一次test成功匹配后lastIndex变成了 3表示从索引 3 开始继续第二次test从索引 3 开始匹配但字符串长度为 3且结尾锚点$要求必须匹配到末尾所以直接失败。解决方案要么去掉g标志要么每次手动重置regex.lastIndex 0。用matchAll时也必须注意它迭代完一次后如果再调用需要重新创建正则对象否则也会因为lastIndex停留在末尾而返回空。这也是为什么我前面写的邮箱验证正则不带g。任何“单次完整匹配”的场景都不要加g。5.3 正则表达式性能优化回溯灾难怎么避免很多新手没意识到正则可能会有性能问题。当你写的表达式和文本发生大量匹配失败时正则引擎会不断尝试不同的匹配路径这叫做“回溯”。极端情况下回溯次数呈指数级增长CPU 被打满。最典型的性能杀手是(a)$这种“嵌套量词”模式。假如字符串是aaaaaaaaaab引擎会尝试各种拆分方式直到耗尽所有可能才发现匹配失败。这个过程中计算量大到能卡死页面这在哈希算法里被称为“ReDoS正则表达式拒绝服务攻击”。日常开发里的建议是能不用嵌套量词就不用比如(a)改写成a用非贪婪*?时要注意它不一定比贪婪更快只是匹配结果不同对不确定长度的输入用indexOf配合slice做前置过滤减少正则进入概率如果有大批量字符串需要校验比如一次验证几万条邮箱用正则.test()的效率远高于new RegExp()每次创建5.4 常用正则速查表写代码之前先来这里找下面这张表是我自己电脑上一直存着的几乎覆盖了 80% 的日常场景直接抄走即可场景正则表达式手机号中国大陆/^1[3-9]\d{9}$/身份证号18位/^\d{17}[\dXx]$/IP地址IPv4/^((25[0-5] | 2[0-4]\d | 1\d{2} | [1-9]?\d)\.){3}(25[0-5] | 2[0-4]\d | 1\d{2} | [1-9]?\d)$/实际使用要拆分组日期 YYYY-MM-DD/^\d{4}-(0[1-9] | 1[0-2])-(0[1-9] | [12]\d | 3[01])$/16进制颜色/^#?([0-9a-fA-F]{6} | [0-9a-fA-F]{3})$/整数/^-?\d$/浮点数/^-?\d\.?\d$/URL/^https?:\/\/[\w-](\.[\w-])([\w.,?^%:/~#-]*[\w?^%/~#-])?$/中文/^[\u4e00-\u9fa5]$/用户名字母开头4-16位/^[a-zA-Z][a-zA-Z0-9_]{3,15}$/注意手机号正则里的\d{9}是 9 位还是 8 位要确认1[3-9]\d{9}一共是 11 位数字。如果写成分组1[3-9]\d{9}中的\d{9}表示 9 位数字总计 11 位没问题。身份证号的正则只校验了格式和校验位符号不能保证身份证真实有效。5.5 正则替换与回调函数的配合使用除了验证和提取正则还有一个高频功能就是替换。String.prototype.replace()支持传入回调函数这为文本处理打开了很大的想象空间。比如有一串文本里所有的数字都要加 1const text 今年是2024年去年是2023年; const result text.replace(/\d/g, (match) { return Number(match) 1; }); // 今年是2025年去年是2024年回调函数的第一个参数match是匹配到的完整内容如果不带g则只替换第一个匹配项。回调里可以做任何逻辑比如查表映射、调用接口、拼接字符串比单纯的替换字符串强大太多。再比如从一段 HTML 里批量提取所有图片的 URLconst html img srchttps://example.com/a.jpgimg src/b.png; const urls []; const regex /img[^]src[\]([^\])[\]/g; let match; while ((match regex.exec(html)) ! null) { urls.push(match[1]); }这里用了exec循环而不是matchAll因为exec在循环里每执行一次会更新lastIndex配合while反复调用直到返回null这样能拿到每个匹配的捕获组。两种方式等效看个人习惯。6. 进阶思路正则在前端工程化里的更多用途6.1 用正则做数据脱敏与隐私保护很多页面需要在展示用户信息时隐藏敏感字段比如手机号中间四位用星号代替。正则做这种脱敏极其高效function maskPhone(phone) { return phone.replace(/^(\d{3})\d{4}(\d{4})$/, $1****$2); } maskPhone(13812345678); // 138****5678这里用到了替换字符串里的$1、$2分别指向第一个和第二个捕获组。逻辑是捕获前三位和后四位中间四位用星号替换。同理还可以脱敏邮箱function maskEmail(email) { return email.replace(/^(.)(.*)(.*)$/, $1***$3); } maskEmail(zhangsanexample.com); // z***example.com捕获组里的.只匹配一个字符(.*)匹配中间任意长度最后(.*)把 及域名部分保留。这种脱敏在管理后台、日志展示、异常信息上报时非常常用但要注意脱敏逻辑本身可能被绕过不能把它当作安全机制它只是“降低敏感信息泄露的影响”。6.2 用正则做路由匹配与权限校验前端的单页应用里路由匹配也可以依赖正则。Vue Router 的路径参数就支持正则模式React Router 的path-to-regexp底层也是正则思路。比如你想匹配/user/:id但限制 id 必须是数字可以使用通配符写法转换成正则后是^/user/(\d)$。这种场景下正则能把“路由约束”和“参数校验”一次做完省去后续在组件里再判断一次类型。权限校验也经常用正则比如判断当前跳转的路径是否在某个受保护前缀下。/^\/admin\//能匹配所有/admin/开头的路径。如果还需要排除某些公开页面可以配合(?!...)负向前瞻来做排除。这类技巧在封装路由守卫时比较有用。6.3 用正则处理日志上报与信息过滤前端错误监控里我们需要从堆栈信息中提取关键字段。堆栈文本通常是这种格式at Object.getUserInfo (http://example.com/js/app.12345.js:120:30)要提取出文件地址、行号、列号可以这样写const stackLine at Object.getUserInfo (http://example.com/js/app.12345.js:120:30); const regex /(https?:\/\/[^\s]):(\d):(\d)/; const match stackLine.match(regex); if (match) { const [, url, line, column] match; console.log(url, line, column); }(https?:\/\/[^\s])匹配 http 或 https 开头、不包含空白字符的地址:(\d)匹配冒号后的行号再一个:(\d)匹配列号。配合解构赋值三行代码拿到核心信息。这套方案在自研前端监控平台时特别实用。信息过滤场景更常见。前端聊天室、评论区要屏蔽违禁词、广告词用正则做敏感词替换是最直接的方案。写一个简单的多词替换函数const bannedWords [垃圾, 广告, 诈骗]; const regex new RegExp(bannedWords.join(|), g); const filtered input.replace(regex, ***);这种方式在词表量级小的时候完全够用。如果词表达到几千条建议用 Trie 树或者其他数据机构做优化否则正则回溯性能会出问题。7. 我在实际项目里总结的小技巧用了这么多年正则说几个不一定写在文档里、但实战特别好用的小技巧。第一个能用字符串方法解决的不要强行上正则。如果只是判断字符串是否包含某个子串includes就够了如果只是按固定分隔符拆数组split更清晰。正则不是万能的滥用正则会让代码变得难读。第二个写正则的时候永远想着“这个表达式的边界是什么”。比如验证邮箱你想的是“ 前面必须有内容”那边界就是“至少一个字符”。再比如提取 URL 参数你想的是“参数名不能包含分隔符”那边界就是[^?]。边界想清楚了正则就不会写出模糊匹配。第三个任何写进业务代码的正则都要配测试用例。正则表达式是出了名的“写的时候爽改的时候疼”没有测试用例兜底你根本不知道加一个字符会不会影响之前的匹配行为。至少把正常的输入、边界输入、异常输入都给覆盖到。第四个命名捕获组提升可读性。JavaScript 支持命名捕获组写法是(?namepattern)匹配结果可以通过match.groups.name直接取。这在捕获组多的时候特别有用。const regex /(?year\d{4})-(?month\d{2})-(?day\d{2})/; const result 2025-06-18.match(regex); console.log(result.groups.year); // 2025 console.log(result.groups.month); // 06 console.log(result.groups.day); // 18以前取捕获组是靠下标match[1]、match[2]一多就容易记混。现在有了命名组代码一眼就能看懂强烈建议使用。最后一个也是我认为最重要的正则表达式是写给下一个维护者看的不是写给你自己爽的。你再会的语法三个月后也会忘。所以复杂正则一定要写注释把每段表达式的作用用//或#标注在旁边。如果项目里能接受甚至可以专门抽一个regex.js文件把所有业务正则集中管理每个正则旁边写上匹配示例和设计思路。这样一来后来接手的同事就不用对着天书发愁了。正则这个东西你花一周背语法不如花一天真正解决一个实战问题。希望这篇文章里邮箱验证和 URL 参数提取两个案例的拆解能帮你建立起“从需求到表达式”的思维路径。下次再拿到提取、验证、替换类需求先别急着百度拿出纸笔梳理一下边界条件你会发现正则其实没那么难。