DOMNodeInserted-Events mit Mutation Oberserver ersetzen

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

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)

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.

JavaScript-Funktionen mit definierter length-Eigenschaft erzeugen: drei Ansätze

Ich habe mich in den letzten Wochen viel mit JavaScript-Funktionen beschäftigt, die andere JavaScript-Funktionen umbauen. Ein gutes Beispiel für solche Funktionen ist die_.flip()-Funktion von Lodash, die die Parameter-Reihenfolge einer Funktion umdreht:

function oldFn (a, b, c) {
  return a + b + c;
}

let newFn = _.flip(oldFn);

console.log(newFn("a", "b", "c")); // > "cba"

Funktionsumbau-Funktionen finde ich besonders dann nützlich, wenn ich eine Kompatibilitätsschicht bauen möchte, weil z.B. ein bestimmtes Modul plötzlich wegen eines Updates ganz andere Funktionsnamen und Parameter-Reihenfolgen hat. Statt den kompletten eigenen Code auf die neue API anzupassen kann man oft auch einfach die alte API rekonstruieren diese Kompatibilitätsschicht zwischen den eigenen, nicht weiter zu ändernden Code und das problematische Modul klemmen. Häufig kommt man dabei sehr weit, indem man einfach die neuen Funktionen des problematischen Moduls nimmt und sie mit Funktionen wie eben _.flip() oder _.rearg() wieder in die alte Form bringt.

Es gibt da nur ein kleines Problem:

function oldFn (a, b, c) {
  return a + b + c;
}

let newFn = _.flip(oldFn);

console.log(oldFn.length); // > 3
console.log(newFn.length); // > 0

So gut wie immer liefern Funktionsumbau-Funktionen Funktionen, die eine length von 0 haben. Ein „Funktionsumbau“ bedeutet immer, dass um eine gegebene Eingangs-Funktion ein Wrapper konstruiert wird, der zusätzliches Verhalten implementiert und am Ende des Tages dann doch wieder die Eingangs-Funktion aufruft. So könnte man flip() zum Beispiel wie folgt bauen:

function flip (inputFn) {
  return function (...args) {
    return inputFn.apply(this, args.reverse());
  };
}

Die Funktion, die flip() zurückgeben würde, hätte natürlich eine length von 0, da sie variadisch ist – sie hat ja auch nichts weiter zu tun, als die an sie übergebenen Parameter in umgekehrter Reihenfolge an inputFn() durchzureichen. Das Problem dabei ist, dass Funktionen ohne definierte length manchmal schlecht weiter umbauen lassen, denn viele Umbau-Operationen müssen wissen, wie viele Parameter eine Funktion haben möchte. Currying ist ein solches Beispiel:

function oldFn (a, b, c) {
  return a + b + c;
}

let flippedFn = _.flip(oldFn);

let curriedFlippedFn = _.curry(flippedFn);

console.log(curriedFlippedFn("a")); // > "aundefinedundefined"

Currying ist das Zerlegen einer Funktion mit N Parametern in N Funktionen mit einem Parameter … was natürlich nur klappt, wenn N (d.h. die length) bekannt ist! In Lodash könnten wir den gewünschten N-Wert als zweiten Parameter _.curry() hineinstecken, aber das ist doch eigentlich absurd! Kann man kein _.flip() konstruieren, das Funktionen mit der korrekten length liefert? Es stellt sich raus: man kann! Es gibt sogar mehrere Lösungen. Und die haben alle ihre ganz eigenen Vor- und Nachteile.

Das Ziel

Ich möchte eine Funktion ary(fn, n) konstruieren, die eine Funktion mit length = n zurückgibt. Diese wiederum liefert das Ergebnis des Aufrufs von fn mit nur den den ersten n an sie übergebenen Parametern.

Lodash hat eine solche Funktion, die jedoch nur den zweiten Teil des Ziels erreicht. Sie reicht nur die ersten n Parameter weiter, hat aber immer noch die falsche length. Für viele Fälle ist das auch völlig ausreichend. Der geneigte JavaScriptler hat vielleicht schon mal dieses Problem angetroffen:

[ "0", "1", "2" ].map(parseInt);
// Ergibt [ 0, NaN, NaN ]

Das Ergebnis kommt zustande, indem die map()-Methode die Funktion parseInt() mit mehr als einem Parameter aufruft:

// Was eigentlich passiert
[ "0", "1", "2" ].map(function (str, idx, array) {
  return parseInt(str, idx, array);
});
// > [ 0, NaN, NaN ]

parseInt() erhält neben dem zu parsenden String noch den Index des Strings in dem Array sowie das Array selbst. Der Index wird dabei als Basis für das Zahlensystem verwendet und der dritte Parameter einfach ignoriert. Mit der Lodash-Funktion _.ary() können wir parseInt() zu einer Funktion umbauen, die nur einen Parameter annimmt und für alle übrigen Werte auf ihre Defaults zurückgreift:

// Baut einen Wrapper um parseInt(), der nur einen Parameter weitergibt
var unaryParseInt = _.ary(parseInt, 1);

console.log([ "0", "1", "2" ].map(unaryParseInt));
// > [0, 1, 2]

// Problem: length === 0
console.log(unaryParseInt.length)
// > 0

Dann probieren wir doch mal, unser eigenes ary(fn, n) mit richtiger length zu bauen. Ich habe hierfür drei Methoden gefunden: die Hirn-Aus-Methode, die Holzhammer-Methode und die universelle Methode.

Die Hirn-Aus-Methode

Die wohl einfachste denkbare Lösung sieht wie folgt aus:

function ary (fn, n) {
  if (n === 0) return function () { return fn.call(this); };
  if (n === 1) return function (a) { return fn.call(this, a); };
  if (n === 2) return function (a, b) { return fn.call(this, a, b); };
  if (n === 3) return function (a, b, c) { return fn.call(this, a, b, c); };
  if (n === 4) return function (a, b, c, d) { return fn.call(this, a, b, c, d); };
  if (n === 5) return function (a, b, c, d, e) { return fn.call(this, a, b, c, d, e); };
  throw new Error("NOPE!")
}

Die Vorteile dieses Ansatzes sind zahlreich:

  1. Funktioniert in jedem noch so alten Browser
  2. Simpler Code, den jeder sofort versteht
  3. Bei Bedarf sehr einfach zu debuggen oder zu erweitern
  4. Garantiert schnell

Eine meiner Programmier-Leitlinien lautet „clever ist das Gegenteil von intelligent“ – lieber ein Problem erst mal auf die billige Tour lösen, bevor man sich in Overengineering verliert. Daher finde ich diesen sehr simplen Ansatz erst mal ganz sympathisch. Dass sich mit dieser Variante maximal fünfstellige Funktionen erzeugen lassen, würde ich nicht als Problem betrachten. Erstens sollte man nie mehr als fünf Parameter brauchen und zweitens ließe sich die Funktion bei Bedarf problemlos entsprechend erweitern. Was will man mehr?

Die Holzhammer-Methode

Wenn die Hirn-Aus-Methode mal zu billig daherkommt, gibt es noch eine weitere Möglichkeit … moderne JavaScript-Engines vorausgesetzt. Seit ES6 ist die length-Eigenschaft von Funktionen veränderbar. Während sie in ES5 noch non-configurable war, ist diese Limitierung in ES6 entfallen. Also lässt sich das Problem ganz einfach mit Object.defineProperty() lösen:

function ary (fn, n) {
  const wrapper = function (...args) {
    return fn.apply(this, args.slice(0, n));
  };
  Object.defineProperty(wrapper, "length", {
    value: n,
    configurable: true
  });
  return wrapper;
}

Sofern die Zielplattform dieses subtile ES6-Feature unterstützt, ist damit unser Ziel erreicht. Bei diesem Ansatz könnte man die Gelegenheit auch nutzen, der zurückgegebenen Funktion einen Namen zu verpassen, indem man die name-Property (ebenfalls in ES6 configurable) überschreibt. Das sorgt im Fehlerfall für schönere Stack Traces, denn im Moment wäre unsere Wrapper-Funktionen schließlich noch anonym und würde im Stack Trace bestenfalls als „wrapper“ benannt. Aber das muss ja nicht sein:

function ary (fn, n) {
  const wrapper = function (...args) {
    return fn.apply(this, args.slice(0, n));
  };
  Object.defineProperties(wrapper, {
    length: {
      value: n,
      configurable: true
    },
    name: {
      value: `${n}ary${fn.name}`,
      configurable: true
    }
  });
  return wrapper;
}

function foo (callback) {
  callback();
}

var unaryFoo = ary(foo, 1);

unaryFoo(function cb () {
  throw new Error();
});

/* Uncaught Error
    at cb (test.js:19)
    at foo (test.js:14)
    at 1aryfoo (test.js:3)/*

Der einzige Haken an der Holzhammer-Methode ist, dass sie moderne JS-Engines voraussetzt und Transpiler oder Polyfills nicht helfen können. Für die älteren Browser brauchen wir also noch eine andere Lösung.

Die Function-Constructor-Doppelwrapper-Methode

Mit dem Function-Constructor lässt sich problemlos eine n-stellige Funktion erzeugen:

new Function ([arg1[, arg2[, ...argN]],] functionBody)

Wenn wir über den unappetitlichen Aspekt hinwegsehen, dass wir den Funktionscode als String bereitstellen müssen, gibt es aber noch ein weiteres Problem: haben wir einen n-stelligen Wrapper mit dem Function-Constructor erzeugt, bekommen wir die zu wrappende Funktion nicht mehr ohne weiteres dort hinein! Dem Function-Constructor entsprungene Funktionen sind keine normalen Closures, sondern werden immer im globalen Scope erzeugt. Die innerhalb von ary(fn, n) erstellte Funktion kann weder n noch fn sehen, wobei zumindest letzteres nötig wäre – irgendwann muss der Wrapper schließlich die Original-Funktion aufrufen! Also machen wir das folgende:

  1. Eine einstellige Funktion wird dem Function-Constructor erzeugt. Der eine Parameter ist die zu wrappende Funktion.
  2. Die mit dem Function-Constructor erzeugte einstellige Funktion lassen wir eine n-stellige Funktion zurückgeben, die die zu wrappende Funktion aufruft (das ist der Parameter der erzeugenden einstelligen Funktion)
  3. Das Ergebnis des Aufrufs der mit dem Function-Constructor erzeugten Funktion mit der zu wrappenden Funktion wird aus ary(fn, n) zurückgeben

Das liest sich in Prosa sehr viel komplizierter als, es am Ende im Code ist. Eigentlich ist das umständlichste, die Parameter-Liste für die n-stellige Funktion zu erzeugen (ein String "x1, x2, xn"):

function ary (fn, n) {
  const argsList = Array(n).fill("x").map((x, i) => x + i).join(",");
  const name = `_${n}ary${fn.name}`;
  const createWrapper = new Function("fn", `return function ${name} (${ argsList }) {
    return fn.call(this, ${ argsList });
  }`);
  return createWrapper(fn);
}

Dieser Ansatz funktioniert mit im Prinzip jedem Browser. Für ältere JavaScript-Umgebungen müssten wir auf die Template-Literals verzichten und die Erzeugung der argsList angepasst werden, aber das wäre kein großes Problem. Der Name der Wrapper-Funktion erhält hier einen Unterstrich als Präfix, da new Function() im Prinzip ein eval() darstellt und sich der JS-Parser an einem Funktionsnamen, der mit einer Zahl beginnt, stören würde. Vermutlich ist damit zu rechnen, dass diese Lösung wegen des Einsatzes von new Function() von allen drei Möglichkeiten nicht die beste Performance bietet.

Zusammenfassung

Im direkten Vergleich gibt es keinen klaren Sieger unter den drei Methoden:

  • Hirn-aus-Methode: simpel, schnell, auch in alten Browsern, Maximal-Length limitiert
  • Holzhammer-Methode: universell, Funktionsnamen möglich, nur in modernen Browsern
  • Function-Constructor-Doppelwrapper-Methode: universell, auch in alten Browsern, Funktionsnamen möglich, komplexer Code, eval (via Function-Constructor)

Alles in Allem bin ich mir auch nicht sicher, welche Methode mir am besten gefällt. Die Hirn-aus-Methode ist mit Sicherheit der in jeder Hinsicht am limitierteste Kandidat, aber spielen diese Limitierungen wirklich eine Rolle? Bis runter zu welchem Browser-Fossil funktioniert die Holzhammer-Methode? Wie langsam sind die von der Function-Constructor-Doppelwrapper-Methode erzeugten Funktionen wirklich und würde das in der Praxis eine spürbare Auswirkung haben? Das Thema verdient weitere Forschung.

Folgt mir!