Keep calm & thinking

js与字符集编码

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

前言

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

  • 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 与字符编码](https://github.com/SamHwang1990/blog/issues/2)

**2. 编译与运行时**  

Javascript 引擎总会尝试把源码转成UTF-16 编码的文本,对于这个定义的探究,便只能查看官方定义,此处参考[Javascript 与字符编码](https://github.com/SamHwang1990/blog/issues/2)

总结来说,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('') // "💩坨一是这"

- 码点的互转,使用`codePointAt`和`fromCodePoint`

'我'.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-2`,`utf-16`,`utf-8`等等,我们讲编码方式,一定是讲的它们。

## 学习参考过程
- 最先看到了这篇,加深了对于[位,字节,二进制,十六进制间的关系](https://blog.csdn.net/pamxy/article/details/11780753)的理解
- 然后看到了阮一峰老师的[字符编码笔记:ASCII,Unicode 和 UTF-8](http://www.ruanyifeng.com/blog/2007/10/ascii_unicode_and_utf-8.html),对字符编码的历史由来有了一个初步的认知
- 然后看了[JavaScript字符集编码与解码](https://www.cnblogs.com/strick/p/6349958.html),了解到需要认识一些术语和名词
- 再然后看了[Javascript 与字符编码 ](https://github.com/SamHwang1990/blog/issues/2),对于术语和名词及各个问题更加清晰了。
- 再结合腾讯Alloyteam的文章[javascript有个unicode的天坑](http://www.alloyteam.com/2016/12/javascript-has-a-unicode-sinkhole/),更加证实了自己的理解,并基于此实践了部分公式及转换。
- 接着看了阮一峰的[Unicode与JavaScript详解](http://www.ruanyifeng.com/blog/2014/12/unicode.html),知道了在BMP平面中存在高位与低位空段来映射SMP字符
- 看了[JavaScript 的内部字符编码是 UCS-2 还是 UTF-16
](https://www.w3ctech.com/topic/1869),知道了平面间的关系及17个unicode平面的由来及公式的实践。
- 接着又看了[UTF16和UTF8什么区别?
](https://www.cnblogs.com/snowinmay/p/3224396.html),知道了utf-16与utf-8的编码换算
- 最后看了[ASCII、Unicode和UTF-8编码的区别;中英文混合截取](https://blog.csdn.net/weiwenjuan0923/article/details/52713387),明白了unicode出来之前的各国编码。
- 还使用了一个在线工具[Unicode和UTF编码转换](https://www.qqxiuzi.cn/bianma/Unicode-UTF.php),来印证我实践的编码正确性。

上次更新: