前言
先说清楚版本问题:标题里的「PHP 8.1」和这个问题的成因没有直接关系。字符串截取乱码是字符编码问题,substr()按字节切、mb_substr()按字符切这件事,从 mbstring 扩展存在的第一天起就是如此,PHP 8.1 并没有为字符串截取引入任何新函数。本文按"与版本无关、函数引入版本逐个标注"的方式写,PHP 7.4 / 8.x 全部适用。
典型的翻车现场是这样的:列表页要显示中文标题的前 20 个字符,开发者写了substr($title, 0, 20),本地用英文标题测试一切正常,上线后遇到中文标题,页面上多出一个黑底问号�;更隐蔽的是接口场景——json_encode()遇到非法 UTF-8 字节会直接返回false,整个响应体变成空字符串。
根因在 UTF-8 的编码结构上。UTF-8 是变长编码:ASCII 字符占 1 字节,汉字占 3 字节,emoji 占 4 字节。substr()的参数单位是字节,它不理解"一个字符由多个字节组成",从中间切开就会留下一个孤立的字节碎片,而后续所有环节都会在这个碎片上出问题。
本文按"定位问题、选函数、处理边界"三步讲,覆盖按字数、按字节、按显示宽度、HTML 安全截取和 emoji 处理。
一、为什么会乱码:字节切与字符切
1.1 两种切法对比
<?php // substr-vs-mb.php —— PHP 7.4+(所有示例均适用) declare(strict_types=1); $text = '中文测试'; $byBytes = substr($text, 0, 4); // 按字节切 4 个 $byChars = mb_substr($text, 0, 2, 'UTF-8'); // 按字符切 2 个 printf("substr : %s | hex=%s | strlen=%d\n", $byBytes, bin2hex($byBytes), strlen($byBytes)); printf("mb_substr : %s | hex=%s | strlen=%d\n", $byChars, bin2hex($byChars), strlen($byChars)); printf("mb_strlen : %d\n", mb_strlen($text, 'UTF-8')); printf("strlen : %d\n", strlen($text)); printf("json_encode(substr) 结果: %s\n", var_export(json_encode($byBytes, JSON_UNESCAPED_UNICODE), true)); printf("json_encode(mb_substr) 结果: %s\n", var_export(json_encode($byChars, JSON_UNESCAPED_UNICODE), true));输出:
substr : 中 | hex=e4b8ade6 | strlen=4 mb_substr : 中文 | hex=e4b8ade69687 | strlen=6 mb_strlen : 4 strlen : 12 json_encode(substr) 结果: false json_encode(mb_substr) 结果: '"中文"'对照着看就很清楚了:
substr($text, 0, 4)拿到 4 个字节e4 b8 ad e6。前 3 字节构成"中",第 4 字节e6是"文"的首字节,单独出现时是非法序列,终端只能显示成�。mb_substr($text, 0, 2, 'UTF-8')拿到完整的"中文",共 6 字节,每一段都是合法 UTF-8。json_encode()对非法 UTF-8 直接返回false,不抛异常也不返回空串——这是接口突然"返回空白"却看不到报错的最常见原因。
1.2 函数选择速查
不同的业务约束要用不同的函数,选错的后果和用substr()一样严重:
| 函数 | 计量单位 | 适用场景 | 引入版本 |
|---|---|---|---|
substr() | 字节 | 纯 ASCII、二进制数据 | PHP 4 |
mb_substr() | 字符(码点) | 按字数截取显示文本 | mbstring 扩展 |
mb_strcut() | 字节,但绝不切断字符 | 数据库列有字节长度上限 | mbstring 扩展 |
mb_strimwidth() | 半角宽度(全角算 2) | 表格对齐、固定宽度展示 | mbstring 扩展 |
mb_str_split() | 字符 | 拆成单字数组逐字处理 | PHP 7.4 |
grapheme_substr() | 字素簇(grapheme cluster) | emoji、带组合符号的文本 | intl 扩展 |
最容易被忽略的是mb_strcut()。它的参数和mb_substr()长得一样,但第二个参数是字节数。它从前往后累加字节,一旦加上下一个字符会超限就停住,因此结果永远不超过指定字节数,也永远不会切断字符。处理VARCHAR(30)这种按字节计数的列时,它是唯一正确的工具。
1.3 让编码参数不再靠猜
mb_*系列函数的最后一个参数是编码。不传时 PHP 会去读 ini 里的默认编码(default_charset从 PHP 5.6 起默认就是UTF-8)。
依赖默认值最大的风险是环境不一致:CLI 的default_charset是 UTF-8,但某个老项目的php.ini里被改成了GBK,同一份代码在两台机器上表现不同。稳妥做法是在应用入口处统一声明:
<?php // bootstrap.php —— 在框架引导文件或公共入口的最前面调用 declare(strict_types=1); mb_internal_encoding('UTF-8'); mb_regex_encoding('UTF-8');更保险的做法是每个mb_*调用都显式写出'UTF-8'。多敲几个字,换来的是不依赖任何 ini 配置的确定性。
二、实战:五种截取场景
2.1 按字数截取并加省略号
最基础的需求:标题最多显示 20 个字,超出就加省略号。注意省略号本身也占字数,要在预算里扣掉:
<?php // truncate-by-chars.php —— PHP 7.4+ declare(strict_types=1); mb_internal_encoding('UTF-8'); function truncateByChars(string $text, int $maxChars, string $ellipsis = '…'): string { // 没超出预算时不要加省略号,这个判断必须放在函数内部 if ($maxChars <= 0) { return ''; } if (mb_strlen($text, 'UTF-8') <= $maxChars) { return $text; } $ellipsisLen = mb_strlen($ellipsis, 'UTF-8'); if ($ellipsisLen >= $maxChars) { return mb_substr($text, 0, $maxChars, 'UTF-8'); } return mb_substr($text, 0, $maxChars - $ellipsisLen, 'UTF-8') . $ellipsis; } $title = 'PHP 多字节字符串处理的常见误区与最佳实践'; // 共 22 个字符 foreach ([6, 10, 30] as $n) { $out = truncateByChars($title, $n); printf("%2d 字 -> %s (实际 %d 字)\n", $n, $out, mb_strlen($out, 'UTF-8')); }输出:
6 字 -> PHP 多… (实际 6 字) 10 字 -> PHP 多字节字符… (实际 10 字) 30 字 -> PHP 多字节字符串处理的常见误区与最佳实践 (实际 22 字)2.2 按字节截取并保证不切断字符
当目标是一个有字节长度上限的数据库列或者定长协议字段时,约束是字节数而不是字数。这时用mb_strcut():
<?php // truncate-by-bytes.php —— PHP 7.4+ declare(strict_types=1); mb_internal_encoding('UTF-8'); $title = '从零开始学习 PHP 多字节字符串处理与编码校验'; $limit = 30; // 目标列 VARCHAR(30) $cut = mb_strcut($title, 0, $limit, 'UTF-8'); $naive = substr($title, 0, $limit); printf("原文 : %s (strlen=%d)\n", $title, strlen($title)); printf("mb_strcut : %s (strlen=%d, 合法 UTF-8: %s)\n", $cut, strlen($cut), mb_check_encoding($cut, 'UTF-8') ? '是' : '否'); printf("substr : %s (strlen=%d, 合法 UTF-8: %s)\n", $naive, strlen($naive), mb_check_encoding($naive, 'UTF-8') ? '是' : '否');输出:
原文 : 从零开始学习 PHP 多字节字符串处理与编码校验 (strlen=62) mb_strcut : 从零开始学习 PHP 多字 (strlen=29, 合法 UTF-8: 是) substr : 从零开始学习 PHP 多字 (strlen=30, 合法 UTF-8: 否)mb_strcut()的结果是 29 字节——第 30 个字节正好落在"节"字中间,它退回到上一个完整字符边界,宁可少 1 字节。而substr()硬切出 30 字节后末尾留下一段孤立的 UTF-8 首字节,mb_check_encoding()立刻判定为非法。
2.3 按显示宽度截取
mb_strimwidth()的宽度单位不是字符也不是字节,而是半角宽度:ASCII 字符算 1,汉字和全角标点算 2。所以$width = 20实际只能装下 10 个汉字。用它来做表格对齐或者终端输出很合适,但把它当"字符数"用就会截得比预期短一半:
<?php // strimwidth.php —— PHP 7.4+ declare(strict_types=1); mb_internal_encoding('UTF-8'); $text = '这是一个较长的中文标题用于演示截断'; // width=20 表示 20 个半角宽度,即 10 个汉字 // 带省略号标记时,标记本身的宽度也计入 20 之内 echo mb_strimwidth($text, 0, 20, '...', 'UTF-8'), PHP_EOL; echo mb_strimwidth('abcdefghijklmnopqrstuvwxyz', 0, 20, '...', 'UTF-8'), PHP_EOL; // 不带标记:此时 20 的宽度可以全部用来装字符 printf("汉字模式字符数: %d\n", mb_strlen(mb_strimwidth($text, 0, 20, '', 'UTF-8'), 'UTF-8')); printf("ASCII 模式字符数: %d\n", mb_strlen(mb_strimwidth('abcdefghijklmnopqrstuvwxyz', 0, 20, '', 'UTF-8'), 'UTF-8'));输出:
这是一个较长的中... abcdefghijklmnopq... 汉字模式字符数: 10 ASCII 模式字符数: 20这里有两层"宽度不是字符数"的陷阱叠在一起。第一层是汉字宽度为 2:不带省略号时20只能装 10 个汉字,却能装 20 个 ASCII 字符。第二层是省略号标记的宽度也算在$width之内:加上 3 个半角的...之后,汉字只剩 8 个的位置(16 + 3 = 19,再加一个汉字就超了)。另外它的$start参数同样以半角宽度计,不是字符下标。
2.4 HTML 安全截取
直接对 HTML 片段调用mb_substr()会产生两种破坏:一是切断了标签(剩下一个没有闭合的<strong>),二是切断了 HTML 实体( 变成&nb,页面上直接显示这几个字符)。
<?php // html-truncate.php —— PHP 7.4+ declare(strict_types=1); mb_internal_encoding('UTF-8'); function excerpt(string $html, int $maxChars, string $ellipsis = '…'): string { // 第一步:把 HTML 实体还原成真实字符,否则 " " 会被当成 6 个字符计数 $plain = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8'); // 第二步:压缩空白。\x{00A0} 是 解码后的不换行空格, // PCRE 的 \s 默认不匹配它,必须显式列进字符类 $plain = trim(preg_replace('/[\s\x{00A0}]+/u', ' ', $plain) ?? $plain); // 第三步:对纯文本按字符截断 if (mb_strlen($plain, 'UTF-8') <= $maxChars) { return htmlspecialchars($plain, ENT_QUOTES | ENT_HTML5, 'UTF-8'); } $cutLen = $maxChars - mb_strlen($ellipsis, 'UTF-8'); $cut = mb_substr($plain, 0, $cutLen, 'UTF-8'); return htmlspecialchars($cut . $ellipsis, ENT_QUOTES | ENT_HTML5, 'UTF-8'); } $html = '<p>PHP 的 <strong>mbstring</strong> 扩展提供了完整的多字节字符串处理能力,' . ' 包括长度、截取、大小写转换等等。</p>'; echo excerpt($html, 24), PHP_EOL; echo excerpt($html, 200), PHP_EOL;输出:
PHP 的 mbstring 扩展提供了完整的… PHP 的 mbstring 扩展提供了完整的多字节字符串处理能力, 包括长度、截取、大小写转换等等。strip_tags()必须先于截断。另外preg_replace()必须带u修饰符,否则正则按字节处理;\s默认也不匹配 U+00A0,需要单独列进字符类。
2.5 处理 emoji 与组合字符
mb_substr()的单位是码点(code point),但屏幕上显示"一个字符"的东西,可能是多个码点组成的字素簇(grapheme cluster)。最典型的是 emoji 的 ZWJ 序列——家庭主题的 emoji 由若干人像码点加零宽连接符(ZWJ,U+200D)拼成,mb_substr()切到中间会把它拆成一串互不相干的人像:
<?php // grapheme.php —— 需要 intl 扩展 declare(strict_types=1); mb_internal_encoding('UTF-8'); // 用 \u{} 转义书写,避免源码文件本身的编码影响结果 $family = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}"; // 三个人像 + 两个零宽连接符 $text = $family . ' 是一家人'; printf("字节数 strlen : %d\n", strlen($text)); printf("码点数 mb_strlen : %d\n", mb_strlen($text, 'UTF-8')); printf("字素簇 grapheme_strlen : %d\n", grapheme_strlen($text)); echo PHP_EOL; // mb_substr 按码点切:取前 3 个码点,正好是「人像 + ZWJ + 人像」 $bad = mb_substr($text, 0, 3, 'UTF-8'); printf("mb_substr(0,3) : %d 字节, 等于完整家庭 emoji: %s\n", strlen($bad), var_export($bad === $family, true)); // grapheme_substr 按字素簇切:整个 ZWJ 序列被视为 1 个字符 $good = grapheme_substr($text, 0, 1); printf("grapheme_substr : %d 字节, 等于完整家庭 emoji: %s\n", strlen($good), var_export($good === $family, true));输出:
字节数 strlen : 31 码点数 mb_strlen : 10 字素簇 grapheme_strlen : 6 mb_substr(0,3) : 11 字节, 等于完整家庭 emoji: false grapheme_substr : 18 字节, 等于完整家庭 emoji: true同一段文本量出三个不同的长度:31 字节(家庭 emoji 占 18 字节,空格和四个汉字占 13 字节)、10 码点(家庭 emoji 是 5 个码点,即三张人脸加两个 ZWJ)、6 字素簇(整个家庭 emoji 在 Unicode 的扩展字素簇规则下算作 1 个字符)。
mb_substr($text, 0, 3)取到 11 字节,只截出"人像 + ZWJ + 人像",末尾的 ZWJ 失去连接对象,屏幕上是两个独立人像;grapheme_substr($text, 0, 1)拿到完整的 18 字节。
grapheme_*系列由 intl 扩展提供,需要系统里有 ICU 库。环境装不了 intl 时,退而求其次是在截断后用mb_check_encoding()校验——它能保证不产出非法 UTF-8,但保证不了 emoji 的完整性。
常见坑点
❌ 用substr()截取中文字符串 ✅ 改用mb_substr($str, $start, $len, 'UTF-8'),前提是装好 mbstring 扩展,用extension_loaded('mbstring')确认
❌ 调用mb_substr()时不传编码,指望 ini 里的默认值 ✅ 显式传'UTF-8',或在应用入口调用mb_internal_encoding('UTF-8')固定下来
❌ 数据库列是VARCHAR(30),却用mb_substr($s, 0, 30)去截 ✅ 字符数和字节数不是一回事,对字节上限的列要用mb_strcut($s, 0, 30, 'UTF-8')
❌ 用mb_strimwidth($s, 0, 20)以为能截 20 个汉字 ✅ 它的宽度单位是半角宽度,一个汉字算 2,20实际只能装 10 个汉字
❌ 对含 HTML 标签或实体的内容直接mb_substr()✅ 先html_entity_decode(strip_tags($html), ...),截断后再htmlspecialchars()输出,否则 会被切成&nb显示在页面上
❌ 用strlen()判断一个中文字符串是不是超长 ✅ 要用mb_strlen($s, 'UTF-8');strlen()返回字节数,同样内容会大 3 倍
❌ 截断后不校验就直接json_encode()返回给前端 ✅ 非法 UTF-8 会让json_encode()返回false,接口响应体变成空串且没有异常抛出;用mb_check_encoding($cut, 'UTF-8')兜底,或改用JSON_INVALID_UTF8_SUBSTITUTE选项
❌ 用mb_substr()截断 emoji ✅ ZWJ 组合序列、肤色修饰符、组合重音符号都会被切断,需要 intl 扩展的grapheme_substr()
❌ 截断时忘了省略号也要占预算,导致最终长度超限 ✅ 先在总量里扣掉省略号的长度,再截正文,并且只有在内容确实被截掉时才追加省略号
总结
| 需求 | 正确函数 | 单位 | 关键注意点 |
|---|---|---|---|
| 按字数截取中文 | mb_substr() | 字符(码点) | 必须显式传'UTF-8' |
| 按字节上限截取 | mb_strcut() | 字节 | 不会切断字符,结果可能比上限少 1~3 字节 |
| 按显示宽度截取 | mb_strimwidth() | 半角宽度 | 一个汉字算 2,$start也是宽度单位 |
| 拆成单字 | mb_str_split() | 字符 | PHP 7.4 起可用 |
| emoji / 组合字符 | grapheme_substr() | 字素簇 | 需要 intl 扩展 |
| 长度判断 | mb_strlen() | 字符 | 别用strlen()判中文长度 |
| 结果校验 | mb_check_encoding() | — | 截断后随手校验,成本极低 |
| 舍弃 HTML 标签 | strip_tags()+html_entity_decode() | — | 必须在截断之前做 |
字符串截取乱码的根源只有一个:字节数、码点数、显示宽度这三种"长度"被混为一谈。选函数之前先想清楚业务约束到底限制的是哪一种长度——限字数就用mb_substr(),限字节就用mb_strcut(),限宽度就用mb_strimwidth(),涉及 emoji 就上grapheme_substr()。选对之后,再补上编码显式声明和截断后校验这两道保险,这个问题基本就绝迹了。