Noen triks for å få fart på JavaScript-applikasjonen
Bartosz Slysz
Software Engineer
I takt med utviklingen av nettleserteknologi har webapplikasjoner begynt å overføre stadig mer logikk til frontend, noe som avlaster serveren og reduserer antallet operasjoner den må utføre. I grunnleggende CRUDs består serverens rolle i autorisering, validering, kommunikasjon med databaser og den nødvendige forretningslogikken. Resten av datalogikken kan enkelt håndteres av koden som er ansvarlig for representasjonen av applikasjonen på UI-siden.
I denne artikkelen vil jeg prøve å vise deg noen eksempler og mønstre som vil bidra til å holde kode effektiv, ryddig og rask.
Før vi går dypere inn i spesifikke eksempler - i denne artikkelen vil jeg bare fokusere på å vise tilfeller som etter min mening kan påvirke hastigheten på applikasjonen på en overraskende måte. Dette betyr imidlertid ikke at bruk av raskere løsninger er det beste valget i alle mulige tilfeller. Tipsene nedenfor bør heller behandles som noe du bør se nærmere på når applikasjonen vår kjører sakte, f.eks. i produkter som krever spillgjengivelse eller mer avanserte grafer på lerretet, videooperasjoner eller aktiviteter som du ønsker å synkronisere i sanntid så snart som mulig.
Først og fremst - Array.prototype-metoder
Vi baserer en stor del av applikasjonslogikken på matriser - mapping, sortering, filtrering, summering av elementer og så videre. På en enkel, gjennomsiktig og naturlig måte bruker vi de innebygde metodene som ganske enkelt lar oss utføre ulike typer beregninger, grupperinger osv. De fungerer på samme måte i hvert tilfelle - som et argument sender vi en funksjon der, i de fleste tilfeller, elementverdien, indeksen og matrisen skyves i sving under hver iterasjon. Den angitte funksjonen utføres for hvert element i matrisen, og resultatet tolkes forskjellig avhengig av metoden. Jeg vil ikke gå nærmere inn på Array.prototype-metodene, da jeg ønsker å fokusere på hvorfor den kjører sakte i mange tilfeller.
Array-metodene er trege fordi de utfører en funksjon for hvert element. En funksjon som kalles fra motorens perspektiv, må forberede et nytt anrop, oppgi riktig scope og en rekke andre avhengigheter, noe som gjør prosessen mye lengre enn å gjenta en bestemt kodeblokk i et bestemt scope. Og dette er sannsynligvis nok bakgrunnskunnskap til at vi kan forstå følgende eksempel:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() })));
console.time('Sum ved å redusere');
const reduceSum = randomArray
.map(({verdi }) => verdi)
.reduce((a, b) => a + b);
console.timeEnd('Sum ved å redusere');
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 etter for-løkke');
console.log(reduceSum === forSum);
})();
Jeg vet at denne testen ikke er like pålitelig som benchmarkene (vi kommer tilbake til dem senere), men den utløser en varsellampe. For et tilfeldig tilfelle på datamaskinen min viser det seg at koden med for-løkken kan være omtrent 50 ganger raskere hvis den sammenlignes med å kartlegge og deretter redusere elementer som oppnår samme effekt! Dette handler om å operere på et merkelig objekt som bare er opprettet for å nå et bestemt mål for beregninger. Så la oss lage noe mer legitimt for å være objektive om Array-metodene:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() })));
console.time('Sum ved å redusere');
const reduceSum = randomArray
.reduce((a, b) => ({verdi: a.verdi + b.verdi })).value
console.timeEnd('Sum ved å redusere');
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 etter for-løkke');
console.log(reduceSum === forSum);
})();
Jeg vet at denne testen ikke er like pålitelig som benchmarkene (vi kommer tilbake til dem senere), men den utløser en varsellampe. For et tilfeldig tilfelle på min datamaskin viser det seg at koden med for-løkken kan være omtrent 50 ganger raskere sammenlignet med å kartlegge og deretter redusere elementer som oppnår samme effekt! Dette er fordi det å få summen i dette spesielle tilfellet ved hjelp av reduce-metoden krever mapping av matrisen for rene verdier som vi ønsker å oppsummere. Så la oss lage noe mer legitimt for å være objektive om Array-metodene:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() })));
console.time('Sum ved å redusere');
const reduceSum = randomArray
.reduce((a, b) => ({verdi: a.verdi + b.verdi })).value
console.timeEnd('Sum ved å redusere');
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 etter for-løkke');
console.log(reduceSum === forSum);
})();
Og det viste seg at vår 50x boost ble redusert til 4x boost. Beklager hvis du føler deg skuffet! For å holde oss objektive til slutt, la oss analysere begge kodene igjen. Først og fremst - uskyldige forskjeller fordoblet fallet i vår teoretiske beregningskompleksitet; i stedet for først å kartlegge og deretter legge sammen rene elementer, opererer vi fortsatt på objekter og et bestemt felt, for til slutt å trekke ut for å få summen vi er interessert i. Problemet oppstår når en annen programmerer tar en titt på koden - da mister sistnevnte, sammenlignet med kodene vist tidligere, sin abstraksjon på et eller annet tidspunkt.
Dette er fordi siden den andre operasjonen som vi opererer på et merkelig objekt, med feltet av interesse for oss og det andre, standardobjektet til den itererte matrisen. Jeg vet ikke hva du synes om det, men fra mitt perspektiv, i det andre kodeeksemplet, er logikken til for-løkken mye tydeligere og mer intensjonell enn denne merkelige reduksjonen. Og selv om det ikke er de mytiske 50 lenger, er det fortsatt 4 ganger raskere når det gjelder beregningstid! Siden hvert millisekund er verdifullt, er valget i dette tilfellet enkelt.
Det mest overraskende eksempelet
Det andre jeg ønsket å sammenligne gjelder Math.max-metoden, eller mer presist, å fylle en million elementer og trekke ut de største og minste. Jeg forberedte koden, metoder for å måle tid også, så fyrer jeg opp koden og får en veldig merkelig feil - stabelstørrelsen er overskredet. Her er koden:
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5);
console.time('Math.max med ES6-spredningsoperator');
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-spredningsoperator');
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 seg at innfødte metoder bruker rekursjon, som i v8 er begrenset av anropsstabler, og antallet er avhengig av miljøet. Dette er noe som overrasket meg mye, men det bærer en konklusjon: den opprinnelige metoden er raskere, så lenge matrisen vår ikke overstiger et visst magisk antall elementer, som i mitt tilfelle viste seg å være 125375. For dette antallet elementer var resultatet for 5 ganger raskere sammenlignet med løkken. Men over det nevnte antall elementer vinner for-løkken definitivt - i motsetning til motstanderen, lar den oss få riktige resultater.
Rekursjon
Konseptet jeg vil nevne i dette avsnittet, er rekursivitet. I forrige eksempel så vi det i Math.max-metoden og argumentfoldingen, der det viste seg at det er umulig å få et resultat for rekursive anrop som overstiger et bestemt tall på grunn av begrensningen på stabelstørrelsen.
Vi skal nå se hvordan rekursivitet ser ut i sammenheng med kode skrevet i JS, og ikke med innebygde metoder. Det kanskje mest klassiske vi kan vise her, er selvfølgelig å finne det n-te leddet i Fibonacci-sekvensen. Så la oss skrive dette!
(() => {
const fiboIterative = (n) => {
la [a, b] = [0, 1];
for (la i = 0; i {
if(n < 2) {
returnerer n;
}
return fiboRecursive(n - 2) + fiboRecursive(n - 1);
};
console.time('Fibonacci-sekvens med for-løkke');
const resultIterative = fiboIterative(30);
console.timeEnd('Fibonacci-sekvens ved for-løkke');
console.time('Fibonacci-sekvens ved rekursjon');
const resultRecursive = fiboRecursive(30);
console.timeEnd('Fibonacci-sekvens ved rekursjon');
console.log(resultRecursive === resultIterative);
})();
Ok, i dette spesielle tilfellet med å beregne det 30. elementet i sekvensen på datamaskinen min, får vi resultatet på omtrent 200 ganger kortere tid med den iterative algoritmen.
Det er imidlertid én ting som kan utbedres i den rekursive algoritmen - det viser seg at den fungerer mye mer effektivt når vi bruker en taktikk som kalles halerekursjon. Det betyr at vi sender resultatet vi fikk i forrige iterasjon, som argumenter for dypere anrop. Dette gjør at vi kan redusere antall anrop som kreves, og dermed får vi et raskere resultat. La oss korrigere koden vår i henhold til dette!
(() => {
const fiboIterative = (n) => {
la [a, b] = [0, 1];
for (la i = 0; i {
if(n === 0) {
returnere første;
}
return fiboTailRecursive(n - 1, second, first + second);
};
console.time('Fibonacci-sekvens med for-løkke');
const resultIterative = fiboIterative(30);
console.timeEnd('Fibonacci-sekvens ved for-løkke');
console.time('Fibonacci-sekvens ved halerekursjon');
const resultRecursive = fiboTailRecursive(30);
console.timeEnd('Fibonacci-sekvens ved halerekursjon');
console.log(resultRecursive === resultIterative);
})();
Her skjedde det noe jeg ikke helt hadde forventet - resultatet av halerekursjonsalgoritmen var i stand til å levere resultatet (beregning av det 30. elementet i en sekvens) nesten dobbelt så raskt som den iterative algoritmen i noen tilfeller. Jeg er ikke helt sikker på om dette skyldes optimalisering for halerekursjon fra v8s side eller mangelen på optimalisering for for-løkken for dette spesifikke antallet iterasjoner, men resultatet er utvetydig - halerekursjonen vinner.
Dette er merkelig fordi for-løkken i bunn og grunn pålegger mye mindre abstraksjon på beregningsaktiviteter på lavere nivå, og du kan si at den ligger nærmere den grunnleggende datamaskinoperasjonen. Likevel er resultatene ubestridelige - en smart utformet rekursiv viser seg å være raskere enn iterasjon.
Bruk asynkrone anropssetninger så ofte du kan
Jeg vil gjerne vie det siste avsnittet til en kort påminnelse om en metode for å utføre operasjoner som også i stor grad kan påvirke hastigheten på applikasjonen vår. Som du sikkert vet, JavaScript er et enkelttrådet språk som holder alle operasjoner med event-loop-mekanisme. Det handler om en syklus som går om og om igjen, og alle trinnene i denne syklusen handler om dedikerte spesifiserte handlinger.
For å gjøre denne løkken rask og la alle syklusene vente mindre på sin tur, bør alle elementene være så raske som mulig. Unngå å kjøre lange operasjoner på hovedtråden - hvis noe tar for lang tid, kan du prøve å flytte disse beregningene til WebWorker eller dele dem opp i deler som kjøres asynkront. Det kan gjøre noen operasjoner tregere, men det vil øke hele økosystemet til JS, inkludert IO-operasjoner, for eksempel håndtering av musebevegelser eller ventende HTTP-forespørsler.
Sammendrag
Som nevnt tidligere kan det i noen tilfeller vise seg å være meningsløst å jakte på millisekunder som kan spares ved å velge en algoritme. På den annen side kan det være livsfarlig for applikasjonen din å neglisjere slike ting i applikasjoner som krever jevn drift og raske resultater. I noen tilfeller bør man i tillegg til algoritmens hastighet også stille seg et annet spørsmål - er abstraksjonen på riktig nivå? Vil programmereren som leser koden, kunne forstå den uten problemer?
Den eneste måten å sikre balansen mellom ytelse, enkel implementering og passende abstraksjon på, er å være sikker på at algoritmen fungerer som den skal for både små og store datamengder. Måten å gjøre dette på er ganske enkel - vær smart, vurder de ulike tilfellene når du utformer algoritmen, og sørg for at den oppfører seg så effektivt som mulig for gjennomsnittlige kjøringer. Det anbefales også å utforme tester - sørg for at algoritmen returnerer riktig informasjon for ulike data, uansett hvordan den fungerer. Sørg for de riktige grensesnittene - slik at både inndata og utdata fra metodene er lesbare, tydelige og gjenspeiler nøyaktig hva de gjør.
Jeg nevnte tidligere at jeg vil komme tilbake til påliteligheten av å måle hastigheten til algoritmene i eksemplene ovenfor. Å måle dem med console.time er ikke veldig pålitelig, men det gjenspeiler standard brukstilfelle best. Uansett, jeg presenterer benchmarkene nedenfor - noen av dem ser litt annerledes ut enn en enkelt kjøring på grunn av det faktum at benchmarkene ganske enkelt gjentar en gitt aktivitet på et bestemt tidspunkt og bruker v8-optimalisering for løkker.
https://jsben.ch/KhAqb - reduce vs for loop
https://jsben.ch/F4kLY - optimalisert reduce vs for-løkke
https://jsben.ch/MCr6g - Math.max vs for loop
https://jsben.ch/A0CJB - rekursiv fibo vs iterativ fibo