PHP 處理中文字符串截取是高頻需求,但 substr() 按字節切割導致亂碼。本文系統整理 mb_substr、正則提取、自定義字符級截取、編碼檢測、智能截取工具類 等多種方案,解決 UTF-8/GBK 混合環境、Emoji、HTML 實體、混合編碼等複雜場景。
<?php
$str = "你好世界"; // UTF-8: 每字 3 字節,共 18 字節
echo substr($str, 0, 6); // "你好" (剛好 2 字符 = 6 字節,偶然正確)
echo substr($str, 0, 7); // "你好" (第 3 字符只取 1 字節 → 截斷 → 亂碼)
echo substr($str, 0, 8); // "你好" (同理)
echo substr($str, 0, 9); // "你好世" (3 字符 = 9 字節,又正確了)
根因: UTF-8 中文 3 字節/字符,substr 按字節截取,非 3 的倍數位置必然截斷字符。
<?php
// 必須指定編碼或設置 mb_internal_encoding
mb_internal_encoding('UTF-8');
$str = "你好世界";
echo mb_substr($str, 0, 4); // "你好世" (4 字符)
echo mb_substr($str, 2, 3); // "世界" (從第 2 字符取 3 個)
echo mb_substr($str, -3); // "世界" (最後 3 字符,PHP 7.1+)
// 支持指定編碼
echo mb_substr($gbkStr, 0, 4, 'GBK'); // GBK 環境
<?php
/**
* 使用正則匹配 UTF-8 字符序列
* 模式解析:
* [\x00-\x7F] - ASCII (1 字節)
* [\xC2-\xDF][\x80-\xBF] - 2 字節序列
* \xE0[\xA0-\xBF][\x80-\xBF] - 3 字節 (首字節 E0)
* [\xE1-\xEF][\x80-\xBF]{2} - 3 字節 (其餘)
* \xF0[\x90-\xBF]{3} - 4 字節 (首字節 F0, Emoji 等)
* [\xF1-\xF7][\x80-\xBF]{3} - 4 字節 (其餘)
*/
function utf8Chars(string $str): array
{
preg_match_all(
'/[\x00-\x7F]|[\xC2-\xDF][\x80-\xBF]|\xE0[\xA0-\xBF][\x80-\xBF]|[\xE1-\xEF][\x80-\xBF]{2}|\xF0[\x90-\xBF]{3}|[\xF1-\xF7][\x80-\xBF]{3}/',
$str,
$matches
);
return $matches[0];
}
function mbSubstrRegex(string $str, int $start, int $length = null): string
{
$chars = utf8Chars($str);
$slice = array_slice($chars, $start, $length ?? count($chars));
return implode('', $slice);
}
// 測試
$str = "你好🌍世界"; // 含 Emoji
echo mbSubstrRegex($str, 0, 4); // "你好🌍世" (正確處理 4 字節 Emoji)
<?php
/**
* 手動實現 UTF-8 字符級截取 (理解原理用)
*/
function manualMbSubstr(string $str, int $start, int $length = null, string $encoding = 'UTF-8'): string
{
if ($encoding !== 'UTF-8') {
// 簡單轉碼處理
$str = mb_convert_encoding($str, 'UTF-8', $encoding);
}
$bytes = strlen($str);
$charCount = 0;
$bytePos = 0;
$startByte = -1;
$endByte = $bytes;
for ($i = 0; $i < $bytes; ) {
$byte = ord($str[$i]);
// 判斷字符字節數
if ($byte < 0x80) { $charBytes = 1; } // ASCII
elseif ($byte < 0xE0) { $charBytes = 2; } // 2 字節
elseif ($byte < 0xF0) { $charBytes = 3; } // 3 字節 (中文)
else { $charBytes = 4; } // 4 字節 (Emoji)
// 記錄起始字節位置
if ($charCount === $start) {
$startByte = $i;
}
$charCount++;
$i += $charBytes;
// 記錄結束字節位置
if ($length !== null && $charCount === $start + $length) {
$endByte = $i;
break;
}
}
if ($startByte === -1) return ''; // 起始位置超出
return substr($str, $startByte, $endByte - $startByte);
}
<?php
/**
* 智能截取:保護 HTML 標籤完整性、實體不被截斷
*/
function smartHtmlTruncate(string $html, int $maxLength, string $ellipsis = '...'): string
{
// 1. 剝離標籤獲取純文本長度
$text = strip_tags($html);
if (mb_strlen($text) <= $maxLength) {
return $html;
}
// 2. 使用 DOMDocument 解析並截取 (最準確)
libxml_use_internal_errors(true);
$dom = new DOMDocument();
$dom->loadHTML('' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
$textContent = '';
$resultNodes = [];
// 遞歸遍歷文本節點
$traverse = function($node) use (&$traverse, &$textContent, &$resultNodes, $maxLength, $ellipsis) {
if ($node->nodeType === XML_TEXT_NODE) {
$nodeText = $node->textContent;
$remaining = $maxLength - mb_strlen($textContent);
if ($remaining <= 0) {
$node->textContent = '';
return false; // 停止遍歷
}
if (mb_strlen($nodeText) > $remaining) {
$node->textContent = mb_substr($nodeText, 0, $remaining) . $ellipsis;
return false;
}
$textContent .= $nodeText;
} elseif ($node->nodeType === XML_ELEMENT_NODE) {
$resultNodes[] = $node;
foreach ($node->childNodes as $child) {
if (!$traverse($child)) return false;
}
}
return true;
};
$traverse($dom);
// 3. 序列化結果
$output = '';
foreach ($dom->childNodes as $node) {
$output .= $dom->saveHTML($node);
}
return $output;
}
<?php
class StringTruncator
{
public function __construct(
private string $encoding = 'UTF-8',
private string $ellipsis = '...',
private bool $htmlAware = false
) {}
/**
* 字符級截取 (核心)
*/
public function truncate(string $string, int $maxLength): string
{
if (mb_strlen($string, $this->encoding) <= $maxLength) {
return $string;
}
if ($this->htmlAware) {
return $this->truncateHtml($string, $maxLength);
}
return mb_substr($string, 0, $maxLength, $this->encoding) . $this->ellipsis;
}
/**
* 字節級截取 (存儲限制)
*/
public function truncateBytes(string $string, int $maxBytes): string
{
if (strlen($string) <= $maxBytes) {
return $string;
}
$ellipsisBytes = strlen($this->ellipsis);
$cutBytes = max(0, $maxBytes - $ellipsisBytes);
return mb_strcut($string, 0, $cutBytes, $this->encoding) . $this->ellipsis;
}
/**
* 智能雙限制:字符數 + 字節數
*/
public function truncateSmart(string $string, int $maxChars, int $maxBytes): string
{
$charLen = mb_strlen($string, $this->encoding);
$byteLen = strlen($string);
if ($charLen <= $maxChars && $byteLen <= $maxBytes) {
return $string;
}
// 先按字符截取
$result = $this->truncate($string, $maxChars);
if (strlen($result) <= $maxBytes) {
return $result;
}
// 字符截取後仍超字節限制,改按字節
return $this->truncateBytes($string, $maxBytes);
}
/**
* HTML 感知截取 (保護標籤)
*/
private function truncateHtml(string $html, int $maxLength): string
{
// 簡化版:剝離標籤截取,再嘗試恢復 (不完美但實用)
$text = strip_tags($html);
if (mb_strlen($text) <= $maxLength) return $html;
$truncatedText = mb_substr($text, 0, $maxLength, $this->encoding) . $this->ellipsis;
// 簡單策略:返回純文本截取 (避免破壞標籤)
// 完美方案需用 DOMDocument 如方案四
return $truncatedText;
}
/**
* 獲取字符統計信息
*/
public function stats(string $string): array
{
return [
'chars' => mb_strlen($string, $this->encoding),
'bytes' => strlen($string),
'words' => str_word_count($string, 0, 'áéíóúÁÉÍÓÚàèìòùÀÈÌÒÙäëïöüÄËÏÖÜñÑçÇ'), // 簡易詞數
'lines' => substr_count($string, "\n") + 1,
];
}
}
// 使用示例
$truncator = new StringTruncator(ellipsis: '…', htmlAware: true);
$title = "這是一個包含粗體和斜體的長標題內容";
echo $truncator->truncate($title, 15);
// "這是一個包含粗體和斜體…" (HTML 標籤被 strip_tags 移除)
$content = "用戶輸入的超長內容...";
echo $truncator->truncateSmart($content, 100, 500); // 雙重保護
<?php
function detectAndTruncate(string $string, int $maxChars): string
{
// 檢測編碼 (優先級: UTF-8 > GBK > ASCII)
$encoding = mb_detect_encoding($string, ['UTF-8', 'GBK', 'ASCII'], true);
if ($encoding === false) {
// 未知編碼,嘗試強制 UTF-8
$encoding = 'UTF-8';
$string = @mb_convert_encoding($string, 'UTF-8', 'auto');
}
return mb_substr($string, 0, $maxChars, $encoding);
}
// 批量處理不同編碼源
$sources = [
'utf8' => "UTF-8 字符串",
'gbk' => mb_convert_encoding("GBK 字符串", 'GBK', 'UTF-8'),
];
foreach ($sources as $name => $str) {
echo "$name: " . detectAndTruncate($str, 4) . "\n";
}
<?php
$str = "PHP 🐘 中文 🎉 測試";
// mb_substr 正確處理 Emoji (4 字節 = 1 字符)
echo mb_substr($str, 0, 6); // "PHP 🐘 中" (6 字符)
// 正則方案也需包含 4 字節模式
// \xF0[\x90-\xBF]{3} 已覆蓋大多數 Emoji
// 計算顯示寬度 (東亞寬度)
function displayWidth(string $str): int
{
$width = 0;
$chars = utf8Chars($str); // 使用方案二的正則分割
foreach ($chars as $char) {
$ord = ord($char[0]);
if ($ord < 0x80) {
$width += 1; // 半寬
} elseif ($ord >= 0xE0 && $ord < 0xF0) {
$width += 2; // 全寬 (中日韓)
} else {
$width += 1; // Emoji 等通常渲染為雙寬,但計算為 1
}
}
return $width;
}
echo displayWidth("Hello世界🌍"); // 5 + 2*2 + 1 = 10
mb_substr | mbstring | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | **首選標準方案** |
| preg_match_all 正則 | 無 | ⭐⭐⭐ | ⭐⭐⭐⭐ | 無 mbstring 環境 |
| 手動解析 | 無 | ⭐⭐ | ⭐⭐ | 教學、特殊邏輯 |
| StringTruncator 類 | mbstring | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | **生產項目統一封裝** |
| DOMDocument HTML | DOM | ⭐⭐ | ⭐⭐⭐⭐⭐ | 富文本截取 |
mb_internal_encoding('UTF-8')$truncator->truncate($str, 50)truncateSmart()strip_tags 後截取mb_detect_encoding + 轉碼PHP 中文字符串截取的核心是編碼感知。mb_substr 是標準答案,StringTruncator 是工程化答案。建立統一截取服務,處理「字符 vs 字節」、「純文本 vs HTML」、「單一編碼 vs 混合編碼」三組矛盾,讓截取功能從「經常出 Bug」變成「可靠的基礎設施」。