ARTICLE DETAIL

资讯详情

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

JavaScript正则表达式实战指南:从基础语法到性能优化

JavaScript正则表达式实战指南:从基础语法到性能优化 千万不要觉得正则表达式只是那种从网上复制一段、能用就行的小工具。我早期写 JavaScript 时也这么想直到有次线上页面因为一句看起来没什么问题的正则直接白屏才意识到这两个词组合在一起的分量。JavaScript 正则表达式是内置在语言里的一套文本模式匹配机制主要用来完成字符串的校验、提取、替换和拆分几乎每个前端项目里都有它的身影。无论你是刚接触 JavaScript 基础语法还是已经在用 ES6 写业务代码正则都能让你处理字符串的效率明显上一个台阶。这篇文章不打算讲教科书式的大道理直接从语法讲到业务场景再聊我在实际项目里踩过的坑。1. 正则表达式在 JavaScript 里的定位和创建方式1.1 正则本质一门描述文本模式的“小语言”很多人第一次接触正则时会被^、$、\d、(?...)这些符号劝退因为它看起来像乱码。但换个角度理解就简单多了正则本质上是一套描述“文本长什么样”的规则语言它不关心字符串的语义只关心字符的排列组合是否匹配。举个例子当我说“匹配一个手机号”表面上是处理数字实际上是描述这么一条规则字符串以 1 开头第二位是 3 到 9 之间的某个数字后面再跟 9 位数字。用正则写出来就是/^1[3-9]\d{9}$/。它像一段高度压缩的文本协议用来告诉 JavaScript 引擎给我找出所有符合这个形状的字符串。在 JavaScript 里正则表达式的执行由内置的 RegExp 引擎完成跟字符串方法配合非常紧密。常见的方法有test()、exec()以及字符串方法match()、matchAll()、replace()、replaceAll()、split()和search()。理解这些方法各自的行为边界往往比背二十个正则模板更重要。1.2 创建正则有几种方式怎么选JavaScript 里创建正则有字面量和构造函数两种方式。// 方式一正则字面量推荐日常写死规则时用 const phoneReg /^1[3-9]\d{9}$/; // 方式二RegExp 构造函数适合规则需要动态拼接的场景 const pattern ^1[3-9]\\d{9}$; const phoneReg2 new RegExp(pattern);这里有个特别容易踩的坑在构造函数里写字符串转义规则会和字面量完全不一样。字面量里写\d构造函数里要写\\d因为字符串本身会把\d里的反斜杠吃一道。如果你从后端接口拿到一段正则字符串再通过new RegExp(str)动态创建一定要留意接口返回的内容是否经过序列化转义。选择建议很简单正则规则在代码里写死就用字面量可读性好、编译快如果规则来自用户输入或服务端配置才用构造函数动态生成同时要做好异常捕获避免用户传了一段非法正则直接把页面搞崩。2. 正则基础语法精讲2.1 字符匹配字符类、转义和特殊符号正则里面最基本的是“一个普通字符匹配自身”比如/a/匹配字符a。但更常用的是字符类也就是用方括号表示的一个字符集合。[abc]匹配 a、b、c 中任意一个字符[^abc]匹配不在 a、b、c 中的任意字符[a-z]匹配 a 到 z 之间的任意小写字母[0-9]匹配任意数字\d等价于[0-9]\D是它的反义\w等价于[A-Za-z0-9_]\W是它的反义\s匹配空白字符包括空格、制表符、换行等\S是反义.匹配除换行符以外的任意字符特殊字符需要反斜杠转义比如匹配小数点本身要写成\.匹配反斜杠要写成\\。我经常看到有人写/www\.baidu\.com/这里的点如果不转义就表示“任意字符”能匹配wwwxbaiduxcom这类完全不是域名的内容这也是搜索类正则里很常见的错误。2.2 量词、贪婪和非贪婪量词用来控制前面字符重复的次数。*表示 0 次或多次表示 1 次或多次?表示 0 次或 1 次{n}表示刚好 n 次{n,}表示至少 n 次{n,m}表示 n 到 m 次。量词默认是贪婪的也就是会尽量多匹配。比如/a/去匹配字符串aaaaa时会一次性吃掉 5 个 a。在量词后面加一个问号就变成非贪婪模式会尽量少匹配。比如/a?/匹配aaaaa时第一次只会匹配到第一个 a。这个差异在提取文本时非常关键。比如要提取 HTML 里第一个 p 标签内的内容如果用/“p.*\/p/贪婪模式下会把从第一个p到最后第二个/p之间的内容全都吞进去改成/“p.*?\/p/才能匹配到最近的一对标签。日常处理日志、接口返回值时遇到“匹配范围比预期大”的问题第一反应就应该是变量上有没有用非贪婪。2.3 分组、捕获和反向引用圆括号在正则有两大作用改变优先级和捕获内容。/(ab)/表示 ab 这个整体重复出现多次而/ab/只让 b 重复。捕获组会把匹配到的内容暂存起来之后可以直接引用。结合字符串方法替换时用$1、$2表示第一、第二个分组const date 2024-05-18; const newDate date.replace(/(\d{4})-(\d{2})-(\d{2})/, $3/$2/$1); console.log(newDate); // 18/05/2024如果你只是想分组不想把内容捕获出来可以用(?:...)。这样能让正则的捕获组索引更干净也能减少内存开销。ES2018 之后JavaScript 支持命名捕获组可读性提升非常明显const time 09:30; const match time.match(/(?hour\d{2}):(?minute\d{2})/); console.log(match.groups.hour); // 09 console.log(match.groups.minute); // 30反向引用则是在正则内部引用前面已经捕获的内容比如判断一个字符串里的引号是否成对/([]).*?\1/。这里面的\1会引用第一个捕获组如果你用单引号开头结尾必须有单引号这样就能避免abc这种不匹配的内容混过去。2.4 修饰符和锚点匹配位置而不是字符修饰符写在正则的最后一个斜杠后面决定了匹配的模式。g全局匹配不写的话默认匹配到第一处就停了i忽略大小写m多行模式让^和$匹配每一行的开头和结尾sdotAll 模式让.可以匹配换行符uUnicode 模式处理码点大于\uFFFF的字符y粘性匹配必须从lastIndex位置开始匹配锚点^匹配字符串开头$匹配字符串结尾\b匹配单词边界。例如判断一个字符串里是否有完整的cat这个单词用/cat/会误伤concatenate用/\bcat\b/就能避免。不过要留意\b的依据是\w定义的字符集合对中文没有边界概念处理中文文本时经常会出乎意料。3. 核心实操贴近业务的正则场景拆解3.1 手机号校验为什么 13 位数字的写法也不一定错热搜里经常有人问“13位数字手机号码正则表达式怎么写”我在项目里也被问过很多次。先明确一个基础事实国内手机号是 11 位数字不是 13 位。基础校验规则是首位为 1第二位目前常见的是 3 到 9后面再跟 9 位数字const phoneReg /^1[3-9]\d{9}$/; console.log(phoneReg.test(13800001111)); // true console.log(phoneReg.test(12800001111)); // false那为什么会有 13 位数字的说法一种情况是你把中国国家区号 86 也算进去了86加 11 位手机号正好是 13 位。另一种情况是业务系统里的单据号、流水号确实要求 13 位纯数字那就没必要套手机号规则直接写/^\d{13}$/更合适。我见过不少同事把这两个场景混在一起拿着 11 位手机号规则去校验 13 位数字的单号结果线上一直被用户反馈“号码不合法”。遇到这类需求先确认你要校验的字段到底是什么再决定用哪套规则比上来就抄正则更靠谱。如果还要兼容86前缀可以写成const phoneReg /^(\?86)?1[3-9]\d{9}$/;这里的分组(\?86)?表示国家区号整体可选\?表示加号最多出现一次。3.2 邮箱、URL 和金额校验的实用写法邮箱校验是另一个经典场景。网上流传的正则五花八门有的过于严格把真实存在的邮箱都拦了有写得太松ab都能通过。业务上推荐相对宽松但合理的写法const emailReg /^[a-zA-Z0-9._%-][a-zA-Z0-9.-]\.[a-zA-Z]{2,}$/;这个规则要求必须有且后面必须有一个点点后面至少两个字母。它允许点号、下划线、百分号等常见字符适合绝大多数业务场景。真要严格校验邮箱是否存在前端正则只能做到这步最后还是要靠发送确认邮件。URL 的校验也用得非常多一般我不会写那种几百字符的超长正则而是根据需求控制范围const urlReg /^https?:\/\/[\w.-](?::\d)?(?:\/[^\s]*)?$/i;这段表示协议是 http 或 https域名部分由字母数字点横线组成可选端口号可选路径且路径部分不含空白字符。它在绝大多数内部系统里够用了比直接拿一个大而全的 URL 正则去替换要稳定。金额校验的需求也常见要求匹配正整数、小数或者负数const moneyReg /^-?\d(\.\d{1,2})?$/;-?表示负号可选\d匹配整数部分(\.\d{1,2})?表示最多保留两位小数的小数部分整体可选。如果是跟钱打交道这个正则最好再加一个条件整数部分不能以 0 开头除非金额就是 0否则01这种字符串也会被误放行。可以根据业务决定要不要处理。3.3 文本提取match 和 matchAll 的边界差异校验只是正则的入门用法真正体现价值的是从混合文本里提取结构化数据。比如从一段日志里把所有ORDER_NO...后面的单号捞出来const log 2024-01-01 ERROR ORDER_NOA12345 shop1001 ... ORDER_NOA12346 shop1002 ...; const matches log.match(/ORDER_NO([A-Z]\d)/g); console.log(matches); // [ORDER_NOA12345, ORDER_NOA12346]但这里有个细节match配合g修饰符返回的是所有完整匹配不会再给你分组的内容。如果你想拿到每个单号本身就需要用matchAll它能让你同时拿到完整匹配和捕获组const iterator log.matchAll(/ORDER_NO([A-Z]\d)/g); const orderNumbers Array.from(iterator, (item) item[1]); console.log(orderNumbers); // [A12345, A12346]matchAll要求正则必须带g修饰符否则会报 TypeError。这种“取分组内容”的需求用matchAll比while (exec) {}更直观也不容易漏掉迭代终止条件。处理大量数据时我习惯先用matchAll拿到迭代器再交给for...of或Array.from处理避免一次性把超大文本的全部分组全塞进数组导致内存压力。3.4 结合 replace、filter 和对象合并做数据清洗正则替换在数据清洗场景里简直是把瑞士军刀。最简单的用法是把连续多个空白字符压缩成一个const str 前端 工程师 ; const cleaned str.replace(/\s/g, ).trim(); console.log(cleaned); // 前端 工程师替换时传回调函数还能把字符串里的占位符动态替换成一个对象对应字段的值这跟对象处理结合得很紧const template 你好{name}本月消费{amount}元; const data { name: 张三, amount: 1880 }; const result template.replace(/\{(\w)\}/g, (_, key) data[key] ?? ); console.log(result); // 你好张三本月消费1880元这里的??是 ES6 之后新增的空值合并运算符能避免data[key]为undefined时显示成字符串undefined的问题。数据过滤也经常用正则配合filter函数。比如从数组里筛出所有身份证号形状的字符串或者只保留数字项const items [123, abc, 456, 7-9, 10086]; const numbers items.filter((item) /^\d$/.test(item)); console.log(numbers); // [123, 456, 10086]再比如后端给了一批字段里面混着首尾空格、中间有多余空格、甚至有空字符串需要清洗后再合并进目标对象。我会先把脏字段用正则做标准化再通过Object.assign或展开运算符做对象合并这样不会把脏数据带进后续 Store。const raw { name: 张三 , phone: 138 0000 1111 }; const clean Object.fromEntries( Object.entries(raw).map(([key, value]) [key, String(value).replace(/\s/g, ).trim()]) ); const merged { id: 1, ...raw, ...clean };这段代码里面Object.entries和Object.fromEntries也都是 ES6 之后非常常用的对象遍历手段配合正则在字段清洗时很顺手。4. 常见报错、性能问题与排查思路4.1 JavaScript 运行时报错先分清是正则语法错还是业务逻辑错“JavaScript 运行时报错”几乎是每个前端每天都可能见到的事。正则相关的报错一般分两类。第一类是正则语法本身非法浏览器会直接抛出SyntaxError。比如const reg /[/;方括号没有闭合控制台会显示类似Invalid regular expression的错误。常见原因有方括号、圆括号不配对量词*前面没有可重复的字符转义符号写在结尾或者命名的捕获组格式写错。这类错误在代码编译阶段就能暴露比较直接。第二类是正则能编译但运行时结果不符合预期。比如test方法配合全局修饰符g后因为lastIndex被改变第二次调用同一个正则对象结果和第一次不一样这是很多新手排查半天也找不到原因的重灾区。我还遇到过一个实际项目里的问题后端通过接口下发了一段过滤规则前端直接new RegExp(rule)创建结果某天接口返回了一条空字符串正则变成new RegExp()项目里所有字段校验全部放行用户提交了一堆垃圾数据。后来我在创建处加了 try/catch并且对空字符串做了兜底处理这样的问题才不再出现。4.2 贪婪匹配和灾难性回溯刚才提过贪婪匹配会吞掉尽可能多的字符但更严重的坑在嵌套量词造成的灾难性回溯。比较典型的例子是const reg /^(a)$/; reg.test(aaaaaaaaaaaaaaaaaaaaaaaaaaaaac);这段正则需要尝试所有可能的分组方式最终确认整个字符串不匹配。当字符串里全是a、只有最后一个是c时引擎会在大量组合之间反复回溯字符串越长耗时越长。我实测过几十个字符就可能让页面卡住几秒再长一点甚至能让 Tab 直接崩溃。这类问题的本质是正则引擎对“可能匹配但最终失败”的路径做了指数级枚举。遇到性能敏感场景优化思路是避免嵌套量词比如把(a)写成更明确的形态把可选的复杂结构拆成多个独立校验前端先算长度或先做简单字符判断给用户输入加长度限制很多灾难性回溯都是超长字符串触发不要用正则去做需要递归语义的匹配那种场景应该老老实实写状态机或循环解析正则不是万能的它擅长匹配“结构相对确定”的文本。遇到连续嵌套、嵌套深度不定的场景比如括号多层嵌套的表达式就该考虑换写法了。4.3 lastIndex 的坑全局正则不是无状态的lastIndex是 JavaScript RegExp 对象上非常容易被忽略的属性。它表示下一次匹配开始的位置只在正则带g或y修饰符时生效。const reg /abc/g; console.log(reg.test(abcabc)); // true console.log(reg.test(abcabc)); // true但匹配的是第二个 abc console.log(reg.test(abcabc)); // false因为 lastIndex 已经到字符串末尾了这个问题在图表组件、列表页分页、实时搜索这些反复触发校验的场景里特别常见。用户第一次输入合法第二次输入同一个值却提示不合法翻来覆去找不到原因。排查方法很简单用同一个全局正则连续调用多次如果结果不稳定十有八九是lastIndex没重置。解决办法是每次 test 之前手动把lastIndex重置为 0reg.lastIndex 0; const ok reg.test(input);或者在业务里避免复用带g的正则对象做布尔校验改成每次新建正则或者用字符串方法match来替代。4.4 在 HBuilder 和浏览器开发者工具里调试正则平时写 H5 项目很多人会用 HBuilder 系列工具创建和管理项目。它把 HTML、CSS、JavaScript 的编辑和运行环境整合在了一起调试正则时比较方便的做法是直接用console.log打印结果而不是一层层走断点。比如我在需要验证一段身份证号规则时会在代码里临时写const idReg /^\d{17}[\dXx]$/; console.log(11010119900307851X.match(idReg)); console.log(1101011990030785a.test?.(idReg));然后直接运行到日志面板看结果。如果结果不对我会再把正则逐步拆开先用/^\d{17}/测前 17 位再用[\dXx]$/测最后一位通过二分法快速定位到底哪段规则出错。除了内置日志正则调试还可以借助在线的可视化工具把正则引擎的匹配路径画出来。这类工具对理解分组和回溯非常有帮助尤其是面对一个复杂表达式时能看到每个分支怎么走最后一步在哪失败。5. 进阶把正则在 JavaScript 函数、事件监听和 ES6 场景里用起来5.1 实时监听input 事件加正则做动态校验在移动端 H5 项目里实时监听输入框内容并做反馈是非常重要的交互方式。最典型的场景是手机号输入框用户一边输入前端一边用正则判断是否合法合法才点亮提交按钮。const phoneInput document.querySelector(#phone); const submitBtn document.querySelector(#submitBtn); phoneInput.addEventListener(input, () { const value phoneInput.value.replace(/\s/g, ); const isValid /^1[3-9]\d{9}$/.test(value); submitBtn.disabled !isValid; phoneInput.value value; });这里有个细节我在监听函数里先用replace(/\s/g, )把空格清掉这样用户粘贴带空格的手机号时也能正确处理。表单校验最怕的不是用户输入错误而是输入格式千奇百怪正则在这里起到的作用就是先把输入“归一化”再做校验。如果你在开发原生 App 里的 WebView 页面或者用 HBuilder 打包成移动应用这种方式同样适用。只需要注意不同手机输入法可能输入全角数字、全角空格正则里可以考虑兼容const normalized value.replace(/[-]/g, (c) String.fromCharCode(c.charCodeAt(0) - 0xfee0));这段把全角数字转成半角数字0xfee0是全角和半角字符的码位差值实测在处理用户输入时很有效。5.2 动态替换回调让 replace 变“处理管线”正则配合 replace 回调函数能做的事比想象中多。之前举例过“模板占位符替换”再延伸一下它可以做一套简易的 Markdown 加粗转换const md 这是**重要**内容还有**更关键**的; const html md.replace(/\*\*([^*])\*\*/g, strong$1/strong); console.log(html); // 这是strong重要/strong内容...当替换规则比较复杂时回调函数能让逻辑清晰很多不用在一个字符串替换模板里堆叠过多占位符。const sentence 订单号 AB1234 金额 88.5 元; const result sentence.replace(/([A-Z]{2}\d)/g, (match, p1) { return 【${p1}】; }); console.log(result); // 订单号 【AB1234】 金额 88.5 元这种做法很适合对结构化文本做二次加工。我做过一个内部小工具把导出的工单内容里所有的日期、单号、金额提取出来重新拼接成固定格式核心逻辑就是几个 replace 回调串起来非常轻量。5.3 用正则给数据分组时配合 Map 和对象进行合并转换有时我们需要把字符串里所有匹配到的字段转成对象。比如从 URL 里解析查询参数正则在这里比URLSearchParams在某些场景下更灵活尤其是你想从一段不完整字符串里抓参数时const queryString name张三age18city上海; const obj {}; for (const match of queryString.matchAll(/([^])([^]*)/g)) { obj[decodeURIComponent(match[1])] decodeURIComponent(match[2]); } console.log(obj);这段代码用matchAll遍历所有键值对然后写进对象。如果后端返回的字符串里同时存在重复 key你还可以把这个循环改成累加逻辑而不是直接覆盖。对象合并场景里也经常需要先清洗再合并。比如你从表单里拿到的对象和从接口拿到的默认对象合并时表单对象里可能存在{ name: 张三 }这种带空格的内容。我在项目里的做法是先把表单对象的所有字符串字段过一遍正则清洗再做展开合并const formData { name: 张三 , memo: 无 }; const defaults { name: 未填写, memo: 暂无, level: 1 }; const cleanForm Object.fromEntries( Object.entries(formData).map(([k, v]) [k, typeof v string ? v.replace(/\s/g, ).trim() : v] ) ); const merged { ...defaults, ...cleanForm };这个过程看起来简单实际项目中能省掉非常多无意义的 bug 排查时间。因为很多“数据对不上”的问题根源就是某个字段里藏了一个肉眼看不到的空格或换行而正则正好能在进入业务逻辑之前把它清理掉。5.4 命名捕获组在 ES6 项目里的体验升级现在前端项目基本都走 ES6 的编译环境命名捕获组的可读性价值很值得利用。以前我们写const result 2024-01-01.match(/(\d{4})-(\d{2})-(\d{2})/); const year result ? result[1] : ; const month result ? result[2] : ; const day result ? result[3] : ;不小心就会把索引写错尤其正则前面多了一个外层分组时索引整体后移所有下标都要改。用命名捕获组后const result 2024-01-01.match(/(?year\d{4})-(?month\d{2})-(?day\d{2})/); const { year, month, day } result ? result.groups : {};代码的意图一目了然也不用担心分组顺序变动导致引用错乱。在多人协作的项目里这比在正则后面写大段注释解释每个$1是啥要实用得多。替换字符串里也可以用命名分组引用const text 2024-05-18; const swapped text.replace(/(?year\d{4})-(?month\d{2})-(?day\d{2})/, $day/$month/$year); console.log(swapped); // 18/05/2024命名分组的语法是(?name...)引用时在模板字符串里是$name。我用过一段时间之后已经不太愿意回到纯数字捕获组写了原因很简单代码写出来是给人看的而命名分组就像是给正则里的每个格子贴了标签。最后分享一个我自己的小习惯受限于 JavaScript 正则引擎的一些能力限制很多人遇到复杂文本解析直接懵掉。我在实际项目里总结出的一个土办法是先别急着写一条终极正则而是把需求拆成多个小正则一步步缩小范围。比如要清洗一批日志先判断该行是否包含错误关键字再提取时间字段再提取单号再校验单号格式每一步单独测全部通过后再拼装到一个函数里。这样每条正则都短小、可读、容易解释也方便同事 review。正则表达式在 JavaScript 里并不是什么高深莫测的东西它更像一把需要处理文本时随时可以抽出来的尺子。多看多写多踩坑慢慢你就会发现大多数字符串处理问题用正则加几个数组方法就能解决得干净利落。真正重要的是理解它的匹配逻辑和边界条件而不是死记硬背一堆模板。下次再看到一段复杂的正则试着拆开讲讲每一段到底在匹配什么你的 JavaScript 正则水平就已经又进了一步。
返回列表