Deep Equality ohne externe Bibliothek: Eigene Vergleichsfunktion für verschachtelte Objekte
AI generated
JS
() =>
JavaScript · Rekursion · Objektvergleich · Design Patterns
Deep Equality ohne externe Bibliothek
Eigene Vergleichsfunktion für verschachtelte Objekte

=== vergleicht Objekte nur über Referenzidentität, und JSON.stringify scheitert an Schlüsselreihenfolge, Maps, Sets und Dates. Eine selbst geschriebene Deep-Equality-Funktion vergleicht Werte rekursiv nach Struktur statt nach Referenz und deckt dabei genau die Datentypen ab, an denen die einfachen Ansätze regelmäßig scheitern.

15 Min. Lesezeit deepEqual · Rekursion · Maps/Sets · Zirkuläre Referenzen Vanilla JS · ohne lodash

1. Warum === und JSON.stringify für Objektvergleiche scheitern

Der Operator === vergleicht Objekte in JavaScript ausschließlich über Referenzidentität. Zwei separat erzeugte Objekte mit identischem Inhalt sind für === immer ungleich, weil es sich um zwei verschiedene Stellen im Speicher handelt. Für Deep Equality, also den Vergleich nach struktureller Übereinstimmung statt nach Referenz, ist === deshalb grundsätzlich ungeeignet und war es auch nie beabsichtigt zu sein.

Der naheliegende Workaround, zwei Objekte über JSON.stringify(a) === JSON.stringify(b) zu vergleichen, wirkt zunächst charmant, hat aber mehrere reale Schwächen. Die Reihenfolge der Objektschlüssel beeinflusst das Ergebnis, obwohl sie für Deep Equality irrelevant sein sollte. undefined-Werte werden beim Serialisieren stillschweigend entfernt. Maps, Sets, Dates und Funktionen werden entweder falsch oder gar nicht serialisiert. Wer Deep Equality korrekt implementieren will, kommt an einer eigenen rekursiven Vergleichsfunktion nicht vorbei.

2. Grundgerüst einer rekursiven deepEqual-Funktion

Das Grundgerüst einer Deep-Equality-Funktion folgt immer demselben Muster: zuerst wird die Referenzgleichheit geprüft, als schnelle Abkürzung für den häufigsten Fall, dass beide Werte dasselbe Objekt sind. Danach wird der Typ beider Werte bestimmt, und je nach Typ wird entweder ein einfacher Wertvergleich oder ein rekursiver struktureller Vergleich durchgeführt. Diese Typunterscheidung ist der Kern jeder korrekten Deep-Equality-Implementierung.

Wichtig ist die Reihenfolge der Prüfungen: primitive Werte und null müssen vor der eigentlichen Objekt-Rekursion abgehandelt werden, weil typeof null === "object" sonst zu einem Fehler in der weiteren Verarbeitung führen würde. Eine saubere deepEqual-Funktion beginnt deshalb immer mit expliziten Guards für null, primitive Typen und die Referenzgleichheits-Abkürzung, bevor die eigentliche rekursive Logik greift.


function deepEqual(a, b) {
  // Fast path: same reference or identical primitive
  if (Object.is(a, b)) return true;

  // Null and non-object types cannot be recursed into
  if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) {
    return false;
  }

  // Different constructors mean different structural shape
  if (a.constructor !== b.constructor) return false;

  // Dispatch to type-specific comparison logic
  if (Array.isArray(a)) return compareArrays(a, b);
  if (a instanceof Map) return compareMaps(a, b);
  if (a instanceof Set) return compareSets(a, b);
  if (a instanceof Date) return a.getTime() === b.getTime();

  return compareObjects(a, b);
}

3. Primitive Werte, NaN und Object.is-Semantik

Ein subtiler, aber wichtiger Punkt bei Deep Equality ist die Behandlung von NaN. Mit dem regulären Gleichheitsoperator ist NaN === NaN immer false, obwohl beide Werte im Sinne von Deep Equality intuitiv als gleich gelten sollten, wenn sie an derselben Stelle in zwei verglichenen Objekten stehen. Object.is löst dieses Problem korrekt, weil Object.is(NaN, NaN) true zurückgibt, während der reguläre Vergleichsoperator hier versagt.

Ein zweiter Unterschied betrifft +0 und -0: +0 === -0 ist true, während Object.is(+0, -0) false zurückgibt. Für die meisten Anwendungsfälle von Deep Equality ist das Verhalten von === bei Null hier erwünschter, weshalb manche Implementierungen bewusst eine Mischung aus beiden Semantiken wählen: Object.is für die NaN-Behandlung, aber ohne die Unterscheidung von positiver und negativer Null. Die Wahl hängt vom konkreten Anwendungsfall ab und sollte bewusst getroffen werden.

4. Arrays rekursiv vergleichen

Beim Vergleich zweier Arrays prüft eine korrekte Deep-Equality-Implementierung zuerst die Länge. Unterscheiden sich die Längen, können die Arrays niemals gleich sein, und die Funktion kann sofort false zurückgeben, ohne die Elemente überhaupt zu betrachten. Erst wenn die Längen übereinstimmen, iteriert die Funktion über die Indizes und ruft deepEqual rekursiv für jedes Elementpaar auf.

Wichtig ist, dass die Reihenfolge der Elemente bei Arrays im Gegensatz zu Objektschlüsseln relevant ist: [1, 2, 3] und [3, 2, 1] sind strukturell nicht gleich, auch wenn sie dieselben Werte enthalten. Diese Unterscheidung zwischen ordnungssensitiven Arrays und ordnungsunabhängigen Objektschlüsseln ist ein häufiger Fehlerpunkt bei selbst geschriebenen Deep-Equality-Funktionen und sollte in Tests explizit abgedeckt werden.


function compareArrays(a, b) {
  if (a.length !== b.length) return false;

  // Order matters for arrays — index by index comparison
  for (let i = 0; i < a.length; i++) {
    if (!deepEqual(a[i], b[i])) return false;
  }
  return true;
}

5. Objekte: Schlüsselmenge und Werte vergleichen

Bei gewöhnlichen Objekten muss eine Deep-Equality-Funktion zwei Dinge prüfen: erstens, ob beide Objekte dieselbe Menge an eigenen, aufzählbaren Schlüsseln besitzen, und zweitens, ob die Werte zu jedem Schlüssel rekursiv gleich sind. Die Schlüsselmenge lässt sich mit Object.keys ermitteln, wobei zunächst die Anzahl der Schlüssel verglichen wird, um asymmetrische Fälle wie ein Objekt mit einem zusätzlichen Schlüssel schnell auszuschließen.

Für die Prüfung, ob ein Schlüssel im jeweils anderen Objekt existiert, sollte Object.hasOwn statt der klassischen hasOwnProperty-Methode verwendet werden, um Probleme mit null-Prototyp-Objekten oder überschriebenen Methoden zu vermeiden. Erst wenn Schlüsselanzahl und Schlüsselexistenz übereinstimmen, vergleicht die Funktion die Werte selbst rekursiv über deepEqual.


function compareObjects(a, b) {
  const keysA = Object.keys(a);
  const keysB = Object.keys(b);
  if (keysA.length !== keysB.length) return false;

  return keysA.every((key) => {
    // Object.hasOwn avoids pitfalls with null prototypes or overridden methods
    return Object.hasOwn(b, key) && deepEqual(a[key], b[key]);
  });
}

6. Sonderfälle: Map, Set und Date korrekt behandeln

Map, Set und Date lassen sich nicht mit der generischen Objekt-Vergleichslogik behandeln, weil ihre relevanten Daten nicht in aufzählbaren Eigenschaften stecken, sondern in internen Slots. Für Date genügt der Vergleich der Zeitstempel über getTime(). Für Map müssen zunächst die Größen verglichen werden, danach für jeden Schlüssel im ersten Map geprüft werden, ob im zweiten Map ein Wert existiert, der rekursiv gleich ist.

Bei Set ist die Situation komplexer, weil Sets keine Schlüssel haben, über die man direkt nachschlagen kann. Für einfache, primitive Werte funktioniert set.has(value), für Objekte als Set-Elemente ist ein naiver Ansatz mit has aber nicht ausreichend, weil has selbst wieder auf Referenzgleichheit prüft. Für den vollständig korrekten Fall müsste man jedes Element im ersten Set gegen jedes verbleibende Element im zweiten Set mit deepEqual testen, was die Komplexität quadratisch macht, aber für die in der Praxis üblichen kleinen Sets unproblematisch bleibt.


function compareMaps(a, b) {
  if (a.size !== b.size) return false;
  for (const [key, valueA] of a) {
    if (!b.has(key) || !deepEqual(valueA, b.get(key))) return false;
  }
  return true;
}

function compareSets(a, b) {
  if (a.size !== b.size) return false;
  const remaining = [...b];
  return [...a].every((itemA) => {
    const index = remaining.findIndex((itemB) => deepEqual(itemA, itemB));
    if (index === -1) return false;
    remaining.splice(index, 1); // avoid matching the same element twice
    return true;
  });
}

7. Zirkuläre Referenzen ohne Endlosschleife erkennen

Enthält ein Objekt eine Referenz auf sich selbst, etwa durch obj.self = obj, würde eine naive rekursive deepEqual-Funktion in eine Endlosschleife laufen und mit einem Stack-Overflow abstürzen. Die Lösung ist ein zusätzlicher Parameter, meist ein WeakMap, der bereits besuchte Objektpaare während des Vergleichs zwischenspeichert. Trifft die Funktion auf ein Paar, das bereits als "wird gerade verglichen" markiert ist, gibt sie true zurück, statt erneut zu rekurrieren.

Diese Technik ist dieselbe, die auch structuredClone intern für zirkuläre Strukturen verwendet, nur dass hier statt einer Kopie ein Vergleichsergebnis gespeichert wird. Für die meisten Anwendungsfälle in der Praxis, etwa Konfigurationsobjekte oder API-Antworten, treten zirkuläre Referenzen selten auf. Für eine robuste, allgemein verwendbare Deep-Equality-Funktion in einer gemeinsam genutzten Bibliothek sollte die Absicherung aber trotzdem eingebaut werden, um Abstürze bei unerwarteten Datenstrukturen zu vermeiden.

8. Performance: wann sich Deep Equality lohnt

Deep Equality hat, anders als der einfache Referenzvergleich mit ===, eine Laufzeitkomplexität, die von der Größe und Verschachtelungstiefe der verglichenen Strukturen abhängt. Bei großen, tief verschachtelten Objekten kann ein vollständiger struktureller Vergleich spürbar länger dauern als ein einfacher Referenzvergleich. In Render-kritischem Code, etwa in React-Komponenten, die bei jedem Render einen Deep-Equality-Check durchführen, kann das zu messbaren Performance-Problemen führen.

Eine sinnvolle Optimierung ist, Deep Equality nur dort einzusetzen, wo sie wirklich benötigt wird, etwa beim Vergleich von Zustandsobjekten vor einem teuren Re-Render, und für häufige, performance-kritische Pfade stattdessen auf flache Vergleiche oder unveränderliche Datenstrukturen mit strukturellem Sharing zu setzen, bei denen Referenzgleichheit bereits inhaltliche Gleichheit garantiert. Die Wahl zwischen beiden Ansätzen ist eine bewusste Architekturentscheidung, keine reine Implementierungsdetail-Frage.

9. Eigene Implementierung im Vergleich zu Alternativen

Die folgende Tabelle vergleicht die gängigen Ansätze für Objektvergleiche in JavaScript und zeigt, wann eine eigene deepEqual-Funktion tatsächlich die richtige Wahl ist.

Ansatz Maps/Sets/Dates Schlüsselreihenfolge egal Zirkuläre Referenzen
=== Nur Referenz Nicht anwendbar Kein Problem
JSON.stringify-Vergleich Falsch/fehlend Relevant, sollte es nicht sein Wirft Exception
Eigene deepEqual-Funktion Korrekt behandelt Korrekt ignoriert Mit WeakMap absicherbar
Externe Bibliothek (lodash) Korrekt behandelt Korrekt ignoriert Korrekt behandelt

Eine externe Bibliothek bleibt in großen Projekten mit vielen Randfällen oft die pragmatischere Wahl, aber für kleine Projekte, Bundle-Größen-sensitive Anwendungen oder zu Lernzwecken zeigt eine eigene deepEqual-Implementierung genau, welche Fallstricke ein korrekter Objektvergleich wirklich hat.

Mironsoft

JavaScript-Architektur, Datenstrukturen und Performance

Zustandsvergleiche, die korrekt und performant bleiben?

Wir analysieren bestehende Vergleichslogik in eurem Code, ersetzen fehleranfällige JSON.stringify-Vergleiche durch robuste Deep-Equality-Patterns und optimieren Performance-kritische Vergleichspfade in State-Management und Rendering.

Code-Review

Fehleranfällige Objektvergleiche im Bestandscode identifizieren

Performance-Tuning

Deep-Equality-Aufrufe in Render-kritischen Pfaden optimieren

Architektur-Beratung

Unveränderliche Datenstrukturen und strukturelles Sharing einführen

10. Zusammenfassung

Eine eigene Deep-Equality-Funktion ersetzt sowohl den unzureichenden Referenzvergleich mit === als auch den fehleranfälligen JSON.stringify-Trick durch eine rekursive Struktur, die primitive Werte mit Object.is-Semantik behandelt, Arrays ordnungssensitiv vergleicht, Objekte über Schlüsselmenge und -werte prüft und Map, Set sowie Date über ihre jeweilige interne Struktur korrekt behandelt.

Für robuste, wiederverwendbare Implementierungen sind zwei zusätzliche Aspekte entscheidend: der Schutz vor zirkulären Referenzen über eine WeakMap-basierte Besuchsverfolgung und ein bewusstes Verständnis der Performance-Kosten, die Deep Equality bei großen, tief verschachtelten Strukturen verursacht. Wer diese Punkte beachtet, hat mit wenigen Dutzend Zeilen Code eine Deep-Equality-Lösung, die für die meisten Projekte ausreicht, ohne eine externe Bibliothek einzubinden.

Deep Equality selbst implementieren — Das Wichtigste auf einen Blick

Grundstruktur

Fast-Path über Object.is, Typ-Guards für null/primitive, danach typ-spezifische rekursive Vergleichslogik.

Sonderfälle

Map, Set und Date benötigen eigene Vergleichslogik, generische Objekt-Iteration reicht hier nicht aus.

Zirkuläre Referenzen

WeakMap-basierte Besuchsverfolgung verhindert Endlosschleifen bei selbstreferenzierenden Objekten.

Performance

Deep Equality gezielt einsetzen, nicht pauschal in jedem Render-Zyklus, um Laufzeitkosten zu begrenzen.

11. FAQ: Deep Equality ohne externe Bibliothek

1Warum reicht === nicht aus?
=== vergleicht nur Referenzen, inhaltlich identische, separate Objekte gelten immer als ungleich.
2Warum ist JSON.stringify-Vergleich problematisch?
Schlüsselreihenfolge beeinflusst das Ergebnis, und Map, Set, Date werden falsch oder gar nicht serialisiert.
3Wie wird NaN behandelt?
Mit Object.is, weil Object.is(NaN, NaN) korrekt true ergibt, anders als der reguläre Gleichheitsoperator.
4Ist Array-Reihenfolge relevant?
Ja, anders als bei Objektschlüsseln gilt [1,2,3] als unterschiedlich zu [3,2,1].
5Wie vergleicht man Maps?
Größe vergleichen, dann jeden Schlüssel im ersten Map gegen den passenden Wert im zweiten prüfen.
6Warum sind Sets komplexer?
Keine Indizes vorhanden, Objekt-Elemente müssen paarweise mit deepEqual getestet werden.
7Wie verhindert man Endlosschleifen?
Mit einer WeakMap, die bereits verglichene Objektpaare zwischenspeichert.
8Ist Deep Equality immer teurer?
Ja, abhängig von Größe und Tiefe. In Render-kritischem Code gezielt einsetzen.
9Immer lodash nutzen?
Nicht zwingend, eine eigene kompakte Funktion reicht in vielen Projekten aus.
10Wichtigster erster Implementierungsschritt?
Guards für Object.is-Referenzgleichheit und null/primitive Typen vor der Rekursion.