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. Podczas przesyłania przez sieć kod JavaScript jest często kompresowany, 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 ono 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 odróżnieniu od obrazów, które po pobraniu wymagają stosunkowo prostego dekodowania, JavaScript musi zostać przeanalizowany, skompilowany i na koniec 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, które mają na celu poprawę wydajności silników JavaScriptu, poprawa jego wydajności jest jak zawsze zadaniem 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, którym jest dołączanie kodu, którego nigdy nie używa się. Potrząśnięcie drzewami to próba rozwiązania tego problemu.

Co to jest potrząsanie drzewem?

Tęczenie drzewem to forma eliminowania martwych kodów. Termin ten został spopularyzowany przez firmę Rollup, ale koncepcja usuwania martwych kodów istnieje już od jakiegoś czasu. Ta koncepcja została również zastosowana w webpacku, 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 (lub nie wszystkich) dodanych przez Ciebie zależności. W miarę rozwoju aplikacji możesz jednak dodawać więcej zależności. Podczas łączenia spraw starsze zależności przestają być używane, ale mogą nie zostać usunięte z bazy 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 w tym przykładzie zamiast importować wszystko z modułu "array-utils" – co może być bardzo długim kodem) – w tym przykładzie są importowane tylko jego określone części. W wersjach deweloperskich nie ma to znaczenia, ponieważ cały moduł jest importowany. W kompilacjach produkcyjnych pakiet internetowy można skonfigurować tak, aby „potrząsać” eksporty z modułów ES6, które nie zostały bezpośrednio zaimportowane, przez co te kompilacje produkcyjne są mniejsze. Z tego przewodnika dowiesz się, jak to zrobić.

Szukamy możliwości, żeby potrząsnąć 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ę w praktyce).

Przykładowa aplikacja to baza danych pedałów efektów gitarowych, w której można wyszukiwać informacje. Wpisujesz zapytanie, a pojawi się lista pedałów efektów.

Zrzut ekranu przedstawiający przykładową jednostronicową aplikację do przeszukiwania bazy danych 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 konkretną aplikacją (czyli „fragmentów”, jak nazywa je webpack):

Zrzut ekranu przedstawiający 2 pakiety kodu aplikacji (fragmenty kodu) widoczne 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 nie ma w ogóle wstrząsania 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żesz importować na różne sposoby, ale te podobne do tego powinny Cię zainteresować. 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 tych wszystkich rzeczy? Sprawdźmy to jeszcze raz. Poszukajmy pliku głównego komponentu, który importuje moduł utils, aby sprawdzić, ile wystąpień tej przestrzeni nazw się pojawia.

Zrzut ekranu przedstawiający wyszukiwanie słowa „utils.” w edytorze tekstu, z widocznymi tylko 3 wynikami.
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ę tylko w trzech miejscach w aplikacji – ale w jakich funkcjach? Gdy spojrzysz jeszcze raz na główny plik komponentu, wygląda na to, że jest to tylko jedna funkcja, czyli utils.simpleSort, która służy do sortowania listy wyników wyszukiwania według określonego kryterium przy zmianie 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 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 przez Babela

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.

Ponieważ w przypadku modułów CommonJS trudniej jest potrząsać drzewami, pakiet internetowy nie będzie wiedzieć, co wyciąć z pakietów, jeśli zdecydujesz się ich użyć. Rozwiązaniem jest skonfigurowanie usługi @babel/preset-env tak, aby jawnie pozostawiała same moduły ES6. Niezależnie od tego, gdzie 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

Kolejnym aspektem, który należy wziąć pod uwagę przy wzbudzaniu zależności aplikacji, jest to, czy moduły projektu mają efekty uboczne. Przykładem efektu ubocznego jest sytuacja, gdy funkcja modyfikuje coś poza swoim zasięgiem, 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. Jeśli nie używasz modułów, które przyjmują przewidywalne dane wejściowe i generują równie przewidywalne dane wyjściowe bez modyfikowania niczego poza ich zakresem, są to zależności, które można bezpiecznie usunąć. Są one samodzielnymi, modułowymi fragmentami kodu. Stąd „moduły”.

W przypadku pakietu webpack można zastosować wskazówkę, aby określić, że pakiet i jego zależności nie zawierają efektów ubocznych. W tym celu należy określić właściwość "sideEffects": false w pliku package.json projektu:

{
  "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 zadziałało potrząsanie drzewem. 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. Potrząsanie nieużywanymi częściami modułu utils zmniejsza rozmiar pakietu main o około 60%. Dzięki temu nie tylko skrócisz czas pobierania skryptu, ale też czas przetwarzania.

Idź potrząść drzewami.

Efekt drżenia drzew zależy od aplikacji oraz jej zależności i architektury. Wypróbuj Jeśli wiesz na pewno, że nie skonfigurowałeś modułu pakietującego pod kątem 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.