如何正确地将文本拆分为单个字符?
使用 Intl.Segmenter 将字符串按用户感知的字符拆分,而不是按代码单元
引言
当你尝试用 JavaScript 的标准字符串方法将表情符号 "👨👩👧👦" 拆分为单个字符时,结果会被拆得支离破碎。你看到的不是一个完整的家庭表情,而是分开的单个人物表情和一些不可见字符。带重音的字母(如 "é")、国旗表情(如 "🇺🇸")以及许多其他在屏幕上看起来是单个字符的文本元素,也会遇到同样的问题。
这是因为 JavaScript 内置的字符串拆分方法将字符串视为 UTF-16 代码单元的序列,而不是用户感知的字符。一个可见字符可能由多个代码单元组合而成。如果按代码单元拆分,就会把这些字符拆开。
JavaScript 提供了 Intl.Segmenter API 来正确处理这种情况。本教程将解释什么是用户感知的字符,为什么标准字符串方法无法正确拆分它们,以及如何使用 Intl.Segmenter 将文本拆分为实际的字符。
什么是用户感知的字符
用户感知的字符是指人在阅读文本时识别为单个字符的内容。在 Unicode 术语中,这被称为字素簇(grapheme cluster)。大多数情况下,字素簇与屏幕上看到的单个字符一致。
字母 "a" 是一个由一个 Unicode 码点组成的字素簇。表情符号 "😀" 是一个由两个码点组成、显示为单个表情的字素簇。家庭表情 "👨👩👧👦" 是一个由七个码点和特殊不可见字符组合而成的字素簇。
在统计文本字符数时,应统计字素簇(grapheme cluster),而不是码点(code point)或码元(code unit)。在将文本拆分为字符时,应在字素簇边界进行拆分,而不是在簇内部的任意位置。
JavaScript 字符串是 UTF-16 码元的序列。每个码元要么表示一个完整的码点,要么是一个码点的一部分。一个字素簇可以跨越多个码点,而每个码点又可以跨越多个码元。这导致 JavaScript 存储文本的方式与用户感知文本的方式不一致。
为什么 split 方法在处理复杂字符时会失败
split('') 方法会在每个码元边界拆分字符串。对于每个字符只占用一个码元的简单 ASCII 字符来说,这种方式是正确的。但对于跨越多个码元的字符,则会失败。
const simple = "hello";
console.log(simple.split(''));
// Output: ["h", "e", "l", "l", "o"]
简单的 ASCII 文本可以正确拆分,因为每个字母只占用一个码元。然而,表情符号和其他复杂字符会被拆分。
const emoji = "😀";
console.log(emoji.split(''));
// Output: ["\ud83d", "\ude00"]
笑脸表情符号由两个码元组成。split('') 方法会将其拆分为两个独立的部分,这两部分本身都不是有效字符。显示时,这些部分会以替换字符或根本不显示。
国旗表情符号使用区域指示符符号组合而成。每个国旗需要两个码点。
const flag = "🇺🇸";
console.log(flag.split(''));
// Output: ["\ud83c", "\uddfa", "\ud83c", "\uddf8"]
美国国旗表情符号会被拆分为四个码元,代表两个区域指示符。单独的指示符都不是有效字符,必须两个指示符组合在一起才能形成国旗。
家庭表情符号使用零宽连接符(zero-width joiner)字符,将多个单人人物表情组合成一个复合字符。
const family = "👨👩👧👦";
console.log(family.split(''));
// Output: ["👨", "", "👩", "", "👧", "", "👦"]
家庭表情符号会被拆分为单独的人物表情和不可见的连接符。原本的复合字符被破坏,你会看到四个独立的人,而不是一个家庭。
重音字母在 Unicode 中有两种表示方式。有些重音字母是单一码点,而另一些则是基础字母加上组合附加符号。
const combined = "é"; // e + combining acute accent
console.log(combined.split(''));
// Output: ["e", "́"]
当字母 é 以两个码点(基础字母加组合重音符号)表示时,分割会将其拆成独立部分。重音符号会单独显示,这并不是用户在按字符分割文本时所期望的。
使用 Intl.Segmenter 正确分割文本
Intl.Segmenter 构造函数会创建一个分段器,根据特定语言环境的规则分割文本。第一个参数传递语言环境标识符,第二个参数传递指定粒度的选项对象。
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
grapheme 粒度参数指示分段器在字素簇(grapheme cluster)边界处分割文本。这会尊重用户感知的字符结构,不会将其拆开。
调用 segment() 方法并传入字符串,可以获得一个分段迭代器。每个分段都包含文本和位置信息。
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "hello";
const segments = segmenter.segment(text);
for (const segment of segments) {
console.log(segment.segment);
}
// Output:
// "h"
// "e"
// "l"
// "l"
// "o"
每个分段对象包含一个 segment 属性(字符文本)和一个 index 属性(其位置)。你可以直接遍历分段以访问每个字符。
要获取字符数组,可以将迭代器展开为数组,并映射到分段文本。
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "hello";
const characters = [...segmenter.segment(text)].map(s => s.segment);
console.log(characters);
// Output: ["h", "e", "l", "l", "o"]
该模式会将迭代器转换为包含分段对象的数组,然后提取每个分段中的文本。最终结果是一个字符串数组,每个元素对应一个字素簇。
正确地将表情符号拆分为字符
Intl.Segmenter API 能正确处理所有表情符号,包括使用多个码点的复合表情符号。
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const emoji = "😀";
const characters = [...segmenter.segment(emoji)].map(s => s.segment);
console.log(characters);
// Output: ["😀"]
表情符号作为一个字素簇保持完整。分段器能够识别这两个代码单元属于同一个字符,因此不会将其拆分。
国旗表情符号会作为单个字符保留,不会被拆分为区域指示符。
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const flag = "🇺🇸";
const characters = [...segmenter.segment(flag)].map(s => s.segment);
console.log(characters);
// Output: ["🇺🇸"]
这两个区域指示符号组成一个代表美国国旗的字素簇。分段器会将它们作为一个字符处理。
家庭表情符号和其他复合表情符号也会作为单个字符保留。
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const family = "👨👩👧👦";
const characters = [...segmenter.segment(family)].map(s => s.segment);
console.log(characters);
// Output: ["👨👩👧👦"]
所有人物表情和零宽连接符会组成一个字素簇。分段器会将整个家庭表情符号视为一个字符,从而保留其外观和含义。
拆分带有重音字母的文本
Intl.Segmenter API 能正确处理带重音的字母,无论其在 Unicode 中的编码方式如何。
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const precomposed = "café"; // precomposed é
const characters = [...segmenter.segment(precomposed)].map(s => s.segment);
console.log(characters);
// Output: ["c", "a", "f", "é"]
当带重音的字母 é 以单一码点编码时,分段器会将其视为一个字符。这符合用户对单词拆分方式的预期。
当同一个字母以基础字母加组合变音符号编码时,分段器依然会将其视为一个字符。
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const decomposed = "café"; // e + combining acute accent
const characters = [...segmenter.segment(decomposed)].map(s => s.segment);
console.log(characters);
// Output: ["c", "a", "f", "é"]
分段器能够识别基础字母和组合符号组成一个字素簇。即使底层编码不同,显示效果也与预组合版本完全一致。
这种行为对于使用变音符号的语言中的文本处理非常重要。用户期望带有重音的字母被视为完整字符,而不是分开的基础字母和附加符号。
正确统计字符数
拆分文本的一个常见用例是统计其包含多少个字符。使用 split('') 方法会对包含复杂字符的文本给出不正确的计数结果。
const text = "👨👩👧👦";
console.log(text.split('').length);
// Output: 7
家庭表情符号在视觉上显示为一个字符,但按代码单元拆分时会被计为七个。这与用户的预期不符。
使用 Intl.Segmenter 可以获得准确的字符计数。
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "👨👩👧👦";
const count = [...segmenter.segment(text)].length;
console.log(count);
// Output: 1
分段器会将家庭表情符号识别为一个字素簇,因此计数为 1。这与用户在屏幕上看到的结果一致。
你可以创建一个辅助函数,用于统计任意字符串中的字素簇数量。
function countCharacters(text) {
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
return [...segmenter.segment(text)].length;
}
console.log(countCharacters("hello"));
// Output: 5
console.log(countCharacters("café"));
// Output: 4
console.log(countCharacters("👨👩👧👦"));
// Output: 1
console.log(countCharacters("🇺🇸"));
// Output: 1
该函数可正确处理 ASCII 文本、带重音字母、表情符号以及任何其他 Unicode 字符。计数始终与用户感知的字符数一致。
正确获取指定位置的字符
当你需要访问特定位置的字符时,可以先将文本转换为字素簇数组。
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "Hello 👋";
const characters = [...segmenter.segment(text)].map(s => s.segment);
console.log(characters[6]);
// Output: "👋"
按字素簇计数时,挥手表情符号位于第 6 个位置。如果直接对字符串使用标准数组索引,会得到无效结果,因为该表情符号跨越了多个代码单元。
这种方法在实现字符级操作(如字符选择、字符高亮或逐字符动画)时非常有用。
正确反转文本
通过反转代码单元数组来反转字符串,对于复杂字符会产生错误结果。
const text = "Hello 👋";
console.log(text.split('').reverse().join(''));
// Output: "�� olleH"
表情符号会被拆分,因为其编码单元被分别反转,导致生成的字符串包含无效的字符序列。
使用 Intl.Segmenter 进行文本反转可以保持字符的完整性。
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "Hello 👋";
const characters = [...segmenter.segment(text)].map(s => s.segment);
const reversed = characters.reverse().join('');
console.log(reversed);
// Output: "👋 olleH"
每个字素簇在反转过程中都保持完整。由于编码单元未被分离,表情符号依然有效。
理解 locale 参数
Intl.Segmenter 构造函数接受一个 locale 参数,但对于字素分段来说,locale 的影响很小。字素簇边界遵循 Unicode 规则,这些规则大多与语言无关。
const segmenterEn = new Intl.Segmenter('en', { granularity: 'grapheme' });
const segmenterJa = new Intl.Segmenter('ja', { granularity: 'grapheme' });
const text = "Hello 👋 こんにちは";
const charactersEn = [...segmenterEn.segment(text)].map(s => s.segment);
const charactersJa = [...segmenterJa.segment(text)].map(s => s.segment);
console.log(charactersEn);
console.log(charactersJa);
// Both outputs are identical
不同的 locale 标识符会产生相同的字素分段结果。Unicode 标准以跨语言适用的方式定义了字素簇边界。
不过,指定 locale 仍然是一个良好实践,这有助于与其他 Intl API 保持一致,并且如果未来 Unicode 版本引入了特定于 locale 的规则也能兼容。
复用分段器以提升性能
创建新的 Intl.Segmenter 实例时需要加载 locale 数据并初始化内部结构。如果需要用相同设置分段多个字符串,建议只创建一次分段器并复用。
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const texts = [
"Hello 👋",
"Café ☕",
"World 🌍",
"Family 👨👩👧👦"
];
texts.forEach(text => {
const characters = [...segmenter.segment(text)].map(s => s.segment);
console.log(characters);
});
// Output:
// ["H", "e", "l", "l", "o", " ", "👋"]
// ["C", "a", "f", "é", " ", "☕"]
// ["W", "o", "r", "l", "d", " ", "🌍"]
// ["F", "a", "m", "i", "l", "y", " ", "👨👩👧👦"]
这种方式比为每个字符串都新建分段器更高效。当处理大量文本时,性能差异会非常明显。
将字素分段与其他操作结合
你可以将字素分段与其他字符串操作结合,构建更复杂的文本处理函数。
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
function truncateByCharacters(text, maxLength) {
const characters = [...segmenter.segment(text)].map(s => s.segment);
if (characters.length <= maxLength) {
return text;
}
return characters.slice(0, maxLength).join('') + '...';
}
console.log(truncateByCharacters("Hello 👋 World", 7));
// Output: "Hello 👋..."
console.log(truncateByCharacters("Family 👨👩👧👦 Photo", 8));
// Output: "Family 👨👩👧👦..."
此截断函数按字素簇计数,而不是按编码单元。截断时能保留表情符号和其他复杂字符,确保输出不会出现破损字符。
处理字符串位置
由 Intl.Segmenter 返回的分段对象包含一个 index 属性,用于指示在原始字符串中的位置。该位置以代码单元为单位计量,而不是以字素簇为单位。
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "Hello 👋";
for (const segment of segmenter.segment(text)) {
console.log(`Character "${segment.segment}" starts at position ${segment.index}`);
}
// Output:
// Character "H" starts at position 0
// Character "e" starts at position 1
// Character "l" starts at position 2
// Character "l" starts at position 3
// Character "o" starts at position 4
// Character " " starts at position 5
// Character "👋" starts at position 6
挥手表情符号从第 6 个代码单元位置开始,尽管它在底层字符串中占据第 6 和第 7 个位置。下一个字符将从第 8 个位置开始。当你需要在字素簇位置和字符串位置之间进行映射(如子串提取等操作)时,这些信息非常有用。
处理空字符串和边界情况
Intl.Segmenter API 能够正确处理空字符串和其他边界情况。
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const empty = "";
const characters = [...segmenter.segment(empty)].map(s => s.segment);
console.log(characters);
// Output: []
空字符串会生成一个空的分段数组,无需特殊处理。
空白字符会被视为独立的字素簇。
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const whitespace = "a b\tc\nd";
const characters = [...segmenter.segment(whitespace)].map(s => s.segment);
console.log(characters);
// Output: ["a", " ", "b", "\t", "c", "\n", "d"]
空格、制表符和换行符各自形成独立的字素簇。这与用户对字符级文本处理的预期一致。