那格 flags 写着 GIM,页面却当它什么都没填
起因是我在 flags 框里敲了大写的 GIM,模式填 A,测试文本填三个 a,状态行回「无匹配」。按框里那三个字母的读法,忽略大小写早该开着。把模板(6118 字节 / 133 行)的 script 段整段摘出来、在纯 Node 里跑一圈才看清顺序:L60 先洗一遍输入,L69 再无条件补一个 g。
洗的依据是一份只有 6 个字母的白名单——g i m s u y;大写、空格、符号、汉字在构造之前就删干净。于是填 x 落到 g,填 GIM 也落到 g,填 giz 只剩 gi,填 vi 的 v 被删后落到 ig,gimsuy 是唯一一次原样通过。补的那一步更硬:空 → 实参 g;i → 实参 ig;imsuy → 实参 imsuyg,re.flags 由引擎归一成 gimsuy。这套先洗后补的写法在正则表达式测试那页谁都能亲手复现一遍。
gg 是另一类下场:不去重、不忽略,构造当场抛 Invalid flags supplied to RegExp constructor,整页转进红色叉号(U+274C)分支,结果面板 display:none。中间夹一个空格的写法会被先洗成 gg,报错是洗字符那一步自己造出来的重复。
图 1:洗掉什么、留下什么、补一个字母,送进构造器的永远含 g。
补 g 不是功能开关,是 matchAll 那行在续命
动机能钉到一行代码:结果面板走 text.matchAll(re),非全局对象喂进去直接 TypeError,实测 message 原文「String.prototype.matchAll called with a non-global RegExp argument」。同一个 /a/ 对 aaa,不带 g 展开即抛,带 g 得 3 条。所以补 g 是那行代码的续命写法,不是留给用户的开关。i m s u y 确实可开可关:模式 A 对 aaa,无 i 时 0 条、有 i 时 3 条;^b 对跨行文本,无 m 时 0 条、有 m 时 1 条。唯独 g 恒真、恒不可关。
三张面板各记各的账
状态行的数字是 matchAll 展开后的数组长度,高亮的 <mark> 外壳按这个数组逐条重复,分组面板另有 20 条上限。把模式 (a) 对着连续 a 的文本往上推档位,三本账就分开了:
| 匹配数 | 状态行 | 高亮 mark 数 | 分组面板 | 面板列表 innerHTML |
|---|---|---|---|---|
| 21 | 21 | 21 | 20 行 | 6021 字符 |
| 25 | 25 | 25 | 20 行 | 6021 字符 |
| 40 | 40 | 40 | 20 行 | 6021 字符 |
| 1000 | 1000 | 1000 | 20 行 | 6021 字符 |
| 10000 | 10000 | 10000 | 20 行 | 6021 字符 |
面板末行永远停在 #20 index=19,列表长度一字不多,页面上也没有任何「仅显示前若干条」的文案。状态行少报了吗?没有,它连 10000 都照写;被切掉的 9980 条只是不出现在分组那一栏。
零宽的时候,两本账才显出区别
(?:) 对 abc(3 个可见字符)状态行写「找到 4 处匹配」,同时画 4 个内容为空的 mark,高亮 HTML 长 419 字符;\b 对 a b c 报 6 处、6 个空 mark;a* 对 baab 报 4 处,其中 3 处 0 宽、只有 1 处装着 aa。数字与绿块数始终相等,但相等不等于看得见:说有 4 处,屏幕上可能一块绿都没有。
外壳也按条付钱:开标签 97 字符、闭标签 7 字符,1 个可见字符要付 105 字符,空匹配同样付 104 字符外壳换 0 个可见字符。模式 a 对 10000 个 a:状态 10000、mark 10000、高亮 HTML 1050000 字符,而原文只有 10000 字符,膨胀 105 倍,这串还是在一次 oninput 里同步拼完再整体赋给 innerHTML 的。DOM 真正绘制要多久,本批未实测——探针只跑到字符串层,没开浏览器。
图 2:末行就是标题那笔账——21 处带组匹配,面板恒 20 行。
分组面板的闸不止一道
- 显示闸:只要有任意一条匹配带捕获组,整块面板就出来。模式
a|(b)对ab状态 2、面板照常显示,但第 1 行一个组单元都没有。 - 跳号闸:未命中的组直接不渲染。
(a)?(b)对ab b a b ab得 4 行,第 2、4 行整行只剩「组2:」,组1 凭空消失。 - 匿名闸:
(?<y>..)对abcd只显示「组1: ab」「组1: cd」,全页没有代码读那份命名组字典,名字y在 UI 里等于不存在。 - 下标账:
index=后面是原文绝对下标,不每轮从 0 起。(a)对xa xa xa三行是 #1 index=1、#2 index=4、#3 index=7。
藏起来的旧账,和一笔转义字符串账
模式为空时在构造之前就 return(实测构造次数 0),两个面板一起藏掉,一行提示都不留。语法错误走红叉分支:模式 ( 的错误串实测 61 字符 / 77 字节,前缀占 9 个码元(含一个全角冒号),余下全是引擎 message;同一时刻,上一轮 210 字符的高亮和「找到 2 处匹配」的状态行仍躺在隐藏 DOM 里,只是被盖住。点「清空」也不抹:flags 被复位成 g 而不是留空,315 字符的旧状态 HTML 照样留着。
转义那条只记字符串账:escHtml 只动 & < > 三个字符,双引号原样进 innerHTML;输入里本来就写的 & 会被再吃一遍,1 个 & 出 1 个 &,长度 1 变 5。页面本体是 4 个函数、9 个 id,无异步、无防抖;替换在这里没有入口——既没有替换框也没有替换结果节点,要在匹配之后改内容得换批量查找替换那页去做。
Q:flags 填大写为什么一点反应都没有?
白名单只收那 6 个小写字母,大写整串在构造之前就被删掉,剩下的空串又被补成 g。页面不给提示,只能从结果反推。
Q:填 gg 报「正则语法错误」,是这页坏了吗?
不是。重复字母由构造器拒收,页面把引擎 message 原样贴出来;夹空格的写法也是先被洗成 gg 才撞上报错。
Q:分组面板只有 20 行,是不是只匹配到 20 处?
不是,状态行才是全量那本账。20 是面板那一步 slice 的上限,40 条时它只列 20 行、末行恒 #20 index=19。
Q:带 g 的正则不是有状态的吗,这里怎么没体现?
那是站内另一篇实测的范围。本页每次按键都重新构造一个 RegExp(构造次数 1 次/调用),对象不跨输入留状态,两件事别混着记。