☰
php简单实现多语言切换的方法
2026/10/10 6:52:22 网站建设 项目流程

前言


先把两个容易被混为一谈的词分开:i18n(internationalization,国际化)指的是"让软件具备支持多语言、多地区的能力"这件工程工作——把散落在代码里的文案抽出来、把日期数字的格式做成可配置的、把布局改成能容纳不同长度文本的。l10n(localization,本地化)指的是"针对某个具体地区做适配"这个内容工作——把文案翻译成日语、把货币改成日元、把地址格式改成日本的写法。i18n 是做框架,l10n 是填内容。本文标题说的"多语言切换",实际上横跨了两者:切换是 i18n 的能力,翻译内容本身是 l10n 的产出。


第二个必须先点明的坑,是关于setlocale()的:



setlocale()是进程级的全局设置,不是请求级的。



这意味着在 Apache 的mod_php这类多线程 SAPI 下,你在 A 请求里把 locale 切成ja_JP,同一个进程里正在处理的 B 请求也会被影响——而 B 请求可能根本没调用过setlocale()。官方手册对此有明确的警告。在 PHP-FPM 这类非线程化(NTS)的部署里情况好一些(每个 worker 是独立进程,请求之间共享同一个进程的 locale),但"请求 A 设了 locale、请求 B 读到被污染的 locale"这个隐患依然存在。


最后一点同样反直觉:gettext方案依赖操作系统已安装对应的 locale。没装ja_JP.UTF-8时setlocale()返回false,而gettext()不报错,只是安静地返回原文——这造就了无数"翻译文件明明编译好了,页面上却还是英文"的诡异问题。


一、最简方案:语言包数组 + 会话保持


对于大多数中小项目,这个方案就够了,而且没有外部依赖、行为完全可控。核心是把文案按语言拆成独立文件,用键去取。


先准备语言包文件。每个语言一个 PHP 文件,返回一个键 => 文案的数组(用require而不是file_get_contents,可以直接利用 Opcache 缓存):


<?php
// lang/zh_CN.php
return [
'welcome' => '欢迎,{name}',
'login' => '登录',
'logout' => '退出',
'items_count' => '共 {count} 条记录',
];

<?php
// lang/en_US.php
return [
'welcome' => 'Welcome, {name}',
'login' => 'Sign in',
'logout' => 'Sign out',
'items_count' => '{count} items',
];

翻译器本身:


<?php
// 适用于 PHP 8.0+
declare(strict_types=1);

final class Translator
{
/** @var array<string, string> */
private array $messages = [];

public const SUPPORTED = ['zh_CN', 'en_US', 'ja_JP'];

private string $locale;

public function __construct(private string $dir, string $locale)
{
$this->setLocale($locale);
}

public function setLocale(string $locale): void
{
// 白名单校验:语言代码来自用户输入,绝不能用来拼文件路径
if (!in_array($locale, self::SUPPORTED, true)) {
$locale = self::SUPPORTED[0];
}
$this->locale = $locale;

$file = rtrim($this->dir, '/\\') . DIRECTORY_SEPARATOR . $locale . '.php';

// 用 require:语言包缺失属于部署错误,应当立刻暴露而不是静默降级
if (!is_file($file)) {
throw new RuntimeException("语言包不存在: {$locale}");
}

$messages = require $file;
$this->messages = is_array($messages) ? $messages : [];
}

public function getLocale(): string
{
return $this->locale;
}

/**
* @param array<string, string|int|float> $params
*/
public function t(string $key, array $params = []): string
{
$text = $this->messages[$key] ?? $key; // 缺失的键返回键名本身,便于发现

foreach ($params as $name => $value) {
$text = str_replace('{' . $name . '}', (string) $value, $text);
}

return $text;
}
}

入口脚本负责"选语言"和"记住选择":


<?php
// 适用于 PHP 8.0+
session_start();
require __DIR__ . '/Translator.php';

$supported = Translator::SUPPORTED;

// 优先顺序:URL 参数 → 会话 → 默认
$locale = null;
if (isset($_GET['lang']) && is_string($_GET['lang'])
&& in_array($_GET['lang'], $supported, true)) {
$locale = $_GET['lang'];
$_SESSION['lang'] = $locale; // 记住用户的选择
} elseif (isset($_SESSION['lang']) && in_array($_SESSION['lang'], $supported, true)) {
$locale = $_SESSION['lang'];
}

$t = new Translator(__DIR__ . '/lang', $locale ?? 'zh_CN');

echo $t->t('welcome', ['name' => '小明']), PHP_EOL;
echo $t->t('items_count', ['count' => 42]), PHP_EOL;

这个方案的三个关键点:



  • 语言代码必须走白名单。上面的代码用in_array($locale, self::SUPPORTED, true)做了校验,之后才用它拼文件名。如果直接require $dir . '/' . $_GET['lang'] . '.php',就是一个典型的路径穿越 + 本地文件包含漏洞——攻击者可以用../跳到别的目录去加载任意 PHP 文件。这是多语言功能里最危险的一处,务必用白名单而不是"过滤掉.."这种黑名单思路。

  • in_array()一定要传第三个参数true。不传时是松散比较(==),在 PHP 8 之前"0"之类的字符串与数字比较会出各种意外;即使是 PHP 8,松散比较仍然不是你想要的东西。

  • 键值缺失时返回键名本身,而不是空字符串。这样页面上会直接显示welcome这样的裸键,翻译遗漏一眼可见,比显示空白好排查得多。


二、gettext方案与它的四个坑


gettext是 Unix 世界的标准 i18n 方案,PHP 通过 ext-gettext 扩展提供支持。它的优点是翻译文件(.mo)可以用 Poedit、msgfmt 等成熟工具链生成,译者不需要碰代码;缺点是运行环境依赖较重。


<?php
// 需要 ext-gettext:编译时加 --enable-gettext,Windows 在 php.ini 打开 extension=gettext

$locale = 'zh_CN.UTF-8';
$domain = 'messages'; // 域(domain)名,对应 .mo 文件名
$dir = __DIR__ . '/locale';

// 1. 告诉 gettext 去哪个目录找翻译文件
bindtextdomain($domain, $dir);

// 2. 指定编码(旧系统上必须显式设置,否则可能按 latin1 处理)
bind_textdomain_codeset($domain, 'UTF-8');

// 3. 选用哪个域
textdomain($domain);

// 4. 设置当前 locale —— 这一步最容易失败
$result = setlocale(LC_ALL, $locale);
if ($result === false) {
// 系统没装这个 locale,gettext 会静默返回原文
error_log("设置 locale 失败: {$locale}");
}

echo gettext('Hello'); // 或写成 _('Hello')

目录结构是固定的,不能自创:


locale/
└── zh_CN/
└── LC_MESSAGES/
├── messages.po # 源文件,供译者编辑
└── messages.mo # 编译产物,gettext 实际读取的文件

四个必须知道的坑:



  1. locale 必须在操作系统里存在。用locale -a列出已安装的 locale。如果只有C、POSIX和en_US.utf8,那么setlocale(LC_ALL, 'zh_CN.UTF-8')必然返回false,而gettext()不会报任何错——它只是原样返回你传入的字符串。这是 gettext 最恼人的失败模式。

  2. locale 名称的写法因操作系统而异。Linux 上是zh_CN.UTF-8,macOS 上可能是zh_CN.UTF-8但实际不生效,Windows 上则是Chinese_China.936或zh-CN这类完全不同的形式。跨平台项目里写死一个字符串一定会出问题,通常需要探测多个候选值。

  3. 改语言必须改 locale,而setlocale()是进程级全局的(见前言)。它的副作用不仅影响 gettext,还会影响strftime()、number_format()的某些行为,乃至一些依赖 locale 的扩展。这就是为什么很多框架宁可自己实现翻译层,也不用 gettext。

  4. .mo文件改了要重新编译。.po是给人看的源文件,gettext()只读.mo。改完.po忘了跑msgfmt -o messages.mo messages.po,页面上就一直是旧译文。.mo是二进制文件,必须放进版本控制。


顺带一提:与 locale 强相关的strftime()和gmstrftime()在 PHP 8.1 已被废弃,应改用IntlDateFormatter。


三、intl扩展:不依赖进程 locale 的正路


intl扩展基于 ICU(International Components for Unicode)库,所有 API 都把 locale 作为显式参数传入,不依赖setlocale(),因此没有进程级全局污染,在多线程 SAPI 下也安全。这是它在架构上明显优于 gettext 的地方。


<?php
// 需要 ext-intl。PHP 5.3+ 起可用(PECL 安装),5.4 起随 PHP 内置

$locale = 'ja_JP';

// 1. MessageFormatter:处理带占位符的句子,支持复数、性别等 ICU 语法
$msg = MessageFormatter::formatMessage(
$locale,
'{name} さん、{count, plural, =0{メッセージはありません} other{# 件のメッセージ}}',
['name' => '田中', 'count' => 3]
);
echo $msg, PHP_EOL;

// 2. NumberFormatter:本地化的数字与货币
$num = new NumberFormatter('de_DE', NumberFormatter::CURRENCY);
echo $num->formatCurrency(1234.5, 'EUR'), PHP_EOL; // 1.234,50 €

// 3. Collator:按语言规则排序与比较
$collator = new Collator('de_DE');
var_dump($collator->compare('ä', 'a')); // 德语词典序下 ä 排在 a 之后

// 4. IntlDateFormatter:本地化的日期时间
$dateFmt = IntlDateFormatter::create(
'zh_CN',
IntlDateFormatter::FULL, // 日期样式
IntlDateFormatter::SHORT, // 时间样式
'Asia/Shanghai'
);
echo $dateFmt->format(new DateTimeImmutable('2026-01-02 15:04:05')), PHP_EOL;

使用前先检查扩展是否存在,因为intl在不少精简环境里没有编译进去:


<?php
// 适用于 PHP 5.3+
if (!extension_loaded('intl')) {
// 降级到纯 PHP 的语言包方案,或直接给出明确提示
error_log('intl 扩展未安装,功能降级');
}

四、UTF-8 与mb_*函数族


多语言的另一半是编码处理。PHP 的strlen()、substr()、strtoupper()都是按字节工作的,对 UTF-8 文本使用它们是错误的:


<?php
// 适用于 PHP 8.0+

$s = '中文字符串';

// ❌ strlen 返回的是字节数(15),不是字符数(5)
echo strlen($s), PHP_EOL; // 15

// ✅ 用 mb_* 函数,显式指定编码
echo mb_strlen($s, 'UTF-8'), PHP_EOL; // 5

// ❌ substr 会从中间切断一个多字节字符,产生乱码
echo substr($s, 0, 2), PHP_EOL; // 输出半个字符,乱码

// ✅ mb_substr 按字符切
echo mb_substr($s, 0, 2, 'UTF-8'), PHP_EOL; // 中文

mb_*的编码参数可以省略,此时使用mb_internal_encoding()的值。在项目入口处统一设置一次,比在每个调用点都写'UTF-8'更不容易遗漏:


<?php
// 适用于 PHP 5.6+
mb_internal_encoding('UTF-8'); // mb_* 家族默认编码
mb_regex_encoding('UTF-8'); // mb_ereg_* 的编码
mb_http_output('UTF-8'); // HTTP 输出编码

// 输出给浏览器时还要有明确的响应头(必须在任何输出之前调用)
// header('Content-Type: text/html; charset=UTF-8');

其余常用函数:mb_strtoupper()/mb_strtolower()(大小写转换;土耳其语的i/İ有特殊映射,这类 locale 相关的大小写mb_*处理不了)、mb_convert_encoding()(编码转换)、mb_detect_encoding()(探测编码,不保证准确,不要用于安全判断)。


输出转义同样要显式指定编码:


<?php
// 适用于 PHP 5.4+
$safe = htmlspecialchars($userInput, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');

第三个参数不要依赖默认值——它取自default_charset这个 ini 配置,可能被环境改掉。ENT_SUBSTITUTE会把非法字节序列替换成U+FFFD,而不是让整个字符串变成空串。


常见坑点



  1. ❌require $dir . '/' . $_GET['lang'] . '.php'直接拼路径


✅ 语言代码必须经过白名单校验(in_array($locale, self::SUPPORTED, true))。这是路径穿越与本地文件包含的高危点,不要用"过滤.."的黑名单思路。



  1. ❌in_array($lang, $supported)不传第三个参数


✅ 松散比较会带来意外的类型匹配。写成in_array($lang, $supported, true),严格比较。



  1. ❌ 在多线程 SAPI 下用setlocale()做请求级的语言切换


✅setlocale()是进程级全局的,同进程的其他请求会被污染。官方手册对此有明确警告。要做请求级切换,用自己维护的语言包数组,或使用不依赖进程 locale 的 intl 扩展。



  1. ❌ 用 gettext 却从不检查setlocale()的返回值


✅ 系统没装对应 locale 时setlocale()返回false,而gettext()不报错,只是安静地返回原文。这是"翻译文件明明存在却不生效"的头号原因。用locale -a确认系统已装。



  1. ❌ 改了.po文件就期待页面立刻变化


✅gettext()读的是编译后的.mo文件。改完.po必须重新跑msgfmt生成.mo。另外.mo是二进制文件,要提交进版本库。



  1. ❌ 用strlen()/substr()处理多语言文本


✅ 它们按字节工作。strlen('中文')是 6 不是 2,substr()还会切出半个字符导致乱码。一律改用mb_strlen()/mb_substr()并指定'UTF-8'。



  1. ❌ 用sort()/usort()给多语言数组排序


✅ 它们基于字节序比较。中文会按 Unicode 码点排而不是拼音,德语变音字母也不符合词典序。需要语言相关的排序时用Collator。



  1. ❌ 语言包文件保存为带 BOM 的 UTF-8,或者语言包 PHP 文件保留了结尾的?>


✅ BOM 会在任何输出之前被发出去,导致header()报"headers already sent",还会破坏 JSON 响应;结尾?>之后的空白同理。语言包和入口文件都应保存为无 BOM 的 UTF-8,且纯 PHP 文件省略结尾标签。


总结




需求推荐方案关键注意点



中小项目快速切换语言语言包数组(require一个 PHP 文件)语言代码白名单校验;in_array传true

译者用专业工具编辑gettext(ext-gettext)依赖系统 locale;.po要编译成.mo

复数、货币、数字格式intl 的MessageFormatter/NumberFormatter不依赖进程 locale,多线程安全

按语言规则排序intl 的Collatorsort()/usort()是字节序,中文排不出拼音序

请求级语言切换语言包数组 或 intl避免setlocale(),它是进程级全局的

记住用户选择$_SESSION['lang']或 Cookie会话优先于Accept-Language猜测

UTF-8 处理mb_*函数族 +mb_internal_encoding('UTF-8')strlen/substr按字节,会切坏多字节字符

输出转义`htmlspecialchars($s, ENT_QUOTES \ENT_SUBSTITUTE, 'UTF-8')`



结论:多语言切换的难点不在"怎么翻译",而在于"在哪里记住用户的选择"和"用什么机制查到译文"。最省心的路线是:自己维护语言包数组(白名单校验 + 会话保持),把 gettext 留给确实需要专业翻译工具链的项目,把数字、日期、排序这类地区差异交给 intl 扩展。这样做最大的好处是——语言切换完全受你控制,不会被进程级的全局状态背地里改掉。




需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询