js与字符集编码

字符与编码

Jmingzi createdAt at 2年前

上一篇文章介绍了几个概念: 字符与编码

前言

为什么是字符集编码而不是字符编码呢?看完本文后你会有一个清晰的理解。本文讲述的内容:

  • ASCII码
  • 字符与字节
  • 字节与二进制、八进制与十六进制的关系
  • Unicode字符集、码点与编码
  • utf-16编码与字节序
  • utf-8编码
  • ucs-2编码
  • js使用的编码
  • ES6的Unicode支持

ASCII码

讲编码的概念,当然得从计算机的起源开始说起了,就好比每当说到中国神话故事,都得从盘古开天的时候讲述一样。

美国人发明的计算机,那当然最开始的编码是从英文字母开始的,此时的编码是处理英文字母、数字、符号与二进制的对应关系,数量不多,128个足够表示全部了。此时,这128个对应关系的定义所得到的编码就被称为ASCII码。

字符与字节

字符与字节在我的上一篇文章有提及,字符是什么呢,字符就是我们在计算机上说看到的,比如'a', '你', 👿等等。

对于ASCII码,上节提到128种编码分别对应128个字符,这被称为单字节字符,也就是说一个字符代表占用一个字节。

对于非单字节字符,像emoji表情,是4个字节组成(为什么是4个后面会说)。也就是说,一个字符可能由多个字节组成,这依赖于对应的编码方式。

字节与二进制、八进制与十六进制的关系

1个字节由8位二进制组成,总共对应0000 0000 ~ 1111 1111256种状态。前面说的ASCII码就 是其中的0000 0000 ~ 0111 1111128种。

我们知道十六进制是用0-9 a-f来表示,最大15用二进制来表示即1111,由于1个字节有8位,所以需要2个十六进制数才能代表一个字节,即00 ~ ff才能代表一个字节的范围。

那八进制与字节的关系如何呢?

八进制最大为7,即111,我们会发现需要3位八进制才能表示一个字节111 111 111,但是又多了一位,此时该如何解释?我没弄懂。。。⚠️

在js中,普通的书写数字一般都是十进制的格式,如果在数字前加0,则代表八进制数,加0x,代表十六进制数。

进制的互相转换只需要讲对应的数字转换成相应的进制来表示,比如十进制110:

  • 转换为二进制
    • 转换过程为100转化为二进制 (100).toString(2) 1100100
    • 10转化为二进制 1010
    • 结合起来就是1101110
  • 转化为八进制
    • 转换过程为100转化为八进制 (100).toString(8) 144
    • 10转化为八进制 12
    • 结合起来就是156
  • 同理转化为十六进制

计算机内都是以二进制的形式存储,八进制与十六进制其实只是人们对二进制的一种简化写法,因为当数值越来越大时,书写起来,会需要很长很长,为了方便使用,就新增了八进制与十六进制等表现形式。

Unicode字符集、码点与编码

我们上面说到ASCII码只是代表了二进制8位中的前7位127个字符,在Unicode还未诞生之前,各个国家是自己编码对应的字符,例如我国就有gb2312,2个字节对应一个字符,具体不深入。

汉字就有好几万,再加上世界上这么多语言,为了统一字符编码,Unicode就应运而生了,计算机只使用这一种字符集,就不会出现乱码了。

码点Code Point组成了Unicode的字符集,码点的表示法是U+0000即4位十六进制,那它代表的字符范围是0000 ~ ffff共65535个。

我们要着重理解的是,Unicode字符集不是编码,即没有规定多字节字符如何存储,而实现这种存储的方式是utf-8和utf-16等。也就是说,如果你问ASCII编码与Unicode的区别就好比你在问吃饭和吃米的区别一样。

BMP/SMP

Unicode的码点范围是U+0000 ~ U+10ffff,前面说到的U+0000 ~ U+ffff共65535代表BMP基本平面,最常见字符都在这个范围内,我们知道2个十六进制代表一个字节,那基本平面的字符都是由2个字节组合表示的。

基本平面以外的,U+010000 ~ U+10ffff0x1016个平面称为补充平面SMP,用来表示常见字符以外的字符,例如emoji表情。

Unicode共代表了U+10ffff110万多个字符。

utf-16编码与字节序

Unicode是采用十六进制来表示的,那一个utf-16编码的字符看起来就是这样的0x597dUnicode差不多,一个是u+,另一个是0x,在js中,用\u表示。

例如,汉字的十六进制编码:

'我'.charCodeAt(0) // 基本平面内对应unicode的码点
// 25105

(25105).toString(16) // 转换为16进制
// "6211"

对应的十六进制编码为’0x6211’,也就是说'\u6211' === '我'

那计算机为啥知道6211就是字符“我”而不是1162,因为62和11是2个字节,这就涉及到了字节序的问题,也就是我们说的文件BOM头。

有2个术语Big endianLittle endian,称为“大头”和“小头”,这里可以理解为62是大头,11是小头。

Unicode 规范定义,每一个文件的最前面分别加入一个表示编码顺序的字符,这个字符的名字叫做”零宽度非换行空格”(zero width no-break space),用FEFF表示。这正好是两个字节,而且FF比FE大1。

如果一个文本文件的头两个字节是FE FF,就表示该文件采用大头方式;如果头两个字节是FF FE,就表示该文件采用小头方式。


上面字符“我”在基本平面是2个字节表示,那么补充平面的内的字符是几个呢?答案是4个字节,为什么这么说呢?

当码点大于U+ffff的字符求长度时:

`🐷`.length // 2

这说明SMP补充平面的字符是由2个BMP基本平面的字符组成,因为基本平面的一个字符长度就是1。而基本平面的字符由2个字节组成,那补充平面很显然是4个字节组成了。

上面我们说unicode和十六进制表现形式只有前面符号的区别,其实它们之间是有转码公式对应的:

  • 基本平面
    u+597d = 0x597d
    
  • 补充平面
    H = Math.floor((码点-0x10000) / 0x400)+0xD800
    L = (码点 - 0x10000) % 0x400 + 0xDC00
    

求码点

  • 基本平面 charCodeAt()
  • 补充平面 codePointAt()

例如🐷这个emoji表情求码点

'🐷'.codePointAt(0)
// 128055 
// 转为十六进制后再带入公式
// (128055).toString(16) = '1f437' 
// 那么高位H和低位L分别为
const H = Math.floor((0x1f437-0x10000) / 0x400)+0xD800
// d83d
const L = (0x1f437 - 0x10000) % 0x400 + 0xDC00
// dc37
'\ud83d\udc37' === '🐷'
// true

从上面的栗子中,我们可以进一步说明SMP的字符是由4个字节组成的。

那计算机如何知道这4个字节放在一起就是SMP的字符而不是2个BMP的字符呢?

其实高位H和低位L在BMP中也是有对应关系的:

  • 高位H的范围是 0xd800 ~ 0xdbff,空间大小为2^10 = 1024
  • 低位L的范围是 0xdc00 ~ 0xdfff,空间大小为2^10 = 1024

那组合起来的总空间为2^20 = 1048576,这恰恰和SMP平面范围U+010000 ~ U+10ffff所表示的范围一致,即0x10ffff - 0x010000 = 1048575

所以,当我们取长度时,可以匹配基本平面的高位和低位段,从而判断是否是SMP字符,进一步处理长度。

const regexAstralSymbols = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g

function countSymbols(_string) {
  var bmpString = _string.replace(regexAstralSymbols, '_')
  return bmpString.length
}

countSymbols('🐷')
// 1

utf-8编码

UTF-8 就是在互联网上使用最广的一种 Unicode 的实现方式,使用可变字节来标示BMP、SMP 的字符。

unicode与utf-8也有一套对照关系

unicode utf-8
0000 - 007F 0xxxxxxx
0080 - 07FF 110xxxxx 10xxxxxx
0800 - FFFF 1110xxxx 10xxxxxx 10xxxxxx

例如,“我”字的unicode码点为“6211”

'我'.charCodeAt(0).toString(16)
// "6211"

由于0x6211 > 0x0800的,所以对应的utf-8为3个字节,将6211转成二进制为:

// (0x6211).toString(2)
0110 0010 0001 0001

用对应的模版补齐则是:

11100110 10001000 10010001
// e  6     8  8    9   1  
// 四位二进制代表一位十六进制

这就得到了“我”字的utf-8编码,转成十六进制e68891

ucs-2编码

在js语言还未诞生之前,ucs-2就已经发布了,它只支持2个字节组成的字符,并不支持4个,因为在涉及之初认为已经够用了,鬼想到还会出现类似emoji这样的东西。

发展历史

  • ucs-2发布
  • js诞生
  • js解释器引擎诞生
  • utf-16发布

也就是说ucs-2是不支持由4个字节也就是SMP平面的字符。理所当然的在BMP中也不会存在高位和低位码点段来映射SMP。

但是utf-16是完全兼容ucs-2的。那js到底是使用的utf-16还是ucs-2呢?

js使用的编码

1. 对js文件解析时

js在编译前需要将文件中的内容按照一定的编码读取成字符串

解码使用的编码方案按优先级高到低由下面几个方面来决定:

  • 如果文件有BOM 标记,则会使用对应的Unicode 编码,比如FFFE、FEFF 就会使用UTF-16。这就是上面提到的“大头”“小头”定义的文件字节序。
  • 由HTTP(S)请求的相应头来决定,比如:
    Content-Type: application/javascript; charset=utf-8
  • 由<script/> 标签的charset 属性决定,比如:
    <script charset="utf-8" src="./main.js"></script>
  • 由html 本身的charset 决定,比如:
    <meta charset="UTF-8">

此处复制于Javascript 与字符编码

2. 编译与运行时

Javascript 引擎总会尝试把源码转成UTF-16 编码的文本,对于这个定义的探究,便只能查看官方定义,此处参考Javascript 与字符编码

总结来说,js语言本身采用UCS-2来实现这是事实

  • 编译时现在的引擎都会采用utf-16
  • 运行时可能既有ucs-2,又有utf-16

过分去深究这个问题似乎没有多少意义,因为二者所表现的特性都是utf-16所表现的。

ES6的Unicode支持

我们先来看看unicode的坑

1. 对于SMP字符获取长度为2上面已经提到了

2. 字符串的反转

正常情况下,反转一个字符串

str.split('').reverse().join('')

那遇到SMP字符就肯定错了,因为它会将SMP字符的2个十六进制对也反转了

'🐷'.split('').reverse().join('')
// "��"

3. 字符与码点的互转

在BMP范围内,互转是没有问题的

'我'.charCodeAt(0).toString(16) // 6211
String.fromCharCode(0x6211) // 我

但是SMP就不行了,取码点要将2个十六进制对都取出来

'💩'.charCodeAt(0).toString(16) // d83d 
'💩'.charCodeAt(1).toString(16) // dca9

// 同样的反转时也是一样
String.fromCharCode(0xD83D, 0xDCA9) // '💩'

4. 正则匹配

正则匹配符.只能匹配单个“字符”,那很明显SMP字符是匹配不到的。

/foo.bar/.test('foo💩bar') // false

5. 字符串遍历

这也是一个很明显的问题,比较直接的办法是:

遍历字符串,用charCodeAt转成码点后存放进临时数组,然后再遍历这个由码点组成的临时数组,判断码点在高位和低位段的认为是SMP对应的码点映射。


针对上述问题,es6填坑了

  • 长度问题还是需要用正则匹配高位和低位码点,将对应的字符替换为BMP中的任意字符即可。
  • 字符串的反转,可以使用Array.from()
    Array.from('这是一坨💩').reverse().join('')
    // "💩坨一是这"
    
  • 码点的互转,使用codePointAtfromCodePoint
    '我'.codePointAt(0).toString(16) // 6211
    '💩'.codePointAt(0).toString(16) // 1f4a9
    String.fromCodePoint(0x1f4a9) // "💩"
    
  • 正则匹配
    /foo.bar/u.test('foo💩bar') // true
    
  • 字符串遍历 let ... of

结语

我们再回过头来看最初问的问题,为什么是字符集编码而不是字符。很显然,字符是编码后我们看到的结果,而不是要编码的内容,从最初的ASCII码字符集,UCS字符集,再到Unicode字符集,它们都是一种统一集合,而实现字符集编码的方式有多种:ucs-2utf-16utf-8等等,我们讲编码方式,一定是讲的它们。

学习参考过程

git1

最后更新:2021年06月09日 16:00

鄂ICP备18011687号-1