在编程中,字符串操作是频繁且基础的,实践中我们会发现字符串处理远比表面看起来复杂,特别是当我们面对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中,charAt、at、substring 和 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、正则表达式:
- 在使用
match、search、replace等方法时,正则表达式的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 截取字符串,所传入的参数都是指以码元为最小单位的元素位置,而不是你看到的文字符号的位置。这一特点作用于字符串的相关操作上,比如:
- 用 charCodeAt、codePointAt 获取字符 Unicode 值;
- 用 fromCharCode、fromCodePoint 来构造字符串;
- 用 indexOf、lastIndexOf 来搜索子串;
- 用 split 来分解字符串;
- 用 >、< 来比较大小;
- 用 match、search、replace 来匹配、搜索和替换子串。