字符编码、Unicode 和 UTF-8:乱码到底是在哪一层出的问题

乱码最容易被误判成玄学,因为现象看起来毫无规律:emoji 昵称进库后变成 ????,订单 CSV 在 Excel 里打开像天书,老接口返回的中文有人看正常有人看乱码。可这类问题真正的根因其实很朴素,都是同一条链路上的“字典”对不上了。

字符先要被表示成码点,再要被编码成字节,最后还要按某种编码规则解回字符。**只要存的时候用的是一套规则,读的时候查的是另一套规则,字节本身没变,看到的字就会变。**上面那三种情况,分别踩中了数据库存储、文件导出和接口传输里的不同环节。

后面不打算把编码讲成术语表,而是顺着这几个具体问题,把字符集、编码方式、UTF-8、Unicode、URL 编码和常见乱码排查方法重新串成一条线。

先分清两件事:字符集和编码

补课时最容易绕晕的地方,是把"字符集"和"编码方式"当成一回事。它俩其实是两层:

  • 字符集:决定"有哪些字符,每个字符叫什么号"。比如 Unicode 规定"你"这个字的编号(码点)是 U+4F60。这只是个抽象的数字,跟它在硬盘上占几个字节没半点关系。
  • 编码方式:决定"这个号怎么变成字节存起来 / 传出去"。同一个码点 U+4F60,用 UTF-8 存是 3 个字节,用 UTF-16 存是 2 个字节,用 GBK 存又是另外的字节。

想明白这一层,乱码的本质就清楚了:用 A 编码存进去的字节,用 B 编码读出来,于是字节没变,但查的"字典"变了,解出来的字符自然就错位。运营那份 CSV 就是典型——UTF-8 写入,GBK 读取。

ASCII:一切的起点

ASCII 是最早的方案,用一个字节里的低 7 位,覆盖英文字母、数字、标点和一些控制符,总共 128 个。

1'A' -> 65   (0x41)
2'a' -> 97   (0x61)
3'0' -> 48   (0x30)
4'\n' -> 10  (换行)

在浏览器控制台随手验证:

1'A'.charCodeAt(0) // 65
2String.fromCharCode(97) // 'a'

ASCII 的格局太小,一个字节最多 256 个位置,128 个还空着没用满,根本塞不下中文几万个常用字。于是各国各搞各的:中国大陆有 GB2312、后来扩展成 GBK、GB18030,台湾有 Big5,日本有 Shift_JIS……这就是早年间网页满天飞乱码的根源——同一段字节,北京的机器按 GBK 解、东京的机器按 Shift_JIS 解,结果谁也认不出谁。我们那个偶发乱码的老接口,根子也埋在这段历史里,后面会细说。

Unicode:给全世界的字符发身份证

Unicode 想终结这种混乱:给地球上所有字符发一张全球唯一的"身份证号",也就是码点(code point),写法是 U+ 加十六进制。

1U+0041  ->  A
2U+4F60  ->  你
3U+597D  ->  好
4U+1F600 ->  😀(emoji 也有码点)

ES6 之后 JS 里可以直接拿到码点:

1'你'.codePointAt(0).toString(16) // '4f60'
2String.fromCodePoint(0x4f60)     // '你'

这里就要说回第一个问题——"评价被截出半个字"的那一半了。JS 字符串内部是 UTF-16,而 emoji 这类码点超过 U+FFFF 的字符,会被拆成两个"代理对"(surrogate pair),占两个 length

1'😀'.length        // 2,不是 1!
2'😀'.charAt(0)     // 半个字符,乱码
3[...'😀'].length   // 1,扩展运算符按码点切分

我们评价发布前有一段"超长截断"逻辑,用的是 str.slice(0, 500)。用户的评价刚好 500 字出头,第 500 个位置落在一个 emoji 的代理对中间,一刀下去把 emoji 劈成两半,前半个是个孤立的高位代理,存进去再读出来就是问号方块。这不是数据库的错,是前端的刀切歪了。

这事不光 emoji,任何超出 U+FFFF 的字符都一样。拿数学粗体的 𝟙(U+1D7D9)在控制台对照一下就清楚了:

1'好'.length        // 1,基本多文种平面里的汉字占 1 个 UTF-16 码元
2'𝟙'.length         // 2,码点 > U+FFFF,被拆成代理对,占 2 个码元
3[...'𝟙'].length    // 1,扩展运算符按码点切分,才是"1 个字符"

顺带把两个常被混的方法也跑一下——对基本平面里的字符,它俩结果一样:

1'好'.charCodeAt(0)   // 22909,返回 UTF-16 码元
2'好'.codePointAt(0)  // 22909,返回完整码点

差别只在代理对上:'𝟙'.charCodeAt(0) 只会拿到前半个高位代理 55349,而 '𝟙'.codePointAt(0) 能识别出完整码点 120793(即 0x1D7D9)。所以处理可能含 emoji、生僻字的文本,认 codePointAt[...str]

截断的修复很直接:先按码点切分,再截长度。

1// 修复后的截断:按码点切,不劈代理对
2function truncate(str, max) {
3  var chars = Array.from(str) // 等价于 [...str]
4  return chars.length > max ? chars.slice(0, max).join('') : str
5}

修完之后我多留了个心眼,拿组合 emoji 又试了一轮,发现按码点切也不是终点。像"一家三口"这种 emoji,其实是多个 emoji 用零宽连接符(ZWJ,U+200D)粘起来的序列:

1'👨‍👩‍👧'.length      // 8,三个 emoji 加两个零宽连接符
2[...'👨‍👩‍👧'].length  // 5,按码点切也还是 5 个码点

按码点截断有可能把"一家人"切散成三个独立的人头——不算乱码,但用户看着也奇怪。真要按"用户眼里的一个字"切分,得按字素簇(grapheme cluster)算,2019 年的浏览器里没有现成的标准 API,讲究的话只能上 grapheme-splitter 这类库。我们最后的取舍是:按码点切保证不出乱码,字素簇级别的完美不追求,毕竟评价截断劈散一个组合 emoji 的概率太低,为它引一个库不值。取舍也是设计的一部分。

还有个更隐蔽的"长度玄学":同一个看起来一样的字符,可能有「组合」和「分解」两种码点序列。比如 é 既可以是单个码点 U+00E9,也可以是 e(U+0065)后跟一个组合用的重音符(U+0301)。这俩肉眼一模一样,但 length 不同:

1'café'.normalize('NFC').length  // 4,重音并进一个码点
2'café'.normalize('NFD').length  // 5,重音被拆成独立的组合字符

所以从不同来源(比如 macOS 文件名常用 NFD)拿到的字符串,直接 === 比较可能不相等,比较前先 normalize('NFC') 统一一下。

但记住:Unicode 只是"编号标准",它没规定怎么存。怎么把这些编号落到字节,是 UTF-8 / UTF-16 这些编码方式的事。

UTF-8:为什么它赢了

UTF-8 是 Unicode 最主流的编码方式,变长设计:

  • ASCII 范围(U+0000 ~ U+007F):1 个字节,且和 ASCII 完全兼容
  • 常见中文(基本在 U+4E00 ~ U+9FFF):3 个字节
  • emoji 等增补字符:4 个字节
1A   -> 41                (1 字节)
2你  -> E4 BD A0          (3 字节)
3😀  -> F0 9F 98 80       (4 字节)

在 Node 里能直观看到字节:

1Buffer.from('A').length    // 1
2Buffer.from('你').length   // 3
3Buffer.from('你').toString('hex') // 'e4bda0'
4Buffer.from('😀').length   // 4

浏览器里不开 Node 也能验证字节数——Blobsize 就是按 UTF-8 编码后的字节长度:

1new Blob(['A']).size   // 1
2new Blob(['好']).size  // 3,一个汉字在 UTF-8 下占 3 字节
3new Blob(['😀']).size // 4

注意 '好'.length1(一个 UTF-16 码元),而 new Blob(['好']).size3(UTF-8 字节数)——"字符个数"和"字节数"是两码事,这正是前面字符集 vs 编码那层区别的直观体现。

UTF-8 能赢,靠的是两点:兼容 ASCII(老的纯英文系统不用改),以及它的字节有"自同步"特性——每个多字节字符的首字节会标明长度,后续字节都以 10 开头,所以哪怕从中间截断,解码器也能很快重新对齐,不会一错错到底。

UTF-16 就没这么省心。它以两个字节为单位,于是有"哪个字节在前"的问题——大端(BE)和小端(LE)两种字节序, 在 UTF-16BE 里是 4F 60,在 UTF-16LE 里是 60 4F。为了让读的人知道用哪种,UTF-16 文件开头常放一个字节序标记(BOM):FE FF 是大端,FF FE 是小端。UTF-8 因为以单字节为单位,根本没有字节序问题,它的 BOM(EF BB BF)纯粹是个"我是 UTF-8"的签名,可有可无——这个签名后面讲 CSV 乱码时会当主角。变长但无字节序,是 UTF-8 赢过 UTF-16 的第三个理由

昵称变 ???? 的真凶:MySQL 的 utf8 不是 UTF-8

注意上面 emoji 是 4 个字节。另一个问题——"昵称整个存成 ????"的根因就在这——DBA 一查,昵称字段的字符集是 MySQL 的 utf8。这个名字是个著名的坑:MySQL 的 utf8 只支持最多 3 字节,存不了 4 字节的 emoji,真正完整的 UTF-8 在 MySQL 里叫 utf8mb4。emoji 进了 3 字节的字段,要么报错,要么被替换成问号,具体看 SQL mode。

1-- MySQL 想完整支持中文 + emoji,认准 utf8mb4
2CREATE DATABASE app DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
3-- 连接也要设,否则连接层会用默认字符集做一次转换
4SET NAMES utf8mb4;

库、表、连接三层字符集要全对齐,漏一层都可能在中间被转一道。后端把昵称和评价相关的表刷成 utf8mb4 之后,emoji 昵称就正常存取了。这个问题其实是两处叠加:前端的截断劈开了代理对,数据库的 utf8 又吞了四字节字符

网页声明编码:meta 不是老大,响应头才是

老接口偶发乱码这个问题,查的是另一条线。先说结论用得上的背景知识。网页里声明编码最常见的就是:

1<meta charset="utf-8">

但我必须强调:这个 meta 只是"建议",不是"命令"。 如果你的 HTML 文件本身是用 GBK 保存的,或者服务器响应头里 charset 写的是别的,浏览器很可能不理会这个 meta。编码这件事,声明的优先级是:HTTP 响应头 > 文件 BOM > HTML meta 标签。三者打架时,响应头说了算。

页面写了 <meta charset="utf-8"> 还是乱码,别百思不得其解,打开 Network 面板看一眼响应头,往往一下就能看出问题:

1Content-Type: text/html; charset=utf-8

接口返回 JSON 同理:

1Content-Type: application/json; charset=utf-8

各端的正确姿势:

1// Node / Express
2res.setHeader('Content-Type', 'application/json; charset=utf-8')
1# Nginx 静态资源兜底
2charset utf-8;

我们那个老接口正是这个问题:一个 PHP 老服务,本身输出的是 UTF-8 字节,但没设响应头 charset,部分浏览器按操作系统默认(中文 Windows 上是 GBK)去猜,于是"有人正常有人乱码"——正常的那批人浏览器猜对了而已。后端补一行 header('Content-Type: application/json; charset=utf-8') 就好了。前端怎么改都没用,因为锅在响应头。

顺带记一个这次查资料学到的兜底手段:如果真遇到必须对接的 GBK 老系统(返回的字节就是 GBK,改不了),前端可以用 fetch 拿到 ArrayBuffer,再用 TextDecoder 指定字典去解:

1fetch('/legacy/api').then(function (res) {
2  return res.arrayBuffer()
3}).then(function (buf) {
4  var text = new TextDecoder('gbk').decode(buf)
5  console.log(text) // 正确的中文
6})

TextDecoder 支持 gbk 这个 label,比让后端改老系统现实多了。

URL 编码:另一套规则

排查间隙还顺出来一个历史遗留问题:搜索页带中文关键词偶尔查不到结果。这牵出 URL 编码这条线。

URL 里只能安全地出现一部分 ASCII 字符,中文、空格、&#? 这些要么非法、要么有特殊含义,必须做百分号编码(percent-encoding)。它的逻辑是:先把字符按 UTF-8 转成字节,再把每个字节写成 %XX

1encodeURIComponent('前端开发')
2// '%E5%89%8D%E7%AB%AF%E5%BC%80%E5%8F%91'

注意上面的字节 E5 89 8D ... 正好就是这几个汉字的 UTF-8 编码,串起来加 % 而已。

encodeURIencodeURIComponent 的区别是新手必踩的坑:

1encodeURI('https://a.com/搜索?q=前端&p=1')
2// 不会编码 :/?&= 等结构字符,用于编码整条 URL
3
4encodeURIComponent('前端&p=1')
5// 会把 & 也编码成 %26,用于编码单个参数值

规则很简单:拼参数值,永远用 encodeURIComponent 不然参数值里如果带个 &,会被当成参数分隔符,把后端的参数解析整个搞乱。我们搜索页那个 bug 就是有人手拼了 URL:

1// 正确
2var url = '/search?q=' + encodeURIComponent(keyword) + '&page=' + page
3
4// 错误:keyword 里有空格、& 就废了
5var url = '/search?q=' + keyword

空格还有个专属陷阱。encodeURIComponent(' ') 得到 %20,但表单以 application/x-www-form-urlencoded 提交时,空格按老规矩被编成 +。于是后端按表单规则解出来的 + 是空格,而前端拿 decodeURIComponent 去解 query 里的 +,它原样保留——用户搜"手机 壳",链接被某个中间环节转成 q=手机+壳,前端回显时就成了"手机+壳"。碰到 query 里的 +,要么先 replace(/\+/g, '%20') 再 decode,要么统一约定链路上只用 %20%20+ 都表示空格,但分属两套规则,混用就出戏。

还有个反复坑人的现象:双重编码。前端 encode 了一次,某个网关或后端框架又自动 encode 了一次,% 自己变成了 %25,最后参数里全是 %2525 这种。排查办法是在链路每一段把原始值打出来,看是哪一环多编了一次。反方向也有坑:对一段不合法的百分号序列调 decodeURIComponent,会直接抛 URIError: URI malformed——线上有用户手改地址栏参数就能触发,解码外部传入的参数记得用 try/catch 包一层,别让一个脏参数白屏整个页面。

再提一个跟"编码"沾边的经典坑:btoa。很多人拿它做 Base64,遇到中文直接抛 InvalidCharacterError,因为 btoa 只认 Latin-1 范围的字符。老办法是先过一道 URL 编码:

1// btoa('你好') 直接报错
2btoa(unescape(encodeURIComponent('你好'))) // '5L2g5aW9'
3// 解回来
4decodeURIComponent(escape(atob('5L2g5aW9'))) // '你好'

escape/unescape 是古董 API,正经字符串处理别用它们,但在这个 Base64 转换的固定搭配里它们还有一口饭吃。

CSV 乱码:BOM 的取舍

最后回到运营那份 CSV 乱码的问题。Excel(尤其是 Windows 版)打开 CSV 时,不看文件内容是不是 UTF-8,默认就按系统区域编码(中文环境是 GBK)去解。文件明明是规规矩矩的 UTF-8,它偏要按 GBK 读,于是中文全乱。

解决办法是在文件开头加一个 UTF-8 BOM(字节 EF BB BF),相当于给 Excel 递个明确的纸条:"我是 UTF-8,请按这个读。"

1function downloadCsv(rows, filename) {
2  var csv = rows.map(function (row) {
3    return row.map(function (cell) {
4      // 处理含逗号、引号、换行的单元格
5      var s = String(cell).replace(/"/g, '""')
6      return /[",\n]/.test(s) ? '"' + s + '"' : s
7    }).join(',')
8  }).join('\r\n')
9
10  // 关键:开头加 UTF-8 BOM(),否则 Excel 用 GBK 解 UTF-8 必乱
11  var blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' })
12
13  var url = URL.createObjectURL(blob)
14  var a = document.createElement('a')
15  a.href = url
16  a.download = filename
17  a.click()
18  URL.revokeObjectURL(url)
19}
20
21downloadCsv([['姓名', '城市'], ['张三', '北京']], '订单.csv')

'' 就是 BOM 的码点,写在字符串最前面,浏览器生成 Blob 时会把它编码成那三个字节。

但 BOM 不是免费午餐,这是个取舍:加了 BOM,Excel 高兴了,可有些程序(某些 Linux 命令行工具、老的 JSON 解析器、shell 脚本)会把开头那三个字节当成正文内容,反而出问题。我们最后定的规矩是——给人用 Excel 打开的导出文件加 BOM;给机器解析、给程序对接的文件绝不加 BOM。两类需求分两个接口参数区分,别图省事都加上。

一条完整的排查链路

几个乱码问题查完,我把整条链路画在了笔记本第一页。乱码出现时,别一上来就改前端,先在脑子里过这条链路,逐段定位:

1源文件编码 → 文件读取编码 → 程序内部处理 → 数据库存储编码 → 接口响应头 → 页面/Excel 解码

我现在的实际排查顺序:

  1. 源文件:用编辑器右下角看一眼文件本身是不是 UTF-8(VS Code 直接显示)。
  2. HTML meta<meta charset="utf-8"> 是否声明,且和实际文件编码一致。
  3. 响应头:Network 面板看 Content-Type 里的 charset,这一步最常出问题(老接口那次)。
  4. 接口传参:参数值是否用 encodeURIComponent 编码,是否被双重编码(搜索页那次)。
  5. 数据库:库、表、连接三层字符集是否都是 utf8mb4(emoji 昵称那次)。
  6. 程序内部处理:截断、比较、算长度是否按码点来,有没有劈开代理对(评价截断那次)。

一个快速自检的小函数

排查时我常在控制台跑这么个小工具,把一个字符的码点、UTF-8 字节、URL 编码一次性打出来,肉眼对一下哪里不对:

1function inspect(ch) {
2  return {
3    char: ch,
4    codePoint: 'U+' + ch.codePointAt(0).toString(16).toUpperCase(),
5    utf8: [].map.call(
6      new TextEncoder().encode(ch),
7      function (b) { return b.toString(16).padStart(2, '0') }
8    ).join(' '),
9    urlEncoded: encodeURIComponent(ch)
10  }
11}
12
13inspect('你')
14// { char: '你', codePoint: 'U+4F60', utf8: 'e4 bd a0', urlEncoded: '%E4%BD%A0' }

TextEncoder 在现在的主流浏览器里已经能直接用,输出的就是 UTF-8 字节,比自己手算方便太多。查 emoji 昵称那个问题时,我就是拿它把用户昵称一个字符一个字符打出来,看到 4 字节的 f0 9f ... 才把矛头指向数据库字段的。

小结

这几个问题,最后都能浓缩成同一句话:乱码 = 字节没变,但解码用的"字典"换了。

Unicode 负责给字符发全球唯一编号,UTF-8 是把编号落成字节的主流编码方式。真正的功夫不在背概念,而在于能在脑子里画出"源文件 → 数据库 → 响应头 → 页面"这条链路,知道每一环用的是什么编码,并且让它们对齐。

它不是玄学。下次再看到一屏方块字,打开 Network 面板看响应头,按链路一段段查,十有八九能定位到是哪一环用错了字典。这个月排查的几个问题,根因都在链路上明晃晃地站着,只是以前不知道往哪看。