Några knep för att påskynda din JavaScript-ansökan
Bartosz Slysz
Software Engineer
I takt med att webbläsartekniken har utvecklats har webbapplikationer börjat överföra mer och mer logik till frontend, vilket avlastar servern och minskar antalet operationer som den måste utföra. I grundläggande CRUD:er handlar serverns roll om auktorisering, validering, kommunikation med databaser och den affärslogik som krävs. Resten av datalogiken kan, som det visar sig, enkelt hanteras av den kod som ansvarar för representationen av applikationen på UI-sidan.
I den här artikeln ska jag försöka visa dig några exempel och mönster som hjälper dig att hålla vår kod effektivt, snyggt och snabbt.
Innan vi går djupare in på specifika exempel - i den här artikeln skulle jag bara vilja fokusera på att visa fall som enligt min mening kan påverka applikationens hastighet på ett överraskande sätt. Detta betyder dock inte att användningen av snabbare lösningar är det bästa valet i alla möjliga fall. Tipsen nedan ska snarare ses som något man bör titta närmare på när vår applikation går långsamt, t.ex. i produkter som kräver spelrendering eller mer avancerade grafer på canvas, videooperationer eller aktiviteter som man vill synkronisera i realtid så snart som möjligt.
Först och främst - Array.prototype-metoder
Vi baserar en stor del av applikationslogiken på arrayer - deras mappning, sortering, filtrering, summering av element och så vidare. På ett enkelt, transparent och naturligt sätt använder vi deras inbyggda metoder som helt enkelt gör att vi kan utföra olika typer av beräkningar, grupperingar etc. De fungerar på samma sätt i varje fall - som ett argument skickar vi en funktion där, i de flesta fall, elementvärdet, indexet och matrisen skjuts i tur och ordning under varje iteration. Den angivna funktionen utförs för varje element i matrisen och resultatet tolkas olika beroende på metod. Jag kommer inte att utveckla Array.prototype-metoderna eftersom jag vill fokusera på varför den går långsamt i ett stort antal fall.
Array-metoderna är långsamma eftersom de utför en funktion för varje element. En funktion som anropas från motorns perspektiv måste förbereda ett nytt anrop, tillhandahålla lämpligt scope och en hel del andra beroenden, vilket gör processen mycket längre än att upprepa ett specifikt kodblock i ett specifikt scope. Och detta är förmodligen tillräckligt med bakgrundskunskap för att vi ska kunna förstå följande exempel:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ värde: Math.random() })));
console.time('Summa genom att reducera');
const reduceraSumma = slumpmässigArray
.map(({värde }) => värde)
.reduce((a, b) => a + b);
console.timeEnd('Summera genom att reducera');
console.time('Summera med for loop');
let forSum = randomArray[0].value;
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Summera med för loop');
console.log(reduceSum === forSum);
})();
Jag vet att det här testet inte är lika tillförlitligt som benchmarks (vi återkommer till dem senare), men det utlöser en varningslampa. För ett slumpmässigt fall på min dator visar det sig att koden med for-slingan kan vara cirka 50 gånger snabbare om man jämför med att kartlägga och sedan reducera element som uppnår samma effekt! Det här handlar om att operera på något konstigt objekt som skapats endast för att nå ett specifikt mål för beräkningar. Så låt oss skapa något mer legitimt för att vara objektiva om Array-metoderna:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ värde: Math.random() })));
console.time('Summa genom att reducera');
const reduceraSumma = randomArray
.reduce((a, b) => ({värde: a.värde + b.värde })).value
console.timeEnd('Summera genom att reducera');
console.time('Summera genom for loop');
let forSum = randomArray[0].value;
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Summera med för loop');
console.log(reduceSum === forSum);
})();
Jag vet att det här testet inte är lika tillförlitligt som benchmarks (vi återkommer till dem senare), men det utlöser en varningslampa. För ett slumpmässigt fall på min dator visar det sig att koden med for-slingan kan vara cirka 50 gånger snabbare jämfört med att mappa och sedan reducera element som uppnår samma effekt! Detta beror på att för att få summan i detta specifika fall med hjälp av reduceringsmetoden krävs mappning av matrisen för rena värden som vi vill sammanfatta. Så låt oss skapa något mer legitimt för att vara objektiva om Array-metoderna:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ värde: Math.random() })));
console.time('Summa genom att reducera');
const reduceraSumma = randomArray
.reduce((a, b) => ({värde: a.värde + b.värde })).value
console.timeEnd('Summera genom att reducera');
console.time('Summera genom for loop');
let forSum = randomArray[0].value;
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Summera med för loop');
console.log(reduceSum === forSum);
})();
Och som det visar sig minskade vår 50x boost till 4x boost. Ber om ursäkt om du känner dig besviken! För att hålla oss objektiva till slutet, låt oss analysera båda koderna igen. Först och främst - oskyldiga skillnader fördubblade minskningen av vår teoretiska beräkningskomplexitet; istället för att först mappa och sedan addera rena element, arbetar vi fortfarande med objekt och ett specifikt fält, för att slutligen dra ut för att få den summa vi är intresserade av. Problemet uppstår när en annan programmerare tar en titt på koden - då, jämfört med de koder som visats tidigare, förlorar den senare sin abstraktion vid någon punkt.
Detta beror på att sedan den andra operationen att vi arbetar på ett konstigt objekt, med fältet av intresse för oss och det andra, standardobjektet för den itererade matrisen. Jag vet inte vad du tycker om det, men ur mitt perspektiv, i det andra kodexemplet, är logiken i for-slingan mycket tydligare och mer avsiktlig än den här konstiga minskningen. Och även om det inte är de mytiska 50 längre, är det fortfarande 4 gånger snabbare när det gäller beräkningstid! Eftersom varje millisekund är värdefull är valet i det här fallet enkelt.
Det mest överraskande exemplet
Den andra saken jag ville jämföra gäller Math.max-metoden eller, mer exakt, att fylla en miljon element och sedan extrahera de största och minsta. Jag har förberett koden, metoder för att mäta tid också, sedan startar jag upp koden och får ett mycket konstigt fel - stackstorleken överskrids. Här är koden:
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5);
console.time('Math.max med ES6 spridningsoperator');
const maxBySpread = Math.max(...randomValues);
console.timeEnd('Math.max med ES6 spridningsoperator');
console.time('Math.max med for-loop');
let maxByFor = randomValues[0];
for (let index = 1; index maxByFor) {
maxByFor = slumpmässiga värden[index];
}
}
console.timeEnd('Math.max med for-loop');
console.log(maxByFor === maxBySpread);
})();
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5);
console.time('Math.max med ES6 spridningsoperator');
const maxBySpread = Math.max(...randomValues);
console.timeEnd('Math.max med ES6 spridningsoperator');
console.time('Math.max med for-loop');
let maxByFor = randomValues[0];
for (let index = 1; index maxByFor) {
maxByFor = slumpmässiga värden[index];
}
}
console.timeEnd('Math.max med for-loop');
console.log(maxByFor === maxBySpread);
})();
Det visar sig att inbyggda metoder använder rekursion, som i v8 begränsas av samtalsstackar och dess antal är beroende av miljön. Detta är något som förvånade mig mycket, men det bär en slutsats: den ursprungliga metoden är snabbare, så länge vår matris inte överstiger ett visst magiskt antal element, vilket i mitt fall visade sig vara 125375. För detta antal element var resultatet för 5x snabbare jämfört med slingan. Men över det nämnda antalet element vinner for-slingan definitivt - till skillnad från motståndaren tillåter det oss att få korrekta resultat.
Rekursion
Det koncept jag vill nämna i detta stycke är rekursion. I det föregående exemplet såg vi det i metoden Math.max och argumentvikning, där det visade sig att det är omöjligt att få ett resultat för rekursiva anrop som överstiger ett visst antal på grund av begränsningen av stackstorleken.
Vi kommer nu att se hur rekursion ser ut i samband med kod skriven i JS, och inte med inbyggda metoder.Det kanske mest klassiska vi kan visa här är naturligtvis att hitta den nionde termen i Fibonacci-sekvensen. Så, låt oss skriva detta!
(() => {
const fiboIterative = (n) => {
låt [a, b] = [0, 1];
for (let i = 0; i {
if(n < 2) {
returnerar n;
}
return fiboRecursive(n - 2) + fiboRecursive(n - 1);
};
console.time('Fibonacci-sekvens genom slinga');
const resultIterative = fiboIterative(30);
console.timeEnd('Fibonacci-sekvens med för slinga');
console.time('Fibonacci-sekvens genom rekursion');
const resultRecursive = fiboRecursive(30);
console.timeEnd('Fibonacci-sekvens genom rekursion');
console.log(resultRecursive === resultIterative);
})();
Okej, i det här specifika fallet, när jag beräknar det 30:e objektet i sekvensen på min dator, får vi resultatet på ungefär 200 gånger kortare tid med den iterativa algoritmen.
Det finns dock en sak som kan rättas till i den rekursiva algoritmen - det visar sig att den fungerar mycket mer effektivt när vi använder en taktik som kallas tail recursion. Det innebär att vi skickar det resultat som vi fick i den föregående iterationen som argument för djupare anrop. Detta gör att vi kan minska antalet anrop som krävs och därmed påskynda resultatet. Låt oss korrigera vår kod i enlighet med detta!
(() => {
const fiboIterative = (n) => {
låt [a, b] = [0, 1];
for (let i = 0; i {
if(n === 0) {
returnera första;
}
return fiboTailRecursive(n - 1, andra, första + andra);
};
console.time('Fibonacci-sekvens genom slinga');
const resultIterative = fiboIterative(30);
console.timeEnd('Fibonacci-sekvens med för slinga');
console.time('Fibonacci-sekvens genom svansrekursion');
const resultRecursive = fiboTailRecursive(30);
console.timeEnd('Fibonacci-sekvens genom svansrekursion');
console.log(resultRecursive === resultIterative);
})();
Något som jag inte riktigt förväntade mig hände här - resultatet av svansrekursionsalgoritmen kunde leverera resultatet (beräkna det 30:e elementet i en sekvens) nästan dubbelt så snabbt som den iterativa algoritmen i vissa fall. Jag är inte helt säker på om detta beror på optimering för tail recursion från v8:s sida eller bristen på optimering för for-loopen för detta specifika antal iterationer, men resultatet är entydigt - tail recursion vinner.
Detta är konstigt eftersom for-slingan i huvudsak innebär mycket mindre abstraktion på beräkningsaktiviteter på lägre nivå, och man kan säga att den ligger närmare den grundläggande datoroperationen. Ändå är resultaten obestridliga - smart utformad rekursion visar sig vara snabbare än iteration.
Använd asynkrona anropssatser så ofta du kan
Jag skulle vilja ägna det sista stycket åt en kort påminnelse om en metod för att utföra operationer som också i hög grad kan påverka hastigheten på vår applikation. Som du borde veta, JavaScript är ett enkeltrådat språk som håller alla operationer med event-loop-mekanism. Det handlar om en cykel som körs om och om igen och alla steg i denna cykel handlar om dedikerade specificerade åtgärder.
För att göra denna loop snabb och låta alla cykler vänta mindre på sin tur bör alla element vara så snabba som möjligt. Undvik att köra långa operationer på huvudtråden - om något tar för lång tid kan du försöka flytta dessa beräkningar till WebWorker eller dela upp dem i delar som körs asynkront. Det kan sakta ner vissa operationer men ökar hela JS ekosystem, inklusive IO-operationer, till exempel hantering av musrörelser eller väntande HTTP-begäran.
Sammanfattning
Sammanfattningsvis kan det, som tidigare nämnts, i vissa fall visa sig vara meningslöst att jaga millisekunder som kan sparas genom att välja en algoritm. Å andra sidan kan det vara dödligt för din applikation att försumma sådana saker i applikationer som kräver smidig drift och snabba resultat. I vissa fall bör man, förutom algoritmens snabbhet, ställa sig ytterligare en fråga - används abstraktionen på rätt nivå? Kommer den programmerare som läser koden att kunna förstå den utan problem?
Det enda sättet är att säkerställa balansen mellan prestanda, enkel implementering och lämplig abstraktion, och vara säker på att algoritmen fungerar korrekt för både små och stora datamängder. Sättet att göra detta är ganska enkelt - var smart, överväg de olika fallen när du utformar algoritmen och ordna den så att den beter sig så effektivt som möjligt för genomsnittliga körningar. Det är också tillrådligt att utforma tester - se till att algoritmen returnerar lämplig information för olika data, oavsett hur den fungerar. Ta hand om rätt gränssnitt - så att både inmatning och utmatning av metoder är läsbara, tydliga och återspeglar exakt vad de gör.
Jag nämnde tidigare att jag kommer att återkomma till tillförlitligheten i att mäta hastigheten på algoritmerna i exemplen ovan. Att mäta dem med console.time är inte särskilt tillförlitligt, men det återspeglar standardanvändningsfallet bäst. Hur som helst, jag presenterar riktmärkena nedan - några av dem ser lite annorlunda ut än en enda körning på grund av att riktmärken helt enkelt upprepar en viss aktivitet vid en viss tidpunkt och använder v8-optimering för loopar.
https://jsben.ch/KhAqb - reducera vs för loop
https://jsben.ch/F4kLY - optimerad reducering vs for loop
https://jsben.ch/MCr6g - Math.max vs for loop
https://jsben.ch/A0CJB - rekursiv fibo vs iterativ fibo