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.
Inhaltsverzeichnis
- 1. Warum === und JSON.stringify für Objektvergleiche scheitern
- 2. Grundgerüst einer rekursiven deepEqual-Funktion
- 3. Primitive Werte, NaN und Object.is-Semantik
- 4. Arrays rekursiv vergleichen
- 5. Objekte: Schlüsselmenge und Werte vergleichen
- 6. Sonderfälle: Map, Set und Date korrekt behandeln
- 7. Zirkuläre Referenzen ohne Endlosschleife erkennen
- 8. Performance: wann sich Deep Equality lohnt
- 9. Eigene Implementierung im Vergleich zu Alternativen
- 10. Zusammenfassung
- 11. FAQ
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.