Potrząsanie drzewem: przewodnik referencyjny

Opublikowany: 2022-03-10
Krótkie podsumowanie ↬ „Trzęsienie drzewami” to niezbędna optymalizacja wydajności podczas dołączania JavaScriptu. W tym artykule zagłębimy się w to, jak dokładnie to działa i jak specyfikacje i praktyki przeplatają się, aby pakiety były szczuplejsze i bardziej wydajne. Dodatkowo otrzymasz wstrząsającą drzewem listę kontrolną do wykorzystania w swoich projektach.

Zanim rozpoczniemy naszą podróż, aby dowiedzieć się, czym jest potrząsanie drzewem i jak przygotować się na jego sukces, musimy zrozumieć, jakie moduły znajdują się w ekosystemie JavaScript.

Od samego początku programy JavaScript stały się bardziej złożone i zwiększyły liczbę wykonywanych przez nie zadań. Stała się oczywista potrzeba podziału takich zadań na zamknięte zakresy wykonania. Te przedziały zadań lub wartości nazywamy modułami . Ich głównym celem jest zapobieganie powtórzeniom i wykorzystanie możliwości ponownego użycia. Opracowano więc architektury, które umożliwiają takie specjalne rodzaje zakresu, eksponowanie ich wartości i zadań oraz konsumowanie zewnętrznych wartości i zadań.

Aby głębiej zagłębić się w to, czym są moduły i jak działają, polecam „Moduły ES: głębokie zanurzenie w kreskówce”. Aby jednak zrozumieć niuanse związane z potrząsaniem drzewami i zużyciem modułów, wystarczy powyższa definicja.

Co właściwie oznacza potrząsanie drzewem?

Mówiąc najprościej, wstrząsanie drzewem oznacza usuwanie nieosiągalnego kodu (znanego również jako martwy kod) z pakietu. Jak stwierdza dokumentacja Webpack w wersji 3:

„Możesz wyobrazić sobie swoją aplikację jako drzewo. Kod źródłowy i biblioteki, których faktycznie używasz, reprezentują zielone, żywe liście drzewa. Martwy kod reprezentuje brązowe, martwe liście drzewa, które są spożywane jesienią. Aby pozbyć się martwych liści, musisz potrząsnąć drzewem, powodując ich upadek.”

Termin został po raz pierwszy spopularyzowany w społeczności front-endowej przez zespół Rollup. Jednak autorzy wszystkich języków dynamicznych borykają się z tym problemem znacznie wcześniej. Pomysł algorytmu wstrząsającego drzewami sięga co najmniej wczesnych lat dziewięćdziesiątych.

W świecie JavaScript, drżenie drzewa było możliwe od czasu specyfikacji modułu ECMAScript (ESM) w ES2015, znanej wcześniej jako ES6. Od tego czasu w większości pakietów domyślnie włączone jest potrząsanie drzewem, ponieważ zmniejszają one rozmiar wyjścia bez zmiany zachowania programu.

Głównym tego powodem jest to, że ESM są z natury statyczne. Przeanalizujmy, co to oznacza.

Więcej po skoku! Kontynuuj czytanie poniżej ↓

Moduły ES a CommonJS

CommonJS wyprzedza specyfikację ESM o kilka lat. Chodziło o rozwiązanie problemu braku obsługi modułów wielokrotnego użytku w ekosystemie JavaScript. CommonJS ma funkcję require() , która pobiera moduł zewnętrzny na podstawie podanej ścieżki i dodaje go do zakresu w czasie wykonywania.

To require to function jak każda inna w programie, która wystarczająco utrudnia ocenę jej wyniku wywołania w czasie kompilacji. Do tego dochodzi fakt, że możliwe jest dodawanie wywołań require w dowolnym miejscu kodu — opakowane w inne wywołanie funkcji, w instrukcjach if/else, w instrukcjach switch itp.

Dzięki nauce i trudnościom, które wynikły z szerokiego przyjęcia architektury CommonJS, specyfikacja ESM zdecydowała się na tę nową architekturę, w której moduły są importowane i eksportowane za pomocą odpowiednich słów kluczowych import i export . Dlatego nie więcej funkcjonalnych połączeń. ESM są również dozwolone tylko jako deklaracje najwyższego poziomu — zagnieżdżanie ich w jakiejkolwiek innej strukturze nie jest możliwe, ponieważ są statyczne : ESM nie zależą od wykonania w czasie wykonywania.

Zakres i skutki uboczne

Jest jednak inna przeszkoda, którą potrząsanie drzewem musi pokonać, aby uniknąć wzdęć: efekty uboczne. Uznaje się, że funkcja ma skutki uboczne, gdy zmienia się lub opiera się na czynnikach spoza zakresu wykonania. Funkcja ze skutkami ubocznymi jest uważana za nieczystą . Czysta funkcja zawsze da ten sam wynik, niezależnie od kontekstu lub środowiska, w którym została uruchomiona.

 const pure = (a:number, b:number) => a + b const impure = (c:number) => window.foo.number + c

Bundlery spełniają swoje zadanie, oceniając dostarczony kod tak bardzo, jak to możliwe, aby określić, czy moduł jest czysty. Ale ocena kodu w czasie kompilacji lub pakowania może zajść tylko do tego stopnia. Dlatego zakłada się, że pakietów z efektami ubocznymi nie da się właściwie wyeliminować, nawet jeśli są całkowicie nieosiągalne.

Z tego powodu pakiety akceptują teraz klucz w pliku package.json modułu, który umożliwia programiście zadeklarowanie, czy moduł nie ma skutków ubocznych. W ten sposób programista może zrezygnować z oceny kodu i podpowiedzieć pakerowi; kod w ramach konkretnego pakietu może zostać wyeliminowany, jeśli nie ma dostępnego importu lub linku do instrukcji require . To nie tylko sprawia, że ​​pakiet jest szczuplejszy, ale także może przyspieszyć czas kompilacji.

 { "name": "my-package", "sideEffects": false }

Tak więc, jeśli jesteś programistą pakietów, sumiennie korzystaj z sideEffects przed publikacją i oczywiście poprawiaj je przy każdym wydaniu, aby uniknąć nieoczekiwanych zmian.

Oprócz głównego klucza sideEffects możliwe jest również określenie czystości na podstawie pliku po pliku, dodając komentarz w wierszu, /*@__PURE__*/ , do wywołania metody.

 const x = */@__PURE__*/eliminated_if_not_called()

Uważam, że ta wbudowana adnotacja to luka ewakuacyjna dla dewelopera konsumenckiego, którą należy wykonać w przypadku, gdy pakiet nie zadeklarował sideEffects: false lub w przypadku, gdy biblioteka rzeczywiście powoduje efekt uboczny w określonej metodzie.

Optymalizacja pakietu internetowego

Od wersji 4 Webpack wymagał coraz mniejszej konfiguracji, aby najlepsze praktyki działały. Funkcjonalność kilku wtyczek została włączona do rdzenia. A ponieważ zespół programistów bardzo poważnie traktuje rozmiar pakietu, ułatwili potrząsanie drzewami.

Jeśli nie jesteś zbytnio majsterkowiczem lub jeśli Twoja aplikacja nie ma specjalnych przypadków, potrząsanie drzewami zależności jest kwestią tylko jednej linii.

Plik webpack.config.js ma właściwość root o nazwie mode . Za każdym razem, gdy wartość tej właściwości to production , będzie ona wstrząsać drzewami i w pełni optymalizować moduły. Oprócz wyeliminowania martwego kodu za pomocą TerserPlugin , mode: 'production' umożliwi deterministyczne zniekształcone nazwy modułów i porcji oraz aktywuje następujące wtyczki:

  • użycie zależności od flagi,
  • flaga zawierała kawałki,
  • konkatenacja modułów,
  • brak emisji przy błędach.

To nie przypadek, że wartością wyzwalającą jest production . Nie będziesz chciał, aby Twoje zależności były w pełni zoptymalizowane w środowisku programistycznym, ponieważ znacznie utrudni to debugowanie problemów. Sugerowałbym więc podejście do tego jednym z dwóch podejść.

Z jednej strony możesz przekazać flagę mode do interfejsu wiersza poleceń Webpack:

 # This will override the setting in your webpack.config.js webpack --mode=production

Alternatywnie możesz użyć zmiennej process.env.NODE_ENV w webpack.config.js :

 mode: process.env.NODE_ENV === 'production' ? 'production' : development

W takim przypadku należy pamiętać o przekazaniu --NODE_ENV=production w potoku wdrażania.

Oba podejścia są abstrakcją na szczycie dobrze znanej wtyczki define z definePlugin w wersji 3 i niższych. Wybór opcji nie ma absolutnie żadnego znaczenia.

Pakiet internetowy w wersji 3 i poniżej

Warto wspomnieć, że scenariusze i przykłady w tej sekcji mogą nie dotyczyć najnowszych wersji Webpack i innych pakietów. Ta sekcja dotyczy użycia UglifyJS w wersji 2 zamiast Terser. UglifyJS to pakiet, z którego rozwinął się Terser, więc ocena kodu może się różnić między nimi.

Ponieważ pakiet Webpack w wersji 3 i starszych nie obsługuje właściwości sideEffects w package.json , wszystkie pakiety muszą zostać całkowicie ocenione przed wyeliminowaniem kodu. Już samo to sprawia, że ​​podejście jest mniej skuteczne, ale należy również wziąć pod uwagę kilka zastrzeżeń.

Jak wspomniano powyżej, kompilator nie ma możliwości samodzielnego sprawdzenia, kiedy pakiet ingeruje w zasięg globalny. Ale to nie jedyna sytuacja, w której pomija drżenie drzew. Istnieją bardziej rozmyte scenariusze.

Weź ten przykład pakietu z dokumentacji Webpacka:

 // transform.js import * as mylib from 'mylib'; export const someVar = mylib.transform({ // ... }); export const someOtherVar = mylib.transform({ // ... });

A oto punkt wejścia pakietu konsumenckiego:

 // index.js import { someVar } from './transforms.js'; // Use `someVar`...

Nie ma możliwości ustalenia, czy mylib.transform wywołuje skutki uboczne. Dlatego żaden kod nie zostanie wyeliminowany.

Oto inne sytuacje o podobnym wyniku:

  • wywoływanie funkcji z zewnętrznego modułu, którego kompilator nie może sprawdzić,
  • ponowne eksportowanie funkcji zaimportowanych z modułów innych firm.

Narzędziem, które może pomóc kompilatorowi we wstrząsaniu drzewami, jest babel-plugin-transform-imports. Podzieli wszystkie eksporty członków i nazwane na eksporty domyślne, umożliwiając indywidualną ocenę modułów.

 // before transformation import { Row, Grid as MyGrid } from 'react-bootstrap'; import { merge } from 'lodash'; // after transformation import Row from 'react-bootstrap/lib/Row'; import MyGrid from 'react-bootstrap/lib/Grid'; import merge from 'lodash/merge';

Ma również właściwość konfiguracyjną, która ostrzega programistę, aby uniknąć kłopotliwych instrukcji importu. Jeśli korzystasz z Webpacka w wersji 3 lub nowszej i wykonałeś należytą staranność z podstawową konfiguracją i dodałeś zalecane wtyczki, ale Twój pakiet nadal wygląda na nadęty, polecam wypróbowanie tego pakietu.

Podnoszenie zakresu i czasy kompilacji

W czasach CommonJS większość bundlerów po prostu umieszczała każdy moduł w innej deklaracji funkcji i mapowała je wewnątrz obiektu. Nie różni się to niczym od innych obiektów na mapie:

 (function (modulesMap, entry) { // provided CommonJS runtime })({ "index.js": function (require, module, exports) { let { foo } = require('./foo.js') foo.doStuff() }, "foo.js": function(require, module, exports) { module.exports.foo = { doStuff: () => { console.log('I am foo') } } } }, "index.js")

Poza tym, że jest to trudne do analizy statycznej, jest to zasadniczo niezgodne z ESM, ponieważ widzieliśmy, że nie możemy zawijać instrukcji import i export . Tak więc w dzisiejszych czasach wiązarki podnoszą każdy moduł na najwyższy poziom:

 // moduleA.js let $moduleA$export$doStuff = () => ({ doStuff: () => {} }) // index.js $moduleA$export$doStuff()

Takie podejście jest w pełni zgodne z ESM; ponadto umożliwia ocenę kodu w celu łatwego wykrycia modułów, które nie są wywoływane, i porzucenia ich. Zastrzeżenie tego podejścia polega na tym, że podczas kompilacji zajmuje znacznie więcej czasu, ponieważ dotyka każdej instrukcji i przechowuje pakiet w pamięci podczas procesu. To jest główny powód, dla którego wydajność wiązania stała się jeszcze większym problemem dla wszystkich i dlaczego języki skompilowane są wykorzystywane w narzędziach do tworzenia stron internetowych. Na przykład esbuild to bundler napisany w Go, a SWC to kompilator TypeScript napisany w Rust, który integruje się ze Sparkiem, także bundlerem napisanym w Rust.

Aby lepiej zrozumieć poszerzanie zakresu, bardzo polecam dokumentację Parcel w wersji 2.

Unikaj przedwczesnego transpilowania

Jest jeden konkretny problem, który jest niestety dość powszechny i ​​może być druzgocący w przypadku potrząsania drzewami. Krótko mówiąc, dzieje się tak, gdy pracujesz ze specjalnymi programami ładującymi, integrując różne kompilatory ze swoim bundlerem. Typowe kombinacje to TypeScript, Babel i Webpack — we wszystkich możliwych kombinacjach.

Zarówno Babel, jak i TypeScript mają własne kompilatory, a ich odpowiednie programy ładujące pozwalają programistom z nich korzystać w celu łatwej integracji. I w tym tkwi ukryte zagrożenie.

Te kompilatory docierają do Twojego kodu przed optymalizacją kodu. Niezależnie od tego, czy domyślnie, czy błędnie skonfigurowane, te kompilatory często wyświetlają moduły CommonJS zamiast ESM. Jak wspomniano w poprzedniej sekcji, moduły CommonJS są dynamiczne i dlatego nie można ich właściwie ocenić pod kątem eliminacji martwego kodu.

Ten scenariusz staje się coraz bardziej powszechny w dzisiejszych czasach, wraz z rozwojem aplikacji „izomorficznych” (tj. aplikacji, które uruchamiają ten sam kod zarówno po stronie serwera, jak i klienta). Ponieważ Node.js nie ma jeszcze standardowej obsługi ESM, gdy kompilatory są ukierunkowane na środowisko node , generują CommonJS.

Upewnij się więc, że sprawdziłeś kod, który otrzymuje Twój algorytm optymalizacji .

Lista kontrolna potrząsania drzewem

Teraz, gdy znasz już tajniki tego, jak działa bundling i wstrząsanie drzewami, narysujmy sobie listę kontrolną, którą możesz wydrukować w przydatnym miejscu, gdy ponownie odwiedzisz swoją obecną implementację i bazę kodu. Mamy nadzieję, że zaoszczędzi to czas i pozwoli zoptymalizować nie tylko postrzeganą wydajność kodu, ale może nawet czasy kompilacji potoku!

  1. Używaj ESM, nie tylko we własnej bazie kodu, ale także faworyzuj pakiety, które wysyłają ESM jako materiały eksploatacyjne.
  2. Upewnij się, że wiesz dokładnie, które (jeśli w ogóle) z Twoich zależności nie zadeklarowały sideEffects lub ustaw je jako true .
  3. Skorzystaj z wbudowanej adnotacji, aby deklarować wywołania metod, które są czyste podczas korzystania z pakietów z efektami ubocznymi.
  4. Jeśli wyprowadzasz moduły CommonJS, pamiętaj o zoptymalizowaniu pakietu przed transformacją instrukcji import i export.

Tworzenie pakietów

Mamy nadzieję, że w tym momencie wszyscy zgadzamy się, że ESM są drogą naprzód w ekosystemie JavaScript. Jednak jak zawsze w tworzeniu oprogramowania, przejścia mogą być trudne. Na szczęście autorzy pakietów mogą zastosować niezawodne środki, aby ułatwić użytkownikom szybką i bezproblemową migrację.

Z kilkoma małymi dodatkami do package.json , twój pakiet będzie w stanie poinformować pakowaczy o środowiskach obsługiwanych przez pakiet i o tym, jak są obsługiwane najlepiej. Oto lista kontrolna ze Skypack:

  • Uwzględnij eksport ESM.
  • Dodaj "type": "module" .
  • Wskaż punkt wejścia za pomocą "module": "./path/entry.js" (konwencja społeczności).

A oto przykład, który pojawia się, gdy przestrzegane są wszystkie najlepsze praktyki i chcesz obsługiwać zarówno środowisko webowe, jak i środowisko Node.js:

 { // ... "main": "./index-cjs.js", "module": "./index-esm.js", "exports": { "require": "./index-cjs.js", "import": "./index-esm.js" } // ... }

Oprócz tego zespół Skypack wprowadził ocenę jakości pakietu jako punkt odniesienia, aby określić, czy dany pakiet jest skonfigurowany pod kątem długowieczności i najlepszych praktyk. Narzędzie jest typu open source w serwisie GitHub i można je dodać jako devDependency do pakietu, aby łatwo przeprowadzać kontrole przed każdym wydaniem.

Zawijanie

Mam nadzieję, że ten artykuł był dla Ciebie przydatny. Jeśli tak, rozważ udostępnienie go w swojej sieci. Nie mogę się doczekać interakcji z Tobą w komentarzach lub na Twitterze.

Przydatne zasoby

Artykuły i dokumentacja

  • „Moduły ES: głębokie zanurzenie w kreskówce”, Lin Clark, Mozilla Hacks
  • „Trzęsienie drzewa”, pakiet internetowy
  • „Konfiguracja”, Webpack
  • „Optymalizacja”, Webpack
  • „Scope Hoisting”, dokumentacja Parcel w wersji 2

Projekty i narzędzia

  • Terser
  • babel-plugin-transform-imports
  • Skypack
  • Pakiet internetowy
  • Paczka
  • Zestawienie
  • esbuild
  • SWC
  • Kontrola paczki