跳到主要内容

正则方法与回溯风险

str.match 的三种模式各返回什么?​

  • 无 g: 返回首个匹配的数组, [0] 是整个匹配, 之后依次是各捕获组, 另带 index 与 input; 有 g 时则返回全部匹配的字符串数组, 分组与位置信息全丢;
  • 无匹配: 两种模式都返回 null, 而非空数组;
const r = "I love JavaScript".match(/Java(Script)/);
r[0];
r[1];
r.length;
r.index;
r.input; // JavaScript, Script, 2, 7, 'I love JavaScript'
str.match(/Java(Script)/g); // ['JavaScript'] — 只剩整体
str.match(/HTML/); // null
  • 坑: 忘记 null 会立刻抛 TypeError; 需要数组时写 str.match(re) || [];

matchAll 与 match 有何不同?​

  • 定位: match 的新改进版, 专为"全部分组都要"设计, 需带 g 使用;
  • 差别: 返回可迭代对象 (RegExp String Iterator) 而非数组, 需 Array.from、for..of 或解构取值; 每项都是带捕获组的匹配数组 (格式同不带 g 的 match, 含 index、input); 无匹配时返回空的可迭代对象而非 null;
const iter = "<h1>Hello</h1>".matchAll(/<(.*?)>/g);
iter[0]; // undefined — 它不是数组
for (const m of iter) m[1]; // h1 — 直接迭代, 无需 Array.from
const [tag] = "<h1>Hello</h1>".matchAll(/<(.*?)>/g);
tag.index; // 0
  • 为何如此设计: 调用时不立刻搜索, 迭代到哪才搜到哪; 循环里 break 后不会再去寻找剩余匹配;

str.split 与 str.search 如何配合正则?​

  • split(sep, limit): sep 可为正则, 在匹配处切分, limit 限制条数 → '12, 34, 56'.split(/,\s*/) 得 ['12', '34', '56'];
  • search(re): 返回首个匹配的位置, 无匹配返回 -1 → 'A drop of ink'.search(/ink/i) 得 10;
  • 局限: search 只找第一个匹配, 后续位置需用 matchAll 等手段;

exec 与 test 如何依赖 lastIndex?​

  • 无 g: exec(str) 等价于 str.match(re), 每次从头找; test(str) 只回答真假;
  • 有 g: exec 从 lastIndex 起找, 命中后把 lastIndex 更新到匹配结尾之后;
  • 到头: 再没匹配时 exec 返回 null, 并把 lastIndex 重置为 0;
  • 因此循环 exec 能依次取回全部匹配 (含分组), 是 matchAll 的手动等价物;
const re = /javascript/gi;
let m;
while ((m = re.exec("a JavaScript b javascript"))) m.index; // 逐个命中
  • test 同样读写 lastIndex, 也可用来从指定位置试探;
  • 陷阱: 同一个带 g 的正则反复用于不同字符串时 lastIndex 残留, 第二次会从中间开始而误判 —— const r2 = /javascript/g 上 r2.test('javascript') 第一次 true (留下 lastIndex = 10), 第二次 false;
  • 规避: 每次搜索前设 re.lastIndex = 0, 或改用不依赖 lastIndex 的字符串方法;
  • "精确位置"语义由 y 提供, 见 环视与粘性;

m 标志如何影响各方法的结果?​

  • 锚点语义: m 让 ^/$ 匹配每行首尾, 详见 锚点与边界; match(/^\d/gm) 每行都产出匹配, 但结果只剩文本, 没有 index, 换成 matchAll 才能定位到行;
  • replace(/^\w+/gm, s => s.toUpperCase()): 逐行替换, g 让替换覆盖全部匹配;
  • 陷阱: 漏 g 只处理第一处; 漏 m 则 ^/$ 只认整串首尾;

替换字符串里有哪些特殊占位符?​

  • 前提: 第一个参数是字符串时 replace 只换第一处, 要全部替换须给正则加 g; 第二个参数是字符串时支持以下占位符:
符号替换串中的作用
$&整个匹配
$`匹配之前的文本
$'匹配之后的文本
$n第 n 个捕获组 (1-2 位数字), 详见 分组与回溯引用
$<name>命名组的内容
$$字面量 $
"12-34-56".replace("-", ":"); // 12:34-56 — 只换第一处
"12-34-56".replace(/-/g, ":"); // 12:34:56
"John Smith".replace(/(john) (smith)/i, "$2, $1"); // Smith, John

函数式替换能拿到哪些信息?​

  • 第二个参数可为函数, 每个匹配调用一次, 返回值即该匹配的替换文本;
  • 签名: func(match, p1, ..., pn, offset, input, groups), 即整个匹配、各捕获组、匹配位置、源串、命名组对象;
  • 无捕获括号时只剩 3 个参数: func(match, offset, input);
"html and css".replace(/html|css/gi, (s) => s.toUpperCase()); // HTML and CSS
"Ho-Ho-ho".replace(/ho/gi, (m, offset) => offset); // 0-3-6
"John Smith".replace(/(\w+) (\w+)/, (m, name, sur) => `${sur}, ${name}`); // Smith, John
  • 组多时: 用剩余参数 (...args) 收集, 或 args.pop() 取末尾的 groups 对象; 函数能读全部匹配信息、访问外层变量, 替换能力最强;

replaceAll 与 replace 有何区别?​

  • 差别一: 首个参数是字符串时, replaceAll 替换全部出现, replace 只换第一处;
  • 差别二: 首个参数是正则且缺 g 时报错, 带 g 时与 replace 等价;
  • 定位: 主要用途是替换字符串的全部出现, 省掉给特殊字符转义写正则;
"12-34-56".replaceAll("-", ":"); // 12:34:56

灾难性回溯是怎么发生的?​

  • 前提: 量词的重复与回退机制, 见 量词与贪婪模式;

  • 症状: 同一正则在多数串上正常, 某些串上卡死, CPU 占满, 浏览器提示杀掉脚本;

  • 根源: 嵌套量词让同一段文本存在多种合法"分组方式", 引擎返回失败前必须逐一试遍;

const re = /^(\w+\s?)*$/;
re.test("A good string"); // true — 正常
re.test("An input string that takes a long time or even makes this regexp hang!"); // 卡死
  • 简化复现: 去掉空格与词字符类得 ^(\d+)*$, 输入 0123456789...z 同样卡死;
  • 推演 ^(\d+)*$ 匹配 123456789z: \d+ 先贪婪吃掉全部数字 → 星号无可重复 → $ 撞上 z, 失败; 于是 + 退一位得 12345678, 星号再吃 9, $ 又撞 z; 再退成 1234567+89、1234567+8+9、123456+789 …;
  • 回溯规则: 最后一个贪婪量词先逐次减到最少, 再由前一个量词减, 层层展开;
  • 组合数: 长度为 n 的数字串有 2^n - 1 种切法; n=9 → 511, n=20 → 约 105 万, n=30 → 约 10.7 亿;
  • 词例同理: input 可写成 (input)、(inpu)(t)、(inp)(u)(t) …, 引擎不知道结尾的 ! 注定失败;
  • 引擎差异: V8 8.8 起 (Chrome 88) 对这类模式有优化, Firefox 仍会卡; 服务端卡住后果更严重;
  • 懒惰无用: 换成 \w+? 只改变尝试顺序, 组合总数不变;

如何规避灾难性回溯?​

  • 思路一, 降低组合数: 让可选部分变成必需, 掐掉重复切分的可能;
  • 改写: ^(\w+\s?)*$ → ^(\w+\s)*\w*$, 匹配集合相同, 但空格变必需; 原理是 (\w+\s) 之间必须夹空格, 单个 input 无法拆成两次 \w+\s 重复; 代价是模式更复杂, 且不总能想出等价形式;
/^(\w+\s)*\w*$/.test("An input string that makes this regexp hang!"); // false, 立刻返回
  • 思路二, 禁止回溯: 让量词一次吃满不再退让; 但 JS 不支持占有量词 \d++ 与原子捕获组;
  • 模拟: (?=(\w+))\1 —— 先用前瞻就地把整个 \w+ 抓下并用括号记住, 再用 \1 整体消费; 套回原例时外层多一层括号使序号顺移, 用 \2 或命名组更稳 → /^((?=(?<word>\w+))\k<word>\s?)*$/;
"JavaScript".match(/\w+Script/); // JavaScript — \w+ 逐字退到 Java 才配上
"JavaScript".match(/(?=(\w+))\1Script/); // null — 整词被 \1 锁定, 剩不下 Script