前几天我把手头一个拖了很久的个人项目重新捡起来,名字一直没换,就叫 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 变化触发 analyze,analyze 完成后产出两套数据,一棵语法树和一组匹配结果,展示层只是纯渲染,这样就顺了很多。
这里有个经验可以分享:分析层和展示层一定要严格解耦。语法树是纯数据结构,不含任何渲染逻辑;展示层是纯渲染组件,不负责计算匹配。这样做的最大好处是可以对分析层做单元测试,渲染部分哪怕出问题也不影响核心正确性。
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 里把结构树看一遍,确认分组和量词挂载的位置跟预期一致,然后再搭配几个边界测试用例去跑。结构树比任何文档都更能暴露问题,因为它把写的时候没注意到的隐式优先级直接画出来了。早期没有这个工具时,我写复杂正则基本靠试错,现在算是“看着树写正则”,出错率降低了很多。如果哪天你的正则也开始让人头疼,不妨也试着自己做一个类似的工具,收获绝对不只是代码量这么简单。