一、先分两层:字符集 vs 编码方案
- 字符集(character set):一张「字符 → 编号」的对照表。Unicode 给全世界每个字符分配一个唯一编号,叫码点(code point),写作
U+4E2D这种形式。它只规定「中」这个字编号是 0x4E2D,不规定怎么存。 - 编码方案(encoding):把码点变成字节的规则。UTF-8、UTF-16、UTF-32 都是同一张 Unicode 表的不同存法。
所以「Unicode 编码」这个说法其实不准确:Unicode 是表,UTF-8 才是落地的字节格式。同一句中文,用 UTF-8 存和用 UTF-16 存,字节数完全不同,但表示的内容一致。
二、UTF-8 的变长规则
UTF-8 用 1–4 个字节表示一个码点,靠首字节的高位标记长度:
| 码点范围 | 字节数 | 首字节形态 |
|---|---|---|
| U+0000–U+007F | 1 | 0xxxxxxx |
| U+0080–U+07FF | 2 | 110xxxxx |
| U+0800–U+FFFF | 3 | 1110xxxx |
| U+10000–U+10FFFF | 4 | 11110xxx |
好处有两个:完全兼容 ASCII(英文仍是 1 字节,老代码不用改),以及自同步(从任意字节位置都能靠首字节判断边界,坏一个字节不会连累后面全部)。中文常用字落在 3 字节区,所以一个汉字在 UTF-8 里通常是 3 字节,在 GBK 里是 2 字节——这就是「UTF-8 中文更占空间」说法的来源。
三、UTF-16 与代理对
UTF-16 用 2 字节或 4 字节。基本平面(BMP)的字符直接 2 字节,超出 BMP 的(emoji、部分生僻字、音乐符号)要用一对代理项(surrogate pair)拼出 4 字节。这带来一个非常实际的问题:
'👍'.length // 2 ← UTF-16 码元数,不是字符数
[...'👍'].length // 1 ← 按码点迭代才是 1
'👨👩👧'.length // 8 ← 带零宽连接符的复合 emoji
JavaScript 的字符串长度按 UTF-16 码元计算,所以做「输入不超过 10 个字」这类限制时,用 .length 会把一个 emoji 算成 2,甚至把家庭 emoji 算成 8。正确做法是用 Array.from(str).length 或 Intl.Segmenter 按字素簇切分。
四、\u 转义与 \u{...}
JSON 和多数语言支持 \uXXXX 表示一个 UTF-16 码元。注意它不能直接表示 4 字节字符,必须写成代理对:
{"emoji": "\ud83d\udc4d"}
JavaScript 的 ES6 之后可以用 \u{1F44D} 写完整码点,但 JSON 标准不支持这种写法。所以接口里出现 \ud83d\udc4d 这种成对转义是正常的,不要以为是数据损坏。
五、乱码的根源只有一句话
写入时用的编码,和读取时声明的编码不一致。 文件、HTTP 响应头、HTML 的 <meta charset>、数据库连接串、编辑器的保存编码,任何一处不统一就会乱码。排查顺序是:先确认原始字节到底是什么编码(看字节特征,别猜),再让读取方按同一编码解析。
遇到 %E4%B8%AD 这类串,可以用 URL 编码解码 还原;想查某个字符的码点和 UTF-8 字节,用 Unicode 转换工具 直接看,不必背表。
小结
Unicode 是编号表,UTF-8/UTF-16 是存法;UTF-8 变长且兼容 ASCII,中文 3 字节;JS 的 .length 数的是 UTF-16 码元,emoji 会算成 2;乱码只有一个原因——编解码不一致。