12k
All articles

Wie Sie verhindern, dass JSON Ihre Objekte flachklopft

Beheben Sie JSON-Flattening in JavaScript mit Replacer, Reviver, toJSON und context.source, um Date, Map, Set und BigInt wiederherzustellen.

OpenReplay Team
OpenReplay Team
Wie Sie verhindern, dass JSON Ihre Objekte flachklopft

JSON.stringify konvertiert ein Date, indem es dessen toJSON-Methode aufruft, die einen ISO-8601-String zurückgibt. JSON.parse besitzt keinen entsprechenden Gegenschritt, sodass der Wert als String zurückkommt – es sei denn, Sie konvertieren ihn selbst mit einem Reviver.

Es zeigt sich meist auf dieselbe Weise: Ein zwischengespeichertes Objekt wandert problemlos in den localStorage, kommt problemlos wieder heraus, und dann wirft .getFullYear() einen Fehler oder eine Tabellenzelle rendert Invalid Date. Die Daten waren nie beschädigt. Der Wert hat zwischen den beiden Aufrufen lediglich aufgehört, ein Date zu sein.

Dieser Artikel behandelt beide Hälften der Reise: toJSON und den Replacer auf dem Hinweg, den Reviver auf dem Rückweg sowie das dritte Argument des Revivers für Werte, die an Präzision verlieren, bevor Sie sie überhaupt zu Gesicht bekommen. Grundkenntnisse werden vorausgesetzt; falls Sie diese zuerst benötigen, siehe how to read and write JSON in JavaScript. Dieser Beitrag setzt beim zweiten Argument an.

Die wichtigsten Erkenntnisse

  • JSON.stringify serialisiert ein Date über toJSON als ISO-String, und JSON.parse gibt diesen String unverändert zurück, sofern ihn kein Reviver zurückkonvertiert.
  • Gibt ein Reviver undefined zurück, fällt dieser Schlüssel aus dem Ergebnis heraus. Jeder Reviver benötigt daher ein abschließendes return value für die Schlüssel, die er nicht behandelt.
  • Der Reviver läuft für jedes Schlüssel-Wert-Paar, Kinder vor ihren Eltern, und anschließend ein weiteres Mal für den gesamten geparsten Wert unter dem Schlüssel "".
  • Map und Set werden zu {} serialisiert; ihre Wiederherstellung erfordert daher einen Replacer und einen Reviver, die als aufeinander abgestimmtes Paar geschrieben sind.
  • Das dritte Argument des Revivers ist ein Kontextobjekt, dessen source-Eigenschaft den ursprünglichen JSON-Text enthält. Damit lässt sich eine große Ganzzahl als BigInt auslesen, bevor Number sie rundet.

Wie sieht der fehlerhafte JSON-Round-Trip aus?

const session = { user: "ada", lastLogin: new Date("2024-03-01T09:30:00Z") };

const wire = JSON.stringify(session);
// '{"user":"ada","lastLogin":"2024-03-01T09:30:00.000Z"}'

const back = JSON.parse(wire);
typeof back.lastLogin; // "string"
back.lastLogin.getFullYear(); // TypeError

Die ausgehende Konvertierung ist in Ordnung. Es ist die eingehende, die keine Ahnung hat, was der String einmal war.

Was übersteht die JSON-Serialisierung – und was nicht?

Die Grammatik von JSON bietet für den größten Teil dessen, was ein JavaScript-Objekt enthält, keinen Platz. Alles, was darüber hinausgeht, wird konvertiert oder verworfen. MDN dokumentiert den vollständigen Satz an Serialisierungsregeln für JSON.stringify; die dritte Spalte unten zeigt, was Sie nach einem Parse tatsächlich zurückerhalten.

WertJSON.stringify schreibtJSON.parse liefert
DateISO-String via toJSONString
Map, Set, WeakMap, WeakSet{}leeres Objekt
undefined, Funktion, Symbol in einem ObjektEigenschaft ausgelassenEigenschaft fehlt
Dieselben Werte innerhalb eines Arraysnullnull
NaN, Infinitynullnull
BigIntwirft TypeErrorn/a
Klasseninstanzeinfaches Objekt aus den enumerierbaren eigenen Eigenschafteneinfaches Objekt, Prototyp verloren
Eigenschaft mit Symbol-Schlüsselignoriertfehlt
Zirkuläre Referenzwirft TypeErrorn/a
Geboxtes Number, String, Booleanausgepackter PrimitivwertPrimitivwert

Zwei Zeilen verdienen besondere Beachtung. Map und Set kommen als {} heraus, weil JSON.stringify die eigenen enumerierbaren Eigenschaften eines Objekts durchläuft – und deren Einträge liegen dort nicht. Und innerhalb eines Objekts werden undefined, Funktionen und Symbolwerte vollständig ausgelassen, während dieselben Werte innerhalb eines Arrays zu null werden, sodass die Indizes erhalten bleiben, auch wenn die Werte es nicht tun.

toJSON entscheidet, was geschrieben wird

Wenn ein Wert eine toJSON-Methode besitzt, schreibt JSON.stringify das, was diese Methode zurückgibt, und ignoriert das Objekt selbst. MDNs toJSON-Beispiel zeigt außerdem, dass der Methode der Schlüssel übergeben wird, unter dem ihr Wert liegt – ein und dasselbe Objekt kann also je nach Position unterschiedlich herauskommen.

class Money {
  constructor(amount, currency) {
    this.amount = amount;
    this.currency = currency;
  }
  format() {
    return `${(this.amount / 100).toFixed(2)} ${this.currency}`;
  }
  toJSON() {
    return { __type: "Money", amount: this.amount, currency: this.currency };
  }
}

JSON.stringify({ total: new Money(4599, "EUR") });
// '{"total":{"__type":"Money","amount":4599,"currency":"EUR"}}'

Das Feld __type ist der Diskriminator, nach dem der Reviver suchen wird. Es zu schreiben, ist die ausgehende Hälfte des Vertrags.

Der JSON.parse-Reviver läuft auf dem Rückweg

Der Reviver ist das zweite Argument von JSON.parse und wird für jedes beim Parsen entstehende Schlüssel-Wert-Paar aufgerufen. MDNs Traversierungsbeispiel zeigt die Reihenfolge: zuerst die tiefsten Werte, dann das, was sie enthält, und ein letzter Aufruf umfasst das gesamte Ergebnis unter dem Schlüssel "".

JSON.parse('{"a":1,"b":{"c":2,"d":{"e":3}}}', (key, value) => {
  console.log(JSON.stringify(key));
  return value;
});
// "a", "c", "e", "d", "b", ""

Und nun die Regel, die still und leise Daten vernichtet: Gibt ein Reviver undefined zurück, fällt dieser Schlüssel aus dem Objekt heraus; geschieht das beim Root-Aufruf, kommt der gesamte Parse als undefined zurück.

const json = '{"user":"ada","lastLogin":"2024-03-01T09:30:00.000Z"}';

// Destructive: every unhandled key falls off the end and is deleted.
JSON.parse(json, (key, value) => {
  if (key === "lastLogin") return new Date(value);
});
// undefined

// Correct: the fallback return keeps everything else intact.
JSON.parse(json, (key, value) =>
  key === "lastLogin" ? new Date(value) : value,
);
// { user: "ada", lastLogin: Date 2024-03-01T09:30:00.000Z }

Keine der beiden Varianten wirft einen Fehler. Genau das macht die erste so gefährlich: Der Verlust äußert sich als fehlendes Feld oder als roher ISO-String in der gerenderten Ausgabe, nicht als Stack Trace – also genau die Art von Defekt, die ein Session Replay lange vor dem ersten Bug-Report sichtbar macht.

Wie stellt man Klasseninstanzen und Maps wieder her?

Die Wiederherstellung einer echten Instanz erfordert beide Hälften der Reise: toJSON schreibt ein Typ-Tag neben die Daten, und der Reviver prüft auf dieses Tag und übergibt die verbleibenden Felder an den Konstruktor.

const reviver = (key, value) =>
  value && value.__type === "Money"
    ? new Money(value.amount, value.currency)
    : value;

JSON.parse('{"total":{"__type":"Money","amount":4599,"currency":"EUR"}}', reviver)
  .total.format(); // "45.99 EUR"

Dasselbe Muster funktioniert für eingebaute Typen, die kein toJSON besitzen. Eine Map geht über einen Replacer als Entries-Array hinaus und kommt über einen Reviver zurück, der ein Array von Arrays erkennt.

const flags = new Map([["beta", true], ["darkMode", false]]);

const text = JSON.stringify({ flags }, (key, value) =>
  value instanceof Map ? Array.from(value.entries()) : value,
);
// '{"flags":[["beta",true],["darkMode",false]]}'

const restored = JSON.parse(text, (key, value) =>
  Array.isArray(value) && value.every(Array.isArray) ? new Map(value) : value,
);
restored.flags.get("beta"); // true

Dieser Struktur-Test ist eine Vermutung, und bei leeren Arrays geht er daneben: [].every(Array.isArray) ergibt true, sodass ein einfaches [] an beliebiger Stelle der Payload als leere Map zurückkommt. Ein Typ-Tag, wie es Money schreibt, beseitigt das Rätselraten.

Replacer und Reviver sind eine einzige Vereinbarung über ein Wire-Format. Ändern Sie nur eine Seite, bricht der Round-Trip.

Der Replacer: Filtern auf dem Hinweg

Der Replacer ist das zweite Argument von JSON.stringify und kennt zwei Formen. Als Funktion läuft er für jedes Schlüssel-Wert-Paar, und die Rückgabe von undefined lässt die Eigenschaft weg. Als Array wirkt er wie eine Allow-List, bei der nur String- und Number-Einträge zählen und alles andere, was Sie in die Liste schreiben – Symbole eingeschlossen – überhaupt keine Wirkung hat.

const account = { id: 7, email: "ada@example.com", password: "hunter2" };

JSON.stringify(account, (key, value) => (key === "password" ? undefined : value));
// '{"id":7,"email":"ada@example.com"}'

JSON.stringify(account, ["id", "email"]);
// '{"id":7,"email":"ada@example.com"}'

Dieselbe Technik entfernt einen bekannten Rückverweis-Schlüssel, der andernfalls dazu führen würde, dass JSON.stringify bei einem Zyklus einen TypeError wirft. Ein allgemein zyklussicherer Serializer benötigt ein WeakSet besuchter Objekte; das Entfernen eines einzelnen benannten Schlüssels deckt nur den Fall ab, den Sie kennen.

Ein Detail zum zeitlichen Ablauf ist wichtig: toJSON läuft, bevor der Replacer einen Wert sieht. Bei einem Date ist das value-Argument des Replacers daher bereits der ISO-String, während this[key] noch das ursprüngliche Objekt ist.

JSON.stringify({ lastLogin: new Date() }, function (key, value) {
  // Must be a regular function: an arrow function has no `this` binding here.
  return key === "lastLogin" ? this[key].getTime() : value;
});

Das dritte Argument, space, beeinflusst ausschließlich die Formatierung. Fordern Sie mehr als 10 Leerzeichen an, erhalten Sie dennoch 10, und ein Einrückungsstring, der länger als 10 Zeichen ist, wird auf seine ersten 10 Zeichen gekürzt.

Den Originaltext mit context.source auslesen

Das dritte Argument des Revivers ist ein Kontextobjekt, das für jeden Aufruf neu erzeugt wird und dessen source-Eigenschaft den ursprünglichen JSON-Text des Wertes enthält. Dieses Argument taucht ausschließlich bei Primitivwerten auf; ein Objekt oder ein Array erhält nichts. Dahinter steht der TC39-Vorschlag zum Zugriff auf den JSON.parse-Quelltext, der Stage 4 erreicht hat und mit ECMAScript 2026, am 30. Juni 2026 von Ecma International verabschiedet, ausgeliefert wurde.

Er löst einen Verlust, der eintritt, bevor irgendein Reviver eingreifen könnte: Wenn Sie value erhalten, wurde eine große Ganzzahl bereits auf einen Double gerundet.

const wire = '{"orderId": 9007199254740993}';

JSON.parse(wire).orderId;
// 9007199254740992  <- precision already gone

JSON.parse(wire, (key, value, context) =>
  key === "orderId" ? BigInt(context.source) : value,
).orderId;
// 9007199254740993n

value ist das verlustbehaftete Produkt. context.source ist das, was tatsächlich über die Leitung kam. Prüfen Sie die Verfügbarkeit in Ihren Ziel-Runtimes, bevor Sie sich darauf verlassen.

Fazit

Serialisierung ist ein Vertrag, den Sie zweimal schreiben: einmal in toJSON oder einem Replacer, einmal in einem Reviver, der versteht, was die erste Hälfte erzeugt hat. Gehen Sie die Objekte durch, die Sie in den localStorage oder eine Cache-Schicht schreiben, identifizieren Sie diejenigen, die Date, Map, Set oder Klasseninstanzen tragen, und geben Sie jedem ein Typ-Tag sowie einen passenden Reviver-Zweig. Prüfen Sie anschließend, ob jeder bereits vorhandene Reviver mit einem Fallback-return value endet.

FAQs

Macht structuredClone einen Reviver überflüssig?

Nein, denn structuredClone erzeugt eine In-Memory-Kopie statt eines JSON-Strings und kann daher nicht in den localStorage oder einen Request-Body geschrieben werden. Es bewahrt zwar Date, Map, Set und zirkuläre Referenzen, wirft jedoch bei Funktionen einen DataCloneError und kopiert die Prototypenkette nicht, sodass eine Klasseninstanz weiterhin als einfaches Objekt ohne ihre Methoden ankommt. Für die Wiederherstellung von Instanzen aus Text wird nach wie vor ein Reviver benötigt.

Sollte ich toJSON oder eine Replacer-Funktion verwenden?

Verwenden Sie toJSON, wenn der Typ sein eigenes Wire-Format besitzt: Die Methode liegt auf der Klasse, sodass jede Serialisierung dieses Wertes dieselbe Struktur erzeugt, ohne dass der Aufrufer etwas tun muss. Verwenden Sie einen Replacer, wenn die Regel zu einer einzelnen Aufrufstelle gehört, etwa beim Entfernen eines Passwortfeldes oder beim Konvertieren einer Map aus einer Bibliothek, die Sie nicht kontrollieren. toJSON läuft zuerst, der Replacer erhält also das, was toJSON zurückgegeben hat.

Läuft der Reviver auch für Array-Elemente?

Ja. Array-Indizes werden dem Reviver als Strings übergeben, das erste Element kommt also mit dem Schlüssel '0' an, und das Array selbst wird anschließend unter seinem eigenen Schlüssel nach oben gereicht. Die Rückgabe von undefined für ein Element löscht dieses Element, anstatt die übrigen zu verschieben, und hinterlässt eine Lücke, während die Array-Länge unverändert bleibt. Array-Reviver brauchen dasselbe abschließende return value wie Objekt-Reviver.

Wie typisiere ich einen JSON.parse-Reviver in TypeScript?

JSON.parse gibt in TypeScript any zurück, unabhängig davon, was der Reviver tut. Die Standardbibliothek deklariert den Reviver mit einem string-Schlüssel, einem any-Wert und einem any-Rückgabetyp, sodass ein Reviver, der Date- oder Klasseninstanzen wiederherstellt, dem Compiler keinerlei zusätzliche Information liefert. Annotieren Sie das Ergebnis an der Aufrufstelle mit einem expliziten Typ oder schicken Sie den geparsten Wert durch einen Schema-Validator, bevor Sie seiner Struktur vertrauen.

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.