Zmniejsz ładunki JavaScript za pomocą potrząsania drzewem

Współczesne aplikacje internetowe mogą być dość duże, zwłaszcza te związane z JavaScriptem. Od połowy 2018 roku mediana rozmiaru przesyłanych plików JavaScript na urządzeniach mobilnych wynosi około 350 KB. To jest tylko rozmiar transferu. Kod JavaScript przesyłany przez sieć jest często kompresowany, co oznacza, że po dekompresji kodu JavaScript jego rzeczywista ilość jest znacznie większa. Na to trzeba zwrócić uwagę, ponieważ w przypadku przetwarzania zasobów kompresja nie ma znaczenia. 900 KB zdekompresowanego kodu JavaScript wciąż zawiera 900 KB dla parsera i kompilatora, chociaż po skompresowaniu może zajmować około 300 KB.

Diagram przedstawiający proces pobierania, dekompresji, analizowania, kompilowania i wykonywania JavaScriptu.
Proces pobierania i uruchamiania kodu JavaScript. Pamiętaj, że chociaż rozmiar transferu skryptu to 300 KB skompresowany, nadal jest to plik JavaScript o wielkości 900 KB, który należy przeanalizować, skompilować i wykonać.

Przetwarzanie JavaScriptu jest kosztowne. W przeciwieństwie do obrazów, które po pobraniu wymagają bardzo prostego dekodowania, JavaScript musi być analizowany, skompilowany, a następnie wykonany. To sprawia, że JavaScript jest droższy niż inne rodzaje zasobów.

Diagram porównujący czas przetwarzania 170 KB JavaScriptu z czasem przetwarzania obrazu JPEG o porównywalnym rozmiarze. Zasób JavaScriptu zajmuje dużo więcej bajtów niż JPEG niż plik JPEG.
Koszt przetwarzania analizy/kompilacji 170 KB JavaScriptu w porównaniu z czasem dekodowania pliku JPEG o równoważnym rozmiarze. (źródło).

Choć nieustannie wprowadzamy ulepszenia, aby poprawić wydajność silników JavaScript, poprawa wydajności JavaScriptu jest jak zawsze zadaniem dla deweloperów.

Istnieją techniki poprawiające wydajność JavaScriptu. Dzielenie kodu to jedna z technik, która zwiększa wydajność przez partycjonowanie kodu JavaScript aplikacji na fragmenty i wyświetlanie ich tylko tym trasom aplikacji, które ich potrzebują.

Ta technika działa, ale nie rozwiązuje problemu występującego w aplikacjach z dużą ilością kodu JavaScript, takich jak uwzględnianie rzadko wykorzystywanego kodu. Potrząsanie drzew to próba rozwiązania tego problemu.

Co to jest trzęsienie drzew?

Trzęsienie drzewem to forma eliminacji martwego kodu. Termin został spopularyzowany przez zespół Rollup, ale już od jakiegoś czasu istnieje koncepcja usuwania martwego kodu. Pomysł ten został również zauważony w pakiecie webpack, co pokazano w tym artykule przy użyciu przykładowej aplikacji.

Termin „trzęsienie drzew” pochodzi z mentalnego modelu aplikacji i jej zależności w postaci struktury drzewa. Każdy węzeł w drzewie reprezentuje zależność zapewniającą odrębną funkcjonalność aplikacji. W nowoczesnych aplikacjach zależności te są wprowadzane za pomocą statycznych instrukcji import, na przykład:

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

Młoda aplikacja – w razie potrzeby – może mieć niewielką liczbę zależności. Dodatkowo wykorzystuje większość, a może nawet wszystkie, zależności. Jednak w miarę rozwoju aplikacji może pojawiać się coraz więcej zależności. Aby połączyć sprawy, starsze zależności przestają być używane, ale mogą nie zostać usunięte z bazy kodu. W efekcie aplikacja otrzymuje dużo nieużywanego kodu JavaScript. Drżenie drzew radzi sobie z tym problemem, wykorzystując sposób, w jaki statyczne instrukcje 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" – a może to być dużo kodu – pozwala zaimportować tylko określone jego części. W wersjach deweloperskich niczego nie zmienia, bo cały moduł jest importowany. W kompilacjach produkcyjnych pakiet internetowy można skonfigurować w taki sposób, aby pomijał eksporty z modułów ES6, które nie zostały bezpośrednio zaimportowane. Dzięki temu kompilacje produkcyjne są mniejsze. Z tego przewodnika dowiesz się, jak to zrobić.

Znalezienie możliwości potrząsania drzewem

W celach ilustracyjnych dostępna jest przykładowa jednostronicowa aplikacja pokazująca, jak działa trzęsienie drzew. Możesz go skopiować i postępować zgodnie z instrukcjami, ale w tym przewodniku omówimy każdy krok tego procesu, więc klonowanie nie jest konieczne (chyba że zależy Ci na nauce przez praktykę).

Przykładowa aplikacja to dostępna do przeszukiwania baza danych pedałów gitarowych. Gdy wpiszesz zapytanie, pojawi się lista pedałów efektów.

Zrzut ekranu z przykładową jednostronicową aplikacją do przeszukiwania bazy danych pedałów gitarowych.
Zrzut ekranu przedstawiający przykładową aplikację.

Działanie aplikacji jest podzielone na dostawców (tzn. Preact i Emotion) oraz pakiety kodu dla konkretnych aplikacji (czyli „fragmenty” odpowiednio do Webpack):

Zrzut ekranu przedstawiający 2 pakiety (lub fragmenty) kodu aplikacji widoczny w panelu sieci w Narzędziach deweloperskich w Chrome.
Dwa pakiety JavaScriptu aplikacji. To są rozmiary nieskompresowane.

Pakiety JavaScript widoczne na grafice powyżej są kompilacjami produkcyjnymi, co oznacza, że są optymalizowane przez uglis. 21,1 KB w przypadku pakietu aplikacji nie jest zły, ale należy pamiętać, że nie występuje trzęsienie drzew. Przyjrzyjmy się kodowi aplikacji i sprawdźmy, jak można rozwiązać ten problem.

W każdej aplikacji znalezienie możliwości związanych z trzęśnięciem drzew będzie wymagało znalezienia statycznych instrukcji import. U góry głównego pliku komponentu zobaczysz wiersz podobny do tego:

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

Moduły ES6 możesz importować na różne sposoby, ale takie jak ten powinny Cię zainteresować. Taki wiersz zawiera informacje „import wszystko z modułu utils i umieszczenie go w przestrzeni nazw utils. Kluczowe pytanie, jakie należy zadać, to „ile rzeczy jest w tym module?”.

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

Czy potrzebujesz tego wszystkiego? Sprawdźmy to, wyszukując główny plik komponentu, który importuje moduł utils, i zobaczmy, ile wystąpień tej przestrzeni nazw się pojawia.

Zrzut ekranu z wyszukiwaniem w edytorze tekstu „utils.”, które zwraca tylko 3 wyniki.
Przestrzeń nazw utils, z której zaimportowaliśmy tony modułów, jest wywoływana tylko 3 razy w głównym pliku komponentu.

Okazuje się, że przestrzeń nazw utils pojawia się w naszej aplikacji tylko w 3 miejscach – ale w przypadku których funkcji? Jeśli ponownie przyjrzysz się głównemu plikowi komponentu, wydaje się, że jest to tylko jedna funkcja, czyli utils.simpleSort, która służy do sortowania listy wyników wyszukiwania według określonej liczby kryteriów w przypadku zmiany menu 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,w którym znajduje się wiele eksportów, używany jest tylko 1 z nich. W rezultacie otrzymuje dużo nieużywanego kodu JavaScript.

Choć ta przykładowa aplikacja ma wprawdzie trochę szczęścia, nie zmienia to faktu, że ten syntetyczny scenariusz przypomina rzeczywiste możliwości optymalizacji, jakie można napotkać w produkcyjnej aplikacji internetowej. Jak to się dzieje, gdy już wiesz, że trzęsienie drzew jest przydatne?

Uniemożliwienie transpilacji modułów ES6 na moduły CommonJS

Babel to niezbędne narzędzie, ale może znacznie utrudniać dostrzeganie efektów trzęsienia drzew. Jeśli używasz @babel/preset-env, Babel może przekształcić moduły ES6 w bardziej zgodne moduły CommonJS, czyli require zamiast import.

W przypadku modułów CommonJS drżenie drzew jest trudniejsze do wykonania, dlatego pakiet internetowy nie będzie wiedział, co należy wyciąć z pakietów, jeśli się na to zdecydujesz. Rozwiązaniem jest skonfigurowanie usługi @babel/preset-env w taki sposób, aby jawnie pozostawić moduły ES6 bez zmian. Niezależnie od tego, czy konfigurujesz Babel, np. w babel.config.js czy package.json, musisz dodać kilka dodatkowych elementów:

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

Jeśli określisz modules: false w konfiguracji @babel/preset-env, Babel działa zgodnie z oczekiwaniami, co pozwala pakietowi internetowemu analizować drzewo zależności i potrząsać nieużywanymi zależnościami.

Pamiętaj o skutkach ubocznych

Kolejnym aspektem, który warto wziąć pod uwagę przy potrząsaniu zależnościami z aplikacją, jest to, czy moduły projektu mają skutki uboczne. Przykładem efektu ubocznego jest zmiana czegoś spoza swojego zakresu przez funkcję, co stanowi skutek uboczny 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, gdy funkcja addFruit modyfikuje tablicę fruits, która wykracza poza jej zakres, skutkuje to efektem ubocznym.

Skutki uboczne mają również zastosowanie do modułów ES6 i mają znaczenie w kontekście trzęsienia drzew. Moduły, które przyjmują przewidywalne dane wejściowe i generują równie przewidywalne dane wyjściowe bez modyfikowania jakichkolwiek elementów spoza swojego zakresu, to zależności, które można bezpiecznie usunąć, jeśli ich nie używamy. Są to niezależne, modułowe fragmenty kodu. czyli „moduły”.

W przypadku pakietu internetowego można użyć podpowiedź, aby wskazać, że pakiet i jego zależności są wolne od skutków ubocznych. W tym celu należy określić "sideEffects": false w pliku package.json projektu:

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

Możesz też określić, 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 każdy plik, który nie został określony, zostanie uznany za wolny od skutków ubocznych. Jeśli nie chcesz dodawać tej flagi do pliku package.json, możesz też określić tę flagę w konfiguracji pakietu internetowego za pomocą metody module.rules.

Importowanie tylko potrzebnych danych

Gdy zalecisz Babel pozostawienie modułów ES6 bez zmian, wymagana jest drobna zmiana w składni import, by uzyskać dostęp tylko do funkcji potrzebnych z modułu utils. W tym przykładzie potrzebujesz tylko funkcji simpleSort:

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

Ponieważ importowany jest tylko moduł simpleSort, a nie cały moduł utils, każde wystąpienie modułu utils.simpleSort musi zostać zmienione 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 wystarczy, aby potrząsanie drzewem zadziałało w tym przykładzie. Oto dane wyjściowe pakietu internetowego przed potrząśnię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 wynik po pomyślnym wstrząśnięciu drzewem:

                 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

Choć oba pakiety są ograniczone, najbardziej przydatny jest pakiet main. Po potrząśnięciu nieużywanymi częściami modułu utils pakiet main zmniejsza się o około 60%. Skraca to nie tylko czas pobierania skryptu, ale także czas przetwarzania.

Potrząśnij drzewami!

Niezależnie od tego, jaki dystans osiągniesz w trakcie trzęsienia drzew, zależy od Twojej aplikacji oraz jej zależności i architektury. Wypróbuj Jeśli wiesz, że usługa tworząca pakiety modułów nie została skonfigurowana do przeprowadzenia tej optymalizacji, możesz sprawdzić, jakie korzyści przyniesie to Twojej aplikacji.

Możesz uzyskać znaczący wzrost skuteczności, potrząsająca drzewem, lub niewiele. Jeśli jednak skonfigurujesz system kompilacji pod kątem wykorzystania tej optymalizacji w kompilacjach produkcyjnych i selektywnie importujesz tylko te elementy, których potrzebujesz, możesz aktywnie ograniczyć liczbę pakietów aplikacji.

Szczególne podziękowania należą się Kristoferowi Baxterowi, Jasonowi Millerowi, Addy Osmani, Jeff Posnick, Samowi Saccone i Philipowi Waltonowi za ich cenne opinie, które znacząco podniosły jakość tego artykułu.