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:
- MySQL 拒收 emoji。 用户提交
Hello 😀,服务器返回Incorrect string value: '\xF0\x9F\x98\x80'。表是utf8字符集,开发者心想「那不就是 UTF-8 吗?哪里出问题了?」答案藏在 MySQL 的历史包袱里(见第 7 节)。 - 字符计数器上线即坏。 一个 280 字符的推文校验器用
text.length,前端放过了塞满 emoji 的文案,API 却拒收。反过来也会发生:合法内容被前端误拒。症状在第 4 节解析。 - 本地 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+0041、U+1F600):Unicode 分配的整数。整个空间从U+0000到U+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+0000到U+FFFF。拉丁、CJK 汉字、西里尔、阿拉伯、希腊,传统文本里见到的几乎所有字符都住在这里。 - 平面 1-16,辅助平面:
U+10000到U+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+0000–U+007F)用 1 字节,并与 ASCII 字节级完全一致。更高的码点用多字节序列,其中首字节标明总长度,每个续字节都以 10xxxxxx 这一比特模式开头。这种自描述布局让 UTF-8 在历次编码方案竞争中胜出。
字节模式表
| 码点范围 | UTF-8 字节数 | 字节模式 |
|---|---|---|
U+0000 – U+007F | 1 字节 | 0xxxxxxx |
U+0080 – U+07FF | 2 字节 | 110xxxxx 10xxxxxx |
U+0800 – U+FFFF | 3 字节 | 1110xxxx 10xxxxxx 10xxxxxx |
U+10000 – U+10FFFF | 4 字节 | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
每个 x 是从码点二进制表示中取出的一个数据位。前导 0 / 110 / 1110 / 11110 告诉解码器总共有多少字节,前导 10 标识每一个续字节。这种冗余带来 UTF-8 的自同步特性:丢一个字节后能在下一个起始字节处恢复,不会一路坏下去。
工作示例:编码 中(U+4E2D)
码点 0x4E2D 落在 U+0800–U+FFFF,因此用 3 字节模板。
- 二进制:
0x4E2D=0100 1110 0010 1101(16 位)。 - 按 4-6-6 拆分填入
x槽位:0100 / 111000 / 101101。 - 代入
1110xxxx 10xxxxxx 10xxxxxx:11100100 10111000 10101101。 - 十六进制:
0xE4 0xB8 0xAD。
这就是为什么 中 经 URL 编码后变成 %E4%B8%AD:百分号编码把每个 UTF-8 字节包成 %XX,它并不直接编码码点。第 7 节坑 3 详细讲了这条链。
工作示例:编码 😀(U+1F600)
码点 0x1F600 超出了 BMP,因此用 4 字节模板。
- 二进制:
0x1F600=0 0001 1111 0110 0000 0000(21 位,已补齐)。 - 按 3-6-6-6 拆分:
000 / 011111 / 011000 / 000000。 - 代入
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx:11110000 10011111 10011000 10000000。 - 十六进制:
0xF0 0x9F 0x98 0x80。
这四个字节就是让 MySQL 的 utf8 collation 失败的原因,它每字符最多分配 3 字节。第 7 节坑 1 给出修复方案。
UTF-8 的三个关键性质
- ASCII 兼容。 一份纯 ASCII 文本文件,在字节层面与其 UTF-8 编码完全一致。几十年来那些早于 Unicode 的工具(
grep、awk、经典 shell 管道)对这部分仍然适用。 - 自同步。 续字节总以
10开头,永远不会与任何起始字节冲突。在网络传输中丢一个字节,可以在下一个字符边界重新对齐,而不会引发连锁乱码。 - 无字节序。 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+D800–U+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+0000–U+FFFF)正好占 1 个码元。辅助平面字符(U+10000–U+10FFFF)占 2 个码元,称为代理对:前一个高代理位于 U+D800–U+DBFF,后一个低代理位于 U+DC00–U+DFFF。U+D800–U+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 里的 grapheme 或 regex,或者直接用 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、fetch、TextEncoder)会拒绝孤立代理,因为它们无法编码成合法的 UTF-8。
UTF-32、BOM 与字节序问题
UTF-32:简单但浪费
UTF-32 每码点固定 4 字节。U+0041 存为 0x00000041,U+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-8 | EF BB BF |
| UTF-16 BE | FE FF |
| UTF-16 LE | FF FE |
| UTF-32 BE | 00 00 FE FF |
| UTF-32 LE | FF FE 00 00 |
utf-8 BOM 存在,但不携带字节序信息,因为 UTF-8 没有字节序。它的唯一作用是宣告「这个文件是 UTF-8」:对那些没有别的信号可依的工具有用,对那些期望文件以魔数或指令开头的工具有害。
BOM 决策矩阵:该加吗?
| 格式 | UTF-8 BOM | UTF-16 BOM | UTF-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-8 | UTF-16 | UTF-16 码元 | UTF-8(Node、Web) | 是,但 .length === 2 |
| Python 3 | UTF-8(PEP 3120) | 动态 1 / 2 / 4 字节(PEP 393) | 码点 | UTF-8(PEP 540,3.7 起) | 是,len === 1 |
| Java | UTF-8(javac 默认) | UTF-16 | UTF-16 码元 | 平台字符集 → UTF-8(JEP 400,JDK 18+) | 是,但 .length() === 2 |
| Go | UTF-8 | UTF-8 字节 | 字节(用 utf8.RuneCountInString 得到码点数) | UTF-8 | 是,len(s) 返回字节数 |
| Rust | UTF-8 | UTF-8 字节(String 不变量) | .len() 返回字节,.chars().count() 返回码点 | UTF-8 | 是,显式 |
| C#(.NET) | UTF-8(.NET Core 3.0 起默认) | UTF-16 | UTF-16 码元 | UTF-8(Encoding.Default,.NET 5 起) | 是,但 .Length === 2 |
| Ruby | UTF-8(2.0 起) | 每串带编码标签 | 码点(.length) | UTF-8 | 是,length === 1 |
| PHP | (无源码编码) | 字节串 | 字节(strlen);码点用 mb_strlen | 取决于 default_charset | 是,配合 mb_* 系列 |
| MySQL | — | 列字符集 | 字节(LENGTH)、字符(CHAR_LENGTH) | character_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 历史上的 utf8 是 utf8mb3 的别名:一种每字符上限 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(utf8mb4) | UTF-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 打开的 CSV | UTF-8 加 BOM | Excel 把无 BOM 的 UTF-8 读成 ANSI,CJK 表头乱码 |
| 新项目、无约束 | UTF-8(无 BOM) | 历次编码方案的争论早已落幕 |
两条经验法则
- 除非平台强制,处处默认 UTF-8。 W3C、IETF、Unicode 联盟意见一致。
- 在边界处转换,不要在中间。 在输入端把字节解码成语言的原生字符串类型。业务逻辑里始终操作字符串,绝不操作字节。在输出端再编码回 UTF-8。这个「UTF-8 三明治」可以避免管道中段的乱码 bug。
常见问题
UTF-8 总是向后兼容 ASCII 吗?
是的。任何合法 ASCII 文件在比特层面都与其 UTF-8 表示相同。前 128 个码点(U+0000–U+007F)编码为单字节且高位为 0。仅识别 ASCII 的遗留工具(早期 grep、sed、经典 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。要拿到真正的计数,用 [..."😀"].length、Array.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 里用 chardet 或 charset-normalizer,Node 里用 jschardet。没有一个是完美的,它们都基于字节分布做统计猜测。UTF-8 检测因为续字节模式高度可靠。Windows-1252、ISO-8859-1 以及其他单字节遗留编码几乎无法相互区分,所以检测常常落到语言启发式上。
UTF-16 能表示每一个 Unicode 字符吗?
能。UTF-16 覆盖全部 1,114,112 个码点。BMP 字符(U+0000–U+FFFF)用一个 16 位码元(2 字节),辅助平面字符(U+10000–U+10FFFF)用代理对(4 字节)。覆盖范围与 UTF-8、UTF-32 完全一致;区别只在字节布局和处理语义。三者之间的选择关乎生态契合度,与能力无关。