概述
String
对象是 JavaScript 的标准内置对象。用于存储和处理文本数据,它具有类数组 (like-array) 的特点,以字符序列的形式来操作每个字符,因此被称之为“字符串对象”。
“字符序列”中的每个字符有着与数组元素相同的索引访问方式,也有着类似的 length
属性获取字符串的长度。(注意,并非视觉上的字符个数,而是其 UTF-16
码元序列的长度)。
'hello'.length; //5
对于空字符串其长度 length
为 0。
var s = '';
s[0]; //undefined;
s.length; //0
和其它语言相比,JavaScript 使用字符串类型来表示所有形式的文本数据,并没有单个字符的 char
类型。
访问字符
因为具有类数组的特性,所以可以通过下标索引语法来访问字符串(字符序列)中特定位置的字符。
"hello"[0]; // 'h'
但字符序列中的每个元素都是不可写(writable
)、不可枚举(enumerable
)、不可配置(configurable
),所以对其进行删除或赋值行为都会失败。
Object.getOwnPropertyDescriptor('hello', 0);
/*
{
"value": "h",
"writable": false,
"enumerable": true,
"configurable": false
}
*/
另一种访问字符的方式,就是使用字符串对象的实例方法 charAt(pos)
(继承自 String.prototype
原型对象)。
var greet = 'hello';
greet.charAt(0); // 'h'
字符串字面量
字符串字面量是创建字符串的常用形式。支持单引号/双引号/反引号(模板字符串)
var s = 's';
var s1 = "s1";
var s2 = `s2`;
也可以调用 String()
方法,以类型转换的方式创建字符串字面量值:
var greet = String('hello');
字符串对象
将 String()
方法作为构造函数调用,可以创建字符串对象:
var greet = new String('hello'); //String {'hello'}
你应该基本不会将
String
作为构造函数使用。
自动装箱与包装对象
“字符串字面量”与“字符串对象”的显著区别就体现在类型上:
var foo = 'foo';
var bar = new String('bar');
typeof foo; // 'string'
typeof bar; // 'object'
可以发现,变量 foo
是基本类型的 string
;而变量 bar
的类型是 object
,一个字符串的对象类型。虽然类型不一致,但 JavaScript 依然允许你使用 foo
来调用字符串对象的实例方法或属性:
foo.toUpperCase(); // FOO
这种机制就被称之为“自动装箱”。它确保了基本类型字符串能够像字符串对相同的使用方式(指调用方法与读取属性),而不需要显式地将它们转换为字符串对象。类似的机制也适用于其他基本类型,如数字和布尔值。
当在字符串字面量上调用方法或属性时,JavaScript 会自动包装原始字符串将其创建为字符串对象,然后在该包装对象上调用实例的方法或属性的读取,这是因为字符串字面量本身是基本数据类型,不具备对象的实例方法和属性,必须要创建一个临时的包装对象(字符串对象)。当执行完毕后,JavaScript 就会销毁这个临时的字符串对象,回归到原始字符串的字面量形式。
我们不建议使用“字符串对象”,因为“字符串字面量”的性能更好,在存储时 JavaScript 也会特别的对其进行优化,占用更少的内存空间,其次在类型判断上也不会产生意外行为。
字符串拼接
- 使用
+
或+=
运算符。 - 使用模板字符串
${xx}
字符串对比
- 使用条件运算符进行字符串的大小对比。
- 不区分大小写对比。先使用
toUpperCase()
或toLowerCase()
统一转换为大写或小写,最后再进行对比。 - 区分大小写对比,直接对比判断,不进行转换。
对比字符串的另一特殊考虑因素在于区域化差异。例如德国字母 ß
在不方便的输入的场景下可以用 ss
替代,因此在德国二者是相等的,但是对于 Unicode
字符集而言,这是两个不同的字符,直接判断便会返回 false
。
var eszett = 'ß';
eszett === 'ss'; //fase
对此,更推荐的方法是使用具有本地化的 API:
localeCompare(str, locale, options)
Intl.Collator
eszett.localeCompare('ss', 'de', {sensitivity:'accent'}); //1
类型转换
以普通函数的方式调用 String()
方法可以将传入的参数转换为字符串字面量值。
- 对于
string
保持原样。 - 对于
undefined
转换为"undefined"
。 - 对于
null
转换为"null"
。 - 对于
Boolean
, 转换为"true"
和"false"
。 - 对于
Number
,使用与Number(10).toString()
相同算法转换数字。 - 对于
Symbol
和Object
,依次调用对象的toString() -> valueOf()
获取对象的原始值(hintstring
即可),然后再将原始值转换为字符串字面量。
var o = new Object();
o.toString(); // '[object Object]'
String(o); //'[object Object]'
除了强制类型转换外,JavaScript 还支持隐式类型转换,这是一种不好的编程范式,应当避免。值得一提的是,在隐式类型转换时,如果转换的是 Symbol
类型会抛出TypeError
错误。
'' + Symbol('symbol desc'); // Uncaught TypeError: Cannot convert a Symbol value to a string
转义字符
转义字符用于在字符串中表示一些不能直接输入的特殊字符。在语法上以反斜杠 \
开头,后面加一个字符,此时该字符就不再具有其字面含义,而是对实际用途的代理。
转义字符常用与转义特殊符号,比如引号(单引号、双引号、反引号)和转义符号 \
本身。这些符号都属于 JavaScript 语法保留的特殊符号,无法被直接使用,因此需要转义。
var s = '\''; // 在字符串中转义单引号
var s2 = "\'"; // 在字符串中转义双引号
var s3 = '\\'; // 在字符串中转义反斜杠
除此之外,转义字符还被常用于转义 ASCII
码中定义的控制字符,例如:
\n
: 换行\t
: 水平制表符\r
: 回车符
var text = '1\n2';
换行回车在
Windows
中的转义字符为:\r\n
;在Linux/Macos
中为\n
。
最后,转义字符还能转义 Unicode 编号和编码,对于 U+00 ~ U+FF
范围的,可以通过 \xXX
的格式转义:
var s1 = "\x65"; // e
var s2 = "\xab"; // «
对于 U+0000 ~ U+FFFF
范围的 Unicode
字符,可以使用 \u
格式进行转义:
var u = '\u4E25'; //严
而对于超出以上范围的其它补充平面 SMP
中的字符,最新的 ES6 语法支持 \u{}
格式进行转义:
var rainbow = '\u{1f308}'; // 🌈
如果要转义的是 Unicode
编码,因为 UTF-32
编码与 Unicode
字符编号实质上是等同的,因此按照 Unicode
编号的方式转义即可。
如果要转义的是 UTF-16
编码,对于使用代理对表示的字符,需要同时给定两个 UTF-16
编码的码元序列。
var rainbow = '\uD83C\uDF08'; // 🌈
如果是 UTF-8
编码,则无法转义,这是因为在 JavaScript 中字符串类型其底层使用的是 UTF-16
编码来表示和存储字符的。
字符串编码
存储编码
“字符串”是由一组不可变的字符序列组成。序列中的每个字符都来自 Unicode
字符集,采用 UTF-16
编码方案存储,每个字符占 2 或 4 个字节,由 16 或 32 位的二进制表示。
代理对
根据 UTF-16
编码规定,如果字符位于 Unicode
基本多语种平面 (BMP
) 范围内,则使用 2 个字节 16 位二进制数的码元表示,由于 2^16-1
正好与 BMP
的字符范围 U+0000 ~ U+FFFF
相等,因此在此范围内的字符其 Unicode
编号就是 UTF-16
编码。举例说明,汉字“严”其 Unicode
编号与 UTF-16
编码都是 4E25
。
对于其它补充平面 SMP
中的字符(范围: U+10000 ~ U+1FFFF
),因 16 位二进制数无法足够表示,便需要使用 4 个字节,32 位长度,也就是一对 16 位二进制数的码元序列来表示,像这样的两个 16 位长的码元序列就被称之为 “代理对(Surrogate Pairs)”。
代理对用来表示超过 U+FFFF
范围的 Unicode
字符,分别由高位代理和低位代理组成。例如,彩虹 🌈 的 Unicode
编号是 U+1F308
,其 UTF-16
编码分别由高位的 U+D83C
和低位 U+DF08
组成的代理对,你可以通过 charCodeAt()
方法来获取字符底层对应存储的 UTF-16
编码:
var u = '严';
u.charCodeAt().toString(16).toUpperCase(); //4E25
而彩虹 🌈 表情属于 SMP
平面中的字符,需要两个 UTF-16
码元序列组成的代理对来表示,因此在调用 charCodeAt(index)
方法时必须要指定索引来分别获取高位和低位代理。
var rainbow = '🌈';
rainbow.charCodeAt(0).toString(16).toUpperCase(); // D83C
rainbow.charCodeAt(1).toString(16).toUpperCase(); // DF08
所以彩虹 🌈 在 JavaScript 字符串底层,实际上是使用一对 UTF-16
码元序列组成的代理对来表示的,即 \uD83C\uDF08
。
长度危机
一个很糟糕的消息,在 ES5 及其之前时代,字符串对象提供的所有方法和属性均只能正常作用于 16 位码元表示的字符,对于代理对不会进行额外的处理,所以在处理 SMP
平面中的字符时就会产生一些特别的问题,需要多加小心。
// 并没有返回彩虹,而是返回了代理对的高位编码
'🌈'.charAt(0); //\uDF08
// 截取子串时也没有正常工作。
'🌈'.substring(0, 1); // \uDF08
// 拆分字符串时,直接返回了代理对的编码序列
'🌈'.split(''); //['\uD83C', '\uDF08']
// 静态方法 fromCharCode 只支持 4 个字节的 UTF-16 编码。
String.fromCharCode(0x4e25); // '严'
// 超过 `U+FFFF` 的 Unicode 编号就会返回乱码
String.fromCharCode(0x1f308); // ''
因为只能识别 16 位长度的码元序列,所以能够直接处理的字符个数便是 2^16 次方,也就是 65536 个字符,在
Unicode
字符集中的表示范围便是U+0000 ~ U+FFFF
,也就是基本平面(BMP
) 的范围。
除了字符串的方法不能正常的作用在代理对表示的字符外,就连使用索引来直接访问具有代理对的字符也会存在问题:
'严'[0]; // 严
'🌈'[0]; // \uDF08
既然索引不支持具有代理对字符的访问,那么在循环遍历字符串时自然也会受到影响:
var rainbow = "🌈";
for (var i = 0; i < rainbow.length; i++) {
console.log(rainbow[i]);
}
会发现循环执行了两次,输出的也都是乱码(代理对的代理伪字符)。这是因为字符串的长度 length
属性也只识别单个 UTF-16
码元所表示的字符,对于代理对表示的字符,会返回其码元序列的长度(length 的实际功能),而非视觉上独立的字符个数。
var s = '严';
var s1 = '🌈';
s.length; // 1
s1.length; // 2 (表示高低两个代理位)
受限于 JavaScript 引擎底层对字符串编码的处理机制,在 ES5 时代,我们能应对的方法非常有限(指对 SMP
平面中的字符),至少通过字符串自身的方式是行不通的(字符串对象自身的方法和属性均不能正确识别和处理两个 16 位码元序列组成的代理对),因此,必须要另辟蹊径!例如通过正则来分别匹配字符串中 BMP
和 SMP
的字符,然后转换为数组,再统计数组元素的数量,间接计算视觉上独立的字符个数。
const regexp = /([\uD800-\uDBFF][\uDC00-\uDFFF]|[\u0000-\uFFFF])/g;
const matches = "🌈看遍彩虹,吃遍彩虹!".match(regexp);
matches.length; //11
\uD800-\uDBFF
是低代理位的编码范围,\uDC00-\uDFFF
为高代理位的编码范围,它们是UTF-16
编码规则中代理对的固定前缀。
ES6 对字符编码的增强
ES6 为字符串新增了两个支持 UTF-16
代理对的新方法
codePointAt()
实例方法。根据给定的索引返回对应字符的 Unicode 码点值
(注意,是 Unicode
字符编号,而非 UTF-16
码元或码元序列)。
var rainbowCode = '彩虹🌈'.codePointAt(2).toString(16).toUpperCase(); //1F308
需要注意的是,字符索引本身还是基于单个 UTF-16
码元。
String.fromCodePoint()
静态方法。据给定的 Unicode
编号或 UTF-16
码元序列返回一个字符。
String.fromCodePoint(parseInt(rainbowCode,16)); // 🌈
String.fromCodePoint(0xD83C, 0xDF08); // 🌈
Symbol.iterator
ES6 还为 String.prototype[Symbol.iterator]()
内置了默认的迭代器方法。它按 Unicode
码点迭代字符串。因此你可以在支持迭代器的语法中,比如 展开语法
、for...of
等以 Unicode
字符为基本单位来遍历字符串,而非以 UTF-16
码元为基本单位来遍历,从而避开 UTF-16
代理对问题。
var emoji = '🌈';
[...emoji].length; //1
var len = 0;
for(const char of emoji){ ++len }
另外,Array.from()
方法支持通过迭代器或类数组中创建一个新的数组实例,所以也可以拿来用于统计字符个数。
Array.from('🌈').length; // 1
字符簇
那么,字符串的长度危机解除了么?答案:并不是!
除了 Unicode
补充平面(SMP
) 中的字符需要多对码元序列来表示外,还存在着一种特殊的变体字符。它们在视觉上虽然是独立的单位,但在实际构成上是由多个独立的 Unicode
字符拼接组合而成的,这种字符就是“字符簇 [^2] (Grapheme Cluster)” 。其中,最常见的便是变体 emoji 表情符号了。
“字符簇”与“代理对”在构成概念上很类似。一个是由多个
Unicode
字符构成的集合,另一个则由两个UTF-16
码元序列组成的代理对。
const flag = '🏳️🌈';
// ['🏳', '️', '', '🌈']
const unicodeUnit = Array.from(flag);
//[["d83c", "dff3"], ["fe0f"], ["200d"], ["d83c", "df08"]];
const utf16CodePoint = unicodeUnit.map((unicode) => {
return unicode.split("").map((code) => {
return code.charCodeAt(0)?.toString(16);
});
});
🏳️🌈 彩虹旗是一个字符簇表情,先通过 Array.from()
方法以 Unicode 字符为基本单位来遍历字符串,接着对单个 Unicode
字符调用 split("")
方法,获取可能存在的代理对字符,最后再获取每个字符的 16 进制值。
可以看到,彩虹旗 🏳️🌈 是由 4 个独立 Unicode
字符组合而成的字符簇表情符号。第一个字符是一个代理对字符,其 UTF-16
码元序列为 0xD83C 0xDFF3
,对应的是 Unicode
字符集中的 🏳 白旗符号。
第二、第三个字符构成了字符簇的结构和样式,其中 0xFE0F
属于“修饰符”,用于指定前面的字符应当以彩色或表情展示,而非无色的文本样式;0x200D
属于连字符(zero-width joiner, 零宽度连字符),它用于连接两个独立的 Unicode
字符,构成字符簇的结构,可以形象的将其理解成一种神奇的胶水字符。
一个额外的拓展,
\u2764
为黑色 ❤,通过组合修饰符\u2764\uFE0F
便会得到一个彩色的爱心 ❤️。这里因为没有连接具有视觉上独立Unicode
字符,因此无需使用连字符U+200D
。
最后又是一对代理对字符,分别是 0x083C 0xDF08
,对应 emoji 表情为 🌈。
使用 JavaScript 处理字符簇将会更加困难,不论是 for...of
语句还是 Array.from()
方法它们底层 [Symbol.iterator]
都是按 Unicode
字符为基本单位进行遍历的,因此在遍历时,循环的次数将会比预想的多。
此时,更推荐的方案就是引入第三方库来处理这些特殊的字符簇,比如使用 grapheme-splitter
来统计可视的 Unicode
字符数量:
const text = "🌈🏳️🌈";
const splitter = new GraphemeSplitter();
const graphemes = splitter.splitGraphemes(text); //['🌈', '🏳️🌈']
对于不考虑兼容性的应用场景,可以使用 ES15 提供的国际化 API Intl.Segmenter
来分割字符簇、单词、语句。
const segmenter = new Intl.Segmenter("zh", { granularity: "grapheme" });
const text = "家庭 Family 👪";
const segments = segmenter.segment(text);
const graphemes = [];
for (const segment of segments) {
graphemes.push(segment.segment);
}
//['家', '庭', ' ', 'F', 'a', 'm', 'i', 'l', 'y', ' ', '👪']
console.log(graphemes);
选项 granularity
用于控制分割的粒度、可取值如下:
grapheme
: 默认,在视觉上可见的字符簇(包含空白字符)。word
: 根据语言区域按词分割。sentence
:根据语言区域按句子分割。