受 Emacs rx 启发的可读正则表达式,适用于 JavaScript/TypeScript
摘要
本文介绍了一个受 Emacs 的 rx 宏启发的小型 JavaScript/TypeScript DSL,它让开发者以可读的、由命名形式构成的树状结构来编写正则表达式,而不是使用晦涩的字符串。文章讲解了其内部实现(原子、序列、备选、量词),并提供了速查表、对比示例以及包含完整源码的 Gist。
<p><a href="https://lobste.rs/s/3hdied/readable_regular_expressions_for">Comments</a></p>
查看缓存全文
缓存时间: 2026/10/02 08:34
# 面向 JavaScript/TypeScript 的可读正则表达式,灵感来自 Emacs 的 rx
来源:https://rahuljuliato.com/posts/emacs-rx-in-typescript
## 引言
https://rahuljuliato.com/posts/emacs-rx-in-typescript#intro
快说,这匹配的是什么?
那就是来自 [semver\.org](https://semver.org/) 的官方正则表达式。它用来校验形如这样的版本号:
先别误会,我热爱正则表达式,但在实际工作中,你多半会花不少时间编写一个正则、拿几个用例测试一下,然后心满意足地走开!过了一段时间,幸运的未来的你(或不幸的某位同事)就得去修改它了。此处停顿一下。我敢说你肯定经历过。
此时你的选择大概是:从头再解码一遍、整段重写,或者,在 AI 时代,向大语言模型讨要一个新的方案(并且希望能有判断力地审阅)。
Emacs 很早以前就为可读的正则表达式提供了一个漂亮的答案:`rx` 宏。我在 Emacs Lisp 里到处用它,因为评审的人总是向我推荐它。后来,我开始怀念 JavaScript 和 TypeScript 里也能有这样一门 DSL,于是为自己的项目写了一个精简版本。
那么,要是像下面代码里那样,把那个 SemVer 正则读成 `semver` 呢?同样的字符串照样能匹配,而且还额外获得了具名捕获组。读完本文,你就会明白它的每一个组成部分。
> **太长不看(TL;DR):**直接跳到 [速查表](https://rahuljuliato.com/posts/emacs-rx-in-typescript#cheat-sheet)、[对照示例](https://rahuljuliato.com/posts/emacs-rx-in-typescript#examples-js-ts-regex-vs-rx)、[完整源码](https://rahuljuliato.com/posts/emacs-rx-in-typescript#full-source),或者直接抓取 [gist](https://gist.github.com/LionyxML/b6c078a2d1b13cad42666db1773a9cec) 一览成品。
> **注意:**这里的 `RX` 与 [RxJS](https://rxjs.dev/) 毫无关系,后者是一个很棒的响应式编程库,基于 Observable 实现。
## 一窥 Emacs Lisp 中的 rx
https://rahuljuliato.com/posts/emacs-rx-in-typescript#a-taste-of-rx-in-emacs-lisp
使用 `rx` 时,你把正则表达式描述成一棵由具名形式(form)组成的树,Emacs 会为你生成对应的正则表达式字符串:
有几点值得注意:
1. **字符串就是字面量。**`"\("` 表示一个括号。你无需手动转义任何字符。
2. **序列是隐式的。**每个形式都接收一个列表,并按顺序依次匹配其中的元素。你不需要把它们包进 `seq` 里,尽管 `seq` 确实存在。
3. **分组只在必要时出现。**`\(\+ digit\)` 会变成 `\[\[:digit:\]\]\+`,而不是 `\\\(?:\[\[:digit:\]\]\\\)\+`。
本文提出的 JavaScript/TypeScript 版本读起来是这样的:
## 底层原理
https://rahuljuliato.com/posts/emacs-rx-in-typescript#under-the-hood
如果你希望字符串是字面量,就不能用普通的 `string` 来表示正则表达式的一段,否则你无法区分 `"\("`(字面括号)和 `"\(?:\.\.\.\)"`(你构建的分组)。因此,每一小段都是一个小对象:
`src` 是正则表达式文本。`kind` 记录了当这段文本与其他内容拼接时会怎样表现:
- `atom`:单个单元,例如 `a`、`\\d`、`\[a\-z\]` 或 `\(\.\.\.\)`。后面可以直接跟量化符。
- `seq`:可以安全地拼接,但若要加量化符,就需要在它外面包上 `(?:...)`。`abc` 是一个 `seq`,`a\+` 也是,因为 `a\+?` 会悄无声息地变成懒惰量化。
- `alt`:顶层包含一个 `|`,因此几乎任何位置都需要 `(?:...)` 包裹。
普通字符串会经过 `literal` 处理,为其转义:
有了这些之后,`seq` 负责把各节点连接起来,只有遇到 `alt` 时才会加括号:
(那个反向引用的检查是只有写测试才能发现的 bug 之一,或者它会恰好在你的生产环境里发生。`backref(1)` 后面跟着字面量 `"0"`,会给你一个编号为十的反向引用。)
每一个量化符都是由它的参数加上后缀构成的一个 `seq`,只有当主体不是 `atom` 时才加括号:
由于每个量化符都会对自己的参数调用 `seq`,你免费获得了隐式序列:`optional("-", group(x))` 会变成 `(?:\-(x))?`。
最后是两个入口函数。与 Emacs 一致,`rx` 返回一个字符串。`RX` 返回一个可以直接使用的 `RegExp`:
`RX.flags` 之所以存在,是因为 Emacs 通过 `case-fold-search` 变量控制大小写折叠,而 JavaScript 把这个开关放在正则表达式对象上。
以上就是全部引擎了!现在,让我们来构建词汇表。
## 字符集
https://rahuljuliato.com/posts/emacs-rx-in-typescript#character-sets
在 Emacs 中你写 `(any "a\-z" "\_")`。在这些字符串内部,`a\-z` 表示一个范围,而位于两端的 `\-` 表示一个普通的连字符。我保留了同样的规则:
连字符会被转义输出,因为字符集之间可以合并。如果你把 `anyOf("\+\-")` 和 `anyOf("0\-9")` 组合起来,一个未转义的 `\-` 会落在中间,从而生成一个从 `\+` 到 `0` 的范围。转义它只是多一个反斜杠而已。
而合并正是 `RxNode` 有一个 `set` 字段的原因。它的作用是存放 `\[\]` 之间的文本,这样 `anyOf` 就可以把其他字符集作为参数接收:
`not` 用于取反一个字符集,它也认识各种简写类:
那个简单的邮箱校验——我们大多数人都曾把它写成 `/^\[^\\s@\]\+@\[^\\s@\]\+\\\.\[^\\s@\]\+$/`——现在变成了:
Emacs 的其余字符类也都在这里:`digit`、`hexDigit`、`space`、`blank`、`wordChar`、`notWordChar`、`alpha`、`alnum`、`lower`、`upper`、`punct`、`control`、`graphic`、`printing`、`ascii` 和 `nonascii`。
有一点不同:在 Emacs 里它们能理解 Unicode,而我的版本只支持 ASCII。`alpha` 不会匹配 `é`。
还有两个来自 rx 的符号列表,人们(包括我,不止一次)常常把它们搞混:
在 rx 中,`anything` 真正意味着任何字符,包括换行符。下面这里就体现出了区别:
## 交替选择,以及最长匹配
https://rahuljuliato.com/posts/emacs-rx-in-typescript#alternatives-and-the-longest-match
`or` 的行为符合你的预期,并且当它出现在序列内部时会被加上括号:
你注意到顺序变了吗?我从 Emacs 复制了这个行为。当 `or` 的每个分支都是普通字符串时,rx 会把它们交给 `regexp-opt`,由后者构建一个优先匹配最长结果的模式。
JavaScript 的交替选择按从左到右的顺序,匹配第一个成功的分支。因此,一个关键词列表的朴素正则表达式有个「bug」:
我并没有像 `regexp-opt` 那样构建一个字典树(trie)。按长度从长到短对字符串排序,就足以获得同样的行为:
与 Emacs 一致,没有分支的 `or()` 会返回 `unmatchable`,在这里即 `(?\!)`。当你在运行时构建分支列表、并且它有可能为空时,这很方便。
## 重复:贪婪与懒惰
https://rahuljuliato.com/posts/emacs-rx-in-typescript#repetition-greedy-and-lazy
Emacs 有 `\(= n \.\.\.\)`、`\(\>= n \.\.\.\)` 和 `\(\*\* n m \.\.\.\)`。在这里它们分别是 `repeat`、`atLeast` 和 `between`:
懒惰版本的 `*?`、`+?` 和 `??` 对应 `zeroOrMoreLazy`、`oneOrMoreLazy` 和 `optionalLazy`。
经典的 HTML 标签示例:
## 分组与反向引用
https://rahuljuliato.com/posts/emacs-rx-in-typescript#groups-and-backreferences
`group` 是一个捕获组,`backref` 用来回指它:
Emacs 还有 `\(group\-n N \.\.\.\)` 用于指定分组编号。JavaScript 做不到这一点,但它有具名捕获组,作用相同,而且读起来更清楚:
`backref` 也接受名称作为参数:
## 锚点
https://rahuljuliato.com/posts/emacs-rx-in-typescript#anchors
`rx` 区分字符串开头(`bos`)和行首(`bol`)。在 JavaScript 中两者都是 `^`,由 `m` 标志决定你得到哪一个。我保留了这两个名字,这样代码里的意图一目了然:
为什么不自动加上 `start` 和 `end`?因为只有在校验整个字符串时你才需要它们。在文本内部进行搜索时——比如 `split`、`replace` 或 `matchAll`——一个隐藏的 `^` 和 `$` 会把一切都搞砸。Emacs 也是这么认为的:`bos` 和 `eos` 在 `rx` 中同样是显式的。
`wordBoundary` 和 `notWordBoundary` 直接映射到 `\\b` 和 `\\B`。Emacs 还有 `bow` 和 `eow`(`\\<` 和 `\\\>`),即单词的开头和结尾。JavaScript 没有这些,所以我把 `\\b` 和一个前后向结合起来:
## 字面量与原始内容
https://rahuljuliato.com/posts/emacs-rx-in-typescript#literal-and-raw
普通字符串本身就是字面量,但 `rx` 为那些运行时计算出来的字符串提供了显式的 `literal` 形式,我也保留了它。它表明这个值来自别处:
反方向是 `rx` 的 `(regexp \.\.\.\)` 形式,也就是逃生通道。在这里它是 `raw`,接受一个字符串或一个已有的 `RegExp`。这让你可以在一个充满旧正则表达式的代码库中逐步采用这门 DSL,而不必重写全部正则,比如:
`raw` 看不到它接收的文本内部是什么,因此当它与其他内容组合时会加上括号。多一个 `(?:)` 而已,正则表达式照样工作。
## 再来试试 SemVer?
https://rahuljuliato.com/posts/emacs-rx-in-typescript#shall-we-try-semver-again
回到引言里的那个正则表达式。在 Emacs 中,你会用 `rx\-define` 或 `rx\-let` 给各个片段命名。在 TypeScript 中,这只需要几个 `const`:
现在你可以在代码中读懂这个规范了。数字标识符是 `0`,或者是一个非零数字后跟任意多个数字。预发布版本是一个由点分隔的标识符列表,构建元数据也是。`dotted` 是一个返回节点的普通函数——这里的抽象做到这一步就够了。它匹配的字符串与官方正则表达式完全一致,而具名捕获组会给你这样的结果:
下次规范更新时,你可以一眼看懂当前的正则表达式在做什么,而不用去和一大堆标点符号搏斗。
## Emacs rx 中缺少了什么
https://rahuljuliato.com/posts/emacs-rx-in-typescript#what-s-missing-from-emacs-rx
我尝试映射每一个 `rx` 形式,有几个在 JavaScript 中没有对应物:
- `point`:JavaScript 正则表达式不认识光标。
- `symbol\-start`、`symbol\-end`、`syntax`、`category`:这些依赖于 Emacs 的语法表。
- `intersection`:用 `v` 标志可以实现,但我还没有需要过。
- `minimal\-match`/`maximal\-match`:它们会翻转内部所有内容的贪婪性。可以实现,但需要单独遍历一遍,而 `*Lazy` 系列函数已经覆盖了我的用例。
- `eval`:TypeScript 本来就在任何地方都能求值表达式,所以这个是白送的。
还有一个 Emacs 不需要的补充:`RX.flags`。
## 速查表
https://rahuljuliato.com/posts/emacs-rx-in-typescript#cheat-sheet
| Emacs rx | TypeScript | JS 正则(大致) |
| --- | --- | --- |
| `seq`、`,`、`and` | `seq(\.\.\.\)`,隐含在每个形式中 | `ab` |
| `or`、`\|` | `or(\.\.\.\)` | `a\|b` |
| `any`、`in`、`char` | `anyOf("a\-z", "\_", digit)` | `\[a\-z\_\d\]` |
| `not\-char` | `notChar(\.\.\.\)` | `\[^\.\.\.\]` |
| `not` | `not(charset)` | `\D`、`[^\.\.\.]` |
| `\*`、`\+`、`?` | `zeroOrMore`、`oneOrMore`、`optional` | `x\*`、`x\+`、`x?` |
| `\*?`、`\+?`、`??` | `zeroOrMoreLazy`、`oneOrMoreLazy`、`optionalLazy` | `x\*?`、`x\+?`、`x??` |
| `=`、`\>=`、`\*\*` | `repeat`、`atLeast`、`between` | `x\{n\}`、`x\{n,\}`、`x\{n,m\}` |
| `group` | `group(\.\.\.\)` | `\(\.\.\.\)` |
| `group\-n` | `named("name", \.\.\.\)` | `\(?\.\.\.\)` |
| `backref` | `backref(1)`、`backref("name")` | `\\1`、`\\k` |
| `literal` | `literal(s)` | `s`,转义后:`1\\\+1` |
| `regexp`、`regex` | `raw("\.\.\.")`、`raw(/\.\.\./)` | `\(?:\.\.\.\)`,原样保留 |
| `rx\-define`、`rx\-let` | `const` | (无) |
| `bos`、`eos` | `start`、`end` | `^`、`$` |
| `bol`、`eol` | `lineStart`、`lineEnd`(配合 `m` 标志) | `^`、`$` |
| `bow`、`eow` | `wordStart`、`wordEnd` | `\\b\(?=\\w\)`、`\\b\(?<=\\w\)` |
| `word\-boundary` | `wordBoundary` | `\\b` |
| `not\-word\-boundary` | `notWordBoundary` | `\\B` |
| `nonl`、`not\-newline` | `notNewline` | `.` |
| `anychar`、`anything` | `anything` | `\[\\s\\S\]` |
| `unmatchable` | `unmatchable` | `\(?\!\)` |
| `digit` | `digit` | `\\d` |
| `hex\-digit`、`xdigit` | `hexDigit` | `\[0\-9a\-fA\-F\]` |
| `space`、`whitespace` | `space` | `\\s` |
| `blank` | `blank` | `\[ \\t\]` |
| `word`、`wordchar` | `wordChar` | `\\w` |
| `not\-wordchar` | `notWordChar` | `\\W` |
| `alpha`、`letter` | `alpha` | `\[a\-zA\-Z\]` |
| `alnum` | `alnum` | `\[a\-zA\-Z0\-9\]` |
| `lower`、`upper` | `lower`、`upper` | `\[a\-z\]`、`\[A\-Z\]` |
| `punct`、`punctuation` | `punct` | `\[\!\-/:\-@\[\-\`\{\-~\]` |
| `cntrl`、`control` | `control` | `\[\\x00\-\\x1f\\x7f\]` |
| `graph`、`graphic` | `graphic` | `\[\!\-~\]` |
| `print`、`printing` | `printing` | `\[ \-~\]` |
| `ascii`、`nonascii` | `ascii`、`nonascii` | `\[\\x00\-\\x7f\]`、`\[\\u0080\-\\uffff\]` |
## 示例:JS/TS 正则与 RX 对照
https://rahuljuliato.com/posts/emacs-rx-in-typescript#examples-js-ts-regex-vs-rx
下面每个示例都会展示目标、注释中的 Emacs `rx` 形式、手写的正则表达式,以及 `RX` 版本。当 `RX` 生成的正则表达式文本不同时,`// =>` 那一行会显示出来。底部的结果来自将两种写法对相同字符串运行后得到的输出。
### 仅数字
https://rahuljuliato.com/posts/emacs-rx-in-typescript#digits-only
整个字符串都是数字。
### 仅字母
https://rahuljuliato.com/posts/emacs-rx-in-typescript#letters-only
整个字符串都是 ASCII 字母。
### 可选字母
https://rahuljuliato.com/posts/emacs-rx-in-typescript#optional-letter
两种拼写:color 和 colour。
### 两个词
https://rahuljuliato.com/posts/emacs-rx-in-typescript#two-words
由一个空格分隔的两个词。
### 电话号码
https://rahuljuliato.com/posts/emacs-rx-in-typescript#phone-number
\(123\) 456\-7890,连括号都算上。
### 十六进制颜色
https://rahuljuliato.com/posts/emacs-rx-in-typescript#hex-color
`\#ff00aa` 风格的颜色。
### 带符号整数
https://rahuljuliato.com/posts/emacs-rx-in-typescript#signed-integer
一个可选的符号,然后是数字。
### 简单邮箱
https://rahuljuliato.com/posts/emacs-rx-in-typescript#simple-email
[Something@something\.something](mailto:[email protected]),不含空格。
### 不含数字
https://rahuljuliato.com/posts/emacs-rx-in-typescript#no-digits
一个不含任何数字的字符串。
### CSV 行
https://rahuljuliato.com/posts/emacs-rx-in-typescript#csv-line
恰好三个以逗号分隔的字段。
### 多选一
https://rahuljuliato.com/posts/emacs-rx-in-typescript#one-of-many
一个固定的词列表。
### 称谓与姓名
https://rahuljuliato.com/posts/emacs-rx-in-typescript#title-and-name
`mr` 或 `ms`,然后是一个名字,保留称谓。
### 区间
https://rahuljuliato.com/posts/emacs-rx-in-typescript#between
二到四位数字。
### 至少
https://rahuljuliato.com/posts/emacs-rx-in-typescript#at-least
三位或以上数字,出现在任意位置。
### 重复的词
https://rahuljuliato.com/posts/emacs-rx-in-typescript#repeated-word
同一个词出现两次。
### 匹配标签
https://rahuljuliato.com/posts/emacs-rx-in-typescript#matching-tags
一个开标签和它自己的闭合标签。
### 整词匹配
https://rahuljuliato.com/posts/emacs-rx-in-typescript#whole-word
`cat` 作为独立的单词,不嵌在别的词里面。
### 忽略大小写
https://rahuljuliato.com/posts/emacs-rx-in-typescript#case-insensitive
`hello`,任何大小写形式都行。
## 完整源码
https://rahuljuliato.com/posts/emacs-rx-in-typescript#full-source
这是一个单文件、无依赖的实现。把它复制进你的项目,开始删掉不需要的形式,或者补上缺少的那些。你可以在 [这个 gist](https://gist.github.com/LionyxML/b6c078a2d1b13cad42666db1773a9cec) 中查看同样的代码,以及本文中的所有示例(还有更多)。
如果你不想搭建任何环境,可以把它粘贴到 [TypeScript Playground](https://www.typescriptlang.org/play),点击「Run」,然后查看「Logs」标签页。
## 结语
https://rahuljuliato.com/posts/emacs-rx-in-typescript#wrapping-up
这些都不是新东西。在 Emacs 这边,正如我之前所说,`rx` 已经问世几十年了,而 Elisp 版本比我的实现更完整。用一门小型 DSL 而非原始语法来描述模式,这个想法也并不新鲜。很多人都尝试过,各有各的方式。
在这个领域里我非常喜欢的一个项目是 [Zod](https://zod.dev/),我在 [Zod 快速教程](https://rahuljuliato.com/posts/zod-tutorial)中介绍过它。它不是一个正则表达式构建器:你组合的是一些小型的 schema 片段,而 Zod 会从同一份构建结果中为你返回一个解析器和一个 TypeScript 类型。不过它遵循着同样的精神:用你能读懂的、带名字的小片段,搭建出庞大的东西。
如果你写 Elisp 但从未试过 `rx`,打开 `\*scratch\*`,输入 `(rx (+ digit))`,然后按 `C\-x C\-e` 试试。如果你写 JavaScript 或 TypeScript,上面那个文件就是你的了。
而如果你把它移植到别的语言,记得把链接发给我。
相似文章
可在‘各处’工作的正则表达式
本文讨论了正则表达式在sed、awk、grep和Emacs等工具之间移植的挑战,并提供了一组在这些环境中可靠工作的正则表达式子集。
@TrisH0x2A: Rob Pike 用大约30行C代码写了一个完整的正则表达式匹配器,它支持 ^、.、* 和 $,仅使用递归……
一条推特重点介绍了 Rob Pike 经典的30行 C 语言正则表达式匹配器,展示了递归和指针算术,作为正则表达式引擎的入门介绍。
ReSyn: 一种广义的递归正则表达式合成框架
ReSyn是一个广义的递归框架,用于从示例中合成正则表达式,旨在改进现有的合成技术。
解析表达式语法与正则表达式对比:在Lisp中构建导出HTML(通过SXML)的Org解析器
该博文介绍了构建OrgWebAlchemy的过程,这是一个采用解析表达式语法(Parsing Expression Grammars)的Guile Scheme库,用于解析Org mode文档并通过SXML转换为HTML,同时对比了在此任务中PEGs与正则表达式的优劣。
正则表达式的真正威力(2012)
这篇文章解释了像 PCRE 这样的现代正则表达式引擎能够解析远超正则语言的内容,驳斥了“HTML 无法用正则表达式解析”这一常见说法。