这篇文章是对ES6之后字符串扩展知识的思考和总结,本文不会聚焦于API,而是着重于介绍字符这一概念的基础表示。
关键词 : 字符集,编码格式,Unicode,码元,码点等。
一、计算机中的字符
JS中的字符在后文会提到。
1.字符集的概念
学过计算机组成原理知道,数字在计算机内存中存储形式是二进制。其实字符最终也会以二进制的形式存储在计算机内存中。但是,字符毕竟不能和数字进行数学运算(类似进制转换的操作),所以我们需要人为规定一个字符-数字映射表,也叫做字符集。在计算机层面,我们也将字符叫做字符集定义的符号。
2.ASCII字符集
耳熟能详的字符集规则有:ASCII,这是一个以7个比特位(bit)表达的字符集,一共能表示128(2^7)个字符,后续增加到8个比特位,额外增加了128个字符。对应规则就像下面这样:
一一对应的关系,十分清晰。上述字符集中,字符数据的部分我们常称之为码点(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)内的所有字符。
正常情况下,每一个码元都能够对应字符串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之间的代码单元上,具体映射方式如下:
- 高代理项(High Surrogate) :范围是U+D800到U+DBFF,用于表示辅助字符码点的高10位。
- 低代理项(Low Surrogate) :范围是U+DC00到U+DFFF,用于表示辅助字符码点的低10位。
还是以😄为例,其码点为'0x1f604':
- 减去U+10000,剩余U+0F604
- 分为高10位:0x03E6;低10位:0x0004
- 高代理项:0x03E6 + 0xD800 = 0xD83D
- 低代理项:0x0004 + 0xDC00 = 0xDE04
- 最终形成代理对 D83D DE04
检验:
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个部分:
🧑
- 一个人(2码元)- 🏽 - 皮肤颜色(2码元)🧑🏽
🚒
- 消防车(2码元)- 连接符 0x200D(1码元)
它们之间通过零宽度连接符(ZWJ)连接在一起,形成一个逻辑上的单个表情符号。
这种字符使用单纯的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