Reactで、2つの文章の違いを色分けして表示する方法

​​React​Intl.Segmenter,jsdiff,Playwright,TypeScript,useMemo

2つの文章の違いを色分けするには、npm の diff パッケージ(jsdiff)で違いを計算し、消えた部分を <del>、増えた部分を <ins> で描きます。日本語の文章は、diffWords に Intl.Segmenter を渡すと単語ごとに比べられます。「会」だけを残して「場」が「議室」に変わったような細切れの表示ではなく、「会場」が「会議室」に変わったという表示になります。

以下では、比べる単位(単語か1文字か)を切り替えられる TextDiff コンポーネントのコードと、長い文章で計算を打ち切る timeout の使い方を載せます。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、diff 9.0.0、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作して表示を確かめました。Firefox と Safari、スマートフォンのブラウザ、日本語の変換中の入力、スクリーンリーダーでの読み上げは試していません。jsdiff の README で案内されている、結果を callback で受け取る非同期の呼び方も試していません。

変更後を変更前と同じ文にした状態から始める。変更前「参加者は会場に集合してください。」に対して、変更後の「場」を消して「議室」と打つと「会場」が赤、「会議室」が緑で表示される。続けて「合し」を「まっ」に書き換えると「集合して」と「集まって」も色分けされ、要約は「追加 7文字/削除 6文字」になる。「1文字」に切り替えると「会」「集」などの共通の文字が色の外に出て「追加 4文字/削除 3文字」になり、「単語」に戻すと元の表示に戻る
変更後の文を書き換えると違いが色分けされ、比べる単位の切り替えで表示が変わる様子

GIF の左上の説明、操作の回数、キーの表示、カーソルと押した位置の輪は、撮影のために重ねたものです。キーボードで操作しているあいだは、カーソルを隠しています。「追加/削除」の文字数の表示は、変わった瞬間だけピンクにしています。

diff パッケージは、違いを「部分の配列」で返す

jsdiff の README にあるとおり、diffChars や diffWords は、2つの文章を「トークン」に分けてから、最小の追加と削除の組み合わせを求めます。結果は、文章の先頭から順に並んだ部分(change object)の配列です。

それぞれの部分は、文字列の value と、追加された部分なら true になる added、消された部分なら true になる removed を持ちます。どちらも false の部分は、2つの文章に共通する部分です。型の定義はパッケージに含まれているので、@types/diff は要りません(README の TypeScript の節に、バージョン8から同梱と書かれています)。

bash
npm i diff

あとは配列を順に描き、added なら <ins>、removed なら <del> で囲めば、違いを色分けできます。MDN の del の説明とins の説明にあるとおり、この2つは文書から消された文字と、加えられた文字を表す要素です。

日本語は Intl.Segmenter を渡して単語ごとに比べる

diffWords は、そのままでは日本語を単語に区切れません。README によると、何も指定しないときは正規表現で単語を区切ります。パッケージのソース(word.ts)を見ると、続けて1つの単語として扱うのはラテン文字と数字などだけで、ひらがなや漢字は1文字ずつ別のトークンになります。そのため、試した日本語の文章では、どれも diffChars と同じ結果になりました。

diffWords の intlSegmenter に、granularity が 'word' の Intl.Segmenter を渡すと、区切り方を Intl.Segmenter に任せられます。MDN の Intl.Segmenter の説明にあるとおり、Intl.Segmenter は、言語に合わせて文章を文字・単語・文に区切るオブジェクトです。

同じ2つの文を、3つの書き方で比べました。[-…] は消えた部分、[+…] は増えた部分です。

書き方 「参加者は会場に集合してください。」→「出席する人は会議室に集まってください。」
diffChars [-参加者][+出席する人]は会[-場][+議室]に集[-合し][+まっ]てください。
diffWords(指定なし) [-参加者][+出席する人]は会[-場][+議室]に集[-合し][+まっ]てください。
diffWords に Intl.Segmenter を渡す [-参加者][+出席する人]は[-会場][+会議室]に[-集合して][+集まって]ください。

1文字ずつ比べると、「会場」と「会議室」に共通する「会」が、変わっていない部分として残ります。画面では「会」だけが色の外に出て、「場」が「議室」に変わったように見えます。単語で比べると、「会場」が「会議室」に変わったと読めます。

Intl.Segmenter で区切った結果は、次のとおりでした。Node.js 22.23.4 と Chromium 153 で同じ結果です。

text
参加|者|は|会場|に|集合|し|て|くだ|さい|。
出席|する|人|は|会議|室|に|集|ま|って|くだ|さい|。

「集合|し|て」のように細かく区切られていても、続けて消えたトークンは1つの部分にまとめて返されるので、表示は「[-集合して]」になります。

一方で、1文字ずつ比べるほうが分かりやすい場合もあります。「明日」を「明後日」に直したときは、1文字ずつなら「明[+後]日」と、増えた1文字だけが色付きになります。単語で比べると「[-明日][+明後日]」です。誤字の修正のように小さな違いを見せたいときは1文字ずつ、言い換えを見せたいときは単語ごと、と使い分けられるように、コンポーネントでは単位を選べるようにしました。

英語の文章は、指定なしの diffWords でも単語ごとに比べられます。「The quick brown fox jumps」を「The quick red fox jumped」にすると、diffWords では「[-brown][+red]」「[-jumps][+jumped]」、diffChars では「[-b]r[-own][+ed]」のように細切れになりました。

補足

README には、Intl.Segmenter の区切り方は仕様で細かく決まっておらず、ブラウザや実行環境によって変わりうること、将来大きく変わりうることが書かれています。同じ2つの文章でも、ブラウザによって色の付き方が違う場合があります。この記事で確かめたのは Node.js と Chromium だけです。

TextDiff コンポーネントのコード

違いの計算は computeDiff() にまとめ、コンポーネントでは useMemo で結果を覚えておきます。React の useMemo のリファレンスにあるとおり、依存する値(ここでは2つの文章と単位)が前の描画と同じなら、前に計算した値がそのまま返ります。親が別の理由で描き直されても、違いを計算し直さずに済みます。

tsx
import { useMemo } from 'react';
import { diffChars, diffWords, type ChangeObject } from 'diff';

// 日本語の文章を単語に区切るための Intl.Segmenter(作るのは1回だけ)
const segmenter = new Intl.Segmenter('ja', { granularity: 'word' });

export type DiffUnit = 'word' | 'char';

// 違いを計算する。時間がかかりすぎたときは undefined が返る
export function computeDiff(before: string, after: string, unit: DiffUnit): ChangeObject<string>[] | undefined {
  return unit === 'word'
    ? diffWords(before, after, { intlSegmenter: segmenter, timeout: 200 })
    : diffChars(before, after, { timeout: 200 });
}

type Props = {
  before: string;
  after: string;
  unit?: DiffUnit;
};

export function TextDiff({ before, after, unit = 'word' }: Props) {
  // 入力が変わらないあいだは、計算し直さない
  const parts = useMemo(() => computeDiff(before, after, unit), [before, after, unit]);

  if (!parts) {
    return <p role="status">文章が長いため、違いを計算しきれませんでした。</p>;
  }

  const added = parts.filter((p) => p.added).reduce((n, p) => n + p.value.length, 0);
  const removed = parts.filter((p) => p.removed).reduce((n, p) => n + p.value.length, 0);

  return (
    <div>
      <p className="diff-summary">
        追加 {added}文字/削除 {removed}文字
      </p>
      <p className="text-diff">
        {parts.map((part, i) =>
          part.added ? (
            <ins key={i}>{part.value}</ins>
          ) : part.removed ? (
            <del key={i}>{part.value}</del>
          ) : (
            <span key={i}>{part.value}</span>
          ),
        )}
      </p>
    </div>
  );
}

timeout を付けると、計算を打ち切ったときに undefined が返ります。README の TypeScript の節にあるとおり、型のうえでも、timeout を渡した呼び出しの戻り値は undefined を含みます。そこで computeDiff() の戻り値を ChangeObject<string>[] | undefined とし、undefined のときは計算しきれなかったことを表示しています。

色は CSS で付けます。

css
.text-diff {
  white-space: pre-wrap; /* 改行やスペースをそのまま見せる */
  line-height: 1.8;
}
.text-diff del {
  background: #ffe1e6;
  color: #9b1c33;
}
.text-diff ins {
  background: #d9f5e3;
  color: #14632f;
}

text-decoration は指定していませんが、検証環境の Chromium では、del に取り消し線(line-through)、ins に下線(underline)が付いていました。色だけでなく線でも消えた部分と増えた部分を区別できるので、この線は消さずに残しています。

white-space: pre-wrap は、文章の改行を表示に残すための指定です。「1行目です。」から始まる3行の文章の2行目を書き換えたところ、違いは「2行目[-です][+を直しました]。」となり、表示も3行に分かれました。

使う側では、2つの入力欄の値と、比べる単位を渡します。

tsx
import { useState } from 'react';
import { TextDiff, type DiffUnit } from './TextDiff';

export function App() {
  const [before, setBefore] = useState('参加者は会場に集合してください。');
  const [after, setAfter] = useState('出席する人は会議室に集まってください。');
  const [unit, setUnit] = useState<DiffUnit>('word');

  return (
    <main>
      <label>
        変更前
        <textarea value={before} onChange={(e) => setBefore(e.target.value)} />
      </label>
      <label>
        変更後
        <textarea value={after} onChange={(e) => setAfter(e.target.value)} />
      </label>
      <fieldset>
        <legend>比べる単位</legend>
        <label>
          <input type="radio" name="unit" checked={unit === 'word'} onChange={() => setUnit('word')} />
          単語
        </label>
        <label>
          <input type="radio" name="unit" checked={unit === 'char'} onChange={() => setUnit('char')} />
          1文字
        </label>
      </fieldset>
      <TextDiff before={before} after={after} unit={unit} />
    </main>
  );
}

最初の表示では「追加 12文字/削除 9文字」、「1文字」に切り替えると「追加 9文字/削除 6文字」になりました。単語で比べると、「会」のような共通の文字も追加と削除の両方に数えられるので、文字数は多くなります。

長い文章は timeout で計算を打ち切る

違いの計算にかかる時間は、文章が長いほど延びます。似ていない2つの文章を作り、Node.js で diffWords(Intl.Segmenter あり)にかかった時間を測りました。

文章の長さ 打ち切らない場合 timeout: 100 の場合
約950文字 67ms 52ms(最後まで計算できた)
約3,700文字 462ms 104ms で打ち切り(undefined)
約9,500文字 3,490ms 110ms で打ち切り(undefined)

README の「Universal options」の節にあるとおり、timeout に渡したミリ秒を過ぎると計算をやめ、undefined を返します。時間ではなく、違いの量で打ち切る maxEditLength というオプションもあります。

TextDiff では timeout: 200 にしています。Chromium で約3,700文字の文章どうしを入れたところ、入力からおよそ 200ms で「文章が長いため、違いを計算しきれませんでした。」と表示されました。

React の useMemo のリファレンスにあるとおり、useMemo に渡した関数は描画の途中で呼ばれます。そのため、違いの計算が長引くと、そのぶん画面の更新が遅れます。timeout を小さくすると、計算で止まる時間も短くなります。

​Posted by 86