Een paar trucjes om je JavaScript-toepassing te versnellen
Bartosz Slysz
Software Engineer
Met de vooruitgang van de browsertechnologie zijn webapplicaties steeds meer logica gaan verplaatsen naar de front-end, waardoor de server wordt ontlast en minder handelingen hoeft uit te voeren. Bij basis CRUD's komt de rol van de server neer op autorisatie, validatie, communicatie met databases en de vereiste bedrijfslogica. De rest van de gegevenslogica, zo blijkt, kan eenvoudig worden afgehandeld door de code die verantwoordelijk is voor de weergave van de toepassing aan de UI-kant.
In dit artikel zal ik proberen om je een paar voorbeelden en patronen te laten zien die helpen om onze code efficiënt, netjes en snel.
Voordat we dieper ingaan op specifieke voorbeelden, wil ik me in dit artikel alleen richten op voorbeelden die naar mijn mening de snelheid van de applicatie op een verrassende manier kunnen beïnvloeden. Dit betekent echter niet dat het gebruik van snellere oplossingen in alle gevallen de beste keuze is. De onderstaande tips moeten eerder worden gezien als iets waar je naar moet kijken als onze applicatie langzaam draait, bijvoorbeeld in producten die game rendering of meer geavanceerde grafieken op het canvas vereisen, videobewerkingen of activiteiten die je zo snel mogelijk in realtime wilt synchroniseren.
Allereerst - Array.prototype methoden
We baseren een groot deel van de applicatielogica op arrays - het in kaart brengen, sorteren, filteren, optellen van elementen enzovoort. Op een eenvoudige, transparante en natuurlijke manier gebruiken we hun ingebouwde methoden waarmee we eenvoudig verschillende soorten berekeningen, groeperingen enz. kunnen uitvoeren. Ze werken in elke instantie op dezelfde manier - als argument geven we een functie door waarbij, in de meeste gevallen, de elementwaarde, index en array om beurten worden gepusht tijdens elke iteratie. De opgegeven functie wordt uitgevoerd voor elk element in de array en het resultaat wordt verschillend geïnterpreteerd, afhankelijk van de methode. Ik zal niet verder ingaan op de methoden van Array.prototype, omdat ik me wil richten op waarom het in een groot aantal gevallen langzaam werkt.
De Array-methodes zijn traag omdat ze voor elk element een functie uitvoeren. Een functie die wordt aangeroepen vanuit het perspectief van de engine moet een nieuwe aanroep voorbereiden, de juiste scope en veel andere afhankelijkheden bieden, waardoor het proces veel langer duurt dan het herhalen van een specifiek blok code in een specifieke scope. En dit is waarschijnlijk genoeg achtergrondkennis om het volgende voorbeeld te begrijpen:
(() => {
const randomArray = [...Array(1E6).keys()].map() => ({waarde: Math.random() });
console.time('Som door reduceren');
const reduceSum = randomArray
.map(({waarde }) => waarde)
.reduce((a, b) => a + b);
console.timeEnd('Som door verminderen');
console.time('Som door for-lus');
let forSum = randomArray[0].value;
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Som door for-lus');
console.log(reduceSum === forSum);
})();
Ik weet dat deze test niet zo betrouwbaar is als de benchmarks (daar komen we later nog op terug), maar er gaat een waarschuwingslampje branden. Voor een willekeurig geval op mijn computer, blijkt dat de code met de for-lus ongeveer 50 keer sneller kan zijn in vergelijking met het in kaart brengen en vervolgens verkleinen van elementen die hetzelfde effect bereiken! Dit gaat over het werken op een vreemd object dat alleen is gemaakt om een specifiek doel van berekeningen te bereiken. Laten we dus iets maken dat meer rechtmatig is om objectief te zijn over de Array-methoden:
(() => {
const randomArray = [...Array(1E6).keys()].map() => ({waarde: Math.random() });
console.time('Som door reduceren');
const reduceSum = randomArray
.reduce((a, b) => ({waarde: a.value + b.value })).value
console.timeEnd('Som door verminderen');
console.time('Som door for-lus');
let forSum = randomArray[0].value;
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Som door for-lus');
console.log(reduceSum === forSum);
})();
Ik weet dat deze test niet zo betrouwbaar is als de benchmarks (daar komen we later nog op terug), maar er gaat een waarschuwingslampje branden. Voor een willekeurig geval op mijn computer blijkt dat de code met de for-lus ongeveer 50 keer sneller kan zijn in vergelijking met het in kaart brengen en vervolgens reduceren van elementen om hetzelfde effect te bereiken! Dit komt omdat het verkrijgen van de som in dit specifieke geval met behulp van de reduceermethode het in kaart brengen van de array vereist voor pure waarden die we willen samenvatten. Laten we dus iets maken dat meer rechtmatig is om objectief te zijn over de Array-methoden:
(() => {
const randomArray = [...Array(1E6).keys()].map() => ({waarde: Math.random() });
console.time('Som door reduceren');
const reduceSum = randomArray
.reduce((a, b) => ({waarde: a.value + b.value })).value
console.timeEnd('Som door verminderen');
console.time('Som door for-lus');
let forSum = randomArray[0].value;
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Som door for-lus');
console.log(reduceSum === forSum);
})();
En wat blijkt, onze 50x boost is gedaald naar 4x boost. Excuses als je je teleurgesteld voelt! Laten we, om objectief te blijven, beide codes nog eens analyseren. Allereerst - onschuldig ogende verschillen verdubbelden de daling van onze theoretische rekencomplexiteit; in plaats van eerst pure elementen in kaart te brengen en dan op te tellen, werken we nog steeds op objecten en een specifiek veld, om er uiteindelijk uit te halen om de som te krijgen waarin we geïnteresseerd zijn. Het probleem ontstaat wanneer een andere programmeur de code bekijkt - dan verliest deze, vergeleken met de eerder getoonde codes, op een bepaald punt zijn abstractie.
Dit komt omdat we sinds de tweede bewerking werken op een vreemd object, met het veld dat voor ons interessant is en het tweede, standaard object van de geïtereerde array. Ik weet niet hoe jij erover denkt, maar vanuit mijn perspectief is in het tweede codevoorbeeld de logica van de for-lus veel duidelijker en meer bedoeld dan deze vreemd uitziende herleiding. En toch, ook al is het niet meer de mythische 50, het is nog steeds 4 keer sneller als het gaat om rekentijd! Omdat elke milliseconde waardevol is, is de keuze in dit geval eenvoudig.
Het meest verrassende voorbeeld
Het tweede wat ik wilde vergelijken is de Math.max methode of, om precies te zijn, het vullen van een miljoen elementen en de grootste en kleinste eruit halen. Ik heb de code voorbereid en ook methoden voor het meten van de tijd. Vervolgens start ik de code en krijg ik een heel vreemde foutmelding - de stackgrootte is overschreden. Hier is de code:
(() => {
const randomValues = [...Array(1E6).keys()].map() => Math.round(Math.random() * 1E6) - 5E5);
console.time('Math.max met ES6 spread operator');
const maxBySpread = Math.max(...randomValues);
console.timeEnd('Math.max met ES6 spreiding operator');
console.time('Math.max met for-lus');
let maxByFor = randomValues[0];
for (let index = 1; index maxByFor) {
maxByFor = randomValues[index];
}
}
console.timeEnd('Math.max met for-lus');
console.log(maxByFor === maxBySpread);
})();
(() => {
const randomValues = [...Array(1E6).keys()].map() => Math.round(Math.random() * 1E6) - 5E5);
console.time('Math.max met ES6 spread operator');
const maxBySpread = Math.max(...randomValues);
console.timeEnd('Math.max met ES6 spreiding operator');
console.time('Math.max met for-lus');
let maxByFor = randomValues[0];
for (let index = 1; index maxByFor) {
maxByFor = randomValues[index];
}
}
console.timeEnd('Math.max met for-lus');
console.log(maxByFor === maxBySpread);
})();
Het blijkt dat native methoden recursie gebruiken, die in v8 wordt beperkt door call stacks en het aantal is afhankelijk van de omgeving. Dit is iets dat me erg verbaasde, maar het draagt een conclusie in zich: de native methode is sneller, zolang onze array niet groter is dan een bepaald magisch aantal elementen, wat in mijn geval 125375 bleek te zijn. Voor dit aantal elementen was het resultaat 5x sneller in vergelijking met de lus. Boven het genoemde aantal elementen wint de for-lus echter absoluut - in tegenstelling tot de tegenstander, kunnen we hiermee correcte resultaten krijgen.
Recursie
Het concept dat ik in deze paragraaf wil noemen is recursie. In het vorige voorbeeld zagen we dit in de Math.max methode en argument folding, waar bleek dat het onmogelijk is om een resultaat te krijgen voor recursieve aanroepen die een bepaald getal overschrijden vanwege de stackgroottebeperking.
We zullen nu zien hoe recursie eruit ziet in de context van code geschreven in JS, en niet met ingebouwde methodes.Misschien wel het meest klassieke dat we hier kunnen laten zien is natuurlijk het vinden van de n-de term in de Fibonacci-reeks. Dus laten we dit schrijven!
(() => {
const fiboIterative = (n) => {
laat [a, b] = [0, 1];
for (laat i = 0; i {
if(n < 2) {
return n;
}
return fiboRecursive(n - 2) + fiboRecursive(n - 1);
};
console.time('Fibonacci-reeks door for-lus');
const resultIterative = fiboIterative(30);
console.timeEnd('Fibonacci-reeks door for-lus');
console.time('Fibonacci-reeks door recursie');
const resultRecursive = fiboRecursive(30);
console.timeEnd('Fibonacci rij door recursie');
console.log(resultRecursive === resultIterative);
})();
Oké, in dit specifieke geval van het berekenen van het 30e item van de reeks op mijn computer, krijgen we het resultaat in ongeveer 200x kortere tijd met het iteratieve algoritme.
Er is echter één ding dat kan worden gecorrigeerd in het recursieve algoritme - het blijkt veel efficiënter te werken als we een tactiek gebruiken die staartrecursie wordt genoemd. Dit betekent dat we het resultaat dat we in de vorige iteratie verkregen hebben, doorgeven als argumenten voor diepere aanroepen. Hierdoor kunnen we het aantal benodigde aanroepen verminderen en, als gevolg daarvan, het resultaat versnellen. Laten we onze code dienovereenkomstig corrigeren!
(() => {
const fiboIterative = (n) => {
laat [a, b] = [0, 1];
for (laat i = 0; i {
if(n === 0) {
return first;
}
return fiboTailRecursive(n - 1, second, first + second);
};
console.time('Fibonacci-reeks door for-lus');
const resultIterative = fiboIterative(30);
console.timeEnd('Fibonacci-reeks door for-lus');
console.time('Fibonacci-reeks door staartrecursie');
const resultRecursive = fiboTailRecursive(30);
console.timeEnd('Fibonacci-reeks door staartrecursie');
console.log(resultRecursive === resultIterative);
})();
Er gebeurde hier iets wat ik niet helemaal had verwacht - het resultaat van het staartrecursiealgoritme was in staat om het resultaat (het berekenen van het 30e element van een reeks) in sommige gevallen bijna twee keer zo snel te leveren als het iteratieve algoritme. Ik weet niet helemaal zeker of dit komt door optimalisatie voor staartrecursie van v8 of door het gebrek aan optimalisatie voor de for-lus voor dit specifieke aantal iteraties, maar het resultaat is ondubbelzinnig - staartrecursie wint.
Dit is vreemd omdat de for-lus in wezen veel minder abstractie oplegt aan rekenactiviteiten op een lager niveau en je zou kunnen zeggen dat het dichter bij de basiscomputerwerking ligt. Toch zijn de resultaten onmiskenbaar - slim ontworpen recursie blijkt sneller te zijn dan iteratie.
Gebruik zo vaak mogelijk asynchrone aanroepverklaringen
Ik wil de laatste paragraaf wijden aan een korte herinnering over een methode om bewerkingen uit te voeren die ook de snelheid van onze applicatie sterk kan beïnvloeden. Zoals je zou moeten weten, JavaScript is een single-threaded taal die alle bewerkingen bijhoudt met event-loop mechanisme. Het draait allemaal om een cyclus die steeds opnieuw wordt doorlopen en alle stappen in deze cyclus gaan over specifieke gespecificeerde acties.
Om deze lus snel te maken en alle cycli minder lang op hun beurt te laten wachten, moeten alle elementen zo snel mogelijk zijn. Vermijd het uitvoeren van lange bewerkingen op de hoofdthread - als iets te lang duurt, probeer deze berekeningen dan te verplaatsen naar WebWorker of op te splitsen in delen die asynchroon worden uitgevoerd. Het kan sommige bewerkingen vertragen, maar het hele ecosysteem van JS krijgt een boost, inclusief IO-bewerkingen, zoals het afhandelen van muisbewegingen of lopende HTTP-verzoeken.
Samenvatting
Concluderend, zoals eerder vermeld kan het najagen van milliseconden die bespaard kunnen worden door een algoritme te kiezen in sommige gevallen zinloos blijken te zijn. Aan de andere kant kan het verwaarlozen van zulke dingen in toepassingen die een soepele werking en snelle resultaten vereisen dodelijk zijn voor je toepassing. In sommige gevallen moet je, naast de snelheid van het algoritme, nog een andere vraag stellen: wordt er op het juiste niveau geabstraheerd? Zal de programmeur die de code leest deze zonder problemen kunnen begrijpen?
De enige manier is om te zorgen voor een balans tussen prestaties, implementatiegemak en de juiste abstractie, en er zeker van te zijn dat het algoritme correct werkt voor zowel kleine als grote hoeveelheden gegevens. De manier om dit te doen is vrij eenvoudig - wees slim, overweeg de verschillende gevallen bij het ontwerpen van het algoritme en zorg ervoor dat het zich zo efficiënt mogelijk gedraagt bij gemiddelde uitvoeringen. Het is ook raadzaam om tests te ontwerpen - zorg ervoor dat het algoritme de juiste informatie retourneert voor verschillende gegevens, ongeacht hoe het werkt. Zorg voor de juiste interfaces - zodat zowel de invoer als de uitvoer van methoden leesbaar en duidelijk zijn en precies weergeven wat ze doen.
Ik zei al eerder dat ik terug zal komen op de betrouwbaarheid van het meten van de snelheid van de algoritmes in de voorbeelden hierboven. Ze meten met console.time is niet erg betrouwbaar, maar het geeft de standaard use-case het beste weer. Hoe dan ook, ik presenteer de benchmarks hieronder - sommige zien er een beetje anders uit dan een enkele uitvoering vanwege het feit dat benchmarks simpelweg een bepaalde activiteit op een bepaald tijdstip herhalen en v8 optimalisatie gebruiken voor lussen.
https://jsben.ch/KhAqb - verkleinen vs for-lus
https://jsben.ch/F4kLY - geoptimaliseerde reduce vs for-lus
https://jsben.ch/MCr6g - Math.max vs for-lus
https://jsben.ch/A0CJB - recursief fibo vs iteratief fibo