- 作者:老汪软件技巧
- 发表时间:2024-12-28 10:06
- 浏览量:74
概述
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}字符串对比
对比字符串的另一特殊考虑因素在于区域化差异。例如德国字母 ß 在不方便的输入的场景下可以用 ss 替代,因此在德国二者是相等的,但是对于 Unicode 字符集而言,这是两个不同的字符,直接判断便会返回 false。
var eszett = 'ß';
eszett === 'ss'; //fase
对此,更推荐的方法是使用具有本地化的 API:
eszett.localeCompare('ss', 'de', {sensitivity:'accent'}); //1
类型转换
以普通函数的方式调用 String() 方法可以将传入的参数转换为字符串字面量值。
对于 string 保持原样。对于 undefined 转换为 "undefined"。对于 null 转换为 "null"。对于 Boolean, 转换为 "true" 和 "false"。对于 Number,使用与 Number(10).toString() 相同算法转换数字。对于 Symbol 和 Object,依次调用对象的 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 码中定义的控制字符,例如:
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 码元所表示的字符,对于代理对表示的字符,会返回其码元序列的长度(),而非视觉上独立的字符个数。
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 用于控制分割的粒度、可取值如下: