受 Emacs rx 启发的可读正则表达式,适用于 JavaScript/TypeScript

Lobsters Hottest 工具

摘要

本文介绍了一个受 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,上面那个文件就是你的了。 而如果你把它移植到别的语言,记得把链接发给我。

相似文章

可在‘各处’工作的正则表达式

Hacker News Top

本文讨论了正则表达式在sed、awk、grep和Emacs等工具之间移植的挑战,并提供了一组在这些环境中可靠工作的正则表达式子集。

正则表达式的真正威力(2012)

Hacker News Top

这篇文章解释了像 PCRE 这样的现代正则表达式引擎能够解析远超正则语言的内容,驳斥了“HTML 无法用正则表达式解析”这一常见说法。