← Alle Artikel

Zwei JSON-Dateien vergleichen: Algorithmen und Tools

Ein einfacher Text-Diff übersieht Schlüssel-Umordnungen und Whitespace-Rauschen. Lerne, wie ein richtiger JSON-Diff funktioniert: LCS-Zeilen-Diff, semantischer Baumvergleich, Schlüssel-Normalisierung und die Trade-offs jedes Ansatzes.

Zwei JSON-Antworten sehen fast identisch aus, aber zwischen Deploys hat sich etwas geändert und Ihre Tests schlagen fehl. Oder Sie reviewen einen Pull Request, der eine große Konfigurationsdatei berührt, und müssen genau wissen, welche Werte sich verschoben haben. JSON-Dateien zu vergleichen klingt einfach —— bis Ihnen auffällt, dass ein naiver Text-Diff das Umordnen von Schlüsseln als Änderung behandelt und dass tief verschachtelte Strukturen einen klügeren Ansatz brauchen. Dieser Artikel erklärt die Algorithmen, die einen ordentlichen JSON-Diff überhaupt erst möglich machen.

Warum ein einfacher Text-Diff zu kurz greift

Der einfachste Weg, zwei Dateien zu vergleichen, ist diff auszuführen oder sie Zeile für Zeile zu vergleichen. Für Klartext funktioniert das gut. Für JSON bricht es auf mindestens zwei Arten.

Problem 1: Schlüsselreihenfolge

JSON-Objekte sind nach Spezifikation ungeordnet. Die folgenden zwei Dokumente sind semantisch identisch:

// Dokument A
{ "name": "Ada", "plan": "pro", "active": true }

// Dokument B
{ "active": true, "name": "Ada", "plan": "pro" }

Ein zeilenweiser Text-Diff würde jede Zeile als geändert melden. Ein ordentlicher JSON-Diff meldet null Unterschiede.

Problem 2: Formatierungsrauschen

Einrückung, Whitespace und ob ein langes Array auf einer Zeile oder vielen Zeilen geschrieben ist —— all das ist für die Daten egal. Ein Text-Diff behandelt jeden Whitespace-Unterschied als Änderung.

Die Lösung beider Probleme ist dieselbe: erst parsen, dann die Struktur diffen, nicht den Text.

Schritt 1 —— Parsen und normalisieren

Vor jedem Vergleich werden beide Dokumente mit JSON.parse() in JavaScript-Objekte geparst und mit sortierten Schlüsseln und konsistenter Einrückung neu serialisiert:

function normalise(json) {
  const value = JSON.parse(json);
  return JSON.stringify(sortKeys(value), null, 2);
}

function sortKeys(value) {
  if (Array.isArray(value)) return value.map(sortKeys);
  if (value !== null && typeof value === 'object') {
    return Object.keys(value)
      .sort((a, b) => a.localeCompare(b))
      .reduce((acc, key) => {
        acc[key] = sortKeys(value[key]);
        return acc;
      }, {});
  }
  return value;
}

Nach der Normalisierung erzeugen die Dokumente A und B oben dieselbe Zeichenkette:

{
  "active": true,
  "name": "Ada",
  "plan": "pro"
}

Jetzt hebt ein Text-Diff zwischen den beiden normalisierten Strings nur echte Datenunterschiede hervor, kein Format- oder Schlüsselreihenfolge-Rauschen.

Schritt 2 —— Zeilenweise Diff mit LCS

Mit normalisiertem Text wird das Problem: Gegeben zwei Zeilenfolgen, finden Sie die minimale Menge an Änderungen (Einfügungen und Löschungen), die eine in die andere überführt. Das ist das klassische Problem der längsten gemeinsamen Teilfolge (LCS).

Was ist LCS?

Die LCS zweier Folgen ist die längste Teilfolge, die in beiden in derselben Reihenfolge vorkommt, ohne zwingend zusammenhängend zu sein. Zum Beispiel:

Before: ["  active: true", "  name: Ada",   "  plan: pro" ]
After:  ["  active: true", "  name: Ada",   "  plan: team"]

LCS:    ["  active: true", "  name: Ada"]   // 2 Zeilen gemeinsam

// Ergebnis:
//   same:    "  active: true"
//   same:    "  name: Ada"
//   deleted: "  plan: pro"
//   added:   "  plan: team"

Die LCS gibt uns genau den gewünschten Diff: gleich gebliebene Zeilen, gelöschte Zeilen, hinzugefügte Zeilen.

Der DP-Algorithmus

LCS wird mit dynamischer Programmierung gelöst. Für zwei Arrays der Länge m und n bauen wir eine 2D-Tabelle, in der dp[i][j] die Länge der LCS der ersten i Elemente des Vorher-Arrays und der ersten j Elemente des Nachher-Arrays ist:

// DP-Tabelle füllen (bottom-up)
for (let i = m - 1; i >= 0; i--) {
  for (let j = n - 1; j >= 0; j--) {
    dp[i][j] = before[i] === after[j]
      ? dp[i + 1][j + 1] + 1                    // Zeilen passen
      : Math.max(dp[i + 1][j], dp[i][j + 1]);   // den besseren Zweig nehmen
  }
}

// Durch die Tabelle zurückverfolgen, um die Diff-Operationen zu rekonstruieren
let i = 0, j = 0;
while (i < m && j < n) {
  if (before[i] === after[j]) {
    ops.push({ type: 'same', line: before[i++] }); j++;
  } else if (dp[i + 1][j] >= dp[i][j + 1]) {
    ops.push({ type: 'del', line: before[i++] });
  } else {
    ops.push({ type: 'add', line: after[j++] });
  }
}

Zeitkomplexität: O(m × n). Speicherkomplexität: O(m × n). Für typische JSON-Dokumente ist das schnell genug. Für sehr große Dokumente (sagen wir, jeweils mehr als 2000 Zeilen) kann die Tabelle erhebliche Speicher verbrauchen —— in dieser Größenordnung lohnt sich der Wechsel zum Myers-Diff-Algorithmus, der in O(n + d²) läuft, wobei d die Zahl der Unterschiede ist.

Speicheroptimierung: typed arrays

Ein normales 2D-JavaScript-Array von Zahlen hat erheblichen Overhead pro Element. Ein flaches Int32Array reduziert den Speicher um etwa Faktor 8 und verbessert die Cache-Lokalität:

const W  = n + 1;
const dp = new Int32Array((m + 1) * W); // flacher Buffer

// dp[i][j] als dp[i * W + j] ansprechen
dp[i * W + j] = before[i] === after[j]
  ? dp[(i + 1) * W + (j + 1)] + 1
  : Math.max(dp[(i + 1) * W + j], dp[i * W + (j + 1)]);

Für ein Dokument mit 500 Zeilen reduziert das die Tabelle von ~2 MB Heap-Objekten auf ~1 MB zusammenhängenden typisierten Speicher.

Schritt 3 —— Benachbarte Änderungen zu „modifizierten“ Zeilen paaren

Die rohe LCS-Ausgabe kennt nur Löschungen und Hinzufügungen. In einer Side-by-Side-Diff-Ansicht sieht es jedoch viel besser aus, eine gelöschte Zeile mit der hinzugefügten Zeile zu paaren, die sie ersetzt hat —— eine „modifizierte“ Zeile:

// Rohe Ops aus der LCS:
del  "  plan: pro"
add  "  plan: team"

// Nach dem Pairing:
modified  left: "  plan: pro"   right: "  plan: team"

Der Pairing-Algorithmus sammelt aufeinanderfolgende Blöcke von del- und add-Operationen und reißverschlusst sie. Nicht gepaarte Löschungen bekommen rechts einen leeren Platzhalter; nicht gepaarte Hinzufügungen links:

// Einen aufeinanderfolgenden del/add-Block sammeln
const dels = [], adds = [];
while (ops[i].type !== 'same') {
  if (ops[i].type === 'del') dels.push(ops[i].line);
  else                        adds.push(ops[i].line);
  i++;
}

// Reißverschluss zu modifizierten Paaren
const pairs = Math.min(dels.length, adds.length);
for (let k = 0; k < pairs; k++)
  rows.push({ type: 'modified', left: dels[k], right: adds[k] });

// Verbleibende nicht gepaarte Löschungen
for (let k = pairs; k < dels.length; k++)
  rows.push({ type: 'deleted', left: dels[k] });

// Verbleibende nicht gepaarte Hinzufügungen
for (let k = pairs; k < adds.length; k++)
  rows.push({ type: 'added', right: adds[k] });

Schritt 4 —— Semantischer Diff für die Zusammenfassung

Der Zeilendiff sagt Ihnen, welcher Text sich geändert hat. Ein semantischer Diff sagt Ihnen, welche Felder sich wie geändert haben —— nützlich für eine Zusammenfassung wie „3 hinzugefügt, 1 entfernt, 2 geändert“.

Der semantische Diff durchläuft beide geparsten Objekte rekursiv und gleichzeitig:

function diffValue(key, path, before, after) {
  if (before === undefined) return { status: 'added',   after  };
  if (after  === undefined) return { status: 'removed', before };

  if (isObject(before) && isObject(after)) {
    const keys     = union(Object.keys(before), Object.keys(after)).sort();
    const children = keys.map(k =>
      diffValue(k, path + '.' + k, before[k], after[k])
    );
    return {
      status: children.every(c => c.status === 'unchanged') ? 'unchanged' : 'changed',
      children,
    };
  }

  if (Array.isArray(before) && Array.isArray(after)) {
    const len      = Math.max(before.length, after.length);
    const children = Array.from({ length: len }, (_, i) =>
      diffValue(i, path + '[' + i + ']', before[i], after[i])
    );
    return {
      status: children.every(c => c.status === 'unchanged') ? 'unchanged' : 'changed',
      children,
    };
  }

  return deepEqual(before, after)
    ? { status: 'unchanged', before, after }
    : { status: 'changed',   before, after };
}

Durch den entstandenen Baum zu gehen und Blätter nach Status zu zählen ergibt die Zusammenfassungs-Zahlen. Anders als der Zeilendiff ist der semantische Diff von Konstruktion her schlüsselreihenfolgen-agnostisch —— er vergleicht Werte am gleichen Schlüsselpfad, unabhängig von der Reihenfolge ihres Auftretens im JSON.

Alles zusammensetzen

Ein vollständiges JSON-Diff-Tool verkettet diese Schritte:

function jsonDiff(leftText, rightText) {
  // 1. Parsen
  const leftVal  = JSON.parse(leftText);
  const rightVal = JSON.parse(rightText);

  // 2. Semantischer Diff → Zusammenfassungs-Zahlen
  const summary = summarise(diffValue('root', '
#x27;, leftVal, rightVal)); // 3. Normalisieren (Schlüssel sortieren, konsistente Einrückung) const leftNorm = JSON.stringify(sortKeys(leftVal), null, 2); const rightNorm = JSON.stringify(sortKeys(rightVal), null, 2); // 4. LCS-Zeilendiff const rows = buildLineDiff(leftNorm, rightNorm); return { summary, rows }; }

Deep-Equal vs struktureller Diff

Eine häufige Abkürzung ist, zwei JSON-Dokumente mit deepEqual zu vergleichen (Nodes assert.deepStrictEqual, Lodashs isEqual) —— schnell, aber nur boolesch. Es sagt Ihnen, dass die Dokumente sich unterscheiden; es sagt Ihnen nicht wo oder wie.

Ein struktureller Diff durchläuft beide Bäume und erzeugt einen Bericht (Zählungen, Pfade, Vorher-/Nachher-Werte) und optional einen JSON Patch. Verwenden Sie deepEqual, wenn die Antwort ja/nein ist —— in Tests, bei Cache-Invalidierung. Verwenden Sie einen strukturellen Diff, wenn ein Mensch auf Basis der Unterschiede handeln muss.

Aus dem Diff einen JSON Patch erzeugen

Sobald Sie einen semantischen Diff-Baum haben, kann der gleiche Durchlauf, der hinzugefügte/entfernte/geänderte Knoten zählt, einen JSON Patch (RFC 6902) ausgeben —— eine portierbare Liste von add / remove / replace-Operationen, die das „Vorher“-Dokument in das „Nachher“ verwandelt. So nimmt man einen Diff und versendet ihn als Body einer HTTP-PATCH-Anfrage oder wendet ihn später anderswo an.

function toJsonPatch(node, pointer = '') {
  const ops = [];
  if (node.status === 'added')   ops.push({ op: 'add',     path: pointer, value: node.after });
  if (node.status === 'removed') ops.push({ op: 'remove',  path: pointer });
  if (node.status === 'changed' && !node.children) {
    ops.push({ op: 'replace', path: pointer, value: node.after });
  }
  for (const child of node.children ?? []) {
    const seg = String(child.key).replace(/~/g, '~0').replace(/\//g, '~1');
    ops.push(...toJsonPatch(child, pointer + '/' + seg));
  }
  return ops;
}

Die Pfadsegmente folgen den Regeln von JSON Pointer (RFC 6901) —— beachten Sie die ~0 / ~1-Escapes für ~ und / in Schlüsseln. Bibliotheken wie fast-json-patch erzeugen dasselbe, wenn Sie es nicht selbst schreiben wollen. Wird der Patch über HTTP gesendet, verwenden Sie den Content-Type application/json-patch+json; für die einfachere „Overlay“-Alternative, bei der null Löschung bedeutet, siehe JSON Patch vs JSON Merge Patch.

Erwähnenswerte Randfälle

  • Umordnung von Array-Elementen —— der semantische Diff vergleicht Arrays positionsbasiert ([0] vs [0]), also kann jedes Element als „geändert“ erscheinen, wenn ein Array zwischen Versionen anders sortiert ist, selbst wenn die Daten gleich sind. Das gut zu behandeln erfordert LCS auf Array-Ebene, was die Komplexität deutlich erhöht.
  • Sehr große Dokumente —— die O(m × n)-LCS-Tabelle kann bei Dokumenten mit Zehntausenden Zeilen den Speicher erschöpfen. Praktische Heuristik: Wenn m × n einen Schwellenwert (z. B. 2 Millionen) überschreitet, fallen Sie darauf zurück, alle Zeilen als geändert anzuzeigen, statt den vollständigen Diff zu berechnen.
  • Numerische Präzision —— JSON.parse() konvertiert alle Zahlen in IEEE-754-Doubles. Sehr große Ganzzahlen (über 2⁵³) verlieren leise an Präzision, sodass zwei Dokumente, die sich nur in den letzten Ziffern einer großen Ganzzahl unterscheiden, nach dem Parsen als gleich verglichen werden können.

Häufig gestellte Fragen

Wie vergleiche ich zwei JSON-Dateien?

Parsen Sie beide, normalisieren Sie sie (Schlüssel sortieren, konsistente Einrückung) und lassen Sie dann einen strukturellen Diff laufen statt eines einfachen Text-Diffs —— oder fügen Sie beide in JSON Diff ein, das genau das tut und eine farbcodierte Side-by-Side-Ansicht zeigt.

Warum funktioniert ein einfacher Text-Diff für JSON nicht?

Weil JSON-Objekte ungeordnet sind und Whitespace bedeutungslos ist. Ein Text-Diff meldet umgeordnete Schlüssel und Reformatierung als Änderungen; ein JSON-bewusster Diff ignoriert beides und meldet nur echte Datenunterschiede.

Was ist ein semantischer JSON-Diff?

Einer, der beide geparsten Strukturen durchläuft und Werte am gleichen Schlüsselpfad unabhängig von der Reihenfolge vergleicht und Zählungen wie „3 hinzugefügt, 1 entfernt, 2 geändert“ erzeugt. Per Konstruktion schlüsselreihenfolgen-agnostisch.

Kann der Vergleich von JSON numerische Präzision verlieren?

Ja —— JSON.parse() konvertiert Zahlen in IEEE-754-Doubles, sodass Ganzzahlen über 2⁵³ als gleich verglichen werden können, selbst wenn ihre letzten Ziffern differieren. Die Kanonisierungsregeln in RFC 8785 adressieren verwandte Themen.

Probieren Sie das JSON-Diff-Tool

JSON Diff auf fixjson.org implementiert alles oben Beschriebene: Es parst beide Dokumente, normalisiert mit sortierten Schlüsseln, lässt den LCS-Zeilendiff laufen und zeigt eine Side-by-Side-Ansicht mit farbcodierten Hinzufügungen, Löschungen und Modifikationen —— plus eine Zusammenfassungszeile mit der Anzahl hinzugefügter, entfernter, geänderter und unveränderter Felder. Es unterstützt auch YAML-Dokumente. Alles läuft in Ihrem Browser; keine Daten werden an einen Server gesendet.