跳到主要内容

正则表达式

正则表达式用模式描述字符串中要匹配的内容,可以用于校验格式、提取片段和替换文本。它擅长处理明确的字符规则,但不会替你决定业务允许什么输入,也不能仅凭格式匹配确认一个账号真实存在。

现场写正则时,先说明输入类型、允许字符、长度和是否要求整串匹配,再把规则逐项对应到模式。JavaScript 还需要留意 g、y 带来的匹配状态,以及 Unicode 字符和 UTF-16 索引之间的区别。

1. 面试核心​

先记住这几条,再读正文
  • 字符类决定匹配什么,量词决定重复多少次,断言限制匹配位置。校验必须先明确业务规则,再决定是否使用整串边界,不能用找到一个合法子串代替输入整体合法。卡 · 字符类、量词与边界
  • 贪婪量词先尝试更多字符,惰性量词先尝试更少字符。两者都需要满足后续模式;惰性不等于不回溯,也不保证更快。卡 · 贪婪、惰性与回溯
  • 分组可以提取内容,也可以控制选择和重复范围。只为组织结构时可用非捕获组,反向引用则要求后面的内容与前面实际捕获的文本一致。卡 · 分组与捕获
  • g、y 会让 test() 和 exec() 使用并更新 lastIndex。重复调用可能得到不同结果;普通格式校验通常不需要这两个标志。卡 · lastIndex
  • 替换字符串支持 $1、$& 等特殊形式,替换函数直接返回文本。替换不是原地修改,函数返回值也不会再作为替换模板解析。卡 · 替换与动态模式
  • Unicode 模式改变字符处理规则,但不等于完整的人类文字分词。\d 仍是 ASCII 数字,\w 也不能当成所有语言的字母;u 按码点处理不代表一个可见字形只占一个单位。卡 · Unicode 与字符串边界
  • 千分位应先分离符号、整数和小数。用字符串保留大整数与小数原文,不能先转 Number 再承诺精度不变。卡 · 十进制字符串的千分位
  • 动态模式和复杂量词需要检查边界。把用户文本当字面量时要转义;嵌套且重叠的重复可能引起大量回溯,输入长度与失败路径也要验证。卡 · 回溯成本与适用范围

2. 字符类、量词与边界​

正则字面量 /pattern/flags 适合固定模式;new RegExp(pattern, flags) 适合运行时构造。构造器接收的是字符串,因此反斜杠还要经过 JavaScript 字符串语法这一层。

const literal = /\d+/;
const constructed = new RegExp("\\d+");
console.log(literal.test("item-42")); // true
console.log(constructed.test("item-42")); // true
console.log(/^\d+$/.test("item-42")); // false

前两个模式只要求找到至少一个连续数字片段,所以 42 足够让它们成功;最后一个要求整串都是数字,item- 使其失败。模式能匹配什么,与整个输入是否符合约束是两件事。

形式含义与边界
[abc]、[0-9]匹配集合或范围中的一个字符
[^0-9]匹配一个非 ASCII 数字字符;方括号内的 ^ 表示排除
\d、\DASCII 数字与其补集
\s、\S空白字符与其补集,空白不只包含普通空格
.默认匹配行终止符以外的字符;s 标志让它也能匹配行终止符
?、*、+分别重复零或一次、零或多次、一次或多次
{m}、{m,n}、{m,}分别重复固定次数、指定范围、至少指定次数
^、$不使用 m 时限制为输入开头和结尾;使用 m 时还可匹配行边界
a|b在模式 a 与模式 b 之间选择

量词作用于紧邻的前一个单元。例如 ab+ 重复的是 b,(?:ab)+ 才是重复整个 ab。选择的范围也需要明确:^cat|dog$ 表示“开头是 cat 或结尾是 dog”,^(?:cat|dog)$ 才要求整串只能是其中之一。

2.1 编号格式校验​

如果题目只说“匹配 QQ 号”,还不足以确定一个唯一的长度范围。应先问清是否只检查文本格式、是否允许前导零,以及允许多少位。下面采用一个练习约定:输入必须是字符串,由 5 到 12 位 ASCII 数字构成,第一位不能是零;这不是对当前账号服务规则的声明。

function isAccountId(value) {
return typeof value === "string" && /^[1-9][0-9]{4,11}$/.test(value);
}
console.log(isAccountId("12345")); // true
console.log(isAccountId("01234")); // false
console.log(isAccountId("1234")); // false
console.log(isAccountId("x12345")); // false
console.log(isAccountId(12345)); // false

第一位已经消耗一个数字,所以后续重复次数是 4 到 11,而不是 5 到 12。typeof 检查也有作用:test() 会把参数转成字符串,不先判断类型,就可能把数值输入也视为合法。

这个函数没有自动 trim()。若产品约定忽略首尾空格,应明确增加规范化步骤,再校验规范化后的值;不能一边声称原始输入必须完全匹配,一边悄悄删除字符。

3. 贪婪、惰性与回溯​

贪婪量词先尝试尽量多地匹配,必要时退回部分内容,让后续模式有机会成功。惰性量词在普通量词后加 ?,先尝试较少内容,再按需要扩展。

const text = "<b>one</b><i>two</i>";
console.log(text.match(/<.*>/)[0]); // <b>one</b><i>two</i>
console.log(text.match(/<.*?>/)[0]); // <b>
console.log(text.match(/<[^>]*>/)[0]); // <b>

贪婪的 .* 先消耗更多内容,再为最后的 > 留出位置,因此匹配跨到了最后一个标签。惰性的 .*? 在第一个能让 > 成功的位置结束。[^>]* 则从字符规则上排除了 >,它表达的是另一种约束。

这里用于说明量词,不是完整 HTML 解析方案。真实 HTML 有属性、转义、注释和嵌套结构,浏览器环境应使用适当的 DOM 解析工具,而不是把这三个模式当作通用标签解析器。

回溯是前面的选择导致后续失败时,尝试其他匹配路径。惰性匹配也会探索路径,所以把 * 改成 *? 不能自动解决性能问题。能否更直接地描述允许字符,比选择一个看起来更短的量词更重要。

4. 分组、反向引用与断言​

4.1 分组与捕获​

捕获组用括号保存匹配到的文本,便于后续提取或替换。普通组按左括号顺序编号,命名组还可以提供更清楚的字段名。

const match = /^(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[0-9]{2})$/.exec("2026-11-07");
console.log(match.groups.year, match.groups.month, match.groups.day); // 2026 11 07
console.log(/^(?:cat|dog)$/.test("cat")); // true
console.log(/^([a-z]+)-\1$/.test("go-go")); // true
console.log(/^([a-z]+)-\1$/.test("go-js")); // false

命名组便于读取 year、month、day,但这个模式只验证了位数与分隔符,不保证 2026-99-99 是真实日期。日期范围和日历规则需要进一步检查。

(?:...) 是非捕获组,只组织模式,不占用捕获编号。\1 是反向引用,要求当前位置与第一个组实际捕获的字符串一致,不是再执行一次 [a-z]+。

4.2 位置断言​

断言检查当前位置是否满足条件,本身不消耗输入字符。^、$ 是位置断言;(?=...) 是正向先行断言,要求后面能匹配相应模式,(?!...) 则要求不能匹配。

const text = "12px 8em 3px";
console.log(text.match(/[0-9]+(?=px)/g).join(",")); // 12,3
console.log(/^(?!0)[0-9]+$/.test("012")); // false

第一条只消费数字,px 用于确认后面的单位,因此结果没有单位文本。第二条用否定断言排除开头的 0,不过在这个简单场景中,直接用 [1-9] 描述首位通常更清楚。

后行断言 (?<=...)、(?<!...) 检查当前位置前面的条件,使用时需要确认目标运行环境支持。断言越复杂,越需要说明实际约束;并不是把字符从结果中排除就必须使用后行断言,捕获组或分步处理往往也能表达。

5. 匹配方法与状态​

test() 只回答是否匹配,exec() 成功时返回完整匹配、捕获组和索引,失败时返回 null。字符串的 match()、matchAll()、replace() 等方法则围绕字符串调用相应匹配操作。

方法或标志需要注意的行为
test()返回布尔值;使用有状态正则时仍会更新匹配位置
exec()返回一次匹配详情;g 或 y 下可连续调用
match()没有 g 时可获得一次匹配与捕获组;带 g 时主要返回所有完整匹配
matchAll()返回包含捕获组的迭代器;传入普通 RegExp 时要求带 g
g全局匹配;exec() 从 lastIndex 开始继续向后搜索
y粘连匹配;必须从 lastIndex 指定位置立即匹配成功
i、m、s、u分别涉及忽略大小写、多行边界、点号覆盖行终止符、Unicode 模式

5.1 lastIndex​

lastIndex 保存有状态正则下一次尝试匹配的起始位置。使用 g 或 y 时,匹配成功后更新到完整匹配结束处,失败则重置为 0。

const pattern = /a/g;
console.log(pattern.test("a"), pattern.lastIndex); // true 1
console.log(pattern.test("a"), pattern.lastIndex); // false 0
console.log(pattern.test("a"), pattern.lastIndex); // true 1

const sticky = /[0-9]+/y;
sticky.lastIndex = 1;
console.log(sticky.exec("a12")[0], sticky.lastIndex); // 12 3
sticky.lastIndex = 0;
console.log(sticky.exec("a12"), sticky.lastIndex); // null 0

同一条 /a/g 对同一个字符串重复调用得到不同结果,是因为第二次从位置 1 开始,已经没有 a 可以匹配。格式校验通常应去掉 g、y,避免把上一次输入的状态带到下一次调用中。

y 不会跳过开头的 a 去搜索后面的数字,这适合要求当前位置必须接上一个词法片段的场景;g 则可以继续寻找后面的匹配。

5.2 空匹配与连续提取​

能够匹配空串的正则,使用 exec() 手写循环时要格外小心。一次成功的空匹配不一定推进 lastIndex,循环可能始终停在同一位置。

const pattern = /a*/g;
const match = pattern.exec("b");
console.log(match[0].length, pattern.lastIndex); // 0 0
console.log([..."b".matchAll(/a*/g)].map(item => item.index).join(",")); // 0,1

matchAll() 的迭代处理会在空匹配后推进位置,避免这个特定的无限循环问题。它使用匹配器副本,迭代不会直接改写原正则的 lastIndex,但开始位置仍受到原值影响,所以传入之前也要清楚对象的状态。

若必须手写 exec() 循环,应明确处理空匹配,并在 Unicode 模式下按正确的码点步进,而不是无条件给 UTF-16 索引加一。没有必要控制这些细节时,优先使用合适的字符串 API。

6. 替换与动态模式​

replace() 返回新字符串,原字符串不变。正则没有 g 时只替换首次匹配;需要全局替换时使用 g,或在符合接口约定的情况下使用 replaceAll()。

const date = "2026-11-07";
console.log(date.replace(/([0-9]{4})-([0-9]{2})-([0-9]{2})/, "$3/$2/$1")); // 07/11/2026
console.log("a1b2".replace(/[0-9]/g, value => String(Number(value) * 2))); // a2b4
console.log("abc".replace(/b/, () => "$&")); // a$&c
console.log(date); // 2026-11-07

替换字符串中的 $1、$2、$3 指向捕获组,$& 指向完整匹配。函数形式接收匹配信息,用返回值作为实际文本;所以返回 "$&" 会写入这两个字符,不会再次展开成 b。

6.1 用户文本与正则语法​

如果用户输入 a.b,需求可能是搜索这三个字面字符,而不是让 . 匹配任意字符。直接拼入模式会改变含义,甚至构造出无效或代价很大的正则。

下面的辅助函数只用于把普通文本作为一个独立的字面模式传给 RegExp,不用于拼入字符类、与前面的数字转义连接,或重新生成正则字面量源码:

function escapeLiteralPattern(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const pattern = new RegExp(escapeLiteralPattern("a.b"), "g");
console.log("a.b axb a.b".replace(pattern, "X")); // X axb X

匹配语法转义与替换文本处理也不同。若替换内容来自用户,并且需要原样写入,可以用 replace(pattern, () => replacement),避免把其中的 $1、$& 当作替换模板。更复杂的动态组合应使用适合目标环境的标准转义能力或成熟方案,不把这个小函数误当成覆盖所有语法位置的通用工具。

7. Unicode 与字符串边界​

JavaScript 字符串按 UTF-16 码元存储与索引,部分字符由两个码元组成。正则的 u 模式会在相应匹配操作中把代理对作为一个码点处理,但匹配结果的 index 和 lastIndex 仍是字符串的 UTF-16 索引。

console.log(/^.$/.test("😀")); // false
console.log(/^.$/u.test("😀")); // true
console.log(/^\w+$/.test("中文")); // false
console.log(/^\p{L}+$/u.test("中文")); // true
console.log(/^\d+$/u.test("١٢")); // false
console.log(/^.$/u.test("e\u0301")); // false

\p{L} 是 Unicode 属性转义,用字母类别描述字符;\d 仍只匹配 0 到 9,不会因为启用 u 就接受所有文字系统的数字。\w 主要围绕 ASCII 字母、数字和下划线定义,忽略大小写等模式还有具体规则,不能用它判断任意语言中的“单词”。

最后的 e\u0301 是字母 e 加一个组合重音,包含两个码点,可能显示为一个字形。按码点匹配和按用户看到的字符处理不同;需要字形簇切分时,应使用相应的 Unicode 分段能力,而不是假定加上 u 就足够。

同样,\b 的单词边界与 \w 的分类有关,不能当作中文分词边界。匹配规则、文本规范化和自然语言分词是不同问题。

8. 十进制字符串的千分位​

千分位首先是格式约定。这里接受可带 + 或 - 的十进制字符串,整数部分除单个 0 外不允许前导零,小数点后至少一位;不接受空格、指数、已有分隔符或非 ASCII 数字,保留符号和小数原文。

function formatDecimal(value) {
if (typeof value !== "string") throw new TypeError("Expected a string");
const match = /^([+-]?)(0|[1-9][0-9]*)(\.[0-9]+)?$/.exec(value);
if (!match) throw new TypeError("Invalid decimal string");
const [, sign, integer, fraction = ""] = match;
const grouped = integer.replace(/\B(?=(?:[0-9]{3})+$)/g, ",");
return sign + grouped + fraction;
}
console.log(formatDecimal("1234567.8900")); // 1,234,567.8900
console.log(formatDecimal("-9007199254740993")); // -9,007,199,254,740,993
console.log(formatDecimal("+0.00")); // +0.00

模式先把符号、整数和小数拆开,避免把小数部分也插入逗号。整数里的先行断言要求当前位置右侧恰好剩下三位数字的整数倍,\B 在纯数字片段中排除开头边界,因此只在合适的内部位置插入分隔符。

全程没有把输入转成 Number,所以超出安全整数范围的数字和小数末尾的零都能保留。若输入本来已经是精度丢失的 Number,再转字符串不能恢复原文。

这个正则写法适合解释零宽位置匹配,不保证对任意长输入都是线性耗时:多个候选位置可能反复检查右侧后缀。对很长的数字字符串,可以用从右向左分组的方法,避免重复扫描:

function groupInteger(integer) {
const groups = [];
for (let end = integer.length; end > 0; end -= 3) {
groups.push(integer.slice(Math.max(0, end - 3), end));
}
return groups.reverse().join(",");
}
console.log(groupInteger("1234567")); // 1,234,567
console.log(groupInteger("0")); // 0

groupInteger 只负责已经校验过的非空整数片段,不能替代外层格式检查。每组最多三位,分组、反转和输出总工作量随数字长度线性增长。产品需要地区化显示时,还应考虑 Intl.NumberFormat 的区域规则和输入精度,而不是默认所有地区都使用三位分组与同一种逗号。

9. 回溯成本与适用范围​

正则的运行成本不仅取决于字符串长度,还取决于模式允许多少种匹配路径。嵌套且覆盖相同字符的重复,例如 ^(a+)+$,在一串 a 后面出现不匹配字符时,可能反复尝试不同拆分方式。

这类灾难性回溯会让某些输入的执行时间快速增长。浏览器主线程上的同步匹配可能阻塞交互;服务端同类问题可能占用处理资源。不能仅用很短的成功样例证明它足够高效,也不能假定某个引擎的优化或保护机制覆盖所有模式。

如果需求只是“一到多个 a”,直接写 ^a+$ 就无需嵌套量词。更一般的处理包括明确分隔字符、消除重叠选择、限制合理输入长度,以及对长输入和接近成功但最终失败的输入做受控验证。复杂语法、嵌套结构或需要详细错误位置时,应考虑专用解析器。

验证慢模式时,应在可终止的隔离进程或 Worker 中使用有限输入,不在页面主线程上直接试超长攻击字符串。setTimeout 不能在同一线程上打断一个已经运行中的同步正则。

10 · 面试题卡片​

本页题目与参考回答

10.1 怎样用正则表达式校验 QQ 号文本,需要先明确哪些规则?​

考察重点:输入类型、长度、首位限制、整串校验与业务边界。

相关内容:字符类、量词与边界、编号格式校验。

参考回答

先明确允许字符、长度、前导零和输入类型,再把它们对应到正则;仅凭题目中的账号名称不能推定完整规则。 采用本文的练习约定时,字符串需满足 /^[1-9][0-9]{4,11}$/:首位非零,后面再接 4 到 11 位数字,总长 5 到 12。

还要先检查 typeof value === "string",避免 test() 自动转换输入;不加 g 或 y,避免校验带状态。格式通过只说明文本满足约定,不能证明账号存在。

10.2 贪婪匹配与惰性匹配有什么区别,惰性一定更快吗?​

考察重点:尝试顺序、后续约束与回溯。

相关内容:贪婪、惰性与回溯、回溯成本与适用范围。

参考回答

贪婪量词先尝试更多字符,惰性量词先尝试更少字符,两者都要让后续模式成立。 贪婪可能回退,惰性也可能不断扩展并回溯。

因此惰性不保证更快。应根据允许字符和分隔规则减少不必要的匹配路径,再用成功、失败和边界输入验证,而不是只把 * 换成 *?。

10.3 捕获组、非捕获组、反向引用和先行断言分别做什么?​

考察重点:文本保存、模式组织、内容一致性与零宽判断。

相关内容:分组与捕获、位置断言。

参考回答

捕获组保存匹配文本,非捕获组只组织模式;反向引用要求后续内容等于前面实际捕获的文本。 它不是重新执行同一段字符规则。

先行断言检查当前位置后面的内容是否满足条件,本身不消费字符。比如数字后接 (?=px),结果只包含数字,px 仅用于确认上下文。

10.4 同一个正则对同一个字符串反复 test,为什么会交替成功和失败?​

考察重点:g、y、lastIndex 与空匹配步进。

相关内容:lastIndex、空匹配与连续提取。

参考回答

带 g 或 y 的正则会读取并更新 lastIndex,下一次不一定从字符串开头开始。 成功后位置移到匹配末尾,失败则重置为 0,所以 /a/g 连续测试 "a" 会出现 true、false、true。

普通校验可去掉这两个标志。连续提取则要管理位置,尤其注意空匹配未推进时的无限循环;适合时使用会处理空匹配步进的 matchAll()。

10.5 replace 的字符串参数和函数参数有什么区别?​

考察重点:捕获替换、返回文本、全局匹配与原字符串。

相关内容:替换与动态模式、用户文本与正则语法。

参考回答

字符串形式会解释 $1、$& 等替换模板,函数形式用返回值作为实际替换文本。 函数返回 "$&" 就写入这两个字符,不再次展开成完整匹配。

replace() 返回新字符串,没有 g 的正则只替换首次匹配。用户提供的字面替换内容可用函数返回;动态匹配模式则需要另外处理正则语法转义,不能把两类规则混用。

10.6 加上 u 标志后,正则就能按所有语言的字符和单词匹配了吗?​

考察重点:UTF-16、码点、字形簇、字符类别与索引。

相关内容:Unicode 与字符串边界。

参考回答

不能,u 提供 Unicode 模式下的码点处理,不等于自然语言分词或字形簇切分。 \d 仍是 ASCII 数字,\w 不能表示所有语言的字母;需要 Unicode 字母类别时可按需求使用属性转义。

组合字符可能包含多个码点,\b 也不等于中文词边界。匹配结果的索引仍按 UTF-16 码元计算,不能直接把索引差当作用户看到的字符数。

10.7 怎样给十进制字符串加千分位,同时保留大整数和小数原文?​

考察重点:输入约定、分段处理、零宽匹配、精度与复杂度。

相关内容:十进制字符串的千分位。

参考回答

先校验字符串格式,分离符号、整数与小数,只对整数分组,最后原样拼回其他部分。 避免先转 Number,才能保留超出安全整数范围的数字和小数末尾的零。

普通长度可以用先行断言识别右侧剩余三位整数倍的位置。很长输入可从右往左每三位切片,避免多次检查后缀。还需要测试空串、非法字符、正负号、零、分组边界与明确拒绝的指数形式。

10.8 正则为什么可能引起长时间阻塞,怎样控制风险?​

考察重点:重叠重复、失败路径、输入限制与同步执行。

相关内容:回溯成本与适用范围、用户文本与正则语法。

参考回答

嵌套且重叠的量词可能在匹配失败时探索大量拆分路径,导致执行成本快速增长。 成功的短样例不能证明复杂度可控,改成惰性也不一定解决问题。

应简化重叠规则、限制合理输入长度、区分用户字面文本与模式,并受控测试接近成功但最终失败的输入。慢模式放进可终止的隔离环境;同一线程上的 setTimeout 无法中断正在执行的同步正则。复杂结构优先考虑专用解析器。

11. 参考资料​

来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明