Reactで、2つの文章の違いを色分けして表示する方法
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 で受け取る非同期の呼び方も試していません。

GIF の左上の説明、操作の回数、キーの表示、カーソルと押した位置の輪は、撮影のために重ねたものです。キーボードで操作しているあいだは、カーソルを隠しています。「追加/削除」の文字数の表示は、変わった瞬間だけピンクにしています。
diff パッケージは、違いを「部分の配列」で返す
jsdiff の README にあるとおり、diffChars や diffWords は、2つの文章を「トークン」に分けてから、最小の追加と削除の組み合わせを求めます。結果は、文章の先頭から順に並んだ部分(change object)の配列です。
それぞれの部分は、文字列の value と、追加された部分なら true になる added、消された部分なら true になる removed を持ちます。どちらも false の部分は、2つの文章に共通する部分です。型の定義はパッケージに含まれているので、@types/diff は要りません(README の TypeScript の節に、バージョン8から同梱と書かれています)。
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 で同じ結果です。
参加|者|は|会場|に|集合|し|て|くだ|さい|。
出席|する|人|は|会議|室|に|集|ま|って|くだ|さい|。
「集合|し|て」のように細かく区切られていても、続けて消えたトークンは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つの文章と単位)が前の描画と同じなら、前に計算した値がそのまま返ります。親が別の理由で描き直されても、違いを計算し直さずに済みます。
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 で付けます。
.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つの入力欄の値と、比べる単位を渡します。
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 を小さくすると、計算で止まる時間も短くなります。





