字符的编码:code point、UTF-16、UTF-8
要弄懂「'😀'.length 为什么是 2」「一个汉字占几个字节」这类问题,先得分清两层:code point(逻辑层,一个字符一个号,U+0000 ~ U+10FFFF)和存储层(字符真正落进内存 / 文件时的字节)。存储层有两套:UTF-16 是 JS
字符串在内存里的样子,每格 16 bit 一个 code unit;UTF-8 是网络 / 磁盘 / 源文件几乎通用的编码,按码点大小用 1~4 个字节变长存。
1 · 三层并排
同一个 code point,两套存储层拆法不同:UTF-16 里补充平面字符占两个码元,UTF-8 里则是规整的 4 个字节。
注 · 想查任意字符 / emoji 的 code point、所属 Unicode block,以及 UTF-8 / UTF-16 / UTF-32 各编码,可用 symbl.cc——完整 Unicode 数据库,一键复制,核对本页拆出的代理对、码点与字节时很方便。
2 · 补充平面到代理对的换算
BMP 内的字符一个 UTF-16 码元放得下;补充平面(astral,> U+FFFF)放不下,得用两个码元拼,即代理对(surrogate pair)。这就是 '😀'.length === 2 的原因。
注 · 代理区的分工是:高位区 U+D800 ~ U+DBFF(1024 个)放 code point 的高 10 bit,低位区 U+DC00 ~ U+DFFF(1024 个)放低 10 bit。1024 × 1024 约等于 100 万,正好覆盖 16 个补充平面。这两段码位专门留给代理、不表示任何字符,所以拼接无歧义。
3 · UTF-8 的前缀位
UTF-8 把 code point 的二进制位塞进固定模板的 x 位里。首字节前缀的 1 的个数等于总字节数;续字节恒以 10 开头。
| 字节数 | 二进制模板 | 载荷位 | 码点范围 |
|---|---|---|---|
| 1 | 0xxxxxxx |
7 bit | U+0000 – 007F(ASCII) |
| 2 | 110xxxxx 10xxxxxx |
11 bit | U+0080 – 07FF |
| 3 | 1110xxxx 10xxxxxx 10xxxxxx |
16 bit | U+0800 – FFFF |
| 4 | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
21 bit | U+10000 – 10FFFF |
建议 · 自同步就是抗错。续字节一定是 10xxxxxx,绝不会和任何首字节(0… / 110… / 1110… / 11110…)撞。所以丢了或损坏一个字节,解码器往后扫到第一个非 10 开头的字节就能重新对齐——坏处只波及一个字符,用替换符
U+FFFD(�)顶上,后面照常。
注 · 两套存储层对照:😀 在 UTF-16 里是 2 个码元(代理对),在 UTF-8 里是规整的 4 个字节,不需要代理。代理区 U+D800 – DFFF 只为 UTF-16 而生,在 UTF-8 里非法。两套存储层,同一个 code point——逻辑层永远是那个 U+1F600。另外 U+0000 – 007F 在 UTF-8
里一字节且字节值就等于 ASCII,所以一份纯 ASCII 文本,当 UTF-8 读和当 ASCII 读一模一样,这正是 UTF-8 得以平滑普及的关键原因。
4 · 字节序与 BOM
UTF-16 的码元是 16 bit,真正写进文件 / 网络时要拆成 2 个字节——谁在前就是字节序(endianness):big-endian 高位字节在前,little-endian 低位在前。读写两端约定不一致,整篇文本就成乱码。所以 UTF-16 文本流的开头常放一个 U+FEFF(byte order mark,BOM,可选)声明字节序:读到 FE FF 就是 BE、FF FE 就是 LE。而 UTF-8 的码元就是 1 个字节,多字节序列的先后由前缀位模板写死,没有「高低位谁先」的选择题——避开 UTF-16 这套字节序加 BOM 的麻烦,正是它的设计目标之一。
注 · 之所以选 U+FEFF 当 BOM,是因为它字节互换后的 U+FFFE 是 Unicode 规定的永久非字符,正常文本里绝不会出现——所以开头读到 FF FE,只可能是「LE 流的 BOM」,不会误判。U+FEFF 本职是 zero-width no-break space,只有出现在流开头才当
BOM;混进文本中间就是一个看不见的空白,典型问题是文件拼接后页面多出不可见的空白行。至于 UTF-8 的「BOM」:U+FEFF 按 UTF-8 编码是 EF BB BF,它不表示字节序(UTF-8 没有字节序),只是个「我是 UTF-8」的签名。Unicode 标准既不要求也不推荐;Windows 记事本曾默认写入,常导致 shebang
失效(#! 前多出 3 个字节)、严格的 JSON 解析器报错。
5 · 源码里写一个 code point 的两种写法
JS 字符串字面量里,一个 code point 有两种转义写法,正好对应上面的逻辑层与存储层。
| 写法 | 层级 | 例:汉字「中」/ emoji「😀」 |
|---|---|---|
\u{...} |
逻辑层 code point | '\u{4E2D}' / '\u{1F600}'(ES6 起,直接写码点) |
\uXXXX |
存储层 code unit | '\u4E2D' / '\uD83D\uDE00'(固定 4 位 hex,astral 要手写代理对) |
注 · 两种写法等价:'\u{1F600}' === '😀',都得到 😀——前者按 code point 写、引擎自动拆成代理对,后者直接写出上面算过的那对码元。BMP 字符两种写法的 hex 相同('\u{4E2D}' === '中'),只有补充平面字符才看得出差别。三套源码转义(HTML / CSS / JS)的完整对照见
三套转义。
注 · 本页讲的是 UTF-8 / UTF-16 这套统一方案。在它普及之前,中文靠 GB2312 / GBK / GB18030(大陆,层层超集)和 Big5(台 / 港,繁体)这些国家 / 地区编码把字符变成字节,见 中文编码。
6 · 参考文献
-
Pavlutin, D. What every JavaScript developer should know about Unicode. 把字符 / 码点 / 码元 / 代理对的关系从头讲透,覆盖
\u{...}转义、String.length为何按码元算、按 code point 遍历与normalize()。dmitripavlutin.com