Ein paar Tricks zur Beschleunigung Ihrer JavaScript-Anwendung
Bartosz Slysz
Software Engineer
Mit der Weiterentwicklung der Browsertechnologie haben Webanwendungen damit begonnen, immer mehr Logik auf das Frontend zu verlagern und so den Server zu entlasten und die Zahl der von ihm auszuführenden Operationen zu verringern. Bei grundlegenden CRUDs beschränkt sich die Rolle des Servers auf die Autorisierung, Validierung, Kommunikation mit Datenbanken und die erforderliche Geschäftslogik. Der Rest der Datenlogik kann, wie sich herausstellt, leicht von dem Code übernommen werden, der für die Darstellung der Anwendung auf der UI-Seite verantwortlich ist.
In diesem Artikel werde ich versuchen, Ihnen einige Beispiele und Muster zu zeigen, die dazu beitragen, dass unsere Code effizient, sauber und schnell.
Bevor wir auf konkrete Beispiele eingehen - in diesem Artikel möchte ich mich nur auf die Beispiele konzentrieren, die meiner Meinung nach die Geschwindigkeit der Anwendung auf überraschende Weise beeinflussen können. Das bedeutet jedoch nicht, dass die Verwendung schnellerer Lösungen in jedem Fall die beste Wahl ist. Die nachstehenden Tipps sollten vielmehr als etwas betrachtet werden, das Sie in Betracht ziehen sollten, wenn unsere Anwendung langsam läuft, z. B. bei Produkten, die das Rendern von Spielen oder fortgeschrittenere Grafiken auf der Leinwand erfordern, bei Videooperationen oder bei Aktivitäten, die Sie so schnell wie möglich in Echtzeit synchronisieren möchten.
Zunächst einmal - Array.prototype-Methoden
Ein großer Teil der Anwendungslogik basiert auf Arrays - ihre Zuordnung, Sortierung, Filterung, Summierung von Elementen usw. Auf einfache, transparente und natürliche Weise verwenden wir ihre eingebauten Methoden, die es uns einfach ermöglichen, verschiedene Arten von Berechnungen, Gruppierungen usw. durchzuführen. Sie funktionieren in jeder Instanz ähnlich - als Argument übergeben wir eine Funktion, bei der in den meisten Fällen der Elementwert, der Index und das Array bei jeder Iteration abwechselnd geschoben werden. Die angegebene Funktion wird für jedes Element im Array ausgeführt und das Ergebnis wird je nach Methode unterschiedlich interpretiert. Ich werde nicht näher auf die Methoden von Array.prototype eingehen, da ich mich darauf konzentrieren möchte, warum es in vielen Fällen langsam läuft.
Die Array-Methoden sind langsam, weil sie für jedes Element eine Funktion ausführen. Eine Funktion, die aus der Perspektive der Engine aufgerufen wird, muss einen neuen Aufruf vorbereiten, den entsprechenden Bereich bereitstellen und eine Menge anderer Abhängigkeiten, was den Prozess viel länger macht als die Wiederholung eines bestimmten Codeblocks in einem bestimmten Bereich. Und das ist wahrscheinlich genug Hintergrundwissen, um das folgende Beispiel zu verstehen:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() }));
console.time('Summe durch reduzieren');
const reduceSum = randomArray
.map(({ Wert }) => Wert)
.reduce((a, b) => a + b);
console.timeEnd('Summe durch reduzieren');
console.time('Summe durch for-Schleife');
let forSum = randomArray[0].value;
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Summe durch for-Schleife');
console.log(reduceSum === forSum);
})();
Ich weiß, dass dieser Test nicht so zuverlässig ist wie die Benchmarks (wir werden später darauf zurückkommen), aber er löst ein Warnlicht aus. Für einen zufälligen Fall auf meinem Computer stellt sich heraus, dass der Code mit der for-Schleife etwa 50 Mal schneller sein kann, wenn man ihn mit dem Mappen und anschließenden Reduzieren von Elementen vergleicht, die denselben Effekt erzielen! Hier geht es darum, mit einem seltsamen Objekt zu arbeiten, das nur geschaffen wurde, um ein bestimmtes Ziel von Berechnungen zu erreichen. Lassen Sie uns also etwas Legaleres schaffen, um die Array-Methoden objektiv zu betrachten:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() }));
console.time('Summe durch reduzieren');
const reduceSum = randomArray
.reduce((a, b) => ({ Wert: a.Wert + b.Wert })).value
console.timeEnd('Summe durch reduzieren');
console.time('Summe durch for-Schleife');
let forSum = randomArray[0].value;
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Summe durch for-Schleife');
console.log(reduceSum === forSum);
})();
Ich weiß, dass dieser Test nicht so zuverlässig ist wie die Benchmarks (wir werden später darauf zurückkommen), aber er löst ein Warnlicht aus. Für einen zufälligen Fall auf meinem Computer stellt sich heraus, dass der Code mit der for-Schleife etwa 50 Mal schneller sein kann, wenn man ihn mit dem Zuordnen und anschließenden Reduzieren von Elementen vergleicht, die denselben Effekt erzielen! Das liegt daran, dass die Summe in diesem speziellen Fall mit der Reduktionsmethode das Mapping des Arrays für reine Werte, die wir zusammenfassen wollen, erfordert. Erstellen wir also etwas Legitimes, um die Array-Methoden objektiv zu betrachten:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() }));
console.time('Summe durch reduzieren');
const reduceSum = randomArray
.reduce((a, b) => ({ Wert: a.Wert + b.Wert })).value
console.timeEnd('Summe durch reduzieren');
console.time('Summe durch for-Schleife');
let forSum = randomArray[0].value;
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Summe durch for-Schleife');
console.log(reduceSum === forSum);
})();
Und wie sich herausstellte, sank unser 50x-Boost auf 4x-Boost. Entschuldigung, wenn Sie enttäuscht sind! Um bis zum Ende objektiv zu bleiben, lassen Sie uns beide Codes noch einmal analysieren. Zunächst einmal verdoppelten harmlos aussehende Unterschiede den Rückgang unserer theoretischen Berechnungskomplexität; statt zunächst eine Zuordnung vorzunehmen und dann reine Elemente zu addieren, operieren wir immer noch mit Objekten und einem bestimmten Feld, um schließlich die Summe herauszuziehen, an der wir interessiert sind. Das Problem entsteht, wenn ein anderer Programmierer einen Blick auf den Code wirft - dann verliert dieser im Vergleich zu den zuvor gezeigten Codes an einem bestimmten Punkt seine Abstraktion.
Das liegt daran, dass wir seit der zweiten Operation mit einem fremden Objekt arbeiten, mit dem Feld, das uns interessiert, und dem zweiten, normalen Objekt des iterierten Arrays. Ich weiß nicht, was Sie darüber denken, aber aus meiner Sicht ist die Logik der for-Schleife im zweiten Codebeispiel viel klarer und bewusster als diese seltsam aussehende Reduzierung. Und auch wenn es nicht mehr die mythischen 50 sind, ist es immer noch viermal schneller, was die Rechenzeit angeht! Da jede Millisekunde wertvoll ist, ist die Entscheidung in diesem Fall einfach.
Das überraschendste Beispiel
Die zweite Sache, die ich vergleichen wollte, betrifft die Math.max-Methode oder, genauer gesagt, das Füllen einer Million Elemente und das Extrahieren der größten und kleinsten Elemente. Ich habe den Code vorbereitet, auch die Methoden zur Zeitmessung, dann starte ich den Code und erhalte einen sehr merkwürdigen Fehler - die Stapelgröße ist überschritten. Hier ist der Code:
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5);
console.time('Math.max mit ES6-Spread-Operator');
const maxBySpread = Math.max(...randomValues);
console.timeEnd('Math.max mit ES6-Spread-Operator');
console.time('Math.max mit for-Schleife');
let maxByFor = randomValues[0];
for (let index = 1; index maxByFor) {
maxByFor = randomValues[index];
}
}
console.timeEnd('Math.max mit for-Schleife');
console.log(maxByFor === maxBySpread);
})();
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5);
console.time('Math.max mit ES6-Spread-Operator');
const maxBySpread = Math.max(...randomValues);
console.timeEnd('Math.max mit ES6-Spread-Operator');
console.time('Math.max mit for-Schleife');
let maxByFor = randomValues[0];
for (let index = 1; index maxByFor) {
maxByFor = randomValues[index];
}
}
console.timeEnd('Math.max mit for-Schleife');
console.log(maxByFor === maxBySpread);
})();
Es stellt sich heraus, dass die nativen Methoden eine Rekursion verwenden, die in v8 durch Aufrufstapel begrenzt ist und deren Anzahl von der Umgebung abhängig ist. Das ist etwas, das mich sehr überrascht hat, aber es bringt eine Schlussfolgerung mit sich: die native Methode ist schneller, solange unser Array eine bestimmte magische Anzahl von Elementen nicht überschreitet, die in meinem Fall 125375 betrug. Bei dieser Anzahl von Elementen war das Ergebnis von for im Vergleich zur Schleife 5x schneller. Oberhalb der genannten Anzahl von Elementen gewinnt die for-Schleife jedoch eindeutig - im Gegensatz zu ihrem Gegner ermöglicht sie es uns, korrekte Ergebnisse zu erhalten.
Rekursion
Das Konzept, das ich in diesem Abschnitt erwähnen möchte, ist die Rekursion. Im vorangegangenen Beispiel haben wir es in der Math.max-Methode und der Argumentenfaltung gesehen, wo sich herausstellte, dass es aufgrund der Stapelgrößenbeschränkung unmöglich ist, ein Ergebnis für rekursive Aufrufe zu erhalten, die eine bestimmte Zahl überschreiten.
Wir werden nun sehen, wie die Rekursion im Kontext von in JS geschriebenem Code und nicht mit eingebauten Methoden aussieht. Das vielleicht klassischste, was wir hier zeigen können, ist natürlich das Finden des n-ten Terms in der Fibonacci-Folge. Also, schreiben wir das!
(() => {
const fiboIterative = (n) => {
lass [a, b] = [0, 1];
for (let i = 0; i {
if(n < 2) {
return n;
}
return fiboRecursive(n - 2) + fiboRecursive(n - 1);
};
console.time('Fibonacci-Folge durch for-Schleife');
const resultIterative = fiboIterative(30);
console.timeEnd('Fibonacci-Folge durch for-Schleife');
console.time('Fibonacci-Folge durch Rekursion');
const resultRecursive = fiboRecursive(30);
console.timeEnd('Fibonacci-Folge durch Rekursion');
console.log(resultRecursive === resultIterative);
})();
Okay, in diesem speziellen Fall der Berechnung des 30. Elements der Sequenz auf meinem Computer, erhalten wir das Ergebnis in etwa 200x kürzerer Zeit mit dem iterativen Algorithmus.
Es gibt jedoch eine Sache, die im rekursiven Algorithmus korrigiert werden kann - wie sich herausstellt, funktioniert er viel effizienter, wenn wir eine Taktik namens Tail-Rekursion verwenden. Das bedeutet, dass wir das Ergebnis, das wir in der vorherigen Iteration erhalten haben, als Argument für weitere Aufrufe verwenden. Auf diese Weise können wir die Anzahl der erforderlichen Aufrufe verringern und somit das Ergebnis beschleunigen. Korrigieren wir unseren Code entsprechend!
(() => {
const fiboIterative = (n) => {
lass [a, b] = [0, 1];
for (let i = 0; i {
if(n === 0) {
return first;
}
return fiboTailRecursive(n - 1, second, first + second);
};
console.time('Fibonacci-Folge durch for-Schleife');
const resultIterative = fiboIterative(30);
console.timeEnd('Fibonacci-Folge durch for-Schleife');
console.time('Fibonacci-Folge durch Schwanzrekursion');
const resultRecursive = fiboTailRecursive(30);
console.timeEnd('Fibonacci-Folge durch Schwanzrekursion');
console.log(resultRecursive === resultIterative);
})();
Hier ist etwas passiert, was ich nicht ganz erwartet habe - das Ergebnis des Tail-Rekursions-Algorithmus war in der Lage, das Ergebnis (Berechnung des 30. Elements einer Sequenz) in einigen Fällen fast doppelt so schnell zu liefern wie der iterative Algorithmus. Ich bin mir nicht ganz sicher, ob dies auf die Optimierung für die Tail-Rekursion seitens v8 oder auf die fehlende Optimierung der for-Schleife für diese spezifische Anzahl von Iterationen zurückzuführen ist, aber das Ergebnis ist eindeutig - die Tail-Rekursion gewinnt.
Das ist seltsam, weil die for-Schleife im Grunde genommen viel weniger Abstraktion von Berechnungsaktivitäten auf niedrigerer Ebene erfordert, und man könnte sagen, dass sie näher an der grundlegenden Computeroperation ist. Dennoch sind die Ergebnisse unbestreitbar - eine geschickt konzipierte Rekursion ist schneller als eine Iteration.
Verwenden Sie so oft wie möglich asynchrone Aufrufanweisungen
Den letzten Absatz möchte ich einer kurzen Erinnerung an eine Methode zur Durchführung von Operationen widmen, die ebenfalls einen großen Einfluss auf die Geschwindigkeit unserer Anwendung haben kann. Wie Sie wissen sollten, JavaScript ist eine Single-Thread-Sprache, die alle Operationen mit einem Ereignis-Schleifen-Mechanismus durchführt. Es geht um einen Zyklus, der immer wieder abläuft, und alle Schritte in diesem Zyklus sind auf bestimmte Aktionen ausgerichtet.
Damit diese Schleife schnell ist und alle Zyklen weniger lange warten müssen, bis sie an der Reihe sind, sollten alle Elemente so schnell wie möglich sein. Vermeiden Sie lange Operationen auf dem Hauptthread - wenn etwas zu lange dauert, versuchen Sie, diese Berechnungen in den WebWorker zu verlagern oder in Teile aufzuteilen, die asynchron laufen. Dies kann einige Operationen verlangsamen, verbessert aber das gesamte Ökosystem von JS, einschließlich IO-Operationen, wie z. B. die Verarbeitung von Mausbewegungen oder anstehenden HTTP-Anfragen.
Zusammenfassung
Wie bereits erwähnt, kann sich die Jagd nach Millisekunden, die durch die Auswahl eines Algorithmus eingespart werden können, in einigen Fällen als sinnlos erweisen. Andererseits kann die Vernachlässigung solcher Dinge in Anwendungen, die einen reibungslosen Ablauf und schnelle Ergebnisse erfordern, für Ihre Anwendung tödlich sein. In manchen Fällen sollte neben der Geschwindigkeit des Algorithmus noch eine weitere Frage gestellt werden: Ist die Abstraktion auf der richtigen Ebene angesiedelt? Wird der Programmierer, der den Code liest, ihn ohne Probleme verstehen können?
Die einzige Möglichkeit besteht darin, ein Gleichgewicht zwischen Leistung, einfacher Implementierung und angemessener Abstraktion herzustellen und sicher zu sein, dass der Algorithmus sowohl bei kleinen als auch bei großen Datenmengen korrekt funktioniert. Der Weg dorthin ist recht einfach - seien Sie klug, berücksichtigen Sie die verschiedenen Fälle beim Entwurf des Algorithmus und gestalten Sie ihn so, dass er sich bei durchschnittlichen Ausführungen so effizient wie möglich verhält. Außerdem ist es ratsam, Tests zu entwerfen - stellen Sie sicher, dass der Algorithmus die entsprechenden Informationen für verschiedene Daten zurückgibt, egal wie er arbeitet. Achten Sie auf die richtigen Schnittstellen - damit sowohl die Eingabe als auch die Ausgabe von Methoden lesbar und klar sind und genau widerspiegeln, was sie tun.
Ich habe bereits erwähnt, dass ich auf die Zuverlässigkeit der Messung der Geschwindigkeit der Algorithmen in den obigen Beispielen zurückkommen werde. Die Messung mit console.time ist nicht sehr zuverlässig, aber sie spiegelt den Standard-Anwendungsfall am besten wider. Wie auch immer, ich präsentiere die Benchmarks unten - einige von ihnen sehen etwas anders aus als eine einzelne Ausführung, was daran liegt, dass die Benchmarks einfach eine bestimmte Aktivität zu einer bestimmten Zeit wiederholen und die v8-Optimierung für Schleifen verwenden.
https://jsben.ch/KhAqb - reduzieren vs. for-Schleife
https://jsben.ch/F4kLY - optimiertes Reduzieren gegenüber for-Schleife
https://jsben.ch/MCr6g - Math.max vs. for-Schleife
https://jsben.ch/A0CJB - Rekursive Fibo vs. iterative Fibo