Daži triki, lai paātrinātu JavaScript lietojumprogrammas darbību
Bartošs Slišs (Bartosz Slysz)
Software Engineer
Attīstoties pārlūkprogrammu tehnoloģijām, tīmekļa lietojumprogrammās arvien vairāk loģikas tiek pārnests uz priekšējo daļu, tādējādi atslogojot serveri un samazinot tam veicamo darbību skaitu. Pamata CRUD lietojumprogrammās servera uzdevums ir autorizācija, validācija, saziņa ar datubāzēm un nepieciešamā biznesa loģika. Pārējo datu loģiku, kā izrādās, var viegli apstrādāt kods, kas atbild par lietojumprogrammas attēlojumu lietotāja saskarnes pusē.
Šajā rakstā es centīšos jums parādīt dažus piemērus un modeļus, kas palīdzēs saglabāt mūsu kods efektīvi, veikli un ātri.
Pirms iedziļināties konkrētos piemēros - šajā rakstā vēlos pievērsties tikai gadījumiem, kas, manuprāt, var pārsteidzošā veidā ietekmēt lietojumprogrammas ātrumu. Tomēr tas nenozīmē, ka ātrāku risinājumu izmantošana ir labākā izvēle visos iespējamos gadījumos. Turpmāk minētie padomi drīzāk būtu jāuztver kā kaut kas tāds, ko vajadzētu apsvērt, ja mūsu lietojumprogramma darbojas lēni, piemēram, produktos, kuros nepieciešama spēļu atveidošana vai sarežģītāki grafiki uz audekla, video operācijas vai darbības, kuras vēlaties sinhronizēt reālajā laikā pēc iespējas ātrāk.
Vispirms - Array.prototipa metodes
Liela daļa lietojumprogrammas loģikas ir balstīta uz masīviem - to kartēšanu, šķirošanu, filtrēšanu, elementu summēšanu utt. Vienkāršā, pārredzamā un dabiskā veidā mēs izmantojam to iebūvētās metodes, kas vienkārši ļauj mums lai veiktu dažāda veida aprēķinus, grupēšanu u. c. Katrā gadījumā tie darbojas līdzīgi - kā argumentu mēs nododam funkciju, kurā vairumā gadījumu katrā iterācijā pārmaiņus tiek stumta elementa vērtība, indekss un masīvs. Norādītā funkcija tiek izpildīta katram elementam masīvā, un atkarībā no metodes rezultāts tiek interpretēts atšķirīgi. Es sīkāk neaprakstīšu Array.prototipe metodes, jo vēlos pievērsties tam, kāpēc tas lielā skaitā gadījumu darbojas lēni.
Matu metodes ir lēnas, jo tās veic funkciju katram elementam. No dzinēja viedokļa izsauktajai funkcijai ir jāsagatavo jauns izsaukums, jānodrošina atbilstoša darbības joma un daudz citu atkarību, kas procesu padara daudz garāku nekā konkrēta koda bloka atkārtošana konkrētā darbības jomā. Un tas, iespējams, ir pietiekami daudz fona zināšanu, lai mēs varētu saprast nākamo piemēru:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() }));
console.time('Summa pēc samazināt');
const reduceSum = randomArray
.map(({ value }) => value)
.reduce((a, b) => a + b);
console.timeEnd('Summa pēc reduce');
console.time('Summa pēc for cikla');
let forSum = randomArray[0].value;
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Summa pēc for cikla');
console.log(reduceSum === forSum);
})();
Es zinu, ka šis tests nav tik uzticams kā etaloni (mēs pie tiem atgriezīsimies vēlāk), taču tas iedarbina brīdinājuma signālu. Atsevišķā gadījumā manā datorā izrādās, ka kods ar for cilpu var būt aptuveni 50 reižu ātrāks, ja salīdzina ar elementu kartēšanu un pēc tam samazināšanu, ar ko panāk to pašu efektu! Runa ir par operēšanu ar kādu dīvainu objektu, kas izveidots tikai tāpēc, lai sasniegtu konkrētu aprēķinu mērķi. Tātad izveidosim kaut ko leģitīmāku, lai būtu objektīvi par masīva metodēm:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() }));
console.time('Summa pēc samazināt');
const reduceSum = randomArray
.reduce((a, b) => ({vērtība: a.value + b.value })).value
console.timeEnd('Summa pēc reduce');
console.time('Summa pēc for cikla');
let forSum = randomArray[0].value;
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Summa pēc for cikla');
console.log(reduceSum === forSum);
})();
Es zinu, ka šis tests nav tik uzticams kā etaloni (mēs pie tiem atgriezīsimies vēlāk), taču tas iedarbina brīdinājuma signālu. Atsevišķā gadījumā manā datorā izrādās, ka kods ar for cilpu var būt aptuveni 50 reižu ātrāks, ja salīdzina ar elementu kartēšanu un pēc tam samazināšanu, ar ko panāk to pašu efektu! Tas ir tāpēc, ka summas iegūšana šajā konkrētajā gadījumā, izmantojot metodi reduce, prasa masīva kartēšanu tīrām vērtībām, kuras mēs vēlamies apkopot. Tātad izveidosim kaut ko leģitīmāku, lai būtu objektīvi par masīva metodēm:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() }));
console.time('Summa pēc samazināt');
const reduceSum = randomArray
.reduce((a, b) => ({vērtība: a.value + b.value })).value
console.timeEnd('Summa pēc reduce');
console.time('Summa pēc for cikla');
let forSum = randomArray[0].value;
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Summa pēc for cikla');
console.log(reduceSum === forSum);
})();
Un, kā izrādās, mūsu 50x palielinājums samazinājās līdz 4x palielinājumam. Atvainojamies, ja jūtaties vīlušies! Lai saglabātu objektivitāti līdz galam, vēlreiz izanalizēsim abus kodus. Pirmkārt - nevainīgi izskatījušās atšķirības divkāršoja mūsu teorētiskās skaitļošanas sarežģītības kritumu; tā vietā, lai vispirms kartētu un pēc tam saskaitītu tīrus elementus, mēs joprojām operējam ar objektiem un konkrētu lauku, lai beigās izvilktu, lai iegūtu mūs interesējošo summu. Problēma rodas tad, kad kodu aplūko cits programmētājs - tad, salīdzinot ar iepriekš parādītajiem kodiem, pēdējais kādā brīdī zaudē abstrakciju.
Tas ir tāpēc, ka kopš otrās operācijas, ka mēs darbojamies ar svešu objektu, ar interesējošo lauku mums un otro, standarta objektu iterēto masīva. Nezinu, ko jūs par to domājat, bet, no manas perspektīvas raugoties, otrajā koda piemērā for cilpas loģika ir daudz skaidrāka un mērķtiecīgāka nekā šī dīvainā izskata reducēšana. Un tomēr, lai gan tas vairs nav mītiskie 50, tas joprojām ir 4 reizes ātrāks, runājot par aprēķinu laiku! Tā kā katra milisekunde ir vērtīga, izvēle šajā gadījumā ir vienkārša.
Pārsteidzošākais piemērs
Otra lieta, ko es gribēju salīdzināt, attiecas uz metodi Math.max jeb, precīzāk, uz miljonu elementu pildīšanu un lielāko un mazāko elementu izdalīšanu. Es sagatavoju kodu, metodes arī laika mērīšanai, tad palaidu kodu un saņemu ļoti dīvainu kļūdu - kaudzes lielums ir pārsniegts. Šeit ir kods:
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5);
console.time('Math.max ar ES6 izplatīšanas operatoru');
const maxBySpread = Math.max(...randomValues);
console.timeEnd('Math.max ar ES6 izkliedes operatoru');
console.time('Math.max ar for cilpu');
let maxByFor = randomValues[0];
for (let index = 1; index maxByFor) {
maxByFor = randomValues[index];
}
}
console.timeEnd('Math.max ar for cilpu');
console.log(maxByFor === maxBySpread);
})();
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5);
console.time('Math.max ar ES6 izplatīšanas operatoru');
const maxBySpread = Math.max(...randomValues);
console.timeEnd('Math.max ar ES6 izkliedes operatoru');
console.time('Math.max ar for cilpu');
let maxByFor = randomValues[0];
for (let index = 1; index maxByFor) {
maxByFor = randomValues[index];
}
}
console.timeEnd('Math.max ar for cilpu');
console.log(maxByFor === maxBySpread);
})();
Izrādās, ka vietējās metodes izmanto rekursiju, ko v8 versijā ierobežo izsaukumu kaudzes, un to skaits ir atkarīgs no vides. Tas mani ļoti pārsteidza, bet no tā izriet secinājums: dzimtā metode ir ātrāka, ja vien mūsu masīvs nepārsniedz noteiktu maģisko elementu skaitu, kas manā gadījumā izrādījās 125375. Šim elementu skaitam rezultāts for bija 5x ātrāks, ja salīdzina ar cilpu. Tomēr, pārsniedzot minēto elementu skaitu, for cilpa noteikti uzvar - atšķirībā no pretinieka tā ļauj mums iegūt pareizus rezultātus.
Rekursija
Koncepcija, ko vēlos minēt šajā punktā, ir rekursija. Iepriekšējā piemērā mēs to redzējām Math.max metodes un argumentu locīšanas gadījumā, kur izrādījās, ka rekursīviem izsaukumiem, kas pārsniedz noteiktu skaitli, skursteņa lieluma ierobežojuma dēļ nav iespējams iegūt rezultātu.
Tagad mēs redzēsim, kā rekursija izskatās kontekstā ar kodu, kas uzrakstīts valodā JS, nevis ar iebūvētām metodēm.Iespējams, klasiskākais, ko mēs šeit varam parādīt, ir, protams, Fibonači secības n-tā locekļa atrašana. Tātad, uzrakstīsim to!
(() => {
const fiboIterative = (n) => {
lai [a, b] = [0, 1];
for (let i = 0; i {
if(n < 2) {
return n;
}
return fiboRecursive(n - 2) + fiboRecursive(n - 1);
};
console.time('Fibonači secība pēc for cilpas');
const resultIterative = fiboIterative(30);
console.timeEnd('Fibonači secība pēc for cilpas');
console.time('Fibonači secība ar rekursiju');
const resultRecursive = fiboRecursive(30);
console.timeEnd('Fibonači secība ar rekursiju');
console.log(resultRececursive === resultIterative);
})();
Labi, šajā konkrētajā secības 30. elementa aprēķināšanas gadījumā manā datorā ar iteratīvo algoritmu mēs iegūstam rezultātu aptuveni 200x īsākā laikā.
Tomēr rekursīvajā algoritmā ir viena lieta, ko var labot - izrādās, ka tas darbojas daudz efektīvāk, ja mēs izmantojam taktiku, ko sauc par astes rekursiju. Tas nozīmē, ka iepriekšējā iterācijā iegūto rezultātu mēs nododam tālāk, izmantojot argumentus dziļākiem izsaukumiem. Tas ļauj samazināt nepieciešamo izsaukumu skaitu un rezultātā paātrina rezultāta iegūšanu. Attiecīgi labosim mūsu kodu!
(() => {
const fiboIterative = (n) => {
lai [a, b] = [0, 1];
for (let i = 0; i {
if(n === 0) {
return first;
}
return fiboTailRecursive(n - 1, second, first + second);
};
console.time('Fibonači secība pēc for cilpas');
const resultIterative = fiboIterative(30);
console.timeEnd('Fibonači secība pēc for cilpas');
console.time('Fibonači secība ar astes rekursiju');
const resultRecursive = fiboTailRecursive(30);
console.timeEnd('Fibonači secība ar astes rekursiju');
console.log(resultRececursive === resultIterative);
})();
Šeit notika kas tāds, ko es īsti negaidīju - astes rekursijas algoritma rezultāts (secības 30. elementa aprēķināšana) dažos gadījumos bija gandrīz divreiz ātrāks nekā iteratīvajam algoritmam. Neesmu pilnīgi pārliecināts, vai tas ir saistīts ar v8 optimizāciju astes rekursijai, vai arī ar to, ka nav optimizēta for cilpa šim konkrētajam iterāciju skaitam, taču rezultāts ir nepārprotams - uzvar astes rekursija.
Tas ir dīvaini, jo būtībā for cilpa uzliek daudz mazāk abstrakcijas zemāka līmeņa skaitļošanas darbībām, un var teikt, ka tā ir tuvāka datora pamatdarbībai. Tomēr rezultāti ir nenoliedzami - gudri izstrādāta rekursija izrādās ātrāka par iterāciju.
pēc iespējas biežāk izmantojiet asinhronos izsaukuma paziņojumus.
Pēdējo rindkopu vēlos veltīt īsam atgādinājumam par operāciju veikšanas metodi, kas arī var būtiski ietekmēt mūsu lietojumprogrammas ātrumu. Kā jums vajadzētu zināt, JavaScript ir viena pavediena valoda, kurā visas operācijas tiek veiktas ar notikumu cikla mehānismu. Runa ir par ciklu, kas darbojas atkal un atkal, un visi šī cikla soļi ir saistīti ar īpašām noteiktām darbībām.
Lai šī cilpa būtu ātra un visi cikli mazāk gaidītu uz savu kārtu, visiem elementiem jābūt pēc iespējas ātrākiem. Izvairieties no garu darbību veikšanas galvenajā pavedienā - ja kaut kas pārāk ilgi aizņem pārāk daudz laika, mēģiniet šos aprēķinus pārcelt uz WebWorker vai sadalīt daļās, kas darbojas asinhroni. Tas var palēnināt dažas operācijas, bet uzlabot JS ekosistēmu kopumā, tostarp IO operācijas, piemēram, peles pārvietošanas vai HTTP pieprasījuma apstrādi.
Kopsavilkums
Nobeigumā, kā minēts iepriekš, dzīšanās pēc milisekundēm, ko var ietaupīt, izvēloties algoritmu, dažos gadījumos var izrādīties bezjēdzīga. No otras puses, šādu lietu neievērošana lietojumprogrammās, kurās nepieciešama vienmērīga darbība un ātri rezultāti, var būt nāvējoša jūsu lietojumprogrammai. Dažos gadījumos papildus algoritma ātrumam būtu jāuzdod viens papildu jautājums - vai abstrakcija darbojas pareizajā līmenī? Vai programmētājs, kas lasa kodu, spēs to saprast bez problēmām?
Vienīgais veids, kā nodrošināt līdzsvaru starp veiktspēju, īstenošanas vienkāršību un atbilstošu abstrakciju, un būt pārliecinātam, ka algoritms darbojas pareizi gan maziem, gan lieliem datu daudzumiem. dati. Veids, kā to izdarīt, ir pavisam vienkāršs - esiet gudri, izstrādājot algoritmu, apsveriet dažādus gadījumus un sakārtojiet to tā, lai tas darbotos pēc iespējas efektīvāk vidējiem izpildījumiem. Ieteicams arī izstrādāt testus - pārliecinieties, ka algoritms atdod atbilstošu informāciju dažādiem datiem neatkarīgi no tā, kā tas darbojas. Parūpējieties par pareizām saskarnēm - lai gan metožu ievade, gan izvade būtu salasāma, skaidra un precīzi atspoguļotu, ko tās dara.
Jau iepriekš minēju, ka atgriezīšos pie iepriekš minētajos piemēros aplūkotās algoritmu ātruma mērīšanas ticamības. To mērīšana ar console.time nav ļoti uzticama, taču tā vislabāk atspoguļo standarta lietošanas gadījumu. Lai nu kā, turpmāk piedāvāju etalonus - daži no tiem izskatās mazliet savādāk nekā atsevišķs izpildījums, jo etaloni vienkārši atkārto noteiktu darbību noteiktā laikā un izmanto v8 optimizāciju cilpām.
https://jsben.ch/KhAqb - samazināt vs for cilpa
https://jsben.ch/F4kLY - optimizēta samazināšana pret for cilpu
https://jsben.ch/MCr6g - Math.max vs for cilpa
https://jsben.ch/A0CJB - rekursīvais fibo vs iteratīvais fibo