不知道你有没有遇到过,上线了一个功能,用户反馈姓名显示乱码。排查了一下午发现:用户在网页表单里输入了中文名,前端用 encodeURIComponent 编码,后端收到后存在 MySQL 的 latin1 编码的字段里。MySQL 读数据时自动把 latin1 转成 UTF-8,中文字符全变成了 ???。
这个问题的本质不是代码逻辑错了,是编码不一致。字符编码这种东西,不出问题的时候没人关心,一出问题就是连环坑。数据库连接配置、表字段编码、HTTP 响应头、HTML 页面的 meta 标签,任何一个环节编码不一致都可能出问题。
ASCII 的 7 位设计
ASCII 诞生于 1960 年代,用 7 位二进制表示 128 个字符:大小写字母、数字、标点和控制字符。A 是 65(0x41),a 是 97(0x61),0 是 48(0x30)。这些编号到现在仍然是所有编码方案的基石。所有的编码方案在设计时都保留了 ASCII 的范围,这是向后兼容的关键。
ASCII 在设计时留了一位不用。当时的通信线路只需要 7 位,第 8 位被用来做奇偶校验,或者被各厂商用来扩展自己的字符集。
扩展 ASCII 的问题就在这里——每家厂商的扩展互不兼容。IBM PC 用 Code Page 437,Windows 用 Windows-1252,ISO 标准用 ISO-8859-1。同样的字节 0x82,在 Code Page 437 中是 é,在 Windows-1252 中是 ‚,在 ISO-8859-1 中是未定义。早期做国际化软件的开发者,大部分时间在跟这些编码表做斗争。
之前的项目邮件模板里的版权符号 © 经常变成乱码。查了半天发现是邮件发送组件用 ISO-8859-1 编码,但模板文件保存的是 Windows-1252。这两个编码在大部分区域兼容,唯独在 0x80 到 0x9F 这个范围映射不一致。花了整整一天逐行对比编码表才定位到问题。
真正的问题在于:全球有几十种互不兼容的编码方案。你用 GB2312 保存的文件,拿到编码为 Shift-JIS 的系统上打开,全是乱码。每种语言都有自己的编码标准,日语有 Shift-JIS 和 EUC-JP,韩语有 EUC-KR,繁体中文有 Big5,简体中文有 GB2312 和 GBK。这种混乱必须有人来终结,而 Unicode 就是答案。
Unicode 的解决思路
Unicode 的思路很直接:给世界上所有字符分配一个唯一的数字编号。不管中文、阿拉伯文、emoji 还是古埃及象形文字,每个字符一个编号,叫码点(code point),写作 U+ 加十六进制数字。
U+0041 是 A,跟 ASCII 一致。这不是巧合,设计者刻意保留了 ASCII 的编号范围,确保 ASCII 文本可以直接映射到 Unicode。U+4E2D 是"中",U+1F600 是 😀。
目前 Unicode 定义了约 15 万个字符,还在增长。最新版本 Unicode 16.0 新增了五千多个字符,包括一些西非文字和乐器符号。Unicode 的版本迭代一直在进行,新的 emoji 和古文字不断加入。
需要注意的是:Unicode 只规定字符的编号,不规定怎么在计算机里存储这些编号。就像我给你一个电话号码,但我不管你是写在纸上、存手机里还是记在脑子里。UTF-8、UTF-16、UTF-32 都是存储方案,各有不同的设计取舍。同一个字符"中",编号是 U+4E2D,但在不同存储方案中占用的字节数不同、字节内容也不同。
平面的划分
Unicode 把码点空间分成 17 个平面,每个平面 65536 个码点。为什么是 17 个?因为 Unicode 码点的总范围是 U+0000 到 U+10FFFF,总共 1114112 个码点,正好可以分成 17 × 65536。
平面 0 叫基本多语言平面(BMP),范围是 U+0000 到 U+FFFF。覆盖了绝大多数现代语言使用的字符。你日常看到的中文、英文、日文、韩文基本都在这个平面内。BMP 的设计意图是把常用字符都放在这里,用一个 16 位的编码单元就能表示。
平面 1 是辅助多语言平面(SMP),U+10000 到 U+1FFFF。主要放历史文字和 emoji。😀 就在这里,这是后来很多 bug 的来源。emoji 不在 BMP 内,导致了 JavaScript 中 '😀'.length === 2 这个著名的诡异现象。
平面 2 是表意文字补充平面(SIP),U+20000 到 U+2FFFF。CJK 统一表意文字的扩展区,生僻汉字在这里。比如 𠀀(U+20000)是一个极少用的汉字,在康熙字典里有收录。
平面 3 到 13 是预留的。平面 14 到 16 是私有用途和特殊用途,用于厂商自定义字符。
BMP 内的字符用 UTF-16 编码只需要 2 个字节,BMP 外的需要 4 个字节。这个差异直接影响了 JavaScript 中字符串长度的表现。说 BMP 外的字符"需要代理对"不准确,准确的说法是"UTF-16 用代理对来编码 BMP 外的字符"。
UTF-8 的编码规则
UTF-8 是目前互联网上最流行的编码方案,设计非常巧妙。它是由 Ken Thompson 和 Rob Pike 在 1992 年设计的,最初用于 Plan 9 操作系统。后来被 Unix 和 Linux 广泛采用,最终成为 Web 的事实标准。
它的核心规则是根据 Unicode 码点的数值范围,使用不同长度的字节序列:
1 字节:0xxxxxxx,覆盖 U+0000 到 U+007F,正好是 ASCII 范围。这意味着所有 ASCII 文本天然就是合法的 UTF-8 文本。你不需要做任何转换,一个纯英文的 HTML 文件保存为 ASCII 和保存为 UTF-8,字节内容完全一样。
2 字节:110xxxxx 10xxxxxx,覆盖 U+0080 到 U+07FF,11 位有效数据位。主要覆盖拉丁语系扩展字符,比如 é、ñ、ü 这些带重音符号的字母。
3 字节:1110xxxx 10xxxxxx 10xxxxxx,覆盖 U+0800 到 U+FFFF,16 位有效数据位。包括 BMP 内的绝大多数字符。中文字符、日文假名、韩文谚文都在这里。一个中文字在 UTF-8 里占 3 个字节,这是文件体积变大的原因之一。
4 字节:11110xxx 10xxxxxx 10xxxxxx 10xxxxxx,覆盖 U+10000 到 U+10FFFF,21 位有效数据位。BMP 外的字符和 emoji 在这里。一个 emoji 占 4 个字节。
前缀 10 开头的字节是延续字节。0 开头的字节是单字节字符。110、1110、11110 开头的字节是多字节字符的起始字节。这种前缀设计使得字节在序列中的角色一目了然。
自同步是 UTF-8 的一个很棒的特性。如果你从一个字节序列的中间位置开始读,最多跳过一个字符就能找到同步位置。因为延续字节都以 10 开头,你只需要找到第一个不是 10 开头的字节,就知道一个新的字符开始了。这在网络传输中数据包被截断、或者文件损坏的场景下特别有用。相比之下,GBK 编码就不具备这个特性,丢失一个字节可能导致后面全部乱掉。
拿"中"字(U+4E2D)为例算一下编码过程:
U+4E2D = 0100 1110 0010 1101
U+4E2D 在 U+0800 到 U+FFFF 之间,用 3 字节模板
模板:1110xxxx 10xxxxxx 10xxxxxx
填入:11100100 10111000 10101101
十六进制:E4 B8 AD
这就是为什么你在 UTF-8 编码的文件里看到"中"字对应的字节是 E4 B8 AD。
再拿 A(U+0041)举例,它在 ASCII 范围内,用 1 字节模板:
U+0041 = 0100 0001
1 字节模板:0xxxxxxx
填入:01000001
十六进制:41
A 的 UTF-8 编码就是 0x41,和 ASCII 一致。
拿 é(U+00E9)举例,它在 U+0080 到 U+07FF 范围内:
U+00E9 = 0000 0000 1110 1001
2 字节模板:110xxxxx 10xxxxxx
填入:11000011 10101001
十六进制:C3 A9
é 在 UTF-8 里是 C3 A9,在 Latin-1 里是 E9,如果你把 UTF-8 编码的数据当成 Latin-1 读取,就会显示成两个字符 é。这个模式在乱码分析中很常见。
UTF-16 和 JavaScript 的坑
UTF-16 对 BMP 内的字符用 2 个字节存储,对 BMP 外的用 4 个字节。它的设计目标是在 BMP 字符占多数的情况下保持较高的存储效率。
BMP 外的字符在 UTF-16 里用代理对表示。码点先减去 0x10000,得到 20 位的中间值,高 10 位加上 0xD800 得到高位代理,低 10 位加上 0xDC00 得到低位代理。
拿 😀(U+1F600)举例:
码点减去 0x10000:0x1F600 - 0x10000 = 0xF600
0xF600 = 0000 1111 0110 0000 0000
高 10 位:0000111101 = 0x03D
低 10 位:1000000000 = 0x200
高位代理:0x03D + 0xD800 = 0xD83D
低位代理:0x200 + 0xDC00 = 0xDE00
UTF-16 编码:0xD83D 0xDE00
Java 和 JavaScript 的字符串内部使用 UTF-16 编码。这就是为什么 '😀'.length 在 JavaScript 中返回 2——一个 emoji 占用了两个 UTF-16 代码单元:
'😀'.length // 2
'中'.length // 1(在 BMP 内,一个代码单元)
[...'😀'].length // 1(通过迭代器按字符分割)
这个差异导致了大量前端 bug。很多输入框限制"最多 140 个字符",用 value.length 检查时 emoji 被算成了两个字符。用户明明只打了 100 个字加一个表情,却提示超出长度限制。正确的做法是用 [...str].length 或者 Array.from(str).length 来获取真正的字符数。
还有个更隐蔽的问题:字符串反转。'😀abc'.split('').reverse().join('') 的结果不是 'cba😀',而是乱码。因为 split('') 按 UTF-16 代码单元分割,把代理对拆开了。正确的反转应该用 [...str].reverse().join('')。
UTF-32 为什么不是首选
UTF-32 固定用 4 个字节存一个码点。简单直接,没有任何歧义。每个字符固定 4 字节,随机访问效率最高——想取第 N 个字符直接去第 N×4 字节位置读就行。
问题是太浪费了。"中"字(U+4E2D)在 UTF-32 中是 00 00 4E 2D,比 UTF-8 的 E4 B8 AD 多了一个字节。纯英文文本用 UTF-32 存储比 UTF-8 大 4 倍。一个 1KB 的英文 HTML 页面用 UTF-32 存会变成 4KB。
另外一个问题是字节序。4 字节的整数有大端和小端两种表示。UTF-32 文件经常需要在开头放 BOM 来标识字节序,增加了处理复杂度。在不同字节序的机器之间传输 UTF-32 数据,如果不处理字节序问题,读出来的全是乱码。
UTF-32 基本不用来做存储和传输。少数系统内部处理会用到它,比如某些 Windows API 的内部字符串表示。大部分情况下,UTF-8 是更好的选择。Unicode 联盟自己也推荐优先使用 UTF-8。
三种方案的横向对比
三种方案各有优劣,选哪个取决于场景:
UTF-8 是互联网的事实标准。ASCII 文本兼容,存储效率高,自同步,没有字节序问题。英文文档用 UTF-8 几乎不增加体积。中文文档体积大约是 GBK 的 1.5 倍——一个汉字在 GBK 里占 2 字节,在 UTF-8 里占 3 字节,大了 50%。但这个代价是值得的,因为 UTF-8 可以同时处理中文、日文、韩文、emoji 而不需要切换编码。Web 页面、API 接口、数据库连接现在默认都用 UTF-8。MySQL 从 8.0 开始默认字符集就是 utf8mb4,可以完整支持 4 字节 emoji。
UTF-16 在 BMP 内字符最多的场景下存储效率最高。中文、日文、韩文都在 BMP 内,用 UTF-16 存储是 2 字节,比 UTF-8 的 3 字节省空间。Windows NT 系列和 Java/JavaScript 内部使用 UTF-16。但 BMP 外的字符需要代理对,处理起来比 UTF-8 更复杂。判断一个字符串的字节长度时需要检查每个字符是否在 BMP 内。
UTF-32 的优势是固定长度,随机访问效率高。代价是体积大、字节序问题。除了某些需要随机访问字符的底层处理场景,基本不用。
如果你在选数据库编码,MySQL 从 5.5.3 开始支持的 utf8mb4 就是 UTF-8 的完整实现。旧版的 utf8 别名其实只支持最多 3 字节,会截断 emoji。
乱码是怎么产生的
乱码的原因非常简单:写入时用一种编码,读取时用另一种。
最常见的情况是 GBK 编码的文本被当作 UTF-8 读取。中文字符在 GBK 里是 2 字节,在 UTF-8 里是 3 字节。UTF-8 解码器遇到 GBK 编码的字节序列时,会发现很多不合法的字节组合,输出一堆 ���(U+FFFD 替换字符)。一个 GBK 编码的"中"字(D6 D0),用 UTF-8 解码时会发现 D6 不是合法的起始字节,两个字节都不能匹配 UTF-8 模板,输出两个 ���。
还有一种情况更隐蔽——Latin1 的"宽吞"特性。ISO-8859-1(Latin1)能解码任何字节序列而不报错,因为它把 0x80 到 0xFF 全部映射成了有效字符。如果 UTF-8 编码的数据被当作 Latin1 解码再按 UTF-8 重新编码,就会出现特殊形式的乱码。比如 é 的 UTF-8 编码是 C3 A9,被 Latin1 解码后变成两个字符 Ã(U+00C3)和 ©(U+00A9),再按 UTF-8 编码后显示为 é。这种模式很有特点——看到 à 开头的基本能断定是 UTF-8 被 Latin1 误读了。
之前有个同事PHP 程序输出的 JSON 在浏览器里显示为 ç§ 这样的乱码。原因是 PHP 的 json_encode 默认会把非 ASCII 字符转义成 \uXXXX 形式,为了"优化"关掉了转义选项,直接用原始 UTF-8 输出。页面在加载时响应头没有声明 charset=utf-8,浏览器用默认的 ISO-8859-1 解码。结果就是 UTF-8 的每个字节被单独解释成了一个字符。
解决这类问题的方法只有一条:从源头到终端的编码保持一致。数据库连接用 SET NAMES utf8mb4,页面声明 charset=utf-8,文件保存选 UTF-8 without BOM,编辑器的编码也设成 UTF-8,HTTP 响应头加 Content-Type: text/html; charset=utf-8。全链路统一,问题自然消失。
BOM 带来的麻烦
BOM(Byte Order Mark)是放在文件开头的几个特殊字节,用来标识编码方式和字节序。
UTF-8 的 BOM 是 EF BB BF。UTF-16 LE 是 FF FE,UTF-16 BE 是 FE FF。
Windows 平台的编辑器默认会在 UTF-8 文件开头加 BOM。记事本的"另存为 UTF-8"保存的文件就有 BOM。但 Unix/Linux 下的工具不认识它。编译器、脚本解释器、Shell 会把 EF BB BF 当成普通文本内容处理,导致第一行出现不可见的额外字符。
我在前端项目里遇到过这个问题。一个 CSS 文件被 Windows 上的同事编辑后加上了 BOM,浏览器解析 CSS 时第一行被解释为无效内容,整个样式文件加载异常。构建工具里加了自动去除 BOM 的处理步骤后问题解决。Git 的 core.autocrlf 配置也解决不了 BOM 问题——BOM 是内容层面的,不是换行符层面的。Linux 上可以用 file 命令检查文件是否包含 BOM:file myfile.css,如果输出包含 "UTF-8 (with BOM)" 就需要处理。
三种编码方案对同一个字符的字节表示对比:
工具验证
调试编码问题的时候,我经常需要确认某个字符在不同编码下的字节序列。 Unicode 编解码工具 可以直接输入字符查看它的 UTF-8、UTF-16、UTF-32 编码结果。排查乱码时,把可疑的乱码字符放进去转一圈,通常能看出来它是从哪种编码"误读"过来的。比如看到 é,在工具里查一下 à 和 © 对应的 UTF-8 编码,再和 é 的 UTF-8 编码对比,很快就能确认是 UTF-8 → Latin1 的误读链条。
写在最后
字符编码的问题平时不会主动找你,但一旦碰上就是大半天。理解 Unicode、UTF-8、UTF-16 的关系后,排查乱码问题会快很多。关键是记住一条原则:全链路统一用 UTF-8。数据库、文件、HTTP 响应头、HTML 页面,全设成 UTF-8,绝大多数编码问题都不会发生。
如果你现在还没遇到编码问题,可能只是运气好,迟早会碰上的。提前理解这些基础原理,遇到的时候不至于抓瞎。