Współczesne aplikacje internetowe mogą być dość duże, zwłaszcza ich część napisana w JavaScript. Według danych z HTTP Archive z połowy 2018 r. mediana rozmiaru przesyłanych plików JavaScript na urządzeniach mobilnych wynosi około 350 KB. A to tylko rozmiar transferu. Kod JavaScript jest często kompresowany podczas przesyłania przez sieć, co oznacza, że rzeczywista ilość kodu JavaScript jest znacznie większa po jego dekompresji przez przeglądarkę. Warto to podkreślić, ponieważ w przypadku przetwarzania zasobów kompresja nie ma znaczenia. 900 KB zdekompresowanego kodu JavaScript to nadal 900 KB dla parsera i kompilatora, mimo że po skompresowaniu może to być około 300 KB.
Przetwarzanie JavaScriptu jest kosztowne. W przeciwieństwie do obrazów, których dekodowanie po pobraniu zajmuje stosunkowo niewiele czasu, kod JavaScript musi zostać przeanalizowany, skompilowany, a następnie wykonany. Z tego powodu JavaScript jest droższy niż inne typy zasobów.
Stale wprowadzamy ulepszenia, aby zwiększyć wydajność silników JavaScriptu, ale poprawa wydajności JavaScriptu jest – jak zawsze – zadaniem dla programistów.
W tym celu istnieją techniki poprawiające wydajność JavaScriptu. Dzielenie kodu to jedna z technik, która zwiększa wydajność przez dzielenie kodu JavaScript aplikacji na części i dostarczanie ich tylko do tych ścieżek aplikacji, które ich potrzebują.
Chociaż ta metoda działa, nie rozwiązuje powszechnego problemu aplikacji opartych na JavaScript, czyli włączania kodu, który nigdy nie jest używany. Tree shaking próbuje rozwiązać ten problem.
Co to jest tree shaking?
Usuwanie nieużywanego kodu to forma eliminacji martwego kodu. Termin ten został spopularyzowany przez Rollup, ale koncepcja eliminacji martwego kodu istnieje już od jakiegoś czasu. Koncepcja ta znalazła też zastosowanie w webpacku, co zostało pokazane w tym artykule na przykładzie przykładowej aplikacji.
Termin „tree shaking” pochodzi z modelu mentalnego aplikacji i jej zależności jako struktury przypominającej drzewo. Każdy węzeł w drzewie reprezentuje zależność, która zapewnia odrębną funkcjonalność aplikacji. W nowoczesnych aplikacjach te zależności są wprowadzane za pomocą statycznych instrukcji import, takich jak:
// Import all the array utilities!
import arrayUtils from "array-utils";
Gdy aplikacja jest nowa, może mieć niewiele zależności. Korzysta też z większości – jeśli nie ze wszystkich – dodanych przez Ciebie zależności. Wraz z rozwojem aplikacji może się jednak pojawić więcej zależności. Co gorsza, starsze zależności wychodzą z użycia, ale mogą nie zostać usunięte z bazy kodu. W rezultacie aplikacja zawiera dużo nieużywanego kodu JavaScript. Tree shaking rozwiązuje ten problem, 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" (co może oznaczać dużą ilość kodu), ten przykład importuje tylko jego określone części. W wersjach deweloperskich nie zmienia to niczego, ponieważ cały moduł jest importowany bez względu na to. W wersjach produkcyjnych webpacka można skonfigurować tak, aby „odrzucał” eksporty z modułów ES6, które nie zostały jawnie zaimportowane, co zmniejsza rozmiar tych wersji. Z tego przewodnika dowiesz się, jak to zrobić.
Znajdowanie możliwości potrząśnięcia drzewem
Dla celów ilustracyjnych dostępna jest przykładowa aplikacja jednostronicowa, która pokazuje, jak działa eliminacja martwego kodu. Możesz go sklonować i wykonać wszystkie kroki, ale w tym przewodniku omówimy je wszystkie, więc klonowanie nie jest konieczne (chyba że wolisz uczyć się przez praktykę).
Przykładowa aplikacja to baza danych efektów gitarowych, w której można wyszukiwać informacje. Wpisz zapytanie, a wyświetli się lista efektów gitarowych.
Zachowanie, które napędza tę aplikację, jest podzielone na dostawcę (np. Preact i Emotion) oraz pakiety kodu specyficzne dla aplikacji (lub „chunks”, jak nazywa je webpack):
Pakiety JavaScriptu widoczne na powyższym rysunku to wersje produkcyjne, co oznacza, że są zoptymalizowane przez zaciemnianie kodu. 21,1 KB w przypadku pakietu specyficznego dla aplikacji to niezły wynik, ale warto zauważyć, że nie następuje żadne usuwanie nieużywanego kodu. Przyjrzyjmy się kodowi aplikacji i zobaczmy, co można zrobić, aby to naprawić.
W każdej aplikacji wyszukiwanie możliwości usuwania nieużywanego kodu będzie polegać na szukaniu 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 te, które wyglądają tak, jak ten, powinny zwrócić Twoją uwagę. Ten wiersz mówi: „import wszystko z modułu utils i umieść to w przestrzeni nazw o nazwie utils”. Najważniejsze pytanie, jakie należy tu zadać, to: „ile rzeczy znajduje się w tym module?”.
Jeśli przyjrzysz się kodowi źródłowemu modułu, zobaczysz,że ma on około 1300 wierszy.utils
Czy potrzebujesz tych wszystkich rzeczy? Sprawdźmy to, wyszukując w głównym pliku komponentu, który importuje moduł utils, ile razy występuje ta przestrzeń nazw.
utils, z której zaimportowaliśmy mnóstwo modułów, jest wywoływana w głównym pliku komponentu tylko 3 razy.
Okazuje się, że przestrzeń nazw utils występuje w naszej aplikacji tylko w 3 miejscach. Do czego służy? Jeśli ponownie przyjrzysz się głównemu plikowi komponentu, zobaczysz, że zawiera on tylko jedną funkcję, czyli utils.simpleSort. Służy ona do sortowania listy wyników wyszukiwania według różnych kryteriów po 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 wieloma eksportami używany jest tylko jeden z nich. W takim przypadku wysyłanych jest wiele nieużywanych plików JavaScript.
Ta przykładowa aplikacja jest nieco sztuczna, ale nie zmienia to faktu, że ten syntetyczny scenariusz przypomina rzeczywiste możliwości optymalizacji, które możesz napotkać w produkcyjnej aplikacji internetowej. Skoro już wiesz, kiedy tree shaking może być przydatny, jak go używać?
Zapobieganie transpilacji modułów ES6 do modułów CommonJS w Babel
Babel to niezastąpione narzędzie, ale może utrudniać obserwowanie efektów usuwania nieużywanego kodu. Jeśli używasz @babel/preset-env, Babel może przekształcić moduły ES6 w bardziej kompatybilne moduły CommonJS, czyli moduły, które require zamiast import.
W przypadku modułów CommonJS tree shaking jest trudniejszy do przeprowadzenia, więc jeśli zdecydujesz się ich użyć, webpack nie będzie wiedzieć, co usunąć z pakietów. Rozwiązaniem jest skonfigurowanie @babel/preset-env tak, aby nie modyfikował modułów ES6. Niezależnie od tego, gdzie skonfigurujesz Babel – w babel.config.js czy package.json – musisz dodać coś ekstra:
// babel.config.js
export default {
presets: [
[
"@babel/preset-env", {
modules: false
}
]
]
}
Określenie modules: false w konfiguracji @babel/preset-env sprawia, że Babel działa zgodnie z oczekiwaniami, co umożliwia webpackowi analizowanie drzewa zależności i usuwanie nieużywanych zależności.
Pamiętaj o skutkach ubocznych
Kolejnym aspektem, który należy wziąć pod uwagę podczas usuwania zależności z aplikacji, jest to, czy moduły projektu mają efekty uboczne. Przykładem efektu ubocznego jest sytuacja, w której funkcja modyfikuje coś poza swoim zakresem. Jest to efekt 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 funkcja addFruit wywołuje efekt uboczny, modyfikując tablicę fruits, która znajduje się poza jej zakresem.
Skutki uboczne dotyczą też modułów ES6, co ma znaczenie w kontekście usuwania nieużywanego kodu. Moduły, które przyjmują przewidywalne dane wejściowe i generują równie przewidywalne dane wyjściowe bez modyfikowania niczego poza własnym zakresem, są zależnościami, które można bezpiecznie usunąć, jeśli ich nie używamy. Są to samodzielne, modułowe fragmenty kodu. Stąd nazwa „moduły”.
W przypadku webpacka wskazówka może służyć do określania, ż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ć webpacka, które konkretne 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 przyjmuje się, że każdy plik, który nie został określony, nie ma efektów ubocznych. Jeśli nie chcesz dodawać tego do pliku package.json, możesz też określić tę flagę w konfiguracji webpacka za pomocą module.rules.
Importowanie tylko niezbędnych danych
Po poinstruowaniu Babela, aby nie modyfikował modułów ES6, musimy nieco zmodyfikować składnię import, aby zaimportować z modułu utils tylko potrzebne funkcje. W przykładzie w tym przewodniku wystarczy funkcja simpleSort:
import { simpleSort } from "../../utils/utils";
Ponieważ importowany jest tylko element simpleSort, a nie cały moduł utils, każde wystąpienie elementu utils.simpleSort należy 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);
}
W tym przykładzie powinno to wystarczyć do działania eliminacji nieużywanego kodu. To jest wynik webpacka przed usunięciem nieużywanych 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 udanym usunięciu nieużywanego kodu:
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
Chociaż oba pakiety się zmniejszyły, to main zyskał najwięcej. Dzięki usunięciu nieużywanych części modułu utils pakiet main zmniejsza się o około 60%. Nie tylko skraca to czas pobierania skryptu, ale także czas przetwarzania.
Potrząśnij drzewami!
To, ile korzyści przyniesie Ci usuwanie nieużywanego kodu, zależy od aplikacji, jej zależności i architektury. Wypróbuj Jeśli masz pewność, że nie skonfigurowano narzędzia do pakowania modułów w celu przeprowadzenia tej optymalizacji, warto spróbować i sprawdzić, jak wpłynie to na Twoją aplikację.
Dzięki tree shaking możesz uzyskać znaczną poprawę wydajności lub nie uzyskać jej wcale. Konfigurując system kompilacji tak, aby korzystał z tej optymalizacji w kompilacjach produkcyjnych i selektywnie importował tylko to, czego potrzebuje aplikacja, możesz proaktywnie zmniejszać rozmiar pakietów aplikacji.
Specjalne podziękowania dla Kristofera Baxtera, Jasona Millera, Addy’ego Osmaniego, Jeffa Posnicka, Sama Saccone i Philipa Waltona za cenne uwagi, które znacznie poprawiły jakość tego artykułu.