先分清码点、码元和字节
码点是 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 编码,不是在查询字体,也不在计算屏幕宽度。若换一个字体,结果仍应相同;若把组合文字换成另一种码点序列,即使肉眼相似,字节输出就可能改变。这种可重复观察比背一张长度表更可靠。
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 80UTF-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-8 字节数 | UTF-16 码元数 | UTF-32 码元数 |
|---|---|---|---|
| A | 1 | 1 | 1 |
| 中 | 3 | 1 | 1 |
| 😀 | 4 | 2 | 1 |
| e + 组合尖音符 | 3 | 2 | 2 |
字节序:同一个数,也要约定先写哪一半
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 检查器,观察这些数字如何分开变化;当每个数字都有明确单位,编码问题就会从玄学变成可以定位的输入输出问题。