正则表达式可视化测试器开发手记:匹配高亮、捕获组着色与零宽匹配防护
一、起因:调试正则时最烦的三件事
写正则的时候,最耗时的往往不是想不出怎么写,而是写出来之后不确定它到底匹配了什么。具体来说有三件反复出现的事:
第一,看不出匹配边界。一段测试文本里正则匹配了几段、每段的起止位置在哪,光盯着字符串数不出来,尤其是模式里带了 \b、环视或者可选分组的时候,边界和预期经常不一致。
第二,捕获组对不上号。一个模式里有五六个括号,跑完只知道整体匹配成功了,但第 3 组抓到的是空串还是没参与匹配、命名组 (?<year>...) 到底抓到了什么,得手动加日志或者去 REPL 里逐个打。
第三,改一个字符全盘重来。在编辑器里改正则、保存、跑脚本、看输出,循环一次几十秒,思路早断了。
所以做了这个小工具:左边写正则,右边实时出匹配高亮、位置编号和捕获组表格,改任何一个字符都立刻反映到结果上。整个工具一个 HTML 文件,零依赖,双击就能跑。
二、整体结构
单文件应用,全部逻辑分四块:
- 模式编译层:把输入框里的模式和 g/i/m/s 四个开关翻译成
RegExp对象,编译失败时把错误信息翻译成人类能读的话; - 匹配引擎层:统一走
exec循环而不是matchAll,原因后面第四节会讲; - 高亮渲染层:把匹配结果拆成「普通文本 / 匹配片段」交替的片段数组,相邻匹配轮换两种颜色,再拼成带
<mark>的 HTML; - 捕获组表格层:每组一行,显示组号、组名、匹配文本、起止位置,未参与匹配的组明确标注。
界面布局是左右两栏:左栏是正则输入、开关和常用正则速查表;右栏上方是待测文本,下方是统计条和捕获组明细。所有状态变化只触发一次重算,输入框用 input 事件而不是 change 事件,保证边打边出结果。
三、匹配高亮:片段化而不是字符串替换
最初想过用 String.replace 直接把匹配包上标签,这条路有几个绕不开的问题:replace 的回调拿不到匹配的全局位置,多匹配时颜色没法轮换,匹配文本里如果本身含 HTML 特殊字符还会被二次解析。
换成片段化思路就干净了。先收集所有匹配的起止区间:
const spans = [];
re.lastIndex = 0;
let m;
while ((m = re.exec(text)) !== null) {
spans.push({ start: m.index, end: m.index + m[0].length, groups: m.slice() });
if (m[0] === '') re.lastIndex++; // 零宽匹配必须手动步进,否则死循环
}
然后把原文本按这些区间切成交替片段,普通片段原样输出(先过 HTML 转义),匹配片段包一层 <mark>。颜色轮换用一个简单的奇偶判断,相邻匹配视觉上就能分开:
let html = '', cursor = 0;
spans.forEach((s, i) => {
html += escapeHtml(text.slice(cursor, s.start));
html += `<mark class="${i % 2 ? 'alt' : ''}">${escapeHtml(text.slice(s.start, s.end))}</mark>`;
cursor = s.end;
});
html += escapeHtml(text.slice(cursor));
这里有个容易漏的细节:escapeHtml 必须在切片之后、拼接之前做。如果先转义整段文本再按索引切,&、< 这些实体会让索引全部偏移,高亮位置就全错了。
四、零宽匹配:工具崩不崩的分水岭
正则是图灵完备的,也就意味着可以写出匹配空串的模式,比如 a*、\b、(?=x)。用 matchAll 或者忘记处理 lastIndex 的 exec 循环,遇到这类模式会直接卡死浏览器标签页——我开发过程中真实踩到过,页面无响应,只能杀进程。
防护就一行:匹配到空串时手动把 lastIndex 加一。这也是第四节坚持手写 exec 循环的原因,matchAll 内部虽然也做了处理,但你拿不到控制权,没法在上层加匹配数量上限。
另外加了两道保险:匹配总数超过 2000 就截断并提示,单次匹配文本超过 500 字符就省略显示。前者防止病态正则把内存吃光,后者防止有人拿它当日志查看器用时渲染出几屏长的单个高亮块。
五、捕获组表格:把「对不上号」变成一眼可见
表格的列是:组号、组名、内容、位置。三个关键设计:
组名推断。JavaScript 的 exec 结果不直接告诉你哪个组是命名组,需要在模式里用正则扫一遍 (?<name> 的出现顺序,把第 n 个命名组对应到第 n 个编号组上。命名组和编号组共享编号空间,所以扫出来的顺序就是编号顺序。
未参与匹配的显式标注。分组匹配失败时 exec 结果里对应项是 undefined,如果直接渲染成空单元格,用户会分不清「匹配了空串」和「没参与匹配」这两种完全不同的情况。这里用灰色斜体的 未参与匹配 明确区分。
空串可见化。匹配到空串的组,内容列渲染成一个细边框的空方块,而不是空白。不然一行四个组三个空,根本看不出谁抓到了东西。
六、实时反馈的节奏控制
「边打边出结果」如果完全同步,在长文本(几千行日志)上每次按键都会触发一次全量重算,输入会明显卡顿。做法是 120ms 防抖:
let timer = null;
input.addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(run, 120);
});
120ms 是试出来的:低于这个值,快速输入时依然能感觉到掉帧;高于 150ms,就能察觉到「我打完了,结果才出来」的延迟感。同时把重算结果做一层缓存,输入没变就不重新算。
七、几个顺带做进去的东西
四个开关的语义。g 是全局匹配(找所有),i 忽略大小写,m 让 ^ $ 匹配每一行,s 让 . 匹配换行符。开关直接拼接在模式串末尾构造 new RegExp(pattern, flags),比在界面上解释每个开关的学术定义更直观——切换一下,看结果变化,立刻就懂了。
常用正则速查表。内置了邮箱、手机号、URL、IPv4、日期、中文字符、十六进制颜色等十来条,点一下直接填入输入框。这些模式都是日常真正常用的,比放一份「正则教程」有用。
错误信息翻译。SyntaxError 的原始信息对中文用户不太友好,比如 Invalid regular expression: /(/: Unterminated group,这里做了一层映射,把常见错误翻译成「有未闭合的括号」这类说法,并指出错误大致位置。
八、小结
这个工具全部代码不到 600 行,一个 HTML 文件,没有任何构建步骤和第三方依赖。但它把「写正则—猜结果—跑脚本—看输出」这个几十秒的循环压缩到了毫秒级,而且匹配边界、捕获组、空串陷阱全部可视化。
更深一层的体会是:很多开发中的小痛点,值得用一个下午做个专用工具,而不是继续忍受。判断标准很简单——如果你发现自己一周内第三次对着同一段输出数匹配位置,那就是时候写工具了。
完整源码已开源在 GitCode,欢迎取用和改进。
- 点赞
- 收藏
- 关注作者
评论(0)