Veebirakenduste arenguga on veebirakendused hakanud üha enam loogikat üle kandma front-end'ile, vabastades seega serveri ja vähendades tema poolt sooritatavate operatsioonide arvu. Põhiliste CRUD-rakenduste puhul taandub serveri roll autoriseerimisele, valideerimisele, andmebaasidega suhtlemisele ja vajalikule äriloogikale. Ülejäänud andmeloogika, nagu selgub, saab hõlpsasti lahendada rakenduse esituse eest vastutava koodi abil kasutajaliidese poolel.
Selles artiklis püüan näidata teile mõned näited ja mustrid, mis aitavad hoida meie kood tõhus, puhas ja kiire.
Enne kui läheme sügavamalt konkreetsete näidete juurde - selles artiklis tahaksin keskenduda ainult selliste juhtumite näitamisele, mis minu arvates võivad rakenduse kiirust üllatavalt mõjutada. See ei tähenda siiski, et kiiremate lahenduste kasutamine on igal võimalikul juhul parim valik. Allpool toodud näpunäiteid tuleks pigem käsitleda kui midagi, mida peaksite vaatama, kui meie rakendus töötab aeglaselt, näiteks toodete puhul, mis nõuavad mängude renderdamist või keerukamaid graafikuid lõuendil, videooperatsioone või tegevusi, mida soovite võimalikult kiiresti reaalajas sünkroonida.
Kõigepealt - Array.prototype meetodid
Suur osa rakendusloogikast põhineb massiividel - nende kaardistamine, sorteerimine, filtreerimine, elementide summeerimine jne. Kasutame lihtsal, läbipaistval ja loomulikul viisil nende sisseehitatud meetodeid, mis lihtsalt võimaldavad meil teha mitmesuguseid arvutusi, grupeeringuid jne. Nad töötavad igas instantsis sarnaselt - argumendina anname üle funktsiooni, kus enamasti lükatakse iga iteratsiooni ajal kordamööda elemendi väärtus, indeks ja massiivi väärtus. Määratud funktsioon täidetakse iga elemendi kohta massiivis ja tulemust tõlgendatakse erinevalt sõltuvalt meetodist. Ma ei hakka Array.prototype meetodeid lähemalt käsitlema, kuna tahan keskenduda sellele, miks see paljudel juhtudel aeglaselt töötab.
Array meetodid on aeglased, sest nad täidavad funktsiooni iga elemendi jaoks. Mootori poolt kutsutud funktsioon peab valmistama ette uue kõne, andma sobiva ulatuse ja palju muid sõltuvusi, mis teeb protsessi palju pikemaks kui konkreetse koodiploki kordamine konkreetses ulatuses. Ja see on ilmselt piisav taustteadmine, et mõista järgmist näidet:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() })));
console.time('Sum by reduce');
const reduceSum = randomArray
.map(({ value }) => value)
.reduce((a, b) => a + b);
console.timeEnd('Sum by reduce');
console.time('Sum by 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);
})();
Ma tean, et see test ei ole nii usaldusväärne kui võrdlusnäitajad (nende juurde tuleme hiljem tagasi), kuid see vallandab hoiatustule. Juhuslikul juhul minu arvutis selgub, et for-silmusega kood võib olla umbes 50 korda kiirem, kui võrrelda kaardistamise ja seejärel sama efekti saavutavate elementide vähendamisega! Tegemist on mingi kummalise objektiga, mis on loodud ainult arvutuste konkreetse eesmärgi saavutamiseks. Nii et loome midagi legaalsemat, et olla objektiivne Array meetodite kohta:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() })));
console.time('Sum by reduce');
const reduceSum = randomArray
.reduce((a, b) => ({ value: a.value + b.value })).value
console.timeEnd('Sum by reduce');
console.time('Sum by 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);
})();
Ma tean, et see test ei ole nii usaldusväärne kui võrdlusnäitajad (nende juurde tuleme hiljem tagasi), kuid see vallandab hoiatustule. Juhuslikul juhul minu arvutis selgub, et for-silmusega kood võib olla umbes 50 korda kiirem, kui võrrelda kaardistamise ja seejärel sama efekti saavutavate elementide vähendamisega! Seda seetõttu, et summa saamine antud juhul reduce meetodi abil nõuab massiivi kaardistamist puhtalt väärtuste jaoks, mida me tahame summeerida. Nii et loome midagi seaduslikumat, et olla objektiivne Array meetodite kohta:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() })));
console.time('Sum by reduce');
const reduceSum = randomArray
.reduce((a, b) => ({ value: a.value + b.value })).value
console.timeEnd('Sum by reduce');
console.time('Sum by 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);
})();
Ja nagu selgub, vähenes meie 50-kordne võimendus 4x võimenduseks. Vabandame, kui tunnete end pettununa! Et jääda objektiivseks lõpuni, analüüsime veelkord mõlemat koodi. Esiteks - süütuna näivad erinevused kahekordistasid meie teoreetilise arvutuskomplekssuse langust; selle asemel, et kõigepealt kaardistada ja seejärel liita puhtad elemendid kokku, opereerime ikka objektide ja konkreetse väljaga, et lõpuks välja tõmmata, et saada summa, mis meid huvitab. Probleem tekib siis, kui teine programmeerija võtab koodi vaatluse alla - siis kaotab viimane võrreldes varem näidatud koodidega mingil hetkel oma abstraktsuse.
Seda seetõttu, et alates teisest operatsioonist, et me tegutseme võõra objektiga, mille valdkond meid huvitab ja teine, standardne objekt iteratsioonimassiivi. Ma ei tea, mida te sellest arvate, kuid minu seisukohast on teises koodinäites for-silmuse loogika palju selgem ja tahtlikum kui see kummalise välimusega redutseerimine. Ja ikkagi, kuigi see ei ole enam see müütiline 50, on see ikkagi 4 korda kiirem, mis puudutab arvutusaega! Kuna iga millisekund on väärtuslik, on valik antud juhul lihtne.
Kõige üllatavam näide
Teine asi, mida ma tahtsin võrrelda, puudutab Math.max meetodit või täpsemalt, miljoni elemendi täitmist ja neist suurima ja väikseima väljavõtte tegemist. Valmistasin koodi ette, meetodid ka aja mõõtmiseks, siis käivitan koodi ja saan väga kummalise vea - korstna suurus on ületatud. Siin on kood:
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5);
console.time('Math.max koos ES6 levikuoperaatoriga');
const maxBySpread = Math.max(...randomValues);
console.timeEnd('Math.max koos ES6 levikuoperaatoriga');
console.time('Math.max koos for loop'iga');
let maxByFor = randomValues[0];
for (let index = 1; index maxByFor) {
maxByFor = randomValues[index];
}
}
console.timeEnd('Math.max with for loop');
console.log(maxByFor === maxBySpread);
})();
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5);
console.time('Math.max koos ES6 levikuoperaatoriga');
const maxBySpread = Math.max(...randomValues);
console.timeEnd('Math.max koos ES6 levikuoperaatoriga');
console.time('Math.max koos for loop'iga');
let maxByFor = randomValues[0];
for (let index = 1; index maxByFor) {
maxByFor = randomValues[index];
}
}
console.timeEnd('Math.max with for loop');
console.log(maxByFor === maxBySpread);
})();
Selgub, et natiivsed meetodid kasutavad rekursiooni, mis v8-s on piiratud call stäkkidega ja selle arv sõltub keskkonnast. See on midagi, mis mind väga üllatas, kuid see kannab järeldust: natiivimeetod on kiirem, kui meie massiivi ei ületa teatud maagilist elementide arvu, mis minu puhul osutus 125375-ks. Selle elementide arvu puhul oli tulemus for 5x kiirem, kui võrrelda loopiga. Kuid üle nimetatud elementide arvu võidab for loop kindlasti - erinevalt vastasest võimaldab ta meil saada korrektseid tulemusi.
Rekursioon
Kontseptsioon, mida ma tahan selles lõigus mainida, on rekursioon. Eelmises näites nägime seda meetodi Math.max ja argumendi voltimise puhul, kus selgus, et rekursiivsete kutsete puhul ei ole võimalik saada tulemust, mis ületab teatud arvu, kuna korstna suurus on piiratud.
Nüüd näeme, kuidas rekursioon näeb välja JS-s kirjutatud koodi kontekstis, mitte sisseehitatud meetoditega.Kõige klassikalisem asi, mida me saame siin näidata, on muidugi Fibonacci jada n-nda termi leidmine. Nii et kirjutame seda!
(() => {
const fiboIterative = (n) => {
lase [a, b] = [0, 1];
for (let i = 0; i {
if(n < 2) {
return n;
}
return fiboRecursive(n - 2) + fiboRecursive(n - 1);
};
console.time('Fibonacci jada for loop'i abil');
const resultIterative = fiboIterative(30);
console.timeEnd('Fibonacci jada for loop'i abil');
console.time('Fibonacci jada rekursiooni abil');
const resultRecursive = fiboRecursive(30);
console.timeEnd('Fibonacci jada rekursiooni abil');
console.log(resultRecursive === resultIterative);
})();
Okei, antud konkreetsel juhul, kui minu arvutis arvutatakse jada 30. element, saame iteratiivse algoritmi abil tulemuse umbes 200x lühema ajaga.
On aga üks asi, mida saab rekursiivses algoritmis parandada - nagu selgub, töötab see palju tõhusamalt, kui kasutame taktikat, mida nimetatakse sabarekursiooniks. See tähendab, et me anname eelmise iteratsiooni käigus saadud tulemuse edasi, kasutades argumente sügavamateks üleskutseteks. See võimaldab meil vähendada vajalike kõnede arvu ja selle tulemusena kiirendab tulemuse saamist. Korrigeerime oma koodi vastavalt!
(() => {
const fiboIterative = (n) => {
lase [a, b] = [0, 1];
for (let i = 0; i {
if(n === 0) {
return first;
}
return fiboTailRecursive(n - 1, second, first + second);
};
console.time('Fibonacci jada for loop'i abil');
const resultIterative = fiboIterative(30);
console.timeEnd('Fibonacci jada for loop'i abil');
console.time('Fibonacci jada sabarekursiooni abil');
const resultRecursive = fiboTailRecursive(30);
console.timeEnd('Fibonacci jada sabarekursioga');
console.log(resultRecursive === resultIterative);
})();
Siin juhtus midagi, mida ma ei osanud oodata - sabarekursiooni algoritmi tulemus (jada 30. elemendi arvutamine) oli mõnel juhul peaaegu kaks korda kiirem kui iteratiivse algoritmi tulemus. Ma ei ole päris kindel, kas see on tingitud v8-i poolsest sabarekursiooni optimeerimisest või selle konkreetse arvu iteratsioonide jaoks silmuse optimeerimise puudumisest, kuid tulemus on ühemõtteline - sabarekursioon võidab.
See on kummaline, sest sisuliselt paneb for-silmus madalama taseme arvutustegevusele palju vähem abstraktsust peale ja võiks öelda, et see on lähemal arvuti põhitegevusele. Ometi on tulemused vaieldamatud - targalt kavandatud rekursioon osutub kiiremaks kui iteratsioon.
Kasutage võimalikult sageli asünkroonseid kutsekorraldusi.
Viimase lõigu tahaksin pühendada lühikesele meeldetuletusele operatsioonide teostamise meetodi kohta, mis võib samuti oluliselt mõjutada meie rakenduse kiirust. Nagu te peaksite teadma, JavaScript on ühetäheline keel, mis säilitab kõik toimingud sündmussilmuse mehhanismiga. Tegemist on tsükliga, mis jookseb ikka ja jälle ja kõik sammud selles tsüklis on seotud spetsiaalsete määratud toimingutega.
Et see tsükkel oleks kiire ja kõik tsüklid saaksid vähem oma järjekorda oodata, peaksid kõik elemendid olema võimalikult kiired. Vältige pikkade operatsioonide teostamist põhisuunas - kui midagi liiga kaua kestab, püüdke need arvutused üle viia WebWorkerisse või jagada need osadeks, mis töötavad asünkroonselt. See võib küll aeglustada mõningaid operatsioone, kuid tõstab kogu JS ökosüsteemi, sealhulgas IO operatsioone, näiteks hiire liikumise või poolelioleva HTTP päringu käsitlemine.
Kokkuvõte
Kokkuvõtteks, nagu eespool mainitud, võib algoritmi valimisega kokkuhoitavate millisekundite tagaajamine osutuda mõnel juhul mõttetuks. Teisest küljest võib selliste asjade unarusse jätmine rakendustes, mis nõuavad sujuvat tööd ja kiireid tulemusi, olla teie rakendusele surmav. Mõnel juhul tuleks lisaks algoritmi kiirusele esitada veel üks küsimus - kas abstraktsioon töötab õigel tasemel? Kas programmeerija, kes loeb koodi, saab sellest probleemideta aru?
Ainus võimalus on tagada tasakaal jõudluse, rakendamise lihtsuse ja sobiva abstraktsiooni vahel ning olla kindel, et algoritm töötab korrektselt nii väikeste kui ka suurte andmemahtude puhul. Selle saavutamise viis on üsna lihtne - olge nutikas, arvestage algoritmi kavandamisel erinevaid juhtumeid ja korraldage see nii, et see käituks võimalikult tõhusalt keskmiste täitmiste puhul. Samuti on soovitav projekteerida teste - olge kindel, et algoritm tagastab erinevate andmete puhul sobivat teavet, ükskõik kuidas ta töötab. Hoolitsege õigete liideste eest - et nii meetodite sisend kui ka väljund oleksid loetavad, selged ja kajastaksid täpselt, mida nad teevad.
Ma mainisin varem, et ma pöördun tagasi algoritmide kiiruse mõõtmise usaldusväärsuse juurde ülaltoodud näidetes. Nende mõõtmine console.time abil ei ole väga usaldusväärne, kuid see kajastab kõige paremini standardset kasutusjuhtumit. Igatahes esitan allpool võrdlusnäitajad - mõned neist näevad veidi teistsugused välja kui üksiku täitmise tõttu, kuna võrdlusnäitajad lihtsalt kordavad teatud tegevust teatud ajal ja kasutavad silmuste jaoks v8 optimeerimist.
https://jsben.ch/KhAqb - reduce vs for loop
https://jsben.ch/F4kLY - optimeeritud vähendamine vs for loop
https://jsben.ch/MCr6g - Math.max vs for loop
https://jsben.ch/A0CJB - rekursiivne fibo vs iteratiivne fibo