当我再次温习 JS 中字符串的相关知识时,结合平常码代码的一些操作,头脑中又迸发了一些疑惑。
这一篇博客便统一记录一下,我对于 JS 中字符串的两个疑问、以及网上的解答,以便日后来进行查阅。
目录:
- 为何 JS 中字符串也会像对象那样,有方法可以调用?
- 字符串涉及 Unicode 字符时,为何长度的获取会如此混乱,如何统一解决?
字符串也是对象?
引子
在 JavaScript 里面,对象 (object) 是个频繁出现的东西。
在 ES6 的面向对象之前,我们提到的对象还不包含类 (class) 的概念,通常是自定义对象、内置对象和浏览器对象。对象包含属性和方法,出于方便,我们可以自由地去设置并调用。
但是,当使用字符串时,我有一个疑惑:
1 | var str = 'andy'; |
比如:在以上的代码中,为何 str
是一个字符串,却像对象那样有相应的方法可以调用呢?
答案是 JS 默认把我们的一些简单数据类型,包装成了复杂数据类型。
“基本包装类型”是什么
数据类型
在 JavaScript 中,数据类型有基本类型和引用类型之分。
基本类型:
undefined
、null
、string
、number
、boolean
、symbol
(ES6 之后)特殊基本包装类型:
String
、Number
、Boolean
引用类型:
Object
、Array
、RegExp
、Date
、Function
其中,“基本类型”和“引用类型”的区别是:引用类型值可添加属性和方法,而基本类型值则不可以。
我们可以注意到,为了方便我们操作基本数据类型,JS 提供了三个特殊的引用类型:String
、Number
和 Boolean
.
基本包装类型
基本包装类型的概念就是将简单数据类型包装成复杂数据类型,这样基本数据类型就有了相应的属性和方法。
也就是说,看似简单的一行 var str = 'andy'
,在 JS 内部解释器的执行过程其实是这样的:
1 | // 1. 生成临时变量,把简单类型包装为复杂数据类型 |
这样一来,变量 str
便有了 String
这特殊基本包装类型的属性和方法,也解释了我们心中的疑惑。
至于这里为何是 String
,而不是 new String
,我们稍后再说。
字符串“不可变”
由于 String
类型并非普通数据类型,这就涉及到了一个和对象相似的概念:字符串“不可变”。
字符串不可变,指的是里面的值是不变的。虽然看上去可以改变内容,但其实是地址变了,内存中开辟了一个新的内存单元。
1 | var str = 'abc'; |
由于字符串的不可变性,我们在裁切字符串的时候也许不会分配内存空间 (参见 MDN),但在大量拼接字符串的时候很可能会有效率问题:
1 | var str = ''; |
为何不是 new String
?
通过上述的过程,我们知道在 JS 中:
每当读取一个基本类型值的时候,后台就会自动创建一个对应的基本包装类型的对象。而且,此对象只存在于一行代码的执行瞬间,然后立即被销毁。
至于为何不用 new
,而是直接调用 String('andy')
或者 Number(1)
,其原因是:
把字符串,数字,布尔值传给构造函数的话,会得到对应的实例,如果对基本包装类型的实例调用 typeof
则会返回 object
。所以,使用 new
操作符调用基本包装类型的构造函数,与直接调用同名的转型函数是不一样的。
我们可以用以下的代码来进行测试:
1 | var value = '123456'; |
当 string
包含 Unicode 字符…
(一点点) Unicode 常识 (原文链接)
在深入研究 JavaScript 之前,先解释一下 Unicode 一些基础知识,这样在 Unicode 方面,我们至少都了解一些。
Unicode 是目前绝大多数程序使用的字符编码,定义也很简单,用一个码位 (code point) 映射一个字符。码位值的范围是从 U+0000
到 U+10FFFF
,可以表示超过 110 万个字符。下面是一些字符与它们的码位。
码位通常被格式化为十六进制数字,零填充至少四位数,格式为 U+前缀
。
Unicode 最前面的 65536 个字符位,称为基本多文种平面 (BMP - Basic Multilingual Plane, 又简称为 plane 0, 零号平面),它的码位范围是从
U+0000
到U+FFFF
。最常见的字符都放在这个平面上,这是 Unicode 最先定义和公布的一个平面。
剩下的字符都放在 辅助平面 (Supplementary Plane) 或者**星形平面 (astral planes)**,码位范围从
U+010000
一直到U+10FFFF
,共 16 个辅助平面。辅助平面内的码位很容易识别:如果需要超过 4 个十六进制数字来表示码位,那么它就是一个辅助平面内的码。
比如说:
- © 的码位是
U+00A9
,在U+0000
到U+FFFF
之间,它是在基本平面上的字符。 - 😂 的码位是
U+1F602
,在U+010000
一直到U+10FFFF
之间,它是在辅助平面上的字符。
现在对 Unicode 有了基本的了解,接下来看看它如何应用于 JavaScript 字符串。
转义字符
要在 JavaScript 的字符串中表示 Unicode 字符,我们通常使用转义符 \u
。
打开 Chrome 的调式工具,在 Console 中输入带有转义符的字符串,可以得到我们的转义结果:
1 | >> '\u0041\u0042\u0043' |
这些被称为 Unicode 转义序列。它们由表示码位的 4 个十六进制数字组成。例如,\u2661
表示码位为 \U+2661
的字符——一个爱心。这种方法可以用于 U+0000
到 U+FFFF
范围内的码位,即整个基本平面。
但是其他的所有辅助平面呢?我们需要 4 个以上的十六进制数字来表示它们的码位,那么如何转义它们呢?
在 ECMAScript 6 中,这很简单,因为它引入了一种新的转义序列:Unicode 码位转义。例如:
1 | >> '\u{41}\u{42}\u{43}' |
在大括号之间可以使用最多 6 个十六进制数字,这足以表示所有 Unicode 码位。
但是为了向后兼容 ECMAScript 5 和更旧的环境,不幸的解决方案是使用代理对:
1 | '\ud83d\ude02' |
在这种情况下,每个转义项表示代理项,是最终”辅助平面“字符的一半的码位。这样的两个代理项便组成一个辅助码位。
注意
代理项对的码位与原始码位不相同。
有公式可以根据给定的辅助码位来计算代理项对码位,反之亦然——根据代理对计算原始辅助码位。
TLDR:如果有辅助码位 C
,那么其两个代理对码位 H
和 L
的计算方法为
1 | H = Math.floor((C - 0x10000) / 0x400) + 0xD800 |
反之,如果有两个代理项的码位 H
和 L
,则组成的辅助码位 C
为
1 | C = (H - 0xD800) * 0x400 + L - 0xDC00 + 0x10000 |
那么问题就出来了…
假设你想要计算给定字符串中的字符个数。你会怎么做呢?
首先想到可能是使用 length
属性。
1 | >> 'A'.length |
由于 JavaScript 内部 (ES5 之前) 将辅助平面内的字符表示为 2 个代理对,并将单独的代理对认作单独的 “字符”,所以如果仅使用 ECMAScript 5 兼容的转义序列来表示字符,将看到其 length
属性为 2。这是十分令人困惑的,因为人们通常只能看到一个 Unicode 字符或图形。
那么我们该如何区分出来辅助平面的字符呢?以下有几个典型的 case.
字符串长度
我们可以用正则匹配来找出这些代理对,把两个代理对替换成一个字符,然后再用 length
来得出字符串长度。这种方法是兼容 ES5 的:
1 | var regexAstralSymbols = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g; |
ES6 中有 Array.from()
方法,它能从一个类似数组或可迭代对象,创建一个新的、浅拷贝的数组实例。这个方法自身能识别出字符串中的代理对:
1 | function countSymbols(string) { |
取字符串
同样地,使用 Array.from()
,我们能很方便地从带有 Unicode 字符的 String 中,取出我们想要的字符。此时,ES6 以下的兼容方法是使用相应的 polyfill.
1 | function slice(string, start, end) { |
字符串翻转
字符串翻转的实现也同理:
1 | function reverse(string) { |
还有一个问题…
上述方法虽好,但是却不能解决组合标记的问题:
1 | function countSymbols(string) { |
比如在上述代码中,str1
和 str2
看上去都是 señorita。但是在获取长度的时候,一个字符串长为 9,另一个则是 10.
这个问题看上去并不是很大,但是如果我们将上述字符串翻转的话:
1 | function reverse(string) { |
在 ñ
是单个字符的情况下,字符串翻转一切正常。
但在 ñ
为两个字符的情况下,字符串在翻转之后,n
上面的波浪号竟然跑到了 o
的上面!这可咋办?
答案则是**规范化 (normalize)**。
我们可以利用 String.prototype.normalize
,使 str1
和 str2
这两个格式相互转化。如果在需要翻转的时候,我们通常把字符串统一为单字符 (即 str1
) 的格式。