一、ASCII 码
一个字节(byte)有8个bit位,每个位有0和1两种状态,一个字可以表示2^8共有256种不同状态
ASCII 码一共规定了128个字符的编码,比如空格SPACE
是32(二进制00100000
),大写的字母A
是65(二进制01000001
)。这128个符号(包括32个不能打印出来的控制符号),只占用了一个字节的后面7位,最前面的一位统一规定为0
。
ASCII 码表:
Bin(二进制) | Oct(八进制) | Dec(十进制) | Hex(十六进制) | 缩写/字符 | 解释 |
0000 0000 | 0 | 0 | 00 | NUT(null) | 空字符 |
0000 0001 | 1 | 1 | 01 | SOH(start of headline) | 标题开始 |
0000 0010 | 2 | 2 | 02 | STX (start of text) | 正文开始 |
0000 0011 | 3 | 3 | 03 | ETX (end of text) | 正文结束 |
0000 0100 | 4 | 4 | 04 | EOT (end of transmission) | 传输结束 |
0000 0101 | 5 | 5 | 05 | ENQ (enquiry) | 请求 |
0000 0110 | 6 | 6 | 06 | ACK (acknowledge) | 收到通知 |
0000 0111 | 7 | 7 | 07 | BEL (bell) | 响铃 |
0000 1000 | 10 | 8 | 08 | BS (backspace) | 退格 |
0000 1001 | 11 | 9 | 09 | HT (horizontal tab) | 水平制表符 |
0000 1010 | 12 | 10 | 0A | LF (NL line feed, new line) | 换行键 |
0000 1011 | 13 | 11 | 0B | VT (vertical tab) | 垂直制表符 |
0000 1100 | 14 | 12 | 0C | FF (NP form feed, new page) | 换页键 |
0000 1101 | 15 | 13 | 0D | CR (carriage return) | 回车键 |
0000 1110 | 16 | 14 | 0E | SO (shift out) | 不用切换 |
0000 1111 | 17 | 15 | 0F | SI (shift in) | 启用切换 |
0001 0000 | 20 | 16 | 10 | DLE (data link escape) | 数据链路转义 |
0001 0001 | 21 | 17 | 11 | DC1 (device control 1) | 设备控制1 |
0001 0010 | 22 | 18 | 12 | DC2 (device control 2) | 设备控制2 |
0001 0011 | 23 | 19 | 13 | DC3 (device control 3) | 设备控制3 |
0001 0100 | 24 | 20 | 14 | DC4 (device control 4) | 设备控制4 |
0001 0101 | 25 | 21 | 15 | NAK (negative acknowledge) | 拒绝接收 |
0001 0110 | 26 | 22 | 16 | SYN (synchronous idle) | 同步空闲 |
0001 0111 | 27 | 23 | 17 | ETB (end of trans. block) | 结束传输块 |
0001 1000 | 30 | 24 | 18 | CAN (cancel) | 取消 |
0001 1001 | 31 | 25 | 19 | EM (end of medium) | 媒介结束 |
0001 1010 | 32 | 26 | 1A | SUB (substitute) | 代替 |
0001 1011 | 33 | 27 | 1B | ESC (escape) | 换码(溢出) |
0001 1100 | 34 | 28 | 1C | FS (file separator) | 文件分隔符 |
0001 1101 | 35 | 29 | 1D | GS (group separator) | 分组符 |
0001 1110 | 36 | 30 | 1E | RS (record separator) | 记录分隔符 |
0001 1111 | 37 | 31 | 1F | US (unit separator) | 单元分隔符 |
0010 0000 | 40 | 32 | 20 | (space) | 空格 |
0010 0001 | 41 | 33 | 21 | ! | 叹号 |
0010 0010 | 42 | 34 | 22 | " | 双引号 |
0010 0011 | 43 | 35 | 23 | # | 井号 |
0010 0100 | 44 | 36 | 24 | $ | 美元符 |
0010 0101 | 45 | 37 | 25 | % | 百分号 |
0010 0110 | 46 | 38 | 26 | & | 和号 |
0010 0111 | 47 | 39 | 27 | ' | 闭单引号 |
0010 1000 | 50 | 40 | 28 | ( | 开括号 |
0010 1001 | 51 | 41 | 29 | ) | 闭括号 |
0010 1010 | 52 | 42 | 2A | * | 星号 |
0010 1011 | 53 | 43 | 2B | + | 加号 |
0010 1100 | 54 | 44 | 2C | , | 逗号 |
0010 1101 | 55 | 45 | 2D | - | 减号/破折号 |
0010 1110 | 56 | 46 | 2E | . | 句号 |
00101111 | 57 | 47 | 2F | / | 斜杠 |
00110000 | 60 | 48 | 30 | 0 | 数字0 |
00110001 | 61 | 49 | 31 | 1 | 数字1 |
00110010 | 62 | 50 | 32 | 2 | 数字2 |
00110011 | 63 | 51 | 33 | 3 | 数字3 |
00110100 | 64 | 52 | 34 | 4 | 数字4 |
00110101 | 65 | 53 | 35 | 5 | 数字5 |
00110110 | 66 | 54 | 36 | 6 | 数字6 |
00110111 | 67 | 55 | 37 | 7 | 数字7 |
00111000 | 70 | 56 | 38 | 8 | 数字8 |
00111001 | 71 | 57 | 39 | 9 | 数字9 |
00111010 | 72 | 58 | 3A | : | 冒号 |
00111011 | 73 | 59 | 3B | ; | 分号 |
00111100 | 74 | 60 | 3C | < | 小于 |
00111101 | 75 | 61 | 3D | = | 等号 |
00111110 | 76 | 62 | 3E | > | 大于 |
00111111 | 77 | 63 | 3F | ? | 问号 |
01000000 | 100 | 64 | 40 | @ | 电子邮件符号 |
01000001 | 101 | 65 | 41 | A | 大写字母A |
01000010 | 102 | 66 | 42 | B | 大写字母B |
01000011 | 103 | 67 | 43 | C | 大写字母C |
01000100 | 104 | 68 | 44 | D | 大写字母D |
01000101 | 105 | 69 | 45 | E | 大写字母E |
01000110 | 106 | 70 | 46 | F | 大写字母F |
01000111 | 107 | 71 | 47 | G | 大写字母G |
01001000 | 110 | 72 | 48 | H | 大写字母H |
01001001 | 111 | 73 | 49 | I | 大写字母I |
01001010 | 112 | 74 | 4A | J | 大写字母J |
01001011 | 113 | 75 | 4B | K | 大写字母K |
01001100 | 114 | 76 | 4C | L | 大写字母L |
01001101 | 115 | 77 | 4D | M | 大写字母M |
01001110 | 116 | 78 | 4E | N | 大写字母N |
01001111 | 117 | 79 | 4F | O | 大写字母O |
01010000 | 120 | 80 | 50 | P | 大写字母P |
01010001 | 121 | 81 | 51 | Q | 大写字母Q |
01010010 | 122 | 82 | 52 | R | 大写字母R |
01010011 | 123 | 83 | 53 | S | 大写字母S |
01010100 | 124 | 84 | 54 | T | 大写字母T |
01010101 | 125 | 85 | 55 | U | 大写字母U |
01010110 | 126 | 86 | 56 | V | 大写字母V |
01010111 | 127 | 87 | 57 | W | 大写字母W |
01011000 | 130 | 88 | 58 | X | 大写字母X |
01011001 | 131 | 89 | 59 | Y | 大写字母Y |
01011010 | 132 | 90 | 5A | Z | 大写字母Z |
01011011 | 133 | 91 | 5B | [ | 开方括号 |
01011100 | 134 | 92 | 5C | \ | 反斜杠 |
01011101 | 135 | 93 | 5D | ] | 闭方括号 |
01011110 | 136 | 94 | 5E | ^ | 脱字符 |
01011111 | 137 | 95 | 5F | _ | 下划线 |
01100000 | 140 | 96 | 60 | ` | 开单引号 |
01100001 | 141 | 97 | 61 | a | 小写字母a |
01100010 | 142 | 98 | 62 | b | 小写字母b |
01100011 | 143 | 99 | 63 | c | 小写字母c |
01100100 | 144 | 100 | 64 | d | 小写字母d |
01100101 | 145 | 101 | 65 | e | 小写字母e |
01100110 | 146 | 102 | 66 | f | 小写字母f |
01100111 | 147 | 103 | 67 | g | 小写字母g |
01101000 | 150 | 104 | 68 | h | 小写字母h |
01101001 | 151 | 105 | 69 | i | 小写字母i |
01101010 | 152 | 106 | 6A | j | 小写字母j |
01101011 | 153 | 107 | 6B | k | 小写字母k |
01101100 | 154 | 108 | 6C | l | 小写字母l |
01101101 | 155 | 109 | 6D | m | 小写字母m |
01101110 | 156 | 110 | 6E | n | 小写字母n |
01101111 | 157 | 111 | 6F | o | 小写字母o |
01110000 | 160 | 112 | 70 | p | 小写字母p |
01110001 | 161 | 113 | 71 | q | 小写字母q |
01110010 | 162 | 114 | 72 | r | 小写字母r |
01110011 | 163 | 115 | 73 | s | 小写字母s |
01110100 | 164 | 116 | 74 | t | 小写字母t |
01110101 | 165 | 117 | 75 | u | 小写字母u |
01110110 | 166 | 118 | 76 | v | 小写字母v |
01110111 | 167 | 119 | 77 | w | 小写字母w |
01111000 | 170 | 120 | 78 | x | 小写字母x |
01111001 | 171 | 121 | 79 | y | 小写字母y |
01111010 | 172 | 122 | 7A | z | 小写字母z |
01111011 | 173 | 123 | 7B | { | 开花括号 |
01111100 | 174 | 124 | 7C | | | 垂线 |
01111101 | 175 | 125 | 7D | } | 闭花括号 |
01111110 | 176 | 126 | 7E | ~ | 波浪号 |
01111111 | 177 | 127 | 7F | DEL (delete) | 删除 |
英语用128个符号编码就够了,但是用来表示其他语言,128个符号是不够的。比如,在法语中,字母上方有注音符号,它就无法用 ASCII 码表示。于是,一些欧洲国家就决定,利用字节中闲置的最高位编入新的符号。比如,法语中的é
的编码为130(二进制10000010
)。这样一来,这些欧洲国家使用的编码体系,可以表示最多256个符号。
但是,这里又出现了新的问题。不同的国家有不同的字母,因此,哪怕它们都使用256个符号的编码方式,代表的字母却不一样。比如,130在法语编码中代表了é
,在希伯来语编码中却代表了字母Gimel
(ג
),在俄语编码中又会代表另一个符号。但是不管怎样,所有这些编码方式中,0--127表示的符号是一样的,不一样的只是128--255的这一段。
至于亚洲国家的文字,使用的符号就更多了,汉字就多达10万左右。一个字节只能表示256种符号,肯定是不够的,就必须使用多个字节表达一个符号。比如,简体中文常见的编码方式是 GB2312,使用两个字节表示一个汉字,所以理论上最多可以表示 256 x 256 = 65536 个符号。
二. Unicode
如果有一种编码,将世界上所有的符号都纳入其中。每一个符号都给予一个独一无二的编码,那么乱码问题就会消失。这就是 Unicode,就像它的名字都表示的,这是一种所有符号的编码。汉字对应Unicode编码。
码点值的范围是从U+0000到U+10FFFF
Unicode最前面的65536个字符位,称为基本平面
它的码点范围是从U+0000到U+FFFF。最常见的字符都放在这个平面,这是Unicode最先定义和公布的一个平面。
剩下的字符都放在补充平面(Supplementary Plane),码点范围从U+010000一直到U+10FFFF,共16个。(从1-10 16进制刚好16个平面)
Unicode 只是一个符号集,它只规定了符号的二进制代码,却没有规定这个二进制代码应该如何存储。
比如,汉字严
的 Unicode 是十六进制数4E25
,转换成二进制数足足有15位(100111000100101
),也就是说,这个符号的表示至少需要2个字节。表示其他更大的符号,可能需要3个字节或者4个字节,甚至更多。
这里就有两个严重的问题,第一个问题是,如何才能区别 Unicode 和 ASCII ?计算机怎么知道三个字节表示一个符号,而不是分别表示三个符号呢?第二个问题是,我们已经知道,英文字母只用一个字节表示就够了,如果 Unicode 统一规定,每个符号用三个或四个字节表示,那么每个英文字母前都必然有二到三个字节是0
,这对于存储来说是极大的浪费,文本文件的大小会因此大出二三倍,这是无法接受的。
Unicode只规定了每个字符的码点,到底用什么样的字节序表示这个码点,就涉及到编码方法。
最直观的编码方法是,每个码点使用四个字节表示,字节内容一一对应码点。这种编码方法就叫做UTF-32。比如,码点0就用四个字节的0表示,码点597D就在前面加两个字节的0。
UTF-32的优点在于,转换规则简单直观,查找效率高。缺点在于浪费空间,同样内容的英语文本,它会比ASCII编码大四倍。这个缺点很致命,导致实际上没有人使用这种编码方法,HTML 5标准就明文规定,网页不得编码成UTF-32。
三、UTF-8
UTF-8 就是在互联网上使用最广的一种 Unicode 的实现方式之一。其他实现方式还包括 UTF-16(字符用两个字节或四个字节表示)和 UTF-32(字符用四个字节表示)
UTF-8 最大的一个特点,就是它是一种变长的编码方式。它可以使用1~4个字节表示一个符号,根据不同的符号而变化字节长度。
UTF-8 的编码规则很简单,只有二条:
1)对于单字节的符号,字节的第一位设为0
,后面7位为这个符号的 Unicode 码。因此对于英语字母,UTF-8 编码和 ASCII 码是相同的。
2)对于n
字节的符号(n > 1
),第一个字节的前n
位都设为1
,第n + 1
位设为0
,后面字节的前两位一律设为10
。剩下的没有提及的二进制位,全部为这个符号的 Unicode 码。
下表总结了编码规则,字母x
表示可用编码的位。
Unicode符号范围 | UTF-8编码方式 (十六进制) | (二进制) ----------------------+--------------------------------------------- 0000 0000-0000 007F | 0xxxxxxx 0000 0080-0000 07FF | 110xxxxx 10xxxxxx 0000 0800-0000 FFFF | 1110xxxx 10xxxxxx 10xxxxxx 0001 0000-0010 FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
跟据上表,解读 UTF-8 编码非常简单。如果一个字节的第一位是0
,则这个字节单独就是一个字符;如果第一位是1
,则连续有多少个1
,就表示当前字符占用多少个字节。
下面,还是以汉字严
为例,演示如何实现 UTF-8 编码。
严
的 Unicode 是4E25
(100111000100101
),根据上表,可以发现4E25
处在第三行的范围内(0000 0800 - 0000 FFFF
),因此严
的 UTF-8 编码需要三个字节,即格式是1110xxxx 10xxxxxx 10xxxxxx
。然后,从严
的最后一个二进制位开始,依次从后向前填入格式中的x
,多出的位补0
。这样就得到了,严
的 UTF-8 编码是11100100 10111000 10100101
,转换成十六进制就是E4B8A5
。
四、UTF-16
UTF-16编码介于UTF-32与UTF-8之间,同时结合了定长和变长两种编码方法的特点。
它的编码规则很简单:基本平面的字符占用2个字节,辅助平面的字符占用4个字节。也就是说,UTF-16的编码长度要么是2个字节(U+0000到U+FFFF),要么是4个字节(U+010000到U+10FFFF)。
编码范围 | 字节
----------------------+---------------------------------------------
0x0000-0xFFFF | 2
0x010000-0x10FFFF | 4
于是就有一个问题,当我们遇到两个字节,怎么看出它本身是一个字符,还是需要跟其他两个字节放在一起解读?
说来很巧妙,我也不知道是不是故意的设计,从U+D800到U+DFFF是一个空段,即这些码点不对应任何字符。因此,这个空段可以用来映射辅助平面的字符。具体来说,辅助平面的字符位共有220个,也就是说,对应这些字符至少需要20个二进制位。UTF-16将这20位拆成两半,前10位映射在U+D800到U+DBFF(空间大小210),称为高位(H),后10位映射在U+DC00到U+DFFF(空间大小210),称为低位(L)。这意味着,一个辅助平面的字符,被拆成两个基本平面的字符表示。
计算公式:
H = Math.floor((c-0x10000) / 0x400)+0xD800
L = (c – 0x10000) % 0x400 + 0xDC00
"💩" 的码点"\u1F4A9"
计算结果是
H:'\ud83d'
L:'\udca9'
�的Unicode码点是+UFFFD,通常用来表示Unicode转换时无法识别的字符(也就是乱码)
所以,当我们遇到两个字节,发现它的码点在U+D800到U+DBFF之间,就可以断定,紧跟在后面的两个字节的码点,应该在U+DC00到U+DFFF之间,这四个字节必须放在一起解读。
五、JavaScript使用哪一种编码?
JavaScript语言采用Unicode字符集,但是只支持一种编码方法。
这种编码既不是UTF-16,也不是UTF-8,更不是UTF-32。上面那些编码方法,JavaScript都不用。
JavaScript用的是UCS-2!
六、UCS-2编码
怎么突然杀出一个UCS-2?这就需要讲一点历史。
互联网还没出现的年代,曾经有两个团队,不约而同想搞统一字符集。一个是1988年成立的Unicode团队,另一个是1989年成立的UCS团队。等到他们发现了对方的存在,很快就达成一致:世界上不需要两套统一字符集。
1991年10月,两个团队决定合并字符集。也就是说,从今以后只发布一套字符集,就是Unicode,并且修订此前发布的字符集,UCS的码点将与Unicode完全一致。
UCS的开发进度快于Unicode,1990年就公布了第一套编码方法UCS-2,使用2个字节表示已经有码点的字符。(那个时候只有一个平面,就是基本平面,所以2个字节就够用了。)UTF-16编码迟至1996年7月才公布,明确宣布是UCS-2的超集,即基本平面字符沿用UCS-2编码,辅助平面字符定义了4个字节的表示方法。
两者的关系简单说,就是UTF-16取代了UCS-2,或者说UCS-2整合进了UTF-16。所以,现在只有UTF-16,没有UCS-2。
七、JavaScript的诞生背景
那么,为什么JavaScript不选择更高级的UTF-16,而用了已经被淘汰的UCS-2呢?
答案很简单:非不想也,是不能也。因为在JavaScript语言出现的时候,还没有UTF-16编码。
1995年5月,Brendan Eich用了10天设计了JavaScript语言;10月,第一个解释引擎问世;次年11月,Netscape正式向ECMA提交语言标准(整个过程详见《JavaScript诞生记》)。对比UTF-16的发布时间(1996年7月),就会明白Netscape公司那时没有其他选择,只有UCS-2一种编码方法可用!
八、JavaScript字符函数的局限
由于JavaScript只能处理UCS-2编码,造成所有字符在这门语言中都是2个字节,如果是4个字节的字符,会当作两个双字节的字符处理。JavaScript的字符函数都受到这一点的影响,无法返回正确结果。
还是以字符为例,它的UTF-16编码是4个字节的0xD834 DF06。问题就来了,4个字节的编码不属于UCS-2,JavaScript不认识,只会把它看作单独的两个字符U+D834和U+DF06。前面说过,这两个码点是空的,所以JavaScript会认为是两个空字符组成的字符串!
上面代码表示,JavaScript认为字符的长度是2,取到的第一个字符是空字符,取到的第一个字符的码点是0xDB34。这些结果都不正确!
解决这个问题,必须对码点做一个判断,然后手动调整。下面是正确的遍历字符串的写法。
while (++index < length) {
// ...
if (charCode >= 0xD800 && charCode <= 0xDBFF) {
output.push(character + string.charAt(++index));
} else {
output.push(character);
}
}
上面代码表示,遍历字符串的时候,必须对码点做一个判断,只要落在0xD800到0xDBFF的区间,就要连同后面2个字节一起读取。
类似的问题存在于所有的JavaScript字符操作函数。
- String.prototype.replace()
- String.prototype.substring()
- String.prototype.slice()
- ...
上面的函数都只对2字节的码点有效。要正确处理4字节的码点,就必须逐一部署自己的版本,判断一下当前字符的码点范围。
九、Little endian 和 Big endian
上一节已经提到,UCS-2 格式可以存储 Unicode 码(码点不超过0xFFFF
)。以汉字严
为例,Unicode 码是4E25
,需要用两个字节存储,一个字节是4E
,另一个字节是25
。存储的时候,4E
在前,25
在后,这就是 Big endian 方式;25
在前,4E
在后,这是 Little endian 方式。
这两个古怪的名称来自英国作家斯威夫特的《格列佛游记》。在该书中,小人国里爆发了内战,战争起因是人们争论,吃鸡蛋时究竟是从大头(Big-endian)敲开还是从小头(Little-endian)敲开。为了这件事情,前后爆发了六次战争,一个皇帝送了命,另一个皇帝丢了王位。
第一个字节在前,就是"大头方式"(Big endian),第二个字节在前就是"小头方式"(Little endian)。
那么很自然的,就会出现一个问题:计算机怎么知道某一个文件到底采用哪一种方式编码?
Unicode 规范定义,每一个文件的最前面分别加入一个表示编码顺序的字符,这个字符的名字叫做"零宽度非换行空格"(zero width no-break space),用FEFF
表示。这正好是两个字节,而且FF
比FE
大1
。
如果一个文本文件的头两个字节是FE FF
,就表示该文件采用大头方式;如果头两个字节是FF FE
,就表示该文件采用小头方式。
十、ECMAScript 6
JavaScript的下一个版本ECMAScript 6(简称ES6),大幅增强了Unicode支持,基本上解决了这个问题。
(1)正确识别字符
ES6可以自动识别4字节的码点。因此,遍历字符串就简单多了。
for (let s of string ) {
// ...
}
但是,为了保持兼容,length属性还是原来的行为方式。为了得到字符串的正确长度,可以用下面的方式。
Array.from(string).length
(2)码点表示法
JavaScript允许直接用码点表示Unicode字符,写法是"反斜杠+u+码点"。
'好' === '\u597D' // true
但是,这种表示法对4字节的码点无效。ES6修正了这个问题,只要将码点放在大括号内,就能正确识别。
(3)字符串处理函数
ES6新增了几个专门处理4字节码点的函数。
- String.fromCodePoint():从Unicode码点返回对应字符
- String.prototype.codePointAt():从字符返回对应的码点
- String.prototype.at():返回字符串给定位置的字符
(4)正则表达式
ES6提供了u修饰符,对正则表达式添加4字节码点的支持。
(5)Unicode正规化
有些字符除了字母以外,还有附加符号。比如,汉语拼音的Ǒ,字母上面的声调就是附加符号。对于许多欧洲语言来说,声调符号是非常重要的。
Unicode提供了两种表示方法。一种是带附加符号的单个字符,即一个码点表示一个字符,比如Ǒ的码点是U+01D1;另一种是将附加符号单独作为一个码点,与主体字符复合显示,即两个码点表示一个字符,比如Ǒ可以写成O(U+004F) + ˇ(U+030C)。
// 方法一
'\u01D1'
// 'Ǒ'
// 方法二
'\u004F\u030C'
// 'Ǒ'
这两种表示方法,视觉和语义都完全一样,理应作为等同情况处理。但是,JavaScript无法辨别。
'\u01D1'==='\u004F\u030C'
//false
ES6提供了normalize方法,允许"Unicode正规化",即将两种方法转为同样的序列。
'\u01D1'.normalize() === '\u004F\u030C'.normalize()
// true