第 2 讲 / 共 10 讲

UTF-8、UTF-16 和 UTF-32:同一段文字,为什么有三种表示

“网站用 UTF-8,JavaScript 却按 UTF-16 计算长度,这是不是冲突?”并不冲突。字符身份、程序中的字符串和网络上的字节属于不同层。本文用 A、中、😀 这组三个样本,把常见 Unicode 编码放到同一张桌上比较:它们保存同样的文字,但基本单位、占用空间和边界处理各不相同。

Copy2Paste约 11 分钟阅读

先分清码点、码元和字节

码点是 Unicode 编号空间中的一个位置,通常写成 U+ 后接十六进制数字。例如 A 的码点是 U+0041,中是 U+4E2D,😀 是 U+1F600。这个编号还不是文件里必然出现的字节。把字符序列变成某种具体表示,需要选择编码形式;把较大的码元写入字节流时,还要规定字节排列。

码元是编码处理的基本单位。UTF-8 的码元为8 位,UTF-16 为16 位,UTF-32 为32 位。名称中的数字说明单位大小,不说明一个视觉字符一定占多少位。一个码点在某些形式里需要多个码元,而一个读者看到的完整符号又可能需要多个码点,两层变化不能混在一起。

我们可以把这看成同一批货物使用不同包装尺寸。货物身份没有改变,箱数和箱子的编号方式却会改变。类比只用于理解分层:实际编码有严格算法,并不是随意打包。比较性能、长度或容量时,先写清楚单位,往往比争论哪种编码“更先进”更能解决问题。

UTF-8:让常见英文兼容旧字节,让其他字符按需展开

现代 UTF-8 用一至四个字节表示一个 Unicode 标量值,并保留 ASCII 范围的原有单字节表示。A 编码后是十六进制 41,中是 E4 B8 AD,😀 是 F0 9F 98 80。三者虽然都是本例中的单个码点,字节数却分别是一、三、四,不能按“字数乘固定常数”得到准确文件大小。

这种结构适合以字节传输的场景,但并不允许在任意位置切开。假设日志系统只保留前两个字节,它可能保住 A,却把中的三个字节截断。结果不是半个能显示的汉字,而是不完整的编码序列。限制传输大小时,应在编码后的完整边界上处理,或让成熟库负责按容量安全截取。

下面的样例通过 TextEncoder 查看实际输出。注意它做的是 UTF-8 编码,不是在查询字体,也不在计算屏幕宽度。若换一个字体,结果仍应相同;若把组合文字换成另一种码点序列,即使肉眼相似,字节输出就可能改变。这种可重复观察比背一张长度表更可靠。

javascript
for (const text of ['A', '中', '😀']) {
  const bytes = new TextEncoder().encode(text);
  console.log(text, bytes.length,
    [...bytes].map(x => x.toString(16).padStart(2, '0')).join(' '));
}
// A 1 41
// 中 3 e4 b8 ad
// 😀 4 f0 9f 98 80

UTF-16:一个码元不一定够放一个码点

UTF-16 对基本多文种平面中可直接表示的标量值使用一个16 位码元,对补充平面的码点使用两个码元,也就是代理对。😀 的两个码元是 D83D 和 DE00,它们合起来表示 U+1F600。单独拿出其中一个,不能把它当作原来的表情,也不能把代理区数值当作独立的 Unicode 标量值。

这解释了一个常见现象:JavaScript 中,😀 的 length 是二,而中的 length 是一。语言规范以16 位单位定义字符串元素和索引,因此这个返回值符合接口约定。真正的问题往往出在调用方把 length 显示成“字数”,或者按用户期望的字符数量直接调用 slice,结果把代理对切断。

迁移旧代码时,应先确定每个长度的用途。定位字符串内部偏移量可以继续使用接口约定的码元索引;给用户显示剩余字数,通常需要另一种算法;限制网络请求则需要检查编码后的字节。将这三个变量分别命名,能防止同一个看似方便的数字被传遍系统,最终在另一个边界引发错误。

UTF-32:固定的是码点表示,不是人眼看到的字符

UTF-32 对每个 Unicode 标量值使用一个32 位码元。这使按码点定位变得直观,但不能把任意32 位整数都当作有效文本:超出 Unicode 范围的值和代理区数值仍然不属于合法标量值。把容器变大,并没有取消字符模型的范围限制。

更容易被忽略的是,固定大小只解决这一层表示。字母 e 后接组合尖音符,可以构成读者看到的 é;它们依然是两个码点。换成 UTF-32 后仍需两个码元,不会因为包装变大就自动合成一个。因此,把全部字符串改为 UTF-32,并不能直接解决昵称计数、删除完整表情或按视觉字符反转的问题。

空间比较也必须使用真实数据。对于本篇三个单码点样本,UTF-32 都使用四字节;UTF-8 的结果则各不相同。混有英文标记、中文正文和表情的页面,不等同于纯汉字样本。还要考虑压缩、内存布局和运行时实现,不能从几个字符的大小直接推出整个产品的性能结论。

UTF-32:固定的是码点表示,不是人眼看到的字符
样本UTF-8 字节数UTF-16 码元数UTF-32 码元数
A111
中311
😀421
e + 组合尖音符322

字节序:同一个数,也要约定先写哪一半

UTF-16 和 UTF-32 的一个码元跨越多个字节,写文件时需要说明高位在前还是低位在前,分别称为大端和小端。以 U+4E2D 为例,UTF-16BE 的字节是 4E 2D,UTF-16LE 则是 2D 4E。文字和码元数值没有变,只是把同一个数排进字节流的顺序不同。

UTF-8 的码元本身就是一个字节,因此没有同样的字节序选择。不能为了适配某台机器,简单倒转 UTF-8 的字节;那会破坏编码。也不能把 UTF-16 中每两个字节倒转称为“反转字符串”,因为改变字节序与改变文字顺序是完全不同的操作。

在实际接口中,最有价值的约定往往很朴素:写明准确的编码标签,明确是否允许标记,说明接收端发生错误时的处理办法。只写“支持 Unicode”远远不够。尤其是跨语言调用和遗留文件导入,明确说明 UTF-16LE 之类的具体形式,能免去依靠文件内容猜测的步骤。

BOM 能提示编码,却不是通用修复按钮

字节顺序标记 BOM 与 U+FEFF 有关,放在特定文本流开头时可用于识别字节序。UTF-8 没有大小端问题,但也可能在开头出现 EF BB BF 这一签名。它是否应当出现、是否被忽略,要由所用格式和协议来决定。给所有文件机械地加上 BOM,并不是保证兼容性的通用办法。

例如,一个导入器若把开头标记当成普通内容保留下来,第一列字段名可能肉眼看似正常,实际却多了一个字符。此时“匹配不到列名”不应靠随意删除文件里的所有不可见字符解决,而应查清读取流程如何处理开头标记。范围清楚的修复,比全局清洗更容易验证。

排查时保留原始文件,先看最初几个字节,再对照格式说明和解码器选项。不要把程序看到的字符串与磁盘字节混为一谈:某个读取接口可能已经消耗了 BOM。检查结果里没有它,不一定说明原文件没有;同样,看到 U+FEFF 也需要结合位置和上下文判断用途。

错误输入怎么处理,比选哪种 UTF 更容易被忘记

编码转换可以保持有效 Unicode 标量值序列,但现实输入未必有效。文件可能在中间截断,网络分块可能暂时只送来半个序列,程序也可能产生未配对的 UTF-16 代理项。不要把这些情况归为“某种语言不支持”,更不要假定只要标上 UTF-8,任意字节都会自动变成正确文字。

在 Web 的 TextDecoder 中,可以选择遇到无效输入时报错,或使用替换字符继续解码。TextEncoder 接收 JavaScript 字符串时,未配对代理项会按接口规定转换为替换字符后编码。因此,看到输出中出现 �,要区分它原本就是文本内容,还是某个处理步骤丢失信息后留下的标记。

产品可以按场景选择策略。导入重要数据时,明确指出错误位置通常比悄悄替换更容易修复;预览受损日志时,保留可读部分可能更有帮助。关键是说明政策并保存足够的诊断信息。若原始字节已经丢弃,后面再把替换字符重新编码,也无法找回先前被替换的内容。

网络分块不等于文字边界

想象一个读取程序每次接收两个字节。中的 UTF-8 序列需要三个字节,因此第一次读取拿到的只是前半段。此刻数据还不一定损坏,后续块可能马上补齐。若每块都使用全新的解码器独立处理,就可能把正常传输误判为无效文本;有状态的流式解码则能保留尚未完成的序列。

TextDecoder 提供 stream 选项来处理这种分块,并在输入结束时完成最后一次刷新。这里需要保存的是解码状态,不是把整个大文件无限积存在内存里。应用仍需设置合理的缓冲和资源限制,但这些限制应与文字边界协同设计,而不是在收到任意长度的块时直接下结论。

测试这种功能,可以故意让分块位置落在多字节字符内部,比较分块解码与一次性解码的结果。只使用纯英文样本,会让许多错误暂时隐身。再加入一个补充平面字符和一个带组合记号的词,分别验证解码正确与后续字符串操作正确,才能覆盖两个不同层面的风险。

转码、规范化与修复乱码,分别在改什么

把有效文字从 UTF-8 转成 UTF-16,目标是保留原来的标量值序列,只改变表示方式。规范化则可能把一个序列换成规范等价的另一序列。例如,e 加组合尖音符经过 NFC 后可以成为预组形式的 é。这些步骤都可能改变字节数量,但它们改变数量的原因不同,不能靠输出文件变小就判断做了哪件事。

修复错误解码又是第三件事。假如最初把正确字节按错误编码读入,之后再把错误文字认真保存为 UTF-8,只会得到编码完全有效、内容却已经不对的文件。因此,检查器显示“有效 UTF-8”只能说明字节结构符合规则,并不能证明它还原了作者原来的意思。

实际转换工具最好分别提供观察与变换:先展示原始序列及长度,再让用户选择明确操作,最后比较输出。对于批量导入,可以先对少量代表性记录试运行,特别包含姓名、符号和生僻字,并与可信原件核对。只有知道输入是什么、要保留什么,才能判断一次转换是否成功;笼统地追求“统一编码”,反而容易把已经发生的损失掩盖起来。

选择编码之前,先写清楚真正的约束

对新建的通用 Web 文本交换,UTF-8 通常是合理起点,WHATWG 编码标准也要求新协议和格式采用它。但遇到既有接口或文件格式时,应遵循对方明确的约定,再通过可靠转换连接各层。无需为了让所有层看起来一样,就强行改变语言运行时的字符串模型。

可以用四个问题完成一次小型设计检查:外部字节采用什么编码;内部索引以什么为单位;无效输入是报错还是替换;界面上的“字符上限”到底限制什么。回答清楚后,再用 A、中、😀 和 e 加组合尖音符做样本,分别记录字节、码元、码点和显示字符数量。

本篇代码在 Node.js 22环境中验证过。工具或浏览器版本变化时,仍应重新执行与业务有关的检查,而不是凭一次截图推断整个支持范围。你可以把自己的文本放进 Unicode 检查器,观察这些数字如何分开变化;当每个数字都有明确单位,编码问题就会从玄学变成可以定位的输入输出问题。

参考资料

  1. UTR #17: Unicode Character Encoding Model ↗
  2. RFC 3629: UTF-8 ↗
  3. WHATWG Encoding Standard ↗
  4. RFC 2781: UTF-16 ↗
  5. ECMAScript: The String Type ↗
  6. Unicode FAQ: UTF-8, UTF-16, UTF-32 and BOM ↗
  7. UAX #15: Unicode Normalization Forms ↗
  8. Unicode 18.0, Chapter 3: Conformance ↗