正则表达式的麻烦,通常不在语法本身,而在“它会怎样失败”。
一个表达式可以顺利通过编译,也能匹配大多数样例;当它遇到一段特定的长文本时,却可能占满一个 CPU 核,页面无响应,接口超时。做正则测试器时,不能只实现 new RegExp() 和结果高亮,还得考虑如何让一次有问题的匹配不拖垮整个页面。
Regex Tester 的思路是把匹配任务放进独立 Web Worker,再配合输入、输出和时间限制。它使用浏览器原生 JavaScript RegExp 引擎,不模拟 Python、Java、PHP 或 PCRE 的行为。
一个“合法”的正则,为什么会卡住
许多常见正则引擎采用回溯算法。匹配失败时,引擎会回到前面的分支,尝试另一条路径。通常这没问题;问题出在一段文本可以被多种重复规则反复拆分时。
看一个典型例子:
/^(a+)+$/
它匹配连续的 a。如果输入是:
aaaaaaaaaaaaaaaaaaaa!
最后的 ! 会让整个匹配失败。此时引擎需要判断前面那一串 a 应该怎样分配给内外两层 +。长度增加后,可能的拆分组合会急剧膨胀,匹配耗时也随之失控。
这类问题通常被称为灾难性回溯,安全领域里也常归入 ReDoS(Regular Expression Denial of Service)。
高风险形状大多有两类:
嵌套重复: (a+)+、(.*)*、(\w*)*
重叠分支重复: (a|aa)+、(a|a?)+
并不是看到 + 或 * 就一定有风险。关键在于:重复的内部规则是否会匹配同一批字符,以及失败是否发生在表达式接近结束的位置。
例如下面这个版本更直接:
/^[a-z]+$/i
它没有嵌套量词,也不需要在多个拆分方案之间来回试探。
测试器为什么不能直接跑在主线程
最直接的实现通常是:
const regex = new RegExp(pattern, flags);
const match = regex.exec(text);
代码没有错,但如果它运行在浏览器主线程,一次灾难性回溯就会冻结输入框、按钮、渲染和错误提示。用户甚至无法点击“停止”。
更稳妥的做法是把正则执行放进 Web Worker:
主线程
├─ 接收模式、标志位和测试文本
├─ 创建 Worker
├─ 设置超时计时器
└─ 接收结果或终止 Worker
Worker
└─ new RegExp(pattern, flags) → exec(text)
Worker 和主线程不共享执行栈。即使 Worker 被一条复杂正则占住,主界面仍可以响应。超过时间阈值后,主线程调用 worker.terminate(),直接结束这次任务。
Regex Tester 将单次执行限制为 500ms。Worker 启动成功后才开始计时,避免把模块加载或浏览器调度误算成正则耗时。
这里有个边界必须说清:终止 Worker 能保护当前测试页面,不代表该表达式在服务端、Node.js、Java、Python 或 PCRE 环境里也安全。目标运行时仍要用真实数据测试。
限制不能只设“超时”
超时是最后一道保险,不是唯一手段。测试器还需要控制任务规模,否则即便每次匹配很快,海量结果和替换输出也可能拖慢页面。
这个工具的限制包括:
| 对象 | 限制 |
|---|---|
| 正则模式 | 最多 2,000 个字符 |
| 测试文本 | 最多 100,000 个字符 |
| 替换文本 | 最多 20,000 个字符 |
| 每类断言用例 | 最多 50 行 |
| 收集的匹配数 | 最多 1,000 个 |
| 替换结果 | 最多 250,000 个字符 |
| 单次 Worker 执行 | 500ms |
匹配数上限很有必要。像 /./g 这样的表达式,在长文本上可能产生大量结果。如果测试器把每个结果都渲染成卡片、标记和捕获组,正则虽然跑完了,DOM 渲染又会成为新的瓶颈。
对于零长度匹配,例如 /\b/g,循环也要额外推进 lastIndex。否则 exec() 会反复返回同一个位置,测试器自身就会陷入死循环。
静态风险提示能做什么,不能做什么
一个实用的正则测试器可以对常见危险结构给出提示,例如:
- 重复组内部又包含
+、*等重复; - 一个可重复分支中存在可能重叠的替代项;
- 全局匹配结果被截断;
- 替换结果超过输出大小限制。
但这些提示只能算启发式检查。
正则语法允许转义、字符类、反向引用、断言和嵌套分组。想靠几个字符串规则完整判断一个表达式是否存在 ReDoS 风险,结果要么误报很多,要么漏掉真正的问题。因此,页面上的“存在回溯风险”应被理解为复查提醒,而不是安全认证。
生产环境中,如果模式或输入来自不可信来源,常见处理方式有:
1. 限制输入长度;
2. 限制正则执行时间;
3. 将匹配放进独立进程、线程或 Worker;
4. 使用具备线性时间保证的 RE2 类引擎;
5. 对高风险场景保留固定表达式,而不是直接执行用户提交的模式。
如果业务必须支持用户自定义正则,尤其不能把它直接放进同步接口、消息消费线程或批处理主流程中执行。
JavaScript 引擎和其他引擎并不一样
“正则能用”必须带上运行时前提。
浏览器的 JavaScript RegExp 与 Python re、Java Pattern、PHP PCRE 看起来相似,但语法与行为并不完全一致。把一个在线测试器里的结果直接复制到后端,可能遇到以下问题:
| 差异点 | JavaScript 中需要注意的地方 |
|---|---|
| 标志位 | g、y、d、u、v 等标志并非所有引擎都有对应物 |
| Unicode 模式 | 工具中的 u 与 v 互斥,不能同时启用 |
| 命名捕获组 | JavaScript 使用 (?<name>...),替换时可写 $<name> |
| 替换语法 | JavaScript 支持 $$、$&、$1、$<name> 等令牌 |
| 特殊特性 | PCRE 的递归、控制动词等能力不能直接当作 JavaScript 语法使用 |
| 回溯表现 | 同一结构在不同引擎、不同版本和不同输入规模下,耗时可能不同 |
因此,测试器应明确标注自己的“方言”。Regex Tester 使用当前浏览器的原生 JavaScript 引擎,输入框里填写的是不带 /.../ 包裹符的模式,标志位单独选择。这样生成的代码可以直接对应:
const regex = new RegExp(pattern, flags);
这种边界说明比“支持所有正则”更可靠。
匹配、替换和断言应分开验证
只看“有没有匹配到”很容易漏问题。一个表达式可能匹配了目标文本,但范围过宽;替换表达式也可能因为捕获组编号变化而产生错误结果。
测试时建议保留两组固定样例:
应该匹配:
ERROR [api] Request timed out
WARN [cache] Miss rate is high
不应匹配:
DEBUG no bracketed service
plain text
每次调整表达式后,同时检查正例和反例。这样可以发现一个常见问题:为了修复漏匹配,表达式被改得太宽,结果把原本不该匹配的内容也吞进去了。
替换预览也应单独执行。对于命名捕获组:
/(?<level>ERROR|WARN)\s+\[(?<service>[^\]]+)\]\s+(?<message>.+)/g
可以使用:
[$<service>] $<level>: $<message>
生成结构化日志文本。没有 g 标志时,JavaScript 的 replace() 默认只替换第一个匹配项,这一点经常被忽略。
防卡死的实现要点
一个浏览器端正则测试器,至少应做到以下几点:
- 不在主线程直接执行不受控模式;
- 每次输入变化前取消上一轮仍在运行的 Worker;
- 对模式、文本、替换结果和匹配数设置独立上限;
- 对零长度全局匹配主动推进索引;
- 把语法错误、运行超时、输出超限分别显示;
- 明确标注正则方言,不承诺跨语言兼容;
- 将风险提示描述为“复查信号”,而不是“安全证明”。
正则表达式本身没有问题,问题在于把它当成一个总能快速结束的字符串工具。对普通文本,它确实很方便;当模式和输入都可控性不足时,它更像一段需要做资源隔离的程序。
Regex Tester 适合在浏览器里检查 JavaScript 正则的匹配范围、捕获组、替换结果和回归样例。真正上线前,还应在目标运行时使用接近生产的数据量做一次性能验证。