多年前仔细了解过一次Unicode编码,但是没有落下笔记,最终今天尝试改一下过去的代码的时候,竟然发现看不懂了(我的两个小项目:正则可视化JS工具函数库)。所以再次记录一下,本文尝试搞懂对Unicode的所有主要疑惑,目标是看完该文章,足以应对所有非专业字符编码相关的需求开发,不过多深究细节。

简介

本文会依次介绍ASCIIUnicode、全角半角、HTML/XML转义、UTF32UTF16UTF8BOM。且每个知识点都会加上TL;DR;用于一句话明白这个是干嘛使的,赶时间就只看TL;DR;也可以。文章同步发在我的个人站知乎中。

如有转载,请注明出处,感谢
原文: https://zhuanlan.zhihu.com/p/370601172

ASCII

TL;DR;

最基础的编码格式,打字机时代的产物,共128个字符,其包含键盘上每一个可显字符,可应对只有英文字母场景下的编码需求。

More

ASCII编码,共128个字符,编码范围为0~127,每个字符占用一个字节(8位),但ASCII实际7位就够用了,所以第8位可用于保存额外的信息,早期多用来做奇偶校验,目前都是填充0。

其中第32到126的字符(\x20 - \x7E),共95个,为可显字符(printable character),为空格、数字、字母、标点符号、和几个特殊符号(例如脱字符^)。其中:

前32个字符和最后1个字符(0到32和127),共33个字符,称之为控制字符(Control character)。

每个控制字符除了通过ASCII编码表示,还有另外的「脱字符表示法」(Caret notation ),第0个为^0,后续26个为^A~^Z,剩余的为^[^\^]^^^_,最后一个为^?。在JS中的正则表达式中,用\c来替代脱字符匹配控制字符,例如\cJ代表^J。 另外,有几个特殊的在现在编码中仍然有用的控制字符,还有「转义符表示法」(escape sequence)。如下表所示:

十进制脱字符表示转义符表示名称名称简写作用
0^@\0NullNUL
7^G\aBellBel振铃
8^H\bBackspaceBS退格
9^I\tHorizontal TabHT水平制表
10^J\nLine FeedLF换行
11^K\vVertical TabVT垂直制表
12^L\fForm feedFF换页
13^M\rCarriage ReturnCR回车
27^[\eEscapeESC换码

显然128个字符对于英文表达足够了,但是明显不适用中文,以及其他非英文表达的语言,所以中国后来有了GBK编码。ASCII编码在早期遇到编码不够用的时候,也有一些扩展和变体,例如「扩展ASCII」可表示256个字符。不过由于后来有了Unicode编码格式,这些都没用了,所以就没必要去了解了。

Unicode

TL;DR;

Unicode编码标准,可表示目前全世界所有语言的所有字符。同时兼容ASCII编码。

More

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位,我们将2^16个值分为一组,则Unicode总共可以分为17份,每一份称之为平面(Plane),每一个平面有65,536(2^16)个码位。

为什么Unicode的最大值为\x10FFFF?因为对于UTF16编码,双字节最多可编码2^20个字符,单字节可编码2^16个字符,加起来共17个平面的字符数。

下表为每个平面详情:

平面编号码位范围(十六进制)名称简写名称
Plane 00000–FFFFBMP基础多语言平面(Basic Multilingual Plane)
Plane 110000–1FFFFSMP补充多语言平面(Supplementary Multilingual Plane)
Plane 220000–2FFFFSIP补充表意语言平面(Supplementary Ideographic Plane)
Plane 330000–3FFFFTIP第三表意语言平面(Tertiary Ideographic Plane)
Planes 4–1340000–DFFFF- (未分配)- (未分配)
Plane 14E0000–EFFFFSSP补充特殊用途平面(Supplementary Special-purpose Plane)
Planes 15–16F0000–10FFFFSPUA-A/B补充私有使用区平面(Supplementary Private Use Area planes)

BMP为基础平面,目前收录了全球范围内大部分的字符。剩余的16个平面均为补充平面,用于进行新的字符的补充。其中私有平面,用于给个人做编码扩展,Unicode不指定字符编码。比如我编写了一个英雄联盟相关的程序,然后定义某一个字符代表一种游戏里的操作,就可以使用私有平面。

Unicode中还有一个概念:对于逻辑上属于一类的字符,称之为块(block)。例如:

另外还有一个比较重要的块General Punctuation,码位在[2000,206F],包含一些符号以及一些特殊的分隔符、连接符、空格符等,这些符号不一定是可显字符,而是告诉解释器该如何操作当前字符。对于所有块,可通过该链接查阅

半角/全角

TL;DR;

对于全角字符,在展示上占用的宽度是半角字符的两倍。每个字符都在Unicode标准里定义了是全角还是半角,对于不需要精确计算的简单业务场景,也可以简单的认为码位大于128的都是全角字符。

More

半角和全角,对应英文为halfwidth,fullwidth。半角全角对应的是UI显示的概念,对于定宽的字体,全角字符占用的宽度是半角字符的两倍。Unicode中每个字符都有一个East_Asian_Width属性,用于指示当前是全角字符还是半角字符,具有以下值

EastAsianWidth.txt文件中列举了已显示声明East_Asian_Width属性的字符。对于不在该文件内的字符,符合下列规则的为W(全角):

其余未列出的,默认为N(半角)。

在一些编码集中,有的字符既有全角形式也有半角形式,Unicode为了实现与这些编码集之间的无损转换,在第一平面的最后,\xFF00\xFFEF区段,定义了用于半角全角转换的字符,如下所示:

对于在JS中判断字符是全角还是半角,目前下载量比较多的一个npm包:is-fullwidth-code-pointstring-width依赖is-fullwidth-code-point计算字符长度。不过实际测试,is-fullwidth-code-point没有完全覆盖所有全角字符(issue),不过对于日常中文场景的开发够用了。

在日常开发中,对于UI展示的场景中,会比较关心字符宽度的问题。但是在涉及存储的时候,更关心的其实是存储该字符占用了几个字节。所以在涉及存储的场景下,关注点就不应该是全角/半角的概念,而是字符编码所占用的字节数。对于UTF8编码,码位小于等于128的使用1字节存储,大于128的会根据需要,使用双字节,三字节或四字节存储。所以多数场景下,为了简便,前后端都可以通过码位是否大于128来判断全角/半角。

HTML/XML实体转义

TL;DR;

我们常说的HTML转义,实际正式应该称之为HTML实体引用。对应有两种引用方式:数字字符引用(numeric character reference) 和 字符实体引用(character entity reference)。

先说常见的字符实体引用,语法为:&name;,name必须小写。例如:&lt;表示小于号<

可以进行引用的实体,称之为命名实体。命名实体有两种,一种是语法中内置的,另一种是在DTD中显示声明的:<!ENTITY name "value">

数字字符引用方式:

还是同样的例子,小于号<如果使用数字字符引用的方式,为:&#60;

HTML

通过该链接查看目前HTML5中支持的命名实体

XML

XML规范中,有5个预定义的实体,如下所示,如果需要使用更多的实体转义,需要在DTD中声明。

名称字符码位十六进制码位十进制标准名称全称
quot"\x002234XML 1.0quotation mark
amp&\x002638XML 1.0ampersand
apos'\x002739XML 1.0apostrophe (1.0: apostrophe-quote)
lt<\x003C60XML 1.0less-than sign
gt>\x003E62XML 1.0greater-than sign

Unicode Encoding Forms

TL;DR;

Unicode字符编码格式(Unicode Encoding Forms),简写为:UTF,即:将一个Unicode字符保存为字节序列的格式规范,用于文件存储、数据传输等。Unicode标准支持3种编码格式,如下:

More

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 pointUTF16编码后实际存储的值
xxxx xxxx xxxx xxxxxxxx xxxx xxxx xxxx

位于其他平面平面的值,即大于\xFFFF的值,使用4个字节表示,如下所示:

code pointUTF16编码后实际存储的值(wwww = uuuuu - 1)
000u uuuu hhhh hhxx xxxx xxxx1101 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,从而,该规则简单描述如下:

将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个字节
\x0000 .. \x007F(7位)00000000 0xxxxxxx0xxxxxxx---
\x0080 .. \x07FF(11位)00000yyy yyxxxxxx110yyyyy10xxxxxx--
\x0800 .. \xFFFFzzzzyyyy yyxxxxxx1110zzzz10yyyyyy10xxxxxx-
\x10000 .. \x10FFFF000uuuuu zzzzyyyy yyxxxxxx11110uuu10uuzzzz10yyyyyy10xxxxxx

UTF8中,

Byte order mark

TL;DR;

字节顺序标记(Byte order mark),指预定义的,放置在文本流开头的,一段特殊的字节序列,用于标记当前文本使用的哪种编码格式(UTF32/UTF16/UTF8)。具体规则如下:

编码格式文本流开头的字节序列
UTF-8EF 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,\BF3个字节。记事本应用在读取一个文本文件的时候,发现前三个字节为\xEF,\xBB,\BF,则认为接下来的字节流通过UTF8形式解析。

endianness

字节顺序(endianness),这里特指当保存一个数字类型数据时,存储的字节序列的顺序。分为大端序(big-endian,简写BE)和小端序(little-endian,简写LE)。

假设当前要将一个16位的整型数字\x0A0B指向内存地址\x100。

对于大端序的CPU,随着内存地址的增加,认为其存储的值的重要性是递减的,所以大端序的CPU会在\x100的位置上存储\x0A,在\x101的位置上存储\x0B

对于小端序的CPU,随着内存地址的增加,认为其存储的值的重要性是递增的,所以小端序的CPU会在\x100的位置上存储\x0B,在\x101的位置上存储\x0A

所以反过来,假设现在在内存中,地址\x100的地方存储了\xAA,在\x101的地方存储了\xBB,假设有一个int16变量指向\x100,对于大端序CPU会认为该变量的值为\xAABB,对于小端序CPU会认为该变量的值为\xBBAA

Byte order mark

因为各个系统之间的字节顺序不同,所以在传输和交换Unicode文本时,要告诉对方当前是以什么顺序保存的,从而接收方才能有效的进行解析。

字节序列标记(Byte order mark,简写BOM),特指\xFEFF字符。在文本的开头,添加\xFEFF字符,用于标识当前文本的字节顺序。

所以,解析程序通过判断BOM即可确定接下来的文本所使用的编码格式以及字节顺序。在Unicode中,\xFEFF是专门用作BOM的,如果该字符出现在文本中间,会被当做「零宽非换行空格」(zero-width non-breaking space),其实就是跳过的意思。同样的,对于它的一个镜像字符\xFFFE,如果出现也会被跳过。

BOM可以省略,不是必须的,因为:

  1. 在某些场景下已经预设了编码格式或字节顺序,例如W3C的HTML5规范中,如果指定charset为utf-8,则会默认按照utf-8解析,而如果文件流指定了BOM,则会优先使用BOM指定的编码格式和字节顺序。
  2. 当BOM被省略时,大部分解析器都会对文本流进行推算,推算出编码格式和字节顺序,但是这个推算并不是绝对可靠的。

当使用UTF8格式保存文本时,Unicode标准建议,如果原文本没有BOM,则不要添加BOM。因为:

  1. UTF8是单字节存储的,不存在字节顺序问题。
  2. 解析器会默认使用UTF8解析文本。
  3. 因为ASCII和UTF8是一一对应的,如果不添加BOM,则ASCII和Unicode可以相互兼容,如果加上了BOM,就打破了相互兼容。

不过当前很多系统或平台并没有按照规范来,在解析文本的时候会要求UTF8要有BOM,以及在保存文本的时候会加上BOM,例如windows系统的记事本。

而对于UTF16UTF32,要添加BOM,不然在解析的出的文本可能就是乱码,因为解析器在对字节顺序的推算上,并不能保证完全可靠。

如有转载,请注明出处,感谢
原文: https://zhuanlan.zhihu.com/p/370601172