字符编码
1:认识emoji和字符编码
如果您是初学者,了解编码可以让您对软件代码有一个大致的了解。如果你是中级开发者,了解编码有助于提升水平。
平时工作学习中判断字符串长度,'中'.length
输出 1
,但实际上它占用了三
个字节。
你是否会感觉到好奇,不妨试下以下代码:
String.fromCharCode(55357,56425,55356,57339,8205,55358,56752)
不出意外的话,你会看到一个美女emoji:👩🏻🦰
里面发生了什么?
为什么这么一串数字就能组成一个emoji?
下面程序无法正常打印出信息。会出现 � 特殊符号:
let a = '😂123';
for(let i = 0;i<a.length;i++){console.log(a[i])}
会什么会出现特殊符号?
如果你不了解前端相关知识,不过这没关系,重要的是认识编码相关知识:您可以打开一个你喜欢的浏览器,并且打开控制台,复制这串代码
String.fromCharCode(55357,56425,55356,57339,8205,55358,56752)
到浏览器控制台,点击回车就可以看到效果。
2:ASCII编码
上个世纪,一群聪明而又令人尊敬的前辈研究出了一套ASCII编码,其中包含了26个英文字母及其他的符号。共128个字符。
随着全球化发展和计算机的流行,ASCII编码渐渐的不能满足每个地区的需求,所以相继出现了ANSI、GBK、GB2312、GB18030等。
为了统一编码,Unicode 编码出生。
Unicode 只是字符集,它包含了 UTF-8、UTF-16、UTF-32 编码规则。
Unicode足够大,大到可以容纳世界上所有的字符。
试下下面这串代码:
String.fromCharCode(97,98,99)
你会看到 abc
三个字母。
3:开机手册
如果你是javascript从业者,你可以使用:
- charCodeAt
- codePointAt(UTF-16 码元)
来查看字符对应的unicode编码:
如:'a'.charCodeAt(0)
,你将看到一个数字 97
如果是python,请使用 ord('a')
,你将看到一个数字 97
此外,你可以登录部分网站查看:
分享一个可以查询字符对应unicode编码的网站,你可以方便的在上面查询你想知道的字符对应的编码。
相应的,你还可以将 unicode
编码转为对应的字符:
- String.fromCharCode()
- String.fromCodePoint() (UTF-16 码元)
如:String.fromCharCode(97)
4:可疑的长度
如果你是前端开发者,应该经常使用 String 的 length
属性。
很多人都说中文占 2
个字节,占 3
个字节,但使用 length
属性的时候明明看到的是长度为 1
。
这是为什么?
如:'中'.length
,js编译器输出了 1
但实际上,它占用了 3
个字节。
字符串的length数据属性包含 UTF-16 代码单元中字符串的长度。
以上内容摘自 MDN。
从文档中得知,它的长度计算是以 UTF-16 编码格式计算的。
如果以 UTF-16 编码格式编码,那两个字节最大可以保存 65536
个字符,而我们常用,接触到的字符大部分小于该数值,所以没有发生过错误,起码是在我们日常使用过程的印象中,是这样的。
它是有风险的。
'𐆙'.length
该字符会输出为2。
是不是有点疑惑,不过不用担心,继续往下。
5:UTF-8
想一下,utf-8 在你心中是个什么东西?
水果?
蔬菜?
汉堡包?
在规范中介绍,它是一个编码格式,不仅仅只在 unicode 中使用,要理解其中含义。
比如:unicode中包含了大量字符,如果直接使用 unicode 来传输数据,会造成很大的资源浪费。
在 utf-8 中,一个字节可以存储 128 中字符,其中包含了全部英文字母,而中文大部分占 3-4个字节,如果都用这么大的空间传输数据,对使用英文字母的网络将会多消耗 3-4 倍的流量。这是消费者无法接受的。
utf-8 的出现解决了这个问题,它是一种针对Unicode的可变长度字元编码。
通俗来将就是,英文继续使用单字节,中文使用3-4个字节。
既然有些字符占用三个字节,有些占用1个字节,那它们是怎么区分的呢?
或者说,utf-8 长度是可变的,那么它是怎么知道一个字符的长度的呢?
Unicode编码范围(16进制) | UTF-8编码方式(二进制) |
---|---|
U+0000 - U+007F | 0xxxxxxx |
U+0080 - U+07FF | 110xxxxx 10xxxxxx |
U+0800 - U+FFFF | 1110xxxx 10xxxxxx 10xxxxxx |
U+10000 - U+1FFFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
U+200000 - U+3FFFFFF | 111110xx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx |
U+4000000 - U+7FFFFFFF | 1111110x 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx |
对于单字节,需要单独使用一位前缀标志。之前可以使用8位来保存的值,此时只能使用7位。
所以在utf-8中,一个字节并不能保存256个值,单字节至多保存128个值。
根据之前学习的知识,我们可以知道 “家” 的unicode值为 23478。
'家'.charCodeAt() // 23478
23478 转换为二进制为 0101101110110110
对照上表分开存储:
- 单字节只能存储 7 位(不包含前缀),而
0101101110110110
为16位 - 使用两个字节可以存储 11 位(不包含前缀),依然还是不够。
- 使用三个字节可以存储 16位(不包含前缀),刚刚好。
除此之外,你可以使用范围区间计算,U+0800 - U+FFFF
转化为十进制为 2048 - 65535
,可以容纳 23478。
1110xxxx 10xxxxxx 10xxxxxx
先看第一位:1110xxxx
可以容纳 4 位,所以从 0101101110110110
前取出 4 位,将 0101
放入 1110xxxx
,此时:
0101101110110110
= 101110110110
(a)
1110xxxx
= 11100101
(b)
再看第二位:10xxxxxx
可以容纳 6 位,所以继续从 a 101110110110
前取出 6 位,将 101110
放入 10xxxxxx
,此时:
101110110110
= 110110
©
10xxxxxx
= 10101110
(d)
继续看第三位:10xxxxxx
可以容纳 6 位,所以继续从 © 110110
前取出 6 位,将 110110
放入 10xxxxxx
,此时:
10xxxxxx
= 10110110
(e)
最终结果就是:
b + d + e = 11100101 10101110 10110110
到这里我们就成功使用 utf-8 编码后的二进制来表示字符 “家”。
虽然 utf-8 可以节省空间,但它确实也很占用空间。
6:UTF-16
刚开始,我们看到了一个问题,经常有人说中文占用3个字节或者4个字节,那为什么 '家'.length
会返回长度 1 呢?
这里就需要了解 UTF-16 编码方式。
它也是一个可变长的编码,只不过相较于 UTF-8,每单位最少使用16位长的码元来进行表示。
16进制编码范围 | UTF-16表示方法(二进制) |
---|---|
U+0000 - U+FFFF | xxxx xxxx xxxx xxxx - yyyy yyyy yyyy yyyy |
U+10000 - U+10FFFF | 1101 10yy yyyy yyyy - 1101 11xx xxxx xxxx |
这里继续先以字符 家
为例,它的unicode码值为 23478
,小于 U+10000
,所以我们直接可以使用 xxxx xxxx xxxx xxxx - yyyy yyyy yyyy yyyy
。
23478 转换为二进制为 101101110110110
那么以 UTF-16 编码方式编码最终显示结果为 0000 0101 1011 1011 0110
如果字符unicode码值大于 U+10000
,那么就要额外处理一步了。
第一步:首先,需要减去 0x10000
这里以字符 'ഘ'
为例,它的 unicode 码值为 10d18(十六进制)
0x10d18 - 0x10000 得出 0xd18,转换为二进制为:110100011000
第二步:分割,分为前十位,后十位
注意,应当先保证后十位成功截取,不足则补 0,所以将 110100011000
转换为 00000000110100011000
截取后分为前后两部分:00 0000 0011
和 01 0001 1000
第三步:前十位与 0xD800 相加,形成高位
0xD800 + 0x3 得出 0xd803,转换为二进制为:1101100000000011
第四步:后十位与 0xDC00 相加,形成低位
0xDC00 + 0x118 得出 0xdd18,转换为二进制为:1101110100011000
第五步:合并结果(在不考虑字节序的情况下)
最终使用 UTF-16 编码过后显示为 1101 1000 0000 0011 1101 1101 0001 1000
UTF-16无法兼容ASCII编码
7:字节序
使用 UTF-16 过程中,理解字节序很重要。
字节序分为 大端表示(big endian)
和 小端表示(little endian)
。
UTF-16 每单位使用 16 位长的码元,而 8 位长的码元代表一字节,它包含了两个字节,这两个字节该从前后哪个位置优先读取就显得很重要。
比如:从上一小节,我们知道 家
的 UTF-16 编码后的值为 0000 0101 1011 1011 0110
,转换为十六进制就是 0x5bb6
按照大端存储, 5b 将存储在内存地址1000 , b6
将存储在内存地址1001。
反过来,小端存储,b6
将存储在内存地址1000,5b 将存储在内存地址1001,最终显示为 0xb65b
通俗来讲,大端存储更符合人的阅读习惯,从左往右。
为了判断文件是以 大端存储的还是小端存储的,会在文件前面增加一个 U+FEFF 空白字符(不是空格),如果是返回的是 FE FF
那就代表是以 大端(BE)编译,如果返回的是 FF FE
,那就代表是 小端(LE)编译。
同样,以 上节的 'ഘ'
字符为例,它的 UTF-16 编码格式值为 1101 1000 0000 0011 1101 1101 0001 1000
,转换为 16 进制后为 0xd803 0xdd18
- 大端表示:d8 03 dd 18
- 小端表示:03 d8 18 dd
8:UTF-32
UTF-32 运行很快,但占用的空间也很大,所以不经常使用。
它可以直接使用下标对应 unicode 的码值,所以不需要转换,另外,它的前导第一位需要为 0,所以只有 31 个码位用来存储。
9:回到原来的
回到最初,我们遇到的 😂 emoji 字符。
还记得 UTF-16 那一小节吗?它之所以显示 length = 2
是因为该 😂 字符unicode 值为 128514
,它超出了 U+FFFF
所以,需要使用两个 UTF-16 单位(4个字节)去表示,并且 length
是以 UTF-16 代码单元基础来计算的,所以会显示长度为2。
如果想要以最简单的方式遍历包含 emoji 的字符串,可以使用for of
来解决,如:
let a = '😂123';
for(i of a){console.log(i)}
拓展:你也可以尝试使用学到的 UTF-16 编码方式去根据对应的unicode码值进行遍历。
10:最后
如果觉得内容难以理解,可以给我留言。如果您不忙,我会尽力解答您的问题,为您解答。如果内容有误,谢谢您为我指出,谢谢