如何在 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 模式来匹配单词字符与非单词字符之间的位置。这种方式适用于 English。

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

这种模式对于没有空格的语言会失效,因为正则引擎无法识别 Chinese、Japanese 或 Thai 等文字系统中的词语边界。

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

正则表达式会把整个字符串当作一个单词处理,因为它无法理解 Chinese 的词语边界。

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

各语言中的词语边界是什么

词语边界是文本中一个单词结束、另一个单词开始的位置。不同的书写系统采用不同的词语边界标记方式。

以空格分隔的语言(如 English、Spanish、French 和 German)使用空格来标记词语边界。单词 "hello" 与 "world" 之间用空格分隔。

Scriptio continua 语言(如 Chinese、Japanese 和 Thai)不在单词之间使用空格。词语边界基于语义和形态规则存在,但在文本中没有视觉标记。Chinese 读者通过对语言的熟悉来识别词语的起止,而不是依靠视觉分隔符。

有些语言采用混合书写规范。例如,日语结合了汉字、平假名和片假名,词语边界通常出现在不同字符类型的切换处,或根据语法结构确定。

Unicode 标准在 UAX 29 中定义了词边界规则。这些规则规定了如何为所有书写系统识别词边界。规则会根据字符属性、书写类型和语言模式来判断词语的起止位置。

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

Intl.Segmenter 构造函数会创建一个分段器对象,按照 Unicode 规则对文本进行分割。你需要指定 locale 和 granularity。

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

第一个参数是 locale 标识符。第二个参数是一个 options 对象,其中 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);
// ["你好", "世界"]

英文区域无法识别中文的单词边界,会将整个字符串视为一个单词。中文区域会应用中文的分词规则,正确识别出两个单词。

始终为被分词的文本选择合适的 locale。

创建可复用的分词器以提升性能

创建分词器的开销不大,但可以在多字符串间复用分词器以获得更好的性能。

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() 调用中复用。分词器会缓存 locale 数据,因此复用实例可避免重复初始化。

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

结合分词与计数功能,对文本进行词频分析。

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

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

if (typeof Intl.Segmenter !== "undefined") {
  const segmenter = new Intl.Segmenter("en", { granularity: "word" });
  // Use segmenter
} else {
  // Fallback for older browsers
}

对于面向旧版浏览器的生产环境应用,请提供降级实现。简单的降级方案是对英文文本使用 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);
  }

  // Fallback: only works for space-separated languages
  return text.split(/\s+/).filter(word => word.length > 0);
}

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

常见错误及规避方法

不要对多语言文本直接按空格或正则分割。这些方法只适用于部分语言,对于中文、日文、泰文等无空格语言会失效。

提取单词时不要忘记按 isWordLike 过滤,否则结果中会包含空格、标点及其他非单词片段。

在对文本进行分词时,请勿使用错误的 locale。locale 决定了适用的分词规则。对中文文本使用英文 locale 会导致分词结果错误。

不要假设所有语言对“词”的定义都相同。不同书写系统和语言习惯下,词的边界各不相同。请使用支持 locale 的分词方法来处理这些差异。

不要用 split(" ").length 来统计国际化文本的词数。该方法只适用于以空格分隔的语言,对于其他语言会导致统计错误。

何时使用分词

在以下场景需要使用分词:

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

仅需统计字符数时,不要使用分词。字符级操作应采用字素(grapheme)分割。

不要用分词来进行句子切分。句子切分应使用句子粒度的分割方法。

分词在国际化中的作用

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

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

这些 API 共同为开发者提供了构建全球用户适用应用所需的工具。当你需要识别单词边界时,请使用 Intl.Segmenter 并设置为单词粒度;而在格式化和比较时,则应使用其他 Intl API。