Kilka sztuczek przyspieszających działanie aplikacji JavaScript
Bartosz Słysz
Software Engineer
Wraz z rozwojem technologii przeglądarek, aplikacje internetowe zaczęły przenosić coraz więcej logiki do front-endu, odciążając tym samym serwer i zmniejszając liczbę operacji, które musi wykonać. W podstawowych CRUD-ach rola serwera sprowadza się do autoryzacji, walidacji, komunikacji z bazami danych i wymaganej logiki biznesowej. Reszta logiki danych, jak się okazuje, może być łatwo obsłużona przez kod odpowiedzialny za reprezentację aplikacji po stronie UI.
W tym artykule postaram się pokazać kilka przykładów i wzorów, które pomogą nam utrzymać nasze zdrowie. kod wydajny, schludny i szybki.
Zanim zagłębimy się w konkretne przykłady - w tym artykule chciałbym skupić się jedynie na pokazaniu przypadków, które moim zdaniem mogą w zaskakujący sposób wpłynąć na szybkość działania aplikacji. Nie oznacza to jednak, że stosowanie szybszych rozwiązań jest najlepszym wyborem w każdym możliwym przypadku. Poniższe wskazówki należy raczej traktować jako coś, na co warto zwrócić uwagę, gdy nasza aplikacja działa wolno, np. w produktach wymagających renderowania gier lub bardziej zaawansowanych wykresów na płótnie, operacji wideo lub działań, które chcemy jak najszybciej zsynchronizować w czasie rzeczywistym.
Po pierwsze - metody Array.prototype
Dużą część logiki aplikacji opieramy na tablicach - ich mapowaniu, sortowaniu, filtrowaniu, sumowaniu elementów itd. W łatwy, przejrzysty i naturalny sposób korzystamy z ich wbudowanych metod, które po prostu pozwalają nam wykonywać różnego rodzaju obliczenia, grupowania itp. Działają one podobnie w każdej instancji - jako argument przekazujemy funkcję, gdzie w większości przypadków wartość elementu, indeks i tablica są kolejno przesuwane podczas każdej iteracji. Podana funkcja jest wykonywana dla każdego elementu w tablicy, a wynik jest różnie interpretowany w zależności od metody. Nie będę rozwodził się nad metodami Array.prototype, ponieważ chcę skupić się na tym, dlaczego w dużej liczbie przypadków działa ona wolno.
Metody Array są powolne, ponieważ wykonują funkcję dla każdego elementu. Funkcja wywoływana z perspektywy silnika musi przygotować nowe wywołanie, zapewnić odpowiedni zakres i wiele innych zależności, co sprawia, że proces jest o wiele dłuższy niż powtórzenie konkretnego bloku kodu w konkretnym zakresie. I to chyba wystarczająca wiedza podstawowa, która pozwoli nam zrozumieć poniższy przykład:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() }));
console.time('Sum by reduce');
const reduceSum = randomArray
.map(({ wartość }) => wartość)
.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('Suma przez pętlę for');
console.log(reduceSum == forSum);
})();
Wiem, że ten test nie jest tak wiarygodny jak benchmarki (wrócimy do nich później), ale uruchamia lampkę ostrzegawczą. Dla losowego przypadku na moim komputerze okazuje się, że kod z pętlą for może być około 50 razy szybszy, jeśli porównać go do mapowania, a następnie zmniejszania elementów, które osiągają ten sam efekt! Chodzi o operowanie na jakimś dziwnym obiekcie stworzonym tylko po to, by osiągnąć konkretny cel obliczeń. Stwórzmy więc coś bardziej uzasadnionego, aby być obiektywnym co do metod Array:
(() => {
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('Suma przez pętlę for');
console.log(reduceSum == forSum);
})();
Wiem, że ten test nie jest tak wiarygodny jak benchmarki (wrócimy do nich później), ale uruchamia lampkę ostrzegawczą. Dla losowego przypadku na moim komputerze okazuje się, że kod z pętlą for może być około 50 razy szybszy w porównaniu do mapowania, a następnie redukcji elementów, które osiągają ten sam efekt! Dzieje się tak, ponieważ uzyskanie sumy w tym konkretnym przypadku przy użyciu metody reduce wymaga mapowania tablicy dla czystych wartości, które chcemy podsumować. Stwórzmy więc coś bardziej uzasadnionego, aby być obiektywnym co do metod Array:
(() => {
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('Suma przez pętlę for');
console.log(reduceSum == forSum);
})();
I jak się okazuje, nasz boost 50x zmniejszył się do boostu 4x. Przepraszamy, jeśli czujesz się rozczarowany! Aby pozostać obiektywnym do końca, przeanalizujmy jeszcze raz oba kody. Po pierwsze - niewinnie wyglądające różnice podwoiły spadek naszej teoretycznej złożoności obliczeniowej; zamiast najpierw mapować, a potem sumować czyste elementy, wciąż operujemy na obiektach i konkretnym polu, by na końcu wyciągnąć interesującą nas sumę. Problem pojawia się, gdy na kod spojrzy inny programista - wtedy, w porównaniu do kodów pokazanych wcześniej, ten drugi w pewnym momencie traci na abstrakcyjności.
Dzieje się tak dlatego, że od drugiej operacji operujemy na dziwnym obiekcie, z interesującym nas polem i drugim, standardowym obiektem iterowanej tablicy. Nie wiem co Ty o tym myślisz, ale z mojej perspektywy, w drugim przykładzie kodu, logika pętli for jest o wiele bardziej przejrzysta i intencjonalna niż ta dziwnie wyglądająca redukcja. I choć nie jest to już mityczne 50, to wciąż jest to 4 razy szybciej, jeśli chodzi o czas obliczeń! Ponieważ każda milisekunda jest cenna, wybór w tym przypadku jest prosty.
Najbardziej zaskakujący przykład
Druga rzecz, którą chciałem porównać dotyczy metody Math.max, a dokładniej wypchania miliona elementów i wyciągnięcia z nich największego i najmniejszego. Przygotowałem kod, metody pomiaru czasu również, po czym odpalam kod i dostaję bardzo dziwny błąd - przekroczono rozmiar stosu. Oto kod:
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5);
console.time('Math.max z operatorem rozrzutu ES6');
const maxBySpread = Math.max(...randomValues);
console.timeEnd('Math.max z operatorem rozrzutu ES6');
console.time('Math.max z pętlą for');
let maxByFor = randomValues[0];
for (let index = 1; index maxByFor) {
maxByFor = randomValues[index];
}
}
console.timeEnd('Math.max z pętlą for');
console.log(maxByFor == maxBySpread);
})();
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5);
console.time('Math.max z operatorem rozrzutu ES6');
const maxBySpread = Math.max(...randomValues);
console.timeEnd('Math.max z operatorem rozrzutu ES6');
console.time('Math.max z pętlą for');
let maxByFor = randomValues[0];
for (let index = 1; index maxByFor) {
maxByFor = randomValues[index];
}
}
console.timeEnd('Math.max z pętlą for');
console.log(maxByFor == maxBySpread);
})();
Okazuje się, że metody natywne korzystają z rekurencji, która w v8 jest ograniczona przez stosy wywołań, a jej liczba jest zależna od środowiska. Jest to coś, co bardzo mnie zaskoczyło, ale niesie za sobą wniosek: metoda natywna jest szybsza, o ile nasza tablica nie przekracza pewnej magicznej liczby elementów, którą w moim przypadku okazało się 125375. Dla tej liczby elementów wynik for był 5x szybszy w porównaniu do pętli. Jednak powyżej wspomnianej liczby elementów pętla for zdecydowanie wygrywa - w przeciwieństwie do przeciwnika pozwala nam uzyskać poprawne wyniki.
Rekursja
Pojęciem, o którym chcę wspomnieć w tym akapicie, jest rekurencja. W poprzednim przykładzie widzieliśmy ją w metodzie Math.max i składaniu argumentów, gdzie okazało się, że niemożliwe jest uzyskanie wyniku dla wywołań rekurencyjnych przekraczających określoną liczbę ze względu na ograniczenie rozmiaru stosu.
Zobaczymy teraz, jak wygląda rekurencja w kontekście kodu napisanego w JS, a nie za pomocą wbudowanych metod. Być może najbardziej klasyczną rzeczą, jaką możemy tutaj pokazać, jest oczywiście znalezienie n-tego wyrazu w ciągu Fibonacciego. Napiszmy więc to!
(() => {
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('Ciąg Fibonacciego przez pętlę for');
const resultIterative = fiboIterative(30);
console.timeEnd('Ciąg Fibonacciego przez pętlę for');
console.time('Ciąg Fibonacciego przez rekurencję');
const resultRecursive = fiboRecursive(30);
console.timeEnd('Ciąg Fibonacciego przez rekurencję');
console.log(resultRecursive == resultIterative);
})();
Okej, w tym konkretnym przypadku obliczania 30. elementu sekwencji na moim komputerze, otrzymujemy wynik w około 200 razy krótszym czasie za pomocą algorytmu iteracyjnego.
Jest jednak jedna rzecz, którą można poprawić w algorytmie rekurencyjnym - jak się okazuje, działa on znacznie wydajniej, gdy zastosujemy taktykę zwaną tail recursion. Oznacza to, że przekazujemy wynik, który uzyskaliśmy w poprzedniej iteracji, używając argumentów do głębszych wywołań. Pozwala nam to zmniejszyć liczbę wymaganych wywołań, a w rezultacie przyspieszyć wynik. Poprawmy odpowiednio nasz kod!
(() => {
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('Ciąg Fibonacciego przez pętlę for');
const resultIterative = fiboIterative(30);
console.timeEnd('Ciąg Fibonacciego przez pętlę for');
console.time('Ciąg Fibonacciego przez rekurencję ogona');
const resultRecursive = fiboTailRecursive(30);
console.timeEnd('Ciąg Fibonacciego przez rekurencję ogona');
console.log(resultRecursive == resultIterative);
})();
Stało się coś, czego się nie spodziewałem - wynik algorytmu rekurencji ogonowej był w stanie dostarczyć wynik (obliczenie 30. elementu sekwencji) prawie dwa razy szybciej niż algorytm iteracyjny w niektórych przypadkach. Nie jestem do końca pewien, czy jest to spowodowane optymalizacją rekurencji ogonowej ze strony v8, czy brakiem optymalizacji pętli for dla tej konkretnej liczby iteracji, ale wynik jest jednoznaczny - wygrywa rekurencja ogonowa.
Jest to dziwne, ponieważ zasadniczo pętla for nakłada znacznie mniej abstrakcji na działania obliczeniowe niższego poziomu i można powiedzieć, że jest bliższa podstawowej operacji komputerowej. Jednak wyniki są niezaprzeczalne - sprytnie zaprojektowana rekurencja okazuje się szybsza niż iteracja.
Używaj asynchronicznych instrukcji wywołania tak często, jak to możliwe
Ostatni akapit chciałbym poświęcić na krótkie przypomnienie metody wykonywania operacji, która również może znacząco wpłynąć na szybkość działania naszej aplikacji. Jak powinieneś wiedzieć, JavaScript to jednowątkowy język, który utrzymuje wszystkie operacje za pomocą mechanizmu pętli zdarzeń. Chodzi o cykl, który działa w kółko, a wszystkie kroki w tym cyklu dotyczą dedykowanych określonych działań.
Aby pętla była szybka i aby wszystkie cykle czekały krócej na swoją kolej, wszystkie elementy powinny być tak szybkie, jak to tylko możliwe. Unikaj wykonywania długich operacji na głównym wątku - jeśli coś trwa zbyt długo, spróbuj przenieść te obliczenia do WebWorkera lub podziel je na części, które działają asynchronicznie. Może to spowolnić niektóre operacje, ale poprawi cały ekosystem JS, w tym operacje IO, takie jak obsługa ruchu myszy lub oczekujące żądanie HTTP.
Podsumowanie
Podsumowując, jak wspomniano wcześniej, pogoń za milisekundami, które można zaoszczędzić wybierając algorytm, może w niektórych przypadkach okazać się bezsensowna. Z drugiej strony, zaniedbanie takich rzeczy w aplikacjach wymagających płynnego działania i szybkich wyników może być zabójcze dla aplikacji. W niektórych przypadkach, poza szybkością działania algorytmu, warto zadać sobie jeszcze jedno pytanie - czy abstrakcja jest obsługiwana na odpowiednim poziomie? Czy programista czytający kod będzie w stanie bez problemu go zrozumieć?
Jedynym sposobem jest zapewnienie równowagi między wydajnością, łatwością implementacji i odpowiednią abstrakcją oraz pewność, że algorytm działa poprawnie zarówno dla małych, jak i dużych ilości danych. Sposób na to jest dość prosty - bądź sprytny, rozważ różne przypadki podczas projektowania algorytmu i zaaranżuj go tak, aby zachowywał się tak wydajnie, jak to możliwe dla średnich wykonań. Wskazane jest również zaprojektowanie testów - upewnij się, że algorytm zwraca odpowiednie informacje dla różnych danych, niezależnie od tego, jak działa. Zadbaj o odpowiednie interfejsy - tak, aby zarówno dane wejściowe, jak i wyjściowe metod były czytelne, przejrzyste i dokładnie odzwierciedlały to, co robią.
Wspomniałem wcześniej, że wrócę do wiarygodności pomiaru szybkości algorytmów w powyższych przykładach. Mierzenie ich za pomocą console.time nie jest zbyt miarodajne, ale najlepiej oddaje standardowy przypadek użycia. W każdym razie poniżej przedstawiam benchmarki - niektóre z nich wyglądają nieco inaczej niż pojedyncze wykonanie ze względu na fakt, że benchmarki po prostu powtarzają daną czynność w określonym czasie i wykorzystują optymalizację v8 dla pętli.
https://jsben.ch/KhAqb - reduce vs for loop
https://jsben.ch/F4kLY - zoptymalizowane redukowanie vs pętla for
https://jsben.ch/MCr6g - Math.max vs pętla for
https://jsben.ch/A0CJB - fibo rekurencyjne vs fibo iteracyjne