Zmniejsz ładunki JavaScript za pomocą potrząsania drzewem

Dzisiejsze aplikacje internetowe mogą być dość duże, zwłaszcza ich część napisana w języku JavaScript. W połowie 2018 r. średni rozmiar przesyłania kodu JavaScript na urządzeniach mobilnych wynosił około 350 KB. A to tylko rozmiar przesyłania. Kod JavaScript jest często kompresowany podczas wysyłania przez sieć, co oznacza, że rzeczywista objętość kodu JavaScript jest nieco większa po dekompresji przez przeglądarkę. Warto o tym wspomnieć, ponieważ kompresja nie ma znaczenia, jeśli chodzi o przetwarzanie zasobów. 900 KB nieskompresowanego kodu JavaScript to nadal 900 KB dla parsowania i kompilowania, nawet jeśli po skompresowaniu ma on rozmiar około 300 KB.

Diagram ilustrujący proces pobierania, rozpakowywania, analizowania, kompilowania i wykonywania kodu JavaScript.
Proces pobierania i uruchamiania kodu JavaScript. Pamiętaj, że chociaż rozmiar skompresowanego skryptu to 300 KB, to kod JavaScript ma rozmiar 900 KB i musi zostać przeanalizowany, skompilowany i wykonany.

Przetwarzanie kodu JavaScript jest kosztowne. W przeciwieństwie do obrazów, które po pobraniu wymagają tylko stosunkowo krótkiego czasu dekodowania, kod JavaScript musi zostać przeanalizowany, skompilowany, a na końcu wykonany. W ujęciu bajtowym sprawia to, że JavaScript jest droższy niż inne typy zasobów.

Diagram porównujący czas przetwarzania 170 KB kodu JavaScriptu z odpowiednio dużym obrazem JPEG. Zasób JavaScriptu zużywa znacznie więcej zasobów niż JPEG.
Koszt przetwarzania polegający na parsowaniu/kompilowaniu 170 KB kodu JavaScript w porównaniu z czasem dekodowania pliku JPEG o odpowiedniej wielkości. (źródło).

Chociaż ciągle wprowadzamy ulepszenia, aby zwiększać wydajność silników JavaScriptu, poprawa wydajności JavaScriptu to – jak zawsze – zadanie dla programistów.

W tym celu istnieją techniki poprawiające wydajność JavaScriptu. Podział kodu to jedna z takich technik, która poprawia wydajność przez podział kodu JavaScript aplikacji na fragmenty i przekazywanie tych fragmentów tylko do tych ścieżek aplikacji, które ich potrzebują.

Ta metoda działa, ale nie rozwiązuje typowego problemu aplikacji obciążonych JavaScriptem, jakim jest dołączanie kodu, którego nigdy nie używa się. W takich przypadkach próbuje się rozwiązać problem przez potrząsanie drzewem.

Co to jest potrząsanie drzewem?

Tree shaking to forma eliminacji martwego kodu. Termin został spopularyzowany przez Rollup, ale koncepcja usuwania martwego kodu istnieje już od jakiegoś czasu. Ta koncepcja została również zastosowana w webpack, co w tym artykule zostało zilustrowane na przykładzie przykładowej aplikacji.

Termin „tree shaking” pochodzi z modelu mentalnego aplikacji i jej zależności w postaci struktury drzewa. Każdy węzeł w drzewie reprezentuje zależność, która zapewnia aplikacjom określone funkcje. W nowoczesnych aplikacjach te zależności są wprowadzane za pomocą instrukcji static import, takich jak:

// Import all the array utilities!
import arrayUtils from "array-utils";

Gdy aplikacja jest młoda – jak sadzonka – może mieć niewiele zależności. Korzysta też z większości (jeśli nie wszystkich) dodanych zależności. W miarę rozwoju aplikacji możesz jednak dodawać więcej zależności. Co więcej, starsze zależności nie są już używane, ale mogą nie zostać usunięte z Twojego kodu. W efekcie aplikacja zawiera dużo nieużywanego kodu JavaScript. Problem ten rozwiązuje usuwanie elementów drzewa, które korzysta z tego, jak instrukcje statyczne import pobierają określone części modułów ES6:

// Import only some of the utilities!
import { unique, implode, explode } from "array-utils";

Różnica między tym przykładem import a poprzednim polega na tym, że zamiast importować wszystko z modułu "array-utils" (co mogłoby zająć dużo miejsca w kodzie), ten przykład importuje tylko określone jego części. W wersjach deweloperskich nie ma to znaczenia, ponieważ cały moduł jest importowany. W wersjach produkcyjnych webpacka można skonfigurować tak, aby „wyrzucał” eksportowane moduły ES6, które nie zostały zaimportowane wprost, co powoduje zmniejszenie rozmiaru wersji produkcyjnych. Z tego przewodnika dowiesz się, jak to zrobić.

Znajdowanie możliwości potrząsania drzewem

W celach poglądowych udostępniliśmy przykładową aplikację jednostronicową, która pokazuje, jak działa potrząsanie drzewa. Możesz go sklonować i podążać za instrukcjami, ale w tym przewodniku omówimy każdy krok, więc klonowanie nie jest konieczne (chyba że wolisz uczyć się na własnych błędach).

Przykładowa aplikacja to wyszukiwalna baza danych pedałów efektów gitarowych. Po wpisaniu zapytania pojawi się lista efektów.

Zrzut ekranu pokazujący przykładową aplikację jednostronicową do wyszukiwania w bazie danych pedałów efektów gitarowych
Zrzut ekranu przykładowej aplikacji.

Zachowanie, które napędza tę aplikację, jest podzielone na dostawcę (np. PreactEmotion) oraz pakietów kodu związanych z poszczególnymi aplikacjami (czyli „fragmentów”, jak nazywa je webpack):

Zrzut ekranu przedstawiający 2 pakiety kodu aplikacji (lub fragmenty) w panelu sieci w Narzędziach deweloperskich w Chrome
Dwa pakiety JavaScripta aplikacji. Są to rozmiary nieskompresowane.

Pakiety JavaScriptu pokazane na rysunku powyżej to wersje produkcyjne, co oznacza, że zostały zoptymalizowane za pomocą uglifikacji. 21,1 KB w przypadku pakietu konkretnej aplikacji to niezły wynik, ale należy pamiętać, że w tym przypadku nie ma wcale redukcji rozmiaru drzewa. Sprawdźmy kod aplikacji i zobaczmy, co możemy zrobić, aby rozwiązać ten problem.

W każdej aplikacji znalezienie możliwości zastosowania funkcji tree shaking wymaga znalezienia statycznych instrukcji import. U góry pliku głównego komponentu zobaczysz wiersz podobny do tego:

import * as utils from "../../utils/utils";

Moduły ES6 można importować na różne sposoby, ale te powinny zwrócić Twoją uwagę. Ten wiersz mówi: „import wszystko z modułu utils i umieścić je w przestrzeni nazw o nazwie utils”. Pytanie, które należy tu zadać, brzmi: „Ile rzeczy jest w tym module?”

Jeśli spojrzysz na kod źródłowy modułu utils, zobaczysz,że ma on około 1300 wierszy kodu.

Czy potrzebujesz wszystkich tych rzeczy? Sprawdźmy to jeszcze raz, przeszukując plik głównego komponentu, który importuje moduł utils, aby sprawdzić, ile wystąpień tej przestrzeni nazw się pojawia.

Zrzut ekranu wyszukiwania w edytorze tekstu dotyczącego „utils.“, które zwraca tylko 3 wyniki.
Przestrzeń nazw utils, z której zaimportowano mnóstwo modułów, jest wywoływana tylko 3 razy w pliku głównego komponentu.

Okazuje się, że przestrzeń nazw utils występuje tylko w 3 miejscach w naszej aplikacji, ale do jakich funkcji? Jeśli jeszcze raz spojrzysz na plik głównego komponentu, zobaczysz, że zawiera on tylko jedną funkcję, utils.simpleSort, która służy do sortowania listy wyników wyszukiwania według kilku kryteriów, gdy zmieniają się opcje sortowania:

if (this.state.sortBy === "model") {
  // `simpleSort` gets used here...
  json = utils.simpleSort(json, "model", this.state.sortOrder);
} else if (this.state.sortBy === "type") {
  // ..and here...
  json = utils.simpleSort(json, "type", this.state.sortOrder);
} else {
  // ..and here.
  json = utils.simpleSort(json, "manufacturer", this.state.sortOrder);
}

Z pliku zawierającego 1300 wierszy z wiele eksportów używany jest tylko jeden. W efekcie wysyłane jest dużo niewykorzystanego kodu JavaScript.

Chociaż ta przykładowa aplikacja jest nieco sztuczna, nie zmienia to faktu, że ten syntetyczny scenariusz przypomina rzeczywiste możliwości optymalizacji, które możesz napotkać w produkcyjnej aplikacji internetowej. Teraz, gdy już wiesz, kiedy warto użyć potrząsania drzewem, jak to się robi?

zapobieganie transpilowaniu modułów ES6 na moduły CommonJS,

Babel to niezastąpione narzędzie, ale może utrudniać obserwowanie efektów potrząsania drzewem. Jeśli używasz @babel/preset-env, Babel może przekształcić moduły ES6 w powszechniejsze moduły CommonJS, czyli takie, które require zamiast import.

W przypadku modułów CommonJS trudniej jest usuwać elementy z drzewa, więc webpack nie będzie wiedzieć, co usunąć z pakietów, jeśli zdecydujesz się ich użyć. Rozwiązaniem jest skonfigurowanie @babel/preset-env tak, aby nie modyfikował on modułów ES6. Niezależnie od tego, czy konfigurujesz Babel w babel.config.js, czy package.json, musisz dodać kilka dodatkowych elementów:

// babel.config.js
export default {
  presets: [
    [
      "@babel/preset-env", {
        modules: false
      }
    ]
  ]
}

Podanie wartości modules: false w konfiguracji @babel/preset-env powoduje, że Babel działa zgodnie z oczekiwaniami, co pozwala webpackowi analizować drzewo zależności i usuwać nieużywane zależności.

Skutki uboczne

Innym aspektem, który należy wziąć pod uwagę, pozbywając się zależności z aplikacji, jest to, czy moduły projektu mają skutki uboczne. Przykładem efektu ubocznego jest sytuacja, gdy funkcja zmienia coś poza swoim zakresem, co jest efektem ubocznym jej wykonania:

let fruits = ["apple", "orange", "pear"];

console.log(fruits); // (3) ["apple", "orange", "pear"]

const addFruit = function(fruit) {
  fruits.push(fruit);
};

addFruit("kiwi");

console.log(fruits); // (4) ["apple", "orange", "pear", "kiwi"]

W tym przykładzie funkcja addFruit wywołuje efekt uboczny, gdy modyfikuje tablicę fruits, która wykracza poza jej zakres.

Skutki uboczne dotyczą też modułów ES6, co ma znaczenie w kontekście usuwania gałęzi. Moduł, który przyjmuje przewidywalne dane wejściowe i generuje równie przewidywalne dane wyjściowe bez modyfikowania niczego poza swoim zakresem, to zależność, którą można bezpiecznie usunąć, jeśli nie jest używana. Są to samodzielne, modułowe fragmenty kodu. Stąd „moduły”.

W przypadku webpacka można użyć wskazówki, aby określić, że pakiet i jego zależności nie mają efektów ubocznych. W tym celu w pliku package.json projektu należy podać wartość "sideEffects": false:

{
  "name": "webpack-tree-shaking-example",
  "version": "1.0.0",
  "sideEffects": false
}

Możesz też poinformować webpack, które pliki nie są wolne od efektów ubocznych:

{
  "name": "webpack-tree-shaking-example",
  "version": "1.0.0",
  "sideEffects": [
    "./src/utils/utils.js"
  ]
}

W tym drugim przykładzie zakłada się, że każdy plik, który nie został określony, nie ma efektów ubocznych. Jeśli nie chcesz dodawać tej flagi do pliku package.json, możesz ją też określić w konfiguracji webpacka za pomocą parametru module.rules.

Importowanie tylko potrzebnych danych

Po przekazaniu instrukcji Babel, aby nie modyfikować modułów ES6, musisz wprowadzić drobną zmianę w składni import, aby zaimportować tylko potrzebne funkcje z modułu utils. W przykładzie w tym przewodniku wystarczy użyć funkcji simpleSort:

import { simpleSort } from "../../utils/utils";

Ponieważ importowany jest tylko moduł simpleSort, a nie cały moduł utils, każdą instancję utils.simpleSort trzeba zmienić na simpleSort:

if (this.state.sortBy === "model") {
  json = simpleSort(json, "model", this.state.sortOrder);
} else if (this.state.sortBy === "type") {
  json = simpleSort(json, "type", this.state.sortOrder);
} else {
  json = simpleSort(json, "manufacturer", this.state.sortOrder);
}

To powinno wystarczyć, aby w tym przykładzie działało potrząsanie drzewa. Oto dane wyjściowe webpack przed wytrząsnięciem drzewa zależności:

                 Asset      Size  Chunks             Chunk Names
js/vendors.16262743.js  37.1 KiB       0  [emitted]  vendors
   js/main.797ebb8b.js  20.8 KiB       1  [emitted]  main

Oto dane wyjściowe po pomyślnym zrzuceniu drzewa:

                 Asset      Size  Chunks             Chunk Names
js/vendors.45ce9b64.js  36.9 KiB       0  [emitted]  vendors
   js/main.559652be.js  8.46 KiB       1  [emitted]  main

Oba pakiety uległy zmniejszeniu, ale najwięcej zyskał pakiet main. Dzięki usunięciu nieużywanych części modułu utils pakiet main zmniejsza się o około 60%. Dzięki temu nie tylko skrócisz czas pobierania skryptu, ale też czas przetwarzania.

Idź potrząść drzewami.

To, jak bardzo pomoże Ci wyrywanie gałęzi, zależy od aplikacji, jej zależności i architektury. Wypróbuj Jeśli wiesz na pewno, że nie skonfigurowano modułu pakietującego w celu wykonania tej optymalizacji, możesz spróbować i sprawdzić, jak to wpłynie na Twoją aplikację.

Możesz zauważyć znaczny wzrost wydajności dzięki wstrząsaniu drzewem, ale może się też okazać, że nie przyniesie to żadnych efektów. Jednak skonfigurowanie systemu kompilacji w celu korzystania z tej optymalizacji w kompletacjach wersji produkcyjnych i selektywnego importowania tylko tego, czego potrzebuje aplikacja, pozwoli Ci aktywnie utrzymywać rozmiar pakietów aplikacji na jak najniższym poziomie.

Specjalne podziękowania dla Kristofera Baxtera, Jasona Millera, Addy Osmani, Jeffa Posnicka, Sama Saccone i Philipa Waltona za cenne opinie, które znacznie poprawiły jakość tego artykułu.