ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PHP8.1字符串截取怎么避免乱码

PHP8.1字符串截取怎么避免乱码 前言先说清楚版本问题标题里的「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_types1); $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 : 中 | hexe4b8ade6 | strlen4 mb_substr : 中文 | hexe4b8ade69687 | strlen6 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 4mb_substr()字符码点按字数截取显示文本mbstring 扩展mb_strcut()字节但绝不切断字符数据库列有字节长度上限mbstring 扩展mb_strimwidth()半角宽度全角算 2表格对齐、固定宽度展示mbstring 扩展mb_str_split()字符拆成单字数组逐字处理PHP 7.4grapheme_substr()字素簇grapheme clusteremoji、带组合符号的文本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_types1); 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_types1); 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_types1); 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 多字节字符串处理与编码校验 (strlen62) mb_strcut : 从零开始学习 PHP 多字 (strlen29, 合法 UTF-8: 是) substr : 从零开始学习 PHP 多字 (strlen30, 合法 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_types1); mb_internal_encoding(UTF-8); $text 这是一个较长的中文标题用于演示截断; // width20 表示 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 实体nbsp;变成nb页面上直接显示这几个字符。?php // html-truncate.php —— PHP 7.4 declare(strict_types1); mb_internal_encoding(UTF-8); function excerpt(string $html, int $maxChars, string $ellipsis …): string { // 第一步把 HTML 实体还原成真实字符否则 nbsp; 会被当成 6 个字符计数 $plain html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, UTF-8); // 第二步压缩空白。\x{00A0} 是 nbsp; 解码后的不换行空格 // 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 pPHP 的 strongmbstring/strong 扩展提供了完整的多字节字符串处理能力 . nbsp;nbsp;包括长度、截取、大小写转换等等。/p; echo excerpt($html, 24), PHP_EOL; echo excerpt($html, 200), PHP_EOL;输出PHP 的 mbstring 扩展提供了完整的… PHP 的 mbstring 扩展提供了完整的多字节字符串处理能力 包括长度、截取、大小写转换等等。strip_tags()必须先于截断。另外preg_replace()必须带u修饰符否则正则按字节处理\s默认也不匹配 U00A0需要单独列进字符类。2.5 处理 emoji 与组合字符mb_substr()的单位是码点code point但屏幕上显示一个字符的东西可能是多个码点组成的字素簇grapheme cluster。最典型的是 emoji 的 ZWJ 序列——家庭主题的 emoji 由若干人像码点加零宽连接符ZWJU200D拼成mb_substr()切到中间会把它拆成一串互不相干的人像?php // grapheme.php —— 需要 intl 扩展 declare(strict_types1); 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 个汉字 ✅ 它的宽度单位是半角宽度一个汉字算 220实际只能装 10 个汉字❌ 对含 HTML 标签或实体的内容直接mb_substr()✅ 先html_entity_decode(strip_tags($html), ...)截断后再htmlspecialchars()输出否则nbsp;会被切成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()。选对之后再补上编码显式声明和截断后校验这两道保险这个问题基本就绝迹了。
返回列表