JavaScript String

163 阅读14分钟

概述

String 对象是 JavaScript 的标准内置对象。用于存储和处理文本数据,它具有类数组 (like-array) 的特点,以字符序列的形式来操作每个字符,因此被称之为“字符对象”。

js-string-structure.png

“字符序列”中的每个字符有着与数组元素相同的索引访问方式,也有着类似的 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 也会特别的对其进行优化,占用更少的内存空间,其次在类型判断上也不会产生意外行为。

字符串拼接

  1. 使用 ++= 运算符。
  2. 使用模板字符串 ${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() 方法可以将传入的参数转换为字符串字面量值。

  1. 对于 string 保持原样。
  2. 对于 undefined 转换为 "undefined"
  3. 对于 null 转换为 "null"
  4. 对于 Boolean, 转换为 "true""false"
  5. 对于 Number,使用与 Number(10).toString() 相同算法转换数字。
  6. 对于 SymbolObject,依次调用对象的 toString() -> valueOf() 获取对象的原始值(hint string 即可),然后再将原始值转换为字符串字面量。
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 位码元序列组成的代理对),因此,必须要另辟蹊径!例如通过正则来分别匹配字符串中 BMPSMP 的字符,然后转换为数组,再统计数组元素的数量,间接计算视觉上独立的字符个数。

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:根据语言区域按句子分割。