你把一段中文写进数据库,在自己的电脑上看一切正常。部署到服务器后,页面却出现了 䏿–‡;你把响应头改成 UTF-8,旧页面好了,导出的 CSV 又乱码了。
另一个问题更隐蔽:用户已经把浏览器首选语言设成中文,请求里也带了中文偏好,网站为什么还是返回英文?你修好语言匹配,第二个用户却从 CDN 缓存里拿到了第一个用户的中文页面。
这两类故障看起来都叫“国际化没做好”,实际上发生在不同层:前者是同一串字节被按不同规则解释,后者是服务器选择了错误的内容版本,或者缓存忘了区分版本。再往后,日期、数字、货币和时区又各有自己的语义,不能靠翻译文案一并解决。
国际化通常缩写为 i18n,因为 internationalization 的首尾字母之间有 18 个字母。它不是“多放几份翻译文件”,而是一组贯穿输入、存储、传输、选择和展示的约定。我们先从最容易看见的乱码开始,再把这条链路逐层接起来。

服务器通过网络发送的不是“中文”“英文”或“表情”,而是一串字节。字符只有先被编码成字节才能传输,接收方也必须用相同的编码把字节解回来。
假设字符“中”在发送端被编码成 UTF-8 字节:
字符:中
Unicode 代码点:U+4E2D
UTF-8 字节(十六进制):E4 B8 AD接收方如果按 UTF-8 解码,会重新得到“中”。如果按 Windows-1252 一类的旧编码解释,同样的三个字节会被拆成别的字符,于是出现 ä¸ 这样的乱码。字节没有在路上“变坏”,变的是接收方采用的解释规则。
日常讨论里,“字符集”和“编码”经常混着说。排查问题时最好把它们拆开:
U+4E2D。Unicode 解决的是“不同系统怎样给文字分配一致的身份和编号”。UTF-8 解决的是“这些编号怎样变成字节”。所以,“这个文件是 Unicode”还不够具体;真正需要确认的是它使用 UTF-8、UTF-16LE、UTF-16BE,还是其他编码。
还有一个常见误区:一个代码点不一定等于用户眼中的一个字符。某些带音标字母可以由一个预组字符表示,也可以由基础字母加组合附加符号表示;一个家庭表情可能由多个代码点通过连接符组成。按代码点截取字符串,仍有可能把用户看到的一个完整符号切成两半。处理光标、昵称长度和文本截断时,需要的是“字素簇”边界,而不只是字节数或代码点数。

UTF-8 使用 1 到 4 个字节表示一个 Unicode 代码点。ASCII 范围内的代码点仍使用相同的单字节值;常用汉字大多使用 3 个字节,补充平面里的字符和许多表情会使用 4 个字节。
它的优势不是“每个字符都更省空间”。纯英文通常很紧凑,中文相对某些传统双字节编码反而可能占更多字节。真正的收益是:同一种编码可以表达完整的 Unicode 范围,ASCII 数据仍能自然工作,字节序也不需要像 UTF-16 那样区分大端和小端。系统边界少一种猜测,故障面就小很多。
UTF-8 可以带字节顺序标记,但它本身没有字节序问题,因此现代 Web 内容通常不需要 BOM。浏览器发现 BOM 时会把它当成很强的编码信号;这也意味着 BOM 与响应头冲突时,问题不会因为“多写一个声明”自动消失。最可靠的做法仍是让文件实际字节、HTTP 声明和 HTML 声明全部一致。
编码问题不只有“看见乱码”一种表现:
䏿–‡ 一类文本。? 或被丢弃。原始信息已经损失。排查时不要在最后一层盲目“转码”。先找每个边界:源文件如何保存、运行时字符串如何表示、数据库连接用什么编码、数据库列如何配置、HTTP 实际发出什么字节、响应头声明什么。找到第一次出现错误的边界,才知道该修哪里。
下面的浏览器代码可以直接观察同一段文本如何变成 UTF-8 字节,以及严格解码如何拒绝非法字节:
const text = "中文🙂";
const bytes = new TextEncoder().encode(text);
console.log(
[...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(" "),
);
const decoded =
fatal: true 很适合边界校验:遇到非法 UTF-8 就明确失败,而不是静默插入替换字符 �,让损坏的数据继续流入系统。
你可能已经在 HTML 里写了 <meta charset="utf-8">,页面仍然乱码。原因是编码声明不只出现于 HTML 内部,HTTP 响应头也能声明编码,而且浏览器必须在正确解析 HTML 之前先决定“用什么编码读这份 HTML”。
对通过 HTTP 获取的 HTML,最值得记住的判断顺序是:
Content-Type 里的 charset 是传输层给出的编码信息。<meta charset>。用户代理还可能提供手动覆盖编码的功能,但应用不能把它当成部署方案。服务端应该主动把三件事对齐:文件保存为 UTF-8、响应声明 UTF-8、HTML 开头也声明 UTF-8。
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Language: zh-CN
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>订单详情</title>
</head>
<body>
<h1>订单详情</
<meta charset> 应完整出现在文档开头的前 1024 个字节内。它不是为了覆盖服务器的错误响应头,而是给没有传输层编码信息的场景提供文档内声明。如果响应头写 charset=gbk,正文实际是 UTF-8,就应该修响应头,不能期待浏览器选择你更喜欢的那个答案。

charset 说的是字符如何从字节解码。Content-Encoding: gzip 说的是整个响应体如何压缩。浏览器要先解压,再按字符编码解码。Accept-Encoding 里的 encoding 也是 gzip、br 这类内容编码,不是在协商 UTF-8。
早期 HTTP 还有 Accept-Charset,用于声明客户端可接受的字符编码。现代 Web 已经广泛统一到 UTF-8,这个请求头不仅收益很小,还会增加带宽、延迟和指纹识别信息,因此已经不适合作为一般浏览器应用的常规协商手段。服务端直接稳定输出 UTF-8,通常更简单也更可靠。
响应能显示中文,不代表输入链路已经安全。查询字符串、表单、JSON、消息队列和上传文件都可能携带文本。每个入口都要明确:
安全校验必须在规范化的输入上进行一次,并避免后续组件再次以不同方式解码。否则,前一层看到的是普通文本,后一层重复解码后却可能变成路径分隔符、控制字符或查询语法。这里的原则很朴素:先确定一种合法表示,再做验证;验证通过后不要偷偷改变表示。
乱码修好之后,第二个问题来了:服务器怎样知道用户想要哪种语言?HTTP 和 HTML 使用 BCP 47 语言标签来描述自然语言。最常见的结构可以写成:
语言[-文字系统][-地区][-变体][-扩展][-私有用途]例如:
语言通常是两到三个字母;文字系统通常是四个字母;地区可以是两个字母,也可以是三位数字。标签还允许已登记变体、扩展和以 x- 开始的私有用途部分。完整语法比上面的常见结构更宽,但业务代码不应该自行发明 zh-CHS、cn-ZH 之类标签。
zh-CN 表示适用于中国大陆地区语境的中文,它通常会让本地化系统推断简体中文,但地区子标签本身不是“简体”开关。如果界面必须明确区分简体和繁体,使用 Hans 与 Hant 表达文字系统更直接。
反过来,也不要见到每个语言都强行补地区。fr 可以是一份面向广泛法语用户的中性内容;fr-FR、fr-CA 才进一步表达地区用法。标签越具体,可表达的信息越多,但匹配和内容维护成本也会增加。

语言标签比较时不区分大小写,但通常按约定写成:语言小写、文字系统首字母大写、地区大写,例如 zh-Hant-TW。HTTP 和 HTML 使用连字符,不要把数据库里遗留的 zh_CN 原样送出去。
标准登记表会给旧标签提供首选写法。应用可以使用成熟的国际化库或运行时 API 做规范化:
const inputLocales = ["EN-us", "iw-IL", "zh-hant-tw"];
const canonicalLocales = Intl.getCanonicalLocales(inputLocales);
console.log(canonicalLocales);
// 常见结果:['en-US', 'he-IL', 'zh-Hant-TW']规范化能统一大小写、别名和扩展顺序,却不会把一个“语法上合法但现实中荒唐”的组合变得合理。业务仍应维护自己的支持列表。例如网站只发布 zh-Hans-CN、zh-Hant-TW 和 en-US,那就只允许用户选择这些版本,不必接受所有语法合法的标签。
HTTP 的 Content-Language 描述这份表示面向的自然语言受众。HTML 的 lang 属性描述元素中文本的语言,两者用途不同。
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>术语说明</title>
</head>
<body>
<p>法语中的 <span lang="fr">bonjour</span> 是“你好”。</p>
</
屏幕阅读器、拼写检查、字体选择和搜索处理更依赖文档里的 lang。一篇中文写成的法语入门教程可以出现大量法语词,但目标受众仍是中文读者;不能因为正文里有两种语言,就机械地把 Content-Language 写成两个值。
省略 Content-Language 时,HTTP 语义相当于没有声明特定语言受众:可能是内容与语言无关,也可能只是服务端不知道。它不等于“浏览器已经替你识别出正确语言”,页面文本仍应使用 lang 明确标注。
遇到阿拉伯语、希伯来语等双向文本时,还要正确使用 dir,必要时把用户输入隔离在 bdi 中。语言标签不会自动解决文字方向问题。
浏览器可以在请求中发送语言偏好:
GET /docs HTTP/1.1
Host: example.test
Accept-Language: zh-Hant-TW, zh-Hant;q=0.9, zh;q=0.8, en;q=0.5这里每一项都是一个语言范围。没有写 q 时默认权重是 1;q 在 0 到 1 之间,最多三位小数;q=0 表示该范围不可接受。权重越高,偏好越强。
同一权重下,有些服务端会参考出现顺序,但客户端不能假设所有实现都这样做。服务端应该定义稳定的平局规则,不要把数组顺序碰巧保持不变当成协议保证。
常见原因并不神秘:
zh-CN,却拿字符串去和 zh-Hans-CN 做完全相等比较。lang 或返回了错误的 Content-Language。Accept-Language 更适合第一次访问时提供合理默认值。用户在站内明确选择语言后,这个选择通常应该优先,并能被用户再次修改。否则,用户每次点到中文,又会被浏览器设置强行送回英文,体验像网站完全没有记忆。
一个实用的优先级是:
URL 中显式 locale
↓
登录账户保存的语言
↓
用户主动选择后保存的 cookie
↓
Accept-Language(首次访问或没有显式选择时)
↓
产品默认语言这里没有唯一答案。例如分享链接是否必须保持语言,会影响 URL 是否放在最高优先级。关键是把规则写清楚,不要让不同接口各自猜一套。
语言匹配有两类常见任务:
/docs 返回一个页面。查找时可以从具体标签逐步回退。例如 zh-Hant-TW 可以依次尝试 zh-Hant-TW、zh-Hant、zh,再进入产品定义的默认语言。它不会天然知道你的 zh 内容究竟对应简体还是繁体;这部分必须由内容目录和产品回退策略明确决定。
下面是一份可运行的简化实现。它先解析权重,再执行逐级查找,并把“支持哪些 locale”限制在固定目录中:
const SUPPORTED_LOCALES = ["zh-Hant-TW", "zh-Hant", "zh", "en-US"];
const DEFAULT_LOCALE = "en-US";
const supportedByLowerCase = new Map(
SUPPORTED_LOCALES.map((locale) => [locale.toLowerCase(), locale]),
);
function parseAcceptLanguage(headerValue) {
if (
这段代码故意不做“看到 zh 就猜 zh-Hans-CN”的聪明映射。产品如果要这样回退,应把规则写成可测试的配置。隐式猜测在支持语言增加后最容易改变结果。函数返回 null 时,调用方可以返回 406,或者展示一个不依赖特定语言的选择页;不能悄悄返回被 q=0 排除的版本。
假设 /docs 根据 Accept-Language 返回不同正文。第一个请求来自英文用户,CDN 缓存了英文响应;第二个请求来自中文用户,如果缓存只看 URL,就会直接复用英文版本,源站根本没有机会重新匹配。
这时响应需要说明“内容会随请求的语言偏好变化”:
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Language: zh-Hant-TW
Vary: Accept-Language
Cache-Control: public, max-age=300Vary: Accept-Language 会把对应请求头纳入缓存匹配条件。它修复了串台,却有成本:浏览器生成的语言列表非常多,顺序和权重也可能不同,按原始头值缓存会产生大量碎片,命中率迅速下降。

内容站通常更适合显式语言 URL:根路径只在首次访问时根据偏好跳转,进入具体语言路径后不再反复协商。这样用户分享出去的链接仍是同一种语言,CDN 也不必为每一种原始 Accept-Language 建缓存对象。
如果必须使用同一 URL,边缘层可以先把复杂请求头归一化成产品支持的少数 locale,再以选中 locale 构造内部缓存键。注意,这必须是 CDN 和源站共同实现的明确规则,不是只在应用里加一个自定义响应头就能完成。
账户语言或 cookie 会让响应变成用户相关内容。直接加 Vary: Cookie 往往会把所有 cookie 差异都带进缓存键,碎片更多。对包含用户数据的页面,通常应使用私有缓存策略;对公共翻译内容,则优先让 locale 进入 URL 或使用受控的边缘缓存键。
还有一个容易漏掉的地方:如果根路径的 302 跳转本身会根据 Accept-Language 改变,缓存这个跳转响应时也必须区分语言,或者干脆限制它被共享缓存复用。不能只给最终页面加 Vary。
很多系统把 locale 翻译成“语言”,然后只存一个 zh-CN,希望它同时决定界面中文、人民币、北京时间、年月日顺序和公制单位。这个做法一开始很省事,遇到跨境用户就会暴露问题。
一个住在东京的中国用户可能想看简体中文界面,使用日元结算,按东京时区看预约时间;一个在上海出差的美国用户可能仍然想用英文和 12 小时制。语言、地区习惯、货币、时区、计量单位和个人偏好有关联,但不是同一个字段。
更稳妥的模型至少把这些概念分开:
{
"uiLocale": "zh-CN",
"timeZone": "Asia/Tokyo",
"currency": "JPY",
"hourCycle": "h23"
}uiLocale 决定翻译目录和常见格式;timeZone 决定某个瞬间显示成当地几点;currency 来自商品、账户或交易本身;hourCycle 可以覆盖 locale 的默认习惯。用户没有明确选择时可以推断默认值,但推断结果要允许覆盖。

“2026-08-14”可能有三种完全不同的含义:
把纯日期当作 UTC 时间戳,用户换到负时区后可能看到前一天。把未来会议只保存成固定偏移 +09:00,又无法表达某些地区将来的夏令时规则变化。对地区时间应使用 Asia/Tokyo 这类 IANA 时区标识,而不是只保存当前偏移。
日期格式也不能用字符串拼接猜出来。08/09/2026 在不同地区可能被理解为 8 月 9 日或 9 月 8 日。展示层应使用 locale 感知的格式化工具,并在高风险场景显示月份名称或采用无歧义格式。
const instant = new Date("2026-08-14T04:30:00Z");
const formatter = new Intl.DateTimeFormat("zh-CN", {
dateStyle: "long",
timeStyle: "short",
timeZone: "Asia/Tokyo",
});
console.log(formatter.format(instant));不要在服务端调用不带 locale 和时区参数的 toLocaleString(),然后假设所有机器都会输出同样结果。容器镜像、运行时数据和系统默认时区不同,输出就可能不同。
同一个数值在不同 locale 下可能显示为 1,234.56、1.234,56,甚至使用不同数字符号。分组符和小数符属于展示,不应该混进数据库里的数值字段。
const amount = 123456.78;
const money = new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
currencyDisplay: "symbol",
});
console.log(money.format(amount));这里必须显式提供 EUR。de-DE 只能给出德国语境的格式习惯,不能证明一笔订单的币种就是欧元。金额计算还要使用适合业务精度的十进制或最小货币单位,不能因为最终用了 Intl.NumberFormat,就忽略二进制浮点误差。
同样的边界也适用于排序、复数规则和单位:展示时使用 locale 数据;业务规则使用明确字段。姓名、地址、电话号码更不能按某个国家的单一模板强切“名”和“姓”。国际化的难点往往不是翻译,而是承认数据没有全球统一的形状。
到这里,我们可以把后端一次请求的职责按顺序排开:
Accept-Language 和产品默认值中解析 locale。Content-Type、Content-Language 和 HTML lang。Vary。
把整句英文当翻译键,改一个标点就会造成所有语言缺失。更适合长期维护的是稳定的语义键:
{
"checkout.paymentFailed": "支付失败,请检查付款方式",
"checkout.retry": "重试",
"order.deliveryDate": "预计送达日期"
}翻译目录应该在构建或发布阶段检查:键是否缺失、占位符是否一致、复数分支是否完整。运行时回退可以保证页面仍能打开,但不能让缺译永久隐藏。日志和监控要能区分“正常命中默认语言”与“目标语言缺少键而被迫回退”。
不要把多个片段拼成一句话。例如把“你有”“3”“个订单”分别翻译,到了需要复数变化或不同语序的语言就会失效。给翻译系统完整消息和变量,让它按目标语言生成整句。
API 可以返回本地化的人类可读消息,但程序判断应该依赖稳定错误码:
{
"error": {
"code": "PAYMENT_METHOD_DECLINED",
"message": "付款方式被拒绝,请更换后重试"
}
}客户端可以展示 message,重试策略和埋点则使用 code。如果代码拿中文或英文错误文本做条件判断,一旦翻译更新,业务逻辑就会悄悄失效。
用户生成的内容也要和界面文案分开。用户选择中文界面,不代表他发布的所有内容都是中文,更不代表服务端可以自动改写原文。需要语言识别、翻译或审核时,应保存原文、识别结果和派生版本之间的关系。
语言偏好看起来不像敏感信息,但一条很长、很少见的 Accept-Language 列表可以增加浏览器指纹的独特性,还可能暴露用户掌握的语言或地区背景。应用只需要用它选语言,不需要把完整请求头永久写进用户画像。
更克制的做法是:解析后只记录最终命中的受支持 locale;用户明确选择后保存这个选择;不把语言偏好当国籍、居住地或身份结论。地理位置、时区和语言都只能是提示,不能替用户作不可撤销的决定。
语言标签来自 URL、cookie 或请求头时,都属于外部输入。不能把它直接拼成文件路径:
const CATALOGS = new Map([
["zh-CN", new URL("./locales/zh-CN.json", import.meta.url)],
["en-US", new URL("./locales/en-US.json", import.meta.url)],
]);
function getCatalogUrl(candidate) {
let canonical;
try {
canonical =
先规范化,再从固定映射中取资源,可以同时挡住无效标签、目录穿越和意外访问未发布翻译。locale 同样不能直接拼进 SQL、模板名或远程 URL。
Unicode 能表达大量文字,也带来一些只看屏幕不容易发现的差异:
é 可以是一个预组代码点,也可以是 e 加组合附加符号,视觉相同但底层序列不同。对用户名、标签键等需要比较和唯一性的字段,系统应定义明确的 Unicode 规范化、大小写和禁止字符策略,并在注册与登录时保持完全一致。显示名称可以更宽松,但授权与账户绑定应使用内部稳定 ID,不能依赖“看起来一样”的字符串。
密码要格外谨慎。不要在某一端临时增加 Unicode 规范化,另一端仍按原始序列验证;这会改变既有密码语义并导致用户无法登录。密码处理策略必须从输入到验证保持一致,变更时还要有迁移方案。
对域名、邮件地址和双向文本,使用成熟实现处理对应标准,不要自己写几个正则表达式就宣布“支持全球字符”。日志与审核界面应对不可见控制字符作转义或明确标记,避免操作人员看到的内容与系统实际比较的内容不同。

最后记住一条底线:locale 只能影响怎样表达,不能决定允许做什么。语言、地区或时区绝不能替代身份认证、权限检查、税务规则和合规判断。
国际化测试不能只切换一次语言,看到标题变了就结束。更有效的方式是从真实故障反推测试:
Accept-Language、无匹配项、q=0、相同权重、非法标签和超长偏好列表。做到这些,你解决的就不只是“页面能显示中文”,而是让同一个数据在不同机器、不同语言、不同地区和不同时间规则下,仍然保持可解释、可选择、可缓存、可追踪。下一步再谈全球部署时,CDN 缓存键、边缘重定向和区域数据边界就不再是突然冒出来的新问题,而是这条国际化链路的自然延伸。