rea:正则表达式可视化分析工具,让匹配过程一目了然
前几天我把手头一个拖了很久的个人项目重新捡起来名字一直没换就叫 rea。全称是我自己起的Runtime Expression Analyzer说白了就是一个跑在浏览器里的正则表达式可视化分析器。正则这玩意儿写起来不难调试起来真要命尤其是稍微复杂一点的模式——分组嵌套、量词叠加、前瞻后顾一混光靠脑补根本猜不到匹配过程到底发生了什么。rea 就是把“黑盒”打开把正则的语法结构、执行结果、分组取值、匹配位置全部摊开给你看我实测下来解决了不少实际问题。适合谁来用前端、后端、测试、数据分析凡是被正则坑过的人都能用上。不需要翻文档不需要记一堆命令行参数打开页面输入正则和要匹配的文本结构树、分组、匹配片段就都出来了新手也能看懂“为什么这里匹配上了那里没匹配上”。这篇文章我打算从设计思路、核心实现、实操步骤到踩过的坑都过一遍代码也会放出来有需要的可以直接抄。1. 为什么我会写 rea 这个正则可视化分析工具1.1 正则调试的核心痛点先说说我自己最真实的感受。日常工作里正则的应用场景很多表单校验、日志提取、代码扫描、文本清洗哪样都有它。但正则这个东西有个反直觉的特点写的时候以为是对的跑起来才发现错得离谱而且报错信息往往只有一行“Invalid regular expression”根本不告诉你具体是哪个括号没闭合、哪个量词炸了。网上有一些现成的测试工具但多数只能告诉你“匹配了”或“没匹配”看不到中间过程。遇到一个复杂的日期解析正则你只能看到最终匹配结果却不知道第一组为什么取到的是那个值第二组为什么是空字符串更不用说前瞻断言对匹配位置的“隐形”影响了。另外一个痛点就是正则语法树。大多数人在学习正则时都会经历一个阶段觉得(a|b)*和(a*|b*)差不多。实际上分组和量词的嵌套顺序对匹配结果影响巨大没有可视化辅助的话这种抽象关系很难建立起来。rea 最初就是冲着“把正则解析成树再画出来”这个目标去的。1.2 为什么不用现成的轮子而要自己写一定有人问浏览器开发者工具里不是自带正则调试功能吗在线正则工具也一抓一大把我的回答是能用但不够用。首先是形态问题。现有的调试工具大多是被动显示匹配结果我更需要的是一个能自定义展示匹配过程、能对语法树做交互操作、能直接把测试数据保存下来的工具。其次是可控性问题。很多在线工具是“黑盒”你根本不知道它内部用的什么正则引擎、对量词贪婪模式是怎么处理的出了问题也没法查。还有一个非常重要的原因是学习价值。手写一遍正则解析器的过程等于把整个正则语法体系从头到尾梳理了一遍收获远大于直接调用开源的解析库。我并不是反对用现成库只是对于一个“正则重度用户”来说理解底层模型会让日常排错快很多。如果只是想快速出结果当然可以用现成方案但如果你跟我一样跟正则纠缠多年自己实现一轮绝对值得。1.3 技术选型为什么用 React 原生正则引擎做这个项目时我第一个想到的就是展示层一定要足够灵活因为要同时渲染输入框、语法树、匹配高亮、分组信息交互还要求实时响应。前端技术栈我选了 React理由很简单组件化模型天然适合这种多区域联动界面加上状态管理之后正则文本一变所有视图能同步刷新开发效率高得多。正则引擎方面我反而没有引入任何第三方引擎直接用了浏览器内置的 RegExp 对象。这里有个很重要的判断重写一个正则引擎看起来逼格很高但实际产品中没必要。浏览器的原生引擎经过大量优化性能、兼容性都远好过自己造轮子而且它跟用户最终环境完全一致你在工具里测出来的结果就是线上跑出来的结果。但换来的是我需要自己去写正则的语法解析器把正则字符串拆解成结构化的语法树同时还要手动维护分组索引。为什么因为原生 RegExp 虽然能执行匹配却不会告诉你“这个分组从哪里开始、到哪里结束”。这部分信息需要解析器来提供也是 rea 可视化能力的核心所在。2. rea 的核心设计拆解2.1 输入层、分析层、展示层三件事整个 rea 的结构我分成三层输入层、分析层、展示层。输入层负责接收正则表达式、测试文本、flags 选项提供防抖机制避免输入过快导致卡顿分析层负责把正则解析成语法树、执行实际匹配、提取分组信息展示层则把语法树渲染成可视结构、给匹配结果加高亮。一开始我把这三层写在一个大组件里后来发现改起来极其痛苦。正则文本一变匹配要重跑、树要重建、高亮要重算全挤在一起逻辑根本理不清。后来拆成三个独立模块用数据流控制input 变化触发 analyzeanalyze 完成后产出两套数据一棵语法树和一组匹配结果展示层只是纯渲染这样就顺了很多。这里有个经验可以分享分析层和展示层一定要严格解耦。语法树是纯数据结构不含任何渲染逻辑展示层是纯渲染组件不负责计算匹配。这样做的最大好处是可以对分析层做单元测试渲染部分哪怕出问题也不影响核心正确性。2.2 正则语法解析从字符串到语法树rea 最核心的部分是手写的解析器。我把正则语法按优先级拆成几个层级选择alternation、序列sequence、重复quantifier、原子atom。为什么这么拆因为正则有明确的优先级规则abc|de中的|优先级最低它会把整个abc和de分成两个分支而ab*中的*优先级最高只作用于b。我采用的是递归下降解析法每个语法层级对应一个解析函数。function parseAlternation(expr) { const branches []; let current ; let depth 0; let i 0; while (i expr.length) { const ch expr[i]; if (ch () { depth; current ch; } else if (ch )) { if (depth 0) throw new Error(多余的右括号); depth--; current ch; } else if (ch | depth 0) { branches.push(parseSequence(current)); current ; } else { current ch; } i; } if (depth ! 0) throw new Error(括号未闭合); branches.push(parseSequence(current)); return { type: alternation, branches }; }这段代码的核心思路是扫描|但只有在顶层不在任何括号内的|才是真正的分支分隔符。括号深度用 depth 变量跟踪遇到左括号加一右括号减一。括号没闭合或者多出右括号直接抛错。这就是 rea 能实时提示错误的基础。当然后面还需要 parseSequence、parseQuantifier、parseAtom 这些函数继续往下拆每一层负责处理一个语法优先级最后组装成结构清晰的 JSON 树渲染层再根据这个树画图形界面。2.3 手写解析器与原生匹配引擎的职责划分可能有朋友会问你自己写了语法解析为什么不连匹配引擎一起写了我的想法是两者解决的问题不一样。解析器解决的是“正则里有什么结构”匹配引擎解决的是“这个结构如何作用于一段文本”。前者需要精确到每个字符的含义后者需要复杂的回溯和状态管理。原生 RegExp 的执行性能是经过大量优化的比如某些引擎会对常见模式做 JIT 编译。我如果自己用 JavaScript 实现回溯引擎性能可能差几十倍而且很难做到跟浏览器完全一致。所以 rea 的做法是语法树用自己的解析器生成实际匹配交给原生引擎然后从匹配结果里反推出分组、位置、捕获组信息。这里有一个细节需要注意原生 RegExp 不提供“分组匹配到哪些子串”的信息只提供整体结果和捕获组数组。我需要在解析器里记录每个分组在语法树中的索引再跟 match 结果里的捕获组顺序一一对应才能在展示层准确标出“第一组匹配的是这段文字”。3. 从零实现 rea 的关键步骤3.1 搭出可扩展的项目骨架rea 的前端部分我采用了比较常规的 React 构建工具组合组件不多就四个核心区输入面板、语法树视图、匹配详情、文本高亮预览区。目录结构大概长这样rea/ ├── src/ │ ├── core/ │ │ ├── parser.js # 正则语法解析器 │ │ ├── matcher.js # 包装原生 RegExp生成匹配结果 │ │ └── formatter.js # 将解析结果格式化为树状数据 │ ├── components/ │ │ ├── InputPanel.js │ │ ├── SyntaxTree.js │ │ └── MatchViewer.js │ ├── hooks/ │ │ └── useDebounce.js │ └── App.js └── package.json这个拆分不是一次性想出来的而是重构了两轮之后的产物。核心部分 core 完全不依赖 React它只负责输入正则字符串、输出结构数据这保证了逻辑可以被独立测试也为以后写命令行版本留了后路。组件部分只做渲染和用户交互不在里面写复杂计算。项目初始化没什么好说的创建工程目录、安装依赖、配置构建脚本。我想重点强调的是parser.js 和 matcher.js 一定要作为纯模块来写不要用任何全局状态。否则后面加“URL 参数分享”功能时你会发现状态管理复杂到想哭。3.2 实现分组与量词的解析逻辑分组和量词是整个正则语法里最容易出错的区域。分组涉及嵌套括号、命名分组、非捕获分组量词涉及贪婪、懒惰、占有三种模式。我在 rea 里先把普通捕获组和量词处理好命名分组这些后面再扩展。function parseQuantifier(atom) { const ch this.peek(); let min, max; if (ch *) { min 0; max Infinity; this.next(); } else if (ch ) { min 1; max Infinity; this.next(); } else if (ch ?) { min 0; max 1; this.next(); } else if (ch {) { // 解析 {m,n} 形式 const content this.readUntil(}); const parts content.split(,); min parseInt(parts[0], 10) || 0; max parts[1] ? (parts[1] ? Infinity : parseInt(parts[1], 10)) : min; this.next(); // 跳过 } } else { return { ...atom, quantifier: null }; } let greedy true; if (this.peek() ?) { greedy false; this.next(); } else if (this.peek() ) { this.next(); } // 占有模式先记录暂不特殊处理 return { ...atom, quantifier: { min, max, greedy } }; }这段代码从原子节点出发检查后面是否有*、、?或{m,n}形式的量词并把贪婪模式标记存储起来。比较容易被忽略的是{m,}这种省略上限的写法在代码里我用parts[1] 把它映射成 Infinity这样后续分析层就能统一处理“无上限重复”。为什么要单独提取量词节点而不是在原子节点上挂个字符串就行因为可视化阶段我要在语法树上直接显示每个节点的量词范围和贪婪性。像a{2,4}这种如果不结构化存储渲染时就得重新解析字符串写起来非常别扭。3.3 匹配结果与文本高亮的回显实现解析器搞定之后下一步就是真正执行匹配并展示结果。这里我用一个全局匹配循环来收集所有匹配位置和分组信息。function collectMatches(pattern, flags, text) { const re new RegExp(pattern, flags.includes(g) ? flags : flags g); const matches []; let m; while ((m re.exec(text)) ! null) { const groups m.slice(1).map((g, i) ({ index: i 1, value: g undefined ? null : g, start: m.index (m[0].indexOf(g) 0 g ! undefined ? 0 : findGroupOffset(text, m.index, m[0], g)), })); matches.push({ full: m[0], start: m.index, end: m.index m[0].length, groups, }); if (m[0] ) re.lastIndex; // 避免空匹配死循环 } return matches; }高亮渲染部分我用的是 Range 对象把 match.start 和 match.end 映射为文本节点中的具体位置再用一个带背景色的mark元素包裹。有人可能觉得直接用 substring 截断字符串加高亮更简单但那样拿不到精确的位置信息特别是文本中有换行、emoji、中文等宽字符时index 容易发生偏移。在写这个模块时我踩了一个坑m[0].indexOf(g)在分组值是空字符串时会返回 0导致空分组的 start 位置变成全串开头。我后来加了一个判断分组值为 null 或空字符串时就不计算 start只在界面上显示为“未参与匹配”。正则的捕获组里没有参与匹配的分组exec 返回的对应位置是 undefined这一步必须处理。3.4 错误提示与边界条件处理正则解析器的错误提示直接关系到工具好不好用。最后我实现了三个层级的错误语法错误、逻辑警告、性能风险。语法错误包括未闭合括号、双重量词、无效字符类写法等逻辑警告包括“永远匹配空字符串”的模式、捕获组编号异常等性能风险则专门针对可能引发灾难性回溯的模式给出提醒。比如连续嵌套的量词(a)以及量词后面跟重复量词a**这种写法我会在界面上标黄提示。虽然正则引擎自己能够忽略部分错误写法的“遗漏分支”行为但作为工具给用户看到这些信息会大大提升排查效率。边界条件方面我把空字符串测试文本、正则只包含锚点^、$、文本只有一行、文本末尾正好匹配等情况在测试用例里全覆盖了一遍。这些场景看似冷门实际用起来很快就会碰到提前处理好后面维护会轻松很多。4. 实操过程与常见问题排查4.1 空匹配死循环一次线上事故的教训我在开发阶段写了一个测试用例匹配文本abc正则用了/x*/g。结果页面直接卡死浏览器标签页崩溃。原因是x*可以匹配空字符串在全局匹配模式下exec 从头到尾每次都会匹配成功但一个字都不消耗导致 infinite loop。这个坑我相信很多朋友都踩过。解决办法其实只有一行代码在每次匹配后检查m[0] 如果是空匹配就手动把 lastIndex 加一。这样匹配位置会跳过当前字符继续向后扫描只要跳过的逻辑是在原始长度范围内就不会死循环。if (m[0] ) re.lastIndex;这个处理必须在 collectMatches 函数里做而不是交给用户。大多数在线正则工具遇到这种情况也会卡死rea 当初差点因为这个问题“出师未捷身先死”。后来我在正则输入区加了一个“执行超时保护”如果一个正则执行超过 500 毫秒就自动中断并提示可能的灾难性回溯。这个机制在 folk 版工具里也移植了实测非常有用。4.2 Unicode 与中文匹配的索引偏移问题另一个让我头疼的坑是宽字符索引偏移。大多数使用 exec 的场景match.index 是按 UTF-16 码元计算的。当文本里包含中文、emoji 这类占两个码元的字符时直接拿这个 index 去做 DOM 高亮位置就会错位。比如测试文本是前端abc正则是a直观上会觉得 a 在第 2 个字符位置但match.index返回的是 4。为什么因为“前”和“端”各自占两个 UTF-16 码元a 实际在第 5 个码元位置。用 substring 截断没问题因为 substring 也是按码元计算的但如果你要把 index 当作字符数展示给用户就会差两个位置。rea 的处理方法是展示区分开两种视图一种叫“码元位置”另一种叫“字符位置”。码元位置就是 match.index 的原生值字符位置则通过Array.from(text).map((_, idx) charIndex... )手动换算。同时我还在匹配详情区显示“此位置按 Unicode 码元计算”让用户自己对照场景使用。4.3 分组的取值与“未参与匹配”的处理一开始以为分组信息就是 match 数组里取一下这么简单后来发现需要处理的情况比想象中多。主要是分组嵌套在分支里时某些分支没执行到的分组在 exec 返回值里是 undefined 而不是空字符串。比如正则(a)|(b)文本是a第一组是a第二组是 undefined。我的展示层用灰色虚线框标出 undefined 的分组用绿色高亮显示有值的分组。同时做了分组路径追踪在语法树中记录每个分组节点的父级路径匹配详情里按路径显示。这样遇到复杂嵌套分组时你可以直观看到某个分组是从哪个分支进来的。还有一个细节是命名分组(?name...)的处理。exec 的返回值里有 groups 对象键是分组名。我在解析器里也维护了命名分组和索引分组的映射关系。不过当前版本对命名分组的支持只做到了展示还没有做重命名冲突检查这是后续计划里的一个事项。4.4 一个表单校验实战案例说一个 rea 在真实场景中的使用案例。我在某项目中要写一个“月份范围”的校验正则要求匹配2024-01到2024-12这种格式月份不能为 0不能超过 12。这个需求看起来简单但很多人会写出^\d{4}-(0[1-9]|1[0-2])$这种模式。问题出在哪分组括号加得太随意捕获组数量多余。而且\d{4}没有限制年份位数。我在 rea 里做的优化是改成^(?:19|20)\d{2}-(0[1-9]|1[0-2])$去掉了不必要的捕获组加了年份范围限制。这个例子很能说明正则调试工具的价值单纯靠脑补你可能很难发现\d{4}会把9999也匹配进去。我还在 rea 里加了“测试用例快照”功能就是把一组测试文本跟期望结果绑定成一个列表每次修改正则后一键运行看看哪些用例挂掉了。这个功能对重构正则表达式特别有用敢大改的前提是有一组回归测试兜底。5. 后续扩展方向与个人经验rea 目前最让我满意的部分是语法树的可交互呈现鼠标悬停在树节点上时对应的高亮区域会同步闪烁匹配详情也会联动更新。这种联动体验第一次做的时候调了很久但做完后真的觉得值得——正则的“结构”和“执行结果”终于对上了。后面我想加三个东西。第一个是 URL 参数分享当前正则和测试文本直接编码进 URL方便把问题转发给同事看第二个是导出测试用例把快照列表导出成 JSON 或者表格文件方便接进 CI 流程第三个是支持多引擎对比把 Rust 的 regex crate 或者 Python 的 regex 模块结果打包进一个服务端接口展示不同引擎的差异。三个功能里 URL 分享实施难度最低已经在做了。最后分享一个我的个人习惯现在写正则我一般先在 rea 里把结构树看一遍确认分组和量词挂载的位置跟预期一致然后再搭配几个边界测试用例去跑。结构树比任何文档都更能暴露问题因为它把写的时候没注意到的隐式优先级直接画出来了。早期没有这个工具时我写复杂正则基本靠试错现在算是“看着树写正则”出错率降低了很多。如果哪天你的正则也开始让人头疼不妨也试着自己做一个类似的工具收获绝对不只是代码量这么简单。