Zmniejsz ładunki JavaScript za pomocą potrząsania drzewem

Dzisiejsze aplikacje internetowe mogą być dość duże, zwłaszcza ich część JavaScript. W połowie 2018 r. średni rozmiar przesyłania kodu JavaScript na urządzeniach mobilnych wynosił około 350 KB. 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 kompilacji, nawet jeśli po skompresowaniu ma ono rozmiar około 300 KB.

Proces pobierania i uruchamiania kodu JavaScript. Pamiętaj, że mimo że 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.

Koszt przetwarzania parsowania/kompilowania 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?

Image for: 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 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 (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 z drzewa, które wykorzystuje sposób, w jaki instrukcje statyczne import wczytują 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 wymagać dużej ilości kodu), w tym przykładzie importowane są tylko określone jego części. W wersjach deweloperskich nie ma to znaczenia, ponieważ cały moduł jest importowany. W wersjach produkcyjnych webpack może być skonfigurowany tak, aby „wytrząść” eksporty z modułów ES6, które nie zostały zaimportowane w prosty sposób. Dzięki temu wersje produkcyjne będą mniejsze. Z tego przewodnika dowiesz się, jak to zrobić.

Znajdowanie okazji do potrząsania drzewem

Image for: Znajdowanie okazji do 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ę w praktyce).

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

Zrzut ekranu przykładowej aplikacji.

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

Dwa pakiety JavaScripta aplikacji. Są to rozmiary nieskompresowane.

Pakiety JavaScriptu pokazane na rysunku powyżej to wersje produkcyjne, co oznacza, że zostały zoptymalizowane przez uproszczenie. 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żna zrobić, aby rozwiązać ten problem.

W każdej aplikacji znajdowanie możliwości wstrząsania drzewem będzie wymagać wyszukiwania 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.

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 pojawia się 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łasz dużo nieużywanego 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,

Image for: 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, gdzie konfigurujesz Babel (w babel.config.js czy package.json), musisz dodać coś jeszcze:

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

Określenie 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

Image for: Skutki uboczne

Kolejnym 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, jest zależnością, którą można bezpiecznie usunąć, jeśli nie jest używana. Są to zamknięte, modułowe elementy kodu. Stąd „moduły”.

W przypadku webpacka można użyć wskazówki, aby określić, że pakiet i jego zależności są wolne od 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

Image for: 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 tym przykładzie wystarczy użyć 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 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);
}

To powinno wystarczyć, aby w tym przykładzie działało wyrywanie 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 zyskuje 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.

Image for: Idź potrząść drzewami.

To, jak wiele zyskasz dzięki wstrząsaniu drzewem, zależy od aplikacji, jej zależności i architektury. Wypróbuj Jeśli wiesz na pewno, że nie skonfigurowano modułu pakietu 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ć ich rozmiar na jak najniższych poziomach.

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.