技术笔记

UTF-8 到底在编码什么

字符数、码点数和字节数是三套尺子。这篇用一个码点走完 UTF-8 的分组,并说明 URL 编码和 JSON 转义为什么会把同一个字写成完全不同的样子。

作者 Brook/更新于 2026-10-09/约 11 分钟

码点、码元和字节不是同一把尺子

Unicode 先给每个字符一个码点,写成 U+XXXX。码点是编号,不是存储格式。UTF-8、UTF-16、UTF-32 是三套把编号变成字节的规则。同一段文字,码点序列可以相同,字节序列完全不同。工具上如果只写「长度」,不说明数的是码点、UTF-16 码元还是字节,数字就没法比较。

JavaScript 字符串按 UTF-16 码元计数。一个普通汉字是一个码元,也是一个码点;辅助平面上的字符(很多 emoji)要两个码元,叫代理对。String.length 数的是码元。按字节算大小时,还得再经过 UTF-8 编码。所以「这个字段限制 32 个字符」和「这个字段限制 32 字节」是两个需求,混用会在中文和 emoji 上第一次爆掉。

UTF-8 按码点大小决定字节数

UTF-8 的规则可以收成一张表:U+0000 到 U+007F 用 1 字节,最高位是 0,和 ASCII 重合;U+0080 到 U+07FF 用 2 字节;U+0800 到 U+FFFF 用 3 字节;U+10000 到 U+10FFFF 用 4 字节。多字节序列的第一个字节用高位上的 1 标明后面还有几个续字节,续字节一律以 10 开头。这样在字节流中间也能判断自己是不是落在字符边界上。

  1. 01编号码点例如 U+4E2D。还只是一个整数。
  2. 02分组16 位分成三段4 位 + 6 位 + 6 位,对应三字节模板。
  3. 03套模板1110xxxx 10xxxxxx 10xxxxxx首字节标明「后面还有两个续字节」。
  4. 04结果三个字节E4 B8 AD。这才是文件和网络上的样子。
图 1 一个落在 U+0800–U+FFFF 的码点(常见汉字都在这里)会被拆成 3 个字节。

把 U+4E2D 套进去:0x4E2D 的二进制是 0100 1110 0010 1101。按 4/6/6 切开得到 0100、111000、101101。填进模板是 11100100 10111000 10101101,也就是 E4 B8 AD。URL 编码里看到的 %E4%B8%AD,就是这三个字节各自写成百分号加两位十六进制,不是「汉字的另一种 Unicode」。

同一次转义,为什么有时像乱码

乱码通常不是字体坏了,而是字节被用错的编码解释了一遍。UTF-8 的 E4 B8 AD 如果按 Latin-1 或 GBK 去显示,就会变成两三个不相干的字符。反过来,把 GBK 字节当成 UTF-8,又会在续字节不合法的地方直接失败。工具如果只做「把字节显示出来」,必须说清楚当前用的是哪一套解释。

JSON 的 \u4e2d 又是另一层。它不代表 UTF-8 字节,而代表一个码点。解析器读到 \u4e2d 会放进一个码点 U+4E2D,再由语言运行时决定这个字符串在内存里用 UTF-16 还是别的形式存。所以同一份数据可以同时出现三种写法:源码里的字、JSON 转义、URL 的百分号编码。三者可以互转,但步骤不能跳。

编辑器里的几个长度分别在数什么

码点

Unicode 编号的个数。

  • 「中」是 1 个码点
  • U+4E2D
  • 适合说「有几个字符」

UTF-16 码元

JavaScript 字符串的 length。

  • 「中」仍是 1
  • 多数 emoji 是 2
  • 切片时可能切断代理对

UTF-8 字节

文件大小和 URL 编码的原料。

  • 「中」是 3 字节
  • ASCII 是 1 字节
  • 和 Content-Length 对齐
图 2 以「中」为例。三个数字都对,问的是三件不同的事。

按码元切片是最常见的坑。如果用 text.slice(0, n) 去截一段可能含 emoji 的标题,n 落在代理对中间时,结果会多出一个孤立的代理项,后续编码或存库可能直接报错,也可能变成替换字符。按码点截断要先把字符串转成码点数组,再决定边界。按字节截断还要保证不把一个 UTF-8 字符从中间切开。

URL 编码是在编码字节,不是在编码「字」

encodeURIComponent 的步骤是:用 UTF-8 把字符串变成字节,再把除了未保留字符以外的字节写成 %HH。空格会变成 %20,不是 +。application/x-www-form-urlencoded 里空格才习惯写成 +。两个函数混用,查询串里就会出现「解码之后多了加号」或者「加号变成了空格」。

已经编码过一次的文本再编码一次,% 本身会变成 %25。解码两次才能回到原文。接口如果分不清参数到底编码了几层,日志里就会稳定地出现 %25E4%25B8%25AD 这种双重编码。处理时先看有没有成对的 %HH,再决定解几次,不要看着像乱码就反复解码。

处理文本时可以少踩的几件事

  • 对外交换用 UTF-8,并在 HTTP 头或文件格式里写明。不要依赖「对方应该能猜出来」。
  • 限制长度时写清单位。给用户看的用码点或字素,给协议和存储的用字节。
  • 比较和搜索前先做同样的规范化。同一个看起来一样的字,可能是不同码点加组合记号。
  • 日志里同时留下原文和编码后的形式。只留百分号编码,排错时还得先在脑子里解码。

本站的 URL 编解码按 UTF-8 做百分号转换,Base64 则处理的是字节而不是字符。先把「我现在拿着的是码点、字节,还是已经百分号编码的文本」说清楚,再选工具,结果才对得上。

文中对应的工具

继续读