Reactで、2つのJSONの違いを項目ごとに表示する方法

​​React​JSON,microdiff,Playwright,TypeScript

2つの JSON の違いは、両方を JSON.parse で読み込み、ルートから同じ場所どうしをたどって比べると、項目ごとに取り出せます。違いを「追加」「削除」「変更」の3種類に分け、toys[0].name のような場所と、変更前・変更後の値を表に並べれば、どこがどう変わったかを1行ずつ読めます。

ここでは、比べる関数 diffJson と、表で見せる JsonDiff コンポーネントを、ライブラリを使わずに書きました。配列の要素を番号で対応させると、1件足しただけで違いが何件も出ます。その対策として id で対応させる方法と、キーの有無を in で調べたときに起きる取り違えも、検証環境で確かめています。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0、microdiff 1.6.0 です。.tsx を tsc で型チェックしてから esbuild で本番用にビルドし、Playwright で Chromium を操作しました。Firefox、Safari、スマートフォンのブラウザ、スクリーンリーダーでの読み上げは試していません。計算時間は Node.js で測り、ブラウザの中では測っていません。配列の要素の並べ替えや途中への挿入を、id のない配列で見つける方法(最長共通部分列などを使う比べ方)も扱っていません。

変更後の JSON の入力欄と、違いの表。最初は「違い 8件」で、toys の要素が番号ごとに変更として並ぶ。「配列の要素を id で対応させる」をオンにすると「違い 4件」になり、toys は「toys[id=3]」の追加1行だけになる。age の 4 を消すと「変更後の JSON を読めません」と出て、3 と入力すると age の行が消えて「違い 3件」になる。オフに戻すと「違い 7件」になる。件数が変わった瞬間だけ、その表示がピンクになる
id で対応させるかどうかを切り替え、変更後の age を書き換えると、違いの表がその場で変わる様子

GIF のカーソル、クリックの輪、右上に出る押したキーの名前、下の帯の説明は、撮影のために重ねたものです。撮影では「変更前」の入力欄を隠し、見た目の CSS も撮影用に変えています。変更前の JSON は、次の節の1つ目と同じです。

比べた2つの JSON と、出てくる違い

次の2つを比べます。1つ目が変更前、2つ目が変更後です。age が 3 から 4 に、tags の「長毛」がなくなり、toys の先頭に羽根のおもちゃが入り、indoor が増えています。

json
{
  "name": "たま",
  "age": 3,
  "tags": ["白", "長毛"],
  "toys": [
    { "id": 1, "name": "ねずみ" },
    { "id": 2, "name": "ボール" }
  ]
}
json
{
  "name": "たま",
  "age": 4,
  "tags": ["白"],
  "toys": [
    { "id": 3, "name": "羽根" },
    { "id": 1, "name": "ねずみ" },
    { "id": 2, "name": "ボール" }
  ],
  "indoor": true
}

配列の要素を番号どうしで比べると、違いは次の8件になりました。

種類 場所 変更前 変更後
変更 age 3 4
削除 tags[1] "長毛"
変更 toys[0].id 1 3
変更 toys[0].name "ねずみ" "羽根"
変更 toys[1].id 2 1
変更 toys[1].name "ボール" "ねずみ"
追加 toys[2] {"id":2,"name":"ボール"}
追加 indoor true

toys は先頭に1件入っただけですが、後ろの要素が1つずつずれたため、5行の違いになっています。配列の要素を id の値で対応させると、toys の違いは toys[id=3] の追加の1行だけになり、全体で4件になりました。

違いを取り出す関数

diffJson は、2つの値を受け取り、違いの配列を返します。オブジェクトどうしならキーごとに、配列どうしなら要素ごとに、中へ進んで比べます。それ以外(文字列・数・真偽値・null)や、片方がオブジェクトでもう片方が配列のように種類が違うときは、!== で比べて違えば「変更」にします。違いの場所は、キーと配列の番号を並べた配列(パス)で持ちます。

ts
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
type JsonObject = { [key: string]: JsonValue };

// パスの1つ分:オブジェクトのキー、配列の番号、または「id が 2 の要素」のような指定
export type PathPart = string | number | { key: string; value: string | number };

export type Change =
  | { kind: 'added'; path: PathPart[]; after: JsonValue }
  | { kind: 'removed'; path: PathPart[]; before: JsonValue }
  | { kind: 'changed'; path: PathPart[]; before: JsonValue; after: JsonValue };

export type DiffOptions = {
  // 配列の要素をこのキーの値で対応させる(指定しなければ、番号で対応させる)
  arrayKey?: string;
};

const isObject = (v: JsonValue): v is JsonObject =>
  typeof v === 'object' && v !== null && !Array.isArray(v);

// すべての要素がオブジェクトで、arrayKey の値(文字列か数値)が重ならずに入っていれば、値から要素を引ける Map を返す
function indexByKey(arr: JsonValue[], key: string): Map<string | number, JsonValue> | null {
  const m = new Map<string | number, JsonValue>();
  for (const item of arr) {
    if (!isObject(item) || !Object.hasOwn(item, key)) return null;
    const id = item[key];
    if ((typeof id !== 'string' && typeof id !== 'number') || m.has(id)) return null;
    m.set(id, item);
  }
  return m;
}

export function diffJson(a: JsonValue, b: JsonValue, options: DiffOptions = {}): Change[] {
  const out: Change[] = [];

  function walk(a: JsonValue, b: JsonValue, path: PathPart[]) {
    if (Array.isArray(a) && Array.isArray(b)) {
      const key = options.arrayKey;
      const ma = key ? indexByKey(a, key) : null;
      const mb = key ? indexByKey(b, key) : null;
      if (key && ma && mb) {
        for (const [id, item] of ma) {
          const p = [...path, { key, value: id }];
          if (mb.has(id)) walk(item, mb.get(id)!, p);
          else out.push({ kind: 'removed', path: p, before: item });
        }
        for (const [id, item] of mb) {
          if (!ma.has(id)) out.push({ kind: 'added', path: [...path, { key, value: id }], after: item });
        }
        return;
      }
      // 番号で対応させる
      for (let i = 0; i < Math.max(a.length, b.length); i++) {
        if (i >= b.length) out.push({ kind: 'removed', path: [...path, i], before: a[i] });
        else if (i >= a.length) out.push({ kind: 'added', path: [...path, i], after: b[i] });
        else walk(a[i], b[i], [...path, i]);
      }
    } else if (isObject(a) && isObject(b)) {
      for (const k of Object.keys(a)) {
        if (Object.hasOwn(b, k)) walk(a[k], b[k], [...path, k]);
        else out.push({ kind: 'removed', path: [...path, k], before: a[k] });
      }
      for (const k of Object.keys(b)) {
        if (!Object.hasOwn(a, k)) out.push({ kind: 'added', path: [...path, k], after: b[k] });
      }
    } else if (a !== b) {
      // 値が違う、または種類(オブジェクト・配列・文字列など)が違う
      out.push({ kind: 'changed', path, before: a, after: b });
    }
  }

  walk(a, b, []);
  return out;
}

// JavaScript の識別子として書けるキーは「.」でつなぎ、それ以外は ["..."] で書く
const IDENT = /^[$_\p{ID_Start}][$\p{ID_Continue}]*$/u;

export function formatPath(path: PathPart[]): string {
  let s = '';
  for (const p of path) {
    if (typeof p === 'number') s += `[${p}]`;
    else if (typeof p === 'object') s += `[${p.key}=${JSON.stringify(p.value)}]`;
    else if (IDENT.test(p)) s += s ? `.${p}` : p;
    else s += `[${JSON.stringify(p)}]`;
  }
  return s || '(全体)';
}

オブジェクトのキーは、変更前のキーを順に見たあと、変更後にだけあるキーを足します。キーの並び順は比べないので、{"a":1,"b":{"x":1,"y":2}} と {"b":{"y":2,"x":1},"a":1} は「違いなし」になりました。同じ2つを JSON.stringify で文字列にして比べると、一致しませんでした(false)。MDN の JSON.stringify() のページにあるとおり、文字列にするときのキーの順番は Object.keys() と同じで、元のオブジェクトの順番が残るためです。

arrayKey を渡すと、配列の要素を、そのキーの値で対応させます。キーの値で対応させるのは、両方の配列で、全部の要素がオブジェクトで、そのキーに文字列か数値が入っていて、値が重なっていないときだけです。条件に合わない配列は、番号での対応に戻ります。id が2つとも 1 の配列で試すと、番号どうしで比べた結果が出ました。id で対応させると、要素の並び順が変わっただけでは違いになりません。

formatPath は、パスを表に出す文字列にします。キーが JavaScript の識別子として書ける名前なら . でつなぎ、a.b や x-y のように書けないキーは ["a.b"] の形にします。キーに . が入っていても、a.b(a の中の b)と取り違えずに読めます。識別子に使える文字の判定には、MDN の字句文法のページにある正規表現を使ったので、名前 のような日本語のキーも . でつながります。

キーがあるかは、in ではなく Object.hasOwn で調べる

「変更後にこのキーがあるか」を in で調べると、JSON にないキーでも true になることがあります。in は、オブジェクトそのものだけでなく、プロトタイプチェーンにあるプロパティでも true を返すからです(MDN の in 演算子のページ)。変更前にだけ "constructor" というキーがある JSON で試すと、次のようになりました。

text
キー constructor: in → true / Object.hasOwn → false / b[k] の型 → function
キー name: in → true / Object.hasOwn → true / b[k] の型 → string
JSON.stringify(b.constructor) → undefined

in で調べると、変更後にない constructor が「ある」ことになり、値として関数を拾います。その関数を JSON.stringify に渡すと undefined になります。diffJson のキーの調べ方を in に変えた関数で比べると、「削除」ではなく、変更後が undefined の「変更」になりました。

text
in で調べたとき : ["changed constructor: \"x\" → undefined"]
diffJson        : [{"kind":"removed","path":["constructor"],"before":"x"}]
microdiff       : [{"path":["constructor"],"type":"CHANGE","oldValue":"x"}] / value の型 → [ 'function' ]

記事の最後で結果を比べる microdiff 1.6.0 でも、この JSON では REMOVE ではなく CHANGE になり、変更後の値(value)には関数が入っていました。Object.hasOwn() は、そのオブジェクト自身が持つプロパティだけを見ます(MDN の Object.hasOwn() のページ)。diffJson では、キーの有無をすべて Object.hasOwn() で調べています。

"__proto__" というキーは、JSON.parse では普通のキーとして読み込まれます(MDN のオブジェクト初期化子のページにある、オブジェクトリテラルと JSON の違い)。{"__proto__":1} と {"__proto__":2} を比べると、__proto__ の変更として1行出ました。

違いとして出ないもの

JSON.parse で読み込んだあとの値を比べるので、書き方だけが違う数は同じとみなされます。1 と 1.0、100 と 1e2、0.5 と 5e-1 は、どれも「違いなし」でした。一方で、文字列の "1" と数の 1 は種類が違うので「変更」になります。

2の53乗以上の整数には気をつけてください。9007199254740993 と 9007199254740992 を比べると、「違いなし」になりました。JavaScript の数で安全に扱える整数の最大は、2の53乗から1を引いた値です(MDN の Number.MAX_SAFE_INTEGER のページ)。それより大きい整数は、隣の整数と区別できないことがあります。JSON.parse は読み込むときに数を JavaScript の数に変えるため、ここでは2つの値が同じ数になりました。MDN の JSON.parse() のページでは、大きな数は文字列にして送り、BigInt などに戻す方法が案内されています。

表で見せるコンポーネント

JsonDiff は、変更前と変更後の JSON を文字列のまま state に持ちます。読み込んだ値と違いの一覧は state にせず、レンダーのたびに計算します。React の公式ドキュメントにある、既存の props や state から計算できるものは、state に入れずにレンダー中に計算するという考え方に沿った書き方です。

tsx
import { useId, useState } from 'react';
import { diffJson, formatPath, type Change, type JsonValue } from './diff';

type Parsed = { ok: true; value: JsonValue } | { ok: false; message: string };

function parse(text: string): Parsed {
  try {
    return { ok: true, value: JSON.parse(text) as JsonValue };
  } catch (e) {
    return { ok: false, message: e instanceof SyntaxError ? e.message : String(e) };
  }
}

const LABEL = { added: '追加', removed: '削除', changed: '変更' } as const;
// 表に出す行の上限。表の行が多くなりすぎないようにする
const MAX_ROWS = 200;
const show = (v: JsonValue | undefined) => (v === undefined ? '' : JSON.stringify(v));

const BEFORE = `{ ...変更前の JSON(上の節と同じ)... }`;
const AFTER = `{ ...変更後の JSON(上の節と同じ)... }`;

export function JsonDiff() {
  const [before, setBefore] = useState(BEFORE);
  const [after, setAfter] = useState(AFTER);
  const [byId, setById] = useState(false);
  const idA = useId();
  const idB = useId();

  const a = parse(before);
  const b = parse(after);
  const changes: Change[] | null =
    a.ok && b.ok ? diffJson(a.value, b.value, { arrayKey: byId ? 'id' : undefined }) : null;

  return (
    <div className="json-diff">
      <div className="inputs">
        <div>
          <label htmlFor={idA}>変更前</label>
          <textarea id={idA} rows={12} spellCheck={false} value={before} onChange={(e) => setBefore(e.target.value)} />
          {!a.ok && <p role="alert">変更前の JSON を読めません:{a.message}</p>}
        </div>
        <div>
          <label htmlFor={idB}>変更後</label>
          <textarea id={idB} rows={12} spellCheck={false} value={after} onChange={(e) => setAfter(e.target.value)} />
          {!b.ok && <p role="alert">変更後の JSON を読めません:{b.message}</p>}
        </div>
      </div>

      <label className="option">
        <input type="checkbox" checked={byId} onChange={(e) => setById(e.target.checked)} />
        配列の要素を id で対応させる
      </label>

      {changes && (
        <>
          <p className="summary">
            違い {changes.length}件(追加 {changes.filter((c) => c.kind === 'added').length}・削除{' '}
            {changes.filter((c) => c.kind === 'removed').length}・変更{' '}
            {changes.filter((c) => c.kind === 'changed').length})
          </p>
          {changes.length > 0 && (
            <table>
              <thead>
                <tr><th>種類</th><th>場所</th><th>変更前</th><th>変更後</th></tr>
              </thead>
              <tbody>
                {changes.slice(0, MAX_ROWS).map((c) => {
                  const where = formatPath(c.path);
                  return (
                    <tr key={c.kind + where} className={c.kind}>
                      <td>{LABEL[c.kind]}</td>
                      <td><code>{where}</code></td>
                      <td><code>{c.kind === 'added' ? '' : show(c.before)}</code></td>
                      <td><code>{c.kind === 'removed' ? '' : show(c.after)}</code></td>
                    </tr>
                  );
                })}
              </tbody>
            </table>
          )}
          {changes.length > MAX_ROWS && <p>最初の {MAX_ROWS}件だけを表示しています。</p>}
        </>
      )}
    </div>
  );
}

BEFORE と AFTER の中身は、記事では省きました。検証では、最初の節の2つの JSON をそのまま入れています。

表の行には added・removed・changed のクラスを付けたので、CSS で種類ごとに色を変えられます。色だけに頼らず、1列目に「追加」「削除」「変更」の文字も出しています。行の key には、種類と場所をつないだ文字列を使いました。1つの場所から出る違いは1件だけなので、行の key は重なりません。

JSON.parse は、JSON として読めない文字列を受け取ると SyntaxError を投げます(MDN の JSON.parse() のページ)。parse で受け止めて、表の代わりにメッセージを出します。Chromium で変更後の age の値を消すと、次のメッセージが出て、表が消えました。

text
変更後の JSON を読めません:Unexpected token ',', ..."
  "age": ,
  "tags""... is not valid JSON

要素の多い配列の先頭に1件足すと、違いが何千件にもなる

番号で対応させると、配列の先頭に1件足しただけで、後ろの要素がすべて「変更」になります。要素が1000個の配列の先頭に1件足して Chromium で試すと、「違い 3002件(追加 1・削除 2・変更 2999)」と出ました。表は最初の200行だけを描き、その下に「最初の 200件だけを表示しています。」と出ています。

違いを取り出す計算そのものにかかる時間は、検証環境の Node.js で測りました。要素の数ごとに変更前と変更後の JSON を作りました。変更後は、先頭に1件足し、1件の値を変えています。最初の3回は慣らしとして捨て、続く11回の中央値を取りました。

配列の要素の数 JSON.parse(2回分) 番号で対応 id で対応
1000個(約0.1MB) 0.8ms 1.9ms(3002件) 2.4ms(2件)
1万個(約1.3MB) 10.8ms 14.5ms(30002件) 13.4ms(2件)
10万個(約13.1MB) 90.9ms 181.9ms(300002件) 139.1ms(2件)

1万個の配列では、読み込みと比べる計算を合わせて20〜30ミリ秒でした。10万個(約13MB)になると、合わせて0.2秒を超えます。このコンポーネントは、1文字入力するたびに、この読み込みと計算をやり直します。違いの件数は、番号で対応させると1万個で3万件、id で対応させると2件です。件数が多いと表の行も増えるので、表に出す行に上限を付けています。

microdiff と同じ結果になるかを確かめた

diffJson(番号で対応させるとき)が正しく違いを出しているかは、npm の microdiff の結果と比べて確かめました。microdiff は、違いを CREATE・REMOVE・CHANGE の3種類と、キーや番号を並べたパスで返すライブラリです。ランダムに作った JSON と、その一部を変えた JSON の組を5000組作り、違いの種類とパスの組み合わせを比べたところ、5000組すべてで一致しました。

​Posted by 86