Nokkur ráð til að flýta fyrir JavaScript-umsókninni þinni
Bartosz Slysz
Software Engineer
Með framþróun vafraaðgerða hafa vefumsóknir farið að flytja sífellt meiri rökfræði yfir á framhliðina, sem léttir á netþjóninum og dregur úr fjölda aðgerða sem hann þarf að framkvæma. Í grunn CRUD-aðgerðum felst hlutverk netþjónsins í heimildaveitingu, gildisskoðun, samskiptum við gagnagrunna og nauðsynlegri viðskiptarökfræði. Restin af gagnarökfræðinni, eins og kemur í ljós, má auðveldlega sinna með kóða sem sér um framsetningu forritsins á notendaviðmótinu.
Hér er tómt.
Í þessari grein mun ég reyna að sýna þér nokkur dæmi og mynstur sem munu hjálpa til við að halda okkar kóði hagkvæmt, snyrtilegt og fljótlegt.
Áður en við förum dýpra í sértækar dæmi – í þessari grein vil ég einblína eingöngu á að sýna dæmi sem, að mínu mati, geta haft áhrif á hraða forritsins á óvæntan hátt. Þetta þýðir þó ekki að notkun hraðari lausna sé besti kosturinn í öllum hugsanlegum tilvikum. Ráðin hér að neðan ætti frekar að skoða sem eitthvað sem vert er að skoða þegar forritið okkar gengur hægt, t.d. í vörum sem krefjast leikjagerðar eða flóknari línurita á striga, myndbandsvinnslu eða aðgerða sem þú vilt samstilla í rauntíma sem fyrst.
Fyrst og fremst – Array.prototype-aðferðir
Við byggjum stóran hluta af forritalógík forritsins á fylki – kortlagningu þeirra, röðun, síun, samanlagningu þátta og svo framvegis. Á einfaldan, gagnsæjan og náttúrulegan hátt notum við innbyggðar aðferðir þeirra sem einfaldlega gera okkur kleift okkur til að framkvæma ýmsar tegundir útreikninga, flokkana o.s.frv. Þau virka á svipaðan hátt í hverju tilviki – sem rök sendum við fall þar sem, í flestum tilfellum, gildi þáttarins, vísitalan og fylkið eru sett inn í röð við hverja endurtekningu. Tilgreinda fallið er keyrt fyrir hvern þátt í fylkinu og niðurstaðan er túlkuð mismunandi eftir aðferðinni. Ég mun ekki fjalla um Array.prototype-aðferðirnar þar sem ég vil einbeita mér að því hvers vegna það gengur hægt í mörgum tilfellum.
Aðferðir fylkja eru hægar vegna þess að þær framkvæma fall fyrir hvern þátt. Fall sem kallað er frá sjónarhóli vélarinnar þarf að undirbúa nýja köllun, útvega viðeigandi gildissvið og margar aðrar forsendur, sem gerir ferlið mun lengri en að endurtaka ákveðinn kóðabrot í tilteknu gildissviði. Og þetta er líklega næg bakgrunnsupplýsing til að gera okkur kleift að skilja eftirfarandi dæmi:
(() => {
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);
})();
Ég veit að þetta próf er ekki eins áreiðanlegt og viðmiðin (við munum koma aftur að þeim síðar), en það kveikir viðvörunarljós. Í handahófskenndu tilfelli á tölvunni minni kemur í ljós að kóðinn með for-lykkjunni getur verið um 50 sinnum hraðari en að kortleggja og síðan fækka þáttum sem ná sama árangri! Þetta snýst um að vinna með einhvern undarlega hlut sem var búinn til eingöngu til að ná ákveðnu reikniframkvæmdasetmarki. Svo skulum við búa til eitthvað meira réttmætt til að vera hlutlægt um Array-aðferðirnar:
(() => {
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);
})();
Ég veit að þetta próf er ekki eins áreiðanlegt og viðmiðin (við komum aftur að þeim síðar), en það kveikir viðvörunarljós. Í handahófskenndu tilfelli á tölvunni minni kemur í ljós að kóðinn með for-lykkjunni getur verið um 50 sinnum hraðari en mapping og síðan reducing á þáttum sem ná sama árangri! Þetta er vegna þess að til að fá summuna í þessu tiltekna tilfelli með reduce-aðferðinni þarf að framkvæma mapping á fylkinu til að búa til hreinar gildi sem við viljum samantekja. Svo skulum við búa til eitthvað meira viðeigandi til að vera hlutlæg um fylkisaðferðirnar:
(() => {
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);
})();
Og eins og kom í ljós, minnkaði 50x hraðaaukningin okkar í 4x hraðaaukningu. Fyrirgefðu ef þú ert vonsvikinn! Til að halda hlutleysi til enda skulum við greina báða kóðana aftur. Í fyrsta lagi – saklausar útlitsmunir tvöfölduðu lækkunina í fræðilegri reikniflokkni okkar; í stað þess að kortleggja fyrst og svo leggja saman hreina þætti vinnum við enn með hluti og ákveðið svið til að að lokum draga fram þá upphæð sem okkur langar að fá. Vandamálið kemur upp þegar annar forritari skoðar kóðann – þá, borið saman við kóðana sem sýndir voru áður, tapar sá seinni abstraktíon sinni á einhverjum tímapunkti.
Þetta er vegna þess að frá annarri aðgerðinni vinnum við með undarlegu hlutinn, með reitinn sem okkur langar að skoða, og hinn venjulegi hlutinn úr endurteknu fylkinu. Ég veit ekki hvað þér finnst um þetta, en frá minni sjónarhóli er rökfræði for-lykkjunnar í öðru kóðadæminu mun skýrari og markvissari en þessi undarlega reduce-aðgerð. Og þrátt fyrir að það sé ekki lengur hið goðsagnakennda 50, er það samt fjórum sinnum hraðara hvað varðar útreikningstíma! Þar sem hver millisekúnda skiptir máli er valið í þessu tilfelli einfalt.
Mest óvænti dæmið
Annað sem ég vildi bera saman varðaði Math.max-aðferðina eða, nákvæmlega, að setja milljón þætti inn í hana og síðan draga út þá stærsta og minnsta. Ég undirbjó kóðann og aðferðir til að mæla tíma líka, kveikti á kóðanum og fékk mjög undarlega villu – staflstærð er yfirstigin. Hér er kóðinn:
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5);
console.time('Math.max með ES6 spread-rekstraraðila');
const maxBySpread = Math.max(...randomValues);
console.timeEnd('Math.max með ES6 spread-rekstraraðila');
console.time('Math.max með for-lykli');
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 með ES6 spread-rekstraraðila');
const maxBySpread = Math.max(...randomValues);
console.timeEnd('Math.max með ES6 spread-rekstraraðila');
console.time('Math.max með for-lykli');
let maxByFor = randomValues[0];
for (let index = 1; index maxByFor) {
maxByFor = randomValues[index];
}
}
console.timeEnd('Math.max with for loop');
console.log(maxByFor === maxBySpread);
})();
Það kemur í ljós að innfæddar aðferðir nota endurköllun, sem í v8 er takmörkuð af köllunarsöfnum og fjöldi þeirra fer eftir umhverfinu. Þetta kom mér mjög á óvart, en það leiðir til einnar niðurstöðu: innfædda aðferðin er hraðari, svo framarlega sem fylkið okkar fer ekki yfir ákveðinn töfratölu þátta, sem í mínu tilfelli reyndist vera 125375. Fyrir þennan fjölda þátta var niðurstaðan 5 sinnum hraðari en lykkjan. Hins vegar, yfir þeim nefnda fjölda þátta, vinnur for-lykkjan hiklaust – ólíkt keppinautnum leyfir hún okkur að fá réttar niðurstöður.
Endurköllun
Hugtakið sem ég vil nefna í þessum málsgrein er sjálfköllun. Í fyrri dæminu sáum við það í Math.max-aðferðinni og í rökfellingu rökargilda, þar sem kom í ljós að ekki er hægt að fá niðurstöðu fyrir sjálfköllanir sem fara yfir ákveðinn fjölda vegna takmarkana á staflaloðdýpt.
Við munum nú sjá hvernig endurköllun lítur út í samhengi kóða sem er skrifaður í JS, og ekki með innbyggðum aðferðum. Kannski er klassískasta dæmið sem við getum sýnt hér, auðvitað, að finna n-ta hlið Fibonacci-raðarinnar. Svo skulum við skrifa þetta!
(() => {
const fiboIterative = (n) => {
let [a, b] = [0, 1];
for (let i = 0; i {
if(n < 2) {
return n;
}
return fiboRecursive(n - 2) + fiboRecursive(n - 1);
};
console.time('Fibonacci röð með for-lykkju');
const resultIterative = fiboIterative(30);
console.timeEnd('Fibonacci-raðir með for-hring');
console.time('Fibonacci-raðir með endurköllun');
const resultRecursive = fiboRecursive(30);
console.timeEnd('Fibonacci-raðir með endurköllun');
console.log(resultRecursive === resultIterative);
})();
Allt í lagi, í þessu tiltekna tilfelli að reikna 30. lið röðunnar á tölvunni minni fáum við niðurstöðuna á um það bil 200 sinnum styttri tíma með endurtekningalegum reikniritinu.
Það er þó eitt sem hægt er að laga í endurköllunaralgórítmanum – eins og kemur í ljós, virkar hann mun skilvirkari þegar við notum aðferð sem kallast halaendurhringing. Þetta þýðir að við sendum niðurstöðuna sem við fengum í fyrri endurtekningu sem rök fyrir dýpri köllum. Þetta gerir okkur kleift að draga úr fjölda kalla sem þarf og þar með hraða niðurstöðunni. Breytum kóðanum okkar í samræmi við þetta!
(() => {
const fiboIterative = (n) => {
let [a, b] = [0, 1];
for (let i = 0; i {
if(n === 0) {
return first;
}
return fiboTailRecursive(n - 1, second, first + second);
};
console.time('Fibonacci-raðir með for-lykkju');
const resultIterative = fiboIterative(30);
console.timeEnd('Fibonacci-raðir með for-lykkju');
console.time('Fibonacciröð með hala-endurköllun');
const resultRecursive = fiboTailRecursive(30);
console.timeEnd('Fibonacciröð með hala-endurköllun');
console.log(resultRecursive === resultIterative);
})();
Eitthvað sem ég ekki alveg bjóst við gerðist hér – niðurstaða halaendurköllunaralgórítmans gat skilað niðurstöðunni (reiknað 30. þátt röð) næstum tvisvar sinnum hraðar en endurtekningaralgórítminn í sumum tilvikum. Ég er ekki alveg viss um hvort þetta stafi af fínstillingu á halaendurtekningu hjá v8 eða skorti á fínstillingu á for-lykkjunni fyrir þennan tiltekna fjölda endurtekninga, en niðurstaðan er óyggjandi – halaendurtekningin vinnur.
Þetta er skrýtið því í grundvallaratriðum krefst for-lykkjan mun minni abstraktunar við lágstigs útreikninga og má segja að hún sé nær grunnreiknivélaraðgerðum. En niðurstöðurnar eru óyggjandi – snjallt hönnuð endurköllun reynist hraðari en endurtekningar.
Notaðu ósamhverfar kallyfirlýsingar eins oft og þú getur.
Mig langar að verja síðasta málsgreininni til stuttrar áminningar um aðferð til að framkvæma aðgerðir sem geta einnig haft veruleg áhrif á hraða forritsins okkar. Eins og þið ættuð að vita, JavaScript er einþráða forritunarmál sem heldur utan um allar aðgerðir með atburðarhringsvinnubragði. Allt snýst um hringrás sem keyrir endurtekið og hvert skref í þessari hringrás felur í sér sérhæfðar, tilgreindar aðgerðir.
Til að gera þessa lykkju hraða og láta alla hringi bíða minna eftir sinni töku, ættu allir þættir að vera eins snöggir og mögulegt er. Forðastu að keyra langvarandi aðgerðir á aðalþræði – ef eitthvað tekur of langan tíma, reyndu að færa þessar útreikningar í WebWorker eða skipta þeim upp í hluta sem keyra ósamstillt. Þetta getur hægjað á sumum aðgerðum en bætt allt JS-umhverfið, þar á meðal inntaks- og úttaks-aðgerðir, eins og að meðhöndla músarbíla hreyfingar eða biðjandi HTTP-beiðnir.
Yfirlit
Að lokum, eins og áður hefur komið fram, getur það reynst tilgangslaust í sumum tilvikum að elta millisekúndur sem hægt er að spara með vali á reiknirit. Á hinn bóginn getur það reynst banvænt fyrir forritið þitt að hunsa slíka þætti í forritum sem krefjast hnökralausrar vinnslu og hraðra niðurstaðna. Í sumum tilvikum, umfram hraða reikniritisins, þarf að spyrja eina viðbótar spurningu – er abstraktgerðin á réttum stigi? Mun forritarinn sem les kóðann geta skilið hann án vandræða?
Eini möguleikinn er að tryggja jafnvægi milli afkasta, einfaldleika innleiðingar og viðeigandi abstraktunar, og vera fullviss um að reikniritinu virki rétt bæði fyrir litlar og stórar magnir af gögn. Hvernig á að gera þetta er nokkuð einfalt – vertu snjall, íhugaðu mismunandi tilvik þegar þú hönnar reiknirit og skipuleggðu það til að virka eins skilvirkt og mögulegt er í meðalframkvæmdum. Einnig er ráðlegt að hanna prófanir – vertu viss um að reikniritinu skili viðeigandi upplýsingum fyrir mismunandi gögn, óháð því hvernig það virkar. Annast rétt viðmót – þannig að bæði inntak og úttak aðferða séu læsileg, skýr og endurspegli nákvæmlega hvað þau gera.
Ég nefndi áður að ég myndi koma aftur að áreiðanleika þess að mæla hraða reikniritanna í dæmunum hér að ofan. Að mæla þau með console.time er ekki mjög áreiðanlegt, en það endurspeglar best hefðbundna notkunartilvik. Allavega, hér eru niðurstöður samanburðarmælinganna – sumar þeirra líta aðeins öðruvísi út en ein framkvæmd vegna þess að samanburðarmælingar endurtaka einfaldlega tiltekna aðgerð á ákveðnum tíma og nýta v8-hagræðingu fyrir lykkjur.
https://jsben.ch/KhAqb – draga úr vs for-hringi
https://jsben.ch/F4kLY – hagræðing á reduce í for-hringi
https://jsben.ch/MCr6g – Math.max vs for hringrás
https://jsben.ch/A0CJB – endurkallandi Fibonacci vs. skrefbundinn Fibonacci