Muutamia niksejä JavaScript-sovelluksen nopeuttamiseksi
Bartosz Slysz
Software Engineer
Selainteknologian kehittyessä verkkosovellukset ovat alkaneet siirtää yhä enemmän logiikkaa etusivulle, mikä vapauttaa palvelinta ja vähentää sen suorittamien operaatioiden määrää. Perus-CRUD-toiminnoissa palvelimen rooli rajoittuu valtuutukseen, validointiin, tietokantojen kanssa kommunikointiin ja tarvittavaan liiketoimintalogiikkaan. Loput datalogiikasta, kuten käy ilmi, voidaan helposti hoitaa koodilla, joka vastaa sovelluksen esittämisestä käyttöliittymän puolella.
Tässä artikkelissa yritän näyttää teille muutamia esimerkkejä ja malleja, jotka auttavat pitämään meidän koodi tehokas, siisti ja nopea.
Ennen kuin menemme syvemmälle konkreettisiin esimerkkeihin - tässä artikkelissa haluan keskittyä vain sellaisten tapausten esittelyyn, jotka mielestäni voivat vaikuttaa sovelluksen nopeuteen yllättävällä tavalla. Tämä ei kuitenkaan tarkoita, että nopeampien ratkaisujen käyttö olisi paras valinta kaikissa mahdollisissa tapauksissa. Alla olevia vinkkejä tulisi pikemminkin pitää sellaisina asioina, joita kannattaa tarkastella silloin, kun sovelluksemme toimii hitaasti, esimerkiksi tuotteissa, jotka vaativat pelien renderöintiä tai edistyneempiä kuvaajia kankaalla, video-operaatioita tai toimintoja, jotka halutaan synkronoida reaaliajassa mahdollisimman pian.
Ensinnäkin - Array.prototype-metodit
Suuri osa sovelluslogiikasta perustuu matriiseihin - niiden yhdistämiseen, lajitteluun, suodatukseen, elementtien yhteenlaskuun ja niin edelleen. Käytämme helposti, läpinäkyvästi ja luonnollisesti niiden sisäänrakennettuja metodeja, joiden avulla voimme yksinkertaisesti suorittaa erilaisia laskutoimituksia, ryhmittelyjä jne. Ne toimivat jokaisessa tapauksessa samalla tavalla - argumenttina välitämme funktion, jossa useimmissa tapauksissa elementin arvo, indeksi ja array työnnetään vuorotellen jokaisen iteraation aikana. Määritelty funktio suoritetaan jokaiselle matriisin elementille, ja tulos tulkitaan eri tavalla menetelmästä riippuen. En käsittele tarkemmin Array.prototype-metodeja, koska haluan keskittyä siihen, miksi se toimii hitaasti suuressa osassa tapauksia.
Array-metodit ovat hitaita, koska ne suorittavat toiminnon jokaiselle elementille. Moottorin näkökulmasta kutsutun funktion on valmisteltava uusi kutsu, annettava sopiva laajuus ja paljon muita riippuvuuksia, mikä tekee prosessista paljon pidemmän kuin tietyn koodilohkon toistaminen tietyssä laajuudessa. Ja tämä on luultavasti tarpeeksi taustatietoa, jotta voimme ymmärtää seuraavan esimerkin:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() })));
console.time('Summa vähentämällä');
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('Summa for-silmukalla');
console.log(reduceSum === forSum);
})();
Tiedän, että tämä testi ei ole yhtä luotettava kuin vertailuarvot (palaamme niihin myöhemmin), mutta se käynnistää varoitusvalon. Satunnaisessa tapauksessa tietokoneellani käy ilmi, että for-silmukan sisältävä koodi voi olla noin 50 kertaa nopeampi, jos sitä verrataan elementtien kartoittamiseen ja sen jälkeen vähentämiseen, joilla saavutetaan sama vaikutus! Kyse on jonkin oudon objektin käyttämisestä, joka on luotu vain tietyn laskentakohteen saavuttamiseksi. Luodaan siis jotain legitiimimpää, jotta Array-menetelmät olisivat objektiivisia:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() })));
console.time('Summa vähentämällä');
const reduceSum = randomArray
.reduce((a, b) => ({ arvo: 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('Summa for-silmukalla');
console.log(reduceSum === forSum);
})();
Tiedän, että tämä testi ei ole yhtä luotettava kuin vertailuarvot (palaamme niihin myöhemmin), mutta se käynnistää varoitusvalon. Satunnaisessa tapauksessa tietokoneellani käy ilmi, että for-silmukan sisältävä koodi voi olla noin 50 kertaa nopeampi, jos sitä verrataan elementtien kartoittamiseen ja sen jälkeen vähentämiseen, joilla saavutetaan sama vaikutus! Tämä johtuu siitä, että summan saaminen tässä nimenomaisessa tapauksessa reduce-menetelmää käyttäen edellyttää arrayn kartoittamista puhtaiden arvojen osalta, jotka haluamme tiivistää. Luodaan siis jotain legitiimimpää, jotta voidaan olla objektiivisia Array-metodeja kohtaan:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() })));
console.time('Summa vähentämällä');
const reduceSum = randomArray
.reduce((a, b) => ({ arvo: 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('Summa for-silmukalla');
console.log(reduceSum === forSum);
})();
Ja kuten kävi ilmi, 50-kertainen boostimme laski 4x boostiksi. Pahoittelut, jos olet pettynyt! Jotta pysyisimme objektiivisina loppuun asti, analysoidaan molemmat koodit uudelleen. Ensinnäkin - viattoman näköiset erot kaksinkertaistivat teoreettisen laskennallisen monimutkaisuutemme laskun; sen sijaan, että ensin kartoittaisimme ja sitten laskisimme yhteen puhtaita elementtejä, toimimme edelleen objekteilla ja tietyllä kentällä, jotta voimme lopulta vetää ulos saadaksemme summan, josta olemme kiinnostuneita. Ongelma syntyy, kun toinen ohjelmoija vilkaisee koodia - silloin, verrattuna aiemmin esitettyihin koodeihin, jälkimmäinen menettää jossain vaiheessa abstraktionsa.
Tämä johtuu siitä, että koska toinen operaatio, että toimimme outo objekti, jossa kenttä kiinnostaa meitä ja toinen, standardi objekti iteroitu array. En tiedä, mitä mieltä olet siitä, mutta minun näkökulmastani toisessa koodiesimerkissä for-silmukan logiikka on paljon selkeämpi ja tarkoituksenmukaisempi kuin tämä oudon näköinen redusointi. Ja silti, vaikka se ei enää olekaan se myyttinen 50, se on silti neljä kertaa nopeampi, kun on kyse laskentaajasta! Koska jokainen millisekunti on arvokas, valinta on tässä tapauksessa yksinkertainen.
Yllättävin esimerkki
Toinen asia, jota halusin verrata, koskee Math.max-menetelmää tai tarkemmin sanottuna miljoonan elementin täyttämistä ja suurimpien ja pienimpien elementtien poimimista. Valmistelin koodin, menetelmiä myös ajan mittaamista varten, sitten käynnistän koodin ja saan hyvin oudon virheen - pinon koko on ylitetty. Tässä on koodi:
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5);
console.time('Math.max ES6:n leviämisoperaattorilla');
const maxBySpread = Math.max(...randomValues);
console.timeEnd('Math.max with ES6 spread operator');
console.time('Math.max for-silmukalla');
let maxByFor = randomValues[0];
for (let index = 1; index maxByFor) {
maxByFor = randomValues[index];
}
}
console.timeEnd('Math.max for-silmukalla');
console.log(maxByFor === maxBySpread);
})();
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5);
console.time('Math.max ES6:n leviämisoperaattorilla');
const maxBySpread = Math.max(...randomValues);
console.timeEnd('Math.max with ES6 spread operator');
console.time('Math.max for-silmukalla');
let maxByFor = randomValues[0];
for (let index = 1; index maxByFor) {
maxByFor = randomValues[index];
}
}
console.timeEnd('Math.max for-silmukalla');
console.log(maxByFor === maxBySpread);
})();
On käynyt ilmi, että natiivit metodit käyttävät rekursiota, jota v8:ssa rajoittavat kutsupinot, ja sen määrä riippuu ympäristöstä. Tämä yllätti minut kovasti, mutta siitä voidaan vetää johtopäätös: natiivimenetelmä on nopeampi, kunhan joukkomme ei ylitä tiettyä maagista alkioiden lukumäärää, joka minun tapauksessani osoittautui 125375:ksi. Tällä elementtien määrällä tulos for oli 5x nopeampi, jos sitä verrataan silmukkaan. Mainitun elementtimäärän yläpuolella for-silmukka kuitenkin voittaa ehdottomasti - toisin kuin vastustaja, sen avulla saamme oikeat tulokset.
Rekursio
Käsite, jonka haluan mainita tässä kohdassa, on rekursio. Edellisessä esimerkissä näimme sen Math.max-metodissa ja argumenttien taittamisessa, jossa kävi ilmi, että tietyn luvun ylittävistä rekursiivisista kutsuista ei voi saada tulosta pinon kokorajoituksen vuoksi.
Katsomme nyt, miltä rekursio näyttää JS-kielellä kirjoitetun koodin yhteydessä, eikä sisäänrakennettujen metodien avulla.Ehkä klassisin asia, jonka voimme näyttää tässä, on tietenkin Fibonaccin sarjan n:nnen termin löytäminen. Kirjoitetaan siis tämä!
Okei, tässä erityistapauksessa, kun laskemme sarjan 30. kohdan tietokoneellani, saamme tuloksen noin 200 kertaa lyhyemmässä ajassa iteratiivisella algoritmilla.
Rekursiivisessa algoritmissa on kuitenkin yksi asia, joka voidaan korjata - kuten käy ilmi, se toimii paljon tehokkaammin, kun käytämme taktiikkaa nimeltä häntärekursio. Tämä tarkoittaa sitä, että välitämme edellisessä iteraatiossa saamamme tuloksen käyttäen argumentteja syvempiin kutsuihin. Näin voimme vähentää tarvittavien kutsujen määrää ja nopeuttaa tuloksen saamista. Korjataan koodimme sen mukaisesti!
(() => {
const fiboIterative = (n) => {
Olkoon [a, b] = [0, 1];
for (let i = 0; i {
if(n === 0) {
return first;
}
return fiboTailRecursive(n - 1, second, first + second);
};
console.time('Fibonacci-sekvenssi for-silmukalla');
const resultIterative = fiboIterative(30);
console.timeEnd('Fibonacci sequence by for loop');
console.time('Fibonaccisekvenssi häntärekursiolla');
const resultRecursive = fiboTailRecursive(30);
console.timeEnd('Fibonaccin sarja häntärekursion avulla');
console.log(resultRecursive === resultIterative);
})();
Tässä tapahtui jotakin, mitä en odottanut - häntärekursio-algoritmin tulos pystyi joissakin tapauksissa toimittamaan tuloksen (sarjan 30. elementin laskeminen) lähes kaksi kertaa nopeammin kuin iteratiivinen algoritmi. En ole täysin varma, johtuuko tämä v8:n optimoinnista häntärekursion osalta vai siitä, että for-silmukkaa ei optimoitu tätä tiettyä iteraatiomäärää varten, mutta tulos on yksiselitteinen - häntärekurssi voittaa.
Tämä on outoa, koska for-silmukka on paljon vähemmän abstraktio alemman tason laskutoimituksille, ja voisi sanoa, että se on lähempänä tietokoneen perustoimintoja. Silti tulokset ovat kiistattomia - taitavasti suunniteltu rekursio osoittautuu nopeammaksi kuin iterointi.
Käytä asynkronisia kutsulausekkeita niin usein kuin mahdollista.
Haluaisin omistaa viimeisen kappaleen lyhyelle muistutukselle eräästä toimintatavasta, joka voi myös vaikuttaa suuresti sovelluksemme nopeuteen. Kuten sinun pitäisi tietää, JavaScript on yksisäikeinen kieli, joka pitää kaikki operaatiot tapahtumasilmukkamekanismilla. Kyse on syklistä, joka kulkee yhä uudelleen ja uudelleen, ja kaikki tämän syklin vaiheet koskevat erityisiä määritettyjä toimintoja.
Jotta tämä silmukka olisi nopea ja jotta kaikki syklit voisivat odottaa vuoroaan vähemmän, kaikkien elementtien tulisi olla mahdollisimman nopeita. Vältä pitkien operaatioiden suorittamista pääsäikeessä - jos jokin laskutoimitus kestää liian kauan, yritä siirtää nämä laskutoimitukset WebWorkeriin tai jakaa ne osiin, jotka suoritetaan asynkronisesti. Se saattaa hidastaa joitakin toimintoja, mutta tehostaa koko JS:n ekosysteemiä, myös IO-operaatioita, kuten hiiren liikkeiden käsittelyä tai odottavaa HTTP-pyyntöä.
Yhteenveto
Kuten aiemmin mainittiin, algoritmin valinnalla säästettävien millisekuntien jahtaaminen voi joissakin tapauksissa osoittautua järjettömäksi. Toisaalta tällaisten asioiden laiminlyöminen sovelluksissa, jotka vaativat sujuvaa toimintaa ja nopeita tuloksia, voi olla sovelluksen kannalta tappavaa. Joissain tapauksissa algoritmin nopeuden lisäksi on kysyttävä vielä yksi kysymys: onko abstraktio oikealla tasolla? Pystyykö koodia lukeva ohjelmoija ymmärtämään sen ongelmitta?
Ainoa tapa on varmistaa tasapaino suorituskyvyn, toteutuksen helppouden ja asianmukaisen abstraktion välillä ja olla varma siitä, että algoritmi toimii oikein sekä pienillä että suurilla tietomäärillä. Tapa tehdä tämä on melko yksinkertainen - ole fiksu, ota eri tapaukset huomioon algoritmia suunnitellessasi ja järjestä se niin, että se käyttäytyy mahdollisimman tehokkaasti keskimääräisissä suorituksissa. Lisäksi on suositeltavaa suunnitella testejä - varmista, että algoritmi palauttaa sopivaa tietoa eri datan osalta, riippumatta siitä, miten se toimii. Huolehdi oikeista rajapinnoista - jotta sekä metodien tulo että lähtö ovat luettavia, selkeitä ja kuvaavat täsmälleen sitä, mitä ne tekevät.
Mainitsin aiemmin, että palaan vielä edellä mainituissa esimerkeissä algoritmien nopeuden mittaamisen luotettavuuteen. Niiden mittaaminen console.time-arvolla ei ole kovin luotettavaa, mutta se kuvastaa parhaiten tavanomaista käyttötapausta. Joka tapauksessa esittelen alla olevat vertailuarvot - jotkut niistä näyttävät hieman erilaisilta kuin yksittäinen suoritus, koska vertailuarvot yksinkertaisesti toistavat tietyn toiminnon tiettynä aikana ja käyttävät silmukoille v8-optimointia.
https://jsben.ch/KhAqb - vähentäminen vs for-silmukka
https://jsben.ch/F4kLY - optimoitu vähentäminen vs for-silmukka
https://jsben.ch/MCr6g - Math.max vs for loop
https://jsben.ch/A0CJB - rekursiivinen fibo vs. iteratiivinen fibo