Keletas gudrybių, kaip pagreitinti JavaScript programą
Bartosz Slysz
Software Engineer
Tobulėjant naršyklių technologijoms, žiniatinklio programose vis daugiau logikos pradėta perkelti į priekinę dalį, taip palengvinant serverio darbą ir sumažinant operacijų, kurias jis turi atlikti, skaičių. Pagrindinėse CRUD programose serverio vaidmuo apsiriboja autorizavimu, patvirtinimu, ryšiu su duomenų bazėmis ir reikiama verslo logika. Likusią duomenų logikos dalį, kaip paaiškėja, lengvai gali atlikti kodas, atsakingas už programos atvaizdavimą vartotojo sąsajos pusėje.
Šiame straipsnyje pabandysiu jums parodyti keletą pavyzdžių ir modelių, kurie padės išlaikyti mūsų kodas efektyviai, tvarkingai ir greitai.
Prieš gilindamasis į konkrečius pavyzdžius - šiame straipsnyje norėčiau sutelkti dėmesį tik į atvejus, kurie, mano nuomone, gali netikėtai paveikti programos greitį. Tačiau tai nereiškia, kad visais įmanomais atvejais greitesnių sprendimų naudojimas yra geriausias pasirinkimas. Toliau pateiktus patarimus veikiau reikėtų vertinti kaip tai, į ką turėtumėte atkreipti dėmesį, kai mūsų programa veikia lėtai, pavyzdžiui, produktuose, kuriuose reikia žaidimų atvaizdavimo arba sudėtingesnių grafikų drobėje, vaizdo operacijų arba veiklos, kurią norime kuo greičiau sinchronizuoti realiuoju laiku.
Pirmiausia - Array.prototype metodai
Didelę dalį taikomosios logikos grindžiame masyvais - jų atvaizdavimu, rūšiavimu, filtravimu, elementų sumavimu ir pan. Lengvai, skaidriai ir natūraliai naudojame jų integruotus metodus, kurie tiesiog leidžia mus atlikti įvairius skaičiavimus, grupavimą ir pan. Kiekvienu atveju jie veikia panašiai - kaip argumentą perduodame funkciją, kurioje daugeliu atvejų per kiekvieną iteraciją paeiliui stumiama elemento reikšmė, indeksas ir masyvas. Nurodyta funkcija atliekama kiekvienam masyvo elementui, o rezultatas interpretuojamas skirtingai, priklausomai nuo metodo. Plačiau neaptarinėsiu Array.prototype metodų, nes noriu sutelkti dėmesį į tai, kodėl daugeliu atvejų jis veikia lėtai.
Masyvo metodai yra lėti, nes jie atlieka funkciją kiekvienam elementui. Variklio požiūriu iškviesta funkcija turi paruošti naują iškvietimą, suteikti atitinkamą sritį ir daugybę kitų priklausomybių, todėl šis procesas yra daug ilgesnis nei konkretaus kodo bloko pakartojimas konkrečioje srityje. Ir tai tikriausiai yra pakankamos pagrindinės žinios, kad galėtume suprasti toliau pateiktą pavyzdį:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() }));
console.time('Suma pagal sumažinti');
const reduceSum = randomArray
.map(({ value }) => value)
.reduce((a, b) => a + b);
console.timeEnd('Suma pagal reduce');
console.time('Suma pagal for ciklą');
leiskite forSum = randomArray[0].value;
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Suma pagal for ciklą');
console.log(reduceSum === forSum);
})();
Žinau, kad šis testas nėra toks patikimas kaip lyginamieji standartai (prie jų grįšime vėliau), tačiau jis įjungia įspėjamąją lemputę. Atsitiktiniu atveju mano kompiuteryje paaiškėjo, kad kodas su for ciklu gali būti maždaug 50 kartų greitesnis, jei palyginsime jį su elementų atvaizdavimu, o tada sumažinimu, kuriais pasiekiamas tas pats efektas! Kalbama apie veikimą su tam tikru keistu objektu, sukurtu tik tam, kad būtų pasiektas konkretus skaičiavimų tikslas. Taigi, sukurkime ką nors legalesnio, kad būtų galima objektyviai vertinti masyvų metodus:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() }));
console.time('Suma pagal sumažinti');
const reduceSum = randomArray
.reduce((a, b) => ({ vertė: a.value + b.value })).value
console.timeEnd('Suma pagal reduce');
console.time('Suma pagal for ciklą');
leiskite forSum = randomArray[0].value;
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Suma pagal for ciklą');
console.log(reduceSum === forSum);
})();
Žinau, kad šis testas nėra toks patikimas kaip lyginamieji standartai (prie jų grįšime vėliau), tačiau jis įjungia įspėjamąją lemputę. Atsitiktiniu atveju mano kompiuteryje paaiškėjo, kad kodas su for ciklu gali būti maždaug 50 kartų greitesnis, jei palyginsime jį su elementų atvaizdavimu, o tada sumažinimu, kuriais pasiekiamas tas pats efektas! Taip yra todėl, kad šiuo konkrečiu atveju sumai gauti naudojant redukavimo metodą reikia atvaizduoti masyvą grynoms reikšmėms, kurias norime apibendrinti. Taigi, sukurkime ką nors įteisinamesnio, kad būtų objektyviau apie masyvų metodus:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() }));
console.time('Suma pagal sumažinti');
const reduceSum = randomArray
.reduce((a, b) => ({ vertė: a.value + b.value })).value
console.timeEnd('Suma pagal reduce');
console.time('Suma pagal for ciklą');
leiskite forSum = randomArray[0].value;
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Suma pagal for ciklą');
console.log(reduceSum === forSum);
})();
Pasirodo, mūsų 50 kartų padidinimas sumažėjo iki 4 kartų. Atsiprašome, jei jaučiatės nusivylę! Kad išliktume objektyvūs iki galo, dar kartą išanalizuokime abu kodus. Pirmiausia - nekaltai atrodantys skirtumai padvigubino mūsų teorinio skaičiavimo sudėtingumo sumažėjimą; užuot iš pradžių atvaizdavę, o paskui sudėję grynus elementus, mes vis dar operuojame objektais ir konkrečiu lauku, kad galiausiai ištrauktume ir gautume mus dominančią sumą. Problema iškyla, kai į kodą pažvelgia kitas programuotojas - tada, palyginti su anksčiau parodytais kodais, pastarasis tam tikru momentu praranda abstraktumą.
Taip yra todėl, kad nuo antrosios operacijos, kad mes veikiame ant svetimo objekto, su domina mus laukas ir antrasis, standartinis objektas iteruoto masyvo. Nežinau, ką apie tai manote jūs, bet, mano požiūriu, antrajame kodo pavyzdyje for ciklo logika yra daug aiškesnė ir tikslingesnė nei šis keistai atrodantis reduktorius. Ir vis dėlto, nors tai nebėra mitinis 50, vis tiek 4 kartus greičiau, kalbant apie skaičiavimo laiką! Kadangi kiekviena milisekundė yra vertinga, pasirinkimas šiuo atveju paprastas.
Labiausiai stebinantis pavyzdys
Antrasis dalykas, kurį norėjau palyginti, yra susijęs su Math.max metodu arba, tiksliau, su milijono elementų įdėjimu ir didžiausių bei mažiausių elementų išskyrimu. Paruošiau kodą, laiko matavimo metodus taip pat, tada paleidžiu kodą ir gaunu labai keistą klaidą - viršijamas kamino dydis. Štai kodas:
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5);
console.time('Math.max su ES6 plitimo operatoriumi');
const maxBySpread = Math.max(...randomValues);
console.timeEnd('Math.max su ES6 sklaidos operatoriumi');
console.time('Math.max su for ciklu');
leiskite maxByFor = randomValues[0];
for (let index = 1; index maxByFor) {
maxByFor = randomValues[index];
}
}
console.timeEnd('Math.max su for ciklu');
console.log(maxByFor === maxBySpread);
})();
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5);
console.time('Math.max su ES6 plitimo operatoriumi');
const maxBySpread = Math.max(...randomValues);
console.timeEnd('Math.max su ES6 sklaidos operatoriumi');
console.time('Math.max su for ciklu');
leiskite maxByFor = randomValues[0];
for (let index = 1; index maxByFor) {
maxByFor = randomValues[index];
}
}
console.timeEnd('Math.max su for ciklu');
console.log(maxByFor === maxBySpread);
})();
Pasirodo, kad vietiniai metodai naudoja rekursiją, kurią v8 versijoje riboja iškvietimų kaminai, o jų skaičius priklauso nuo aplinkos. Tai mane labai nustebino, bet iš to peršasi išvada: gimtasis metodas yra greitesnis, jei mūsų masyvas neviršija tam tikro magiško elementų skaičiaus, kuris mano atveju pasirodė esąs 125375. Esant tokiam elementų skaičiui, rezultatas for buvo 5 kartus greitesnis, jei lygintume su ciklu. Tačiau virš minėto elementų skaičiaus ciklas for neabejotinai laimi - priešingai nei priešininkas, jis leidžia mums gauti teisingus rezultatus.
Rekursija
Šioje pastraipoje noriu paminėti rekursijos sąvoką. Ankstesniame pavyzdyje ją matėme metodo Math.max ir argumentų sulankstyme, kur paaiškėjo, kad dėl kamino dydžio apribojimo neįmanoma gauti rezultato rekursyviniams iškvietimams, viršijantiems tam tikrą skaičių.
Dabar pamatysime, kaip rekursija atrodo kodo, parašyto JS, o ne su integruotais metodais.Bene klasikinis dalykas, kurį galime parodyti, yra Fibonačio sekos n-ojo nario radimas. Taigi, parašykime tai!
(() => {
const fiboIterative = (n) => {
tegul [a, b] = [0, 1];
for (let i = 0; i {
if(n < 2) {
return n;
}
return fiboRecursive(n - 2) + fiboRecursive(n - 1);
};
console.time('Fibonačio seka pagal for ciklą');
const resultIterative = fiboIterative(30);
console.timeEnd('Fibonačio seka pagal for ciklą');
console.time('Fibonačio seka pagal rekursiją');
const resultRecursive = fiboRecursive(30);
console.timeEnd('Fibonačio seka pagal rekursiją');
console.log(resultRecursive === resultIterative);
})();
Šiuo konkrečiu atveju, kai mano kompiuteryje apskaičiuojamas 30-asis sekos elementas, taikant iteracinį algoritmą rezultatą gauname per maždaug 200 kartų trumpesnį laiką.
Tačiau rekursyviniame algoritme galima ištaisyti vieną dalyką - pasirodo, jis veikia daug efektyviau, kai naudojame taktiką, vadinamą uodegos rekursija. Tai reiškia, kad ankstesnės iteracijos metu gautą rezultatą perduodame kaip argumentus gilesniems iškvietimams. Tai leidžia sumažinti reikiamų iškvietimų skaičių ir dėl to pagreitina rezultato gavimą. Atitinkamai pataisykime savo kodą!
(() => {
const fiboIterative = (n) => {
tegul [a, b] = [0, 1];
for (let i = 0; i {
if(n === 0) {
return first;
}
return fiboTailRecursive(n - 1, second, first + second);
};
console.time('Fibonačio seka pagal for ciklą');
const resultIterative = fiboIterative(30);
console.timeEnd('Fibonačio seka pagal for ciklą');
console.time('Fibonačio seka pagal uodegos rekursiją');
const resultRecursive = fiboTailRecursive(30);
console.timeEnd('Fibonačio seka pagal uodegos rekursiją');
console.log(resultRecursive === resultIterative);
})();
Čia nutiko tai, ko nesitikėjau - kai kuriais atvejais uodegos rekursijos algoritmo rezultatas (apskaičiuojant 30-ąjį sekos elementą) buvo beveik dvigubai greitesnis nei iteracinis algoritmas. Nesu visiškai tikras, ar taip nutiko dėl to, kad v8 optimizavo uodegos rekursiją, ar dėl to, kad for ciklas nebuvo optimizuotas šiam konkrečiam iteracijų skaičiui, tačiau rezultatas vienareikšmis - uodegos rekursija laimi.
Tai keista, nes iš esmės for ciklas daug mažiau abstrahuoja žemesnio lygmens skaičiavimo veiksmus ir, galima sakyti, yra artimesnis pagrindiniam kompiuterio veikimui. Vis dėlto rezultatai neginčijami - gudriai suprojektuota rekursija pasirodo esanti greitesnė už iteraciją.
Kuo dažniau naudokite asinchroninius iškvietimo sakinius
Paskutinę pastraipą norėčiau skirti trumpam priminimui apie operacijų atlikimo būdą, kuris taip pat gali turėti didelės įtakos mūsų programos greičiui. Kaip turėtumėte žinoti, JavaScript yra vienos gijos kalba, kurioje visos operacijos atliekamos naudojant įvykių ciklo mechanizmą. Viskas susiję su ciklu, kuris vyksta vis iš naujo ir iš naujo, o visi šio ciklo veiksmai yra susiję su specialiais nurodytais veiksmais.
Kad ši kilpa būtų greita ir visi ciklai mažiau lauktų savo eilės, visi elementai turėtų būti kuo greitesni. Venkite vykdyti ilgas operacijas pagrindiniame sraute - jei kas nors užtrunka per ilgai, pabandykite šiuos skaičiavimus perkelti į "WebWorker" arba padalyti į asinchroniškai vykdomas dalis. Tai gali sulėtinti kai kurias operacijas, tačiau padidinti visos JS ekosistemos, įskaitant IO operacijas, pavyzdžiui, pelės judėjimo ar laukiančios HTTP užklausos tvarkymą, našumą.
Santrauka
Apibendrinant, kaip minėta anksčiau, tam tikrais atvejais gali pasirodyti, kad lenktyniavimas dėl milisekundžių, kurias galima sutaupyti pasirenkant algoritmą, yra beprasmis. Kita vertus, tokių dalykų nepaisymas programose, kuriose reikia sklandaus veikimo ir greitų rezultatų, gali būti pražūtingas jūsų programai. Kai kuriais atvejais, be algoritmo greičio, reikėtų užduoti dar vieną papildomą klausimą - ar abstrakcija veikia tinkamu lygmeniu? Ar programuotojas, skaitantis kodą, galės jį suprasti be problemų?
Vienintelis būdas - užtikrinti pusiausvyrą tarp našumo, įgyvendinimo paprastumo ir tinkamos abstrakcijos bei būti tikriems, kad algoritmas teisingai veikia ir mažų, ir didelių kiekių atveju. duomenys. Tai padaryti gana paprasta - būkite protingi, projektuodami algoritmą atsižvelkite į skirtingus atvejus ir pasirūpinkite, kad jis kuo efektyviau veiktų vidutinio vykdymo metu. Be to, patartina suprojektuoti testus - įsitikinkite, kad algoritmas grąžina tinkamą informaciją skirtingiems duomenims, kad ir kaip jis veiktų. Pasirūpinkite tinkamomis sąsajomis - kad tiek metodų įvestis, tiek išvestis būtų skaitomos, aiškios ir tiksliai atspindėtų, ką jos daro.
Anksčiau minėjau, kad grįšiu prie algoritmų greičio matavimo patikimumo pirmiau pateiktuose pavyzdžiuose. Matuoti juos naudojant console.time nėra labai patikima, tačiau tai geriausiai atspindi standartinį naudojimo atvejį. Šiaip ar taip, toliau pateikiu lyginamuosius testus - kai kurie iš jų atrodo šiek tiek kitaip nei vienkartinis vykdymas dėl to, kad lyginamieji testai tiesiog kartoja tam tikrą veiklą tam tikru laiku ir ciklams naudoja v8 optimizavimą.
https://jsben.ch/KhAqb - sumažinti vs for ciklas
https://jsben.ch/F4kLY - optimizuotas mažinimas ir for ciklas
https://jsben.ch/MCr6g - Math.max vs for loop
https://jsben.ch/A0CJB - rekursinis fibo ir iteracinis fibo