Intl.Segmenter API

如何在 JavaScript 中正确统计字符、分割单词和分割句子

介绍

JavaScript 的 string.length 属性计算的是代码单元,而不是用户感知的字符。当用户输入表情符号、带重音的字符或复杂脚本的文本时,string.length 返回的计数可能是错误的。split() 方法对于不使用空格分隔单词的语言无效。正则表达式的单词边界对中文、日文或泰文文本不起作用。

Intl.Segmenter API 解决了这些问题。它根据 Unicode 标准对文本进行分段,遵循每种语言的语言规则。您可以计算字素(用户感知的字符)、将文本分割成单词(无论语言如何),或将文本分割成句子。

本文将解释为什么基本的字符串操作对国际化文本无效,什么是字素簇和语言边界,以及如何使用 Intl.Segmenter 正确处理所有用户的文本。

为什么 string.length 无法正确计算字符数

JavaScript 字符串使用 UTF-16 编码。JavaScript 字符串中的每个元素是一个 16 位的代码单元,而不是一个完整的字符。string.length 属性计算的是这些代码单元的数量。

对于基本的 ASCII 字符,一个代码单元等于一个字符。字符串 "hello" 的长度是 5,这符合用户的预期。

但对于许多其他字符,这种方式就不适用了。请看以下示例:

"😀".length; // 2,而不是 1
"👨‍👩‍👧‍👦".length; // 11,而不是 1
"किं".length; // 5,而不是 2
"🇺🇸".length; // 4,而不是 1

用户看到的是一个表情符号、一个家庭表情符号、两个印地语音节或一面旗帜。而 JavaScript 计算的是底层的代码单元。

当您为文本输入框构建字符计数器、验证长度限制或截断文本以供显示时,这一点尤为重要。JavaScript 报告的计数与用户看到的并不一致。

什么是字形簇

字形簇是用户感知为单个字符的单位。它可能由以下组成:

  • 一个单独的代码点,例如 "a"
  • 一个基本字符加上组合标记,例如 "é"(e + 组合急音符)
  • 多个代码点组合在一起,例如 "👨‍👩‍👧‍👦"(男人 + 女人 + 女孩 + 男孩,通过零宽连接符连接)
  • 带有肤色修饰符的表情符号,例如 "👋🏽"(挥手 + 中等肤色)
  • 用于表示旗帜的区域指示符序列,例如 "🇺🇸"(区域指示符 U + 区域指示符 S)

Unicode 标准在 UAX 29 中定义了扩展字形簇。这些规则决定了用户期望的字符边界位置。当用户按下退格键时,他们期望删除一个字形簇。当光标移动时,它应按字形簇移动。

JavaScript 的 string.length 并不计算字形簇,但可以使用 Intl.Segmenter API。

使用 Intl.Segmenter 计算字形簇

创建一个具有字形粒度的分段器来计算用户感知的字符:

const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const text = "Hello 👋🏽";
const segments = segmenter.segment(text);
const graphemes = Array.from(segments);

console.log(graphemes.length); // 7
console.log(text.length); // 10

用户看到七个字符:五个字母、一个空格和一个表情符号。字形分段器返回七个分段。JavaScript 的 string.length 返回十个,因为表情符号使用了四个代码单元。

每个分段对象包含:

  • segment:字形簇作为字符串
  • index:该分段在原始字符串中的起始位置
  • input:对原始字符串的引用(并非总是需要)

您可以使用 for...of 遍历分段:

const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const text = "café";

for (const { segment } of segmenter.segment(text)) {
  console.log(segment);
}
// 输出:"c", "a", "f", "é"

构建一个支持国际化的字符计数器

使用字素分段来构建准确的字符计数器:

function getGraphemeCount(text) {
  const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
  return Array.from(segmenter.segment(text)).length;
}

// 测试各种输入
getGraphemeCount("hello"); // 5
getGraphemeCount("hello 😀"); // 7
getGraphemeCount("👨‍👩‍👧‍👦"); // 1
getGraphemeCount("किंतु"); // 2
getGraphemeCount("🇺🇸"); // 1

此函数返回的计数与用户的感知一致。输入一个家庭表情符号的用户会看到一个字符,计数器也会显示一个字符。

对于文本输入验证,请使用字素计数而不是 string.length

function validateInput(text, maxGraphemes) {
  const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
  const count = Array.from(segmenter.segment(text)).length;
  return count <= maxGraphemes;
}

使用字素分段安全地截断文本

在截断用于显示的文本时,不能切割字素簇。按任意代码单元索引截断可能会分割表情符号或组合字符序列,导致无效或损坏的输出。

使用字素分段找到安全的截断点:

function truncateText(text, maxGraphemes) {
  const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
  const segments = Array.from(segmenter.segment(text));

  if (segments.length <= maxGraphemes) {
    return text;
  }

  const truncated = segments
    .slice(0, maxGraphemes)
    .map(s => s.segment)
    .join("");

  return truncated + "…";
}

truncateText("Hello 👨‍👩‍👧‍👦 world", 7); // "Hello 👨‍👩‍👧‍👦…"
truncateText("Hello world", 7); // "Hello w…"

这可以保留完整的字素簇并生成有效的 Unicode 输出。

为什么 split() 和正则表达式在单词分段时会失败

常见的将文本分割为单词的方法是使用 split() 和空格或空白模式:

const text = "Hello world";
const words = text.split(" "); // ["Hello", "world"]

这对英语和其他用空格分隔单词的语言有效。但对于不使用空格分隔单词的语言完全无效。

中文、日文和泰文的文本中没有单词之间的空格。按空格分割会将整个字符串作为一个元素:

const text = "你好世界"; // 中文中的“Hello world”
const words = text.split(" "); // ["你好世界"]

用户看到的是四个独立的单词,但 split() 返回一个元素。

正则表达式的单词边界(\b)对这些语言也无效,因为正则表达式引擎无法识别没有空格的脚本中的单词边界。

不同语言中的分词工作原理

Intl.Segmenter API 使用 Unicode UAX 29 中定义的单词边界规则。这些规则能够识别所有书写系统的单词边界,包括那些没有空格的书写系统。

创建一个以单词为粒度的分词器:

const segmenter = new Intl.Segmenter("zh", { granularity: "word" });
const text = "你好世界";
const segments = Array.from(segmenter.segment(text));

segments.forEach(({ segment, isWordLike }) => {
  console.log(segment, isWordLike);
});
// "你好" true
// "世界" true

分词器会根据语言环境和书写系统正确识别单词边界。isWordLike 属性指示分段是单词(字母、数字、表意文字)还是非单词内容(空格、标点符号)。

对于英文文本,分词器会返回单词和空格:

const segmenter = new Intl.Segmenter("en", { granularity: "word" });
const text = "Hello world!";
const segments = Array.from(segmenter.segment(text));

segments.forEach(({ segment, isWordLike }) => {
  console.log(segment, isWordLike);
});
// "Hello" true
// " " false
// "world" true
// "!" false

使用 isWordLike 属性可以从标点符号和空白中筛选出单词分段:

function getWords(text, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
  const segments = segmenter.segment(text);
  return Array.from(segments)
    .filter(s => s.isWordLike)
    .map(s => s.segment);
}

getWords("Hello, world!", "en"); // ["Hello", "world"]
getWords("你好世界", "zh"); // ["你好", "世界"]
getWords("สวัสดีครับ", "th"); // ["สวัสดี", "ครับ"] (泰语)

此函数适用于任何语言,能够处理以空格分隔和非空格分隔的书写系统。

准确统计单词数量

构建一个适用于国际化的单词计数器:

function countWords(text, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
  const segments = segmenter.segment(text);
  return Array.from(segments).filter(s => s.isWordLike).length;
}

countWords("Hello world", "en"); // 2
countWords("你好世界", "zh"); // 2
countWords("Bonjour le monde", "fr"); // 3

这可以为任何语言的内容生成准确的单词计数。

查找包含光标位置的单词

containing() 方法用于查找字符串中包含特定索引的片段。这对于确定光标所在的单词或包含点击位置的片段非常有用。

const segmenter = new Intl.Segmenter("en", { granularity: "word" });
const text = "Hello world";
const segments = segmenter.segment(text);

const segment = segments.containing(7); // 索引 7 位于 "world" 中
console.log(segment);
// { segment: "world", index: 6, isWordLike: true }

如果索引位于空格或标点符号中,containing() 会返回该片段:

const segment = segments.containing(5); // 索引 5 是空格
console.log(segment);
// { segment: " ", index: 5, isWordLike: false }

可将此方法用于文本编辑功能、搜索高亮或基于光标位置的上下文操作。

分割句子以进行文本处理

句子分割在句子边界处拆分文本。这对于摘要生成、文本转语音处理或浏览长文档非常有用。

像基于句号的简单方法会失败,因为句号会出现在缩写、数字和其他非句子边界的上下文中:

const text = "Dr. Smith bought 100.5 shares. He sold them later.";
text.split(". "); // 错误:在 "Dr." 和 "100." 处错误分割

Intl.Segmenter API 理解句子边界规则:

const segmenter = new Intl.Segmenter("en", { granularity: "sentence" });
const text = "Dr. Smith bought 100.5 shares. He sold them later.";
const segments = Array.from(segmenter.segment(text));

segments.forEach(({ segment }) => {
  console.log(segment);
});
// "Dr. Smith bought 100.5 shares. "
// "He sold them later."

分段器正确地将 "Dr." 和 "100.5" 视为句子的一部分,而不是句子边界。

对于多语言文本,不同语言的句子边界规则各不相同。该 API 能够处理这些差异:

const segmenterEn = new Intl.Segmenter("en", { granularity: "sentence" });
const segmenterJa = new Intl.Segmenter("ja", { granularity: "sentence" });

const textEn = "Hello. How are you?";
const textJa = "こんにちは。お元気ですか。"; // 使用日语句号

Array.from(segmenterEn.segment(textEn)).length; // 2
Array.from(segmenterJa.segment(textJa)).length; // 2

何时使用每种粒度

根据需要计数或分割的内容选择粒度:

  • 字素:用于字符计数、文本截断、光标定位或任何需要匹配用户对字符感知的操作。

  • 单词:用于单词计数、搜索和高亮、文本分析或任何需要跨语言的语言学单词边界的操作。

  • 句子:用于文本到语音的分段、摘要、文档导航或任何逐句处理文本的操作。

当需要单词边界时,不要使用字素分割;当需要字符计数时,不要使用单词分割。每种粒度都有其独特的用途。

创建和重用分段器

创建分段器成本较低,但可以重用分段器以提高性能:

const graphemeSegmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const wordSegmenter = new Intl.Segmenter("en", { granularity: "word" });

// 重用这些分段器处理多个字符串
function processTexts(texts) {
  return texts.map(text => ({
    text,
    graphemes: Array.from(graphemeSegmenter.segment(text)).length,
    words: Array.from(wordSegmenter.segment(text)).filter(s => s.isWordLike).length
  }));
}

分段器会缓存区域设置数据,因此重用相同的实例可以避免重复初始化。

检查浏览器支持

Intl.Segmenter API 在 2024 年 4 月达到基线状态。它可以在当前版本的 Chrome、Firefox、Safari 和 Edge 中使用。旧版浏览器不支持该功能。

在使用前检查支持情况:

if (typeof Intl.Segmenter !== "undefined") {
  // 使用 Intl.Segmenter
  const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
  // ...
} else {
  // 针对旧版浏览器的回退方案
  const count = text.length; // 不够准确,但可用
}

对于面向旧版浏览器的生产应用程序,可以考虑使用 polyfill 或提供降级功能。

避免常见错误

不要使用 string.length 来显示用户的字符计数。对于表情符号、组合字符和复杂脚本,它会产生错误的结果。

不要基于空格分割或使用正则表达式的单词边界来进行多语言的单词分割。这些方法仅适用于部分语言。

不要假设单词或句子的边界在所有语言中都是相同的。请使用支持区域设置的分割方法。

在统计单词时,不要忘记检查 isWordLike 属性。包括标点符号和空白会导致计数膨胀。

在截断字符串时,不要在任意索引处切割。始终在字素簇边界处切割,以避免生成无效的 Unicode 序列。

何时不应使用 Intl.Segmenter

对于仅包含简单 ASCII 字符的操作,且您确定文本仅包含基本拉丁字符,基本的字符串方法更快且足够。

当您需要字符串的字节长度用于网络操作或存储时,请使用 TextEncoder

const byteLength = new TextEncoder().encode(text).length;

当您需要实际的代码单元计数以进行低级字符串操作时,string.length 是正确的。这在应用程序代码中很少见。

对于涉及用户界面内容的大多数文本处理,尤其是在国际化应用程序中,请使用 Intl.Segmenter

Intl.Segmenter 与其他国际化 API 的关系

Intl.Segmenter API 是 ECMAScript 国际化 API 的一部分。此系列中的其他 API 包括:

  • Intl.DateTimeFormat:根据区域设置格式化日期和时间
  • Intl.NumberFormat:根据区域设置格式化数字、货币和单位
  • Intl.Collator:根据区域设置对字符串进行排序和比较
  • Intl.PluralRules:确定不同语言中数字的复数形式

这些 API 一起提供了构建全球用户友好型应用程序所需的工具。使用 Intl.Segmenter 进行文本分割,使用其他 Intl API 进行格式化和比较。

实用示例:构建一个文本统计组件

结合字素和单词分割来构建一个文本统计组件:

function getTextStatistics(text, locale) {
  const graphemeSegmenter = new Intl.Segmenter(locale, {
    granularity: "grapheme"
  });
  const wordSegmenter = new Intl.Segmenter(locale, {
    granularity: "word"
  });
  const sentenceSegmenter = new Intl.Segmenter(locale, {
    granularity: "sentence"
  });

  const graphemes = Array.from(graphemeSegmenter.segment(text));
  const words = Array.from(wordSegmenter.segment(text))
    .filter(s => s.isWordLike);
  const sentences = Array.from(sentenceSegmenter.segment(text));

  return {
    characters: graphemes.length,
    words: words.length,
    sentences: sentences.length,
    averageWordLength: words.length > 0
      ? graphemes.length / words.length
      : 0
  };
}

// 适用于任何语言
getTextStatistics("Hello world! How are you?", "en");
// { characters: 24, words: 5, sentences: 2, averageWordLength: 4.8 }

getTextStatistics("你好世界!你好吗?", "zh");
// { characters: 9, words: 5, sentences: 2, averageWordLength: 1.8 }

此函数使用每种语言环境的正确分割规则,为任何语言的文本生成有意义的统计数据。