前言
「Smarty 截取中文乱码」这个说法其实不准确。乱码不是 Smarty 造成的,Smarty 只是把 PHP 的字符串处理函数包了一层。真正的成因只有两个:按字节截断,以及字符集声明前后不一致。搞清楚这两件事,「乱码」就变成了一个必然结果,而不是玄学问题。
另一个要提前纠正的前提:这个标题把 gb2312 和 utf-8 并列当作两种可选方案。实际情况是,GB2312 是上世纪的标准,收录汉字有限,遇到生僻字会出现无法表示的字符;UTF-8 才是当前所有新项目的唯一合理选择。如果你是接手一个老 GB2312 项目,正确目标是迁移到 UTF-8,而不是继续在 GB2312 里缝缝补补。
本文先讲清字符与字节的关系,再拆开 Smarty 的truncate修饰器看它到底按什么单位截断,然后给出几个可以直接用的安全写法。示例基于 PHP 8.1 与 Smarty 4.x;Smarty 3 也保留同样的truncate修饰器。文中代码无法在本机执行验证,请结合本地实测阅读。
一、字符、字节、编码:乱码的根
一个汉字在计算机里存成几个字节,取决于编码:
| 编码 | 一个汉字占多少字节 | 说明 |
|---|
| UTF-8 | 通常 3 字节,扩展区 4 字节 | 变长编码,ASCII 仍是 1 字节 |
| GB2312 | 2 字节 | 只覆盖常用汉字,生僻字缺字 |
| GBK / GB18030 | 2 字节(GB18030 有 4 字节区) | GB2312 的超集 |
PHP 的substr()是按字节切的,它不认识「一个汉字」这个概念。对 UTF-8 字符串'中文字符'调用substr($s, 0, 4),切出来的是「中」加上「文」的第一个字节——半个字符。这半个字符再输出到浏览器,解析不了,于是显示成问号、方块或者一串鬼画符。
正确的工具是 mbstring 扩展提供的mb_substr(),它按字符切:
<?php // 适用于 PHP 8.0+
$s = '中文字符串';
echo substr($s, 0, 4); // 按字节切:切碎了
echo "\n";
echo mb_substr($s, 0, 4, 'UTF-8'); // 按字符切:中文所以第一条结论:任何针对中文的截断,都必须用mb_*系列函数,并且显式指定字符集。不指定字符集时,mbstring 会退回到mb_internal_encoding()的值,那个值可能被框架或 php.ini 改过,显式写死才稳。
二、Smarty 的 truncate 到底按什么单位截
Smarty 的截断修饰器用法是:
{$content|truncate:30:"...":true}位置参数依次是:截断长度、省略符、是否允许在单词中间断开(break_words),还有一个middle参数用于从中间截断。这里要注意的是,长度参数的单位并不固定:
- 当环境启用了 mbstring 扩展时,Smarty 用
mb_substr()按字符截断,中文不会被切碎; - 当 mbstring 不可用时,Smarty 回退到
substr(),也就是按字节截断,中文必然乱码。
这就是为什么同样一份模板,在开发机(装了 mbstring)正常、上了某台服务器(没装 mbstring)就乱码。所以排查顺序应该是:
php -m | grep mbstring或者用代码判断:
<?php // 适用于 PHP 8.0+
var_dump(extension_loaded('mbstring'));
var_dump(mb_internal_encoding());mbstring 没装就装上,排查就到此为止。装好之后仍然乱码,就往下一步走。
三、字符集链条:四者必须一致
即使 mbstring 装好了,只要链路上任何一个环节的字符集声明和实际的字节不一致,照样乱码。这条链上有四个位置:
- PHP 源文件本身的编码(含模板文件
.tpl)。必须是无 BOM 的 UTF-8。 - mbstring 的 internal encoding,以及你调用
mb_substr时传的字符集参数。 - 数据来源的编码。数据库连接字符集(MySQL 用
utf8mb4)、导入的 CSV 或接口返回的数据。 - 输出端的声明。HTTP 响应头
Content-Type: text/html; charset=utf-8,以及 HTML 里的<meta charset="utf-8">。
四个位置里只要有一个是 GB2312、其他三个是 UTF-8,数据在这一环就会被二次编码或错误解码。典型症状是「数据库里看着正常,页面上是一串『ä¸Â』这样的怪字符」——那是 UTF-8 的字节被当成 Latin-1 又解码了一次。
如果确实需要和历史数据互转,用mb_convert_encoding()或iconv():
<?php // 适用于 PHP 8.0+
// 老数据是 GB2312,转成 UTF-8 再进模板
$utf8 = mb_convert_encoding($legacy, 'UTF-8', 'GB2312');
// 生僻字建议按 GB18030 处理,它兼容 GB2312 且字符集更大
$utf8 = mb_convert_encoding($legacy, 'UTF-8', 'GB18030');注意转换方向:mb_convert_encoding($str, $to_encoding, $from_encoding),第三个参数是来源编码,写反了会更乱。
四、推荐做法与输出安全
最稳的方案是根本不在模板里截断。模板只做展示,字符串加工在 PHP 里完成,这样用到的函数、字符集、边界处理都是你可以控制的:
<?php // 适用于 PHP 8.0+,需要 mbstring 扩展
declare(strict_types=1);
/**
* 按字符截断,超出部分用省略号代替。
* $len 指保留的字符数,不是字节数。
*/
function cutText(string $text, int $len, string $etc = '…'): string
{
if ($len <= 0) {
return '';
}
if (mb_strlen($text, 'UTF-8') <= $len) {
return $text;
}
return mb_substr($text, 0, $len, 'UTF-8') . $etc;
}
$smarty->assign('intro', cutText($row['intro'], 40));模板里就只剩一句:
<p>{$intro}</p>如果业务要求按「显示宽度」截断(半角算 1、全角算 2),用mb_strimwidth():
<?php // 适用于 PHP 8.0+
// 宽度 40 意味着大约 20 个汉字,超出部分用 ... 补齐
$short = mb_strimwidth($text, 0, 40, '...', 'UTF-8');它的第四个参数是省略符,第五个是字符集;返回的字符串总宽度包含省略符在内。这里要注意它和mb_substr的区别:mb_strimwidth数的是「显示宽度」,不是「字符个数」。
截断处理完之后,还有一个容易和「乱码」混为一谈的环节:输出转义。经常有人把|escape和乱码混在一起讲。要区分清楚:
- 乱码是字节和编码不匹配,属于编码问题;
|escape是把小于号、大于号、与号、引号转成 HTML 实体,属于输出安全,和编码无关。
不过在 PHP 8.1 上它们确实会撞车,值得单独说一句:htmlspecialchars()的默认参数在 PHP 8.1 发生了变化,默认旗标从ENT_COMPAT变成了ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401。其中ENT_SUBSTITUTE的作用是:遇到不合法的字节序列时,用替换字符代替、而不是返回空字符串。在 PHP 8.1 之前,一段含有非法 UTF-8 字节的中文文本传进htmlspecialchars()会得到'',页面那一整段直接消失,看起来比乱码还吓人。
所以显式写全参数仍然是好习惯:
<?php // 适用于 PHP 8.0+
echo htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');Smarty 侧的等价写法是给修饰器补上类型参数:
{$text|escape:'html'}如果希望模板里所有{$var}默认就转义,可以在 PHP 侧打开开关:
<?php // 适用于 PHP 8.0+
$smarty->escape_html = true;常见坑点
- ❌ 在模板里写
{$title|truncate:20},服务器上没装 mbstring
✅ 要么装 mbstring 并用{$title|truncate:20:"...":true},要么把截断挪到 PHP 侧用mb_substr()。前者依赖环境,后者最稳。
- ❌ 用
substr()截中文
✅ 用mb_substr($s, 0, $n, 'UTF-8')。substr()按字节,切在汉字中间必然坏掉。
- ❌ 数据库连上了、字段也存了 UTF-8,却漏了连接的字符集
✅ MySQL 要显式设定连接字符集(PDO 的 DSN 里加charset=utf8mb4,或在连接后执行SET NAMES utf8mb4),否则数据在传输层被再编码一次。
- ❌ HTML 里写了
<meta charset="utf-8">就以为万事大吉
✅ 响应头Content-Type里的 charset 优先级更高,两者不一致时以响应头为准。两边都写对。
- ❌ 模板文件和 PHP 文件编码不一致
✅ 统一 UTF-8 无 BOM。混用 GB2312 与 UTF-8 的include,等于在拼接不同编码的字节流。
- ❌ 用
iconv()转换时没处理非法字符
✅iconv()遇到无法转换的字符会返回false(或触发 notice),可以用//IGNORE、//TRANSLIT后缀处理,或直接改用mb_convert_encoding()。
- ❌ 靠
mb_internal_encoding()的全局值隐式生效
✅ 每个mb_*调用都显式传'UTF-8'。全局值可能被框架、扩展或另一个库改掉,排查这类问题时最容易忽略。
- ❌ 在模板里对已经截断过的字符串再截一次
✅ 截断只做一次,且在数据进入模板之前完成。多层截断会让省略符出现两次,还会因为中间的 escape 把实体当成普通字符再切一次。
总结
| 现象 | 真正的原因 | 处理办法 |
|---|
| 中文尾部出现问号或方块 | 按字节截断,切在汉字中间 | 用mb_substr()/mb_strimwidth() |
| 开发机正常,服务器乱码 | 服务器没装 mbstring,truncate退化成按字节 | 安装 mbstring,或把截断移到 PHP 侧 |
一整段文字变成丠| UTF-8 字节被按 Latin-1 解码了一次 | 检查数据库连接字符集与响应头 |
| 页面某段文字凭空消失 | htmlspecialchars()遇到非法字节序列 | PHP 8.1 前补ENT_SUBSTITUTE,并显式传'UTF-8' |
| GB2312 项目生僻字缺字 | 编码字符集本身不含该字 | 迁移到 UTF-8,转换时按 GB18030 读旧数据 |
一句话结论:中文截断的关键不在 Smarty,而在「用按字符的函数、显式指定 UTF-8、让整条链路的字符集声明一致」。把截断放到 PHP 侧做,既绕开了truncate依赖 mbstring 的不确定性,也让这段逻辑变得可测试。至于 GB2312,它的正确归宿是尽早迁移,而不是继续在新代码里使用。