在模板引擎、配置解析或者前端数据绑定的场景里,经常会遇到形如 {{username}} 这样的双大括号占位符。需求听起来很直接:把里面的内容取出来,同时去掉可能存在的首尾空白。但真正动手写正则的时候,你会发现事情没有想象中那么顺,尤其是在一段文本里连续出现多个占位符时,贪婪匹配会立刻暴露出问题。

基础写法:为什么贪婪匹配会翻车
先看一个最直觉的写法:/\{\{.*\}\}/。这里 .* 会从第一个左双大括号一直匹配到最后一个右双大括号,中间不管隔着多少组占位符都照单全收。假设原始文本是 {{ name }} 和 {{ age }},贪婪模式返回的结果会是 {{ name }} 和 {{ age }} 一整串,而不是两组独立的内容。原因在于点号默认不匹配换行,但完全匹配空格、右大括号这些普通字符,量词 * 又倾向于尽可能多地吞掉字符。
要解决这个问题,第一反应通常是改成懒惰匹配:/\{\{.*?\}\}/。加一个问号之后,匹配引擎会从第一个双大括号开始,每吃进一个字符就判断一下后面是否跟着 }}。一旦找到最近的一组右双大括号就立即停止。这样确实能把上面那个例子拆成 {{ name }} 和 {{ age }} 两组。但懒惰匹配也不是万能的,如果占位符内部本身含有右大括号字符,或者占位符跨了多行,单纯的点号加懒惰量词仍然会出错。
更稳妥的做法是使用排除性字符类。既然我们想要的是双大括号内部那段不包含右大括号的文本,就可以写成 /\{\{([^}]+)\}\}/。方括号里的 ^} 表示任意非右大括号字符,这样匹配引擎在进入捕获组后根本不允许跨越 },也就不存在贪婪或懒惰的选择问题了。这个写法比 .*? 更精确,性能上也有优势,因为不需要频繁回溯。
跨行匹配与首尾空白的处理
点号在正则表达式里的默认行为是不匹配换行符,这就导致 \{\{.*?\}\} 碰到下面这种多行占位符时直接匹配失败:
const tpl = '{{ \n username \n }}';
const re = /\{\{.*?\}\}/;
console.log(re.test(tpl)); // false
原因是 \n 不在点号的匹配范围内。解决方法有两种:一种是给正则加上 s 标志(JavaScript 里叫 dotAll),让点号也能匹配换行;另一种是干脆不用点号,改用 [\s\S]+? 或 [\w\W]+?。这类字符类的巧妙之处在于,它由一对互补的转义字符组成,合并起来刚好等价于任意字符,包括换行。写成 /\{\{([\s\S]+?)\}\}/ 之后,跨行匹配的问题就解决了。
接着看首尾空白。捕获到的内容可能是 \n username \n 这种前后带着多余空格和换行的文本。最直接的办法是在拿到捕获组之后调用语言内置的 trim() 方法(Python 里是 strip(),PHP 里是 trim())。如果想在正则内部一步完成,可以借助空白边界 \s* 来吸收首尾空白,同时把捕获组收紧为中间的有效内容:
const re = /\{\{\s*([\s\S]+?)\s*\}\}/g;
const tpl = '{{ \n username \n }} 和 {{ age }}';
const result = [...tpl.matchAll(re)].map(m => m[1].trim());
console.log(result); // ["username", "age"]
注意这里 \s* 本身就可以匹配换行,所以即使不用 s 标志也能跨行。捕获组里的 [\s\S]+? 保留懒惰特性,配合外层 \s* 的贪婪回溯,最终拿到的便是去掉首尾空白后的干净字符串。不过要注意,如果占位符内部本身就有多个连续空格想保留,\s* 只会把贴边的那部分空白吸收掉,中间的内容不受影响。
多语言实现与完整封装
Python 的场景稍有不同。Python 的 re 模块默认也是点号不匹配换行,需要传入 re.S 或 re.DOTALL 标志。下面是一个同时处理多组占位符的完整例子:
import re
tpl = "{{ \\n username \\n }} 和 {{ age }}"
# 写法一:正则提取后手动 strip
pattern = re.compile(r'\{\{\s*([\s\S]+?)\s*\}\}', re.S)
matches = [m.group(1).strip() for m in pattern.finditer(tpl)]
print(matches) # ['username', 'age']
# 写法二:利用 r 字符串
pattern2 = re.compile(r'\{\{([^{}]+)\}\}')
matches2 = [m.group(1).strip() for m in pattern2.finditer(tpl)]
print(matches2)
PHP 的实现思路一致,只是正则定界符和函数名不同。preg_match_all 配合 PREG_SET_ORDER 可以拿到每组完整匹配和子捕获组。如果占位符内容可能包含右大括号本身,排除性字符类就失效了,这种情况建议使用递归或手动解析,单纯正则很难完美解决。对于绝大多数正常的模板占位符场景,上面的排除性写法已经足够健壮。
最后再强调一个容易被忽视的细节:全局匹配时一定要加 g 或使用 finditer/preg_match_all,否则只能拿到第一组。另外,如果双大括号之间完全没有内容,比如 {{}},排除性字符类的 + 会拒绝匹配,此时可以改用 * 量词或者单独写一个空占位符的分支来兜底。把这些边界情况都覆盖到,正则提取双大括号内容的逻辑才算真正可靠。