一些字符编码规则
Unicode 编码规则
以下部分转自: 一文彻底搞懂Unicode编码问题
ASCII
最基础的编码格式,打字机时代的产物,共128个字符,其包含键盘上每一个可显字符,可应对只有英文字母场景下的编码需求。
ASCII编码,共128个字符,编码范围为0~127,每个字符占用一个字节(8位),但ASCII实际7位就够用了,所以第8位可用于保存额外的信息,早期多用来做奇偶校验,目前都是填充0。
其中第32到126的字符( - 7E),共95个,为可显字符(printable character),为空格、数字、字母、标点符号、和几个特殊符号(例如脱字符^)。其中:
- 32(),为空格
- 48-57(-),数字0-9
- 65-90(-5A),字母A-Z
- 97-122(-7A),字母a-z
前32个字符和最后1个字符(0到32和127),共33个字符,称之为控制字符(Control character)。
每个控制字符除了通过ASCII编码表示,还有另外的「脱字符表示法」(Caret notation ),第0个为^0
,后续26个为^A
~^Z
,剩余的为^[
,^\
,^]
,^^
,^_
,最后一个为^?
。在JS中的正则表达式中,用\c
来替代脱字符匹配控制字符,例如\cJ
代表^J
。 另外,有几个特殊的在现在编码中仍然有用的控制字符,还有「转义符表示法」(escape sequence)。如下表所示:
十进制 | 脱字符表示 | 转义符表示 | 名称 | 名称简写 | 作用 |
---|---|---|---|---|---|
0 | ^@ | \0 | Null | NUL | 空 |
7 | ^G | Bell | Bel | 振铃 | |
8 | ^H | Backspace | BS | 退格 | |
9 | ^I | Horizontal Tab | HT | 水平制表 | |
10 | ^J | Line Feed | LF | 换行 | |
11 | ^K | Vertical Tab | VT | 垂直制表 | |
12 | ^L | Form feed | FF | 换页 | |
13 | ^M | Carriage Return | CR | 回车 | |
27 | ^[ | Escape | ESC | 换码 |
显然128个字符对于英文表达足够了,但是明显不适用中文,以及其他非英文表达的语言,所以中国后来有了GBK编码。ASCII编码在早期遇到编码不够用的时候,也有一些扩展和变体,例如「扩展ASCII」可表示256个字符。不过由于后来有了Unicode编码格式,这些都没用了,所以就没必要去了解了。
Unicode
Unicode编码标准,可表示目前全世界所有语言的所有字符。同时兼容ASCII编码。
Unicode的前128个字符编码和ASCII是一致的,即向后兼容ASCII,对于使用ASCII编码的程序可以直接使用Unicode规范。在Unicode中,对于每一个字符编码的值,叫做code point
。例如小写字母a的code point
为97,对应十六进制为\x61
。下文为了方便对code point
称作「码位」。
在Unicode中,码位的总范围为\x0
到\x10FFFF
,共1,114,112个码位。2048个用于编码代理(UTF-16),66个非字符码位(例如BOM),137,468个预留给私人使用,最终剩余974,530用于普通字符分配。
码位的最大值为\x10FFFF
,对应二进制有21位,我们将216个值分为一组,则Unicode总共可以分为17份,每一份称之为平面(Plane),每一个平面有65,536(216)个码位。
为什么Unicode的最大值为\x10FFFF
?因为对于UTF16
编码,双字节最多可编码220个字符,单字节可编码216个字符,加起来共17个平面的字符数。
下表为每个平面详情:
平面编号 | 码位范围(十六进制) | 名称简写 | 名称 |
---|---|---|---|
Plane 0 | 0000–FFFF | BMP | 基础多语言平面(Basic Multilingual Plane) |
Plane 1 | 10000–1FFFF | SMP | 补充多语言平面(Supplementary Multilingual Plane) |
Plane 2 | 20000–2FFFF | SIP | 补充表意语言平面(Supplementary Ideographic Plane) |
Plane 3 | 30000–3FFFF | TIP | 第三表意语言平面(Tertiary Ideographic Plane) |
Planes 4–13 | 40000–DFFFF | - (未分配) | - (未分配) |
Plane 14 | E0000–EFFFF | SSP | 补充特殊用途平面(Supplementary Special-purpose Plane) |
Planes 15–16 | F0000–10FFFF | SPUA-A/B | 补充私有使用区平面(Supplementary Private Use Area planes) |
BMP为基础平面,目前收录了全球范围内大部分的字符。剩余的16个平面均为补充平面,用于进行新的字符的补充。其中私有平面,用于给个人做编码扩展,Unicode不指定字符编码。比如我编写了一个英雄联盟相关的程序,然后定义某一个字符代表一种游戏里的操作,就可以使用私有平面。
Unicode中还有一个概念:对于逻辑上属于一类的字符,称之为块(block)。例如:
C0 Controls and Basic Latin
块,\x0000
-\x007F
,就是从ASCII继承来的前128个字符。CJK Unified Ideographs
块,\x4E00
-\x9FFC
,包含大部分的中日韩文字,Halfwidth and Fullwidth Forms
,\xFF00
-\xFFEF
,用于英文字母/数字/日文/个别符号等一些字符的全角-半角相互转换。Miscellaneous Symbols and Pictographs
,\x1F300
-\x1F5FF
,Supplemental Symbols and Pictographs
,\1F900
-\1F9FF
,包含大部分emoji表情
另外还有一个比较重要的块General Punctuation
,码位在[2000,206F]
,包含一些符号以及一些特殊的分隔符、连接符、空格符等,这些符号不一定是可显字符,而是告诉解释器该如何操作当前字符。对于所有块,可通过该链接查阅。
半角/全角
对于全角字符,在展示上占用的宽度是半角字符的两倍。每个字符都在Unicode标准里定义了是全角还是半角,对于不需要精确计算的简单业务场景,也可以简单的认为码位大于128的都是全角字符。
半角和全角,对应英文为halfwidth,fullwidth。半角全角对应的是UI显示的概念,对于定宽的字体,全角字符占用的宽度是半角字符的两倍。Unicode中每个字符都有一个East_Asian_Width
属性,用于指示当前是全角字符还是半角字符,具有以下值:
- A, Ambiguous,根据上下文决定
- F, Fullwidth,全角
- H, Halfwidth,半角
- N, Neutral,中立,作为半角
- Na, Narrow,半角
- W, Wide,全角
在EastAsianWidth.txt文件中列举了已显示声明East_Asian_Width
属性的字符。对于不在该文件内的字符,符合下列规则的为W
(全角):
- the CJK Unified Ideographs Extension A block, 对应区间:
\x3400
..\x4DBF
- the CJK Unified Ideographs block, 对应区间:
\x4E00
..\x9FFF
- the CJK Compatibility Ideographs block, 对应区间:
\xF900
..\xFAFF
- the Supplementary Ideographic Plane, 对应区间:
\x20000
..\x2FFFF
- the Tertiary Ideographic Plane, 对应区间:
\x30000
..\x3FFFF
其余未列出的,默认为N
(半角)。
在一些编码集中,有的字符既有全角形式也有半角形式,Unicode为了实现与这些编码集之间的无损转换,在第一平面的最后,\xFF00
到\xFFEF
区段,定义了用于半角全角转换的字符,如下所示:
\xFF01
–\xFF5E
为ASCII的\x21
到\x7E
的全角形式。其中空格没有纳入进来,因为全角空格已通过\x3000
定义。\xFF65
–\xFF9F
为半角的日语字符。\xFFA0
–\xFFDC
为半角的汉语字符。\xFFE0
–\xFFEE
包含了一些符号,有半角有全角。
对于在JS中判断字符是全角还是半角,目前下载量比较多的一个npm包:is-fullwidth-code-point
。string-width
依赖is-fullwidth-code-point
计算字符长度。不过实际测试,is-fullwidth-code-point
没有完全覆盖所有全角字符(issue),不过对于日常中文场景的开发够用了。
在日常开发中,对于UI展示的场景中,会比较关心字符宽度的问题。但是在涉及存储的时候,更关心的其实是存储该字符占用了几个字节。所以在涉及存储的场景下,关注点就不应该是全角/半角的概念,而是字符编码所占用的字节数。对于UTF8
编码,码位小于等于128的使用1字节存储,大于128的会根据需要,使用双字节,三字节或四字节存储。所以多数场景下,为了简便,前后端都可以通过码位是否大于128来判断全角/半角。
HTML/XML实体转义
我们常说的HTML转义,实际正式应该称之为HTML实体引用。对应有两种引用方式:数字字符引用(numeric character reference) 和 字符实体引用(character entity reference)。
先说常见的字符实体引用,语法为:&name;
,name必须小写。例如:<
表示小于号<
。
可以进行引用的实体,称之为命名实体。命名实体有两种,一种是语法中内置的,另一种是在DTD中显示声明的:<!ENTITY name "value">
。
数字字符引用方式:
- 十进制:
&#nnnn;
- 十六进制:
&#xhhhh;
, x必须小写。hhhh大小写可以混用。
还是同样的例子,小于号<
如果使用数字字符引用的方式,为:<
。
HTML
通过该链接查看目前HTML5中支持的命名实体。
XML
XML规范中,有5个预定义的实体,如下所示,如果需要使用更多的实体转义,需要在DTD中声明。
名称 | 字符 | 码位十六进制 | 码位十进制 | 标准 | 名称全称 |
---|---|---|---|---|---|
quot | " | 34 | XML 1.0 | quotation mark | |
amp | & | 38 | XML 1.0 | ampersand | |
apos | ' | 39 | XML 1.0 | apostrophe (1.0: apostrophe-quote) | |
lt | < | 003C | 60 | XML 1.0 | less-than sign |
gt | > | 003E | 62 | XML 1.0 | greater-than sign |
Unicode Encoding Forms
Unicode字符编码格式(Unicode Encoding Forms),简写为:UTF,即:将一个Unicode字符保存为字节序列的格式规范,用于文件存储、数据传输等。Unicode标准支持3种编码格式,如下:
- UTF-32: 使用4字节表示一个Unicode字符。
- UTF-16: 变长的编码格式,码位大于
\xFFFF
的字符,使用4字节存储,小于等于\xFFFF
的字符,使用2字节存储。 - UTF-8: 变长的编码格式,码位大于
\xFFFF
的字符,使用4字节存储,小于等于\xFFFF
大于\x07FF
的使用3字节,小于等于\x07FF
大于\x007F
的使用2字节,小于等于\x007F
使用1字节。
Unicode标准支持3种编码格式,UTF32
/UTF16
/UTF8
,用于映射码位为 \x0000
到\xD7FF
和 \xE000
到\x10FFFF
的字符,即除去高位代理和低位代理的所有字符。至于什么是高位代理和低位代理后面会讲到。
UTF32
是一种定长编码格式,使用32位(4字节)表示Unicode中的一个码位。由于Unicode的码位实际只用了21位,所以多余部分前导0。例如字符小写字母a,对应码位为\x61
,存储的字节序列为:\x00000061
。
UTF16
变长编码格式,按平面区分,位于第一平面中的字符(\x0000..\xD7FF
和\xE000..\xFFFF
),使用16位(2字节)存储,使用和码位相同的值。位于其他平面的字符(\x10000..\x10FFFF
),通过高位和低位代理使用32位(4字节)表示。
对于位于第一平面的值,即小于等于\xFFFF
的值,使用2个字节就足够表示,所以直接使用两个字节表示其码位的值,如下所示:
code point | UTF16编码后实际存储的值 |
---|---|
xxxx xxxx xxxx xxxx | xxxx xxxx xxxx xxxx |
位于其他平面平面的值,即大于\xFFFF
的值,使用4个字节表示,如下所示:
code point | UTF16编码后实际存储的值(wwww = uuuuu - 1) |
---|---|
000u uuuu hhhh hhxx xxxx xxxx | 1101 10ww wwhh hhhh 1101 11xx xxxx xxxx |
位于其他平面的值,即\x10000
到\x10FFFF
的值,二进制最高使用21位。将其拆分为两部分,即前11位和后10位,前11为用hhhhhh hhhh
表示,后10位用xxxxx xxxxx
表示。其中,前11位中,前5位是用来表示位于第几个平面,所以这里也特殊标注出来,用u表示,即前11位为:uuuuuh hhhhh
。
由于这里前五位的有效值为\x1
到\x10
,所以可以减1,让有效值从0开始,则有效值变成了\x00
到\x0F
,即4位,减1后的值用w表示,从而前11位可以表示为: wwwwh hhhhh。
将前10位前导110110
,后10位前导110111
,即UTF16
对于大于\xFFFF
字符的表示如上述表格所示。
这里, 二进制1101 1000 0000 0000
为\xD800
,二进制1101 1100 0000 0000
为\xDC00
,从而,该规则简单描述如下:
- 假设某个字符x位于
\x10000
到\x10FFFF
之间,将其减去\x10000
,得到x',x'的范围为:\x00000
–\xFFFFF
。 - 将x'分成两部分,前10位和后10位,用w1和w2表示,其范围为
\x0000
–\x03FF
。 - 将w1加上
\xD800
,得到w1',范围为:\xD800
–\xDBFF
. - 将w2加上
\xDC00
,得到w2',范围为:\xDC00
–\xDFFF
.
将w1'和w'2转换为二进制,即UTF16
下x存储的字节序列。
x' = yyyyyyyyyyxxxxxxxxxx // x - 0x10000
x1' = 110110yyyyyyyyyy // 0xD800 + yyyyyyyyyy
x2' = 110111xxxxxxxxxx // 0xDC00 + xxxxxxxxxx
UTF8
变长编码格式,是直接兼容ASCII的编码格式,对于能在1字节内保存的,直接保存为1字节。否则进行类似UTF16
高低位代理的方式,最高位使用4字节。
UTF8
中没有减1的逻辑,只是简单的增加前缀,具体规则如下:
范围 | 码位(二进制) | 第1个字节 | 第2个字节 | 第3个字节 | 第4个字节 |
---|---|---|---|---|---|
.. 007F(7位) | 00000000 0xxxxxxx | 0xxxxxxx | - | - | - |
.. 07FF(11位) | 00000yyy yyxxxxxx | 110yyyyy | 10xxxxxx | - | - |
.. | zzzzyyyy yyxxxxxx | 1110zzzz | 10yyyyyy | 10xxxxxx | - |
.. 10FFFF | 000uuuuu zzzzyyyy yyxxxxxx | 11110uuu | 10uuzzzz | 10yyyyyy | 10xxxxxx |
在UTF8
中,
- 如果字节序列以
0
开头,代表当前字节本身表示了一个字符。 - 如果为
10
开头,则代表当前字节为多字节字符中的一个字节。 - 如果当前字符以
11
开头,则前面1
的个数,代表当前字符所使用的字节数,2个1
代表使用两个字节表示一个字符,3个1
代表使用3个字节表示一个字符。
Byte order mark
字节顺序标记(Byte order mark),指预定义的,放置在文本流开头的,一段特殊的字节序列,用于标记当前文本使用的哪种编码格式(UTF32
/UTF16
/UTF8
)。具体规则如下:
编码格式 | 文本流开头的字节序列 |
---|---|
UTF-8 | EF BB BF |
UTF-16 (BE) | FE FF |
UTF-16 (LE) | FF FE |
UTF-32 (BE) | 00 00 FE FF |
UTF-32 (LE) | FF FE 00 00 |
例如Windows的记事本应用,将文本保存为UTF8
格式时,会在文本内容的开头添加\xEF
,\xBB
,\BF
3个字节。记事本应用在读取一个文本文件的时候,发现前三个字节为\xEF
,\xBB
,\BF
,则认为接下来的字节流通过UTF8
形式解析。
endianness
字节顺序(endianness),这里特指当保存一个数字类型数据时,存储的字节序列的顺序。分为大端序(big-endian,简写BE)和小端序(little-endian,简写LE)。
假设当前要将一个16位的整型数字\x0A0B
指向内存地址。
对于大端序的CPU,随着内存地址的增加,认为其存储的值的重要性是递减的,所以大端序的CPU会在100的位置上存储\x0A
,在101的位置上存储\x0B
。
对于小端序的CPU,随着内存地址的增加,认为其存储的值的重要性是递增的,所以小端序的CPU会在100的位置上存储\x0B
,在101的位置上存储\x0A
。
所以反过来,假设现在在内存中,地址100的地方存储了\xAA
,在101的地方存储了\xBB
,假设有一个int16
变量指向,对于大端序CPU会认为该变量的值为\xAABB
,对于小端序CPU会认为该变量的值为\xBBAA
。
Byte order mark
因为各个系统之间的字节顺序不同,所以在传输和交换Unicode文本时,要告诉对方当前是以什么顺序保存的,从而接收方才能有效的进行解析。
字节序列标记(Byte order mark,简写BOM),特指\xFEFF
字符。在文本的开头,添加\xFEFF
字符,用于标识当前文本的字节顺序。
- 对于
UTF8
编码格式,该字符会被保存为\xEFBBBF
- 对于
UTF16 BE
编码格式,该字符会被保存为\xFEFF
- 对于
UTF16 LE
编码格式,该字符会被保存为\xFFFE
- 对于
UTF32 BE
编码格式,该字符会被保存为\x0000FEFF
- 对于
UTF32 LE
编码格式,该字符会被保存为\xFFFE0000
所以,解析程序通过判断BOM即可确定接下来的文本所使用的编码格式以及字节顺序。在Unicode中,\xFEFF
是专门用作BOM的,如果该字符出现在文本中间,会被当做「零宽非换行空格」(zero-width non-breaking space),其实就是跳过的意思。同样的,对于它的一个镜像字符\xFFFE
,如果出现也会被跳过。
BOM可以省略,不是必须的,因为:
- 在某些场景下已经预设了编码格式或字节顺序,例如W3C的HTML5规范中,如果指定charset为utf-8,则会默认按照utf-8解析,而如果文件流指定了BOM,则会优先使用BOM指定的编码格式和字节顺序。
- 当BOM被省略时,大部分解析器都会对文本流进行推算,推算出编码格式和字节顺序,但是这个推算并不是绝对可靠的。
当使用UTF8
格式保存文本时,Unicode标准建议,如果原文本没有BOM,则不要添加BOM。因为:
UTF8
是单字节存储的,不存在字节顺序问题。- 解析器会默认使用
UTF8
解析文本。 - 因为ASCII和
UTF8
是一一对应的,如果不添加BOM,则ASCII和Unicode可以相互兼容,如果加上了BOM,就打破了相互兼容。
不过当前很多系统或平台并没有按照规范来,在解析文本的时候会要求UTF8
要有BOM,以及在保存文本的时候会加上BOM,例如windows系统的记事本。
而对于UTF16
和UTF32
,要添加BOM,不然在解析的出的文本可能就是乱码,因为解析器在对字节顺序的推算上,并不能保证完全可靠。
组合字符
Unicode 有一类字符称为组合字符,它可以附加在前一个非组合字符上,从而使整体看起来像是一个字符。 Unicode 组合字符设计上,并没有加组合数量限制,这样使我们可以无限加这类组合字符, 例如汉语拼音字母「ü」上面的两个小点,或「á」、「à」字母上面的音标。
组合字符有两种
- 组合字符: 将组合字符置于需要修饰的目标字符后边,使目标字符被渲染(或打印)成相应结果。
- 预组合字符: 事先将字符组好并赋予码位。字符串中有可能同时使用组合字符和预组字符。这导致了若要比较两个unicode字符串时,需要先运行unicode字符的等价性。
组合字符在 Unicode 统一码中存在多个区块,编码范围主要有:
组合用附加符号(Combining Diacritical Marks):区间从
U+0300
到U+036F
共 80 字。它常与字母组合,修饰字母的读音。但其实它的定义比较宽泛,不但包括了拉丁、希腊及西里尔系文字中的变音记号,也包括那些不是变音但不占据宽度的附加标记。例如
a็็็็๎๎๎๎้้้้
为 a็็็็๎๎๎๎้้้้组合用附加符号补集(Combining Diacritical Marks Supplement):区间从
U+1DC0
到U+1DFF
共 64 字。它常与一些符号组合,用于渲染和修饰符号. 例如:
᷐᷐a᷄᷄
为 ᷐᷐a᷄᷄组合用记号(Combining Diacritical Marks for Symbols):区间从
U+20D0
到U+20FF
共 48 字。例如:文⃝
为 文⃝组合用半形符号(Combining Half Marks):区间从
U+FE20
到U+FE2F
共 16 字。多个附加字符可以叠加到一个基础字符上. 例如
︭︠a
为 ︭︠a
组合字符也可以用于 Emoji, 例如 ✋
为 ✋, ✋🏻
为 ✋🏻
零宽字符
零宽字符不可见,不可打印,主要作用于调整字符的显示格式.
零宽字符主要有以下几类:
- 零宽度空格符 (zero-width space) U+200B : 用于较长单词的换行分隔
- 零宽度非断空格符 (zero width no-break space) U+FEFF : 用于阻止特定位置的换行分隔
- 零宽度连字符 (zero-width joiner) U+200D : 用于阿拉伯文与印度语系等文字中,使不会发生连字的字符间产生连字效果
- 零宽度断字符 (zero-width non-joiner) U+200C : 用于阿拉伯文,德文,印度语系等文字中,阻止会发生连字的字符间的连字效果
- 左至右符 (left-to-right mark) U+200E : 用于在混合文字方向的多种语言文本中(例:混合左至右书写的英语与右至左书写的希伯来语),规定排版文字书写方向为左至右
- 右至左符 (right-to-left mark) U+200F : 用于在混合文字方向的多种语言文本中,规定排版文字书写方向为右至左
零宽字符可以用于:
- 数据防爬: 将零宽度字符插入文本中,干扰关键字匹配。爬虫得到的带有零宽度字符的数据会影响他们的分析,但不会影响用户的阅读数据。
- 信息传递: 将自定义组合的零宽度字符插入文本中,用户复制后会携带不可见信息,达到传递作用。
- 传递隐密信息: 利用零宽度字符不可见的特性,我们可以用零宽度字符在任何未对零宽度字符做过滤的网页内插入不可见的隐形文本。下面是一个简单的利用零宽度字符对文本进行加密/解密的
- 隐形水印: 通过零宽度字符我们可以对内部文件添加隐形水印。在浏览者登录页面对内部文件进行浏览时,我们可以在文件的各处插入使用零宽度字符加密的浏览者信息,如果浏览者又恰好使用复制粘贴的方式在公共媒体上匿名分享了这个文件,我们就能通过嵌入在文件中的隐形水印轻松找到分享者了。
- 加密信息分享: 通过零宽度字符我们可以在任何网站上分享任何信息。敏感信息的审核与过滤在当今的互联网社区中扮演着至关重要的角色,但是零宽度字符却能如入无人之境一般轻松地穿透这两层信息分享的屏障。对比明文哈希表加密信息的方式,零宽度字符加密在网上的隐蔽性可以说是达到了一个新的高度。仅仅需要一个简单的识别/解密零宽度字符的浏览器插件,任何网站都可以成为信息分享的游乐场。
- 逃脱敏感词过滤: 通过零宽度字符我们可以轻松逃脱敏感词过滤。敏感词自动过滤是维持互联网社区秩序的一项重要工具,只需倒入敏感词库和匹配相应敏感词,即可将大量的非法词汇拒之门外。使用谐音与拼音来逃脱敏感词过滤会让语言传递信息的效率降低,而使用零宽度字符可以在逃脱敏感词过滤的同时将词义原封不动地传达给接受者,大大提高信息传播者与接受者之间交流的效率。开发时只过滤
\u200b
就够了
Emoji 中的零宽字符
零宽字符可以用于 Emoji 的组合, 例如
在构建 👨🏻🦳 时
- 👨🏻🦳 为
👨🏻‍🦳
- 👨🏻 为
👨🏻‍
- 👨🏻 为
👨🏻
- 👨 为
👨
其中
- 👨 - U+1F468 - 基础字符
- 🏻 - U+1F3FB - 组合字符,表示肤色
- U+200D - 零宽度连字符,表示上下相连
- 🦳 - U+1F9B3 - 基础字符,表示发型
零字宽字符连接了发型与颜色
- 👨🏻🦳 为
👩❤👨 为
👩‍❤‍👨
, 其中- 👩 - U+1F469 - 基础字符
- U+200D - 零宽度连字符,表示上下相连
- ❤ - U+2764 - 基础字符
- U+200D - 零宽度连字符,表示上下相连
- 👨 - U+1F468 - 基础字符
👩👩👦👦 为
👩‍👩‍👦‍👦
, 其中- 👩 - U+1F469 - 基础字符
- U+200D - 零宽度连字符,表示上下相连
- 👩 - U+1F469 - 基础字符
- U+200D - 零宽度连字符,表示上下相连
- 👦 - U+1F466 - 基础字符
- U+200D - 零宽度连字符,表示上下相连
- 👦 - U+1F466 - 基础字符