Intl.Segmenter API

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

简介

JavaScript 的 string.length 属性统计的是代码单元,而不是用户感知的字符。当用户输入表情符号、带重音的字符或复杂书写系统的文本时,string.length 返回的计数结果会出错。split() 方法在不以空格分词的语言中会失效。正则表达式的单词边界对于中文、日文或泰文文本无效。

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

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

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

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

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

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

"😀".length; // 2, not 1
"👨‍👩‍👧‍👦".length; // 11, not 1
"किं".length; // 5, not 2
"🇺🇸".length; // 4, not 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);
}
// Logs: "c", "a", "f", "é"

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

使用字素分割来构建精确的字符计数器:

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

// Test with various inputs
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" in Chinese
const words = text.split(" "); // ["你好世界"]

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

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

不同语言的分词原理

Intl.Segmenter API 使用 UAX 29 中定义的 Unicode 单词边界规则。这些规则能够识别所有文字脚本的单词边界,包括没有空格的语言。

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

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

分段器会根据 locale 和 script 正确识别单词边界。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"); // ["สวัสดี", "ครับ"] (Thai)

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

准确统计单词数

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

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); // Index 7 is in "world"
console.log(segment);
// { segment: "world", index: 6, isWordLike: true }

如果索引位于空白或标点符号内,containing() 会返回该分段:

const segment = segments.containing(5); // Index 5 is the space
console.log(segment);
// { segment: " ", index: 5, isWordLike: false }

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

句子分段用于文本处理

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

像按句号分割这样的基础方法并不可靠,因为句号还会出现在缩写、数字等非句子边界的场景中:

const text = "Dr. Smith bought 100.5 shares. He sold them later.";
text.split(". "); // Incorrect: breaks at "Dr." and "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 = "こんにちは。お元気ですか。"; // Uses Japanese full stop

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

何时使用不同的粒度

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

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

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

  • Sentence:用于文本转语音分段、摘要生成、文档导航,或任何按句处理文本的操作。

当需要词语边界时,不要使用 grapheme 分割;当需要字符计数时,不要使用 word 分割。每种粒度都有其特定用途。

创建与复用分段器

创建分段器的开销很小,但为了性能可以复用分段器:

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

// Reuse these segmenters for multiple strings
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 月达到 Baseline 状态。它已在当前版本的 Chrome、Firefox、Safari 和 Edge 中可用。旧版浏览器不支持该 API。

使用前请先检查是否支持:

if (typeof Intl.Segmenter !== "undefined") {
  // Use Intl.Segmenter
  const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
  // ...
} else {
  // Fallback for older browsers
  const count = text.length; // Not accurate, but available
}

对于面向旧版浏览器的生产应用,建议使用 polyfill 或提供降级功能。

常见错误及规避方法

不要使用 string.length 向用户展示字符计数。它无法正确处理 emoji、组合字符和复杂书写系统。

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

不要假设不同语言的单词或句子边界是相同的。请使用支持本地化的分段方法。

在统计单词数时,不要忘记检查 isWordLike 属性。将标点符号和空白字符计入会导致统计结果偏大。

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

何时不应使用 Intl.Segmenter

对于仅包含基本拉丁字符的简单 ASCII 操作,已知文本内容时,基础字符串方法更快且足够用。

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

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

当你需要实际的代码单元(code unit)数量以进行底层字符串操作时,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
  };
}

// Works for any language
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 }

此函数可针对任意语言生成有意义的文本统计数据,并为每个语言环境应用正确的分割规则。