PHP 语法进阶 19|正则表达式:从能用,到不被拖死
PCRE 的语法外壳
PHP 用的是 PCRE 库,写法比"纯正则"多一层定界符:
<?php
preg_match('/^\d{11}$/', '13800138000'); // / 是定界符
preg_match('#^/api/v1/#', '/api/v1/users'); // # 也行,路径多时更清爽
preg_match('~^a/b~', 'a/b'); // ~ 也行
preg_match('{^/api/}', '/api/v1'); // 花括号成对也行
定界符可以是任意非字母数字字符,且必须成对出现({ 对 }、( 对 ))。习惯上用 /,但正则里斜杠多的时候换个定界符能少写一堆 \/。
四个修饰符,必须会用
| 修饰符 | 作用 |
|---|---|
i |
大小写不敏感 |
m |
多行模式,^ $ 匹配每行的开头结尾 |
s |
单行模式,. 能匹配换行 |
x |
忽略模式里的空白和# 注释,可写多行 |
u |
按 UTF-8 字符处理(中文必加) |
<?php
preg_match('/hello/i', 'HeLLo'); // 1
// 跨行匹配(. 默认不匹配换行,s 打开)
preg_match('/start.*end/s', "start\nmiddle\nend"); // 1
// 中文:不加 u 会按字节处理,量词全乱
preg_match('/^.{3}$/u', '你好世'); // 1
preg_match('/^.{3}$/', '你好世'); // 0(9 个字节)
只要模式或输入里有中文 / emoji,就加 u。不加的话 \w 也只匹配 ASCII,中文匹配不到。
preg_match 的返回值
<?php
$r = preg_match('/\d+/', 'abc 123');
var_dump($r); // int(1) —— 匹配到了
$r = preg_match('/\d+/', 'abc');
var_dump($r); // int(0) —— 没匹配到
$r = preg_match('/\d+/', 'abc', $m);
print_r($m); // []
返回值三态:1 匹配、0 不匹配、false 出错。所以判断必须用 === 1 或 === false:
<?php
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) === 1) {
// 格式合法
}
$matches 的结构:
<?php
preg_match('/(\d{4})-(\d{2})-(\d{2})/', '2026-11-08', $m);
print_r($m);
// [0 => '2026-11-08', 1 => '2026', 2 => '11', 3 => '08']
$m[0] 是整体匹配,$m[1] 起是捕获组。这个偏移经常被搞混。
命名捕获组:可读性的关键
<?php
preg_match('/(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/', $d, $m);
// 或者用 (?P<year>...) 这种更通用的写法
echo $m['year']; // 同时也有 $m[1]
命名组的好处是"改顺序不怕乱"。用位置下标时,只要在正则中间加一个捕获组,后面所有 $m[2] $m[3] 全部错位——这是正则维护中最常见的低级 bug。
贪婪与惰性
<?php
$html = '<b>加粗</b><i>斜体</i>';
preg_match('/<b>(.*)<\/b>/', $html, $m); // 匹配整段(贪婪)
echo $m[1]; // 加粗</b><i>斜体 ← 吃多了
preg_match('/<b>(.*?)<\/b>/', $html, $m); // 惰性,最少匹配
echo $m[1]; // 加粗 ← 正确
*+{n,}默认贪婪,尽可能多吃。- 加
?变惰性,尽可能少吃。 *+++是占有量词,匹配后不回溯,性能最好但不允许回退。
<?php
// 占有量词:适合"确定不会回溯"的场景,能防回溯爆炸
preg_match('/"[^"]*+"/', '"abc"');
回溯灾难:一个能让服务器卡死的正则
<?php
$pattern = '/^(a+)+$/';
$input = str_repeat('a', 30) . 'b'; // 30 个 a 后面接个 b
preg_match($pattern, $input); // 极慢,甚至直接失败
原因:嵌套量词 (a+)+ 在失配时会产生指数级回溯,n 个字符就是 2^n 条路径。这就是 ReDoS(正则拒绝服务)。
防护三条:
- 不要写嵌套量词。
(a+)+改成a+。 - 用占有量词或原子组:
(?>a+)、a++,明确禁止回溯。 - 设上限:
<?php
ini_set('pcre.backtrack_limit', '100000');
ini_set('pcre.jit', '1'); // JIT 默认开,别关
$r = preg_match($pattern, $input);
if ($r === false) {
// 超过回溯上限也算 false
error_log("正则执行失败:" . preg_last_error_msg());
}
preg_last_error() 能区分具体原因:
<?php
const PCRE_ERROR_NAMES = [
PREG_NO_ERROR => 'NO_ERROR',
PREG_BACKTRACK_LIMIT_ERROR=> 'BACKTRACK_LIMIT',
PREG_RECURSION_LIMIT_ERROR=> 'RECURSION_LIMIT',
PREG_BAD_UTF8_ERROR => 'BAD_UTF8',
PREG_JIT_STACKLIMIT_ERROR => 'JIT_STACKLIMIT',
];
如果正则的输入来自用户,必须设 backtrack_limit,否则一个恶意字符串就能占满 CPU。
替换
<?php
// 简单替换
echo preg_replace('/\s+/', ' ', "a b c"); // a b c
// 用捕获组做重排($1 或 \1 都行,推荐 ${1} 避免歧义)
echo preg_replace('/(\d{4})-(\d{2})-(\d{2})/', '$3/$2/$1', '2026-11-08'); // 08/11/2026
// 需要复杂逻辑时用回调
$text = preg_replace_callback(
'/\{\{(\w+)\}\}/',
fn($m) => htmlspecialchars($vars[$m[1]] ?? '', ENT_QUOTES, 'UTF-8'),
"你好,{{name}}"
);
preg_replace_callback 是安全的模板替换方式,因为它能对每个片段单独做转义。用 preg_replace 直接把用户数据替换进去,容易造成二次注入。
替换的 $ 转义坑:
<?php
// 用户输入里带 $1,会被当成"第一个捕获组"
preg_replace('/x/', '$1', 'x'); // 结果不是 "$1"
替换串里的 $ 要转义成 \$(或用 preg_replace_callback 完全绕开这个问题)。
preg_split 与 preg_quote
<?php
print_r(preg_split('/[,;]+/', 'a,b;;c')); // ['a','b','c']
print_r(preg_split('/\s+/', 'a b c', -1, PREG_SPLIT_NO_EMPTY)); // ['a','b','c']
// 把字符串转成安全的字面量正则
$kw = preg_quote($userInput, '/');
preg_match("/{$kw}/", $haystack);
preg_quote() 必须用。任何"把用户输入拼进正则"的地方,不转义就等于让用户写正则——轻则匹配错误,重则 ReDoS。
常用模式的正确写法
手机号(中国大陆)
<?php
$p = '/^1[3-9]\d{9}$/';
不要用那些很长很复杂的"全量号段"正则,号段会变,维护成本高。这个宽松版够用。
邮箱
<?php
// 够用版(验证格式,不验证"真实存在")
$p = '/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/u';
RFC 5322 的完整邮箱正则有几千字符,且允许大量现实中不用的写法。实用的做法是"宽松校验 + 发送验证邮件",验证邮件能不能收到才是唯一标准。
URL
<?php
$p = '#^https?://[^\s/$.?#].[^\s]*$#i';
或者直接用 filter_var($url, FILTER_VALIDATE_URL),比自己写可靠。
日期
<?php
$p = '/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|1[0-2]|3[01])$/';
注意正则只能校验"格式",不能校验"日期存在"。2026-02-30 能过这个正则。真校验用 DateTime::createFromFormat + getLastErrors()(见第 17 篇)。
密码强度
<?php
$p = '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$/';
四个前瞻分别要求小写、大写、数字、符号。这类正则严格说不是"好方案"(无回溯问题但可读性差),更推荐逐条判断:
<?php
$ok = strlen($pw) >= 8
&& preg_match('/[a-z]/', $pw)
&& preg_match('/[A-Z]/', $pw)
&& preg_match('/\d/', $pw)
&& preg_match('/[^\w\s]/', $pw);
报错时还能分别告诉用户"缺哪一项"。
提取:一个爬虫式的例子
<?php
$html = '<a href="/p/1">标题一</a><a href="/p/2">标题二</a>';
preg_match_all('/<a\s+href="([^"]+)"[^>]*>([^<]+)<\/a>/u', $html, $m, PREG_SET_ORDER);
foreach ($m as $item) {
echo $item[1], ' => ', $item[2], PHP_EOL;
}
PREG_SET_ORDER 让结果按"每个匹配一组"排列,而不是"所有匹配的第一个捕获组排一列"。遍历时更自然。
不该用正则的地方
- 解析 HTML / XML:用 DOMDocument、SimpleXML。正则在嵌套标签、属性顺序、注释上全线崩。
- 解析 JSON:用
json_decode。 - 解析 CSV:用
fgetcsv(它处理引号内的逗号和换行)。 - 简单字符串查找:用
str_contains/strpos,比正则快一个数量级。 - 剥除标签:
strip_tags()而不是preg_replace('/<[^>]*>/', '')。
判断标准是:"这个语法有嵌套结构吗?" 有,就别用正则。
性能几条
<?php
// 1) 简单前缀判断用字符串函数,快得多
str_starts_with($s, 'http'); // 比 preg_match('/^http/') 快
str_contains($s, 'abc'); // 比 preg_match('/abc/') 快
// 2) 循环外编译?—— PHP 内部会缓存已编译的模式,
// 但模式字符串若每次都拼出来(带变量),缓存就失效
foreach ($rows as $r) {
preg_match("/^$prefix/", $r); // ✗ 每次都是新模式
}
$pattern = '/^' . preg_quote($prefix, '/') . '/';
foreach ($rows as $r) {
preg_match($pattern, $r); // ✓ 模式固定,能命中缓存
}
// 3) 能用锚点就加锚点,减少无谓尝试
'/^abc/'; // 比 'abc' 快
第 2 条是最容易被忽视的性能坑:循环里拼模式字符串,PCRE 的编译缓存完全失效,每次都要重新编译。
坑清单
- 不用
=== 1判断preg_match,false(出错)和0(不匹配)混为一谈。 $m[0]当第一个捕获组用。- 中文不加
u修饰符,量词按字节算。 .*贪婪匹配跨过边界,该用.*?。- 嵌套量词
(a+)+造成回溯爆炸。 - 用户输入直接拼进模式,不
preg_quote。 - 替换串里出现
$1,被当成捕获组。 - 用正则解析 HTML / JSON / CSV。
- 循环里拼模式字符串,PCRE 缓存失效。
- 不设
pcre.backtrack_limit,用户输入把 CPU 跑满。