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(正则拒绝服务)。

防护三条:

  1. 不要写嵌套量词。(a+)+ 改成 a+。
  2. 用占有量词或原子组:(?>a+)、a++,明确禁止回溯。
  3. 设上限:
<?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. 不用 === 1 判断 preg_match,false(出错)和 0(不匹配)混为一谈。
  2. $m[0] 当第一个捕获组用。
  3. 中文不加 u 修饰符,量词按字节算。
  4. .* 贪婪匹配跨过边界,该用 .*?。
  5. 嵌套量词 (a+)+ 造成回溯爆炸。
  6. 用户输入直接拼进模式,不 preg_quote。
  7. 替换串里出现 $1,被当成捕获组。
  8. 用正则解析 HTML / JSON / CSV。
  9. 循环里拼模式字符串,PCRE 缓存失效。
  10. 不设 pcre.backtrack_limit,用户输入把 CPU 跑满。