字符编码&JavaScript

679 阅读18分钟

字符串是任何一种编程语言中的重要概念,同时,也是一个非常复杂的问题。

什么是编码解码?

众所周知,计算机内部使用的是二进制码进行数据的存储、运算,而大部分人更加擅长对图形、文字的阅读理解。为了便于理解计算机存储内容或者存储图文内容,就需要在两者之间做一种转换。 编码,是数据从一种形式或格式转换为另一种形式的过程,简单来讲就是语言的翻译过程。它是一套算法,比如将字符A转换成01000001就是一场编码过程,反之就是解码了,类似电报的加密与解密。

字符集

字符集是一个系统支持的所有抽象字符的集合。例如我们的《新华字典》,就像当于一个字符集,第100页10行是汉字“XXX”,反之,“XXX”在字典第100页10行。简而言之,字符集就是一个映射关系表,将一串码值映射到抽象字符表里的特定字符。常见的字符集有:ASCII字符集、GBK 字符集、Unicode字符集等。不同的字符集规定了有限个字符,比如:ASCII 字符集只含有拉丁文字字母,GBK 包含了汉字,而 Unicode 字符集包含了世界上所有的文字符号。

ASCII编码

自计算机诞生起的很长一段时间里,其应用范围主要是在欧美一些发达国家,因此,他们在的程序中需要转换的就是拉丁字母和阿拉伯数字。于是乎, ASCII(American Standard Code for Information Interchange,美国信息交换标准代码)应运而生。ASCII编码一共规定了 128 个字符及对应的二进制转换关系,128 个字符包括了可显示的26个字母(大小写)、10个数字、标点符号以及特殊的控制符,也就是英语与西欧语言中常见的字符,这128个字符用一个字节(8bit)来表示绰绰有余,因为一个字节可以表示256个字符,所以当前只利用了字节的7位,最高位统一置零。如下图所以,字符小写 a 对应 01100001,大写 A 对应 01000001。

aaa.jpeg

GB2312编码

随着计算机开始在各国逐步普及,针对ASCII码并没有兼顾他国语言的字符集的问题,各个国家针对自己本国的语言扩展了ASCII码,特别是我们中国的汉字,博大精深,常用的汉字有就有3500个,即使是EASCII(ASCII扩充最高位)也无法支撑。由中国国家标准总局于1980年发布,1981年5月1日实施的GB2312编码,规定每个汉字由两个字节组成,理论上它可以表示65536个字符,不过它只收录了6763个汉字,详情请查看GB2312简体中文编码表

GBK编码

汉字内码扩展规范,称GBK,全名为《汉字内码扩展规范(GBK)》1.0版本,由全国信息技术标准化技术委员会1995年12月1日制订。GBK是对GB2312-80的字节范围进行扩展,每个汉字依旧占据两个字节空间,主要是针对GB2312字符集中没有用到的AA-AF和F8-FE区域进行扩展。扩展后可表示的汉字多达21886个,扩展内容主要是GB2312不支持的部分中文姓、繁体字、日文假名、希腊字母及俄语字母等。

GB2312GBK编码都不是本文重点,包括兼容前两者、可变多字节编码的GB 18030,就不做深入探讨,有兴趣可自行查阅相关资料。

Unicode字符集

由于每个国家都制订了本国的字符集和字符编码导致:国际间的图文传输需要频繁的进行编码转换,否则A国家的文档发送到B国家按照B国家的编码规则解码后就会出现乱码。解决办法有两种:

  1. 国际化的程序或系统需要安装复杂繁多的字符集和编码规则,用来处理不同语言间解码转换;
  2. 将世界上所有的文字字符集中在一张字符集上,使用相同的编码/解码方式。

显然,第一种方法看起来就很头疼。在1991年,国际标准化组织和统一码联盟组织各自开发了ISO/IEC 10646 (USC)和Unicode项目,他们的目的都是希望用一种标准的字符集来统一全世界的字符,为了避免出现编码结果差异,他们决定把彼此的工作内容合并,虽然项目独立,但各自相互兼容。不过由于Unicode名字比较好记,因此使用更为广泛。

Unicode(官方中文名称为统一码,也译名万国码、国际码、单一码),它整理、编码了世界上大部分的文字系统,使得计算机使用更简单的方式来呈现和处理文字。Unicode至今仍在不断增修,目前最新版本为2020年3月公布的13.0.0,已收录超过13万个字符,涵盖了除视觉上的字形、编码方法、标准的字符编码外,还包含了字符特性,如大小写字母等。

码点

Unicode中,每个字符被分配一个唯一数值,我们称之为码点(又名代码点)。码点表示形式为U+[XX]XXXXX代表一位十六进制数字,取值范围为U+0000~U+10FFFF。例如字符‘a’在Unicode字符集中的码点就是U+0061(61换算为十进制就是97,Unicode兼容ASCII码,所以0~127的码点范围即ASCII码表可表示的字符集)

'哈'.charCodeAt(0) // 21704 => 0x54c8
'\u54c8' => '哈'

根据码点,Unicode字符集被分为17组编排,每组称为一个平面(Plane,又称位面),每个平面拥有65536(FFFF => 16^4)个码位

平面始末码位值中文名称英文名称
0号平面U+0000 - U+FFFF基本多文种平面Basic Multilingual Plane,简称 BMP
1号平面U+10000 - U+1FFFF多文种补充平面Supplementary Multilingual Plane,简称 SMP
2号平面U+20000 - U+2FFFF表意文字补充平面Supplementary Ideographic Plane,简称 SIP
3号平面U+30000 - U+3FFFF表意文字第三平面Tertiary Ideographic Plane,简称 TIP
4号平面-13号平面U+40000 - U+DFFFF(尚未使用)
14号平面U+E0000 - U+EFFFF特别用途补充平面Supplementary Special-purpose Plane,简称 SSP
15号平面U+F0000 - U+FFFFF保留作为 私人使用区(A 区)Private Use Area-A,简称 PUA-A
16号平面U+100000 - U+10FFFF保留作为 私人使用区(B 区)Private Use Area-B,简称 PUA-B

Unicode字符集定义了字符与码点之间的映射关系,但并没有规定如何转换存储。如果在存储过程中直接存储码点,每个字符至少需要3个字节(U+0000 ~ U+10FFFF 取最大码点所占空间),但是我们大部分常用的字符其实都分布在0号平面(U+0000 ~ U+FFFF)仅占两个字节空间,那么每一个字符就差不多要浪费一个字节的存储空间。为了平衡所有平面字符的存储空间、兼容和解码问题,Unicode标准中规定了UTF-8,UTF-16,UTF-32等这几种编码方式(UTF是Unicode Transformation Format的缩写)。

UTF-8编码

编码方式如下:

  1. 对于单字节字符,字节第一位置零,后7位使用该字符Unicode码点。即该字符编码同ASCII码一致
  2. 对于n(n > 1)个字节字符,最高位字节,前n位置1,第n + 1位置0,剩余低字节均以10开头,有效的二进制位(下图中的x占用位)则表示该字符的Unicode码点。
Unicode码点范围(十六进制)UTF-8编码方式(二进制)字节数
U+0000 ~ U+007F(0~127)0xxx xxxx单字节
U+0080 ~ U+07FF(128~2047)110x xxxx 10xx xxxx双字节
U+0800 ~ U+FFFF(2048~65535)1110 xxxx 10xx xxxx 10xx xxxx三字节
U+10000 ~ U+10FFFF(65536~2097151)1111 0xxx 10xx xxxx 10xx xxxx 10xx xxxx四字节

依据上表,对比Unicode码点进行编码就很简单了,以字符⻥(Unicode码点为U+2EE5)为例:码点在U+0800 ~ U+FFFF范围区间,占三个字节,只需将2EE5 => 0010 1110 1110 0101 从低位开始替换1110 xxxx 10xx xxxx 10xx xxxx中的x(没有填充的x置零),即1110 0010 1011 1011 1010 0101,转为十六进制就是E2BBA5

解码也很简单,如果一个字节最高位是0,则该字节就是一个字符,取低7位直接转换为Unicode码点;如果第一个字节最高位是1,则连续有多少个1(最多4个),则该字符占用多少个字节,取出其中的有效位(x),转换为Unicode字节码。例如编码结果E2BBA5 => 1110 0010 1011 1011 1010 0101占三个字节,提取有效位0010 1110 1110 0101 => 2EE5即Unicode码点。

UTF-8编码单字节除最高位以外都是有效位,空间利用率7/8(87.5%),双字节中五位(110 10)不可用,空间利用率11/16(68.75%),依次类推,三字节利用率10/24(41.67%),四字节利用率11/32(34.34%)。另外在双字节中,Unicode起始值是U+0080,编码就是1100 0010 1000 0000,那么1100 0000 1000 0000 ~ 1100 0010 1000 0000(有效值全部置零~起始值)这之间的值是不会被编码到的,也就是被浪费的。

utf-16编码

和UTF-8不同的是,UTF-16使用的是2或4字节来进行编码。

UTF-16源于UCS-2(Universal Character Set coded in 2 octets、2-byte Universal Character Set),最初被设计成16位(双字节)编码,覆盖范围:U+0000 ~ U+FFFF(0~65536),后来Unicode和USC合并后,需要将覆盖范围扩展至U+10000 ~ U+10FFFF,为了兼容旧的版本,引入新的编码机制——代理机制。

范围在U+0000 ~ U+FFFF的码点被称为BMP(Basic Multilingual Plane,基本多语言平面),拓展的码点范围U+10000 ~ U+10FFFF 被称为SP(Supplementary Planes,增补平面)。在BMP平面中存在D800~DFFF共2048个空闲部分,我们称之为代理区(该范围没有指向任何字符,兼容旧的版本编码解码)。该代理区分为高代理区(U+D800 ~ U+DBFF)和低代理区(U+DC00 ~ U+DFFF)。

image-20210824165636364.png

编码方式如下:

  1. 码点位于U+0000 ~ U+FFFF区间,直接进行二进制编码填充两个字节,位数不够左侧补零
  2. 码点位于U+10000 ~ U+10FFFF区间,则该码点减去U+10000 获得码点(U+0000 ~ U+FFFFF)转为20位(5个F,5×4)二进制,不足左侧补零,生成yyyy yyyy yyxx xxxx xxxx。取高10位y(yy yyyy yyyy)加上1101 1000 0000 0000(D800)组成1101 10yy yyyy yyyy记为高位代理;取出低10位x(xx xxxx xxxx)加上1101 1100 0000 0000(DC00)组成1101 11xx xxxx xxxx记为低位代理。编码结果为高位代理和低位代理(共四个字节)组成。
Unicode码点范围(十六进制)UTF-16编码方式(二进制)字节数
U+0000 ~ U+FFFF(0~65535)xxxx xxxx xxxx xxxx双字节
U+10000 ~ U+10FFFF(65536~2097151)1101 10yy yyyy yyyy 1101 11xx xxxx xxxx四字节

高位代理 = ((码点 - 0x10000) >>> 10) + D800 // 右移10位取高10位

或 高位代理 = (码点 - 0x10000) ÷ 0x400 + D800 // 右移10位即除以100 0000 0000 => 0x400

低位代理 = ((码点 - 0x10000) & 0x003ff) + DC00 // 高10位置0,低10位置1,取与值

或 高位代理 = (码点 - 0x10000) % 0x400 + DC00 // 取余即取低10位

以”丽“(码点U+2F800)为例(注意,此处的”丽“并非中文的丽-U+4E3D):

  1. 0x2F800 - 0x10000 = 0001 1111 10,00 0000 0000
  2. 高位代理 = (0x2F800 - 0x10000) / 0x400 + 0xD800 = 1101 1000 0111 1110 = 0xD87E
  3. 低位代理 = (0x2F800 - 0x10000) % 0x400 + 0xDC00 = 1101 1100 0000 0000 = 0xDC00

所以编码结果为:0xD87E DC00。解码过程:

  1. 如果当前双字节编码不在在D800~DBFF(高位代理区间)区间则直接解码为码点
  2. 如果当前双字节编码在D800~DBFF区间则它和它的下一个双字节共同解码。当前双字节(1101 10yy yyyy yyyy )减去0xD800 后(0000 00yy yyyy yyyy)左移10位(yyyy yyyy yy00 0000 0000),下一个双字节( 1101 11xx xxxx xxxx)减去0xDC00后(0000 00xx xxxx xxxx),两者相加得yyyy yyyy yyxx xxxx xxxx,再加上U+10000即码点

例如0xD87E DC00:

  1. 第一个双字节D87E在D800~DFFF区间,则减去0xD800后为0000 0000 0111 1110
  2. 第二个字节DC00减去0xDC000000 0000 0000 0000
  3. 两者相加为0001 1111 1000 0000 0000即0x1F800,加上0x10000,即0x2F800

UTF-16巧妙的利用D800~DFFF共2048个空闲部分,高代理区(U+D800 ~ U+DBFF)可以表示10位(2^10),低代理区(U+DC00 ~ U+DFFF)也可以表示10位(2^10),而U+10000 ~ U+10FFFF之间刚好有20位,拆分开前10位和后10位进行高低代理即可完美解决问题。

UTF-32编码

UTF-32编码就很简单了,固定四个字节作为一个字符的存储空间,不够左侧补零。Unicode最大码点U+10FFFF也只需3个字节就可以了,那么为什么UTF-32要设计四个字节或者说为什么没有UTF-24编码呢,这个不得而知(笔者猜测,可能跟计算机系统习惯以2的指数位来进行存储运算,改成三字节可能会有兼容性问题)。UTF-8和UTF-16编码和解码就进行了位处理,但是它们存储空间利用率高,UTF-32牺牲了空间利用率,但是提高了编码解码效率。

总结

UTF-8编码和UTF-16编码结果都是变长,在存储空间上都有做优化处理,不同点在于UTF-16计算编码结果执行索引操作速度很快,而UTF-8相对较乏力。例如针对一个编码结果xxxx xxxx 10xx xxxx xxxx xxxx,UTF-8想知道第二个字节对应的字符就需要从头到尾解析一次这个编码结果,而UTF-16只需判断第二个字节10xx xxxx如果在U+D800 ~ U+DBFF区间(高位代理)则它的下一个字节跟他一起组成一个字符,如果在U+DC00 ~ U+DFFF区间(低位代理)则它的上一个字节跟他一起组成一个字符。

UTF-32编码在存储空间上浪费率确实比较高,但是它执行索引速度相当快(每四个字节一个字符,想获取第n个字节的字符,直接找到第n/4后连接的四个字节就行),并且计算字符长度也是很快(字节长度/4)。

JavaScript编码

JavaScript语言采用Unicode字符集,但是使用的编码既不是UTF-8、UTF-16,也不是UTF-32,而是UCS-2编码。由于历史原因,JavaScript问世的时候,UTF-16还没发布,而UCS(上文提及的ISO/IEC 10646)组织的开发进度快于Unicode,率先发布了第一套编码方法UCS-2(UTF-16于6年后才发布),使用2个字节表示已经有码点的字符(当时就只有基本平面,两个字节足够)。UTF-16发布后,明确规定是UCS-2的超集,所以现在只有UTF-16,没有UCS-2。

由于JavaScript只能处理UCS-2编码(两个字节),增补平面(四个字节)就会被当成两个字符来处理,所以JavaScript处理字符的函数或属性受这个限制,有时无法返回准确的结果。例如上文说到的”丽“(码点U+2F800):

// '丽'(码点U+2F800),编码结果为0xD87E DC00
// 直接复制代码可能由于编辑器原因会复制成中文“丽”,建议去https://unicode-table.com/cn/2F800/复制“丽”字
'丽'.length // 2
'丽'.charAt(0) // \ud87e
'丽'.charAt(1) // \udc00
'丽'.substr(0, 1) // \ud87e
'丽'.substring(0, 1) // \uD87E
'丽'.slice(-1) // \uDC00
'丽'.split('') // ['\uD87E', '\uDC00']
'丽' === '\u2F800' // false
'丽' === '\uD87E\uDC00' // true
'丽'.charCodeAt(0) // 55422(0xd87e)
'丽'.replace('\uD87E', '0') // '0\uDC00'
'丽'.indexOf('\uDC00') // 1
// 就会出现一个字符两个长度的问题

// 兼容处理的方式如下:
var index = 0;
var len = str.length
while (index < len) {
  var charCode = str.charCodeAt(index)
  if (charCode >= 0xD800 && charCode <= 0xDBFF) { // 高位代理
    console.log(str.charAt(index) + str.charAt(++index))
  } else {
    console.log(str.charAt(index++))
  }
}

ES6

es6针对增补平面的字符坐了很多兼容处理,弥补了之前版本的问题。

  1. 字符的Unicode表示法

    ES6允许采用\uxxxx(xxxx表示Unicode码点)表示一个字符,增补平面(四字节)的字符可以\uyyyy\uxxxx(yyyy表示高位代理码点,xxxx表示低位代理码点)表示或者采用\u{xxxxx}表示。

  2. 字符串遍历接口

    使用for...of可以正确识别字符串中的增补平面(四字节)字符或者使用迭代符生成数组[...string]Array.from(str)来进行遍历(本质都是迭代器的封装)。

  3. JSON.stringify的改造

    根据标准,JSON数据必须是UTF-8编码。转义序列可以为:“\”、“"”、“/”、“\b”、“\f”、“\n”、“\r”、“\t”,或双字节Unicode码点(\uxxxx),增补平面(四字节)必须使用UTF-16编码代理。

    { "face": "😂" }
    // or
    { "face": "\uD83D\uDE02" }
    
  4. 正则u修饰符

    字符串支持Unicode表示法,正则也要做出相应的支持

    // \uD83D\uDC2A应该是一个字符,正则不匹配
    /^\uD83D/u.test('\uD83D\uDC2A') // false
    // \uD83D\uDC2A被识别为两个字符,所以能识别
    /^\uD83D/.test('\uD83D\uDC2A') // true
    
  5. String.prototype.includes

    理论上es6应该支持兼容增补平面(四字节)字符串,但是很遗憾:

    // '丽'被当做两个字符
    '丽'.includes('\uD87E') // true
    // 这里很迷惑
    '丽'.includes('\u{2F800}') // true
    

总结

在日常字符操作中,需要处理增补平面(四字节)字符串的场景还是比较多的,比如一个输入框或者富文本框,输入带表情的字符,处理它们就需要谨慎了。

参考

Programming with Unicode

wiki-UTF-16

Unicode 官网

Unicode与Javascript详解

UTF-8

ASCII