Maps vs. Plain Objects in JavaScript

Veröffentlicht am 7. Februar 2018

Als Webtechnologie-Erklärbär genieße ich den zweifelhaften Luxus, vergleichsweise selten selbst Code schreiben zu dürfen (und stattdessen sehr viel über Code zu erzählen). Nur im Sommer oder zum Jahresende, wenn niemand eine Schulung haben will, komme ich mal dazu, etwas nicht völlig triviales zu programmieren. Dabei zeigt sich immer, was ich selbst bei meinen eigenen Schulungen gelernt habe. Dieses Jahr hat sich bei mir (d.h. in meinem Code) die Benutzung von Maps (und in geringerem Maße auch Sets) durchgesetzt und normale Objekte bzw. Objekt-Literals verwende ich nur für ausgesuchte Zwecke. Dieser Blogpost versucht euch zu überzeugen, es mir gleichzutun und Maps in euren aktiven JS-Wortschatz aufzunehmen.

Use Cases für Objekte in JS

Von null und undefined abgesehen ist alles in JavaScript entweder ein Objekt oder verhält sich wie eins. Objekte bündeln Primitives oder Unter-Objekte nebst zugehörigen Funktionen und füllen damit genau die Rolle aus, die Objekte in so ziemlich jeder Programmiersprache haben. Andere Programmiersprachen verwenden zur Definition von Objekten oft ein Klassensystem, bei den die Programmierer ein Template für eine Datenstruktur nebst Funktionalität definieren (sowie bei statisch typisierten Sprachen einen entsprechenden Typ).

Vergleichsweise ungewöhnlich an JavaScript ist, dass man mit Objekten recht rücksichtslos umgehen kann. Mit Objekt-Literalen wie { foo: 42 } lassen sich beliebige Objekte aus dem Nichts erzeugen, eine Klasse oder ein Typ muss nicht deklariert werden. Und sofern keine speziellen Vorkehrungen getroffen werden kann das Objekt jederzeit manipuliert werden. Werte können überschrieben werden, Attribute können jederzeit gelöscht oder hinzugefügt werden.

Objekte werden aufgrund dieser großen Benutzerfreundlichkeit sowie aus Mangel an Alternativen für zwei sehr unterschiedliche Use Cases verwendet:

  • Als eher statische Objekte in dem Sinne, wie sie auch in anderen Programmiersprachen vorkommen (Objekte/Structs), d.h. als in ihrer Struktur unveränderliche Bündelungen von Daten und Funktionalität.
  • Als eher dynamische Key-Value-Datenstrukturen, in die jederzeit beliebige neue Einträge eingefügt oder aus denen Daten gelöscht werden.

Vom Grundprinzip her sind JS-Objekte also durchaus als eine Key-Value-Datenstruktur zu beschreiben, die beliebige Werte beliebigen String-Keys zuordnet:

// Speichert 42 unter dem Key "foo"
let x = { foo: 42 };

// Überschreibt 42 unter "foo" mit 23
x.foo = 23;

// Liest den unter "foo" gespeicherten Wert aus
let y = x.foo;

// Löscht den Eintrag "foo" nebst Daten aus dem Objekt
delete x.foo;

Diese Vermischung der Use Cases führt offensichtlich nicht zum Untergang des Abendlandes, aber optimal ist sie nicht. Objekte sind keine besonders gute Datenstruktur und mit Maps bietet uns modernes JS mit Maps eine hervorragende Alternative.

Objekte vs. Maps

Herkömmliche JavaScript-Objekte sind Key-Value-Paare, bei denen die Key immer Strings sind. Was kein String ist, aber als Objekt-Key verwendet wird, wird stringifiziert:

let o = {};

o.foo = 1; // Klappt
o[42] = 2; // Klappt
o[{}] = 3; // Klappt


const allKeys = Object.keys(o);
allKeys.every( x => typeof x === "string")
// > true

console.log(allKeys);
// > [ '42', 'foo', '[object Object]' ]

Bei Objekten fungieren also streng genommen nicht die Keys als Keys, sondern die String-Repräsentationen der Keys sind die Keys! Wenn zwei verschiedene Objekte die gleiche String-Repräsentationen haben, sind sie als Objekt-Key austauschbar:

let o = {};

const x = { a: 23 };
const y = { a: 42 };

o[x] = 1;

console.log(o[y]); // > 1

Die Objekte x und y sind völlig unterschiedlich, als Key aber austauschbar. Autsch! Bei Maps hingegen sind die Objekte selbst die Keys:

let m = new Map();

const x = { a: 23 };
const y = { a: 42 };

m.set(x, 1);

console.log(m.get(y)); // > undefined

Nur Objekte, die gemäß === gleich sind (mit wenigen Ausnahmen) werden von Maps als gleiche Keys betrachtet, was in so gut wie allen Fällen sinnvoller ist, als Key-Objekte stets und ständig zu stringifizieren.

Jedes JavaScript-Objekt hat seine Prototypen-Kette – Plain Objects ebenso wie Maps. Das Problem ist hier, dass diese Prototypen-Kette tatsächlich relevant wird, sobald man mit normalen Objekten und der Punkt/Eckklammer-Notation arbeitet:

let o = {}; // leeres Objekt... oder?
typeof o.toString; // > function! Ups...

Die Eigenschaft toString ist eigentlich nicht wirklich auf dem Objekt vorhanden, dank der Vererbungs-Kette aber irgendwie doch schon. Dem Problem lässt sich in der Theorie entgegentreten, indem man Objektzugriff nur per hasOwnProperty() (d.h. unter Umgehung der Prototypen-Kette) durchführt und/oder Objekte grundsätzlich per Object.create(null)(d.h. ganz ohne Prototyp) anlegt. Sollte das auch nur einmal nicht passieren, kann durch das Überschreiben eines „magischen Keys“ wie toString das Verhalten eines Objekts verändert werden. Beide o.g. Maßnahmen helfen im Übrigen nichts, wenn der fragliche magische Key __proto__ ist. Man stelle sich nur folgendes Szenario vor:

const wordCountForInput = {};
const getUserInput = () => "__proto__"; // Stub
const countWords = (x) => x.split(" ").length;
const userInput = getUserInput();
const wordsInUserInput = countWords(userInput);
wordCountForInput[userInput] = wordsInUserInput;

Hier wird mitnichten eine Zahl unter einem String-Key abgespeichert! Durch den magischen Key __proto__ wird vielmehr versucht, ein Number-Primitive als Prototyp des Objekts zu definieren. Das hat in, keinem modernen Browser einen Effekt, aber das bedeutet auch, dass keine Zahl abgespeichert wird. Wäre der Wert an dieser Stelle nicht eine Zahl sondern ein Objekt, könnte alles mögliche passieren – sobald irgendein Nutzer in irgendein Input __proto__ eingibt, kann man seinem Code nicht mehr über den Weg trauen. Das ist zugegebenermaßen ein wenig wahrscheinliches Szenario, aber warum würde man sich dieses Problem überhaupt ans Bein binden wollen, wenn doch Maps zur Verfügung stehen?

const m = new Map();

m.has("toString"); // > false - was auch sonst?

m.set("__proto__", 42); // klappt
m.get("__proto__"); // > 42

Maps haben neben weniger Edge Cases auch den großen Vorteil, Iterierbare Objekte zu sein, Iteratoren für Keys und Values zu bieten und darüber hinaus auch strikt die Insertion Order beizubehalten. Bedeutet:

  1. Über Maps kann ohne weiteres mit for-of-Schleifen iteriert werden
  2. Map.prototype.keys() liefert einen Iterator über alle Keys
  3. Map.prototype.values() liefert einen Iterator über alle Values
  4. In allen drei Fällen werden die Werte in exakt der Reihenfolge ausgespuckt, in der Sie in die Map eingefügt wurden

Somit ist der folgende mit Maps umgesetzte Code …

const m = new Map();
m.set("a", 1);
m.set("b", 2);

for (const [ key, value ] of m) {} // Klappt
const keys = m.keys();   // > Iterator "a", "b"
const vals = m.values(); // > Iterator 1, 2

const clone = new Map(m); // Maps können mit Iterables initialisiert werden

… zwar auch mit normalen Objekten machbar …

const o = {};
o.a = 1;
o.b = 2;

for (const [ key, value ] of o) {} // Klappt nicht
for (const key in o) {} // Bester Ersatz, aber bezieht Prototypen mit ein
const keys = Object.keys(o); // > Array "a", "b"
const vals = Object.values(o); // > Array 1, 2 (seit ES2017)

const clone = Object.assign({}, o);

… aber wieder mit Edge Cases und weniger Komfort. Bei for-in ist zu beachten, dass auch über Prototyp-Properties iteriert wird und die Reihenfolge der Properties ist weder durch den Standard noch durch real existierende Implementierungen garantiert – die Keys und Values können also in jeder beliebigen Reihenfolge ausgegeben werden! Das ist natürlich in sehr vielen Fällen kein Problem, nur warum würde man all den Kleinigkeiten überhaupt die Chance geben wollen, zu einem Problem zu werden?

Kurz und gut: Maps können fast alles, was Plain Objects können, aber mit weniger Edge Cases und mehr eingebauten Features. Das bedeutet allerdings längst nicht, dass man Maps jetzt auch für wirklich alles benutzen sollte.

Was für Objekte spricht

Objekte haben gegenüber Maps auch zahlreiche Vorteile. Die Literal-Syntax ist im Vergleich zum komplizierten new Map() natürlich unschlagbar einfach und auch einfache Wertzuweisung schlägt get() und set() im Benutzerfreundlichkeits-Wettkampf klar. Wenn man den ganzen Tag Code schreibt, ist das ein nicht zu vernachlässigender Bonus.

Außerdem ist es beileibe nicht immer wünschenswert, Objekte unstrinifiziert anhand von Objekt-Identität zu vergleichen. Ich persönlich hatte jüngst eine Map konstruiert, die als Keys x/y-Koordinaten-Objekte verwendete und diesen Koordinaten jeweils andere Objekte zuordnete. Mein Problem: viele hundert Zeilen später wollte ich für gegebene Koordinaten das passende Objekt aus der Map fischen, doch das dann dafür zur Verfügung stehende x/y-Koordinaten-Objekt war (bei gleichem Inhalt) eben ein anderes Objekt. In diesem Fall funktionieren Maps nicht:

const m = new Map();
const coords = { x: 1, y: 2 };
m.set(coords, 42);
const sameCoords = { x: 1, y: 2 };
m.has(sameCoords); // > false - logisch, wenn auch unpraktisch

Manchmal ist eben eine Stringifizierung doch das Mittel der Wahl für Vergleiche. Außerdem gibt es natürlich viele Objekt-Use-Cases, bei denen die Prototyp-Kette ein Feature und kein Hindernis darstellt. Hier haben Maps nichts zu suchen.

Wir stellen also fest: Maps können viele der Aufgaben erfüllen, die in JavaScript klassischerweise von Plain Objects übernommen werden. Es bleibt die Frage, ob und wann sie denn auch wirklich statt eines Plain Objects zum Einsatz kommen sollten.

Fazit und abschließende Empfehlungen

Maps haben einen Use Case, bei dem sie in modernem JS in allen Fällen normale Objekte verdrängen sollten: Key-Value-Datenstrukturen mit unbekannten Keys. Wann immer sich über die Programmlaufzeit ein Objekt mit Daten füllen soll, die zum Zeitpunkt des Programmierens noch nicht bekannt sind, sind Maps das Mittel der Wahl. Unter alle anderen Umständen sind weiterhin normale Objekte vorzuziehen, da sich dort die Nachteile nicht auswirken und das, was im Datenstruktur-Use-Case ein lästiger Edge Case ist (die Prototyp-Kette), ein nützliches Feature ist.

Für die folgenden Fälle sind und bleiben Plain Objects das Mittel der Wahl:

// Struct - Keys bekannt, Prototypen egal
const coords = { x: 1, y: 2 };

// Klasse (Bündel aus Daten und Funktionen)
// Prototypen sind hier ein wichtiges Feature!
class Car {
  drive () {
    console.log("Brumm");
  }
}

// Funktionsparameter (wie Structs)
function foo (options) { ... }
foo({ bar: 23, baz: 42 });

In diesem Szenario sind wir mit einer Map besser beraten:

const userInput = "Hello Hello __proto__ toString";

const countWords = (str) => {
  const counter = new Map();
  const words = str.split(" ");
  for (const word of words) {
    if (counter.has(word)) {
      counter.set(word, counter.get(word) + 1);
    } else {
      counter.set(word, 1);
    }
  }
  return counter;
}

countWords(userInput);
// > Map { 'Hello' => 2, '__proto__' => 1, 'toString' => 1 }

Der Code verschluckt sich nicht an __proto__, liefert garantiert die Wörter in der Reihenfolge, wie sie im Ursprungs-Input vorkamen und toString steht im Output, weil das Wort im Input war – keine Prototyp-Kette der Welt kann dafür verantwortlich sein.

Der einzig gute Grund, auch im Jahr 2018 weiterhin Plain Objects als Key-Value-Datenstruktur zu verwenden, ist der gute alte Legacy-Code. Libraries wie Lo-Dash und viele Millionen Zeilen Bestandscode wurden konzipiert, bevor Maps in ECMAScript landeten. Ich hätte kein Problem damit, bestehende APIs mit guten alten Objekten zu füttern, denn wenn sie sich bis heute gehalten haben, kümmern sich sie bestimmt fein säuberlich um all die bisher aufgezählten Edge Cases und es kann eigentlich nichts schiefgehen. Richtig?

DOMNodeInserted-Events durch Mutation Observer ersetzen

Veröffentlicht am 17. Januar 2018

In den letzten Wochen hatte ich mal wieder Zeit ein wenig selbst zu programmieren und wollte ein wenig mit Höhen und Breiten von dynamisch generierten DOM-Elementen rechnen. Das Problem hierbei ist, dass die wahren Höhen und Breiten eines Elements erst feststehen, wenn es im Dokument gelandet ist. Also galt es, den Zeitpunkt des Einfügens eines Elements in das DOM abzupassen. Das klingt einfacher, als es ist, wenngleich es sich nach kurzer Überlegung dann doch recht simpel lösen ließ.

Das Problem ist, dass es mal ein Event namens DOMNodeInserted gab, das jedoch zusammen mit allen anderen Mutation Events aus den Standards geflogen ist. Als Ersatz sollen MutationObserver herhalten, doch einen direkten Weg zur Beobachtung des eingefügt-werdens gibt es nicht. Also nehmen wir den indirekten Weg:

  1. Wir setzen einen MutationObserver nicht auf unser Ziel-Element, sondern auf irgendein Element im Dokument an, das eines Tages (direkt oder indirekt) unser Element beinhalten wird. Das könnten z.B. document.body oder document.firstElementChild sein.
  2. Der Observer durchsucht bei childList-Ereignissen (d.h. Änderungen an den Kindelementen des observierten Elements) die neu hinzugefügten Knoten nach unserem Ziel-Element. Wird es gefunden, führen wir einen Callback aus.

Die einfachste Implementierung dieses Patterns sieht wie folgt aus:

function observeInsertion (targetNode, callback, rootNode = document.firstElementChild) {
  const insertionObserver = new MutationObserver( (mutations) => {
    for (const mutation of mutations) {
      if (mutation.type === "childList") {
        for (const added of mutation.addedNodes) {
          if (added === targetNode || added.contains(targetNode)) {
            callback(targetNode);
            insertionObserver.disconnect();
            return;
          }
        }
      }
    }
  });
  insertionObserver.observe(rootNode, {
    childList: true,
    subtree: true,
  });
}

In Aktion auf CodePen!

MutationObserver sammeln durch eine DOM-Änderung anfallende Mutations-Events ein und liefern sie im Paket an den Observations-Callback – mutations ist also ein Array von Objekten. Aus diesem picken wir uns in einer for-of-Schleife die childList-Events und durchsuchen die hinzugefügten DOM-Knoten nach unserem Ziel-Element. Da wir den gesamten DOM-Tree beobachten (subtree: true) wird uns das Einfügen unseres Ziel-Elements nicht entgehen, egal wie tief verschachtelt es stattfindet. Sobald wir das Einfügen unseres Elements beobachtet haben, triggern wir den Callback, schalten den MutationObserver mit disconnect() ab und steigen durch das return aus der dann fertig abgearbeiteten Observer-Callback-Funktion aus.

Dieser Code (bzw. seine TypeScript-Variante) haben für mich soweit ganz gut funktioniert. Vermutlich könnte man für den Einsatz auf breiter Front eine noch effizientere Variante basteln, die nicht für jede zu beobachtende Node einen eigenen Observer benötigt, aber im allerbesten Fall kommt man ganz ohne ein Element-Wurde-Eingefügt-Event aus. Sofern es nicht gerade um (wie in meinem Fall) unsauberes Gewurschtel mit Computed Styles geht, gibt es wirklich wenig Gründe, einen Ersatz für DOMNodeInserted überhaupt zu wollen, denn mit Event Delegation und anderen intelligenten Techniken ist es eigentlich egal, wann und ob ein Element im DOM gelandet ist.

Am Ende habe ich den in diesem Artikel gezeigten Mutation Observer selbst aus meinem Code geworfen und die CSS-Rechnerei in CSS-Variablen ausgelagert.

Progressive Web Apps ohne HTTPS auf Mobilgeräten testen

Veröffentlicht am 29. September 2017

Progressive Web Apps (bzw. Service Worker) funktionieren nicht ohne HTTPS und das ist auch gut so. Wir haben 2017 und Webstandards sollten wahrlich Secure By Default sein. HTTPS ist kein Hexenwerk, bedeutet aber doch einen gewissen Aufwand, gerade wenn man als Entwickler nur mal schnell einen Prototyp zusammenschrauben möchte. Daher lassen Browser für die lokale Entwicklung praktischerweise Service Worker auch ohne HTTPS zu – wenn in der Adressleiste irgendwas mit localhost oder 127.x.y.z steht, geht's auch ohne grünes Schlösschen. Also alles in Butter? Nicht ganz! Denn „lokale Entwicklung“ von PWA dürfte in so ziemlich allen Fällen auch das Testen auf Mobilgeräten beinhalten. Und bei deren Browsern wird, auch wenn sie im lokalen WLAN hängen, ganz sicher etwas in der Adressleiste stehen, das den Service Worker HTTPS verlangen lassen wird.

Es gibt diverse browserspezifische Wege, die das Problem zu umschiffen. Die Firefox-Config kennt den Flag devtools.serviceWorkers.testing.enabled, der das HTTPS-Requirement abschaltet und Chrome kann man mit komischen Flags starten oder den Mobile-Browser in lokale Devtools einklinken … alles ziemlich kompliziert und speziell für bestimmte Browser. Es geht aber auch einfacher.

Die HTTPS-Ausnahme für lokale Entwicklung greift, sobald in der Browser-Adressleiste localhost oder 127.x.y.z steht. Also warum nicht einfach einen kleinen Proxy-Server auf dem Entwicklungs-Rechner laufen lassen, der alle Anfragen auf den lokalen App-Server umbiegt? Wenn man diesen Proxy-Server auf dem Mobilgerät verwendet und localhost eingibt, landet man auf dem App-Server und genießt dabei die HTTPS-Ausnahmeregelung. Mit dem Node-Modul http-proxy ist der Proxy-Server schnell gebaut:

// npm install http-proxy
require("http-proxy").createProxyServer({
  target: "http://localhost/projekte/foo/" // lokale App läuft hier
}).listen(8001);

Dann noch Mobilgerät und Entwicklungs-Rechner in ein gemeinsames Netzwerk pflanzen, die IP des Entwicklungs-Rechners mit dem passenden Port als Proxy auf dem Mobilgerät eintragen und schon funktionieren nicht nur Service Worker, sondern auch mit andere Web-APIs, die normalerweise HTTPS brauchen.

Immutable Arrays und Objekte für JavaScript mit Proxies (in 33 Zeilen)

Veröffentlicht am 16. August 2017

Datenstrukturen, deren Inhalt, sobald einmal festlegt, nicht mehr geändert werden kann sind etwas, das in JavaScript von immer mehr Entwicklern verlangt wird. So ganz nachvollziehen kann ich das persönlich das nicht – wann immer ich möchte, dass ein Objekt unveränderlich ist, verändere ich es eben nicht. In meinem Alltags-Code wende ich dieses Verfahren auf 99% meiner gesamten Arrays und Objekte an und es funktioniert ganz hervorragend! Nicht nur werden meine Arrays und Objekte nicht verändert, sondern ich benötige auch keine zusätzliche Library. Und der größte Vorteil: wenn's mal sein muss, kann man ein Array oder Objekt dann doch verändern.

Libraries für Immutable JS

Da mein simpler Hands-off-Ansatz den meisten Entwicklern zu billig ist, gibt es eine Vielzahl von Libraries für Immutable Data Structures wie unter anderem das viel genutzte Immutable.js aus dem Hause Facebook. Die meisten dieser Libraries erschaffen immutable Arrays und Objekte, indem sie diese in nativem JS bereits vorhandenen Objekte fast komplett re-implementieren, wobei nur die APIs zu Daten-Veränderung ausgelassen werden. Keine Mutation-APIs = Immutable JS!

Das ist ein im Prinzip absolut sinnvolles Verfahren um unveränderliche Arrays und Objekte zu erzeugen, aber ideal finde ich es trotzdem nicht:

  1. Re-Implementierte Objekte und Arrays sind aufgrund von API-Unterschieden so gut wie immer von normalen Arrays und Objekten zu unterscheiden. Und damit meine ich nicht, dass Mutations-Methoden fehlen, sondern dass auch der Rest subtil anders ist, als man es von den Originalen kennt. Die Folge: man muss sich sowohl mit „normalen“ Arrays und Objekten herumschlagen als auch ihre leicht seltsamen Brüder und Schwestern im Kopf haben und mit diesen in einem komischen API-Dialekt kommunizieren
  2. Re-Implementierungen sind nicht Future-Proof. Zum Beispiel freue ich mich sehr auf die hoffentlich bald im ECMAScript-Standard landenden Array-Methoden flatMap() und flatten() aber werden diese dann auch in den Immutable-Array-Implementierungen der Library meiner Wahl laden? Vielleicht, eines fernen Tages, wenn der Autor der Library sich die Mühe macht …
  3. Die Re-Implementierungen wiegen in der Regel mehrere Kilobytes. Das ist für sich genommen nicht viel, aber am Ende gilt: nur eingesparte Kilobytes sind wirklich gute Kilobytes!

Als ich kürzlich bei Hacker News eine Diskussion über die Vor- und Nachteile diverser Libraries rund um Immutable Arrays und Objekte verfolgte, dachte ich mir, das müsste doch auch einfacher möglich sein … und zwar mit Proxies!

Proxies

Ein Proxy im einem Rechnernetz ist eine Kommunikationsschnittstelle zwischen zwei Parteien, die Daten durchleitet und gegebenenfalls auf die Daten reagiert, indem bestimmte Ereignisse auslöst oder die durchzuleitenden Daten manipuliert. Ein JavaScript-Proxy funktioniert genau so, nur sind die Kommunikationspartner hier JS-Objekte und die durchgeleiteten Daten sind Objekt-Operationen wie x.foo = 42. Ein kleines Beispiel:

let originalObj = { x: 42, y: "Hallo" };

// Der Proxy-Hander enthält "traps", d.h. die
// Logik für das Abfangen bestimmter Operationen
const handler = {

  // Get-Trap für das Auslesen von Properties auf
  // dem Objekt, für das der Proxy als Proxy fungiert
  // "targetObj" ist das Ziel-Objekt, "property" die
  // angfragte Eigenschaft. Der Rückgabewert dieser
  // Funktion bestimmt die Antwort
  get: function (targetObj, property){
    // Angefragte Eigenschaft aus Ziel auslesen...
    let value = targetObj[property];
    // ... und manipulieren wenn es eine Zahl ist
    if(typeof value === "number"){
      value = value * 2;
    }
    return value;
  }

};

// Einen Proxy auf das Original-Objekt mit der Logik
// aus "handler" anlegen. Der Proxy verhält sich wie
// das Original-Objekt, nur die im Handler definierten
// Operationen liefern andere Ergebnisse bzw. lösen
// Nebenwirkungen aus
let proxyObj = new Proxy(originalObj, handler);

console.log(originalObj.y); // > "Hallo"
console.log(proxyObj.y);    // > "Hallo"
console.log(originalObj.x); // > 42
console.log(proxyObj.x);    // > 84 - der Proxy schlägt zu!

Dieser Proxy leitet alle Operationen unverändert an das Ziel-Objekt durch, es sei denn das Ergebnis einer Get-Operation ist eine Zahl – diese wird dann verdoppelt zurückgegeben.

Die diversen Taps erlauben Proxies das Abfangen und Manipulieren von jeder Art von Objekt-Operation. Damit ist es in drei einfachen Schritten möglich, Immutablility für beliebige Objekte umzusetzen:

  1. Wir bauen eine Funktion, die einen „Sorry, dieses Objekt ist immutable“-Error wirft
  2. Wenn eine das Objekt verändernde Operation durchgeführt wird (z.B. x.a = 42 oder Object.setPrototypeOf(x, y)), gibt der Proxy über die entsprechenden Handler die Error-Funktion zurück
  3. Für Arrays wird bei Get-Operationen geprüft, ob eine das Array verändernde Methode angefragt wird (z.B. sort() oder pop()) und in diesen Fällen auch mit der Error-Funktion geantwortet

Klingt einfach? Ist es auch!

Immutability-Proxy in unter 40 Zeilen

Zunächst brauchen wir eine Funktion, die einen schönen Fehler wirft, wenn versucht wird, ein unveränderliches Objekt zu verändern:

function nope () {
  throw new Error("Object is immutable");
}

Als nächstes müssen wir den Proxy-Handler für normale Objekte konstruieren. Das ist nicht schwer, denn unsere Logik für mutierende Operationen ist immer gleich: die nope()-Funktion:

const objectHandler = {
  setPrototypeOf: nope,
  preventExtensions: nope,
  defineProperty: nope,
  deleteProperty: nope,
  set: nope,
};

Damit könnten wir nun normale Objekte bequem absichern. Für Arrays müssen wir aber noch eine Extrawurst braten, denn sie haben Methoden, die die betroffenen Arrays selbst verändern. Diese Methoden können wir ausschalten, indem wir bei Get-Operationen prüfen, ob eine dieser Mutator-Methoden abgefordert wurde und dann mit der nope()-Funktion antworten. Diese Logik kombinieren wir via Object.assign() mit dem normalen Objekt-Handler, denn normale Set-Operationen wie x[0] = 1 wollen wir auf unseren Arrays schließlich auch nicht erlauben:

const blacklistedArrayMethods = [
  "copyWithin", "fill", "pop", "push", "reverse", "shift", "sort", "splice", "unshift",
];

const arrayHandler = Object.assign({}, objectHandler, {
  get (target, property) {
    if (blacklistedArrayMethods.includes(property)) {
      return nope;
    } else {
      return target[property];
    }
  }
});

So gut wie fertig! Nun können wir unsere beiden Handler in einer schönen makeImmutable()-Funktion kombinieren, die, je nachdem ob sie ein Array oder ein Objekt übergeben bekommt, den jeweils passenden Proxy mit korrektem Handler produziert:

function makeImmutable (x) {
  if (Array.isArray(x)) {
    return new Proxy(x, arrayHandler);
  } else {
    return new Proxy(x, objectHandler);
  }
}

Fertig! Mit makeImmutable() lassen sich unveränderliche Arrays und Objekte produzieren … beziehungsweise, wenn man es genau nimmt, Bindings auf ganz normale Arrays und Objekte, bei denen bestimmte APIs auf eine Blacklist gesetzt wurden:

const immutableArray = makeImmutable([ "a", "b", "c" ]);

try {
  immutableArray[0] = "d"; // klappt nicht
} catch (err) {
  console.error(err.message); // "Object is immutable"
} finally {
  console.log(immutableArray[0]); // "a"
}

try {
  immutableArray.push("d"); // klappt nicht
} catch (err) {
  console.error(err.message); // "Object is immutable"
} finally {
  console.log(immutableArray.length); // 3
}

const immutableObject = makeImmutable({ foo: 23 });

try {
  immutableObject.foo = 42; // klappt nicht
} catch (err) {
  console.error(err.message); // "Object is immutable"
} finally {
  console.log(immutableObject.foo); // 23
}

try {
  Object.defineProperty(immutableObject, "bar",{
    value: 1337
  }); // klappt nicht
} catch (err) {
  console.error(err.message); // "Object is immutable"
} finally {
  console.log(immutableObject.bar); // undefined
}

Unveränderliche Arrays und Objekte in 33 Zeilen Code dank moderner ECMAScript-APIs!

Ausweitung auf Maps, Sets, Weak Maps und Weak Sets

Unterstützung für Maps und Sets sowie ihre Geschwister mit schwachen Referenzen lässt sich ganz einfach mit zusätzlichen Mutator-Methoden-Blacklists nachrüsten. So muss ein Proxy bei Maps z.B. Get-Anfragen auf clear(), delete() und set() anfangen. Das ist im Prinzip kein Problem, bläht den Code dann aber schon auf über 50 Zeilen auf. UglifyJS macht daraus knapp unter 1200 Zeichen.

Mit entsprechend angepassten Blacklists könnte man sowohl zukünftig noch in ECMAScript eingeführte Datenstrukturen als auch Third-Party-Datenstrukturen unveränderlich machen. Einfach die Mutation-APIs in eine Liste schieben, mit dem Objekt-Handler kombinieren, in makeImmutable() einbauen und fertig!

Wo ist der Haken?

Auf der Haben-Seite verbucht Immutablility via Proxy:

  • Winzige Datenmenge für die Implementierung
  • Keine API-Unterschiede zwischen normalen Objekten und ihren unveränderlichen Varianten (abgesehen davon, das Mutationsversuche eine Exception werfen)
  • Eingebautes bzw. triviales Future-Proofing (es sei denn neue Mutation-APIs werden Arrays, Maps, Sets etc. hinzugefügt, dann müssten die Blacklists erweitert werden)

Der einzige kleine Haken ist, Proxies im Internet Explorer nicht funktionieren. Jeder andere relevante Browser inklusive iOS-Safari, Edge und anderen Problemkindern hat Unterstützung an Bord – Einsatzmöglichkeiten für die winzigen Immutablility-Proxies sind also vorhanden!

Den größten Haken sehe ich persönlich im mit der Proxy-Lösung kleinen, aber immer noch verhandenen Overhead. Immer noch muss ich explizit Objekte, Arrays und Co als unveränderlich markieren. Immer noch habe ich eine (winzige, aber vorhandene) Extra-Menge an Bytes und eine (winzige, aber vorhandene) Dependency in meinem Code. Da bleibe ich dann doch lieber bei meiner guten alten Hands-Off-Methode.