📝

テキスト差分比較をブラウザだけで実装する方法 — diff ライブラリで作るオンライン Diff ツール

に公開2

設定ファイルの新旧を見比べたい。翻訳テキストの修正箇所を確認したい。仕様書を改訂したあとに、どこが変わったのか一覧で見たい。

テキストの差分比較は開発者に限らず、誰でも頻繁に発生する作業だ。WinMerge や VSCode の diff 機能を使えば解決はするが、インストール済みの環境が手元にあるとは限らない。出先の PC、社用端末、スマートフォン。「今すぐ 2 つのテキストを比べたいだけなのに」という場面は意外と多い。

そこで、ブラウザだけで動くテキスト差分比較ツールを作った。サーバーにテキストを送信せず、すべての処理がクライアントサイドで完結する。

https://sakutto-panda.com/tools/text-diff

この記事では、その実装の中身を解説する。使っているのは npm の diff ライブラリ(v8 系)と、Next.js 16.2 + TypeScript の構成。同じようなツールを自作したい人の参考になるはずだ。

diff ライブラリと LCS アルゴリズム

テキスト差分検出のコアには、npm パッケージ diff(v8.0.4)を使っている。

このライブラリの内部は LCS(Longest Common Subsequence / 最長共通部分列) ベースのアルゴリズムで動いている。Eugene Myers の差分アルゴリズム(1986 年の論文 "An O(ND) Difference Algorithm and Its Variations")の実装で、Git の diff もこれと同系統だ。

簡単に言うと、2 つのテキストから「共通して存在する最長の部分列」を見つけ出し、それ以外の部分を「追加」「削除」として検出する。行単位・単語単位・文字単位など、比較の粒度を関数で切り替えられるのが特徴だ。

diff ライブラリが提供する主な関数:

関数 比較粒度 用途
diffChars() 文字単位 1文字ずつの差分検出
diffWords() 単語単位 自然言語テキストの比較
diffLines() 行単位 ソースコード・設定ファイルの比較
createPatch() 行単位 unified diff 形式のパッチ生成
createTwoFilesPatch() 行単位 2ファイル間の unified diff 生成

今回のツールでは diffLines() を採用している。コードや設定ファイルの比較では行単位が最も自然で、WinMerge や GitHub の diff ビューとも挙動が近い。

diffLines() の使い方と出力構造

diffLines() の基本的な使い方はシンプルだ。

import { diffLines } from 'diff'

const changes = diffLines(beforeText, afterText, { newlineIsToken: false })

戻り値の changesChange[] 型の配列で、各要素は以下の構造を持つ。

interface Change {
  value: string    // 該当するテキスト(1行分とは限らない。連続する同種の行がまとまる)
  added?: boolean  // 追加された部分なら true
  removed?: boolean // 削除された部分なら true
  count?: number   // 含まれる行数
}

ポイントは、addedremovedfalse(または未定義)の要素が「変更なし」を意味すること。つまり、配列を走査して added / removed のフラグを見れば、差分の種類を判定できる。

たとえば "hello\n""world\n" を比較すると、こうなる。

const changes = diffLines("hello\n", "world\n")
// [
//   { value: "hello\n", removed: true, count: 1 },
//   { value: "world\n", added: true, count: 1 }
// ]

removed の直後に added が来るパターンは「行の変更」と解釈できる。この判定ロジックが、次に説明するサイドバイサイド表示の肝になる。

サイドバイサイド表示のためのデータ変換

diffLines() の出力はフラットな配列なので、そのままでは WinMerge 風の左右並列表示に使えない。変更前(Before)と変更後(After)を行ごとに対応づけるデータ構造に変換する必要がある。

実装では、以下のような型を定義している。

type RowType = 'equal' | 'removed' | 'added' | 'changed'

interface DiffRow {
  leftLine: string | null    // 変更前の行(なければ null)
  rightLine: string | null   // 変更後の行(なければ null)
  leftLineNum: number | null // 変更前の行番号
  rightLineNum: number | null // 変更後の行番号
  type: RowType              // 行の差分タイプ
}

変換ロジックのポイントは、removed の直後に added が来た場合の処理だ。

if (change.removed) {
  const next = changes[i + 1]
  if (next?.added) {
    // removed + added が連続 → 「変更」として左右を対応づける
    const removedLines = splitLines(change.value)
    const addedLines = splitLines(next.value)
    const maxLen = Math.max(removedLines.length, addedLines.length)
    for (let j = 0; j < maxLen; j++) {
      const hasLeft = j < removedLines.length
      const hasRight = j < addedLines.length
      const type = hasLeft && hasRight ? 'changed' : hasLeft ? 'removed' : 'added'
      rows.push({
        leftLine: hasLeft ? removedLines[j] : null,
        rightLine: hasRight ? addedLines[j] : null,
        type,
        // ... 行番号の採番
      })
    }
    i += 2  // removed と added の両方を消費
    continue
  }
}

この処理により、以下の 4 種類の行タイプが生成される。

  • equal: 変更なし。左右に同じ内容が表示される
  • removed: 削除。左側のみに内容があり、右側は空白
  • added: 追加。右側のみに内容があり、左側は空白
  • changed: 変更。左右に異なる内容が表示される

diff ライブラリの生の出力には changed という概念がない。removed + added のペアを検出して changed に変換する処理は、サイドバイサイド表示を実現するために自前で実装している部分だ。

差分統計の計算

差分の全体像を素早く把握するために、統計情報も算出している。

interface DiffSummary {
  added: number    // 追加行数
  removed: number  // 削除行数
  changed: number  // 変更行数
}

計算は行タイプの判定と同時に行う。各行を DiffRow に変換するループの中で、type に応じてカウンタをインクリメントするだけだ。

const summary: DiffSummary = { added: 0, removed: 0, changed: 0 }

// 行ごとの変換ループの中で:
if (type === 'changed') summary.changed++
else if (type === 'removed') summary.removed++
else if (type === 'added') summary.added++

UI 側では、この統計をバッジ風に表示している。「+3 追加 / -1 削除 / ~2 変更」のように、差分の規模がひと目でわかる。

diff ライブラリのその他の機能

今回のツールでは diffLines() + サイドバイサイド表示に絞っているが、diff ライブラリには他にも便利な関数がある。createTwoFilesPatch() を使えば Git や patch コマンドで使われる unified diff 形式を生成でき、createPatch() で単一ファイルのパッチも作れる。将来的に「パッチファイルのダウンロード」機能を追加する場合に活用できる。

実装上の工夫 — lib 層への分離とテスト

ぱんだツールズでは、変換ロジックを src/lib/ ディレクトリに純粋関数として切り出す設計ルールを敷いている。差分比較も同様で、src/lib/text/computeTextDiff.ts にコアロジックを置き、React コンポーネントからは結果を受け取って表示するだけにしている。

src/
  lib/
    text/
      computeTextDiff.ts    # 差分計算(純粋関数)
  app/
    tools/
      text-diff/
        TextDiffClient.tsx  # UI(コアロジックを呼ぶだけ)
        page.tsx            # Next.js ページ

この分離により、computeTextDiff()useState や DOM に一切依存しない。引数として 2 つの文字列を受け取り、DiffResult を返すだけ。つまり Vitest でそのままテストできる。

import { computeTextDiff } from '../text/computeTextDiff'

it('行が変更された場合', () => {
  const { rows, summary } = computeTextDiff('hello\n', 'world\n')
  expect(summary.changed).toBe(1)
  const changedRow = rows.find(r => r.type === 'changed')
  expect(changedRow?.leftLine).toBe('hello')
  expect(changedRow?.rightLine).toBe('world')
})

it('両方空文字列は差分なし', () => {
  const { rows, summary } = computeTextDiff('', '')
  expect(rows).toHaveLength(0)
  expect(summary).toEqual({ added: 0, removed: 0, changed: 0 })
})

正常系(追加・削除・変更・行番号の正確性)、境界値(空文字列・末尾改行なし)をカバーするテストを書いている。ロジックが UI から独立しているので、テストは軽量で高速に回る。

ファイル読み込みも File.text() でテキストを取得して computeTextDiff() に渡すだけなので、対応形式の追加が容易だ。現状は .txt / .json / .csv / .md / .html / .js / .ts など主要なテキスト形式をサポートしている。

まとめ

テキスト差分比較のブラウザ実装は、diff ライブラリを使えば思ったより簡単にできる。やっていることを整理すると:

  1. diffLines() で行単位の差分を検出
  2. removed + added のペアを changed に変換してサイドバイサイド用のデータ構造にする
  3. 行番号と差分統計を同時に計算
  4. UI はデータを受け取って色分け表示するだけ

コアロジックは 85 行程度の純粋関数で、外部依存は diff パッケージのみ。ロジックとUIを分離しておけば、テストも書きやすく、表示形式の切り替え(unified / inline / side-by-side)にも対応しやすい。

「自分でも diff ツールを作ってみたい」という人は、まず diffLines() の出力を console.log で眺めるところから始めると理解が早い。データ構造さえ掴めれば、あとは UI の問題でしかない。

https://sakutto-panda.com/tools/text-diff

Discussion

recdndrecdnd

これかなり良かったです。

特に、

removed + added
→ changed

を semantic layer に再構成している部分、実装としてかなり綺麗でした。

diff の生データをそのまま UI に流さず、一回 row structure を挟んでいるのがとても好きです。

あと、

「まず diffLines() の出力を console.log で眺める」

という締めもかなり共感しました。

自分も最近、LLM 出力の weird spacing / invisible char / broken bullet cleanup 用に deterministic text pipeline を作っていて、transform trace を console に全部出しながら module の挙動を観察しています 😭

https://kiln.ooo/

サクッとぱんだサクッとぱんだ

ありがとうございます!!

基本的にはwinmergeに寄せたいってところがあったのでchangeは入れたかったんですよね、やっぱ追加削除だけだと直感的には分かりにくいので

おー…!LLMのトレースログを眺め続けるのは根気入りそうですね、頑張ってください!