如何在 JavaScript 中将文本拆分为单词?

使用 Intl.Segmenter 从文本中提取单词,适用于任何语言,包括没有单词间空格的语言。

介绍

当需要从文本中提取单词时,常见的方法是使用 split(" ") 按空格分割。这种方法适用于英语,但对于不使用空格分隔单词的语言完全无效。中文、日语、泰语等语言的文本是连续书写的,没有单词分隔符,但用户仍然能够感知到文本中的独立单词。

Intl.Segmenter API 解决了这个问题。它根据 Unicode 标准和每种语言的语言规则识别单词边界。无论语言是否使用空格,您都可以从文本中提取单词,分段器会处理确定单词起止位置的复杂性。

本文将解释为什么基本的字符串分割方法对国际化文本无效,不同书写系统中的单词边界如何工作,以及如何使用 Intl.Segmenter 正确地将文本分割为所有语言的单词。

为什么按空格分割会失败

split() 方法在每次遇到分隔符时将字符串分割开。对于英文文本,按空格分割可以提取单词。

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

这种方法假设单词是由空格分隔的。然而,许多语言并不遵循这种模式。

中文文本中没有单词之间的空格。

const text = "你好世界";
const words = text.split(" ");
console.log(words);
// ["你好世界"]

用户看到的是两个独立的单词,但由于没有空格可供分割,split() 返回的是整个字符串作为一个元素。

日语文本混合了多种书写系统,也不使用空格分隔单词。

const text = "今日は良い天気です";
const words = text.split(" ");
console.log(words);
// ["今日は良い天気です"]

这句话包含多个单词,但按空格分割只会生成一个元素。

泰语文本同样是连续书写的,没有空格分隔单词。

const text = "สวัสดีครับ";
const words = text.split(" ");
console.log(words);
// ["สวัสดีครับ"]

这段文本包含两个单词,但 split() 返回的却是一个元素。

对于这些语言,需要一种不同的方法来识别单词边界。

为什么正则表达式在单词边界上会失败

正则表达式的单词边界使用 \b 模式来匹配单词和非单词字符之间的位置。这在英语中是有效的。

const text = "Hello world!";
const words = text.match(/\b\w+\b/g);
console.log(words);
// ["Hello", "world"]

这种模式在没有空格的语言中会失效,因为正则表达式引擎无法识别中文、日文或泰文等文字中的单词边界。

const text = "你好世界";
const words = text.match(/\b\w+\b/g);
console.log(words);
// ["你好世界"]

正则表达式将整个字符串视为一个单词,因为它无法理解中文的单词边界。

即使是英语,正则表达式模式在处理标点符号、缩写或特殊字符时也可能产生错误的结果。正则表达式并不是为处理所有书写系统中的语言学单词分割而设计的。

不同语言中的单词边界是什么

单词边界是文本中一个单词结束而另一个单词开始的位置。不同的书写系统对单词边界有不同的约定。

像英语、西班牙语、法语和德语这样的以空格分隔的语言使用空格来标记单词边界。例如,单词 "hello" 和 "world" 之间通过空格分隔。

像中文、日文和泰文这样的连续书写语言(scriptio continua)不使用空格分隔单词。单词边界基于语义和形态规则存在,但这些边界在文本中没有视觉标记。中文读者通过对语言的熟悉来识别单词的起止,而不是通过视觉分隔符。

某些语言使用混合约定。日语结合了汉字、平假名和片假名字符,单词边界可能出现在字符类型的转换处或基于语法结构。

Unicode 标准在 UAX 29 中定义了单词边界规则。这些规则规定了如何识别所有书写系统的单词边界。规则考虑了字符属性、书写系统类型和语言模式,以确定单词的起止位置。

使用 Intl.Segmenter 将文本分割为单词

Intl.Segmenter 构造函数创建一个分段器对象,该对象根据 Unicode 规则分割文本。您需要指定一个语言环境和粒度。

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

第一个参数是语言环境标识符。第二个参数是一个选项对象,其中 granularity: "word" 指定分段器在单词边界处进行分割。

segment() 方法返回一个包含分段的可迭代对象。您可以使用 for...of 遍历这些分段。

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

for (const segment of segmenter.segment(text)) {
  console.log(segment);
}
// { segment: "Hello", index: 0, input: "Hello world!", isWordLike: true }
// { segment: " ", index: 5, input: "Hello world!", isWordLike: false }
// { segment: "world", index: 6, input: "Hello world!", isWordLike: true }
// { segment: "!", index: 11, input: "Hello world!", isWordLike: false }

每个分段对象包含以下属性:

  • segment:该分段的文本
  • index:该分段在原始字符串中的起始位置
  • input:正在分段的原始字符串
  • isWordLike:该分段是否为单词或非单词内容

理解 isWordLike 属性

当您按单词分割文本时,分段器会返回单词分段和非单词分段。单词包括字母、数字和表意字符。非单词分段包括空格、标点符号和其他分隔符。

isWordLike 属性指示分段是否为单词。对于包含单词字符的分段,该属性为 true;对于仅包含空格、标点符号或其他非单词字符的分段,该属性为 false

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

for (const { segment, isWordLike } of segmenter.segment(text)) {
  console.log(segment, isWordLike);
}
// "Hello" true
// "," false
// " " false
// "world" true
// "!" false

使用 isWordLike 属性可以从标点符号和空白中筛选出单词分段。这将为您提供仅包含单词而没有分隔符的结果。

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

console.log(words);
// ["Hello", "world"]

这种模式适用于任何语言,包括那些没有空格的语言。

从没有空格的文本中提取单词

分段器可以正确识别不使用空格的语言中的单词边界。对于中文文本,分段器根据 Unicode 规则和语言模式在单词边界处进行分割。

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

for (const { segment, isWordLike } of segmenter.segment(text)) {
  console.log(segment, isWordLike);
}
// "你好" true
// "世界" true

分段器在这段文本中识别出了两个单词。虽然没有空格,但分段器能够理解中文的单词边界并适当地分割文本。

对于日文文本,分段器可以处理混合脚本的复杂性并识别单词边界。

const segmenter = new Intl.Segmenter("ja", { granularity: "word" });
const text = "今日は良い天気です";

for (const { segment, isWordLike } of segmenter.segment(text)) {
  console.log(segment, isWordLike);
}
// "今日" true
// "は" true
// "良い" true
// "天気" true
// "です" true

分段器将这句话分成了五个单词片段。它识别出像“は”这样的助词是独立的单词,并且像“天気”这样的复合词是单一的单元。

对于泰文文本,分段器可以在没有空格的情况下识别单词边界。

const segmenter = new Intl.Segmenter("th", { granularity: "word" });
const text = "สวัสดีครับ";

for (const { segment, isWordLike } of segmenter.segment(text)) {
  console.log(segment, isWordLike);
}
// "สวัสดี" true
// "ครับ" true

分段器在这段问候语中正确识别出了两个单词。

构建一个单词提取函数

创建一个可以从任何语言的文本中提取单词的函数。

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("今日は良い天気です", "ja");
// ["今日", "は", "良い", "天気", "です"]

getWords("Bonjour le monde!", "fr");
// ["Bonjour", "le", "monde"]

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("今日は良い天気です", "ja");
// 5

countWords("Bonjour le monde", "fr");
// 3

countWords("สวัสดีครับ", "th");
// 2

计数结果与用户对每种语言中单词边界的感知一致。

找出包含某个位置的单词

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

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

const segment = segments.containing(7);
console.log(segment);
// { segment: "world", index: 6, input: "Hello world", isWordLike: true }

索引 7 位于单词 "world" 中,该单词从索引 6 开始。此方法返回该单词的片段对象。

如果索引位于空格或标点符号中,该方法会返回该片段,并将 isWordLike 设置为 false

const segment = segments.containing(5);
console.log(segment);
// { segment: " ", index: 5, input: "Hello world", isWordLike: false }

此功能可用于文本编辑器功能,例如双击选择单词、基于光标位置的上下文菜单或高亮当前单词。

处理标点符号和缩写

分段器将标点符号视为单独的片段。英语中的缩写通常会被拆分为多个片段。

const segmenter = new Intl.Segmenter("en", { granularity: "word" });
const text = "I can't do it.";

for (const { segment, isWordLike } of segmenter.segment(text)) {
  console.log(segment, isWordLike);
}
// "I" true
// " " false
// "can" true
// "'" false
// "t" true
// " " false
// "do" true
// " " false
// "it" true
// "." false

缩写 "can't" 被拆分为 "can"、"'" 和 "t"。如果需要将缩写保留为单个单词,则需要额外的逻辑根据撇号合并片段。

对于大多数使用场景,统计类似单词的片段即使在缩写被拆分时,也能提供有意义的单词计数。

区域设置如何影响单词分割

您传递给分词器的区域设置会影响单词边界的确定方式。不同的区域设置可能对相同的文本有不同的规则。

对于具有明确单词边界规则的语言,区域设置可确保应用正确的规则。

const segmenterEn = new Intl.Segmenter("en", { granularity: "word" });
const segmenterZh = new Intl.Segmenter("zh", { granularity: "word" });

const text = "你好世界";

const wordsEn = Array.from(segmenterEn.segment(text))
  .filter(s => s.isWordLike)
  .map(s => s.segment);

const wordsZh = Array.from(segmenterZh.segment(text))
  .filter(s => s.isWordLike)
  .map(s => s.segment);

console.log(wordsEn);
// ["你好世界"]

console.log(wordsZh);
// ["你好", "世界"]

英语区域设置无法识别中文的单词边界,并将整个字符串视为一个单词。而中文区域设置应用了中文的单词边界规则,正确地识别出了两个单词。

始终为要分割的文本语言使用适当的区域设置。

创建可重用的分词器以提高性能

创建分词器的开销不大,但您可以在多个字符串中重用分词器以提高性能。

const enSegmenter = new Intl.Segmenter("en", { granularity: "word" });
const zhSegmenter = new Intl.Segmenter("zh", { granularity: "word" });
const jaSegmenter = new Intl.Segmenter("ja", { granularity: "word" });

function getWords(text, locale) {
  const segmenter = locale === "zh" ? zhSegmenter
    : locale === "ja" ? jaSegmenter
    : enSegmenter;

  return Array.from(segmenter.segment(text))
    .filter(s => s.isWordLike)
    .map(s => s.segment);
}

这种方法只需创建一次分词器,并在所有对 getWords() 的调用中重用它们。分词器会缓存区域设置数据,因此重用实例可以避免重复初始化。

实用示例:构建单词频率分析器

结合分词和计数来分析文本中的词频。

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

  const frequency = {};
  for (const word of words) {
    frequency[word] = (frequency[word] || 0) + 1;
  }

  return frequency;
}

const text = "Hello world! Hello everyone in this world.";
const frequency = getWordFrequency(text, "en");
console.log(frequency);
// { hello: 2, world: 2, everyone: 1, in: 1, this: 1 }

此函数将文本分割为单词,规范化为小写,并统计出现次数。它适用于任何语言。

const textZh = "你好世界!你好大家!";
const frequencyZh = getWordFrequency(textZh, "zh");
console.log(frequencyZh);
// { "你好": 2, "世界": 1, "大家": 1 }

相同的逻辑无需修改即可处理中文文本。

检查浏览器支持

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

在使用该 API 之前,请检查支持情况。

if (typeof Intl.Segmenter !== "undefined") {
  const segmenter = new Intl.Segmenter("en", { granularity: "word" });
  // 使用 segmenter
} else {
  // 为旧版浏览器提供回退方案
}

对于面向旧版浏览器的生产应用程序,请提供回退实现。一个简单的回退方案是对英文文本使用 split(),对其他语言返回整个字符串。

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

  // 回退:仅适用于以空格分隔的语言
  return text.split(/\s+/).filter(word => word.length > 0);
}

这确保了您的代码可以在旧版浏览器中运行,但对于非空格分隔的语言功能会有所降低。

常见错误避免

不要基于空格或正则表达式模式对多语言文本进行分割。这些方法仅适用于部分语言,对于没有空格的语言(如中文、日语、泰语等)会失败。

提取单词时不要忘记使用 isWordLike 过滤器。如果没有这个过滤器,结果中会包含空格、标点符号和其他非单词片段。

分割文本时不要使用错误的区域设置。区域设置决定了适用的单词边界规则。对中文文本使用英文区域设置会产生错误的结果。

不要假设所有语言对单词的定义方式相同。单词边界因书写系统和语言习惯而异。使用支持区域设置的分割方法来处理这些差异。

不要使用 split(" ").length 来统计国际化文本的单词数。这种方法仅适用于以空格分隔的语言,对其他语言会产生错误的计数。

何时使用单词分割

在以下情况下使用单词分割:

  • 统计多语言用户生成内容中的单词数
  • 实现适用于任何书写系统的搜索和高亮功能
  • 构建处理国际化文本的文本分析工具
  • 在文本编辑器中创建基于单词的导航或编辑功能
  • 从多语言文档中提取关键词或术语
  • 验证接受任何语言的表单中的单词数限制

当只需要字符计数时,不要使用单词分割。对于字符级操作,请使用字素分割。

当需要句子分割时,不要使用单词分割。为此目的,请使用句子粒度。

单词分割在国际化中的作用

Intl.Segmenter API 是 ECMAScript 国际化 API 的一部分。该系列中的其他 API 处理国际化的不同方面:

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

这些 API 共同提供了构建全球用户友好型应用所需的工具。当需要识别单词边界时,使用具有单词粒度的 Intl.Segmenter,而对于格式化和比较,请使用其他 Intl API。