Reactで、2つのJSONの違いを項目ごとに表示する方法
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件」になる。件数が変わった瞬間だけ、その表示がピンクになる](https://neko-note.org/wp-content/uploads/2026/11/react-json-diff-json-diff.gif)
GIF のカーソル、クリックの輪、右上に出る押したキーの名前、下の帯の説明は、撮影のために重ねたものです。撮影では「変更前」の入力欄を隠し、見た目の CSS も撮影用に変えています。変更前の JSON は、次の節の1つ目と同じです。
比べた2つの JSON と、出てくる違い
次の2つを比べます。1つ目が変更前、2つ目が変更後です。age が 3 から 4 に、tags の「長毛」がなくなり、toys の先頭に羽根のおもちゃが入り、indoor が増えています。
{
"name": "たま",
"age": 3,
"tags": ["白", "長毛"],
"toys": [
{ "id": 1, "name": "ねずみ" },
{ "id": 2, "name": "ボール" }
]
}
{
"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)や、片方がオブジェクトでもう片方が配列のように種類が違うときは、!== で比べて違えば「変更」にします。違いの場所は、キーと配列の番号を並べた配列(パス)で持ちます。
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 で試すと、次のようになりました。
キー 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 の「変更」になりました。
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 に入れずにレンダー中に計算するという考え方に沿った書き方です。
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 の値を消すと、次のメッセージが出て、表が消えました。
変更後の 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組すべてで一致しました。





