字符的编码: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 个字节变长存。
同一个 code point,两套存储层拆法不同——下面这个拆解器把三层并排摊给你看。
按 code point 拆(for..of / [...str],代理对算一个)
UTF-16 码元(charCodeAt / .length,代理对算两个)
UTF-8 字节(hex + 二进制位,深色是载荷位、灰色是前缀位)
同一串,各存储层各占多少
补充平面 → 代理对的换算
BMP 内的字符一个 UTF-16 码元放得下;补充平面 (astral, > U+FFFF) 放不下,得用两个码元拼,
即代理对 (surrogate pair)。这就是 '😀'.length === 2 的原因。输入里第一个 astral 字符按公式拆成高低位代理:
U+D800 ~ U+DBFF(1024 个)放 code point 的高 10 bit,
低位区 U+DC00 ~ U+DFFF(1024 个)放低 10 bit。1024 × 1024 ≈ 100 万,正好覆盖 16 个补充平面。
这两段码位专门留给代理、不表示任何字符,所以拼接无歧义。
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(�)顶上,后面照常。
U+D800 – DFFF 只为 UTF-16 而生,在 UTF-8 里非法。两套存储层,同一个 code point——逻辑层永远是那个 U+1F600。
ASCII 字节级兼容:
U+0000 – 007F 一字节且字节值就等于 ASCII,所以一份纯 ASCII 文本,当 UTF-8 读和当 ASCII 读一模一样——这正是 UTF-8 得以平滑普及的关键原因。
字节序与 BOM:UTF-8 天生不挑字节序
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 的麻烦,正是它的设计目标之一。
同一串字符,三种字节流(高位字节 低位字节 BOM)
把 BE 字节流交给解码器,字节序猜对 vs 猜错
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 解析器报错。
在源码里写一个 code point 的两种写法
JS 字符串字面量里,一个 code point 有两种转义写法,正好对应上面的逻辑层 / 存储层:
| 写法 | 层级 | 例:汉字「中」/ emoji「😀」 |
|---|---|---|
\u{...} | 逻辑层 code point | '\u{4E2D}' / '\u{1F600}'(ES6 起,直接写码点) |
\uXXXX | 存储层 code unit | '中' / '😀'(固定 4 位 hex,astral 要手写代理对) |
'\u{1F600}' === '😀',都得到 😀——前者按 code point 写、引擎自动拆成代理对,
后者直接写出上面算过的那对码元。BMP 字符两种写法的 hex 相同('\u{4E2D}' === '中'),只有补充平面字符才看得出差别。
\u{...} 转义、String.length 为何按码元算、用 for...of / 展开运算符按 code point 遍历、
以及 normalize() 归一化。