所谓字符编码(Character encoding),就是把字符集中的字符,以某种指定的格式或规则,映射到另外一个集合中某一个值(相当于是把字符从一种形式转换成另一种形式),以便字符在计算机中存储或通过网络传递。
从计算机诞生到现在,对于字符的编码经历了多个阶段的变化,让我们一起来了解下字符编码的发展简史。
一、为什么聊这个
为什么会想要聊这个呢?这还得从开发中遇到的一个bug说起。
在一次开发中,由于要对一些字符串中的字符做一些高亮和截断,但是最开始只考虑了英文字符,单个字符的长度都是1,但是后续由于部分中文、emoji的加入,导致长度判断出了问题,出现了一些乱码。我们发现emoji的长度大多数为2甚至更长,比如"😂":
javascript
'😂'.length; // 2
这激起了我的一些好奇心,于是乎从头开始,完整了解了一下字符编码的发展简史。
二、从二进制到各国编码
1. 二进制
我们都知道,由于电子管只有开
和关
这两种状态,所以计算机采用的是二进制来存储数据,也就是说所有的数据,最终都是以二进制的形式被计算机存储起来的。例如,a、b、c、d这样的52个字母(包括大写)以及0、1等数字还有一些常用的符号(例如*、#、@等)在计算机中存储时也要使用二进制数来表示。
采用二进制可以更方便计算机进行运算,但是对于使用计算机的人类来说,就不那么方便了。为了方便人可以看懂计算机的二进制数据,需要设计一套规则来说明用哪些二进制数字表示哪个符号 ,这就是编码。
2. ASCII
为了防止不同的计算机使用不同的编码规则造成混乱,美国有关的标准化组织就推出了ASCII编码(American Standard Code for Information Interchange,美国信息交换标准代码)。
具体来说,ASCII编码一共规定了128个字符的编码,包括大小写英文字母、数字、常用符号,以及32个无法打印出来的控制符号。128个字符分别用数字0 ~ 127
(十进制)来表示,对应的二进制为0000 0000 ~ 0111 1111
。比如a
对应是97
(十进制),相应的二进制为0110 0001
。
这样的话,任意英文都可以在计算机中用二进制表示了,比如"Hello world"这句话,在计算机中的表示就是下面这样:
01001000 01100101 01101100 01101100 01101111 00100000 01110111 01101111 01110010 01101100 01100100
按理说128个字符,用7位二进制表示就够了(2^7=128 ),为什么要用8位二进制来表示呢。因为计算机里数据的计量单位是字节(byte),一个字节是8比特(bit),也就是8位,所以使用8位二进制(一个字节)来表示这128个字符,最前面的一位统一规定为0。
该编码的字符集如下所示:
x0 | x1 | x2 | x3 | x4 | x5 | x6 | x7 | x8 | x9 | xA | xB | xC | xD | xE | xF | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0x | NUL | SOH | STX | ETX | EOT | ENQ | ACK | BEL | BS | HT | LF | VT | FF | CR | SO | SI |
1x | DLE | DC1 | DC2 | DC3 | DC4 | NAK | SYN | ETB | CAN | EM | SUB | ESC | FS | GS | RS | US |
2x | SP | ! | " | # | $ | % | & | ' | ( | ) | * | + | , | - | . | / |
3x | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | : | ; | < | = | > | ? |
4x | @ | A | B | C | D | E | F | G | H | I | J | K | L | M | N | O |
5x | P | Q | R | S | T | U | V | W | X | Y | Z | [ | \ | ] | ^ | _ |
6x | ` | a | b | c | d | e | f | g | h | i | j | k | l | m | n | o |
7x | p | q | r | s | t | u | v | w | x | y | z | { | | | } | ~ | DEL |
在上表中,0x20
是空格。0x00 ~ 0x1F
、0x7F
表示不同的控制字符。
3. 编码的"战国时期"
ASCII码是美国人发明的,对于只用英文的美国人来说,ASCII码其实就够用了,但是在计算机传到欧洲时情况就有变化了。虽然很多欧洲国家的语言中也是包含26个英文字母,但是很多国家还会包含一些带有声调或者其他附加符号的字母或者其他字母,如À
、Ö
等。对于这些字母,ASCII码无法表示。
3.1 Latin-1
在这种背景下,欧洲推出了自己的一种编码,Latin-1编码(又叫做ISO/IEC 8859-1)。这个编码以ASCII为基础,使用了ASCII码中没有用到的第一位,在空置的0xA0 ~ 0xFF
(即十进制的160 ~ 255
,二进制的1010 0000 ~ 1111 1111
)的范围内,加入96个字母及符号,以供使用附加符号的拉丁字母语言使用。
该编码的字符集如下所示:
x0 | x1 | x2 | x3 | x4 | x5 | x6 | x7 | x8 | x9 | xA | xB | xC | xD | xE | xF | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0x | ||||||||||||||||
1x | ||||||||||||||||
2x | SP | ! | " | # | $ | % | & | ' | ( | ) | * | + | , | - | . | / |
3x | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | : | ; | < | = | > | ? |
4x | @ | A | B | C | D | E | F | G | H | I | J | K | L | M | N | O |
5x | P | Q | R | S | T | U | V | W | X | Y | Z | [ | \ | ] | ^ | _ |
6x | ` | a | b | c | d | e | f | g | h | i | j | k | l | m | n | o |
7x | p | q | r | s | t | u | v | w | x | y | z | { | | | } | ~ | |
8x | ||||||||||||||||
9x | ||||||||||||||||
Ax | NBSP | ¡ | ¢ | £ | ¤ | ¥ | ¦ | § | ¨ | © | ª | << | ¬ | SHY | ® | ¯ |
Bx | ° | ± | ² | ³ | ´ | µ | ¶ | · | ¸ | ¹ | º | >> | ¼ | ½ | ¾ | ¿ |
Cx | À | Á | Â | Ã | Ä | Å | Æ | Ç | È | É | Ê | Ë | Ì | Í | Î | Ï |
Dx | Ð | Ñ | Ò | Ó | Ô | Õ | Ö | × | Ø | Ù | Ú | Û | Ü | Ý | Þ | ß |
Ex | à | á | â | ã | ä | å | æ | ç | è | é | ê | ë | ì | í | î | ï |
Fx | ð | ñ | ò | ó | ô | õ | ö | ÷ | ø | ù | ú | û | ü | ý | þ | ÿ |
在上表中,0x20
(32)是空格、0xA0
(160)是不换行空格、0xAD
是选择性连接号。0x00 ~ 0x1F
、0x7F
、0x80 ~ 0x9F
在此字符集中没有定义(控制字符是由ISO/IEC 6429定义)。
3.2 其他欧洲字符集
可是只有Latin-1还是不够,上面这个字符集只能用来表示西欧 的字符,对于中欧、北欧,以及包括俄文在内的斯拉夫语族的字符都不包含在内,于是乎各个地区的国家,又陆续以同样的方式,使用ASCII编码中没有使用到的第一位进行拓展,产生了Latin-2、Latin-3、Latin-4等等欧洲地区的其他Latin编码,这些编码后来统一定义在了ISO/IEC 8859标准里,并且分别命名为ISO/IEC 8859-1、ISO/IEC 8859-2等。
这些编码虽然很好地支持了不同的国家的语言,但是在不同国家之间的兼容性上,可以说是几乎没有。由于它们都是基于ASCII进行拓展,占用了ASCII未使用到的第一位,所以对于0x7F
(十进制0111 1111
)之后的字符,同样的二进制数,在不同国家地区代表了不同的字符。某个国家的文件在另一个国家打开,一般情况下看到的都是乱码。
3.3 亚洲地区的编码
同时随着计算机在亚洲的流行,像中文、日文、韩文这种动则几千上万字符的语言,都基于ASCII增加或者自创了自己的编码,比如中文的GB2312编码,日文的Shift_JIS等。
由于越来越多的编码出现,电脑上一份文件要想展示正确的内容,需要电脑包含所有这些不同的字符集,并且采用正确的方式打开,否则看到的将会是乱码。这也就是乱码产生的本质,即使用了错误的编码方式/字符集来展示某个文件。
为了解决这个问题,Unicode编码出现了。
三、Unicode:万国码
Unicode,又叫做万国码,其官方机构Unicode联盟整理、编码了世界上大部分的文字系统,使得电脑能以通用划一的字符集来处理和显示文字。目前最新的版本为2022年9月公布的15.0.0,已经收录超过14万个字符。
1. 码位
Unicode编码从0开始,为每一个字符分配一个唯一的编号,这个编号就叫做码位(code point,也叫码点) ,以"计算机"的"机"字举例,"机"的码位为26426
(十进制),即Unicode字符集中第26426个字符,就是"机"。
但是在表示一个Unicode的字符时,通常会用U+
然后紧接着一组十六进制的数字来表示这一个字符。也就是说,"机"在Unicode中的表示方式是U+673A
,码位是673A
。
Unicode为了和已有的编码方式相互兼容,其首256个字符保留给ISO/IEC 8859-1所定义的字符(Latin-1编码),使既有的西欧语系文字(包括英文)的转换不需特别考量;并且把大量相同的字符重复编到不同的字符码中去,使得旧有纷杂的编码方式得以和Unicode编码间互相直接转换,而不会丢失任何信息。
2. 平面
对于不同国家数十万的字符,Unicode联盟显然不可能一下子就给出全部字符的码位定义,更何况现在的有些字符,在那个时候还没有出现。Unicode联盟把所有字符,分为了17组进行编排,每组称为平面(Plane),而每平面拥有65536(即216)个代码位。
也就是说目前一共有17个平面,共计2^16 * 17(1114112)个码位,最多可以表示一百多万个字符。
平面 | 始末字符值 | 名称 | 简称 |
---|---|---|---|
0号平面 | U+0000 ~ U+FFFF |
基本多文种平面 | BMP |
1号平面 | U+10000 ~ U+1FFFF |
多文种补充平面 | SMP |
2号平面 | U+20000 ~ U+2FFFF |
表意文字补充平面 | SIP |
3号平面 | U+30000 ~ U+3FFFF |
表意文字第三平面 | TIP |
4-13号平面 | U+40000 ~ U+DFFFF |
(尚未使用) | |
14号平面 | U+E0000 ~ U+EFFFF |
特别用途补充平面 | SSP |
15号平面 | U+F0000 ~ U+FFFFF |
保留作为私人使用区(A区) | PUA-A |
16号平面 | U+100000 ~ U+10FFFF |
保留作为私人使用区(B区) | PUA-B |
其中我们常用的一些字符,都定义在了第一个平面里,即基本多文种平面(Basic Multilingual Plane, BMP),或称基本平面 或0号平面(Plane 0) 。比如大部分的汉字,都是定义在U+4E00 ~ U+9FFF
代表的中日韩统一表意文字内(我们上面所说的"机",U+673A
,也在里面)。
而除了基本平台其他的十六个平面,都叫做辅助平面,用来放一些不常用的字符(如扑克牌花色),或者已经被废弃的古文字(如甲骨文)等。还有一些用作私人使用区,主要是指没有在Unicode标准中指定,而是由合作用户之间的私人协议决定其用途的编码区。
现在看起来只需要使用Unicode编码,就可以囊括所有的语言符号,再也不会有乱码的问题了,真美好。
3. 好像还有些事情要做
可是事情真的像预期的那边美好吗?
Unicode定义了字符的编码方式,但是没有定义这些编码的实现方式。就是说Unicode编码虽然给每一个字符一个唯一的编码,解决了字符集不统一的情况,但是没有定义在计算机底层,要怎么存储或者传输每一个Unicode字符。
例如ASCII定义了用一个字节(8位二进制)来存储每个字符,GB2312是用两个字节来存储每个汉字,Unicode的每一个字符应该用多大的空间来存储呢?这就涉及到Unicode编码的实现方式了,或者称为Unicode转换格式。
四、UTF
Unicode编码的实现方式称为Unicode转换格式(Unicode Transformation Format,简称为UTF)。由于不同系统平台的设计不一定一致,以及出于节省空间的目的,对Unicode编码的实现方式也有所不同,下面我们说几个经常听到的转换格式。
0. 码元
在开始之前,先引入一个概念,码元(Code Unit),是指一种编码转换格式中具有最短的二进制组合的单元,即字符占用的最少二进制位。比如上面说的ASCII编码,每个字符都是占用8位二进制,所以ASCII编码的码元是8。
1. UTF-32
首先,最简单的就是,用一个能包含所有Unicode编码的空间,来存储每一个字符。Unicode目前一共有17个平面,共计216 * 17个码位。
2^16 * 17 = 2^20 + 2^16
2^20 < 2^20 + 2^16 < 2^21
只需要使用大于21位的二进制来存储就可以了,例如UTF-32编码,这个编码用32位(4个字节)的二进制来存储Unicode编码的字符,不算固定为0
的首位数字,总计能表示 231个字符,够用了。
由于UTF-32可以表示的字符比Unicode所有字符还多,可以做到UTF-32的编码与字符的Unicode码位的数值完全一致。由于UTF-32种每个字符都是用用32位(4个字节)来表示,所以UTF-32的码元就是32。
还是以上面的"机"为例,它的码位是U+673A
(即十六进制0x673A
),那么它在UTF-32中的编码值为0x0000673A
(因为总共有32位,4个字节,所以要在前面补上两个字节的0,凑够4字节)
字符: | 机 |
---|---|
码位: | U+673A |
码位十六进制: | 0x673A |
码位二进制: | 01100111 00111010 |
UTF-32编码: | 0x0000 673A |
UTF-32编码的二进制形式: | 00000000 00000000 01100111 00111010 |
看起来UTF-32挺好的,可以通过Unicode码位直接知道对应的UTF-32编码,查找编码是一个常数时间的操作。但是它有个巨大的缺陷,就是占用空间太大。
在大多数文本中,非基本平面的字符非常罕见,绝大多数常见字符都位于基本平面里,一般只需要1-2个字节就可以表示,比如上面的"机"(01100111 00111010
),只需要两个字节就可以表示,但是UTF-32中却占了4个字节。
对于纯英文的文本来说,这种情况更甚,比如字母a
的码位为0110 0001
,只需要一个字节,但是在UTF-32同样要用4个字节来表示,00000000 00000000 00000000 01100001
。
这也就造成UTF-32所需空间接近UTF-16的两倍和UTF-8的四倍,空间浪费较多。所以UTF-32编码,目前基本没有人使用,甚至在HTML5标准中明确规定在HTML中禁止使用UTF-32编码。
2. UTF-16
2.1 变长编码
既然在大多数文本中,非基本平面的字符非常罕见,绝大多数常见字符都位于基本平面里,那么有没有一种更节省空间的实现方式?有,那就是UTF-16。(当然这个是从现在这个时间点来看的,UTF-16设计出来的目的并不是为了解决UTF-32的问题,还涉及到一个UCS-2的编码实现方式,下面会讲到)
UTF-16是一个变长的编码转换格式,也就是说相比UTF-32这种固定长度(4字节)的来说,它的编码长度取决于字符在Unicode中的码位,可能是2个字节,有可能是4个字节。
根据上面的Unicode平面对应的码位,可以看到基本平面内的字符U+0000 ~ U+FFFF
,长度最长只有2个字节。所以对于基本平面的字符,在UTF-16中用2个字节来表示。对于其他辅助平面内的字符U+10000 ~ U+10FFFF
,则用4个字节来表示。由于UTF-16中字符最少要占用2个字节(16位),所以UTF-16的码元是16,即UTF-16中字符要么用1个码元来表示,要么用2个码元来表示。
以上面"计算机"中的"机"为例,"机"的码位是U+673A
,位于基本平面内,用两个字节表示,这个时候它的UTF-16编码和它在Unicode中的码位是一样的,即0x673A
,换成二进制就是01100111 00111010
:
字符: | 机 |
---|---|
码位: | U+673A |
码位十六进制: | 0x673A |
码位二进制: | 01100111 00111010 |
UTF-16 编码: | 0x673A |
UTF-16 编码的二进制形式: | 01100111 00111010 |
这样在绝大多数情况下,用2个字节就可以存储或者表示字符,极少数情况下用到的辅助平面字符,才会用到4个字节的表示方式。
但是这样也会带来一个问题,计算机在读取字符的二进制时,比如读取了2个字节,要怎么知道这2个字节表示的是一个字符,还是说要再加上后面2个字节连起来组成一个字符呢?
2.2 前导代理和后尾代理
我们知道,需要用4个字节来表示的字符,都是位于16个辅助平面内的字符U+10000 ~ U+10FFFF
,16辅助平面内的字符总用有220个:
2^16 * 16 = 2^20
这些辅助平面内的字符,相对于辅助平面内的第一个字符U+10000
,偏移量分别是 0 ~ 2^20 - 1
0x10000 - 0x10000 = 0
0x10FFFF - 0x10000 = 2^20 - 1
只需要记录下每个辅助平面内字符相对于第一个辅助平面字符的偏移量,就可以知道每个字符在Unicode中的码位。
而220个数字只需要用20位长度的二进制就可以表示,20位长度的二进制拆分到4个字节里,每2个字节存储10位长度的二进制。
以𐐷
这个字符为例,这个字符的码位是U+10437
:
字符: | 𐐷 |
---|---|
码位: | U+10437 |
相对于 0x10000 的偏移量: |
0x10437 - 0x10000 = 0x00437 |
偏移量的二进制表示: | 0000 0000 0100 0011 0111 |
前10位: | 0000000001 |
后10位: | 0000110111 |
也就是说𐐷
这个字符占4个字节,前2个字节是00000000 00000001
,后2个字节是00000000 00110111
。
但是到这里依然没有解决上面的问题,计算机在读取到前2个字节00000000 00000001
时,不知道这表示的是SOH
这个控制字符,还是说要和后面2个字节连起来,共同组成一个字符𐐷
。因此,需要在基本平面中保留不对应任何Unicode字符的两个区域,用于标识UTF-16的4字节字符的前10位和后10位。这两个区域就是
0xD800 ~ 0xDBFF |
容纳前10位的区域,区域大小为 210 |
---|---|
0xDC00 ~ 0xDFFF |
容纳后10位的区域,区域大小为 210 |
而只需要把上面的前10位00000000 00000001
和后10位00000000 00110111
,分别加上这两个区域的起始值,就是这个字符在UTF-16中真正的表示:
字符: | 𐐷 |
---|---|
码位: | U+10437 |
相对于 0x10000 的偏移量: |
0x10437 - 0x10000 = 0x00437 |
偏移量的二进制表示: | 0000 0000 0100 0011 0111 |
前10位: | 0000000001 , 0x0001 |
后10位: | 0000110111 , 0x0037 |
前10位 + 0xD800 : |
0xD800 + 0x0001 = 0xD801 , 11011000 00000001 |
后10位 + 0xDC00 : |
0xDC00 + 0x0037 = 0xDC37 , 11011100 00110111 |
UTF-16 中真正的表示: | 0xD801``0xDC37 ,11011000 00000001``11011100 00110111 |
所以𐐷
这个字符,在UTF-16中的真正表示为0xD801
0xDC37
,以二进制形式就是11011000 00000001
11011100 00110111
,前后各2个字节分别叫做这个字符的前导代理 (lead surrogates)和后尾代理 (trail surrogates),这个字符就是由这样一个代理对(Surrogate Pair)来表示。
2.3 计算机读取
计算机在读取时,每读取2个字节,如果这两个字节范围处于0xD800 ~ 0xDBFF
这个区域,那么就知道这2个字节,要和后面的2个字节连起来,共同组成一个字符。
以上面为例,在读取到11011000 00000001
时,发现这个数字位于0xD800 ~ 0xDBFF
,那么就知道11011000 00000001
表示这个字符的前导代理而不是一个真正字符,需要再读取后2个字节里的后尾代理,具体步骤为:
- 读取:
11011000 00000001
11011100 00110111
,即0xD801
0xDC37
- 分别减去
0xD800
和0xDC00
:0x0001
0x0037
,即0000000001
0000110111
- 组合起来就是
0000 0000 0100 0011 0111
,即0x00437
- 也就是说字符相对于
0x10000
的偏移量是0x00437
,或者换种说法,这个字符是辅助平面字符里的第0x00437
个辅助平面字符 - 所以真正的码位是
0x10000 + 0x00437 = 0x10437
,即U+10437
,所表示字符为𐐷
2.4 小结
到这里UTF-16基本是说完了,看起来很复杂,但总结下来就三点:
- 对于基本平面内的字符,用2个字节表示;对于其他辅助平面内的字符,用4个字节表示
- 对于辅助平面内的字符,拆成两半
- 一半映射在
0xD800 ~ 0xDBFF
内 - 一半映射在
0xDC00 ~ 0xDFFF
内
- 一半映射在
- 读取2个字节,如果发现这2个字节的码位处于
0xD800 ~ 0xDBFF
,那么就当做4字节字符处理,再读取后面的2个字节共同组成字符。否则就直接作为2字节字符
3. UTF-8
既然UTF-16可以通过对字符做区分,不同字符使用不同长度的字节来表示,那应该有更加节省空间的变长编码转换格式才对。而UTF-8就是这样一个变长的编码转换格式。
3.1 编码规则
UTF-8从名字就可以知道,这是一种最小长度为8位二进制(即1个字节)的编码转换格式。使用 1 ~ 4 个字节来表示字符(1、2、3、4个字节都有可能),具体的规则如下:
码位范围 | 字节数 | Byte 1 | Byte 2 | Byte 3 | Byte 4 |
---|---|---|---|---|---|
U+0000 ~ U+007F |
1 | 0xxxxxxx |
|||
U+0080 ~ U+07FF |
2 | 110xxxxx |
10xxxxxx |
||
U+0800 ~ U+FFFF |
3 | 1110xxxx |
10xxxxxx |
10xxxxxx |
|
U+10000 ~ U+10 FFFF |
4 | 11110xxx |
10xxxxxx |
10xxxxxx |
10xxxxxx |
对于一个字符,首先确定其在Unicode中的码位,根据上面表格第一列,确定这个码位所属的范围所需要用到的字节。
- 如果是单字节字符,那么这个字节首位为
0
,用剩下的7位来表示这个字符。可以看到,对于ASCII中的字符,在ASCII和UTF-8中的编码规则是一致的,也就是说UTF-8兼容ASCII编码 - 如果是多字节字符,假设是
n
字节字符,就在第一个字节开头用n
个1
来填充,第n + 1
位用0
填充,且后面的字节的前两个字符,都用10
来填充。剩下的没有被填充的位,就是用来填充这个字符Unicode码位的二进制,从右向左填充,未填充满的用0
补齐
由于UTF-8中字符最少占据1个字节(8位),所以UTF-8的码元是8,即UTF-8中字符用 1 ~ 4 个码元来表示。 还是以上面的"计算机"的"机"为例:
- "机"的码位为
U+673A
- 根据上面的表格,处于
U+0800 ~ U+FFFF
这个范围内,所以要用3个字节来表示 - 也就是说格式为
1110xxxx
10xxxxxx
10xxxxxx
,x
为这个字符码位二进制填充位 U+673A
的二进制为110011100111010
,从右向左依次填充,得到:
11100110
10011100
10111010
即字符"机"在UTF-8中的编码为11100110 10011100 10111010
,用16进制表示为0xE69CBA
。
字符 | 机 |
---|---|
码位: | U+673A |
码位十六进制: | 0x673A |
码位二进制: | 01100111 00111010 |
UTF-8 编码: | 0xE69CBA |
UTF-8 编码的二进制形式: | 11100110 10011100 10111010 |
3.2 真的最节省空间吗?
UTF-8最小字节单位为 1 个字节,相比起UTF-16和UTF-32最小字节单位是 2 字节和 4 字节来说,确实是可以节省下不少空间。但是这个节省空间是相对的。
对于以拉丁字母作为主要语言的英语、西欧语言等来说,绝大多数情况下只需要 1 个字节或者 2 个字节就可以表示字符。对于这些语言来说,最节省空间的确实是UTF-8。但是对于很多亚洲国家来说,比如中日韩,却不是这样。
从上面的内容可以知道,对于中日韩这些文字来说,在UTF-16中,绝大多数文字都只需要 2 个字节就可以表示,但是在UTF-8中,却需要 3 个字节来表示,占用空间比UTF-16多出了 50%,这样看来,对于中日韩文字CJK文字来说,最节省空间的反而是UTF-16编码了。
也就是说,哪种编码转换格式最节省空间是由字符内容决定的,UTF-8只有在英语等拉丁语言的情况下才是最节省空间的,但是对于亚洲地区来说,最合适的编码方式是UTF-16。
4. Big Endian 和 Little Endian
其实除了上面说的内容,对于多字节的编码转换格式来说,还有一个 Big Endian(大端序,简称BE)和 Little Endian(小端序,简称LE)的概念。因为这些多字节的编码中字符占用多个字节,在将字符二进制拆分成多个字节后,多个字节可能会有排列顺序的区分。
比如上面的"机",UTF-16编码中会把码位二进制01100111 00111010
放到两个字节里:
字符: | 机 |
---|---|
码位: | U+673A |
码位十六进制: | 0x673A |
码位二进制: | 01100111 00111010 |
那么在UTF-16 BE和UTF-16 LE中,编码分别为01100111 00111010
和00111010 01100111
,两个字节的顺序正好相反。
UTF-16 BE 编码 | 01100111 00111010 |
---|
UTF-16 LE 编码 | 00111010 01100111 |
---|
我们上面举例的一些字符,都是以BE的形式来表示的。这个其实没有一个优劣,更多是一种人们情绪化的选择,就好像鸡蛋应该从大的那头拨开还是小的那头拨开一样。感兴趣的可以看看这个大小端问题的起源。
五、JavaScript中使用的编码
说了这么多,在JavaScript中使用的是哪种编码呢?
1. 编码格式
其实JavaScript使用的是一个叫做UCS-2的编码,并不是上面的任何一种。UCS-2是一种定长 的编码转换格式,用2个字节来表示字符,可以理解为是只能用来表示基本平面内字符的UTF-16,不能表示辅助平面内的字符。或者说UTF-16是基于UCS-2的超集。
同样的,Java和 Objective-C都是使用的是UCS-2。这些语言之所以选择这个编码转换格式,是因为在Unicode早期,大家都以为用2个字节就足够表示所有字符了,所以UCS-2使用了2字节定长编码,且字符编码和字符的Unicode码位的一样的,获取字符的开销是一个常数操作,对于需要处理字符串的编程语言来说是最合适的选择。
但是到后期随着Unicode的扩充,2字节定长编码的UCS-2无法满足需求,也就诞生了基于UCS-2的UTF-16编码,在基本平面内兼容UCS-2,同时通过可变的长度来支持辅助平面内字符。这些编程语言也基本过渡到了UTF-16上。
但是一些稍微新一些的编程语言,比如Python3,Go等,默认的编码转换格式都已经是UTF-8了。
2. 获取字符串长度
既然知道了JavaScript中的编码转换格式,那我们来试下获取字符长度:
javascript
'a'.length; // 1
'机'.length; // 1
'😂'.length; // 2
可以发现,同样是一个字符,"a"和"机"的长度都是"1",但是一个笑哭的emoji"😂",长度却是2。这是为什么呢? 因为获取字符串长度,本质上就是获取字符串在当前编码格式中占用的码元(Code Unit)数量。 在UTF-16(UCS-2)中,码元为16(2个字节):
- "a"和"机"都是基本平面内的字符,都可以用 2 个字节,即 1 个UTF-16码元来表示,所以长度是1
- "😂"这个emoji的Unicode码位是
U+1F602
,已经超出了基本平面U+0000 ~ U+FFFF
,是辅助平面内的字符,要用 4 个字节,即 2 个UTF-16码元来表示,所以长度是2
Java和OC中执行结果也和上面一样。相应的,如果这些字符串放在默认编码方式为UTF-8的语言中,如Go,得到的长度就会是下面这样:
go
len("a") // 1
len("机") // 3
len("😂") // 4
这是因为在UTF-8中,码元为8(1个字节):
- "a",
U+0061
,是用 1 个字节表示,1个码元,所以长度是1 - "机",
U+673A
,用 3 个字节表示,即 3 个UTF-8码元,所以长度是3 - "😂",
U+1F602
,用 4 个字节表示,即 4 个UTF-8码元,所以长度是4
所以,在一些前后端默认字符编码编码格式不一样的场景,比如前端(JS/Java/OC),后端Go,就可能会出现一些字符串长度判断不一致的情况(看到一篇文章里就有遇到这种情况)。
3. 获取字符串中真正的字符个数
说了这么多,有没有办法在JS中获取到真正的字符个数呢,比如"😂"。有的,通过Array.from(str)
:
javascript
Array.from('😂').length; // 1
再来看一个特殊情况:
javascript
'👩👩👧👧'.length; // 11
Array.from('👩👩👧👧').length; // 7
Array.from('👩👩👧👧'); // ['👩', '', '👩', '', '👧', '', '👧']
可以看到"👩👩👧👧"这个emoji,其实是由4个emoji组合到一起的,并且中间用了U+200D
这个名字叫做"零宽度连字符"的字符来连接,这个字符的长度是1,所以这个emoji本质上是7个字符组合到一起的,总计7个字符,长度为:
2 + 1 + 2 + 1 + 2 + 1 + 2 = 11
我们也可以反向来试一下:
javascript
['👩', '', '👩', '', '👧', '', '👧'].join(''); // 👩👩👧👧 这里的 join 方法使用的是空字符串
['👩', '👩', '👧', '👧'].join('\u{200D}'); // 👩👩👧👧 这里的 join 方法使用的是 U+200D
4. 其他一些和字符编码有关的方法和逻辑
4.1 获取码位
在JS中,想要获取一个字符的Unicode码位很简单,只需要通过String.prototype.codePointAt()
即可获取码位的十进制值 。codePointAt
接受一个参数,可以用来获取指定码元处码位,不传则默认为0
,即这个字符第一个码元处的码位的值。
javascript
'a'.codePointAt(); // 97 (十进制)
'机'.codePointAt(); // 26426
'😂'.codePointAt(); // 128514
'😂'.codePointAt(0); // 128514
'😂'.codePointAt(1); // 56834
如果指定的这个码元位置的码位是UTF-16代理对的前导代理 ,那么就会返回这个字符完整的Unicode码位,否则就直接返回这个位置的码位,详见MDN。
如上面'😂'.codePointAt(0)
,因为"😂"这个字符在UTF-16中是用2个码元(4个字节)来表示的,根据规则组成这个字符的 2个码元是一个代理对 ,它的第一个码元是前导代理,所以直接返回了"😂"这个字符的完整Unicode码位128514
,即U+1F602
。
但是'😂'.codePointAt(1)
,因为索引为1的码元,是一个后尾代理,所以直接返回了这个后尾代理的码位56834
,即U+DE02
。而"😂"的在UTF-16中的完整编码格式为U+D83D
U+DE02
:
字符: | 😂 |
---|---|
码位: | U+1F602 |
前导代理: | U+DE02 |
后尾代理: | U+DE02 |
UTF-16 BE 中的表示: | U+D83D U+DE02 |
4.2 用码位表示字符
JS中是允许用码位来表示字符的,表示方式是\u{xxxx}
,即反斜杠 + u + 花括号,花括号内为16进制的码位。又由于JS使用的是UTF-16,所以在JS中可以用下面的方式来表示"😂":
javascript
'😂' === '\u{1F602}'; // true Unicode码位表示
'😂' === '\u{D83D}\u{DE02}'; // true UTF-16编码格式表示
'\u{1F602}' === '\u{D83D}\u{DE02}'; // true
4.3 直接查看文件中字符的二进制
说了这么多,都是在讲解,那有什么办法直接查看某个字符或者字符串的二进制编码呢?最简单的我们可以通过一些文本编辑器来看,比如sublime。还是以"机"和"𐐷"为例。
在sublime中输入一个字符,然后以某种编码格式保存,我们这里分别用UTF-8,UTF-16 BE,UTF-16 LE来保存:
然后打开终端,通过xbb -b
命令分别查看三个文件的二进制内容:
可以看到二进制内容,和上面讲到的是完全一致的。
六、小结
到这里基本上已经说完了,字符编码从二进制,到ASCII问世,到Latin-1、2、3...,再到GB2312、Shift_JIS的"战国时代",直到最后Unicode万国码"一统天下"。而随Unicode诞生的,还有UTF-32、UTF-16、UTF-8、UCS-2等。
可以看到一个小的字符编码引起的Bug,后面可以挖出这么多,有时候日常开发中不那么起眼的一些点,后面也有很多有趣的内容。
参考链接
感谢下面这些参考链接的作者,看这些文章收获很多!
- Wikipedia 字符编码
- 字符编码笔记:ASCII,Unicode 和 UTF-8 - 阮一峰的网络日志
- Unicode与JavaScript详解 - 阮一峰的网络日志
- 计算机编码发展历史和编码方式_计算机最早的编码方式_cacho_37967865的博客-CSDN博客
- '👩👩👧👧'.length === 11 ? Emoji 有多长
- 前端开发中需要搞懂的字符编码知识 - jimojianghu - 博客园
- JS 判断带 emoji 的字符串长度的究极方法 - 小小孩
- 程序的逻辑 (6) 编码的本质与乱码的恢复
- 《JavaScript 权威指南》解读------字符串是 16 位值的序列
- Javascript中的string类型使用UTF-16编码_weixin_34281537的博客-CSDN博客
- Java 为什么使用 UTF-16 而不是更节省内存的 UTF-8? - 知乎