JavaScript/Tutorials/Debounce und Throttle

Aus SELFHTML-Wiki
Wechseln zu: Navigation, Suche

Es kommt vor, dass eine Webseite Ereignisse mit relativ hoher Frequenz feuert, wie z. B. bei einer Größenveränderung des Fensters (resize Event), einer Mausverfolgung (mousemove Event) oder einem Tastendruck (keydown Event) mit Auto-Repeat. Ein Eventhandler, der da jedesmal aufgerufen wird und mehr als nur triviale Dinge tut, überlastet dann schnell den Browser. Bei der Behandlung von resize oder mousemove ist es auch gar nicht nötig, auf jedes einzelne Event zu reagieren. Es genügt oftmals das erste oder letzte in einer schnellen Folge.

Für diesen Anwendungszweck gibt es die Muster der debounce- und der throttle-Funktionen (to debounce: entprellen, to throttle: drosseln). Die hier vorgestellte Fassung der debounce-Funktionen basiert auf der underscore.js-Library von Jeremy Ashkenas und einer Vereinfachung von David Walsh. Wenn Sie ohnehin underscore.js in Ihrem Projekt nutzen, können Sie einfach _.debounce verwenden.

Die Vorlage für die throttle-Funktion stammt von Jhey Tompkins auf codeburst.io.

Die Idee ist, analog zur bind-Methode der Function-Objekte, eine Kapsel um die eigentliche Funktion zu erzeugen, die sich um das Debouncing kümmert. Danach verwendet man die Kapselfunktion an Stelle der eigentlichen Funktion und der Rest geschieht automatisch.

Einkapseln

Das Einkapseln von Funktionen funktioniert in einer einfachen Form so:

Einkapseln einer Funktion
function bindeKontext(func, context) {
   return function() {
      func.apply(context, arguments);
   }
}

let helloChar = bindeKontext(function(pos) { return this[pos]; }, "Hallo Welt");
console.log(helloChar(2));
console.log(helloChar(3));
console.log(helloChar(5));
Ausgabe:
a
l
o

Dies ist eine Hälfte dessen, was die bind-Methode tut. bindeKontext nimmt eine Funktion und einen Kontext entgegen und gibt eine andere Funktion zurück. Die zurückgegebene Funktion sorgt bei ihrem Aufruf dafür, dass die ursprüngliche Funktion mit dem angegebenen Kontext als this-Objekt aufgerufen wird.

Debouncing

Mit dieser Einkapselungstechnik soll nun ein allgemeiner Debouncer vorgestellt werden. Aber, falls Sie noch nicht wissen, was es in JavaScript mit Closures auf sich hat, dann lesen Sie jetzt bitte erst einmal nach.

Die Basisversion - ein abwartender Debouncer

Beim Realisieren eines Debounces kann man sich entscheiden, ob man den ersten oder den letzten Aufruf in einer schnellen Folge von Funktionsaufrufen durchführen will. Der hier gezeigte Debouncer wartet erst einmal ab, bis wieder Ruhe einkehrt, bevor er die Funktion aufruft. Der Vorteil ist, dass während der schnellen Ereignisfolge nichts dazwischengrätscht. Der Nachteil ist, dass die Funktion nicht zum Zuge kommt, während die Ereignisse feuern.

Da ein Debouncer auf Funktionen angewendet wird, ist es nahe liegend, ihn als Erweiterung von Function.prototype bereitzustellen.

Abwartender Debouncer, Schritt 1
Function.prototype.debounce = function(wait) {
   let timeout,
       debouncedFunc = this;
   return function() {
      let context = this;
      function later(args) {
         timeout = null;
         debouncedFunc.apply(context, args);
      };
      clearTimeout(timeout);
      timeout = setTimeout(later, wait, arguments);
   };
};

// Demo:

function hello(text) {
   console.log(text);
}
let debouncedHello = hello.debounce(100);             // Kapsel mit Debouncer erzeugen

// 6 Aufrufe zu unterschiedlichen Zeiten anfordern
setTimeout(() => debouncedHello("Time:   0"),   0);
setTimeout(() => debouncedHello("Time:  50"),  50);
setTimeout(() => debouncedHello("Time: 100"), 100);
setTimeout(() => debouncedHello("Time: 150"), 150);   // hello wird 250ms nach Testbeginn ausgeführt
setTimeout(() => debouncedHello("Time: 300"), 300);   
setTimeout(() => debouncedHello("Time: 350"), 350);   // hello wird 450ms nach Testbeginn ausgeführt

Dieser Debouncer ist bereits funktionsfähig. Er bildet eine Closure um den Parameter wait sowie um die lokale Variablen timeout und debouncedFunc, und gibt eine Funktion zurück, die diese Closure als Speicher nutzen kann. Diese zurückgegebene Funktion ist der eigentliche Debouncer. Für jeden Aufruf von debounce entsteht eine neue Closure und damit auch ein neuer, eigenständiger Debouncer.

Weil debounce eine Methode von Function.prototype ist, steht die Funktion, für die der Debouncer erzeugt werden soll, beim Aufruf von debounce als this-Objekt zur Verfügung. Weil this keine lokale Variable ist, sondern den Aufrufkontext der Funktion enthält, wird es in einer Variablen gespeichert, um im Debouncer-Kern verfügbar zu sein.

Der eigentliche Debouncer nutzt setTimeout, um einen Timer zu starten, der nach seinem Ablauf den Aufruf der later Funktion auslöst. An dieser Stelle wird eine weitere Closure gebildet, die das beim Aufruf der Funktion gültige this-Objekt sowie die übergebenen Parameter enthält. Das Speichern von this als Kontext ist nicht unbedingt erforderlich, aber dadurch wird es möglich, die von debounce erzeugte Funktion als Methode eines Objekts zu speichern und es in der eingekapselten Funktion in this verfügbar zu haben.

Wird der erzeugte Debouncer aufgerufen, bevor die Wartezeit abgelaufen ist, so bewirkt der Aufruf von clearTimeout, dass der beim vorherigen Aufruf gestartete Timer wieder gelöscht wird. Von einer zu schnellen Folge von Aufrufen wird also nur der letzte durchgeführt.

Wartender Debouncer mit cancel

Was noch fehlt, ist die Möglichkeit, den Aufruf der gekapselten Funktion vollständig zu verhindern. Das ist relativ leicht hinzuzufügen, wir nutzen dafür den Umstand, dass Funktionen Objekte sind wie alle anderen auch. Und das heißt: man kann sehr einfach Methoden ergänzen. Ergänzen wir eine cancel Methode:

Abwartender Debouncer, Schritt 2
Function.prototype.debounce = function(wait) {
   let timeout,
       debouncedFunc = this;
   function debounceCore() {
      var context = this, args = arguments;
      function later() {
         timeout = null;
         debouncedFunc.apply(context, args);
      };
      cancel();
      timeout = setTimeout(later, wait);
   }
   function cancel() {
      clearTimeout(timeout);
   }
   debounceCore.cancel = cancel;
   return debounceCore;
};

Das Innenleben von debounce wurde etwas umgebaut. Die inneren Funktionen sind jetzt nicht mehr anonym, weil mit ihnen operiert werden muss. Und die cancel-Methode ist nach dem don't repeat yourself Prinzip als eigene Funktion realisiert worden, damit sie auch von der Kernfunktion _debounce genutzt werden kann.

Blockierender Debouncer

Die Alternative zu einem abwartenden Debouncer ist ein blockierender Debouncer. Er ruft die übergebene Funktion sofort auf und blockiert dann so lange weitere Aufrufe, bis die vorgegebene Wartezeit abgelaufen ist. Dafür braucht man aber kein setTimeout, dafür genügt Date.now().

Vorteil des blockierenden Debouncers ist, dass er den ersten Aufruf auf jeden Fall und ohne Zeitverzug durchführt. Wenn aber über längere Zeit eine schnelle Aufruffolge eintrifft, wird nichts mehr davon berücksichtigt. Der Einsatz eines solchen Debouncers ist daher gut zu überlegen.

Um klarzustellen, was dieser Debouncer tut, haben wir ihn callAgainOnlyAfter genannt.

Blockierender Debouncer
Function.prototype.callAgainOnlyAfter = function(wait) {
   let nextCallAllowed = 0,
       debouncedFunc = this;

   function debounceCore() {
      let now = Date.now();
      if (now > nextCallAllowed) {
         debouncedFunc.apply(this, arguments);
      }
      nextCallAllowed = now + wait;
   }
   debounceCore.forceNext = function() {
      nextCallAllowed = 0;
   }
   return debounceCore;
};

function hello(text) { console.log(text); }
let debouncedHello = hello.callAgainOnlyAfter(100);
// 6 Aufrufe zu unterschiedlichen Zeiten anfordern
setTimeout(() => debouncedHello("Time:   0"),   0);   // hello wird zu Testbeginn ausgeführt
setTimeout(() => debouncedHello("Time:  50"),  50);
setTimeout(() => debouncedHello("Time: 100"), 100);
setTimeout(() => debouncedHello("Time: 150"), 150);   
setTimeout(() => debouncedHello("Time: 300"), 300);   // hello wird 300ms nach Testbeginn ausgeführt
setTimeout(() => { debouncedHello.forceNext();        // forceNext() übergeht die Wiederaufrufsperre
                   debouncedHello("Time: 350");       // hello wird 350ms nach Testbeginn ausgeführt
                 }, 350);

Bei jedem Aufruf der debounceCore Funktion wird geprüft, ob die Sperrfrist bis zum nächsten Aufruf abgelaufen ist. Wenn nicht, wird der Aufruf ignoriert. Auch hier steht eine Art von Cancel-Methode bereit, um das Debouncing zu umgehen, aber in diesem Fall der nächste Aufruf erzwungen werden soll, wurde sie forceNext getauft.

Throttling

Beim Throttling (Drosseln) handelt es sich um eine Modifikation des Debouncings. Problem aller oben gezeigten Debouncer ist, dass die Funktion nicht gerufen wird, wenn über eine längere Zeit hinweg die Aufrufe mit einem Abstand stattfinden, der kürzer als die Wartezeit ist. Beim Throttling prüft man zusätzlich, ob eine Mindestwartezeit zwischen zwei tatsächlich durchgeführten Aufrufen vergangen ist.

Die hier vorgestellte Funktion weicht von der von Jhey Tompkins veröffentlichen Funktion etwas ab. Jhey hat die Aufrufe grundsätzlich über setTimeout laufen lassen. Das führt zu Mindestverzögerungen vom 4ms, was hier verhindert werden soll.

Throttling
Function.prototype.throttle = function(minimumDistance) {
   let timeout,
       lastCalled = 0,
       throttledFunction = this;

   function throttleCore() {
      let context = this;

      function callThrottledFunction(args) {
         lastCalled = Date.now();
         throttledFunction.apply(context, args);
      }
      // Wartezeit bis zum nächsten Aufruf bestimmen
      let timeToNextCall = minimumDistance - (Date.now() - lastCalled);
      // Egal was kommt, einen noch offenen alten Call löschen
      cancelTimer();
      // Aufruf direkt durchführen oder um offene Wartezeit verzögern
      if (timeToNextCall < 0) {
         callThrottledFunction(arguments, 0);
      } else {
         timeout = setTimeout(callThrottledFunction, timeToNextCall, arguments);
      }
   }
   function cancelTimer() {
      if (timeout) {
         clearTimeout(timeout);
         timeout = undefined;
      }
   }
   // Aufsperre aufheben und gepeicherte Rest-Aufrufe löschen
   throttleCore.reset = function() {
      cancelTimer();
      lastCalled = 0;
   }
   return throttleCore;
};

Die vorgestellte throttle-Funktion merkt sich, wann sie die zu drosselnde Funktion zuletzt aufgerufen hat. Wenn ein neuer Aufruf kommt, wird die Zeit seit dem letzten Aufruf bestimmt (Date.now() - lastCalled) und von der minimalen zeitlichen Distanz zwischen zwei Aufrufen abgezogen. Daraus ergibt sich die Wartezeit, bis ein neuer Aufruf erlaubt ist. Ist das Ergebnis negativ, ist genügend Zeit vergangen und die Funktion kann direkt aufgerufen werden. Ist es positiv, wird der Aufruf der zu drosselnden Funktion um die ermittelte Wartezeit verzögert. In beiden Fällen wird ein eventuell noch offener setTimeout-Call beendet.

Der Zeitpunkt des letzten Aufrufs wird auf 0 initialisiert, das heißt, dass der erste Aufruf auf jeden Fall durchgeführt wird. Ab dann beginnt die Drosselung.

Um die Drosselung aufheben zu können, wird auch hier eine Hilfsfunktion namens reset bereitgestellt. Sie löscht einen eventuell laufenden Timer und setzt den Zeitpunkt des letzten Aufrufs auf 0.