Zmniejsz ładunki JavaScript za pomocą potrząsania drzewem

Współczesne aplikacje internetowe mogą być całkiem duże, zwłaszcza te wykorzystujące JavaScript. Od połowy 2018 roku mediana rozmiaru transferu plików JavaScript na urządzeniach mobilnych w archiwum HTTP wynosi około 350 KB. To tylko rozmiar transferu! JavaScript jest często kompresowany przy wysyłaniu przez sieć, co oznacza, że rzeczywista ilość kodu JavaScript po zdekompresowaniu jest znacznie większa od przeglądarki. Jest to ważne, ponieważ jeśli chodzi o przetwarzanie zasobów, kompresja nie ma znaczenia. 900 KB zdekompresowanego kodu JavaScript to nadal 900 KB dla parsera i kompilatora, choć po skompresowaniu może wynosić około 300 KB.

Diagram ilustrujący proces pobierania, dekompresowania, analizowania, kompilowania i wykonywania kodu JavaScript.
Proces pobierania i uruchamiania JavaScriptu. Pamiętaj, że chociaż rozmiar transferu skryptu wynosi 300 KB, nadal stanowi on 900 KB kodu JavaScript, który należy przeanalizować, skompilować i wykonać.

Przetwarzanie JavaScriptu 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. Liczba bajtów na bajty sprawia, że JavaScript jest droższy niż w przypadku innych typów zasobów.

Diagram porównujący czas przetwarzania pliku JavaScript o wielkości 170 KB z obrazem JPEG o odpowiednim rozmiarze. Zasób JavaScript wymaga dużo więcej zasobów na bajty niż JPEG.
Koszt przetwarzania analizy lub kompilacji 170 KB pliku JavaScript w porównaniu z czasem dekodowania pliku JPEG o odpowiednim rozmiarze. (ź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.

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

Ta technika działa, ale nie rozwiązuje typowej sytuacji w aplikacjach intensywnie korzystających z JavaScriptu, czyli uwzględniania kodu, który nigdy nie jest używany. Potrząśnięcie drzewami to próba rozwiązania tego problemu.

Co to jest drżenie 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. Metoda ta umożliwiła również dokonanie zakupu w pakiecie webpack, co pokazujemy w tym artykule za pomocą przykładowej aplikacji.

Termin „drżenie drzewa” to model umysłowy aplikacji i zależności w postaci drzewa. Każdy węzeł w drzewie reprezentuje zależność, która zapewnia różne funkcje aplikacji. We współczesnych aplikacjach te zależności pochodzą ze statycznych instrukcji import, np.:

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

Aplikacja młoda – czyli tylko 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 mogą się jednak pojawiać kolejne zależności. Podczas łączenia spraw starsze zależności przestają być używane, ale mogą nie zostać usunięte z bazy kodu. Efektem jest to, że aplikacja wysyła dużo nieużywanego kodu JavaScript. Wstrząśnięcie drzew rozwiązuje ten problem, wykorzystując sposób, w jaki statyczne instrukcje import wciągają 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 kompilacjach deweloperskich nic to nie zmienia, ponieważ cały moduł jest importowany. W kompilacjach produkcyjnych pakiet internetowy można skonfigurować w sposób „shake” (potrząśnięcie). eksportów z modułów ES6, które nie zostały bezpośrednio zaimportowane, przez co te kompilacje produkcyjne będą mniejsze. Z tego przewodnika dowiesz się, jak to zrobić.

Szukamy możliwości, żeby potrząsnąć drzewem

Do celów ilustracyjnych udostępniamy przykładową, jednostronicową aplikację, która pokazuje, jak działa potrząsanie drzewem. Możesz go skopiować i postępować zgodnie z instrukcjami, ale w tym przewodniku omówimy wszystkie kroki, więc klonowanie nie jest konieczne (chyba że wolisz uczyć się przez praktykę).

Przykładowa aplikacja to dostępna do przeszukiwania baza danych pedałów gitarowych. 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 przedstawiający przykładową aplikację.

Zachowanie, które wpływa na działanie tej aplikacji, jest podzielone według dostawcy (np. Preact i Emotion) oraz pakiety kodu związane z określonymi aplikacjami (czyli „fragmentami”, jak nazywa się je pakiet internetowy):

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

Pakiety JavaScript widoczne na ilustracji powyżej to kompilacje produkcyjne, co oznacza, że są optymalizowane przez ugllifikację. 21,1 KB w przypadku pakietu aplikacji nie jest złe, ale należy zauważyć, że w ogóle nie występuje drżenie drzewem. Przyjrzyjmy się kodowi aplikacji i zobaczmy, co można zrobić, aby to naprawić.

W każdej aplikacji znalezienie możliwości potrząsania drzewami będzie polegało na wyszukaniu statycznych instrukcji import. W górnej części 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 podobne do tego powinny Cię zainteresować. Ten konkretny wiersz zawiera tekst „import wszystko z modułu utils i umieść je w przestrzeni nazw o nazwie utils”. Po pierwsze: „ile rzeczy zawiera ten moduł?”.

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

Czy potrzebujesz tych wszystkich rzeczy? Sprawdźmy jeszcze raz: przeszukaj główny plik komponentu, który importuje moduł utils, i zobaczmy, ile pojawia się wystąpień tej przestrzeni nazw.

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 i kilku eksportów używany jest tylko jeden z nich. Powoduje to przesyłanie dużej ilości nieużywanego JavaScriptu.

Choć ta przykładowa aplikacja jest nieco pracochłonna, nie zmienia to faktu, że ten syntetyczny scenariusz przypomina rzeczywiste możliwości optymalizacji, które można napotkać w produkcyjnej aplikacji internetowej. Wiesz już, jak można wykorzystać potencjał potrząsania drzewem. Jak to się robi?

Dzięki temu nie dochodzi do transpilacji modułów ES6 na moduły CommonJS

Babel jest narzędziem niezbędnym, ale może nieco utrudniać obserwację skutków potrząsania drzewem. Jeśli używasz @babel/preset-env, Babel może przekształcić moduły ES6 w bardziej zgodne moduły CommonJS, czyli moduły require zamiast import.

Ponieważ w przypadku modułów CommonJS trudniej jest potrząsać drzewem, 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 – czy to w babel.config.js, czy w package.json – wymaga to dodania czegoś dodatkowego:

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

Jeśli określisz parametr modules: false w konfiguracji @babel/preset-env, Babel będzie działać zgodnie z oczekiwaniami, co umożliwi pakietowi internetowemu analizę drzewa zależności i wyeliminowanie nieużywanych zależności.

Pamiętaj o efektach ubocznych

Kolejnym aspektem, który należy wziąć pod uwagę podczas określania zależności aplikacji, jest to, które mają efekty uboczne. Przykładem efektu ubocznego jest sytuacja, w której modyfikuje coś poza własnym zakresem, co jest efektem ubocznym. jego 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 jest poza jej zakresem.

Efekty uboczne mają również zastosowanie do modułów ES6 i ma to znaczenie w kontekście potrząsania drzewem. Moduły, które przyjmują przewidywalne dane wejściowe i generują tak samo przewidywalne dane wyjściowe bez modyfikowania niczego spoza własnego zakresu, to zależności, które można bezpiecznie pominąć, jeśli ich nie używamy. Są one samodzielnymi, modułowymi fragmentami kodu. Oznacza to, że są to „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ż 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 drugim przykładzie każdy plik, który nie został określony, będzie uznawany za wolny od efektó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ą module.rules.

Importowanie tylko potrzebnych plików

Gdy poinstruujesz Babel, aby pozostawić moduły ES6 bez zmian, konieczna jest drobna zmiana w składni import, aby dodać tylko funkcje niezbędne z modułu utils. W tym przewodniku potrzebujesz tylko funkcji simpleSort:

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

Importowane jest tylko simpleSort zamiast całego modułu utils, więc każde wystąpienie tagu 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 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 dane wyjściowe po udanym wstrząsaniu 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ć w obu przypadkach mniej się pakują, to na nich najbardziej skorzystał pakiet main. Potrząsanie nieużywanymi częściami modułu utils zmniejsza rozmiar pakietu main o około 60%. Skraca to nie tylko czas pobierania przez skrypt, ale także czas przetwarzania.

Potrząśnij drzewami!

Efekt drżenia drzew zależy od aplikacji oraz jej zależności i architektury. Wypróbuj Jeśli wiesz na pewno, że narzędzie do tworzenia pakietów modułów nie zostało skonfigurowane w taki sposób, aby przeprowadzić tę optymalizację, możesz spróbować sprawdzić, jakie korzyści przynosi Twojej aplikacji.

Potrząśnięcie drzewem może znacznie poprawić wydajność lub ograniczyć je wcale. Jeśli jednak skonfigurujesz system kompilacji pod kątem korzystania z tej optymalizacji w kompilacjach produkcyjnych i selektywnie importujesz tylko te elementy, których potrzebuje Twoja aplikacja, będziesz aktywnie unikać pakietów aplikacji.

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