JavaScript字符串操作的底层机制

168 阅读8分钟

在编程中,字符串操作是频繁且基础的,实践中我们会发现字符串处理远比表面看起来复杂,特别是当我们面对Unicode编码和多种字符类型时。本文将通过具体实例,详细解析在字符串操作中,码元(code unit)、Unicode字符与可见符号之间的微妙关系,以及这些关系如何影响常见的字符串操作。

码元:字符串操作的基本单位

在JavaScript等基于UTF-16编码的编程语言中,字符串被视为由一系列16位的码元组成。这些码元是字符串处理时的最小单位。

1、length属性:
  • length属性:当你使用length属性获取字符串长度时,返回的是码元的数量,而不是Unicode字符或可见符号的数量。

示例:

let str1 = "hello";
console.log(str1.length); // 输出: 5

字符串str1包含5个基本字符('h', 'e', 'l', 'l', 'o'),每个字符在UTF-16编码中都只需要一个16位的码元。因此,length属性返回5,这与字符的数量一致。

let str2 = "😀"; // 这是一个表情符号,
console.log(str2.length); // 输出: 2

字符串str2包含一个表情符号('😀'),这个表情符号在UTF-16编码中需要两个16位的码元来表示。因此,尽管str2看起来只包含一个可见符号,但length属性返回2,这表示的是码元的数量。

let str3 = "hello😀";
console.log(str3.length); // 输出: 7

字符串str3包含5个基本字符('h', 'e', 'l', 'l', 'o')和一个表情符号('😀')。表情符号需要两个码元,而其他字符各需要一个码元。因此,length属性返回7(5 + 2)。

2、charAt、at、substring、slice方法:

在JavaScript中,charAtatsubstring 和 slice 方法确实都是基于字符串的码元(UTF-16代码单元)位置来进行操作的。这意味着当处理包含非BMP(基本多语言平面)字符(如表情符号)的字符串时,这些方法的行为可能会与你的直观预期不同,因为这些字符在UTF-16编码中需要两个码元来表示。

charAt 方法

charAt 方法返回指定位置的字符。但是,由于它是基于码元位置的,所以在处理表情符号等需要两个码元的字符时,可能会出现问题。

let str = "a😀b";
console.log(str.charAt(0)); // 输出: "a"(第一个码元)
console.log(str.charAt(1)); // 输出: 空字符(因为😀的第一个码元在这里,但不是完整的字符)
console.log(str.charAt(2)); // 输出: 空字符(同上,是😀的第二个码元,但charAt只返回单个码元对应的字符,这里不完整)
at 方法

at 方法是ES2020中引入的,它允许你指定一个位置并返回该位置的字符。和charAt一样,它也是基于码元位置的。

let str = "a😀b";
console.log(str.at(0)); // 输出: "a"
console.log(str.at(1)); // 输出: 空字符(因为😀的开始在这里,但不是一个完整的字符)
console.log(str.at(2)); // 同上,空字符
substring 方法

substring 方法返回字符串中两个指定的下标号之间的字符。它也是基于码元位置的。

let str = "a😀b";
console.log(str.substring(0, 1)); // 输出: "a"(从索引0到索引1,不包括索引1)
console.log(str.substring(1, 2)); // 输出: 空字符串(因为这里只包含了😀的第一个码元,不是一个完整的字符)
console.log(str.substring(1, 3)); // 输出: 😀(从索引1开始,到索引3结束,但不包括索引3,这里恰好包含了完整的表情符号)
slice 方法

slice 方法提取字符串的某个部分,并返回一个新的字符串。和substring一样,它也是基于码元位置的。

let str = "a😀b";
console.log(str.slice(0, 1)); // 输出: "a"(从索引0开始,到索引1之前)
console.log(str.slice(1, 2)); // 输出: 空字符串(因为这里只包含了😀的第一个码元)
console.log(str.slice(1, 3)); // 输出: 😀(从索引1开始,到索引3之前,这里恰好包含了完整的表情符号)

Unicode字符:超越码元的完整表示

Unicode字符是字符编码的完整表示,它可能由单个或多个码元组成。

1、charCodeAt 方法
  • charCodeAt 方法返回指定位置的字符的UTF-16编码单元的值。
let str = "a";
console.log(str.charCodeAt(0)); // 输出: 97,这是字符 'a' 的UTF-16编码单元值。

let emojiStr = "😀"; // 这是一个位于辅助平面中的emoji字符
console.log(emojiStr.charCodeAt(0)); // 输出: 55357,这是emoji字符的第一个UTF-16代理项的值。
console.log(emojiStr.charCodeAt(1)); // 输出: 56372,这是emoji字符的第二个UTF-16代理项的值。
2、codePointAt 方法

codePointAt 方法返回指定位置的字符的完整Unicode代码点。

let str = "a";
console.log(str.codePointAt(0)); // 输出: 97,这是字符 'a' 的Unicode代码点值。

let emojiStr = "😀"; // 这是一个位于辅助平面中的emoji字符
console.log(emojiStr.codePointAt(0)); // 输出: 128512,这是emoji字符的完整Unicode代码点值。
3、fromCharCode 方法

fromCharCode 方法接收一个或多个UTF-16编码单元的值(码元值),并返回由这些编码单元组成的字符串。

// 构造基本多语言平面中的字符
let char1 = String.fromCharCode(97); // 'a'
console.log(char1); // 输出: 'a'
  • fromCharCode 方法根据UTF-16编码单元的值(码元值)来构造字符串,它可能无法正确处理位于辅助平面中的字符。
4、fromCodePoint 方法

fromCodePoint 方法接收一个或多个Unicode代码点值,并返回由这些代码点组成的字符串。

// 构造基本多语言平面中的字符
let char2 = String.fromCodePoint(97); // 'a'
console.log(char2); // 输出: 'a'

// 正确构造辅助平面中的字符(emoji)
let emoji = String.fromCodePoint(128512); // '😀'
console.log(emoji); // 输出: '😀'(一个emoji字符)
  • fromCodePoint 方法根据Unicode代码点值来构造字符串,它能够正确处理包括位于辅助平面中的字符在内的所有Unicode字符。

因此,在处理可能包含位于辅助平面中的字符的Unicode值时,使用fromCodePoint 方法是更可靠的选择。

字符串操作的微妙影响

1、indexOf 和 lastIndexOf 
  • indexOf 和 lastIndexOf 方法用于在字符串中搜索子串或字符,并返回其首次或最后一次出现的位置(索引),这两个方法都是基于码元(UTF-16编码单元)位置进行搜索的

示例:

let str = "Hello, 🌍!";
let index = str.indexOf("🌍");
console.log(index); // 输出: 7(在 '🌍' 之前的字符数,包括逗号)

let str = "🌍 Hello, 🌍!";
let lastIndex = str.lastIndexOf("🌍");
console.log(lastIndex); // 输出: 13(在最后一个 '🌍' 之前的字符数,包括感叹号)
2、split方法 split方法在分割字符串时,同样需要考虑码元的影响。
let str = "Hello, 🌍! Welcome to 🌍.";
let parts = str.split("🌍");
console.log(parts); // 输出: ["Hello, ", "! Welcome to ", "."]
//split 方法通常能够正确工作,前提是字符串中的emoji字符是完整且未被拆分的。
3、比较操作
  • 使用><等比较运算符时,实际上是在比较字符串中码元的字典顺序,比较是基于字符的Unicode码点值逐个进行的,从左到右,直到找到不同的字符或到达字符串的末尾。
let str1 = "a";
let str2 = "b";
console.log(str1 < str2); // 输出: true,因为 'a' 的Unicode码点值小于 'b' 的
let num = 123;
let str = "12";
console.log(num < str); // 输出: false,因为 "123" 的字典顺序大于 "12"
// 注意:这里实际上是将 num 转换为了字符串 "123",然后与 "12" 进行比较

数字与字符串之间的比较,会自动将数字转换为字符串,然后进行字典顺序比较。

let str3 = "a🌍";
let str4 = "b🌍";
console.log(str3 < str4); // 输出: true,因为 'a' 的Unicode码点值小于 'b' 的,尽管它们后面都跟着相同的emoji

当字符串包含多字节Unicode字符(如emoji)时,比较是基于这些字符的Unicode码点值进行的。

let str5 = "abc";
let str6 = "abcd";
console.log(str5 < str6); // 输出: true,因为 "abc" 在字典顺序上小于 "abcd"

如果两个字符串在比较的前几个字符上相同,但长度不同,那么较短的字符串会被视为在其末尾有一系列Unicode码点值最小的字符

4、正则表达式:
  • 在使用matchsearchreplace等方法时,正则表达式的Unicode模式(如u标志)可以帮助正确处理双码元字符。

示例:

let str = "Hello, 🌍!";
let regexWithoutU = /./; // 没有u标志的正则表达式,匹配单个码元
let regexWithU = /./u;  // 有u标志的正则表达式,匹配单个Unicode字符

let matchWithoutU = str.match(regexWithoutU); // 可能会将双码元字符拆分成两个码元
let matchWithU = str.match(regexWithU);       // 正确处理双码元字符

console.log(matchWithoutU); // 输出可能包含拆分的码元,这不是我们想要的
console.log(matchWithU);    // 输出: ["H", "e", "l", "l", "o", ",", " ", "🌍", "!"]
let positionWithoutU = str.search(/🌍/); // 可能会因为缺少u标志而返回错误的位置
let positionWithU = str.search(/🌍/u);   // 正确处理双码元字符并返回正确的位置

console.log(positionWithoutU); // 输出可能不正确,取决于JavaScript引擎的实现
console.log(positionWithU);    // 输出: 7,这是"🌍"在字符串中的正确位置
let replacedWithoutU = str.replace(/./, 'X'); // 可能会将双码元字符拆分成两个码元并替换
let replacedWithU = str.replace(/./u, 'X');   // 正确处理双码元字符并替换

console.log(replacedWithoutU); // 输出可能不是预期的,因为双码元字符可能被拆分
console.log(replacedWithU);    // 输出可能是 "Xello, 🌍!", 但这取决于替换的具体位置和逻辑
// 注意:上面的replace示例可能会替换第一个字符,因为正则表达式是 /./u。
// 如果想要替换所有的字符,应该使用全局标志 g,如 /./gu。

结论

不管是通过 length 获取字符串长度,还是用 charAt、at、substring、slice 截取字符串,所传入的参数都是指以码元为最小单位的元素位置,而不是你看到的文字符号的位置。这一特点作用于字符串的相关操作上,比如:

  1. 用 charCodeAt、codePointAt 获取字符 Unicode 值;
  2. 用 fromCharCode、fromCodePoint 来构造字符串;
  3. 用 indexOf、lastIndexOf 来搜索子串;
  4. 用 split 来分解字符串;
  5. 用 >、< 来比较大小;
  6. 用 match、search、replace 来匹配、搜索和替换子串。