無料公開中
公開翌日4時まで

Intl.Segmenterで文字と単語を正しく分割する 前編 Intl.Segmenterの基本とその注意点

JavaScriptの文字列処理で起きる「見た目の1文字」や単語の境界のずれは、標準APIのIntl.Segmenterで解決できます。前編では、3つの分割粒度と返り値の構造を押さえたうえで、形態素解析エンジンとの違いや実行環境による差など、使う前に知っておきたい制限事項を整理します。

発行

著者 國仲 義則 フロントエンド・エンジニア
Intl.Segmenterで文字と単語を正しく分割する シリーズの記事一覧

はじめに

JavaScriptで文字列を扱っていると、「人間が見た目の1文字や単語として捉えている単位」と「プログラムが認識している単位」のずれに悩まされることがあります。

代表的なのが絵文字の文字数カウントです。まずは実際に起きる現象をコードで見てみましょう。家族の絵文字「👨‍👩‍👧‍👦」を例にします。

見た目は1文字でも長さが一致しない例

const family = "👨‍👩‍👧‍👦";

console.log(family.length); // 11
console.log([...family].length); // 7

画面上ではどう見ても1文字なのに、JavaScriptの標準的なプロパティや構文では11や7という数値が返ってきてしまいます。

なぜ.lengthやスプレッド構文でずれるのか

JavaScriptの文字列の.lengthは、文字数ではなくUTF-16コード単位(Code Units)の数を返します。たとえばサロゲートペアで表現される絵文字は1文字で長さ2とカウントされます。 さらに、家族絵文字のような結合絵文字は、複数の絵文字コードポイントがゼロ幅接合子(ZWJ:U+200D)で連結されているため、コードポイント単位に展開するスプレッド構文([...str])でも「1」にはなりません。

また、日本語には英語のような空白区切りが存在しないため、単語の処理を単純なsplit(" ")に任せることもできません。

たとえば、見出しのテキストを単語の途中で途切れさせずにきれいに改行させたい場面や、「パン」で検索したときに「フライパン」までヒットしてしまわないよう単語単位で厳密に区別したい場面などが考えられます。

このような、人間が見た目や意味として認識している単位でテキストを正しく切り分けるために策定されたのが、標準国際化APIであるIntl.Segmenterです。

先ほどの家族の絵文字の例を、Intl.Segmenterで解決してみましょう。

Intl.Segmenterで見た目どおりに分割する

const segmenter = new Intl.Segmenter("ja", { granularity: "grapheme" });

console.log([...segmenter.segment(family)].length); // 1

1という数値が返り、絵文字を見た目どおりの1文字としてカウントできるようになりました。

Intl.Segmenterを使えば、重い外部ライブラリを読み込まずとも、ブラウザ標準の機能だけで見た目どおりの1文字や単語の境界を扱えるようになります。

この連載では、前編でIntl.Segmenterの基本仕様を理解したうえで、形態素解析エンジンとの仕組みの違いや制限事項を押さえ、後編で日本語環境で役立つ実用パターンを紹介します。

そもそもIntlとは何か?

IntlはInternationalization(国際化)の略称です。JavaScriptには、言語や地域ごとに異なるフォーマットや慣習を扱うための標準名前空間としてIntlオブジェクトが用意されています。日付を扱うIntl.DateTimeFormat、数値を扱うIntl.NumberFormat、文字列の比較順を扱うIntl.Collatorなどがあり、Intl.Segmenterもこの国際化仕様の一環として追加されたAPIです。

ブラウザとランタイムのサポート状況

Intl.SegmenterはECMAScript 2022(ES2022)で仕様化された国際化APIです。

Chrome、Safari、Firefoxの主要モダンブラウザすべてで実装が完了しており、Node.js(v16.0.0以降)、Deno、Bunといったサーバーサイドランタイムでも標準利用できます。

Baselineでは、2024年4月に「Newly available」(主要ブラウザすべてで利用可能)になりました。

Intl.Segmenterの基本構文

Intl.Segmenterの使い方は、インスタンスを生成してsegment()メソッドを呼び出すという、ほかのIntl系APIと同様のインターフェースです。

Intl.Segmenterの基本形

const segmenter = new Intl.Segmenter(locales, options);
const segments = segmenter.segment(string);

Intl.Segmenterの使用例

const segmenter = new Intl.Segmenter("ja", { granularity: "grapheme", localeMatcher: "best fit" });

引数やプロパティは、次のような意味を持ちます。

  • locales: 言語タグ("ja"、"en"など)。省略時は実行環境のデフォルトロケール
  • options: 分割の挙動を指定するオブジェクト
  • options.granularity: 分割の粒度("grapheme"、"word"、"sentence"のいずれか。デフォルトは"grapheme")
  • options.localeMatcher: ロケール照合アルゴリズム("lookup"、"best fit"のいずれか。デフォルトは"best fit")

localeMatcherについては基本的に気にする機会はないでしょうが、気になる方は下記の参考リンクを参照してください。

参考リンク

3つの分割粒度(granularity)

Intl.Segmenterの構文を詳しく見ていきましょう。optionsのgranularity(粒度)プロパティによって、分割する基準を指定します。

指定できる値は"grapheme"、"word"、"sentence"の3つです。

granularity 分割の基準 判定される単位
"grapheme" 書記素 見た目どおりの「1文字」
"word" 単語の境界 単語・助詞・記号などの区切り
"sentence" 文の境界 句点や感嘆符などの文末区切り

それぞれの粒度が具体的にどのような場面で役立つのか、役割を整理しておきます。

  • "grapheme"(書記素): 文字の結合やサロゲートペアを考慮し、人間が見たまま認識している「1文字」を正しく取り出すための粒度。冒頭のような複数のコードポイントが組み合わさった絵文字の文字数カウントや、1文字ずつspanタグで囲んで装飾するような場面で使う。
  • "word"(単語): スペース区切りの分かち書きではない日本語の文章から、単語の区切りを見つけ出す粒度。見出しのテキストを単語の途中で途切れさせない自然な折り返しや、「パン」と「フライパン」を取り違えずに単語単位で厳密に一致させる軽量な絞り込み処理などで活躍する。
  • "sentence"(文): 「。」や「!」などの句点・感嘆符を手がかりに、文単位でテキストを区切る粒度。チャットUIや生成AIのストリーミング出力を1文ずつ区切ってフェードインさせたり、長文から最初の1文だけを取り出して要約表示を作ったりする用途に適している。

返り値(Segmentsオブジェクト)の構造

segmenter.segment(text)を実行すると、配列ではなくSegmentsと呼ばれる反復可能オブジェクトが返されます。

これをfor...ofで反復処理するか、スプレッド構文やArray.from()で配列化することで、個々のセグメント情報を取り出せます。

どちらを使っても問題ありませんが、配列として保持しておく特別な理由がなければ、そのままfor...ofで反復処理するほうがシンプルです。配列化を選ぶのは、.lengthを使って手軽に個数を取得したい場合や、配列のメソッド(map()やfilter()など)をつなげて別のデータへ加工したい場合などが該当します。

セグメントオブジェクトの構造(反復処理)

const segmenter = new Intl.Segmenter("ja", { granularity: "word" });
const result = segmenter.segment("吾輩は猫である。");

for (const item of result) {
  console.log(item);
}

上記例の場合、出力される個々のオブジェクトは、次のようになります。

1つのセグメントに含まれるプロパティ

{
  "segment": "吾輩",
  "index": 0,
  "input": "吾輩は猫である。",
  "isWordLike": true
}
{
  "segment": "は",
  "index": 2,
  "input": "吾輩は猫である。",
  "isWordLike": true
}
{
  "segment": "猫",
  "index": 3,
  "input": "吾輩は猫である。",
  "isWordLike": true
}
{
  "segment": "で",
  "index": 4,
  "input": "吾輩は猫である。",
  "isWordLike": true
}
{
  "segment": "ある",
  "index": 5,
  "input": "吾輩は猫である。",
  "isWordLike": true
}
{
  "segment": "。",
  "index": 7,
  "input": "吾輩は猫である。",
  "isWordLike": false
}

それぞれのプロパティの意味は次のとおりです。

  • segment: 分割された文字列の断片
  • index: 元の文字列における開始インデックス(0始まりのUTF-16コード単位位置)
  • input: 元の文字列全体
  • isWordLike: そのセグメントが単語に相当するかどうかを示す真偽値(granularity: "word"のときのみ付与)

granularity: "word"で分割した場合、テキスト内の文字だけでなく、空白や句読点(「、」「。」など)も1つのセグメントとして切り出されます。

isWordLikeは、そのセグメントが文字や数字で構成された「単語」なのか、あるいはそれらの間を区切る「記号やスペース」なのかを判定するためのプロパティです。実質的な単語だけを抽出したり、記号と単語で異なる処理を行ったりする際の目印として使います。

特性と制限事項

具体的な活用例を見る前に、Intl.Segmenterの仕組みと限界を押さえておきましょう。

形態素解析エンジンとの違い

日本語の形態素解析エンジンといえばKuromojiやMeCabが有名です。それらとIntl.Segmenterは、根本的に目的が異なります。

  • Kuromoji:Java製の形態素解析エンジン。JavaScriptの移植版も存在する。
  • MeCab:C++で開発されており、さまざまな言語から呼び出せる形態素解析エンジン。
項目 Intl.Segmenter 形態素解析エンジン
分割の仕組み Unicode標準ルール+統計ヒューリスティック 大規模辞書+品詞接続コスト計算
品詞情報の取得 不可(isWordLikeの真偽値のみ) 可能(名詞、動詞、助詞など詳細)
読み(ふりがな) 不可 可能(辞書に基づき取得)
活用形の正規化 不可(表層形のまま) 可能(基本形・語幹を取得)
辞書の追加・編集 不可 可能(ユーザー辞書の追加)
動作環境 ブラウザ・ランタイム標準 巨大な辞書データのダウンロードが必要(数MB〜数十MB)

Intl.Segmenterのgranularity: "word"は、「テキストを単語のような塊に切り分ける」ことだけに特化しています。

「名詞だけを抽出したい」「動詞の原形を取得したい」「漢字にふりがなを振りたい」といった高度な自然言語処理の用途には使えません。そうした用途には引き続き形態素解析ライブラリが必要です。

造語や専門用語の分割について

形態素解析エンジン、PCやスマートフォンのIMEなどは、元をたどれば辞書データに依存して単語を判定しています。ただし、それらには「ユーザー辞書を登録して未知の単語を覚えさせる」仕組みが備わっています。

一方、Intl.Segmenterにはユーザー辞書を追加・編集するAPIが一切存在しません。

そのため、創作物の固有名詞や専門用語の分割は、文字種(カタカナ・漢字・英数字)の並びと既定のルール任せになります。

  • カタカナやアルファベットの連続: 未知の単語であっても文字種が途切れないため、1つの単語としてまとまりやすい(例:「エスタボルカ」→「エスタボルカ」)
  • 未知の漢字の組み合わせ: 辞書にない漢字列は、内部的に知っている既存の漢字単語に勝手に分解されやすい(例:「剣戟天崩ビクトリー」→「剣戟」「天」「崩」「ビクトリー」)

「形態素解析エンジンやIMEのように、文脈を汲んでいい感じに単語を学習・判定してくれるわけではない」という点は意識しておく必要があります。

実行環境やUnicodeバージョンの差異

Intl.Segmenterは内部的にUnicodeのICU(International Components for Unicode)ライブラリや各エンジンの辞書・境界規則を参照しています。

そのため、ブラウザのバージョン、OS(macOS、Windows、iOS、Android)、Node.jsのバージョンによって搭載されているICUのバージョンが異なると、同じ日本語の文字列でも単語の分割境界が異なる場合があります。

厳密な一意性が求められるサーバーサイドの検索インデックス作成などでは、環境差異が起き得る点に注意してください。

インスタンスの生成コスト

new Intl.Segmenter()の生成処理は、通常のオブジェクト生成に比べて、ロケールやルールの解決などで一定の初期化コストがかかります。

ループの内部で毎回インスタンス化するとパフォーマンス劣化を招くため、インスタンスは関数の外側やスコープ内でキャッシュして再利用するとよいでしょう。

インスタンスを使い回す

// 避けるべき例:呼び出しごとに毎回生成される
function countCharactersBad(str) {
  return [...new Intl.Segmenter("ja", { granularity: "grapheme" }).segment(str)].length;
}

// 推奨:インスタンスを事前に作って再利用する
const graphemeSegmenter = new Intl.Segmenter("ja", { granularity: "grapheme" });

function countCharacters(str) {
  return [...graphemeSegmenter.segment(str)].length;
}

ここまでのまとめ

ここまで、Intl.Segmenterが必要になる背景から、基本構文と返り値の構造、使う前に押さえておきたい特性と制限事項までを見てきました。要点は次のとおりです。

  • .lengthやスプレッド構文、split(" ")では、見た目の1文字や日本語の単語の境界を正しく扱えない
  • Intl.Segmenterはgranularityで"grapheme"(書記素)、"word"(単語)、"sentence"(文)の3つの粒度を指定して分割できる
  • segment()の返り値は反復可能なSegmentsオブジェクト。for...ofで処理するか、配列化して使う

後編では、見た目どおりに数える文字数カウンター、UI向けの自然なテキスト折り返し、単語単位での絞り込み、文単位での処理と、日本語環境で役立つIntl.Segmenterの実用パターンを紹介します。