Et par tricks til at gøre din JavaScript-applikation hurtigere
Bartosz Slysz
Software Engineer
Med udviklingen af browserteknologi er webapplikationer begyndt at overføre mere og mere logik til frontend, hvilket aflaster serveren og reducerer antallet af operationer, den skal udføre. I grundlæggende CRUD'er kommer serverens rolle til at handle om autorisation, validering, kommunikation med databaser og den nødvendige forretningslogik. Resten af datalogikken kan nemt håndteres af den kode, der er ansvarlig for repræsentationen af applikationen på UI-siden.
I denne artikel vil jeg forsøge at vise dig et par eksempler og mønstre, der kan hjælpe med at holde vores Kode effektivt, pænt og hurtigt.
Før vi går dybere ind i specifikke eksempler - i denne artikel vil jeg kun fokusere på at vise tilfælde, der efter min mening kan påvirke applikationens hastighed på en overraskende måde. Det betyder dog ikke, at brugen af hurtigere løsninger er det bedste valg i alle mulige tilfælde. Nedenstående tips skal snarere ses som noget, man bør undersøge, når vores applikation kører langsomt, f.eks. i produkter, der kræver spilrendering eller mere avancerede grafer på lærredet, videooperationer eller aktiviteter, som man ønsker at synkronisere i realtid så hurtigt som muligt.
Først og fremmest - Array.prototype-metoder
Vi baserer en stor del af applikationslogikken på arrays - deres mapping, sortering, filtrering, summering af elementer og så videre. På en nem, gennemsigtig og naturlig måde bruger vi deres indbyggede metoder, som ganske enkelt giver os mulighed for at udføre forskellige typer beregninger, grupperinger osv. De fungerer på samme måde i hvert tilfælde - som argument sender vi en funktion, hvor elementværdien, indekset og arrayet i de fleste tilfælde skubbes på skift under hver iteration. Den angivne funktion udføres for hvert element i arrayet, og resultatet fortolkes forskelligt afhængigt af metoden. Jeg vil ikke uddybe Array.prototypens metoder, da jeg vil fokusere på, hvorfor den kører langsomt i mange tilfælde.
Array-metoderne er langsomme, fordi de udfører en funktion for hvert element. En funktion, der kaldes fra motorens perspektiv, skal forberede et nyt kald, give det rette scope og en masse andre afhængigheder, hvilket gør processen meget længere end at gentage en bestemt kodeblok i et bestemt scope. Og det er nok baggrundsviden nok til at forstå det følgende eksempel:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() }));
console.time('Sum ved at reducere');
const reduceSum = randomArray
.map(({ value }) => value)
.reduce((a, b) => a + b);
console.timeEnd('Sum ved at reducere');
console.time('Sum ved for-løkke');
let forSum = randomArray[0].value;
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Sum by for loop');
console.log(reduceSum === forSum);
})();
Jeg ved godt, at denne test ikke er lige så pålidelig som benchmarks (dem vender vi tilbage til senere), men den udløser en advarselslampe. I et tilfældigt tilfælde på min computer viser det sig, at koden med for-løkken kan være ca. 50 gange hurtigere sammenlignet med at kortlægge og derefter reducere elementer, der opnår samme effekt! Det handler om at operere på et mærkeligt objekt, der kun er skabt for at nå et bestemt mål for beregninger. Så lad os skabe noget mere legitimt for at være objektive omkring Array-metoderne:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() }));
console.time('Sum ved at reducere');
const reduceSum = randomArray
.reduce((a, b) => ({ value: a.value + b.value })).value
console.timeEnd('Sum ved at reducere');
console.time('Sum ved for-loop');
let forSum = randomArray[0].value;
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Sum by for loop');
console.log(reduceSum === forSum);
})();
Jeg ved godt, at denne test ikke er lige så pålidelig som benchmarks (dem vender vi tilbage til senere), men den udløser en advarselslampe. I et tilfældigt tilfælde på min computer viser det sig, at koden med for-løkken kan være ca. 50 gange hurtigere sammenlignet med at kortlægge og derefter reducere elementer, der opnår den samme effekt! Det skyldes, at det at få summen i dette særlige tilfælde ved hjælp af reduce-metoden kræver, at man mapper arrayet for rene værdier, som vi ønsker at opsummere. Så lad os skabe noget mere legitimt for at være objektive omkring Array-metoderne:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() }));
console.time('Sum ved at reducere');
const reduceSum = randomArray
.reduce((a, b) => ({ value: a.value + b.value })).value
console.timeEnd('Sum ved at reducere');
console.time('Sum ved for-loop');
let forSum = randomArray[0].value;
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Sum by for loop');
console.log(reduceSum === forSum);
})();
Og det viste sig, at vores 50x boost blev reduceret til 4x boost. Undskyld, hvis du føler dig skuffet! Lad os analysere begge koder igen for at forblive objektive til sidst. Først og fremmest - uskyldige forskelle fordoblede faldet i vores teoretiske beregningskompleksitet; i stedet for først at mappe og derefter lægge rene elementer sammen, opererer vi stadig med objekter og et specifikt felt for til sidst at trække det ud for at få den sum, vi er interesseret i. Problemet opstår, når en anden programmør kigger på koden - så mister sidstnævnte sin abstraktion på et eller andet tidspunkt sammenlignet med de koder, der blev vist tidligere.
Det skyldes, at vi siden den anden operation opererer på et mærkeligt objekt med det felt, der er interessant for os, og det andet standardobjekt i det itererede array. Jeg ved ikke, hvad du synes om det, men fra mit perspektiv er logikken i for-løkken i det andet kodeeksempel meget klarere og mere hensigtmæssig end denne mærkelige reduktion. Og selv om det ikke længere er de mytiske 50, er det stadig 4 gange hurtigere, når det gælder beregningstid! Da hvert millisekund er værdifuldt, er valget i dette tilfælde enkelt.
Det mest overraskende eksempel
Den anden ting, jeg gerne ville sammenligne, er Math.max-metoden, eller mere præcist, at fylde en million elementer og udtrække de største og mindste. Jeg har forberedt koden og metoderne til at måle tiden, og så starter jeg koden og får en meget mærkelig fejl - stakstørrelsen er overskredet. Her er koden:
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5);
console.time('Math.max med ES6 spread operator');
const maxBySpread = Math.max(...randomValues);
console.timeEnd('Math.max med ES6 spredningsoperator');
console.time('Math.max med for-løkke');
let maxByFor = randomValues[0];
for (let index = 1; index maxByFor) {
maxByFor = randomValues[index];
}
}
console.timeEnd('Math.max med for-løkke');
console.log(maxByFor === maxBySpread);
})();
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5);
console.time('Math.max med ES6 spread operator');
const maxBySpread = Math.max(...randomValues);
console.timeEnd('Math.max med ES6 spredningsoperator');
console.time('Math.max med for-løkke');
let maxByFor = randomValues[0];
for (let index = 1; index maxByFor) {
maxByFor = randomValues[index];
}
}
console.timeEnd('Math.max med for-løkke');
console.log(maxByFor === maxBySpread);
})();
Det viser sig, at native metoder bruger rekursion, som i v8 er begrænset af call stacks, og antallet er afhængigt af miljøet. Det er noget, der overraskede mig meget, men det har en konklusion: Den oprindelige metode er hurtigere, så længe vores array ikke overstiger et vist magisk antal elementer, som i mit tilfælde viste sig at være 125375. For dette antal elementer var resultatet for 5x hurtigere sammenlignet med løkken. Men over det nævnte antal elementer vinder for-løkken helt sikkert - i modsætning til modstanderen giver den os mulighed for at få korrekte resultater.
Rekursion
Det koncept, jeg vil nævne i dette afsnit, er rekursion. I det foregående eksempel så vi det i Math.max-metoden og argumentfoldningen, hvor det viste sig, at det er umuligt at få et resultat for rekursive kald, der overstiger et bestemt tal, på grund af begrænsningen i stakkens størrelse.
Vi vil nu se, hvordan rekursion ser ud i forbindelse med kode skrevet i JS og ikke med indbyggede metoder. Den måske mest klassiske ting, vi kan vise her, er selvfølgelig at finde det n'te udtryk i Fibonacci-rækken. Så lad os skrive det!
(() => {
const fiboIterative = (n) => {
lad [a, b] = [0, 1];
for (let i = 0; i {
if(n < 2) {
returnerer n;
}
return fiboRecursive(n - 2) + fiboRecursive(n - 1);
};
console.time('Fibonacci-sekvens med for-loop');
const resultIterative = fiboIterative(30);
console.timeEnd('Fibonacci-sekvens ved for loop');
console.time('Fibonacci-sekvens ved rekursion');
const resultRecursive = fiboRecursive(30);
console.timeEnd('Fibonacci-sekvens ved rekursion');
console.log(resultRecursive === resultIterative);
})();
Okay, i dette særlige tilfælde, hvor vi beregner det 30. element i sekvensen på min computer, får vi resultatet på ca. 200 gange kortere tid med den iterative algoritme.
Der er dog en ting, der kan rettes op på i den rekursive algoritme - det viser sig, at den fungerer meget mere effektivt, når vi bruger en taktik, der kaldes tail recursion. Det betyder, at vi sender det resultat, vi fik i den forrige iteration, videre som argumenter for dybere kald. Det giver os mulighed for at reducere antallet af nødvendige kald og dermed fremskynde resultatet. Lad os rette vores kode i overensstemmelse hermed!
(() => {
const fiboIterative = (n) => {
lad [a, b] = [0, 1];
for (let i = 0; i {
if(n === 0) {
returnerer første;
}
return fiboTailRecursive(n - 1, second, first + second);
};
console.time('Fibonacci-sekvens med for-loop');
const resultIterative = fiboIterative(30);
console.timeEnd('Fibonacci-sekvens ved for loop');
console.time('Fibonacci-sekvens ved hale-rekursion');
const resultRecursive = fiboTailRecursive(30);
console.timeEnd('Fibonacci-sekvens ved hale-rekursion');
console.log(resultRecursive === resultIterative);
})();
Der skete noget, som jeg ikke helt havde forventet - resultatet af hale-rekursionsalgoritmen var i stand til at levere resultatet (beregning af det 30. element i en sekvens) næsten dobbelt så hurtigt som den iterative algoritme i nogle tilfælde. Jeg er ikke helt sikker på, om det skyldes optimering af tail recursion fra v8's side eller den manglende optimering af for-loopen til dette specifikke antal iterationer, men resultatet er entydigt - tail recursion vinder.
Det er underligt, fordi for-løkken i bund og grund pålægger beregningsaktiviteter på lavere niveau meget mindre abstraktion, og man kan sige, at den er tættere på den grundlæggende computeroperation. Alligevel er resultaterne ubestridelige - smart designet rekursion viser sig at være hurtigere end iteration.
Brug asynkrone call statements så ofte som muligt
Jeg vil gerne bruge det sidste afsnit til en kort påmindelse om en metode til at udføre operationer, som også i høj grad kan påvirke hastigheden af vores program. Som du bør vide, JavaScript er et single-threaded sprog, der udfører alle operationer med en event-loop-mekanisme. Det handler om en cyklus, der kører igen og igen, og alle trin i denne cyklus handler om dedikerede, specificerede handlinger.
For at gøre dette loop hurtigt og lade alle cyklusser vente mindre på deres tur, skal alle elementer være så hurtige som muligt. Undgå at køre lange operationer på hovedtråden - hvis noget tager for lang tid, så prøv at flytte disse beregninger til WebWorker eller opdel dem i dele, der kører asynkront. Det kan gøre nogle operationer langsommere, men øger hele JS' økosystem, herunder IO-operationer, såsom håndtering af musebevægelser eller ventende HTTP-anmodninger.
Sammenfatning
Som nævnt tidligere kan jagten på millisekunder, der kan spares ved at vælge en algoritme, i nogle tilfælde vise sig at være meningsløs. På den anden side kan det være dødbringende for din applikation at negligere sådanne ting i applikationer, der kræver jævn kørsel og hurtige resultater. I nogle tilfælde bør man ud over algoritmens hastighed også stille et andet spørgsmål: Er abstraktionen på det rigtige niveau? Vil den programmør, der læser koden, kunne forstå den uden problemer?
Den eneste måde er at sikre balancen mellem ydeevne, nem implementering og passende abstraktion og være sikker på, at algoritmen fungerer korrekt for både små og store datamængder. Måden at gøre det på er ret enkel - vær smart, overvej de forskellige tilfælde, når du designer algoritmen, og sørg for, at den opfører sig så effektivt som muligt ved gennemsnitlige udførelser. Det er også tilrådeligt at designe tests - vær sikker på, at algoritmen returnerer de relevante oplysninger for forskellige data, uanset hvordan den fungerer. Sørg for de rigtige grænseflader - så både input og output fra metoderne er læsbare, tydelige og afspejler præcis, hvad de gør.
Jeg nævnte tidligere, at jeg vil vende tilbage til pålideligheden af at måle hastigheden af algoritmerne i eksemplerne ovenfor. At måle dem med console.time er ikke særlig pålideligt, men det afspejler bedst standardtilfældet. Under alle omstændigheder præsenterer jeg benchmarks nedenfor - nogle af dem ser lidt anderledes ud end en enkelt udførelse, fordi benchmarks simpelthen gentager en given aktivitet på et bestemt tidspunkt og bruger v8-optimering til løkker.
https://jsben.ch/KhAqb - reduce vs for loop
https://jsben.ch/F4kLY - optimeret reduce vs for loop
https://jsben.ch/MCr6g - Math.max vs for loop
https://jsben.ch/A0CJB - rekursiv fibo vs iterativ fibo