对于JavaScript中字符的进一步理解

224 阅读14分钟

这篇文章是对ES6之后字符串扩展知识的思考和总结,本文不会聚焦于API,而是着重于介绍字符这一概念的基础表示。

关键词 : 字符集,编码格式,Unicode,码元,码点等。

一、计算机中的字符

JS中的字符在后文会提到。

1.字符集的概念

学过计算机组成原理知道,数字在计算机内存中存储形式是二进制。其实字符最终也会以二进制的形式存储在计算机内存中。但是,字符毕竟不能和数字进行数学运算(类似进制转换的操作),所以我们需要人为规定一个字符-数字映射表,也叫做字符集。在计算机层面,我们也将字符叫做字符集定义的符号。

2.ASCII字符集

耳熟能详的字符集规则有:ASCII,这是一个以7个比特位(bit)表达的字符集,一共能表示128(2^7)个字符,后续增加到8个比特位,额外增加了128个字符。对应规则就像下面这样:

image.png

一一对应的关系,十分清晰。上述字符集中,字符数据的部分我们常称之为码点(Code Point),在这里,我们可以说:字符A的码点是65(十进制)。

根据上面的内容可以做出总结:

  • 码点是字符集中字符对应的唯一数值。
  • ASCII字符集是定长编码的,其字符总是能够使用8个比特位的二进制数来表示。
  • ASCII字符集编码过程简单,码点能够简单对应上特定字符。

3.Unicode字符集

ASCII是小型字符集,Unicode也是一个字符集标准。相比之下,其内部涵盖了超百万的字符,是现代被广泛采用的字符集标准。

在ASCII字符集标准中,码点范围从 0000 0000 ~ 1111 1111(2进制) ;而在Unicode字符集标准中,码点范围从 U+0000 ~ U+10FFFF(16进制)

10FFFF转换为10进制是数字:1,114,111。可以看出,Unicode 能够表示的字符集非常庞大,而为了更方便表达,Unicode的码点范围以16进制形式呈现。

那么对于Unicode的理解是否可以像ASCII字符集那样,一一对应去理解呢?

在本文,对于理解字符集的映射作用是可以这样思考的。但是,为了后续理解JS存储字符的规则,我们还需要继续深挖。请先思考以下问题:

1. 计算机存储器的基本存储单位是什么?

2. ASCII字符集是定长编码,其每个码点对应的字符在内存中固定占一个字节。Unicode字符集的中一个码点对应的字符应该占几个字节呢?

3. Unicode字符集中的码点数量如此庞大,如果每个码点都用固定字节数来表示,会带来什么问题?

回答在文末

二.Unicode字符集的编码格式

初步理解了:字符-数字映射 的规则,我们还需要理清楚计算机内部如何将字符转为二进制数字,这个过程称之为编码。

ASCII字符集采用固定1字节的编码方式。但是这个方式如果运用到Unicode字符集中,事情就变得糟糕了。假设Unicode的编码长度是固定的,那么意味着其编码的字节数(长度)会由最大的码点决定,而U+10FFFF(21位二进制)这个码点转化为二进制,在计算机中一共要占据 3 个字节,即 2^24 。由于编码长度固定,那么U+0000这个码点就也需要使用 3 个字节来表示。

是不是发现了问题?许多字符的码点用不着占3个字节,如果Unicode采用定长的编码格式,那么就会造成内存空间浪费,而内存空间又是十分昂贵的。

因此Unicode字符集标准采用变长和定长相结合的编码规则,提供了UTF-8、UTF-16、UTF-32的编码格式,用于不同情况的字符编码。

其中:

  • UTF-8(不定长):对于ASCII字符,每个字符只占用1个字节;对于其他字符,根据需要占用2到4个字节。这种设计使得UTF-8在大多数情况下比UTF-16和UTF-32更节省空间,尤其是在文本主要由ASCII字符组成的情况下。
  • UTF-16(不定长):对于BMP内的字符,每个字符占用2个字节;对于非BMP字符,每个字符占用4个字节。
  • UTF-32(定长):每个字符总是占用4个字节,无论字符是否复杂。这种设计虽然简单,但在大多数实际应用中会导致显著的空间浪费。

BMP:指的是基本多语言平面(Basic Multilingual Plane),是Unicode字符集的内容。简单了解一下:BMP的编码范围是从U+0000到U+FFFF(65535),从U+D800到U+DFFF之间的码点区段用于UTF-16编码中的代理对机制。

三.码点和码元

由于Unicode字符集的编码格式的特殊性,还需要增加一个码元的概念。

上面我们已经说过,字符对应的唯一数值即是码点(Code Point)。对于ASCII来说,简单的映射集让其中字符的码点就能作为编码的唯一形式,即字符的唯一标识符。

例如:

  • 拉丁字母“A”的Unicode码点是U+0041(16进制)。
  • 表情符号“😊”的Unicode码点是U+1F600(16进制)。

对于Unicode形式的编码对象(字符),其数据在内存的表现单位就是码元。由于是存储概念,我们常说一个码元多少位(bit),例如:

  • 在UTF-8中,一个码元是8位。
  • 在UTF-16编码中,一个码元是16位。
  • 在UTF-32中,一个码元是32位。

又或者这样说:在UTF-16的编码格式下,一个字符由n个码元构成,每个码元占2个字节。

讨论一下码点和码元的关系

他们的中间点是字符,码点是字符的抽象(数值)表示,码元是字符的内存表示。使用Unicode字符集标准编码时,会根据不同的编码格式(UTF-8/UTF-16/UTF-32),将字符对应的码点编码成一个或者多个码元。

示例

假设我们有一个表情符号😊,其Unicode码点是U+1F600,采用不同的编码格式编码:

  • UTF-8:会用4个8位码元(4个字节)来表示这个码点:F0 9F 98 80,即1个码元一个字节。
  • UTF-16:因为这个码点超出了基本多语言平面(BMP)最大码点能够表示的范围(65535),所以需要用2个16位(4个字节)的码元(代理对)来表示:D83D DE00(后文会提到如何计算),即一个码元2字节。
  • UTF-32:只需要1个32位的码元(4个字节)来表示这个码点:0001F600,即1个码元4字节。

四、JS中的字符

Javascript中采用Unicode字符集作为映射标准,并且采用UTF-16的编码格式对字符进行编码,即每个码元占16位(2个字节)。16位能够表示65535个字符,刚好涵盖了基本多语言平面(BMP)内的所有字符。

image.png

正常情况下,每一个码元都能够对应字符串String的一个字符,这样的特点也是String.prototype.length属性设计的一个依据。

没错!字符串的.length属性表达的是字符串在内存中码元的个数,一个码元2字节。

例如:

console.log("".length); //0
console.log("novalic".length); //7
console.log("掘金".length); // 2
console.log("😄".length); //2

其他的都不难理解,而其中́😄字符占两个码元,这种非BMP字符(对应码点值超过U+FFFF,后面简称辅助字符)都需要使用超过1个码元来表示,这里需要2个码元,这2个码元被称为代理对。

也就是说,在UTF-16编码格式下,需要两个码元表示的字符采用代理对的形式表达。

对于😄,其码点为'0x1f604',因为已经超过BMP字符的范围0xFFFF,属于辅助字符,因此需要将其使用代理对来表示。

具体来说,辅助字符码点范围是从U+10000到U+10FFFF。UTF-16将这个范围的码点映射到U+D800到U+DFFF之间的代码单元上,具体映射方式如下:

  1. 高代理项(High Surrogate) :范围是U+D800到U+DBFF,用于表示辅助字符码点的高10位。
  2. 低代理项(Low Surrogate) :范围是U+DC00到U+DFFF,用于表示辅助字符码点的低10位。

还是以😄为例,其码点为'0x1f604':

  • 减去U+10000,剩余U+0F604
  • 分为高10位:0x03E6;低10位:0x0004
  • 高代理项:0x03E6 + 0xD800 = 0xD83D
  • 低代理项:0x0004 + 0xDC00 = 0xDE04
  • 最终形成代理对 D83D DE04

检验:

image.png

JS提供了相关方法能够对字符进行编码和解码,在ES6以前,字符串的 charCodeAt()  方法返回一个整数,表示给定索引处的 UTF-16 码元,其值介于 0 和 65535 之间。

charCodeAt() 方法总是将字符串当作 UTF-16 码元 序列进行索引,因此它可能返回单独代理项(lone surrogate)。如果要获取给定索引处的完整 Unicode 码点,应当使用 codePointAt() 方法。

例如:

const c = '😄'console.log(c.charCodeAt(0).toString(16)); //D83D,对比之前的图可知道这只是第一个码元的值
console.log(c.codePointAt(0).toString(16)); //1F604

codePointAt()方法是ES6新增的一个方法,用于返回字符串给定位置的码点值,能够正确处理包括辅助平面(需要2个码元表示的字符)在内的Unicode字符。

看到这,咱应该就明白了字符在JS中的存储方式:

  • 大多数字符采用单码元存储。
  • 辅助字符采用双码元存储。

五、JS中对字符串的操作

前面的理论很枯燥,还是得来点实际需求,比如说:

  • 如何在JS中判断一个字符是否是BMP字符(一个码元能够表达)。
    //得到该字符的码点,看其是否小于65535(10进制)即可。
   function is16BitChar(char,index=0){
       return char.codePointAt(index) <= 0xffff;
   }
  • 如何计算一个字符串的实际字符个数(包含辅助字符的字符串)
    function charsCount(str){
        let count = 0;
        for(let i = 0 ; i < str.length ; i++){
            if(!is16bitChar(str,i)){//如果不是单码元字符,则跳过一个索引
                i++;
            }
            count++;
        }
        return count;
    }

其实一般的for循环是索引码元序列,所以对于占据两个码元的字符(辅助字符)来说,需要整体看来两个码元。

  • 其他有关JS中字符的处理方式。
    • 使用for...of遍历能够准确获取到每个双码元字符,因为字符串的构造对象是可迭代的,每次遍历以码点为单位,将可能的双码元当整体进行处理,这是字符串中可迭代对象的默认行为。

    • Array.from和...(扩展运算符)也是以码点为基础操作字符串对象。

    • for...in是以代码单元(码元)为索引。

六、组合字符

除了BMP字符(单码元)和辅助字符(双码元),还有一类组合字符,例如:🧑🏽‍🚒,👩‍❤️‍👨。

其是由多个码元组合而成:

console.log("🧑🏽‍🚒".length); //7

🧑🏽‍🚒 有4个部分:

  1. 🧑 - 一个人(2码元)
  2. 🏽 - 皮肤颜色(2码元)🧑🏽‍
  3. 🚒 - 消防车(2码元)
  4. 连接符 0x200D(1码元)

它们之间通过零宽度连接符(ZWJ)连接在一起,形成一个逻辑上的单个表情符号。

image.png

这种字符使用单纯的codePointAt(index)是没办法获取到正确码点值的,需要对这个字符进行遍历,没错,因为这种多码元字符只是逻辑上是一个整体,实际上并不是。

换句话来说,其不是简单的辅助字符,所以最外层表现形式不是以代理对为基础的,是更加复杂的形式。

不过我们依旧可以通过JS操作来验证上面的结果:

let str = "🧑🏽‍🚒";
const codeUnits = [];
for (const element of "🧑🏽‍🚒") {
    codeUnits.push(element.codePointAt(0).toString(16));
}
console.log(codeUnits);//[ '1f9d1', '1f3fd', '200d', '1f692' ];
//上面除了连接符,其余都是占据2码元的码点值,代表辅助字符,可拆分为具体的代理对形式。

这里的到了内部的码点值,继续将数组中的辅助字符码点值,分解为单码元,注意对非辅助字符的过滤。

//处理内部的双码元字符
const perChars = [];
codeUnits.forEach((item) => {
    let char = String.fromCodePoint('0x'+item);
        //不是零宽字符
    if(!is16BitChar(char)){
        //分别获取辅助字符码点的两个码元数据
        perChars.push([char.charCodeAt(0),char.charCodeAt(1)]);
    }
})

perChars.map((item) => {
    console.log(item[0].toString(16),item[1].toString(16));
})

/*
d83e ddd1
d83c dffd
d83d de92
*/

console.log('\ud83e\uddd1');//🧑
console.log('\ud83c\udffd');//🏽,这里显示有问题,但是结果是对的
console.log('\ud83d\ude92');//🚒

这是我们人为拆分组合字符(多码元字符)的步骤:

  • 得到字符所占码元个数,如果是多码元字符。
  • 使用for...of迭代组合字符得到每一部分值。
  • 对辅助字符(双码元字符)继续拆分,得到第一、第二个码元的内容。

当处理这样的组合字符时,JavaScript的迭代器(包括for...of循环、Array.from()、扩展运算符...等)不能够正确地将其识别成一个整体,例如之前的方法charsCount("🧑🏽‍🚒"),得到的结果是:4。这是因为之前那个方式最多只能计算双码元字符。

不过在表现形式上考虑,现代浏览器和JavaScript引擎实现了Unicode标准中的图形聚类算法(Grapheme Cluster Algorithm),该算法定义了如何将字符组合视为逻辑单元,也就是🧑🏽‍🚒这部分在逻辑上是一个整体,不过图形聚类算法的具体内容等用上了再学吧。

到这,差不多了。

tips:

在JS中,单码元和双码元的字符都只对应了一个码点,而组合字符严格来说不算是一个真正的字符,其表现成一个字符只不过是因为特殊的处理手段。所以我们主要学会使用for...of,codePointAt()等手段处理好单码元和双码元字符即可。

问题相关

1. 计算机存储器的基本存储单位是什么?

字节

2. ASCII字符集是定长编码,其每个码点对应的字符在内存中固定占一个字节。Unicode字符集的中一个码点对应的字符应该占几个字节呢?

应该根据编码格式决定,UTF-8的一个码点占1-4字节,UTF-16的一个码点占2-4字节,UTF-32的一个码点固定占4字节。

3. Unicode字符集中的码点数量如此庞大,如果每个码点都用固定字节数来表示,会带来什么问题?

会导致浪费内存空间

参考

相关术语:String - JavaScript | MDN

图片资来源:Yanni4Night