Startseite / Artikel

Fabriken mit Abschlussunterstützung: Privater Staat, Verzögerung, Rate Limits und Rückgängigmachen

Erfahren Sie, wie Closures den privaten Zustand in JavaScript-Factories am Leben erhalten, warum sie veraltete Werte in React-Timern verursachen und wie debounce, Rate Limiting sowie Undo von ihnen abhängen.

Verlag: REACTAPP.TOP7 Min. Lesezeit1631 Wörter

Quellen: Closures in JavaScript and React, Explained Simply

Closures stecken hinter einer überraschend großen Anzahl alltäglicher JavaScript-Verwendungen: Zählern, verzögerten Eingaben, Request-Throttling-Mechanismen, Undo-Stacks sowie nahezu jedem React-Hook. Sie sind außerdem für eine ganze Reihe von Fehlern verantwortlich, bei denen ein Callback einen Wert meldet, der nicht mehr zutrifft. Diese Anleitung liefert eine funktionierende Definition, zeigt auf, was dies für den React-State bedeutet, und erläutert anschließend vier praktische Factory-Muster – einschließlich der Grenzen, die man vor deren Einsatz in der Produktion kennen sollte.

Eine funktionierende Definition

Eine Closure ist eine Funktion, die zusammen mit den Variablen, die zum Zeitpunkt ihrer Definition im Gültigkeitsbereich lagen, gebündelt wird. Die Funktion behält auch nach dem Rückruf der äußeren Funktion, die sie deklariert hat, weiterhin Zugriff auf diese Variablen. Ein nützliches Bild ist eine Funktion, die das Haus verlässt und dabei einen Beutel mit den von ihr referenzierten Variablen mitnimmt; egal wo sie später aufgerufen wird, greift sie in denselben Beutel.

Die Zähler-Factory

Die klassische Demonstration ist eine Fabrik, die eine Funktion zurückgibt, die eine Variable aktualisiert, an der sie nicht besitzt:

function makeCounter() {
  let count = 0; // lives inside makeCounter

  function increment() {
    count = count + 1;
    console.log(count);
  }

  return increment;
}

const counter = makeCounter();
counter(); // 1
counter(); // 2
counter(); // 3

Schritt für Schritt geschieht Folgendes:

  • makeCounter() wird einmal ausgeführt und deklariert count.
  • Dann wird increment zurückgegeben, der auf count verweist, und anschließend endet makeCounter.
  • Jeder Aufruf von counter() liest und aktualisiert genau diese gleiche count, weshalb der Ausgabewert von 1 auf 3 ansteigt.

Normalerweise werden die lokalen Variablen einer Funktion unzugänglich, sobald sie zurückgegeben wird, und der Interpreter kann sie wieder verwenden. Hier bezieht sich increment weiterhin auf count, sodass die Variable so lange aktiv bleibt, wie increment existiert. Wenn Sie die zugrundeliegenden Regeln erfahren möchten, nach denen der Interpreter count überhaupt findet, behandelt der Artikel zu wie die Scope-Chain Variablen auflöst dieses Mechanismus.

Warum React-State in Timern veraltet wird

React fügt zu Closures nichts Besonderes hinzu; es verlässt sich einfach überall auf sie, insbesondere in Hooks. Diese Abhängigkeit erklärt eine der häufigsten Überraschungen im Komponentencodierung:

function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setTimeout(() => {
      console.log("Count is: " + count);
    }, 3000);
  }

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>Add</button>
      <button onClick={handleClick}>Log count in 3 seconds</button>
    </div>
  );
}

Starten Sie mit count bei 0. Klicken Sie auf „Zahl in 3 Sekunden protokollieren“ und drücken Sie anschließend dreimal „Hinzufügen“, bevor der Timer auslöst. Die Konsole gibt 0 aus, nicht 3.

Der Grund dafür ist, dass jede Auflösung von Counter eine neue Funktionsaufrufung darstellt. Jeder Aufruf erhält seine eigene count-Konstante und definiert seinen eigenen handleClick. Der setTimeout-Callback wurde während der Auflösung erstellt, bei der count den Wert 0 hatte, und das ist die einzige count-Wert, den er je sehen kann.

Dieses Modell erklärt die meisten Fragen vom Typ „Warum ist mein Zustand veraltet?“ – und genau das ist der Zweck des Abhängigkeitsarrays in useEffect: Es teilt React mit, auf welche gespeicherten Werte der Effekt angewiesen ist, damit React ihn bei deren Änderung mit neuen Werten neu ausführen kann. Wenn man tatsächlich den neuesten Wert innerhalb eines verzögerten Callbacks benötigt, sind die üblichen Optionen eine funktionale Aktualisierung wie setCount(c => c + 1) für Schreibvorgänge oder ein Ref, das bei Lesevorgängen auf dem neuesten Stand gehalten wird. Die Vor- und Nachteile dieser Optionen werden in Snapshot versus Live-Werte in verzögerten Callbacks erläutert.

Vier Factory-Muster, die Sie in echtem Code antreffen werden

Eingebetteter Zustand ohne Klassen

Bevor Felder mit dem Präfix # zum Sprachstandard wurden, waren Schließfunktionen die übliche Methode, um Daten zu verbergen – und sie bleiben auch heute eine leichte Lösung. Alles, was in der Factory deklariert wird, ist nur über die von ihr zurückgegebenen Funktionen erreichbar:

function createBankAccount(balance) {
  return {
    deposit(amount) {
      balance += amount;
      return balance;
    },
    withdraw(amount) {
      balance -= amount;
      return balance;
    },
    getBalance() {
      return balance;
    }
  };
}

const account = createBankAccount(100);
account.deposit(50);
console.log(account.getBalance()); // 150
console.log(account.balance); // undefined — can't touch it directly

balance ist keine Eigenschaft des zurückgegebenen Objekts, daher ist account.balance undefined. Die einzige Möglichkeit, es zu ändern, besteht über deposit und withdraw.

Debouncing einer Suchleiste

Debouncing verzögert eine Aktion, bis die Eingabe für eine bestimmte Zeit ruht. Dadurch wird verhindert, dass ein Suchfeld bei jeder Tastenbetätigung eine Anfrage sendet:

function debounce(func, delay) {
  let timer; // remembered between calls
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => func(...args), delay);
  };
}

const searchInput = document.getElementById("search");
const handleSearch = debounce((e) => {
  fetch(`/api/search?q=${e.target.value}`);
}, 300);

searchInput.addEventListener("input", handleSearch);

Der entscheidende Punkt ist, wo timer deklariert wird: einmal innerhalb von debounce, außerhalb der zurückgegebenen Funktion. Jeder Tastendruck ruft die zurückgegebene Funktion auf, die den ausstehenden Timeout löscht und einen neuen ansetzt, sodass eine Anfrage erst nach 300 ms ohne Eingabe gesendet wird. Würde timer innerhalb der zurückgegebenen Funktion deklariert, würde jede Aufrufung eine neue Variable erhalten, der vorherige Timeout könnte niemals storniert werden und jeder Tastendruck würde weiterhin eine Anfrage auslösen.

In einem React-Komponenten gilt dieselbe Logik mit einem zusätzlichen Schritt: Die gebremste Funktion muss einmal erstellt und stabil gehalten werden (zum Beispiel mit useMemo oder einem ref), andernfalls wird bei jeder Neuzeichnung eine neue Schließung mit einem neuen timer erstellt, wodurch das Debouncing stillschweigend aufhört zu funktionieren.

Ein einfacher Anfragenbegrenzer

Dieselbe Technik kann begrenzen, wie oft ein Endpunkt bedient wird:

function createRateLimiter(maxRequests, windowMs) {
  let requestCount = 0;
  setInterval(() => (requestCount = 0), windowMs);

  return function () {
    requestCount++;
    return requestCount <= maxRequests;
  };
}

const rateLimiter = createRateLimiter(5, 60000); // 5 requests per minute

app.get("/api/data", (req, res) => {
  if (!rateLimiter()) {
    return res.status(429).send("Too many requests");
  }
  res.send("Here's your data");
});

requestCount wird von jedem Aufruf an rateLimiter privat geteilt, und ein Intervall setzt es in jedem Zeitfenster zurück. Diese Version ist ein im Speicher gehaltener Zähler mit fester Zeitfensterlänge, daher sollten Sie ihre Grenzen berücksichtigen:

  • er zählt alle Aufrufer zusammen, sodass ein Client das Budget für alle verbrauchen kann; echte Limitierer sortieren die Zählung nach Benutzer, Token oder IP
  • der Zustand befindet sich in einem Prozess, wodurch mehrere Serverinstanzen jeweils das volle Limit zulassen
  • feste Zeitfenster ermöglichen Spitzenwerte an den Grenzen – bis zu doppelt so viel wie das Limit in kurzer Zeit
  • setInterval wird niemals aufgehoben, was bei Tests oder wenn Limitierer dynamisch erstellt werden, von Bedeutung ist

Ablegen und Wiederherstellen der Historie

Ein History-Manager behält seinen Stack und Cursor für jede Instanz privat:

function createHistoryManager() {
  let history = [];
  let currentIndex = -1;

  return {
    save(state) {
      history = history.slice(0, currentIndex + 1);
      history.push(state);
      currentIndex++;
    },
    undo() {
      if (currentIndex > 0) currentIndex--;
      return history[currentIndex];
    },
    redo() {
      if (currentIndex < history.length - 1) currentIndex++;
      return history[currentIndex];
    }
  };
}

save löscht zunächst alles nach der aktuellen Position, wodurch das Aufzeichnen einer neuen Änderung nach einem Undo den Redo-Zweig entfernt – das entspricht dem Verhalten der meisten Editor. undo und redo verschieben lediglich currentIndex innerhalb der Grenzen und geben den Zustand an diesem Punkt zurück. Beachten Sie, dass die gespeicherten Zustände unveränderliche Snapshots sein sollten; wenn Sie ein Objekt speichern und es anschließend verändern, verweisen alle Historie-Einträge auf dieselben geänderten Daten.

Schnelle Heuristiken zur Erkennung von Closures

  • Innere Funktionen nutzen äußere. Eine in einer anderen Funktion definierte Funktion, die auf die Variablen der äußeren Funktion verweist, ist ein Closure.
  • Es erfasst eine Bindung, nicht einen Echtzeit-Feed neuer Renderungen. In React erzeugt jede Renderung neue Bindungen, sodass ein alter Callback die Werte aus seiner eigenen Renderung beibehält. Genauer gesagt hält eine Schließung die Variable selbst fest, wodurch, falls dieselbe Variable später neu zugewiesen wird – wie bei count in der Zählerfunktion – die Schließung den neuen Wert erkennt.
  • Eine zurückgegebene Funktion ist ein starkes Indiz. Wenn eine Funktion eine weitere Funktion zurückgibt, ist fast immer eine Schließung beteiligt.
  • Die Falle von var in Schleifen

    Schleifenvariablen zeigen die Bindungsregel am deutlichsten:

    for (var i = 0; i < 3; i++) {
      setTimeout(() => console.log(i), 1000);
    }
    // Logs: 3, 3, 3 — var is shared across the whole loop
    
    for (let i = 0; i < 3; i++) {
      setTimeout(() => console.log(i), 1000);
    }
    // Logs: 0, 1, 2 — let creates a new i on every iteration
    

    Mit var gibt es nur eine einzige Variable i für die gesamte Funktion, und wenn die Timeout-Funktionen ausgeführt werden, hat der Loop diese bereits auf 3 gesetzt, sodass alle drei Callbacks den Wert 3 ausgeben. Mit let erhält jede Iteration ihre eigene Bindung von i, wodurch jeder Callback einen anderen Wert verwendet und somit 0, 1 bzw. 2 ausgibt.

    Zusammenfassung

    Closures sind das Mechanismus hinter privatem Zustand, verzögerten Eingaben, Anfragenbeschränkern, Undo-Stacks und benutzerdefinierten Hooks sowie vielen Fehlern aufgrund veralteten Zustands. Zwei Fragen beseitigen den größten Teil der Verwirrung: Welche Variable hat diese Funktion erfasst, und wurde diese Variable seitdem durch eine neuere ersetzt? In herkömmlichen JavaScript-Factories wird die erfasste Variable geteilt und vor Ort aktualisiert. In React führt jede Renderung zu neuen Variablen, sodass jeder Callback, der länger existiert als seine jeweilige Renderung, einen älteren Zustand sieht – es sei denn, man gibt ihm absichtlich die Möglichkeit, den aktuellen Zustand abzurufen.