「中文」是语言,「汉字」是文字,「从左往右」是方向。日常说话时这三件事搅在一起没关系,一落到代码里就得出事:lang 属性填的是语言,字体按文字分组,而方向是另一套属性。
三个词,三件事
| 词 | 说的是 | 例子 |
|---|---|---|
| 语言(language) | 人说的话,可以有多种写法 | 汉语、英语、塞尔维亚语 |
| 文字(script / writing system) | 一套字符与它背后的书写规则 | 拉丁字母、西里尔字母、汉字、阿拉伯字母 |
| 书写方向(direction) | 一行字往哪边走、一页怎么排 | 从左到右、从右到左、竖排 |
它们不是一一对应的,这正是麻烦所在:
- 一种语言可以用两种文字写:塞尔维亚语用西里尔(
српски)也可以写拉丁(srpski);汉语有简体与繁体两套写法; - 一种文字服务很多语言:拉丁字母撑着西欧大半个语族,阿拉伯字母服务阿拉伯语、波斯语、乌尔都语;
- 同一个文字里方向也可能变:中文古书竖排,现代网页横排(见 书写方向)。
语言标记:zh-CN 到底在说什么
网页上写语言用的是 BCP 47(RFC 5646)定义的语言标记。结构是固定顺序的几段:
language [-script] [-region] [-variant…]
zh -CN
zh -Hans -CN
sr -Latn -RS
| 段 | 取自 | 写法 | 例子 |
|---|---|---|---|
| 语言 | ISO 639-1(两位)/ 639-3(三位) | 小写 | zh、en、sr、yue(粤语) |
| 文字 | ISO 15924(四位) | 首字母大写 | Latn、Hans、Hant、Cyrl |
| 地区 | ISO 3166-1(两位)或 UN M49(三位数字) | 全大写 | CN、TW、GB、419(拉美) |
大小写只是书写习惯:BCP 47 规定匹配时不分大小写,zh-cn 与 zh-CN 等价。但请按习惯写,因为人读代码时靠这个区分「这一位是文字还是地区」。
几个常见标记:
| 标记 | 意思 |
|---|---|
zh |
汉语(不指定文字与地区) |
zh-CN |
中国大陆的汉语——约定俗成是简体,但标记本身没写文字 |
zh-TW |
台湾地区的汉语——约定俗成是繁体,同样没写文字 |
zh-Hans / zh-Hant |
明确指定文字:简体 / 繁体。这才是「我想说什么字」的准确写法 |
zh-Hant-HK |
繁体 + 香港(用词与台湾不同:裏 / 裡、質素 / 品質) |
en-US / en-GB |
美式 / 英式英语(拼写与日期格式都不同) |
sr-Latn-RS |
塞尔维亚语、拉丁字母、塞尔维亚 |
und |
未定(语言不明时用,比乱猜一个更诚实) |
zh-CN 与 zh-Hans 的区别值得记住:前者说「哪个地区」,后者说「哪种写法」。两者经常一致,但新加坡的 zh-SG 用简体、马来西亚的 zh-MY 也用简体,「地区」和「文字」并不是同一件事。
那些「长得像但不是它」的写法
同一个语言,在不同系统里有好几种拼法。你在配置文件里见到的多半不是 BCP 47:
| 写法 | 常见出处 | 是 BCP 47 吗 |
|---|---|---|
zh-CN |
HTML lang、HTTP Accept-Language、JSON-LD、Windows Runtime、现代 Android values-b+zh+CN |
✅ |
zh-Hans-CN |
同上,写全了文字 | ✅ |
zh_CN |
POSIX 环境变量 LANG=zh_CN.UTF-8;Java Locale.toString();ICU / CLDR 的文件名与资源名 |
❌ 只是换了分隔符 |
zh_Hant |
ICU 资源名、早年 CLDR 的数据目录 | ❌ |
zh-rCN |
老 Android 资源目录 values-zh-rCN(新写法是 values-b+zh+Hans+CN) |
❌ |
zhCN |
各种 SDK 自己定的枚举/常量(有的还把大小写改成 zhCN、enUS),没有统一标准 |
❌ |
两条实用结论:
- 要跟标准打交道(
lang、Accept-Language、搜索引擎、JSON-LD)就走 BCP 47,其余场合先看清楚对方要什么; - 别对
zhCN这种写法做前缀匹配——它没有规范定义,谁也不知道下一家 SDK 会不会写成zh-cn、zh_CN或zhHansCN。要做匹配就先把它们归一化成 BCP 47。
文字代码:ISO 15924 的四位码
文字也有编号,是 ISO 15924 的四位字母(首字母大写):
| 代码 | 文字 | 备注 |
|---|---|---|
Latn |
拉丁字母 | 英语、法语、土耳其语…… |
Cyrl |
西里尔字母 | 俄语、乌克兰语、保加利亚语…… |
Arab |
阿拉伯字母 | 阿拉伯语、波斯语、乌尔都语 |
Hani |
汉字(Hanzi / Kanji / Hanja) | 一个码,不分简繁、不分中日韩 |
Hans / Hant |
汉字(简体 / 繁体变体) | 见下 |
Hira / Kana |
平假名 / 片假名 | |
Hang |
谚文(Hangul) | 韩文 |
Thai、Deva、Hebr…… |
泰文、天城文、希伯来文…… | |
Zyyy |
未定文字(Common) | 数字、空格、多数标点 |
Zinh |
继承(Inherited) | 组合标记:本身没有文字,跟着前一个字 |
Zzzz |
未编码文字 | 数据缺口的占位 |
有两条容易看漏:
Jpan/Kore/Hrkt是「组合别名」:Jpan的定义就是“汉字 + 平假名 + 片假名”,Kore是“谚文 + 汉字”。日文不是一种字符,而是几套字符的组合——这解释了为什么「按文字选字体」在日文上格外难;Hans/Hant在 ISO 15924 里是独立代码,但在 Unicode 的Script属性里没有。一个简体汉字和一个繁体汉字的Script都是Hani。想区分简繁,Script帮不上忙。
一对多与多对一
| 关系 | 例子 | 对做产品意味着什么 |
|---|---|---|
| 一种语言 → 多种文字 | 塞尔维亚语(西里尔 / 拉丁)、哈萨克语(正在从西里尔转向拉丁)、汉语(简 / 繁) | 只写语言标记选不出字形,还得知道文字 |
| 一种文字 → 多种语言 | Latn 之下的英语、德语、土耳其语…… |
只写文字标记选不出字体细节,还得知道语言 |
| 一种语言 + 一种文字 → 多个地区 | zh-Hant 在台湾与香港用词不同 |
地区影响的是用词与格式,不是字形,但它会影响 locl 挑哪一套字形 |
所以「这个字体支不支持中文」这种问法,严格来说要拆成两问:它有没有汉字的接口字形(cmap 覆盖),以及它有没有按 zh-Hans / zh-Hant / ja 提供对应的字形变体(locl 特性)。前者看覆盖率,后者看特性表。
汉字的三种「不同」
中文口语里都叫「异体字」,实际上混着几件机制完全不同的事。分清它们,才知道该换字体、该加变体选择符,还是该换码位。
一、同一个码位,按语言换字形
同一个码位,中日韩三地各有各的惯例写法。浏览器会按元素的 lang 去挑字形——下面是同一串字符,只有 lang 不同:
U+9AA8 U+76F4 U+8349 U+8005),只有 lang 不同:浏览器会按语言标记各挑一个它认为合适的字体。
能看出几套写法,取决于这台机器上装了几套资源——两行长得一样,往往是它们落到了同一个字体上。看不到差别不代表这件事不存在。
几处常被举到的差别:草(U+8349)的草字头,繁体中文习惯四笔(⺿),简体中文与日文习惯三笔(⺾);骨 里那个框在某些地区写法里像镜像;者 有的写法多一笔、有的少一笔。这些差别看着很小,对本地读者却是「这不是我的字」的信号。
它们都是一个码位,靠字体的 locl 特性按语言系统挑字形。所以「这个字体支持中文」并不等于「它画得对」——覆盖了码位,不等于给你这套惯例。
繁体内部也不是一块:台湾与香港各有一套字形参考(前者是国字标准字体,后者是常用字字形表),所以有的字上 zh-Hant-TW 与 zh-Hant-HK 也会分家。而标 lang 只是发出请求——能不能兑现,取决于这台机器上有没有字体愿意接。
⚠️ 上面那张图其实混着两件事:lang 挑的首先是字体,不是字形。不同语言能用的字体未必来自同一个家族,风格、笔形、字面大小都可能不同,所以你看到的「有差别」里,一部分是字形惯例,另一部分是两个字体本身的差别。真要比较字形惯例,得用同一家族的字体——像 Source Han / Noto CJK 那样一个家族内部按 locl 切简繁日韩港的,才只剩字形差异。
二、Unicode 里就是两个码位
有些变体在编码阶段就分了家:
| 字 | 码位 | 说明 |
|---|---|---|
| 內 / 内 | U+5167 / U+5185 |
同一个「里面」,两个字 |
| 丟 / 丢 | U+4E1F / U+4E22 |
同上 |
| 個 / 个 | U+500B / U+4E2A |
繁体 / 简体 |
| 兌 / 兑 | U+514C / U+5151 |
两种办法都能显出差异:换码位,或者换字体 |
| 佛 / 仏 | U+4F5B / U+4ECF |
日文新字体把「佛」写成「仏」,两个码位 |
后果很实在:字数不一样、搜索不一样、比较也不一样。'內' === '内' 是 false,indexOf 找不到,数据库去重去不掉。所以做跨地区文本处理时,「什么算同一个字」得先定下来——Unicode 不会给你一个通用的答案,它只保证后面那类兼容汉字的等价。
三、兼容汉字:同一个字、两个码位,而且强制等价
CJK 兼容汉字区(U+F900–FAFF)里有一批字,是为了跟老编码逐字节往返才加的副本:
| 字 | 本位 | 兼容副本 |
|---|---|---|
| 車 | U+8ECA |
U+F902 |
| 漢 | U+6F22 |
U+FA9A |
| 海 | U+6D77 |
U+FA45 |
它们有规范分解(见 字符属性):U+F902 分解成 U+8ECA。所以任何形式的 normalize() 都会把它们合并回本位那个。
⚠️ 但不 normalize 的字符串比较里它们是两个字符——老系统导出的文本、从 PDF 复制出来的文本里经常能碰到。处理用户输入时先 normalize('NFC'),这类问题自动消失。
四、要明确指定时:变体选择符
一个码位对应多个字形,而又必须明确说「我要这个写法」(人名、古籍、商标)时,用变体选择符:码位后面跟 U+FE00–FE0F 或 U+E0100–E01EF。字体要在 cmap 的变体序列表里声明支持哪些组合,这套机制叫 IVS(Ideographic Variation Sequence)。
也就是 码位与平面 里说过的同一个东西,只是汉字这边用得最多。
从一段文本推断书写系统
经常需要「拿到一句用户输入,猜它是什么文字」——为了选字体、为了决定断行规则、为了给排版预览挑样例。直接读 Unicode 的 Script 属性(Scripts.txt)是最省事的第一步:
- 每个码位取
Script值; - 丢掉
Zyyy(数字、空格、标点)与Zinh(组合标记)——它们对「这是什么文字」没有信息量; - 剩下的里面取占比最大的那个。
这套办法能用,但有三件事它做不到:
- 分不出简繁:
Script里没有Hans/Hant,简体繁体都是Hani; - 分不出中日韩:纯汉字的句子,中文、日文、韩文都是
Hani。日文有假名、韩文有谚文时可以靠它们判出来,但「漢字」两个字的句子照样分不出; - 分不出同一文字下的语言:一串拉丁字母可以是英语、法语、德语、土耳其语;一串西里尔同样跨着俄语、乌克兰语、保加利亚语。
还有一层:有些字符的 Script 是 Common,但它明确属于某几种文字。比如长音符 ー(U+30FC)在 Scripts.txt 里是 Common,要看 ScriptExtensions.txt 才知道它同时属于平假名与片假名;天城文的句号 ।(U+0964)也属于一大票印度系文字。所以「丢掉所有 Zyyy」会连这类字符一起丢掉——要不要用 Script_Extensions,取决于你猜的是「大概是什么文字」还是「这段文本能交给哪种字体」。
要做到「猜语言」而不只是「猜文字」,得换数据。 唯一有区分力的东西是各语言的常用字符集:同样一个汉字,在中文常用字表里出现,在日文常用字表里没出现,这件事比它的 Script 值有用得多。CLDR 的 exemplarCharacters 就是这份表(由各语言的本地化社区维护),拿它比对「这段文本的字落在谁的表里」,才能把 zh-Hans / zh-Hant / ja / ko 分开。
西里尔那种情况还有个小技巧:单个语言的字母表彼此不通用(俄语没有 ў,乌克兰语有;保加利亚语没有 ы),所以可以取多个西里尔语言的交集当「核心西里尔集」,落在核心集里就算,边角字母再单独判断。
⚠️ 这些都只是「推断」:短文本、专有名词、混排(中文里夹英文品牌名)随时会翻车。推断结果要能显示出来、并且允许用户改,别默默按它去换字体。
前端能控制什么
| 写的东西 | 影响 |
|---|---|
<html lang="zh-Hant">、元素上的 lang |
locl 特性按它挑字形(同一个字体里「直」「骨」这类字的简繁日写法不同);读屏按它选发音;浏览器按它挑断词与连字符规则 |
:lang(zh) |
按 RFC 4647 的前缀匹配:zh 能匹配 zh-CN、zh-Hant-TW;:lang(zh-Hant) 只匹配带 Hant 的 |
@font-face 的 unicode-range |
按码位分片加载字体(中文字体可以拆成几千片,只下用到的那几片)——这是「按文字」切分资源的现代做法 |
font-family 的回退链 |
浏览器逐字符按「谁的 cmap 有这个码位」挑下一个字体,所以回退是按字符生效的,不是整段 |
lang 写错 |
上面三件事全部走偏:字形挑错、发音读错、断词断错。这是最低成本、最高收益的一个属性 |
本站怎么用这两个概念
- 样本图按书写系统出:字体详情页除了几门有样句的语言(中文、日文、英文……),还会按书写系统各出一行字,让泰文、阿拉伯文这类字体不至于拿一张拉丁字母图当封面;
- 语言标记只用标准的 BCP 47 写法:
<html lang>写zh-CN,JSON-LD 的inLanguage写zh-CN/en——都是能与外部工具对上的那种拼法; - 文档中心的正文语言只看路径(
/docs英文、/zh/docs中文),不跟 cookie:那是内容,不是界面偏好。代价是界面语言和正文语言可以对不上,这是有意接受的。