如何正确地将文本分割为单个字符?

使用 Intl.Segmenter 将字符串分割为用户感知的字符,而不是代码单元

介绍

当您尝试使用 JavaScript 的标准字符串方法将表情符号 "👨‍👩‍👧‍👦" 拆分为单个字符时,结果会被破坏。您看到的不是一个家庭表情符号,而是单独的人物表情符号和不可见字符。同样的问题也会出现在带重音的字母(如 "é")、旗帜表情符号(如 "🇺🇸")以及许多其他在屏幕上显示为单个字符的文本元素中。

这是因为 JavaScript 内置的字符串拆分方法将字符串视为 UTF-16 代码单元的序列,而不是用户感知的字符。一个可见字符可能由多个代码单元组合而成。当按代码单元拆分时,这些字符会被分开。

JavaScript 提供了 Intl.Segmenter API 来正确处理这一问题。本课程将解释什么是用户感知的字符,为什么标准字符串方法无法正确拆分它们,以及如何使用 Intl.Segmenter 将文本拆分为实际的字符。

什么是用户感知的字符

用户感知的字符是指人们在阅读文本时识别为单个字符的内容。在 Unicode 术语中,这些被称为字形簇(grapheme clusters)。大多数情况下,字形簇与您在屏幕上看到的单个字符相匹配。

字母 "a" 是一个由一个 Unicode 代码点组成的字形簇。表情符号 "😀" 是一个由两个代码点组成的字形簇,形成一个单一的表情符号。家庭表情符号 "👨‍👩‍👧‍👦" 是一个由七个代码点通过特殊的不可见字符连接在一起的字形簇。

当您统计文本中的字符时,您希望统计的是字形簇的数量,而不是代码点或代码单元的数量。当您将文本拆分为字符时,您希望在字形簇的边界处拆分,而不是在簇内的任意位置。

JavaScript 字符串是 UTF-16 代码单元的序列。每个代码单元表示一个完整的代码点或代码点的一部分。一个字形簇可以跨越多个代码点,而每个代码点可以跨越多个代码单元。这导致了 JavaScript 存储文本的方式与用户感知文本的方式之间的不匹配。

为什么 split 方法在处理复杂字符时会失败

split('') 方法会在每个代码单元边界处分割字符串。这对于每个字符都是一个代码单元的简单 ASCII 字符来说是正确的。但对于跨越多个代码单元的字符,它会失败。

const simple = "hello";
console.log(simple.split(''));
// 输出: ["h", "e", "l", "l", "o"]

简单的 ASCII 文本可以正确分割,因为每个字母都是一个代码单元。然而,表情符号和其他复杂字符会被拆分。

const emoji = "😀";
console.log(emoji.split(''));
// 输出: ["\ud83d", "\ude00"]

这个笑脸表情符号由两个代码单元组成。split('') 方法将其分割成两个独立的部分,而这些部分本身并不是有效的字符。当显示时,这些部分可能会显示为替代字符或根本不显示。

旗帜表情符号使用区域指示符号组合形成旗帜。每个旗帜需要两个代码点。

const flag = "🇺🇸";
console.log(flag.split(''));
// 输出: ["\ud83c", "\uddfa", "\ud83c", "\uddf8"]

美国国旗表情符号被分割成四个代码单元,代表两个区域指示符。单独的指示符并不是有效的字符。需要两个指示符组合在一起才能形成旗帜。

家庭表情符号使用零宽度连接符将多个人物表情符号组合成一个复合字符。

const family = "👨‍👩‍👧‍👦";
console.log(family.split(''));
// 输出: ["👨", "‍", "👩", "‍", "👧", "‍", "👦"]

家庭表情符号被分割成单独的人物表情符号和不可见的连接符。原始的复合字符被破坏,显示为四个独立的人物,而不是一个家庭。

带重音的字母在 Unicode 中可以有两种表示方式。一些带重音的字母是单个代码点,而另一些则是通过基字母和组合变音符号组合而成。

const combined = "é"; // e + 组合的急音符
console.log(combined.split(''));
// 输出: ["e", "́"]

当字母 é 表示为两个代码点(基字母加组合重音符号)时,分割会将其拆分成独立的部分。重音符号单独出现,这并不是用户在将文本分割成字符时所期望的结果。

使用 Intl.Segmenter 正确分割文本

Intl.Segmenter 构造函数创建一个分段器,根据特定语言环境的规则分割文本。将语言环境标识符作为第一个参数传递,并将指定粒度的选项对象作为第二个参数。

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

grapheme 粒度指示分段器在字素簇边界处分割文本。这种方式尊重用户感知的字符结构,不会将其拆分。

调用 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);
}
// 输出:
// "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);
// 输出: ["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);
// 输出: ["😀"]

表情符号作为一个字素簇保持完整。分段器识别出两个代码单元属于同一个字符,因此不会将其拆分。

旗帜表情符号保持为单个字符,而不会分解为区域指示符。

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

const flag = "🇺🇸";
const characters = [...segmenter.segment(flag)].map(s => s.segment);
console.log(characters);
// 输出: ["🇺🇸"]

两个区域指示符号组成一个字素簇,表示美国国旗。分段器将它们作为一个字符保持完整。

家庭表情符号和其他复合表情符号也保持为单个字符。

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

const family = "👨‍👩‍👧‍👦";
const characters = [...segmenter.segment(family)].map(s => s.segment);
console.log(characters);
// 输出: ["👨‍👩‍👧‍👦"]

所有人物表情符号和零宽连接符组成一个字素簇。分段器将整个家庭表情符号视为一个字符,保留其外观和含义。

拆分带重音字母的文本

Intl.Segmenter API 能够正确处理无论在 Unicode 中如何编码的重音字母。

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

const precomposed = "café"; // 预组合的 é
const characters = [...segmenter.segment(precomposed)].map(s => s.segment);
console.log(characters);
// 输出: ["c", "a", "f", "é"]

当重音字母 é 被编码为单个代码点时,分段器将其视为一个字符。这符合用户对单词拆分方式的预期。

当相同的字母被编码为基础字母加上组合变音符号时,分段器仍然将其视为一个字符。

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

const decomposed = "café"; // e + 组合急音符号
const characters = [...segmenter.segment(decomposed)].map(s => s.segment);
console.log(characters);
// 输出: ["c", "a", "f", "é"]

分段器识别出基础字母和组合符号形成了一个单一的字形簇。结果看起来与预组合版本相同,尽管底层编码不同。

这种行为对于使用变音符号的语言中的文本处理非常重要。用户期望重音字母被视为完整的字符,而不是分开的基础字母和符号。

正确统计字符数

拆分文本的一个常见用例是统计其包含的字符数。split('') 方法对于包含复杂字符的文本会给出错误的计数。

const text = "👨‍👩‍👧‍👦";
console.log(text.split('').length);
// 输出: 7

家庭表情符号显示为一个字符,但在按代码单元拆分时计数为 7。这不符合用户的预期。

使用 Intl.Segmenter 可以获得准确的字符计数。

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "👨‍👩‍👧‍👦";
const count = [...segmenter.segment(text)].length;
console.log(count);
// 输出: 1

分段器将家庭表情符号识别为一个字形簇,因此计数为 1。这与用户在屏幕上看到的内容一致。

您可以创建一个辅助函数来统计任何字符串中的字形簇数量。

function countCharacters(text) {
  const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
  return [...segmenter.segment(text)].length;
}

console.log(countCharacters("hello"));
// 输出: 5

console.log(countCharacters("café"));
// 输出: 4

console.log(countCharacters("👨‍👩‍👧‍👦"));
// 输出: 1

console.log(countCharacters("🇺🇸"));
// 输出: 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]);
// 输出: "👋"

在按字形簇计数时,挥手表情符号位于位置 6。如果您对字符串使用标准数组索引,则会得到无效的结果,因为该表情符号跨越了多个代码单元。

这种方法在实现字符级操作(如字符选择、字符高亮或逐字符动画)时非常有用。

正确地反转文本

通过反转代码单元数组来反转字符串会导致复杂字符的结果不正确。

const text = "Hello 👋";
console.log(text.split('').reverse().join(''));
// 输出: "�� 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);
// 输出: "👋 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);
// 两者输出相同

不同的 locale 标识符会产生相同的字形分割结果。Unicode 标准以跨语言适用的方式定义了字形簇边界。

然而,指定 locale 仍然是一个良好的实践,以便与其他 Intl API 保持一致,并为将来 Unicode 版本引入的可能的 locale 特定规则做好准备。

重用分段器以提高性能

创建一个新的 Intl.Segmenter 实例需要加载区域设置数据并初始化内部结构。当需要使用相同设置对多个字符串进行分段时,可以创建一次分段器并重复使用。

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);
});
// 输出:
// ["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));
// 输出: "Hello 👋..."

console.log(truncateByCharacters("Family 👨‍👩‍👧‍👦 Photo", 8));
// 输出: "Family 👨‍👩‍👧‍👦..."

此截断函数按字素簇而不是代码单元计数。截断时,它会保留表情符号和其他复杂字符,因此输出中不会包含损坏的字符。

处理字符串位置

Intl.Segmenter 返回的分段对象包含一个 index 属性,该属性指示原始字符串中的位置。此位置以代码单元为单位,而不是字素簇。

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "Hello 👋";

for (const segment of segmenter.segment(text)) {
  console.log(`字符 "${segment.segment}" 起始于位置 ${segment.index}`);
}
// 输出:
// 字符 "H" 起始于位置 0
// 字符 "e" 起始于位置 1
// 字符 "l" 起始于位置 2
// 字符 "l" 起始于位置 3
// 字符 "o" 起始于位置 4
// 字符 " " 起始于位置 5
// 字符 "👋" 起始于位置 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);
// 输出: []

空字符串会生成一个空的分段数组。不需要特殊处理。

空白字符会被视为单独的字形簇。

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);
// 输出: ["a", " ", "b", "\t", "c", "\n", "d"]

空格、制表符和换行符各自形成独立的字形簇。这符合用户对字符级文本处理的预期。