基础
Unicode 与 UTF-8 的区别:(参考知乎回答) Unicode 是一个字符集,几乎为全世界已知的所有字符分配了一个编码,编码与字符一一对应;而 UTF-8 定义了如何存储编码的一种方式,类似的还有 UTF-16/UTF-32/GB2312/GBK 编码
UTF-8: 是针对 Unicode 的一种可变长度字符编码(1字节 ~ 4字节),其编码的第一字节与 ASCII 兼容。
UTF-16: 与 UTF-8 类似,也是一种可变长度字符编码,其长度可能是 2字节 或者 4字节。
UTF-32: 使用 4字节(32位)长度表示一个 Unicode 字符,不用做任何转换,缺点是比较占空间。
UCS-2: UTF-16 编码是 UCS-2 编码的超集,在没有辅助平面(UTF-16 的后2个字节)前,UTF-16 与 UCS-2 是指同一个东西,一个字符占用 2 个字节空间。而 JavaScript 就使用 UCS-2 编码,所以当字符码点大于 U+FFFF 时,需特别注意处理。
UTF-8 编码 (rfc3629)
UTF-8 是可变长度编码,一个字符最小占用 1 字节,最多占用 4 字节空间。编码规则 (rfc3629#section-3) 如下:
单字节: 占用 1 字节(8bit)空间,首位以 0 开始,后续 7 位则为 Unicode 码点值,最大可到 0b01111111。
多字节:占用 2~4 字节(16bit ~ 32bit)空间。编码规则:
- 首字节的 8 位,最前面连续有多少个
1就表示该字符占用多少个字节空间,应该是有 2 ~ 4 个1,然后紧跟着 1 个0, 后续的位用于表示码点的位 - 后续字节(第 2 ~ 4 字节):由首字节最前面的多少个
1决定后续字节为 2/3/4 个。后续字节的前2位为10,其后的位用于表示码点的位。完整的码点表示为:首字节的有效位+后续字节的有效位
示例 (有效的码点位用加粗标识):
| 字符 | Unicode 码 | 字节数 | UTF-8 编码 |
|---|---|---|---|
| 'a' | 97 | 1 | 01100001 |
| 'Ɛ' | 400 | 2 | 11000110 10010000 |
| '一' | 19968 | 3 | 11100100 10111000 10000000 |
| '𠮷' | 134071 | 4 | 11110000 10100000 10101110 10110111 |
使用 C++ 获取 UTF-8 编码字符的码点值
/**
* 获取 UTF-8 编码字符的码点值
* @param str - 字符串
* @param index - 字符串索引
*/
unsigned int getCodePointAt(string &str, int index) {
int code = (unsigned char) str[index];
unsigned int baseBits = 0b10000000;
unsigned int lastBits;
// 计算第一个字节前 n 位为 1 的个数
int bytes = 0;
if ((code & baseBits) == baseBits) { // 0b1xxxxxxx
do {
lastBits = baseBits;
baseBits = baseBits | (baseBits >> 1);
bytes++;
} while ((code & baseBits) == baseBits);
} else {
return code;
}
// 3字节示例:1110xxxx 10xxxxxx 10xxxxxx
// 第一字节有效位数: 8 - 字节数 - 1, 后续每个字节有效位数: 6
// 由于后续字节有效位占6位,所以需要把高位左移 6 * (第n字节 - 1), 为低位留空间
int i = bytes - 1;
unsigned int result = (code ^ lastBits) << (i * 6);
while (i >= 1) {
int c = (unsigned char) str[index + i];
result += (c & 0b00111111) << ((bytes - i - 1) * 6);
i--;
}
return result;
}
UTF-16 规范详解 (rfc2781)
由 双字节 或 四字节编码,接下来的编码或解码规则从规范中翻译而来:
编码规则(rfc2781#section-2.1):
把字符码用 U 表示,该数字应该不大于 0x10FFFF
- 如果 U < 0x10000, 直接使用 U 作为 16位无符号整数编码就可以了,结束;
- 否则,令 U' = U - 0x10000, 因为 U 小于等于 0x10FFFF,所以 U' 一定小于等于 0xFFFFF,那么 U' 可以用 20个 bit 表示出来(说明:
0xFFFFF == 0b11111111111111111111,对应二进制20个1) - 初始化 2 个 16 位无符号整数,分别记为 W1 和 W2,W1 初始值为 0xD800, W2 初始值为 0xDC00,这2个整数分别有 10 个 bit 的剩余空间对字符进行编码,加起来总共 20 位(说明:0xD800 与 0xDC00 的二进制后10位均为
0, 这里就是利用其空间) - 将 20 个 bit 的 U' 中的 10 个高位 (U' 前 10 个 bit) 分配给 W1, 10 个低位 (U' 的后 10 个 bit) 分配给 W2,结束。
第 2 步到第 4 步看起来像下面这样:
U' = yyyyyyyyyyxxxxxxxxxx
W1 = 110110yyyyyyyyyy
W2 = 110111xxxxxxxxxx
解码规则 (rfc2781#section-2.2)
设 W1 为表示文本的整数序列 的一个16位整数(通俗来讲,就是用 16 位整数的数组来表示字符串),设 W2 为紧跟着 W1 后面的一个 16位整数(也是用于表示当前字符的最后一个整数)
- 如果 W1 < 0xD800 或 W1 > 0xDFFF,W1 的值就是字符的码点值,结束;
- 确定 W1 在 0xD800 ~ 0xDBFF 之间。如果不是,则该字符不是一个有效字符,结束;
- 如果没有 W2 (W1 后面的一个数字) 或者 W2 不在 0xDC00 ~ 0xDFFF 之间,则字符序列有误,结束;
- 构造一个 20 位无符号整数 U', 取 W1 的 10 个低位(后 10 位)填入 U' 的高位,并取 W2 的 10 个低位(后 10 位)填入 U' 的低位;
- U' + 0x10000 = U,U 就是字符的码点值,结束;
总结
UTF-16 编码分为双字节 和 四字节的情况,
- 双字节范围:0x0000 ~ 0xD799 及 0xDE00 ~ 0xFFFF
- 四字节范围:前 2 个字节 (0xD800 ~ 0xDBFF), 后 2 个字节 (0xDC00 ~ 0xDFFF)
示例
| 字符 | Unicode 码 | 字节数 | UTF-16 编码 |
|---|---|---|---|
| 'a' | 97 | 2 | 00000000 01100001 |
| 'Ɛ' | 400 | 2 | 00000001 10010000 |
| '一' | 19968 | 2 | 01001110 00000000 |
| '𠮷' | 134071 | 4 | 11011000 01000010 11011111 10110111 |
下面用 '𠮷' 字符(UTF-16 编码: 11011000 01000010 11011111 10110111 )来验证一下解码规则:
- 读取第 1 个双字节值为 55362,在 0xD800 ~ 0xDBFF 之间,所以该字符用 4 字节表示,即需要读取下一个双字节
- 读取下一个双字节值为 57271,验证在 0xDC00 ~ 0xDFFF 之间,所以字符有效
- 分别取第一个双字节的 10 个低位 (
0001000010),和后一个双字节的 10 个低位 (1110110111) ,组成一个 20 位整数,即:00010000101110110111,转为十进制位 68535,最后在加上 0x10000,得到码点值:68535 + 0x10000 = 134071
JavaScript 与字符编码
由于 JavaScript 使用 UCS-2 编码,即与 UTF-16 编码的双字节规则相同。所以对于码点大于等于 0x10000 时,会看到如下现象:
'𠮷'.length // 2
可以通过以下一些方式规避这个问题:
Array.from('𠮷').length // 1
// 将所有码点值大于等于 0x10000 的字符(JS默认作为 2 个字符表示)都替换成单字符
'𠮷'.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, '_').length // 1
获取字符的码点:
// 规格 UCS-2 规则,这里只能读取了第一个双字节的值,读取了一个错误的码点值
'𠮷'.charCodeAt(0) // 55362
// 使用 ECMAScript 2015 规范中的字符串 API 获取
'𠮷'.codePointAt(0) // 134071