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

nDatenstrukturen, 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.n

nnn

Libraries für Immutable JS

n

nDa 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!n

n

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

n

    n

  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. n

  3. 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 …
  4. n

  5. 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!
  6. n

n

nAls 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!n

nnn

Proxies

n

nEin 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:n

n

let originalObj = { x: 42, y: "Hallo" };nn// Der Proxy-Hander enthält "traps", d.h. dien// Logik für das Abfangen bestimmter Operationennconst handler = {nn  // Get-Trap für das Auslesen von Properties aufn  // dem Objekt, für das der Proxy als Proxy fungiertn  // "targetObj" ist das Ziel-Objekt, "property" dien  // angfragte Eigenschaft. Der Rückgabewert diesern  // Funktion bestimmt die Antwortn  get: function (targetObj, property){n    // Angefragte Eigenschaft aus Ziel auslesen...n    let value = targetObj[property];n    // ... und manipulieren wenn es eine Zahl istn    if(typeof value === "number"){n      value = value * 2;n    }n    return value;n  }nn};nn// Einen Proxy auf das Original-Objekt mit der Logikn// aus "handler" anlegen. Der Proxy verhält sich wien// das Original-Objekt, nur die im Handler definiertenn// Operationen liefern andere Ergebnisse bzw. lösenn// Nebenwirkungen ausnlet proxyObj = new Proxy(originalObj, handler);nnconsole.log(originalObj.y); // > "Hallo"nconsole.log(proxyObj.y);    // > "Hallo"nconsole.log(originalObj.x); // > 42nconsole.log(proxyObj.x);    // > 84 - der Proxy schlägt zu!

n

nDieser 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.n

n

nDie 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:n

n

    n

  1. Wir bauen eine Funktion, die einen „Sorry, dieses Objekt ist immutable“-Error wirft
  2. n

  3. 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
  4. n

  5. 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
  6. n

n

Klingt einfach? Ist es auch!

nnn

Immutability-Proxy in unter 40 Zeilen

n

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

n

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

n

nAls 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:n

n

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

n

nDamit 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:n

n

const blacklistedArrayMethods = [n  "copyWithin", "fill", "pop", "push", "reverse", "shift", "sort", "splice", "unshift",n];nnconst arrayHandler = Object.assign({}, objectHandler, {n  get (target, property) {n    if (blacklistedArrayMethods.includes(property)) {n      return nope;n    } else {n      return target[property];n    }n  }n});

n

nSo 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:n

n

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

n

nFertig! 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:n

n

nconst immutableArray = makeImmutable([ "a", "b", "c" ]);nntry {n  immutableArray[0] = "d"; // klappt nichtn} catch (err) {n  console.error(err.message); // "Object is immutable"n} finally {n  console.log(immutableArray[0]); // "a"n}nntry {n  immutableArray.push("d"); // klappt nichtn} catch (err) {n  console.error(err.message); // "Object is immutable"n} finally {n  console.log(immutableArray.length); // 3n}nnconst immutableObject = makeImmutable({ foo: 23 });nntry {n  immutableObject.foo = 42; // klappt nichtn} catch (err) {n  console.error(err.message); // "Object is immutable"n} finally {n  console.log(immutableObject.foo); // 23n}nntry {n  Object.defineProperty(immutableObject, "bar",{n    value: 1337n  }); // klappt nichtn} catch (err) {n  console.error(err.message); // "Object is immutable"n} finally {n  console.log(immutableObject.bar); // undefinedn}

n

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

nnn

Ausweitung auf Maps, Sets, Weak Maps und Weak Sets

n

nUnterstü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.n

n

nMit 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!n

nnn

Wo ist der Haken?

n

nAuf der Haben-Seite verbucht Immutablility via Proxy:n

n

    n

  • Winzige Datenmenge für die Implementierung
  • n

  • Keine API-Unterschiede zwischen normalen Objekten und ihren unveränderlichen Varianten (abgesehen davon, das Mutationsversuche eine Exception werfen)
  • n

  • 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)
  • n

n

nDer 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!n

n

nDen 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.n


Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert