Sanyanggae LogoSanyanggae

💻 Web 与前端开发

Unicode 与 UTF-8 有何不同?韩文乱码、字节、字符串长度与规范化

比较韩文和表情符号的码点、UTF-8 字节与 JavaScript 字符串长度,区分错误解码、字体问题、规范化与原文保留。

사냥개 (Sanyanggae)发布 更新 6分钟

本文是韩语原文的译本。 韩语原文 · 翻译审阅: 人工智能审阅

说明字符与码点、UTF-8 字节、显示与规范化关系的三栏示意图
Sanyanggae 制作的说明图,区分 Unicode 字符编号、UTF-8 存储字节及显示与比较规则。不是实际故障截图或测量结果。

1. 字符的编号与保存的字节是两回事

假设导出一份韩文文档后重新打开,标题却显示成了奇怪的字符。输入者的语言并没有改变,问题可能是读取已保存数字时采用的规则不一致。Unicode 是给各种文字和符号分配码点编号的标准,UTF-8 则是一种把相应的值表示为字节序列的编码。例如,韩文音节‘가’的码点是 U+AC00。U+ 后面的内容采用十六进制表示,不是它在文件中的位置,也不是内存地址。这个编号并不指定字体的字形或屏幕上的像素位置。同一码点在不同字体和排版环境中,也可能呈现不同的外观。因此,是否支持 Unicode、是否以 UTF-8 保存、是否安装韩文字体,是相互关联却需要分别检查的事项。Unicode 对 UTF 的说明可以帮助我们先区分字符与存储格式。

在 UTF-8 中,‘가’被表示为十六进制的 EA B0 80,共三个字节。英文字母 A 的码点则是 U+0041,在 UTF-8 中只需一个字节 41。一个字节包含八个比特,所以同一文档中不同字符的字节长度也可能不同。仅凭 .txt 或 .csv 扩展名,并不能确定文件实际采用的编码。UTF-8 用一到四个字节表示有效的 Unicode 标量值,因此不能把屏幕上的字符数乘以某个固定数,就准确得到文件大小。也不能把这个韩文音节的例子推广到所有与韩文有关的符号或所有语言。编码是把字符信息转成字节的规则,不是压缩,也不是加密。同一句话的总大小还可能受到文件格式的附加信息、换行方式和传输压缩的影响。把 WHATWG Encoding Standard所定义的字节与字符转换,与整个文件的容量和保密功能区分开,才能更准确地定位问题。

2. 字节数、代码单元与看得见的字符数

JavaScript 字符串的 length 返回 UTF-16 代码单元的数量,不是 UTF-8 文件的字节数,也不等于人所理解的字符数。比如笑脸‘😀’只有一个码点 U+1F600,却需要两个 UTF-16 代码单元,所以 '😀'.length 的结果是 2。编码为 UTF-8 后,它是 F0 9F 98 80,共四个字节。codePointAt(0) 返回的十进制数 128512,与 U+1F600 表示的是同一个编号,不是字符数量或文件大小。‘가’按这三种标准分别是一个码点、一个 UTF-16 代码单元和三个 UTF-8 字节。同一字符串得到不同数字,是因为测量单位不同。MDN 的字符串长度说明明确说明了 length 的单位。仅凭这个数,也不能断言 JavaScript 引擎实际占用了多少字节的内存。

即使逐个统计码点,也不总等于用户看到的字符数量,因为基本字符可能附带组合标记,多个码点也可能构成一个表情符号。Unicode 文本分割规则说明了处理这些组合的扩展字素簇边界。像 Array.from(text) 这样按码点展开字符串的方法,并不会自动把多个码点构成的组合算作一个单位。如果要在浏览器里统计,可以先确认支持情况,再考虑把 Intl.Segmenter 的 granularity 设为 'grapheme'。但字素簇并不保证与字体绘出的一个图形一一对应,不同语言对编辑操作的预期也可能不同。简介输入框应采用用户能理解的字符计数;存储和通信限制则应另行计算字节数。如果界面写着‘20 个字符’,内部却直接按 UTF-16 代码单元截断,就可能从中间切断表情符号或组合字符。

3. 韩文乱码要分清保存、传输与显示

写入字节时使用的编码,与读取时使用的解码规则不同,就可能产生乱码。UTF-8 文件被按另一种编码解释时,即使文件本身没有变化,屏幕也可能出现错误字符。在 HTML 中,需要一起检查实际保存格式、HTTP 响应 Content-Type 中的 charset,以及文档前部的 meta charset 声明。HTML 的 lang='ko' 是告诉系统内容使用什么语言的属性,与选择如何读取韩文字节的 charset 职责不同。W3C 的 HTML 编码声明指南要求不仅把声明改成 UTF-8,还要实际以 UTF-8 保存文件,并让服务器声明与文档声明一致。编码签名还可能影响判定的优先级,因此不能认为一个 meta 声明始终覆盖其他信息。如果直接打开文件与通过服务器访问的结果不同,传输环节的设置就是重要线索。

字体缺少所需字形,导致文字显示为方框,与错误解码可能是不同的问题。如果字符数据正确,只是找不到相应字形,修改保存编码并不能解决;反过来,已经被解读成其他字符的内容,也不会换一个字体就恢复。调查时应保留问题文件的副本,依次比较编辑器选择的编码、服务器响应声明、浏览器解释出的字符以及实际使用的字体。不要只凭外观确定原因,还要查看复制同一段内容后得到的码点。记录问题是否只在某个浏览器出现、其他编辑器是否也读错同一文件,可以帮助区分显示问题和数据变更。为了找到正常显示的文字而反复保存,可能使原始字节丢失。如果页面声明均正确,却只有特定文章异常,还要追查内容进入数据库之前,究竟在哪一步发生过转换。

4. 用 TextEncoder 和 TextDecoder 核对同一组值

简单实验可以从自己编写的短字符串开始。TextEncoder把 JavaScript 字符串转换为 UTF-8 字节数组。new TextEncoder().encode('가') 的结果按十六进制读取是 EA B0 80,数组的 length 为 3。开发工具也可能以十进制显示 234、176、128;这不是另一组数据,只是数字进位制不同。与同一字符串普通 length 为 1 比较,就能看出单位的区别。用 new TextDecoder('utf-8').decode(bytes) 读取这个数组,会得到‘가’,这里的 bytes 指前面生成的字节数组。这个编码器不是可以任意选择旧编码的通用保存转换器,而是生成 UTF-8 的工具。调查实际文件时,应使用事先取得的原始字节。把屏幕上已经错误解码的句子再次编码成 UTF-8,不能证明最初文件原本有哪些字节。

为 TextDecoder 构造函数指定 'utf-8' 和 { fatal: true },就能在遇到不符合规则的输入时抛出错误,而不是静默替换。例如,单个字节 FF 不是有效的 UTF-8 输入,因此这种模式的解码会失败。默认的替换模式可能在错误位置显示 U+FFFD 替换字符。但没有报错,不代表已经识别了原始编码,也不代表确认了内容正确。有些字节同时适用于多种编码;内容也可能先被转换成其他字符,再次保存成有效的 UTF-8。这个检查只是在确认选定的解码器能否接受输入,并不会自动验证文档来源、内容是否与原文一致,或文件在中途是否被篡改。

5. 规范化是统一同一字符表示的另一项操作

正常显示的两个字符串,内部码点序列也可能不同。‘가’既可表示为一个 U+AC00,也可表示为组合初声 U+1100 与中声 U+1161 的序列。后面这两个值,与直接排列日常兼容字母‘ㄱ’和‘ㅏ’的例子不同,因此要一起核对编号。这两种表示通常绘成同一个音节,但 JavaScript 的原始字符串比较可能认为它们不相等。依据 Unicode 规范化规则,对两者应用 NFC,就能把这个规范等价的例子统一为同一种表示再比较。NFD 则采用规范分解。在这个例子中,NFC 的 UTF-8 表示有三个字节,NFD 的两个字母表示则有六个字节,所以画面一样,存储大小也可能不同。搜索词与保存的姓名看起来一样却匹配不到时,可以调查这种表示差异;它应与错误解释字节所造成的乱码区分开。

规范化形式也要按目的选择。NFC 处理规范等价,而 NFKC 包含兼容分解,可能更广泛地改变全角数字、带圈数字等原本有所区别的输入表示。生成搜索用的比较值时,这可能有帮助,但在数学记号或原始资料中,这些区别也可能有意义。因此,可以保留原始值,再决定对必要的比较副本应用哪种形式。规范化也不代替大小写比较、按语言排序或寻找发音相同的词。批量转换现有文件名或账号标识符,可能让不同项目合并为同一个比较值,所以应先检查服务的命名规则和冲突处理。不能仅因两个字符看起来相似,就认为把所有这类字符保存为同一值是规范化的普遍目标。

6. 保留原文,记录每一次转换的边界

定位损坏原因时,应在覆盖初始文件之前保留原始字节和取得文件的途径。在副本中尝试按预期编码读取,并检查把正常显示的结果再次保存后,内容是否仍然一致。W3C 的 BOM 说明介绍的 UTF-8 起始签名是 EF BB BF,但这些字节不是必需的。UTF-8 不需要选择字节顺序,所以不能把这个签名理解为与 UTF-16 的字节顺序选择具有相同作用。添加或删除 BOM,与把正文的字节转换成另一种编码,也是不同操作。如果无法读取的值已经被替换字符或问号替代后保存,多个原始值可能已合并成一个,不能保证自动还原。只有确认原始编码和转换历史后,才能评估是否有机会逆转;规范化并不是找回丢失字节的工具。

开发多语言功能时,不能只检查一行英文就结束。应在同一组短句中加入韩文音节、带组合标记的拉丁字母、补充平面的表情符号,以及阿拉伯文、希伯来文等从右向左书写的文字,并比较输入、保存、重新查询与导出的结果。每一步需要核对显示外观、码点、UTF-8 字节数,以及产品规定的字符计数标准。书写方向不是把 UTF-8 换成另一种编码就能解决的属性,界面的语言与方向设置也需要单独检查。修改输入限制或搜索比较方式时,同样应保留原文,并说明标准如何变化。还要确认浏览器提示的数字与服务器接受的标准是否使用同一单位;如果确实需要不同限制,就分别标示。本文的数字与代码只演示短字符串的转换,不是对某个服务故障的测量结果。依次检查字符编号、保存字节、字符串单位、显示和比较规则,才能重现每个边界究竟发生了什么变化。

依据与参考资料

本文依据公开资料进行说明。标注某机构为来源,不代表该机构审阅或认证了本文。

运营者介绍与联系方式

同主题文章