Jak zoptymalizować rozmiar aplikacji opartej o Nuxt.js?

Jak zoptymalizować rozmiar aplikacji opartej o Nuxt.js?

Na co dzień dokładamy wszelkich starań aby zmniejszyć rozmiar aplikacji po stronie klienta, po to aby w przypadku słabej kondycji łącza nie obciążać go zbyt mocno oraz po to aby poprawić ogólne odczucia użytkownika - co ciekawe, średni rozmiar zasobów, które są ładowane przez strony internetowe rośnie z roku na rok. Przyjęło się również, że rozmiar zasobów dla poprawnie zoptymalizowanej strony powinien wynosić około 1-1.5MB, przy czym nie powinien być większy niż 3MB.

Jak zoptymalizować rozmiar aplikacji opartej o Nuxt.js?

https://httparchive.org/reports/page-weight?start=earliest&end=latest#bytesTotal

Aby doprowadzić nasz frontend do tak wspaniałych wyników potrzebujemy narzędzi - które zazwyczaj występują pod postacią zależności node_modules (bardzo dużej liczby zależności). 

Musimy być świadomi, że w momencie instalacji jednego modułu - możemy dostać 20 w gratisie (zależności instalowanej paczki) - to doprowadza do tego, że nasz projekt rośnie w takim tempie, że po instalacji wymaganych narzędzi (potrzebnych do transpilowania, kompilowania, testowania, lintowania) ważyć może niespełna 1GB. W większości czasu nie zastanawiamy się czy 1GB to dużo, nasze lokalne komputery mają dużo przestrzeni dyskowej, a instalowanie raz na jakiś czas tych wszystkich zależności nie jest dla nas problemem. O ile komputer pojedynczego developera jest na to gotowy, to środowisko Continuous Integration niekoniecznie - procesy te instalują zależności o wiele częściej - dodatkowo współdzielą zasoby pomiędzy inne projekty, co wpływa na przestrzenie dyskowe, wydajność procesów I/O czy samą kondycję łącza internetowego.

Dlatego powinniśmy dbać nie tylko o użytkownika końcowego, a również o same środowiska wydawania zmian, ponieważ tak duże rozmiary aplikacji powodują wydłużony proces wydawania zmian, a co za tym idzie? Frustracje developerów, a wiem z doświadczenia, że nic nie denerwuje tak jak długi czas wydawania małej zmiany.

Kierunków optymalizacji jest kilka, my - jako frontend deweloperzy możemy zająć się tym, co boli nas najbardziej, czyli liczbą zależności. W tym artykule dowiesz się, jak zmniejszyć liczbę paczek node_modules do minimum - może nawet do zera? 

Pozbądźmy się node_modules

Najprostsze, co możemy zrobić, to po zbudowaniu aplikacji zostawić tylko zależności, które są wymagane do działania aplikacji w trybie produkcyjnym - w tym celu rozróżniamy instalowane moduły między zależnościami produkcyjnymi („dependencies”) a developerskimi („devDependencies”).

Aby lepiej zademonstrować problemy i rozwiązania, o których będziesz czytał, przeanalizujmy projekt Nuxt.js stworzony za pomocą  https://github.com/nuxt/create-nuxt-app - z dodatkowym modulem @nuxtjs/pwa.

Przyjrzyjmy się, jak wygląda plik package.json:

"dependencies": {
        "@nuxtjs/pwa": "^3.3.4",
        "core-js": "^3.8.2",
        "nuxt": "^2.14.12"
      },
      "devDependencies": {
        "@nuxtjs/eslint-config": "^5.0.0",
        "@nuxtjs/eslint-module": "^3.0.2",
        "babel-eslint": "^10.1.0",
        "eslint": "^7.18.0",
        "eslint-plugin-nuxt": "^2.0.0",
        "eslint-plugin-vue": "^7.4.1"
      }

Wygląda super - 9 paczek, w tym 3 wymagane do działania produkcyjnego. A teraz spójrzmy na sumę paczek node_modules:

➜ nuxt-standalone git:(master) ✗ ls node_modules | wc -l && du -sh node_modules
     809
186M node_modules

Widzimy, że instalując prosty projekt, który potrzebuje 9 zależności - tak naprawdę dostajemy 800 w gratisie i niecałe 200MB na start. Dla przykładu node_modules w projektach, z którymi pracujemy na co dzień, działają w oparciu o mechanizm mono repozytoriów + yarn workspaces, a waga tych wszystkich plików node_modules wynosi średnio 800MB.

Krok pierwszy - zależności tylko dla produkcji

Wiemy, że do poprawnego działania aplikacji nie potrzebujemy wszystkich paczek - „devDependnencies” potrzebne są tylko do zbudowania projektu, a więc zbudujmy projekt i zostawmy tylko zależności produkcyjne.

Yarn build 
yarn install --production 

„Yarn install --production” instaluje paczki tylko produkcyjne (dependnencies) - nawet jeśli wcześniej mieliśmy zainstalowane zależności developerskie (devDependencies).

Zobaczmy, jak wygląda nasz folder node_modules po tej operacji:

➜ nuxt-standalone git:(master) ✗ ls node_modules | wc -l && du -sh node_modules
     724
148M node_modules

Czy wygląda to lepiej? Niestety nie, spodziewalibyśmy się trochę mniejszych liczb. Na szczęście zespół Nuxt.js zadbał o zdrowie psychiczne developerów i przygotował dla nas eksperymentalny feature, który w momencie pisania tego artykułu nie był dostępny w dokumentacji. Poznaj tryb budowania standalone. 

Krok drugi - Nuxt build --standalone

O tej opcji dowiedzieliśmy się przeszukując GitHuba. Wszystko zaczęło się od pewnego komentarza użytkownika “clarkdo”, który słusznie zauważył jeden z potencjalnych problemów użycia nodeExternals. Community Nuxt.js nigdy nie musi czekać długo na odpowiedź, ponieważ prototyp rozwiązania pojawił się dwa dni później. Ale o co chodzi? 

https://github.com/nuxt/nuxt.js/pull/4645 

Krok drugi - Nuxt build --standalone

Tryb budowania nuxt build --standalone pozwala na “wciągniecie” corowych paczek vue do wynikowego pliku server.js, dzięki czemu potencjalnie do działania aplikacji opartej na Nuxt.js nie potrzebujemy żadnych innych dependencji - potencjalnie możemy pozbyć się folderu node_modules.

Normal build:
Size of server.js: 22 Kib
Cost of dependencies (vue, vue-router, lodash (/omit), vue-no-ssr, debug): 7.4M
Cold start: 306.385ms
Memory usage: 29.9 MB (RSS: 106 MB)
Standalone build:
Size of server.js: 145 Kib
cold start: 306.550ms
Memory usage: 28.6 MB (RSS: 106 MB)

https://github.com/nuxt/nuxt.js/pull/4661

Niestety tylko potencjalnie, ponieważ po usunięciu node_modules nadal potrzebujesz uruchomić aplikację za pomocą skryptu “yarn start”, który z kolei potrzebuje paczki @nuxtjs/cli. Możesz poradzić sobie z tym problemem, uruchamiając aplikację za pomocą komendy:

“npx nuxt-start”

W ten sposób nie musisz instalować wszystkich paczek lokalnie. Polecenie npx wyszuka w pierwszej kolejności binarki w Twoich lokalnych node_modules. Jeżeli jej nie znajdzie, to będzie szukał globalnie zainstalowanych modułów, a w ostatniej kolejności odwoła się do npm registry w celu pobrania tego konkretnego modułu z Internetu.

Zdaje sobię sprawę, że polecenie npx może nie być najlepszym pomysłem dla produkcji - bez definicji konkretnej wersji - otrzymamy zawsze najnowszą wersję - lub tę, którą posiadamy lokalnie. Dlatego ten proces może wyglądać różnie w zależności od stosowanego deploymentu. Jeżeli używasz dockera, możesz zainstalować globalnie konkretną wersję nuxt-start na dockerze lub przygotować sobie dodatkowy skrypt, który po zbudowaniu aplikacji w trybie standalone usunie wszystkie node_modules, a następnie zainstaluje tylko potrzebny moduł nuxt-start. 

Jedna rzecz, o której warto pamiętać podczas developmentu aplikacji, to używanie “buildModules” zamiast “modules” podczas definicji listy używanych modułów w nuxt.config.js. Różnica pomiędzy nimi jest taka, że “buildModules” zawsze trafiają do plików wynikowych, pozwalając na szybszy start aplikacji oraz zmniejszenie rozmiaru node_modules na potrzeby deploymentu produkcyjnego. Musisz pamiętać, aby sprawdzić dokumentację instalowanego modułu, czy wspiera on opcję buildModules. Więcej informacji możesz przeczytać tutaj.

// Modules for dev and build (recommended) (https://go.nuxtjs.dev/config-modules)
buildModules: [
  // https://go.nuxtjs.dev/eslint
  '@nuxtjs/eslint-module',
  '@nuxtjs/pwa'
],

// Modules (https://go.nuxtjs.dev/config-modules)
modules: [
],

Choć tak jak twierdzi autor rozwiązania na GitHubie jest to opcja eksperymentalna i powinna być dobrze przetestowana, to jesteśmy w trakcie wydawania pierwszego projektu produkcyjnie w tym trybie i nie zauważyliśmy żadnych problemów. Pozwoliło nam to zmniejszyć rozmiary naszych kontenerów z średnio 800MB do około 65MB, co automatycznie skróciło czas deploymentu oraz zmniejszyło zapotrzebowanie na przestrzeń dyskową na serwerach integracji.

Zmniejszony rozmiar obrazu Docker z 800 MB na 65 MB, poprawiając efektywność wdrażania.