Skip to content
返回博客
教程

UTF-8 vs UTF-16 vs Unicode 编码完全指南

UTF-8、UTF-16、UTF-32 编码完整解析:码点、代理对、BOM、MySQL utf8mb4 陷阱与 JS string.length 谎言。了解如何选对编码、规避乱码与 emoji 截断。

12 分钟

UTF-8 vs UTF-16 vs Unicode 编码完全指南

先回答多数人搜索 utf-8 unicode encoding 时真正想知道的事:Unicode 和 UTF-8 不是同一回事。Unicode 是一张巨大的编号表,给每个字符分配一个码点(codepoint,例如 U+1F600)。UTF-8、UTF-16、UTF-32 是三种编码,是把这些码点转成字节的不同方式。新项目里默认选 UTF-8:英文文本下它与 ASCII 字节级兼容,emoji 用 4 字节表达,JSON、HTML5 和大多数现代协议都强制要求它。

这份指南写给被坑过的开发者:MySQL 在 😀 上抛出 Incorrect string value、JavaScript 里 "😀".length === 2 的意外、CSV 在 cat 下正常但在 Excel 里乱码。本文从码点出发,依次讲 UTF-8 字节机制、代理对、BOM、九种语言的默认行为,以及生产中会踩的八个坑,最后给一份决策矩阵和 FAQ。

想边读边验证字节序列?把任意字符串粘进 Base64 在线编码解码 - 免费即时转换工具,解码出来的 payload 就是本文要讲的 UTF-8 字节流。

为什么编码问题在 2026 年仍然咬人

三个场景,都是过去十二个月里真实发生过的 bug:

  1. MySQL 拒收 emoji。 用户提交 Hello 😀,服务器返回 Incorrect string value: '\xF0\x9F\x98\x80'。表是 utf8 字符集,开发者心想「那不就是 UTF-8 吗?哪里出问题了?」答案藏在 MySQL 的历史包袱里(见第 7 节)。
  2. 字符计数器上线即坏。 一个 280 字符的推文校验器用 text.length,前端放过了塞满 emoji 的文案,API 却拒收。反过来也会发生:合法内容被前端误拒。症状在第 4 节解析。
  3. 本地 HTML 变成「中文」。 开发者用 Windows-1252 保存文件,浏览器猜成 UTF-8 打开,乱码就铺开来。这是第 5 节要讲的 BOM 与 charset 声明,和 URL 编码与解码:百分号编码开发实战指南 中字节与字符错配毁掉查询字符串的故事是同一类问题。

读完最后一页,你应该能做到几件事:用一句话区分 Unicode 与 UTF-8;为新项目在 UTF-8、UTF-16、UTF-32 之间作出选择;在主流语言里写出能正确计数 emoji 的代码;仅凭字节流调试 charset bug。字符编码的坑很深,但日常用到的表面其实不大。

什么是 Unicode?码点 vs 字符 vs 字形

Unicode 是一张字符表,给每个字符分配一个唯一数字,叫码点,例如 U+1F600。UTF-8、UTF-16、UTF-32 是把这些码点翻译成字节的编码。Unicode 本身不存储任何字节,它只定义从抽象字符到整数的映射。

下面三个术语经常被混淆,因为它们常指向同一个肉眼可见的符号:

必须分开的三个层次

  • 码点U+0041U+1F600):Unicode 分配的整数。整个空间从 U+0000U+10FFFF,约 110 万个槽位,目前已分配约 15 万个。
  • 字符(或抽象字符):语义身份,比如「拉丁大写字母 A」「咧嘴笑脸 emoji」。
  • 字形(glyph):字体渲染出来的视觉形状。一个字符可以有许多字形:衬线体 A、斜体 A、手写体 A。Unicode 不关心字形。
  • 字素簇(grapheme cluster):用户感知到的「一个字符」。通常是一个码点,有时是好几个。字母 á 可以是单个码点 U+00E1,也可以是两个码点 a + U+0301(组合锐音符);字符与字数限制完全指南 2026 — Twitter、SMS、SEO、Instagram 探讨了 Twitter、SMS、SEO 各自如何在这里画线。

如果只能记一件事,就记住:码点 → 编码 → 字节 → 渲染。每个箭头都可能独立崩坏。

码点写法:U+XXXX\uXXXX

码点有几种常见写法。U+0041 是 Unicode 规范写法:4 到 6 位十六进制数字,前缀 U+。在源代码里:

  • JavaScript / JSON"A"(4 位十六进制,仅 BMP)和 "\u{1F600}"(ES6 花括号语法,任意码点)。
  • Python"A"(4 位)、"\U00000041"(8 位,大写 U)、"\N{LATIN CAPITAL LETTER A}"(按名称)。
  • Shell / git log / sed 输出:常看到原始 UTF-8 字节,例如 é 显示为 \xc3\xa9。那不是码点,是编码后的形态,正好引出第 3 节。

17 个平面:BMP 与之外

Unicode 把码点空间划分为 17 个平面,每个 65,536 个码点(17 × 2^16 = 1,114,112)。

  • 平面 0基本多文种平面(BMP):U+0000U+FFFF。拉丁、CJK 汉字、西里尔、阿拉伯、希腊,传统文本里见到的几乎所有字符都住在这里。
  • 平面 1-16辅助平面U+10000U+10FFFF。多数 emoji(U+1F600 及其同伴)、生僻 CJK 字符、历史文字(埃及象形文字、楔形文字)、乐谱符号。

U+FFFF 处的 BMP 与辅助平面分界线是本文最重要的一个数字。UTF-16 在此从「一码点一码元」变成「一码点两码元」,UTF-8 在此从 3 字节跳到 4 字节,MySQL 那个名不副实的 utf8 collation 也在此停摆。

用 emoji 做个快速验证

"a"        → 1 codepoint  U+0061             → 1 grapheme
"é" (NFC)  → 1 codepoint  U+00E9             → 1 grapheme
"é" (NFD)  → 2 codepoints U+0065 U+0301      → 1 grapheme
"😀"        → 1 codepoint  U+1F600 (Plane 1)  → 1 grapheme
"👨‍👩‍👧"      → 5 codepoints (3 people + 2 ZWJ U+200D) → 1 grapheme

最后一行才是真正棘手的地方。家庭 emoji 是用户感知到的一个字符,实际上由 5 个码点用零宽连接符(ZWJ)拼成。栈的每一层都可能给出不同的计数结果,第 7 节坑 6 就是这种分歧引发的 bug 报告。

UTF-8 编码机制:1 到 4 字节怎么工作

UTF-8 用 1 到 4 字节编码 Unicode 码点。ASCII 范围(U+0000U+007F)用 1 字节,并与 ASCII 字节级完全一致。更高的码点用多字节序列,其中首字节标明总长度,每个续字节都以 10xxxxxx 这一比特模式开头。这种自描述布局让 UTF-8 在历次编码方案竞争中胜出。

字节模式表

码点范围UTF-8 字节数字节模式
U+0000U+007F1 字节0xxxxxxx
U+0080U+07FF2 字节110xxxxx 10xxxxxx
U+0800U+FFFF3 字节1110xxxx 10xxxxxx 10xxxxxx
U+10000U+10FFFF4 字节11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

每个 x 是从码点二进制表示中取出的一个数据位。前导 0 / 110 / 1110 / 11110 告诉解码器总共有多少字节,前导 10 标识每一个续字节。这种冗余带来 UTF-8 的自同步特性:丢一个字节后能在下一个起始字节处恢复,不会一路坏下去。

工作示例:编码 (U+4E2D)

码点 0x4E2D 落在 U+0800U+FFFF,因此用 3 字节模板。

  1. 二进制:0x4E2D = 0100 1110 0010 1101(16 位)。
  2. 按 4-6-6 拆分填入 x 槽位:0100 / 111000 / 101101
  3. 代入 1110xxxx 10xxxxxx 10xxxxxx11100100 10111000 10101101
  4. 十六进制:0xE4 0xB8 0xAD

这就是为什么 经 URL 编码后变成 %E4%B8%AD:百分号编码把每个 UTF-8 字节包成 %XX,它并不直接编码码点。第 7 节坑 3 详细讲了这条链。

工作示例:编码 😀(U+1F600)

码点 0x1F600 超出了 BMP,因此用 4 字节模板。

  1. 二进制:0x1F600 = 0 0001 1111 0110 0000 0000(21 位,已补齐)。
  2. 按 3-6-6-6 拆分:000 / 011111 / 011000 / 000000
  3. 代入 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx11110000 10011111 10011000 10000000
  4. 十六进制:0xF0 0x9F 0x98 0x80

这四个字节就是让 MySQL 的 utf8 collation 失败的原因,它每字符最多分配 3 字节。第 7 节坑 1 给出修复方案。

UTF-8 的三个关键性质

  1. ASCII 兼容。 一份纯 ASCII 文本文件,在字节层面与其 UTF-8 编码完全一致。几十年来那些早于 Unicode 的工具(grepawk、经典 shell 管道)对这部分仍然适用。
  2. 自同步。 续字节总以 10 开头,永远不会与任何起始字节冲突。在网络传输中丢一个字节,可以在下一个字符边界重新对齐,而不会引发连锁乱码。
  3. 无字节序。 UTF-8 是字节流,不是 16 位或 32 位单元,因此与大小端无关。UTF-16 和 UTF-32 需要 Byte Order Mark 声明字节序;UTF-8 不需要,通常也不该加(见第 5 节)。

不合法的 UTF-8:规范禁止哪些

严格解码器会拒收以下字节序列:

  • 5 或 6 字节序列。 早期 RFC 允许过;RFC 3629(2003)把 UTF-8 上限定在 4 字节,以匹配 21 位的 Unicode 空间。
  • 过长编码。/ 编成三字节 0xE0 0x80 0xAF 而不是单字节 0x2F。曾是路径校验中目录穿越漏洞的高产源头,那些校验器先做卫生检查再解码。
  • 孤立代理码点U+D800U+DFFF)。这些保留给 UTF-16,绝不应出现在 UTF-8 里。
  • 截断序列。 3 字节起始字节后面只跟着一个续字节。当用户输入在多字节字符中间按字节边界被截断时,常见这种情况。

想直观看到这些,把字符串粘进 Base64 在线编码解码 - 免费即时转换工具,先编码再以字节形式解码,编解码器之间那段字节数组就是本节描述的 UTF-8 流。

UTF-16 与代理对:JavaScript length 为什么会撒谎

围绕 utf-8 vs utf-16 最常见的搜索其实是「为什么我的代码里 "😀".length 等于 2?」答案是代理对(surrogate pair),这是一个 1990 年代的决定,JavaScript、Java、C# 和 Windows 都继承了下来。

一段话讲完 UTF-16

UTF-16 用 16 位码元表示 Unicode。BMP 字符(U+0000U+FFFF)正好占 1 个码元。辅助平面字符(U+10000U+10FFFF)占 2 个码元,称为代理对:前一个高代理位于 U+D800U+DBFF,后一个低代理位于 U+DC00U+DFFFU+D800U+DFFF 这段区间在 Unicode 中被永久保留,没有任何真实字符住在里面。UTF-16 是 JavaScript、Java、C#(.NET)、Windows 内核 API、Objective-C NSString 和 Qt 的内部字符串格式。它们设计时都觉得 65,536 个字符已经够用了。

String.length 陷阱

"a".length          // 1   — BMP, single code unit
"é".length          // 1   — BMP (U+00E9), single code unit
"中".length         // 1   — BMP (U+4E2D), single code unit
"😀".length         // 2   — supplementary plane (U+1F600), surrogate pair!
"a😀".length        // 3   — one BMP + two surrogate units

String.prototype.length 报告的是 UTF-16 码元数量,不是字符数。来自辅助平面的任何字符都会被读作 2。同样的坑也存在于 Java 的 String.length() 和 C# 的 string.Length

在 JS 中正确计数码点

[..."😀"].length              // 1 — spread iterator walks codepoints
Array.from("😀").length       // 1 — Array.from also walks codepoints
"😀".match(/./gu).length      // 1 — /u flag = unicode-aware regex

// "😀".charAt(0) returns the lone high surrogate (visually broken)
"😀".codePointAt(0)           // 128512 — the full codepoint U+1F600

展开运算符和 Array.from 走的是迭代器协议,规范把它定义为按码点遍历。普通索引访问(str[0]charAt)仍然返回码元,会在 emoji 上给你半个代理对。

Python:len() 已经做对了(几乎)

len("😀")           # 1   — Python 3 strings are codepoint-indexed
len("👨‍👩‍👧")        # 5   — codepoints (3 humans + 2 ZWJ), not graphemes
# Python 2 was byte-indexed by default — len("😀") returned 4

Python 3 用一种弹性的 1、2 或 4 字节表示法存储字符串(PEP 393),并按码点索引。len("😀") 是 1,但这还不是字素数,家庭 emoji 仍会数成 5。要数用户感知的字符,得用字素库:JavaScript 里的 Intl.Segmenter(Node 22+、所有现代浏览器)、Python 里的 graphemeregex,或者直接用 Swift,它是主流语言里唯一默认按字素计数的 String.count

UTF-16 vs UCS-2:那次悄悄的迁移

1996 年之前,Unicode 承诺能塞进 16 位,对应的编码是 UCS-2,一种固定 2 字节映射。Unicode 2.0 加入辅助平面后打破了这个承诺。UTF-16 是用代理对打的补丁版本。JavaScript 规范在一些地方仍引用 UCS-2 的旧术语,因此语言容忍本该非法的孤立代理,「WTF-16」的段子是真的。Web 平台 API(DOM、fetchTextEncoder)会拒绝孤立代理,因为它们无法编码成合法的 UTF-8。

UTF-32、BOM 与字节序问题

UTF-32:简单但浪费

UTF-32 每码点固定 4 字节。U+0041 存为 0x00000041U+1F600 存为 0x0001F600。优点是常数时间的随机访问:第 n 个码点位于字节偏移 4n。缺点是体积:纯 ASCII 文本膨胀到 UTF-8 的 4 倍,CJK 文本也翻倍。几乎没有系统在磁盘上存 UTF-32。在内存中,Python 3 会根据最高码点在 1、2、4 字节之间选择;Linux fontconfig 栈对内存中的字形表使用 UTF-32。

字节序:为什么 UTF-16 / UTF-32 要在意大小端

UTF-8 是单字节流,不涉及字节序。UTF-16 和 UTF-32 以多字节单元操作,而不同 CPU 在「一个数字哪一端在前」这件事上意见不一。

U+0041 ('A') in UTF-16 BE → 00 41
U+0041 ('A') in UTF-16 LE → 41 00

x86 和 ARM CPU 是小端,老式 PowerPC 和「网络字节序」是大端。写 UTF-16 文件时必须选定一种,并告诉读取方是哪种,这就是 BOM 的用处。

BOM 是什么、什么时候用

Byte Order Mark 是放在文件开头的 U+FEFF。编码后,它同时宣告编码本身,并(对 UTF-16 / UTF-32)宣告字节序。

编码BOM 字节
UTF-8EF BB BF
UTF-16 BEFE FF
UTF-16 LEFF FE
UTF-32 BE00 00 FE FF
UTF-32 LEFF FE 00 00

utf-8 BOM 存在,但不携带字节序信息,因为 UTF-8 没有字节序。它的唯一作用是宣告「这个文件是 UTF-8」:对那些没有别的信号可依的工具有用,对那些期望文件以魔数或指令开头的工具有害。

BOM 决策矩阵:该加吗?

格式UTF-8 BOMUTF-16 BOMUTF-32 BOM
HTML不加(老解析器对 <!doctype> 检测会失败)
JSON不加(RFC 8259 明令禁止)
JavaScript / CSS 源码避免(老 Node 和 IE 会噎住)
用 Excel 打开的 CSV加(Excel 把不带 BOM 的 UTF-8 当 ANSI 读,CJK 乱码)
XML可选(XML 声明已经写了编码)必须必须
纯文本 .txt可选(Windows 记事本默认会加)必须必须

简短规则:web 上服务的任何东西都别加 UTF-8 BOM;想让 Excel 打开的 CSV 要加;其余让读取方自行决定。

9 种语言横向对比:默认编码行为

跨语言协作时,这部分知识就开始回本。同一个字符串 "a😀é",在 Bash 脚本里调用的每一个运行时中长度都不一样。

跨语言行为表

语言源文件编码字符串存储length / len 计的是默认 I/O 编码4 字节 emoji 安全?
JavaScript(V8 / SpiderMonkey)UTF-8UTF-16UTF-16 码元UTF-8(Node、Web)是,但 .length === 2
Python 3UTF-8(PEP 3120)动态 1 / 2 / 4 字节(PEP 393)码点UTF-8(PEP 540,3.7 起)是,len === 1
JavaUTF-8(javac 默认)UTF-16UTF-16 码元平台字符集 → UTF-8(JEP 400,JDK 18+)是,但 .length() === 2
GoUTF-8UTF-8 字节字节(用 utf8.RuneCountInString 得到码点数)UTF-8是,len(s) 返回字节数
RustUTF-8UTF-8 字节(String 不变量).len() 返回字节,.chars().count() 返回码点UTF-8是,显式
C#(.NET)UTF-8(.NET Core 3.0 起默认)UTF-16UTF-16 码元UTF-8(Encoding.Default,.NET 5 起)是,但 .Length === 2
RubyUTF-8(2.0 起)每串带编码标签码点(.lengthUTF-8是,length === 1
PHP(无源码编码)字节串字节(strlen);码点用 mb_strlen取决于 default_charset是,配合 mb_* 系列
MySQL列字符集字节(LENGTH)、字符(CHAR_LENGTHcharacter_set_* 系统变量仅当用 utf8mb4

这张表真正在告诉你什么

这里有三种实现哲学,对应三类 bug:

  • 内部 UTF-8(Go、Rust、Ruby)。原生字符串是字节;length 定义清晰,但只数它该数的东西。仅在跨越 UI 或校验边界时才转成码点或字素。
  • 内部 UTF-16(JavaScript、Java、C#)。承袭了 1990 年代的假设;length 是码元,代理对算 2。任何面向用户的计数都要用码点感知的迭代方式。
  • 按码点索引(Python 3)。len 给的是码点,乍看挺对,直到遇到 ZWJ emoji,这时还是要靠字素库。

PHP 是个特例。它的内置 str* 函数全部按字节操作,把 UTF-8 序列当不透明 blob。任何非 ASCII 项目都必须用 mb_*(多字节)系列,年复一年的 bug 报告说明这件事经常被忘掉。

实操指引:把 UTF-8 当作处处使用的传输格式(文件、HTTP body、数据库列),并在边界处转成运行时的原生字符串类型。这就是第 8 节会回到的「UTF-8 三明治」。

8 个真实工程陷阱

下面这些模式在国际化代码库的每次 code review 上都会冒出来。

坑 1:MySQL utf8 是 3 字节谎言,改用 utf8mb4

症状。 INSERT INTO users (bio) VALUES ('Hello 😀'); 返回 Incorrect string value: '\xF0\x9F\x98\x80' for column 'bio'

根因。 MySQL 历史上的 utf8utf8mb3 的别名:一种每字符上限 3 字节的 UTF-8 变体。任何 U+FFFF 以上的码点(所有 emoji、好几千个生僻 CJK 字符、所有历史文字)都需要 4 个 UTF-8 字节,因此被拒收。

修复。

ALTER DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE users CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
SET NAMES utf8mb4;  -- client connection
# my.cnf
[mysqld]
character-set-server = utf8mb4
collation-server     = utf8mb4_unicode_ci

MySQL 8.0 仍然保留 utf8 作为 utf8mb3 的别名。utf8mb3 已被弃用但尚未移除。每个新列、每个新数据库、每个新连接都用 utf8mb4,遗留变体没有任何收益可言。

坑 2:Windows-1252 回退,问号之谜

症状。 一个从 Windows 同事记事本里导出的 .txt,在他机器上显示 "smart quotes" 和一个长破折号。到你服务器上变成 ?U+FFFD(替换字符)。

根因。 老版本记事本默认 Windows-1252(CP-1252),它把弯引号 " 编为 0x93。UTF-8 解码器看到 0x93 是一个游离的续字节(高位 10)但前面没有起始字节,于是替换为替换字符。

修复。 检测源编码(Unix 上用 file、Python 里用 chardet / charset-normalizer、Node 里用 jschardet),用正确的解码器解码,再以 UTF-8 重新编码后保存。在入口处统一到 UTF-8,可以避免复发。

坑 3:URL 百分号编码 ≠ UTF-8(但建立在它之上)

症状。 fetch("/search?q=中文") 在一个后端框架返回 404,在另一个又能工作。

根因。 百分号编码作用在字节上,不是码点上。 是一个码点但有 3 个 UTF-8 字节(E4 B8 AD),每个字节单独百分号编码成 %E4%B8%AD,也就是 URL 中的 9 个 ASCII 字符。如果某个框架把 URL 当 Latin-1 而不是 UTF-8 解码,就会把这三个乱码字节当成三个单字节字符递给处理函数。

修复。 客户端用 encodeURIComponent("中文")(浏览器把 UTF-8 + 百分号编码一步完成),并确认服务端框架按 UTF-8 解码 URL(所有现代框架默认如此)。要直观确认,把 中文 粘进 URL 编码解码工具 — 在线 URL 解析器 | 百分号编码转换,看着它变成 %E4%B8%AD%E6%96%87。完整链条见 URL 编码与解码:百分号编码开发实战指南

坑 4:Base64 输入是字节,但你输入的是字符串

症状。 btoa("你好") 抛出 InvalidCharacterError: The string contains characters outside the Latin1 range

根因。 btoa 是 ASCII / Latin-1 时代设计的。它期望每个输入字符能塞进一个字节(码点 0-255)。你好 在 JS 引擎里是 UTF-16,码点 U+4F60 U+597D,都远超 255。

修复。 先编码成 UTF-8 字节,再对那些字节做 Base64 编码。

// Wrong:
btoa("你好");  // throws

// Correct:
const bytes = new TextEncoder().encode("你好");
// Uint8Array(6) [228, 189, 160, 229, 165, 189]
const b64 = btoa(String.fromCharCode(...bytes));
// "5L2g5aW9"

更长的故事见 什么是 Base64 编码?新手入门指南Base64 高级指南:MIME、Data URL、性能优化与安全实践Base64 在线编码解码 - 免费即时转换工具 一步完成转换,并展示中间字节流。

坑 5:用 String.length 做校验(Twitter / SMS 限制)

症状。 一个 280 字符的编辑器客户端校验通过,API 却返回 422。或者反过来:一个完全合规的帖子被客户端拒收。

根因。 JavaScript 的 .length 数的是 UTF-16 码元,一个 emoji 算作 2。Twitter 数的是码点(emoji = 1)。字符计数在两个方向上都可能算错,取决于你信哪个 API。

修复。[...text].length 数码点,或者用 Intl.Segmenter 拿到真正的字素数(Bluesky / iMessage 的做法)。各平台具体数字以及 SMS GSM-7 与 UCS-2 的分界,详见 字符与字数限制完全指南 2026 — Twitter、SMS、SEO、Instagram

坑 6:ZWJ emoji 家庭数成 N 个码点、1 个字素

症状。 "👨‍👩‍👧".length === 8。按码点数得到 5。对用户而言它是一张图。

根因。 零宽连接符(U+200D)把多个 emoji 码点粘成一个渲染单元——三个人形 emoji 加两个 ZWJ 等于 5 个码点、8 个 UTF-16 码元、1 个字素。

修复。

const seg = new Intl.Segmenter('en', { granularity: 'grapheme' });
[...seg.segment("👨‍👩‍👧")].length;  // 1

Intl.Segmenter 在 Node 22+ 和每个现代浏览器都可用。对更老的运行时,grapheme-splitter 包实现了 UAX #29。

坑 7:JSON \uXXXX 转义对 U+FFFF 以上的码点要用代理对

症状。 JSON payload 含 "😀",接收端解码器要么正确渲染为 😀,要么显示成两个方块,取决于它是否理解 JSON 里的代理对。

根因。 JSON 的 \uXXXX 转义只接受恰好 4 位十六进制数字,也就是一个 UTF-16 码元。要编码 😀U+1F600)就得用代理对 😀。JSON 里没有 \u{...} 花括号语法。

修复。 要么接受代理对(每个合规的解析器都能处理),要么直接写 emoji 字面量。JSON 在转义语法之外允许任何 UTF-8 字符,多数现代解析器更偏好这种形式。

坑 8:HTTP Content-Type: charset= 的默认值不是你以为的那个

症状。 一个 UTF-8 的 HTML 页在某个浏览器里乱码,在另一个里正常。

根因。 RFC 2616 最初把 ISO-8859-1 规定为 text/* 响应在未显式声明 charset 时的默认值。RFC 7231(2014)移除了这个默认值,留给各浏览器自行猜测。有的嗅探内容,有的回退到 UTF-8,有的按系统区域设置。

修复。 服务端始终发送 Content-Type: text/html; charset=utf-8并且在文档头里加 <meta charset="utf-8">。任何一个单独都能工作;双保险是为了应对会剥掉响应头的遗留代理。

要在字节层观察任何一种坑,Base64 在线编码解码 - 免费即时转换工具 是最快的显微镜:粘字符串、编码成 Base64,解码出来的 payload 就是 UTF-8 流。

选对编码:决策矩阵

对于 utf-8 vs utf-16 这个问题,答案几乎总是 UTF-8。下面这张表覆盖剩下的边界情况。

决策矩阵

场景理由
Web 页面、API JSON、源代码文件UTF-8(无 BOM)兼容 ASCII、无字节序、拉丁文本最小、RFC 8259 要求 JSON 必须 UTF-8
重 CJK 存储(中文数据库、日文游戏数据)UTF-8(utf8mb4UTF-8 中每个 CJK 字符 3 字节 vs UTF-16 的 2 字节,但来自标记和 JSON 键的 ASCII 开销让 UTF-8 在实际场景里仍然胜出——而且周边生态都是 UTF-8
Windows 原生 API、遗留 Java / C# 代码UTF-16平台默认;在每次 API 调用处转换只会引入 bug
索引密集的内存文本处理UTF-32常数时间码点访问;仅在解析器热路径才值得
Windows 上用 Excel 打开的 CSVUTF-8 加 BOMExcel 把无 BOM 的 UTF-8 读成 ANSI,CJK 表头乱码
新项目、无约束UTF-8(无 BOM)历次编码方案的争论早已落幕

两条经验法则

  1. 除非平台强制,处处默认 UTF-8。 W3C、IETF、Unicode 联盟意见一致。
  2. 在边界处转换,不要在中间。 在输入端把字节解码成语言的原生字符串类型。业务逻辑里始终操作字符串,绝不操作字节。在输出端再编码回 UTF-8。这个「UTF-8 三明治」可以避免管道中段的乱码 bug。

常见问题

UTF-8 总是向后兼容 ASCII 吗?

是的。任何合法 ASCII 文件在比特层面都与其 UTF-8 表示相同。前 128 个码点(U+0000U+007F)编码为单字节且高位为 0。仅识别 ASCII 的遗留工具(早期 grepsed、经典 shell 管道)无需修改就能处理纯 ASCII 的 UTF-8 文件。麻烦只在非 ASCII 字节(高位为 1)进入流时才出现。

我应该在文件里加 UTF-8 BOM 吗?

默认不加。HTML、JSON、JavaScript、CSS 文件在某些解析器中遇到开头的 BOM 会失败或告警。标准例外是要在 Windows 上用 Excel 打开的 CSV:没有 BOM,Excel 会猜成 ANSI 并把中日韩表头搞乱。详见第 5 节的 BOM 决策矩阵。

为什么 JavaScript 里 "😀".length === 2

JavaScript 字符串以 UTF-16 存储,.length 返回的是码元数,不是字符数。😀U+1F600)住在辅助平面,需要一个代理对(两个 16 位码元),所以 .length 是 2。要拿到真正的计数,用 [..."😀"].lengthArray.from("😀").length 或者 Intl.Segmenter

Unicode 和 UTF-8 有什么区别?

Unicode 是给每个字符分配码点(例如 U+1F600 这样的数字)的字符表。UTF-8 是把这些码点转成字节的若干种编码之一(每码点 1 到 4 字节)。Unicode 定义字符是什么;UTF-8 定义它如何在文件或网络中流动。UTF-16 和 UTF-32 是同一张 Unicode 表的另外两种编码方式。

在 MySQL 里 utf8mb4 总是比 utf8 更安全吗?

对新项目而言是。MySQL 的 utf8 是名不副实的 3 字节上限变体 utf8mb3,无法存储 U+FFFF 以上的任何字符,包括所有 emoji、许多生僻 CJK 字符、所有历史文字。utf8mb4 是完整的 4 字节 UTF-8。唯一需要注意的是索引长度:每个 utf8mb4 字符可能占 4 字节,InnoDB 旧版 767 字节索引上限会把唯一索引限制到 191 字符(在 MySQL 5.7+ 通过 innodb_large_prefix 解决,8.0 起为默认)。

怎么检测未知文件的编码?

Unix 上用 file,Python 里用 chardetcharset-normalizer,Node 里用 jschardet。没有一个是完美的,它们都基于字节分布做统计猜测。UTF-8 检测因为续字节模式高度可靠。Windows-1252、ISO-8859-1 以及其他单字节遗留编码几乎无法相互区分,所以检测常常落到语言启发式上。

UTF-16 能表示每一个 Unicode 字符吗?

能。UTF-16 覆盖全部 1,114,112 个码点。BMP 字符(U+0000U+FFFF)用一个 16 位码元(2 字节),辅助平面字符(U+10000U+10FFFF)用代理对(4 字节)。覆盖范围与 UTF-8、UTF-32 完全一致;区别只在字节布局和处理语义。三者之间的选择关乎生态契合度,与能力无关。

标签: unicode utf-8 utf-16 character-encoding surrogate-pair encoding