正则方法与回溯风险
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