Unicode 与 UTF-8,到底是什么关系?

0 阅读6分钟

零帧起手,先说结论:

Unicode 不是某一种具体的存储格式,它更像一套“统一编号标准”;UTF-8 则是把这套编号真正编码成字节的一种实现方式。

很多人第一次接触这两个概念时,容易把它们混在一起。原因也很简单:它们总是一起出现,而且都和“编码”有关。但如果一开始就没把这层关系理顺,后面看乱码、字符集、文件编码时,就很容易越看越乱。

这篇文章就讲清楚两件事:

  1. Unicode 解决了什么问题。
  2. UTF-8 又是怎么把 Unicode 落地的。

为什么会乱码?

先举个不严谨但很好懂的例子。

假设郭德纲和于谦各自发明了一套“字符表”。

  • 郭德纲规定:1 -> 郭
  • 于谦规定:1 -> 于

两个人都写了一篇文章,再把文章转换成自己那套编号。问题来了:如果你只拿到一串数字,却不知道它到底遵循的是哪一套规则,那你在还原时就可能把“郭”读成“于”,把“于”读成“郭”。

乱码本质上就是这个问题:同样一串字节,在不同规则下会被解释成不同字符。

我们平时看到的 锟斤拷,很多时候就是“拿错了规则去解码”的结果。

Unicode 解决了什么?

既然问题出在“大家各用各的规则”,那最直接的办法就是:尽量统一规则。

于是就有了 Unicode

你可以把 Unicode 理解成一个超大的“全球统一字符表”。它试图给世界上常见的文字、符号、表情等都分配一个统一编号。这样一来,不管你在中国、日本、美国还是别的地方,只要大家都认这套编号,至少“这个字符是什么”这件事就能先统一下来。

注意,这里统一的是“字符和编号的对应关系”,不是“这个编号在计算机里具体怎么存”。

这点非常重要。

也就是说,Unicode 更像是在回答:

“这个字符应该对应哪个码点?”

而不是回答:

“这个码点最终要用几个字节保存?”

下面这两张图,可以把它理解成 Unicode 字符集资料的一部分。

你会发现里面有一些字形看起来很像。这是因为 Unicode 里有“中日韩统一表意文字”这一类内容,而同一个字在不同地区可能会有细微写法差异。为了兼容不同语言环境,这些差异通常都会被认真处理,而不是简单粗暴地“只留一个”。

所以,Unicode 做的事,本质上是统一映射关系:哪个字符对应哪个码点。

那 UTF-8 又是什么?

讲到这里,就该轮到 UTF-8 出场了。

如果说 Unicode 负责规定“每个字符的编号”,那 UTF-8 负责的就是:

“怎样把这个编号编码成字节,真正存进文件,或者真正发到网络里。”

也就是说,UTF-8Unicode 的一种编码实现。

除了 UTF-8,还有其他实现方式,比如 UTF-16UTF-32。更早期的场景里,你也可能见过 UCS-2 这样的名字。

它们做的是同一类事情:把 Unicode 码点变成计算机真正能保存和传输的二进制数据。

为什么同一个字符可以有不同实现?

这其实不难理解。

同一个结果,往往可以有不同实现方式。

比如:

(4 + 6) * 5 = 50

你可以先算括号:

(4 + 6) * 5 = 10 * 5 = 50

也可以直接展开:

(4 + 6) * 5 = 4 * 5 + 6 * 5 = 20 + 30 = 50

最后结果一样,但过程不一样。

Unicode 的不同编码实现,也可以这么理解:目标都是把同一个字符正确表示出来,但具体怎么编码、占多少字节、是否兼容旧系统,这些实现细节可以不同。

UTF-8 是怎么编码的?

对于初学者来说,不一定要把所有二进制规则背下来,但你至少要知道 UTF-8 的核心特点:

  • 它是一种可变长度编码。
  • 不同字符会占用不同字节数。
  • 它会根据字符对应的码点范围,决定使用 1、2、3 或 4 个字节。

你可以粗略把这个过程理解为:

  1. 先找到这个字符在 Unicode 里的码点。
  2. 再根据码点范围,决定应该用几字节编码。
  3. 最后按 UTF-8 的规则,把码点拆进对应的字节结构里。

所以,UTF-8 不是随便“塞进去”几个字节,而是有固定格式的。

const arr = ["A", "z", "5", "¢", "é", "ß", "你", "好", "中", "😀", "🚀", "𠮷"];

function toUTF8Bytes(str: string) {
  const encoder = new TextEncoder();
  return encoder.encode(str);
}

arr.forEach(char => {
  const bytes = toUTF8Bytes(char);
  const binary = Array.from(bytes)
    .map(b => b.toString(2).padStart(8, "0"))
    .join(" ");

  console.log(`${char} -> ${binary}`);
});

其中最关键的,就是每种长度对应的前导位模式。你可以把它理解成一种“字节头信息”,解码器看到这些前导位,就知道这一段数据该按几字节来读。

字节数二进制格式说明
1 字节0xxxxxxx兼容 ASCII 单字节
2 字节110xxxxx 10xxxxxx双字节编码
3 字节1110xxxx 10xxxxxx 10xxxxxx三字节编码
4 字节11110xxx 10xxxxxx 10xxxxxx 10xxxxxx四字节编码

为什么 UTF-8 这么常见?

UTF-8 能流行起来,原因很实际。

第一,它兼容 ASCII。很多早期英文世界的软件和协议,本来就是围绕 ASCII 建起来的。UTF-8 对这部分兼容得很好,迁移成本相对低。

第二,它对常见文本场景很友好。英文内容占用空间小,而中文这类常见字符通常用 3 个字节,也能接受。

第三,它适合网络传输。今天你打开网页、写接口、存配置文件,看到的大多数“文件编码是 UTF-8”,本质上都是因为它在兼容性、空间占用和通用性之间取得了一个很不错的平衡。

最后总结

把这两个概念分开,很多问题就会一下子清楚:

  • Unicode 解决的是“字符应该如何统一编号”。
  • UTF-8 解决的是“这个编号在计算机里如何编码存储”。

前者更像统一标准,后者更像具体实现。

所以以后再看到“文件是 UTF-8 编码”,你脑子里最好自动翻译成一句话:

“这份文件里的字符,遵循的是 Unicode 的字符体系;落地存储时,使用的是 UTF-8 这种编码方式。”