最后更新:
Unicode 详解 - 字符编码入门指南
"乱码"的困扰你一定经历过。其原因大多与字符数与字节数的区别有关。本文将解析现代文本处理不可或缺的 Unicode 基础知识。
鲜为人知的 Unicode 冷知识
截至 2024 年,Unicode 收录了 15 万个以上的字符,其中也包含一些"很难说有多少实用价值"的字符。例如 Unicode 6.0 追加的表情符号"💩" (U+1F4A9、Pile of Poo),据说其起源本是日本手机上的表情符号。当年把日本运营商 (SoftBank) 自行实现的表情符号并入 Unicode 时,为了维持兼容性而将它一并收录。另外,Unicode 中还存在被称为"幽灵文字"的谜团汉字。据说在制定 JIS X 0208 规格时,有约 12 个来源不明的汉字被收录进去,"妛""彁""挧"就是其中的例子。这些字在实际的日语文献中几乎找不到使用实例,一般推测其原因是规格制定时的转录失误或误读。
什么是字符编码
计算机将文字作为数值处理。规定哪个数值对应哪个文字的规则就是"字符编码"。有代表性的字符编码如下。
| 字符编码 | 特点 | 主要用途 |
|---|---|---|
| ASCII | 仅英数字和符号 (128 字符) | 英语圈基础 |
| Shift_JIS | 支持日语 (约 7,000 字符) | Windows 日语环境 |
| EUC-JP | 支持日语 | Unix/Linux 日语环境 |
| Unicode (UTF-8) | 支持全球文字 (15 万字符以上) | Web 标准 |
需要注意的是,"字符编码"和"编码方式"严格来说是不同的概念。Unicode 是定义字符集合与码位分配 (字符集) 的规格,UTF-8 和 UTF-16 则是把 Unicode 的码位转换为字节序列的方式 (编码方式)。Shift_JIS 是字符集与编码方式一体化的规格,而 Unicode 采用的是把字符集与编码方式分离的设计。正因为这种分离,同一个 Unicode 字符集才可以按用途选择多种编码方式。
Unicode 的诞生背景
过去各国和地区使用不同的字符编码,跨环境传输文本时频繁出现乱码。Unicode 的构想据说始于 1987 年左右。当时以 Xerox 公司的 Joe Becker、Apple 公司的 Lee Collins 与 Mark Davis 等人为核心,制定了"用 16 位 (65,536 字符) 表示全球文字"这一雄心勃勃的计划。最初认为 65,536 个字符已经足够,但后来发现仅中国、日本、韩国的汉字 (CJK 统一表意文字) 就多达数万字,最终扩展到 21 位 (约 110 万字符) 的空间。据说正是这份"最初估算的乐观",才催生了后来 UTF-16 中代理对这一复杂机制。
回顾 Unicode 的版本历史,收录字符数的增长是阶段性的。1991 年的 Unicode 1.0 约为 7,000 字符,1999 年的 Unicode 3.0 扩大到约 49,000 字符并追加了 CJK 统一表意文字扩展 A。2010 年的 Unicode 6.0 首次正式收录表情符号,达到约 110,000 字符,2023 年的 Unicode 15.1 收录了约 149,000 字符。其中 CJK 统一表意文字占整体约 6 成,可见 Unicode 的字符空间在多大程度上依赖东亚的文字体系。
CJK 统一表意文字中包含一项名为 Han Unification (汉字统合) 的设计判断,即把在中文、日文、韩文中字形略有差异的汉字统合到同一个码位上。例如"直"这个字,在日文与中文 (简体字) 中笔画和字形略有不同,但 Unicode 把它们分配到了同一个 U+76F4。这种统合出于节省码位的实用理由,但也会引起本该用日文字体显示的文字被中文字体显示之类的问题,在 CJK 圈的开发者之间至今仍有争论。
UTF-8 与 UTF-16 的区别
Unicode 有几种编码方式。
| 方式 | 每字符字节数 | 特点 |
|---|---|---|
| UTF-8 | 1-4 字节 | ASCII 兼容,Web 标准 |
| UTF-16 | 2 或 4 字节 | JavaScript 内部使用 |
| UTF-32 | 固定 4 字节 | 处理简单但体积大 |
Web 上 UTF-8 是事实上的标准。在 HTML 文件开头写上 <meta charset="UTF-8"> 正是出于这个原因。
UTF-8 之所以是可变长编码,原因在于它的位模式设计——由首字节的位模式决定该字符占几个字节。0xxxxxxx 为 1 字节 (ASCII 兼容),110xxxxx 为 2 字节,1110xxxx 为 3 字节,11110xxx 为 4 字节,后续字节则一律采用 10xxxxxx 的形式。凭这套设计,即使从字节序列的中途开始也能确定字符边界,因此在流式处理和随机访问上具有优势。另一方面,UTF-16 用 2 字节表示 BMP 内的字符,对 BMP 之外的字符 (U+10000 以上) 则使用代理对机制。它把高位代理 (U+D800 至 U+DBFF) 与低位代理 (U+DC00 至 U+DFFF) 两个 16 位值组合起来表示 1 个字符,因此处理表情符号和部分汉字时需要留意。
那么 UTF-8 为什么成了 Web 的标准呢。据说 UTF-8 诞生于 1992 年,Ken Thompson 与 Rob Pike (后来设计 Go 语言的人物) 把设计草图画在了餐厅的餐巾纸上。UTF-8 最大的优点是与 ASCII 完全向后兼容。既有的 ASCII 文本可以直接当作有效的 UTF-8 文本处理,迁移成本极低,因此随着 Web 的普及而迅速扩散。据 W3Techs 调查,约 98% 的网站使用 UTF-8。
字符编码搞错会怎样 - 乱码的实例
字符编码不一致会引发各种各样的麻烦。
- UTF-8 文件用 Shift_JIS 打开:日语会变成"譁ー蜒""縺ゅ>縺"这类不知所云的字符串。这是因为 UTF-8 的 3 字节序列被误当成 Shift_JIS 的 2 字节字符来解释。
- Shift_JIS 文件用 UTF-8 打开:会显示出大量"�" (U+FFFD、Replacement Character),或者部分字符缺失。原因是 UTF-8 解码器检测到非法字节序列后将其替换为替换字符。
- CSV 文件乱码:用 Excel 打开 CSV 文件时出现乱码,据说原因是 Excel 默认把 CSV 的编码视为 Shift_JIS (Windows)。要正确打开 UTF-8 的 CSV,需要保存为带 BOM (Byte Order Mark) 的 UTF-8,或者在 Excel 的"数据"→"从文本文件"中指定编码。
- 数据库乱码:在 MySQL 中把表的字符集留作
latin1就存入日语数据,会导致数据损坏。一旦损坏就很难恢复,因此在数据库设计阶段就把字符集设为utf8mb4十分重要。之所以要用utf8mb4而不是utf8,是因为 MySQL 的utf8只支持到 3 字节,无法存放表情符号 (4 字节)。 - BOM 的陷阱:BOM (Byte Order Mark、U+FEFF) 是附加在 UTF-8 文件开头的 3 字节 (
EF BB BF) 标记。它对 Excel 读取 CSV 有效,但在 PHP 或 Python 的脚本中,BOM 会作为"看不见的字符"混入输出,引起 HTTP 头发送错误或 JSON 解析失败。Unix/Linux 系的工具 (cat、grep等) 也把 BOM 当作普通字符处理,因此 shell 脚本开头带 BOM 时#!/bin/bash就无法被识别,执行随之失败。在 UTF-8 中 BOM 属于不推荐做法,Web 开发中使用不带 BOM 的 UTF-8 更安全。
对字符计数的影响
同一字符串在不同编码下字节数不同。例如"你好"是 2 个字符,但 UTF-8 下为 6 字节。日语的"こんにちは"是 5 个字符,UTF-8 下为 15 字节,Shift_JIS 下则为 10 字节。字符计数器同时显示字符数和字节数,可根据用途进行确认。
还需注意的是 Unicode 的"规范化" (Normalization) 问题。看起来相同的字符,内部有可能由不同的码位序列表示。例如日语的"が"既可以表示为 1 个码位 U+304C (NFC 形式),也可以表示为"か" (U+304B) 加浊点 (U+3099) 两个码位 (NFD 形式)。macOS 的文件系统 (APFS/HFS+) 倾向于使用 NFD 形式,因此把在 macOS 上创建的文件名拿到 Windows 或 Linux 上处理时,有时会出现找不到文件的问题。在程序中做字符串比较时,建议事先规范化为 NFC。
表情符号与 Unicode - 看起来 1 个字符未必就是 1 个字符
表情符号是 Unicode 中特别复杂的领域。表情符号的字数计算原理一文有详细解析:看起来是 1 个字符,内部却可能由多个码位组成。例如家庭表情符号"👨👩👧👦"由 7 个码位构成,某些程序会将其计为 7 个字符。
这一机制是通过名为 ZWJ (Zero Width Joiner、零宽连接符、U+200D) 的特殊字符实现的。"👨👩👧👦"实际上是"👨 + ZWJ + 👩 + ZWJ + 👧 + ZWJ + 👦"这 7 个码位的连结。肤色变体 (👋🏻👋🏽👋🏿) 也由基本表情符号加肤色修饰符 (Skin Tone Modifier) 两个码位构成。因此 JavaScript 中 "👨👩👧👦".length 返回的是 11 (因为还包含 UTF-16 的代理对)。要取得准确的"视觉字符数",需要使用 Intl.Segmenter API 或 Unicode 的书写素簇分割。
专业人士实践的字符编码管理技巧
下面介绍专业工程师和写作者为防止字符编码相关麻烦而实践的技巧。
- 用项目的
.editorconfig统一编码。只要设定charset = utf-8,团队全体成员就能以相同的编码创建文件。 - 用 Git 的
.gitattributes管理换行符和编码。设定* text=auto之后,就能自动处理不同操作系统之间换行符的差异。 - 随时通过文本编辑器的状态栏确认编码。VS Code 会在右下角显示编码,点击即可进行转换。
- 在 API 和数据库中把
utf8mb4定为标准。不要"先用 UTF-8 凑合",而是从一开始就选择支持表情符号的utf8mb4,可以避免日后的迁移成本。
Unicode 的"未来" - 尚未填满的空间
Unicode 的设计最多可容纳 1,114,112 个码位 (U+0000 至 U+10FFFF),但截至 2024 年,已分配的码位据说只有约 15 万个。也就是说整体只用掉了约 13%。剩下的空间包含今后有待发现和整理的历史文字体系、新的表情符号,以及为未知用途保留的预留区域。Unicode 联盟每年都会发布新版本,仅表情符号每年就会追加数十到一百个以上。字符编码的世界至今仍在持续演进。
常见误解与注意事项
关于 Unicode,即使是经验丰富的开发者也容易误解的地方有以下几点。
- "Unicode = UTF-8"是错误的:Unicode 是字符集的规格,UTF-8 是其编码方式之一。UTF-16 和 UTF-32 也都是 Unicode 的编码方式。
- "1 个码位 = 1 个字符"是错误的:由于组合字符 (重音符号等) 和 ZWJ 序列 (表情符号) 的存在,多个码位有可能构成一个"视觉字符" (书写素簇)。
- "UTF-8 总是 3 字节"是错误的:日语的平假名、片假名和汉字是 3 字节,但 ASCII 为 1 字节,部分欧洲语言字符为 2 字节,表情符号为 4 字节。
- "用
String.length就能得到字符数"是危险的:JavaScript 的.length返回 UTF-16 编码单元数,因此对包含代理对的字符 (表情符号和部分汉字) 会得到比实际字符数更大的值。Python 3 的len()返回码位数,但这同样与书写素簇数不同。 - Shift_JIS 的"5C 问题":在 Shift_JIS 中,部分汉字的第 2 个字节与
0x5C(反斜杠) 取值相同。"表""能""ソ"等字符即属于此类,会在 C 语言和文件路径的处理中被误解释为转义序列。这也是强烈建议从 Shift_JIS 迁移到 UTF-8 的理由之一。
总结
Unicode 是现代文本处理的基础,理解其历史和机制可以从根本上解决乱码和字符计数问题。尤其是了解 UTF-8 成为 Web 标准的背景,以及表情符号的内部结构之后,在系统开发和内容制作中会有很多用得上的场合。请一边用字符计数器确认字符数和字节数,一边推进工作。