浅谈JS中奇怪的字符串

当我再次温习 JS 中字符串的相关知识时,结合平常码代码的一些操作,头脑中又迸发了一些疑惑。

这一篇博客便统一记录一下,我对于 JS 中字符串的两个疑问、以及网上的解答,以便日后来进行查阅。

目录:

  1. 为何 JS 中字符串也会像对象那样,有方法可以调用?
  2. 字符串涉及 Unicode 字符时,为何长度的获取会如此混乱,如何统一解决?

字符串也是对象?

引子

在 JavaScript 里面,对象 (object) 是个频繁出现的东西。

在 ES6 的面向对象之前,我们提到的对象还不包含类 (class) 的概念,通常是自定义对象内置对象浏览器对象对象包含属性和方法,出于方便,我们可以自由地去设置并调用。

但是,当使用字符串时,我有一个疑惑:

1
2
var str = 'andy';
console.log(str.length); // 或者 console.log('andy'.length);

比如:在以上的代码中,为何 str 是一个字符串,却像对象那样有相应的方法可以调用呢?

答案是 JS 默认把我们的一些简单数据类型,包装成了复杂数据类型

“基本包装类型”是什么

数据类型

在 JavaScript 中,数据类型有基本类型和引用类型之分。

  • 基本类型:undefinednullstringnumberbooleansymbol (ES6 之后)

    特殊基本包装类型:StringNumberBoolean

  • 引用类型:ObjectArrayRegExpDateFunction

其中,“基本类型”和“引用类型”的区别是:引用类型值可添加属性和方法,而基本类型值则不可以。

我们可以注意到,为了方便我们操作基本数据类型,JS 提供了三个特殊的引用类型:StringNumberBoolean.

基本包装类型

基本包装类型的概念就是将简单数据类型包装成复杂数据类型,这样基本数据类型就有了相应的属性和方法。

也就是说,看似简单的一行 var str = 'andy',在 JS 内部解释器的执行过程其实是这样的:

1
2
3
4
5
6
// 1. 生成临时变量,把简单类型包装为复杂数据类型
var temp = String('andy'); // *
// 2. 赋值给我们声明的字符变量
str = temp;
// 3. 销毁临时变量
temp = null;

这样一来,变量 str 便有了 String特殊基本包装类型的属性和方法,也解释了我们心中的疑惑。

至于这里为何是 String,而不是 new String,我们稍后再说。

字符串“不可变”

由于 String 类型并非普通数据类型,这就涉及到了一个和对象相似的概念:字符串“不可变”。

字符串不可变,指的是里面的值是不变的。虽然看上去可以改变内容,但其实是地址变了,内存中开辟了一个新的内存单元。

1
2
3
4
var str = 'abc';
str = 'hello';
// 当重新给 str 赋值的时候,常量 'abc' 不会被修改,依然在内存中
// 重新给字符串赋值,会重新在内存中开辟空间,这个特点就是字符串的不可变

由于字符串的不可变性,我们在裁切字符串的时候也许不会分配内存空间 (参见 MDN),但在大量拼接字符串的时候很可能会有效率问题:

1
2
3
4
5
var str = '';
for (var i = 0; i < 10000000; i++) {
str = '1' + str;
}
console.log(str); // 这个结果需要花费大量的时间来显示,因为需要不断开辟新空间

为何不是 new String

通过上述的过程,我们知道在 JS 中:

每当读取一个基本类型值的时候,后台就会自动创建一个对应的基本包装类型的对象。而且,此对象只存在于一行代码的执行瞬间,然后立即被销毁。

至于为何不用 new,而是直接调用 String('andy') 或者 Number(1),其原因是:

把字符串,数字,布尔值传给构造函数的话,会得到对应的实例,如果对基本包装类型的实例调用 typeof 则会返回 object。所以,使用 new 操作符调用基本包装类型的构造函数,与直接调用同名的转型函数是不一样的。

我们可以用以下的代码来进行测试:

1
2
3
4
5
6
7
8
9
10
var value = '123456';
var num = Number(value);
console.log(typeof num); // number
var numObj = new Number(value);
console.log(typeof numObj); // object

// 把字符串,数字,布尔值传给构造函数就会创建相应的实例,
// 对基本包装类型的实例调用 typeof 会返回 object

console.log(num.toString()); // 测试一下方法

string 包含 Unicode 字符…

(一点点) Unicode 常识 (原文链接)

在深入研究 JavaScript 之前,先解释一下 Unicode 一些基础知识,这样在 Unicode 方面,我们至少都了解一些。

Unicode 是目前绝大多数程序使用的字符编码,定义也很简单,用一个码位 (code point) 映射一个字符。码位值的范围是从 U+0000U+10FFFF,可以表示超过 110 万个字符。下面是一些字符与它们的码位。

码位通常被格式化为十六进制数字,零填充至少四位数,格式为 U+前缀

  • Unicode 最前面的 65536 个字符位,称为基本多文种平面 (BMP - Basic Multilingual Plane, 又简称为 plane 0, 零号平面),它的码位范围是从 U+0000U+FFFF

    最常见的字符都放在这个平面上,这是 Unicode 最先定义和公布的一个平面。

  • 剩下的字符都放在 辅助平面 (Supplementary Plane) 或者**星形平面 (astral planes)**,码位范围从 U+010000 一直到 U+10FFFF,共 16 个辅助平面。

    辅助平面内的码位很容易识别:如果需要超过 4 个十六进制数字来表示码位,那么它就是一个辅助平面内的码。

比如说:

  • © 的码位是 U+00A9,在 U+0000U+FFFF 之间,它是在基本平面上的字符。
  • 😂 的码位是 U+1F602,在 U+010000 一直到 U+10FFFF 之间,它是在辅助平面上的字符。

现在对 Unicode 有了基本的了解,接下来看看它如何应用于 JavaScript 字符串。

转义字符

要在 JavaScript 的字符串中表示 Unicode 字符,我们通常使用转义符 \u

打开 Chrome 的调式工具,在 Console 中输入带有转义符的字符串,可以得到我们的转义结果:

1
2
3
4
5
>> '\u0041\u0042\u0043'
'ABC'

>> 'I \u2661 JavaScript!'
'I ♡ JavaScript!

这些被称为 Unicode 转义序列。它们由表示码位的 4 个十六进制数字组成。例如,\u2661 表示码位为 \U+2661 的字符——一个爱心。这种方法可以用于 U+0000U+FFFF 范围内的码位,即整个基本平面

但是其他的所有辅助平面呢?我们需要 4 个以上的十六进制数字来表示它们的码位,那么如何转义它们呢?

在 ECMAScript 6 中,这很简单,因为它引入了一种新的转义序列:Unicode 码位转义。例如:

1
2
3
4
5
>> '\u{41}\u{42}\u{43}'
'ABC'

>> '\u{1F602}'
'😂'

在大括号之间可以使用最多 6 个十六进制数字,这足以表示所有 Unicode 码位。

但是为了向后兼容 ECMAScript 5 和更旧的环境,不幸的解决方案是使用代理对:

1
2
'\ud83d\ude02'
'😂'

在这种情况下,每个转义项表示代理项,是最终”辅助平面“字符的一半的码位。这样的两个代理项便组成一个辅助码位。

注意

代理项对的码位与原始码位不相同。

有公式可以根据给定的辅助码位来计算代理项对码位,反之亦然——根据代理对计算原始辅助码位。

TLDR:如果有辅助码位 C,那么其两个代理对码位 HL 的计算方法为

1
2
H = Math.floor((C - 0x10000) / 0x400) + 0xD800
L = (C - 0x10000) % 0x400 + 0xDC00

反之,如果有两个代理项的码位 HL,则组成的辅助码位 C

1
C = (H - 0xD800) * 0x400 + L - 0xDC00 + 0x10000

那么问题就出来了…

假设你想要计算给定字符串中的字符个数。你会怎么做呢?

首先想到可能是使用 length 属性。

1
2
3
4
>> 'A'.length
1
>> '😂'.length
2

由于 JavaScript 内部 (ES5 之前) 将辅助平面内的字符表示为 2 个代理对,并将单独的代理对认作单独的 “字符”,所以如果仅使用 ECMAScript 5 兼容的转义序列来表示字符,将看到其 length 属性为 2。这是十分令人困惑的,因为人们通常只能看到一个 Unicode 字符或图形。

那么我们该如何区分出来辅助平面的字符呢?以下有几个典型的 case.

字符串长度

我们可以用正则匹配来找出这些代理对,把两个代理对替换成一个字符,然后再用 length 来得出字符串长度。这种方法是兼容 ES5 的:

1
2
3
4
5
6
7
8
9
var regexAstralSymbols = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g;

function countSymbols(string) {
return string
// Replace every surrogate pair with a BMP symbol.
.replace(regexAstralSymbols, '_')
// …and *then* get the length.
.length;
}

ES6 中有 Array.from() 方法,它能从一个类似数组或可迭代对象,创建一个新的、浅拷贝的数组实例。这个方法自身能识别出字符串中的代理对:

1
2
3
function countSymbols(string) {
return Array.from(string).length;
}
取字符串

同样地,使用 Array.from(),我们能很方便地从带有 Unicode 字符的 String 中,取出我们想要的字符。此时,ES6 以下的兼容方法是使用相应的 polyfill.

1
2
3
function slice(string, start, end) {
return Array.from(string).slice(start, end).join('');
}
字符串翻转

字符串翻转的实现也同理:

1
2
3
function reverse(string) {
return Array.from(string).reverse().join('');
}

还有一个问题…

上述方法虽好,但是却不能解决组合标记的问题:

1
2
3
4
5
6
7
8
9
10
function countSymbols(string) {
return Array.from(string).length;
}
// ----------------------------------
var str1 = '😹se\xF1orita';
var str2 = '😹sen\u0303orita';
console.log(str1); // 😹señorita
console.log(countSymbols(str1)); // 9
console.log(str2); // 😹señorita
console.log(countSymbols(str2)); // 10

比如在上述代码中,str1str2 看上去都是 señorita。但是在获取长度的时候,一个字符串长为 9,另一个则是 10.

这个问题看上去并不是很大,但是如果我们将上述字符串翻转的话:

1
2
3
4
5
6
7
8
9
10
function reverse(string) {
return Array.from(string).reverse().join('');
}
// ----------------------------------
var str1 = '😹se\xF1orita';
var str2 = '😹sen\u0303orita';
console.log(str1); // 😹señorita
console.log(reverse(str1)); // atiroñes😹
console.log(str2); // 😹señorita
console.log(reverse(str2)); // atirõnes😹

ñ 是单个字符的情况下,字符串翻转一切正常。
但在 ñ 为两个字符的情况下,字符串在翻转之后,n 上面的波浪号竟然跑到了 o 的上面!这可咋办?

答案则是**规范化 (normalize)**。

我们可以利用 String.prototype.normalize,使 str1str2 这两个格式相互转化。如果在需要翻转的时候,我们通常把字符串统一为单字符 (即 str1) 的格式。