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

使用 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"]

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