指南内含 1 场可玩狩猎

JavaScript 与 Python 正则表达式:那些会坑你的差异

同一个正则在 JavaScript 和 Python 中的行为可能不同。这些差异会引发真实的 bug:re.match 与 test、$ 与末尾换行、Unicode 的 \d、findall 与分组、replace 与 sub、后行断言的宽度、命名组语法以及内联修饰符。每一条结论都在 Node 23 和 Python 3.12 中验证过。

Regex Hunter 标志

· 阅读约 3 分钟

/(?<=\$)\d+(\.\d\d)?/

抓住

  • $5
  • cost $12.50
  • $0.99
  • pay $100

放过

  • 5
  • 5$
  • USD 12
  • $.50

JavaScript 和 Python 的正则语法大部分是相同的:字符类、量词、分组、锚点、环视。正因如此,差异才危险。一个正则从一边复制到另一边,通常都能编译通过,然后行为却有细微的不同。

下面是会引发真实 bug 的差异,大致按踩坑频率排序。下文的每一种行为都在 Node 23 和 Python 3.12 中验证过。本页的可交互示例在你的浏览器里运行,所以它们展示的是 JavaScript 的行为。

01re.match 只看开头

JavaScript 的 test() 和 match() 会搜索整个字符串。Python 有三个函数,其中名叫 match 的那个只在开头匹配:

import re

re.match(r"\d", "a1")      # None: the string doesn't start with a digit
re.search(r"\d", "a1")     # matches "1"
re.fullmatch(r"\d", "1")   # the whole string must match
/\d/.test("a1");  // true: test() searches

所以 JavaScript 的 test() 相当于 Python 的 search()。而在 Python 中做校验时,re.fullmatch 通常才是你想要的:不会忘记加锚点。

02$ 与末尾换行

在 Python 中,$ 既匹配字符串的最末尾,也匹配最后一个换行符之前的位置。JavaScript 的 $(不加 m 修饰符时)只匹配最末尾:

re.match(r"^\d{5}$", "90210\n")   # matches in Python
/^\d{5}$/.test("90210\n");        // false in JavaScript

在 Python 中,用 re.fullmatch 或 \Z 就能堵上这个漏洞。关于锚点与多行模式的文章对此有详细介绍。

03Python 的 \d 和 \w 支持 Unicode

在 JavaScript 中,\d 永远等于 [0-9],即使加了 u 修饰符也一样。在 Python 3 中,对于 str 类型的正则,\d 匹配任何 Unicode 数字,\w 匹配任何文字系统中的字母:

re.fullmatch(r"\d+", "٣٤٥")            # matches in Python (Arabic-Indic digits)
re.fullmatch(r"\d+", "٣٤٥", re.ASCII)  # None
/^\d+$/.test("٣٤٥");   // false
/^\d+$/u.test("٣٤٥");  // still false

如果这些数字要交给 int() 或存进数据库,在 Python 中请用 [0-9] 或 re.ASCII。

04findall 返回的是分组,而不是匹配

如果正则里有捕获组,Python 的 findall 返回的是分组,而不是完整的匹配:

re.findall(r"\w=\d", "a=1 b=2")      # ['a=1', 'b=2']
re.findall(r"(\w)=(\d)", "a=1 b=2")  # [('a', '1'), ('b', '2')]

JavaScript 的 match 加上 g 修饰符时,无论正则里有什么分组,总是返回完整的匹配:

"a=1 b=2".match(/(\w)=(\d)/g);  // ["a=1", "b=2"]

在 Python 正则里加一个“只为分组”的括号,会悄悄改变 findall 的返回值。请使用非捕获组 (?:…),或者用 finditer 配合 m.group(0)。

05replace 只替换一次,sub 全部替换

JavaScript 的 replace 只替换第一个匹配,除非正则带有 g。Python 的 re.sub 会替换所有匹配,除非你传入 count:

"a-b-c".replace("-", "+");      // "a+b-c"
"a-b-c".replace(/-/g, "+");     // "a+b+c"
"a-b-c".replaceAll("-", "+");   // "a+b+c"
re.sub(r"-", "+", "a-b-c")           # "a+b+c"
re.sub(r"-", "+", "a-b-c", count=1)  # "a+b-c"

替换字符串的语法也不同:JavaScript 用 $1 和 $<name>,Python 用 \1、\g<1> 和 \g<name>。

06后行断言的宽度

JavaScript 允许任意后行断言,包括长度可变的:

匹配4/4 符合预期
/(?<=\$)\d+(\.\d\d)?/

应当匹配2/2

  • $5
  • cost $12.50

不应匹配2/2

  • 5
  • USD 12
在沙盒里试试
像 (?<=\$) 这样固定宽度的后行断言,在两种语言中都能用。
匹配3/3 符合预期
/(?<=USD ?)\d+/

应当匹配2/2

  • USD12
  • USD 12

不应匹配1/1

  • EUR 12
在沙盒里试试
可选的空格让这个后行断言变成了可变宽度。JavaScript 接受它。

Python 的 re 会拒绝第二个正则,报错 look-behind requires fixed-width pattern。可以把它改写成几个固定宽度的分支 (?:(?<=USD)|(?<=USD ))\d+,或者使用第三方的 regex 模块。

07命名组的写法不同

JavaScript Python
定义 (?<year>\d{4}) (?P<year>\d{4})
反向引用 \k<year> (?P=year)
在替换中使用 $<year> \g<year>
读取 m.groups.year m.group("year")

Python 3.12 的 re 会拒绝 JavaScript 的写法,报错 unknown extension ?<y。带命名组的正则不改动就无法在两者之间互通。

08内联修饰符

Python 接受写在正则内部开头的修饰符:(?i)abc。全局修饰符放在其他任何位置都会报错。两种语言都支持作用于分组的局部修饰符 (?i:a)b,它只让 a 忽略大小写。这在 JavaScript 中是新特性(ES2025,Node 23 以及当前版本的 Chrome 和 Firefox 都已支持),所以请确认你的目标环境。JavaScript 完全不支持开头的 (?i);修饰符要写在斜杠后面。

09只有一方才有的功能

  • 仅 Python: 原子组 (?>…) 和占有量词 a++(3.11+)、\A 和 \Z、条件分组 (?(1)…)。
  • 仅 JavaScript: 配合 u 修饰符使用的 Unicode 属性转义 \p{L}(Python 需要 regex 模块)、粘性修饰符 y,以及用 d 获取匹配索引。

10字符串中的转义

Python 的正则写在字符串里,所以要写成原始字符串 r"\d+",否则每个反斜杠都要写两遍。JavaScript 的正则字面量 /\d+/ 不需要额外转义。但 new RegExp("\\d+") 是从字符串构建的,和非原始的 Python 字符串一样,需要把反斜杠写两遍。

11试一试

可交互棋盘运行的是 JavaScript,所以这里可以用 (?<=\$)\d 这样的后行断言。不过标准杆要求更短:

可玩狩猎标准杆 4

抓住 $ 符号后面紧跟数字的行。

目标 · 抓住 0/4

  • $5目标
  • cost $12.50目标
  • pay $100 now目标
  • $0目标

无辜词 · 误抓 0

  • 5无辜
  • 5$无辜
  • USD 12无辜
  • $ 5无辜
  • $.50无辜

输入正则——棋盘会实时更新。

在沙盒里试试
喜欢吗?每天都有新的狩猎。玩今天的狩猎

12检查清单

  • JS 的 test() 会搜索。Python 的 re.match 只检查开头。请用 re.search 或 re.fullmatch。
  • Python 的 $ 允许末尾有一个换行。请用 re.fullmatch 或 \Z。
  • Python 的 \d 和 \w 支持 Unicode。请用 [0-9] 或 re.ASCII。
  • 当正则中有分组时,Python 的 findall 返回的是分组。
  • JS 的 replace 只改第一个匹配,除非使用 g 或 replaceAll。Python 的 sub 全部替换。
  • Python 要求后行断言是固定宽度的。
  • 命名组:JS 中是 (?<n>…),Python 中是 (?P<n>…)。

13继续练习