Kamil Owczarek
Opublikowano

Jak zdjęliśmy 22 KB z entry bundle'a, lazy-loadując bibliotekę do toastów

Autorzy

Liczba, której nie powinno tam być

Przeglądaliśmy analizę produkcyjnego bundle'a, kiedy w entry chunku rzuciła nam się w oczy znajoma nazwa: vue3-toastify. Nie w chunku konkretnego route'a. Nie w lazy-loadowanym module. W entry bundle'u — pliku JavaScript, który pobiera każdy odwiedzający przy każdym wejściu na stronę, zanim jeszcze cokolwiek zobaczy.

Entry chunk ważył 604 KB. To podatek, który płaci każdy odwiedzający, niezależnie od tego, na jakiej stronie wyląduje. Crawlery wyszukiwarek, nowi użytkownicy na mobilnym internecie, wracający klienci na światłowodzie — wszyscy pobierają 604 KB JavaScriptu, zanim aplikacja w ogóle zdąży się zhydrować.

Na vue3-toastify przypadały 22 KB tego chunka. Jego CSS dokładał kolejne 9 KB do entry stylesheeta, podnosząc entry.css ze 109 KB do 118 KB. Razem 31 KB pobierane przez każdego odwiedzającego dla funkcji, która odpala się wyłącznie w dashboardzie — panelu admina używanym przez garstkę osób z firmy.

Trzydzieści jeden kilobajtów brzmi niegroźnie. Ale waga entry bundle'a kumuluje się w sposób, w jaki nie kumuluje się waga bundli przypisanych do konkretnych route'ów:

  • Blokuje pierwszy render na każdej stronie
  • Pobiera go każdy odwiedzający, nie tylko ci, którzy go potrzebują
  • Wlicza się do Total Blocking Time, a to wpływa na Core Web Vitals
  • Na łączu 3G (wciąż popularnym na wielu rynkach) 31 KB dokłada około 200 ms do first paint

Sklep — karty produktów, listingi kategorii, checkout — nigdy nie pokazuje toasta. Ani razu. Toasty pojawiają się tylko w callbackach mutacji w dashboardzie: „User updated!”, „Cache reset!”, „Article created!”. Funkcja używana przez 0,1% ruchu siedziała w ścieżce krytycznej dla 100% tego ruchu.

Namierzanie winowajcy

Wszystko wyszło przy rutynowym przeglądzie bundle'a. Do generowania treemap z produkcyjnego outputu używamy wbudowanego w Vite rollup-plugin-visualizer (można też odpalić npx nuxi analyze). Treemapa pokazywała vue3-toastify jako solidny blok w środku entry chunka, wciśnięty między runtime Vue a konfigurację routera. Nie w lazy chunku. Nie rozdzielony po route'ach. Dokładnie w ścieżce krytycznej, ładowany zanim wyrenderuje się pierwszy piksel.

Sprawdziliśmy, czy chunk splitting Nuxta nie powinien był wyłapać tego automatycznie. W teorii, jeśli moduł jest importowany tylko przez strony konkretnych route'ów, code splitting wrzuci go do chunka odpowiedniego route'a. Ale vue3-toastify był importowany w pluginie (a ten uruchamia się przy starcie aplikacji) i w jedenastu różnych komponentach rozrzuconych po wielu route'ach. Heurystyka bundlera zobaczyła moduł używany wszędzie i podjęła racjonalną decyzję: wrzucić go do współdzielonego entry chunka, żeby nie pobierać go jedenaście razy.

Ironia polega na tym, że bundler optymalizował pod zły scenariusz. Minimalizował łączny rozmiar pobierania dla wszystkich możliwych ścieżek nawigacji. Ale dla pojedynczej sesji — zwłaszcza tych 99,9% sesji, które nigdy nie zaglądają do dashboardu — maksymalizował pobieranie zupełnie niepotrzebne. Algorytm zakładał, że każdy użytkownik prędzej czy później odwiedzi każdy route. W rzeczywistości odwiedzający sklep nigdy nie wchodzą do dashboardu, a użytkownicy dashboardu to promil całego ruchu.

Jak on się tam znalazł

Winowajcą był zwykły plugin Nuxta. Kiedy dodawaliśmy toasty, poszliśmy dokładnie za konfiguracją zalecaną w dokumentacji biblioteki:

// plugins/toast.ts
import Vue3Toastify, { type ToastContainerOptions } from 'vue3-toastify';
import 'vue3-toastify/dist/index.css';

export default defineNuxtPlugin((nuxt) => {
  nuxt.vueApp.use(
    Vue3Toastify,
    {
      autoClose: 2000,
      position: 'bottom-right',
      limit: 5,
    } as ToastContainerOptions
  )
})

Szkody zrobiły dwie linijki. import Vue3Toastify from 'vue3-toastify' wciągnął całą bibliotekę do grafu modułów na etapie builda. import 'vue3-toastify/dist/index.css' zrobił to samo ze stylami. Ponieważ pluginy Nuxta wykonują się podczas inicjalizacji aplikacji, bundler słusznie uznał, że te importy są potrzebne przy starcie, i umieścił je w entry chunku.

Do tego w całym kodzie jedenaście komponentów i stron importowało funkcję toast bezpośrednio:

import { toast } from 'vue3-toastify';

// Used in mutation callbacks
const { mutate } = useMutation({
  mutationFn: (body) => $fetch('/api/endpoint', { method: 'POST', body }),
  onSuccess() {
    toast.success("Record updated!")
  },
  onError() {
    toast.error("Something went wrong!")
  },
})

Każdy z tych import { toast } utwierdzał bundler w jego decyzji: vue3-toastify jest używany w wielu modułach, więc jego miejsce jest we współdzielonym entry chunku. Bundler robił dokładnie to, do czego został zaprojektowany — wrzucał często używany kod do wspólnego chunka, żeby uniknąć duplikacji. Problemem nie był bundler. Problemem było to, że importowaliśmy runtime'ową zależność na poziomie modułu, zamiast ładować ją na żądanie.

Dlaczego oczywiste rozwiązania nie działają

Zanim napisaliśmy leniwy composable, rozważyliśmy trzy prostsze podejścia. Każde miało wadę, która je dyskwalifikowała.

Podejście 1: plugin tylko po stronie klienta. Nuxt pozwala dodać do nazwy pliku pluginu sufiks .client.ts, żeby pominąć go podczas SSR. Serwer faktycznie nie wciągnąłby wtedy vue3-toastify do bundle'a, ale klientowi to w niczym nie pomaga. Entry chunk po stronie klienta nadal zawierałby całą bibliotekę. Problemem nie był narzut SSR, tylko waga pobierania po stronie klienta.

Podejście 2: defineNuxtPlugin z warunkowym importem. Rozważaliśmy owinięcie importu sprawdzeniem route'a — ładuj bibliotekę tylko wtedy, gdy użytkownik jest na stronie dashboardu:

export default defineNuxtPlugin((nuxt) => {
  const route = useRoute()
  if (route.path.startsWith('/panel')) {
    // Load toast only for dashboard
  }
})

To nie działa, bo pluginy uruchamiają się raz, przy starcie aplikacji, a nie przy każdej nawigacji. Route w momencie inicjalizacji pluginu to route pierwszego wejścia. Jeśli ktoś wejdzie na sklep, a dopiero potem przejdzie do dashboardu, plugin już się wykonał (bez ładowania toasta) i drugi raz się nie odpali. Toasty po cichu przestałyby działać każdemu, kto nie wchodzi bezpośrednio pod adres dashboardu.

Podejście 3: przeniesienie importów do komponentów, które ich używają. Zamiast pluginu moglibyśmy usunąć go całkowicie i pozwolić, żeby każdy komponent importował vue3-toastify u siebie. W teorii code splitting po route'ach umieściłby wtedy bibliotekę w chunku każdego route'a.

To prawie działa, ale ma dwa problemy. Po pierwsze, vue3-toastify wymaga jednorazowego wywołania app.use(), żeby zarejestrować swój komponent kontenera — bez pluginu w DOM nie ma kontenera na toasty, a wywołania toast.success() po cichu nic nie robią. Po drugie, nawet gdybyśmy rozwiązali kwestię rejestracji, import w jedenastu komponentach rozsianych po wielu route'ach sprawia, że heurystyka wspólnego chunka i tak ściągnie bibliotekę z powrotem do entry bundle'a. Bylibyśmy w punkcie wyjścia.

Dobre rozwiązanie musiało spełnić trzy warunki: odłożyć pobranie do pierwszego użycia, obsłużyć jednorazową rejestrację przez app.use() i działać z dowolnego komponentu, bez tego, żeby ten komponent musiał cokolwiek wiedzieć o mechanizmie lazy loadingu.

Wzorzec: dynamiczne composable

Rozwiązanie miało dwie części: composable, który leniwie ładuje bibliotekę, i przepisany plugin, który odkłada inicjalizację.

Część 1: leniwy composable do toastów

Zamiast importować toast na poziomie modułu w każdym komponencie, napisaliśmy composable, który ładuje bibliotekę dopiero wtedy, gdy ktoś faktycznie odpali toasta:

// composables/useLazyToast.ts
import type { ToastOptions } from 'vue3-toastify'

let toastModule: typeof import('vue3-toastify') | null = null
let initPromise: Promise<typeof import('vue3-toastify')> | null = null

async function init() {
  if (toastModule) return toastModule

  if (!initPromise) {
    initPromise = import('vue3-toastify').then(async (mod) => {
      await import('vue3-toastify/dist/index.css')
      toastModule = mod
      return mod
    })
  }

  return initPromise
}

export function useLazyToast() {
  const success = async (message: string, options?: ToastOptions) => {
    const { toast } = await init()
    toast.success(message, options)
  }

  const error = async (message: string, options?: ToastOptions) => {
    const { toast } = await init()
    toast.error(message, options)
  }

  return { success, error }
}

Są tu cztery decyzje projektowe, które warto omówić.

Singleton na poziomie modułu. Zmienne toastModule i initPromise żyją poza funkcją composable'a. Dzięki temu każdy komponent, który wywoła useLazyToast(), współdzieli tę samą referencję do zacache'owanego modułu. Biblioteka ładuje się raz, przy pierwszym toaście gdziekolwiek w aplikacji. Każdy kolejny toast — z dowolnego komponentu — korzysta z już załadowanego modułu, bez żadnego narzutu asynchroniczności.

Deduplikacja Promise'a. Jeśli dwie mutacje zakończą się jednocześnie i obie spróbują pokazać toasta, sprawdzenie initPromise gwarantuje, że wykona się tylko jedno wywołanie import(). Drugi wywołujący czeka na ten sam Promise co pierwszy. Bez tego można skończyć z podwójnym wstrzyknięciem CSS-a albo z race condition przy inicjalizacji modułu.

CSS ładowany wewnątrz łańcucha dynamicznego importu. Wywołanie await import('vue3-toastify/dist/index.css') jest wpięte w .then() po załadowaniu modułu JS. Dzięki temu arkusz stylów wstrzykuje się dopiero wtedy, gdy runtime toastów jest dostępny — i tylko wtedy, gdy toast jest naprawdę potrzebny. Koniec z 9 KB CSS-a w entry stylesheecie dla funkcji, z której nikt w sklepie nie korzysta.

Import wyłącznie typu na górze pliku. import type { ToastOptions } na początku pliku to import typu TypeScriptu. Znika kompletnie podczas kompilacji — zero bajtów w outputcie. Mamy pełne typowanie parametru options bez wciągania biblioteki do bundle'a.

Część 2: przepisany plugin

Plugin też wymagał zmiany. Zamiast synchronicznie importować i rejestrować vue3-toastify przy starcie, teraz odkłada wszystko do dynamicznego importu:

// plugins/toast.ts
import type { ToastContainerOptions } from 'vue3-toastify'

export default defineNuxtPlugin((nuxt) => {
  void import('vue3-toastify').then(async ({ default: Vue3Toastify }) => {
    await import('vue3-toastify/dist/index.css')
    nuxt.vueApp.use(
      Vue3Toastify,
      {
        autoClose: 2000,
        position: 'bottom-right',
        limit: 5,
      } as ToastContainerOptions,
    )
  })
})

Słowo kluczowe void przed import() jest tam celowo. Mówi pluginowi: odpal dynamiczny import i zapomnij o nim — plugin kończy się natychmiast, nie czekając na załadowanie vue3-toastify. Aplikacja Vue inicjalizuje się i renderuje bez biblioteki toastów. Gdy użytkownik w końcu odpali mutację pokazującą toasta, funkcja init() z composable'a załaduje moduł (albo użyje go ponownie, jeśli import w tle zdążył się już zakończyć).

Część 3: aktualizacja wszystkich miejsc użycia

Kiedy composable był gotowy, każdy komponent, który wcześniej importował toast bezpośrednio, musiał przejść na useLazyToast():

// Before
import { toast } from 'vue3-toastify';

const { mutate } = useMutation({
  onSuccess() { toast.success("Updated!") },
  onError()   { toast.error("Something went wrong!") },
})

// After
const { success: toastSuccess, error: toastError } = useLazyToast()

const { mutate } = useMutation({
  onSuccess() { toastSuccess("Updated!") },
  onError()   { toastError("Something went wrong!") },
})

Zmieniliśmy jedenaście plików: sześć stron dashboardu, dwa komponenty, jeden layout, jedną publiczną stronę z ankietą i sam plugin. Diff był czysto mechaniczny — znajdź każdy import { toast } from 'vue3-toastify', zamień na const { success: toastSuccess, error: toastError } = useLazyToast(), a potem zmień toast.success na toastSuccess i toast.error na toastError.

Ponieważ useLazyToast to composable z katalogu composables/, Nuxt importuje go automatycznie. W żadnym z tych jedenastu plików nie trzeba było dopisywać importu.

Zmiana w konfiguracji bundlera

Trzeba było zmienić jeszcze jedno. W naszej konfiguracji Vite vue3-toastify siedział w tablicy optimizeDeps.include:

// nuxt.config.ts (before)
vite: {
  optimizeDeps: {
    include: [
      'vue3-toastify',   // <-- Forces pre-bundling into entry
      '@tanstack/vue-query',
      'slugify',
    ],
  },
},

Opcja optimizeDeps.include każe Vite pre-bundle'ować zależność przy starcie dev servera — przekonwertować ją z CommonJS albo wstępnie zoptymalizować pod szybsze przeładowania w trybie deweloperskim. Ale jest to też sygnał dla produkcyjnego bundlera, że ta zależność ładowana jest zachłannie. Po przejściu na dynamiczne importy zostawienie vue3-toastify na tej liście podkopywałoby całą strategię lazy loadingu.

Usunięcie tej jednej linijki sprawiło, że bundler zaczął traktować vue3-toastify jako kandydata na osobny, dynamiczny chunk — zarówno w buildzie deweloperskim, jak i produkcyjnym.

Wyniki

Po deployu na produkcję:

ZasóbPrzedPoOszczędność
entry.js604 KB582 KB22 KB (-3,6%)
entry.css118 KB109 KB9 KB (-7,6%)
Łączna waga entry722 KB691 KB31 KB (-4,3%)

vue3-toastify ładuje się teraz jako osobny chunk, dopiero przy pierwszym toaście. Dla zdecydowanej większości odwiedzających — tych, którzy przeglądają sklep, czytają opisy produktów, porównują wykończenia — ten chunk nie ładuje się nigdy.

Użytkownikom dashboardu, którzy toasty faktycznie odpalają, biblioteka doczytuje się w tle przy pierwszej mutacji. Pierwszy toast może pojawić się z ledwie zauważalnym opóźnieniem (czas na pobranie i sparsowanie chunka), ale każdy kolejny jest natychmiastowy, bo moduł jest już zacache'owany.

Co te liczby znaczą dla realnych użytkowników

Ścięcie 31 KB z entry przekłada się różnie, zależnie od łącza:

ŁączeZaoszczędzony czas (transfer)Odczuwalność
4G (10 Mbps)~25 msNiezauważalne
3G (1.5 Mbps)~165 msZauważalne przy first paint
Wolne 3G (400 Kbps)~620 msRealnie wpływa na zaangażowanie

Na szybkich łączach to niewidoczne. Na wolniejszych — a te wciąż odpowiadają za spory kawałek globalnego ruchu — to różnica między stroną, która wydaje się szybka, a taką, która się wlecze. I ten zysk dotyczy każdego wejścia, każdego odwiedzającego, każdej sesji.

Kiedy stosować ten wzorzec

Wzorzec leniwego composable'a sprawdzi się przy każdej bibliotece, która spełnia trzy kryteria:

  1. Nie jest potrzebna przy pierwszym renderze. Jeśli biblioteka musi działać podczas hydracji (silniki layoutu, state managery, route guardy), nie odłożysz jej na później.

  2. Odpala ją interakcja użytkownika. Toasty, modale, operacje na schowku, parsery uploadowanych plików, edytory WYSIWYG — wszystko, co czeka na akcję użytkownika, jest kandydatem do lazy loadingu.

  3. Niewielkie opóźnienie przy pierwszym użyciu jest do zaakceptowania. Pierwsze wywołanie płaci za pobranie i sparsowanie chunka. Przy toastach 50-100 ms opóźnienia przed pierwszym powiadomieniem jest nieodczuwalne — użytkownik właśnie kliknął przycisk i czeka na feedback, a nie mierzy czas stoperem.

Biblioteki, które najczęściej niepotrzebnie lądują w entry bundle'u:

BibliotekaTypowy rozmiarWzorzec
Toasty / powiadomienia15-30 KBDynamiczny composable (to, co zrobiliśmy)
Edytor WYSIWYG100-400 KBDynamiczny komponent ze stanem ładowania
Date picker30-80 KBDynamiczny komponent
Biblioteka wykresów50-200 KBDynamiczny komponent ze skeletonem
Generator PDF100-500 KBDynamiczny import po kliknięciu przycisku
Wrapper na Clipboard API5-15 KBDynamiczny composable

Ten wzorzec się skaluje. Jeśli masz bibliotekę wykresów ważącą 200 KB, która renderuje się na jednej stronie dashboardu, opakowanie jej w leniwy composable albo dynamiczny komponent trzyma ją całkowicie poza entry chunkiem. Oszczędności się sumują — każda odłożona biblioteka to waga zdjęta z każdego wejścia na stronę.

Co powinniśmy byli zrobić od początku

Z perspektywy czasu błędem nie był wybór vue3-toastify. Biblioteka jest dobrze utrzymywana, lekka jak na to, co robi, i ma czyste API. Błędem było potraktowanie jej jak zależności na poziomie frameworka, podczas gdy jest zależnością na poziomie pojedynczej funkcjonalności.

Zależności na poziomie frameworka — Vue, router, biblioteka do zarządzania stanem — naprawdę muszą siedzieć w entry chunku. Używa ich każda strona, każdy cykl renderowania, od pierwszego paintu w górę. Zależności na poziomie funkcjonalności — toasty, edytory WYSIWYG, biblioteki wykresów, generatory PDF — obsługują konkretne funkcje, odpalane konkretnymi akcjami użytkownika, na konkretnych stronach.

To rozróżnienie ma znaczenie, bo gdy zatrzesz tę granicę, bundler zaczyna optymalizować pod złą metrykę. Importując vue3-toastify w pluginie i w jedenastu komponentach, powiedzieliśmy mu: „to jest poziom frameworka, ładuj to wszędzie”. Uwierzył nam. Wykonał swoją robotę bezbłędnie — tylko że nie tę, o którą nam chodziło, bo daliśmy mu zły sygnał.

Zasada, którą teraz stosujemy: jeśli bibliotekę odpala interakcja użytkownika, a nie render strony, nie ma prawa pojawić się w statycznym imporcie. Każdy import na górze pliku to obietnica dana bundlerowi, że ten kod jest potrzebny w momencie załadowania modułu. Jeśli ta obietnica jest fałszywa — jeśli kod jest potrzebny dopiero po kliknięciu przycisku albo wysłaniu formularza — import powinien być dynamiczny.

Szersza zasada

Optymalizacja bundle'a zwykle skupia się na dużych wygranych: code splitting po route'ach, tree shaking nieużywanych eksportów, wymiana ciężkich bibliotek na lżejsze. To ważne rzeczy. Ale entry chunk zasługuje na osobną, drobiazgową kontrolę, bo jest jedynym chunkiem, który każdy odwiedzający pobiera na każdej stronie.

Większość zespołów pilnuje łącznego rozmiaru bundle'a. Znacznie mniej audytuje sam entry chunk. Cały bundle może ważyć 3 MB i nic złego się nie dzieje — pod warunkiem że każda strona pobiera tylko te chunki, których potrzebuje. Ale jeśli entry chunk ma 700 KB, z czego 100 KB to kod funkcji, których większość odwiedzających nigdy nie odpali, to każdy z nich płaci podatek za coś, czego nigdy nie użyje.

Sam audyt jest prosty:

  1. Odpal analizator bundle'a (npx nuxi analyze dla Nuxta, source-map-explorer dla innych frameworków)
  2. Patrz konkretnie na entry chunk — nie na największy chunk, tylko na entry chunk
  3. Przy każdym module w entry chunku zadaj pytanie: „czy to jest potrzebne, zanim wyrenderuje się pierwszy piksel?”
  4. Wszystko, przy czym odpowiedź brzmi „nie”, jest kandydatem na dynamiczny import

U nas audyt zajął piętnaście minut i wskazał jedną bibliotekę. Poprawka zajęła dwie godziny i dotknęła trzynastu plików. Zaoszczędzone 31 KB działa na każde wejście, każdego odwiedzającego, każdą sesję — od teraz aż do dnia, w którym usuniemy tę bibliotekę na dobre.

Pytanie nie brzmi „jak duży jest nasz cały bundle?”, tylko „ile z entry chunka jest naprawdę potrzebne do pierwszego renderu?”.

Każdy kod w entry chunku, który nie jest wymagany do pierwszego renderu strony, jest podatkiem nałożonym na wszystkich odwiedzających. Biblioteki toastów, narzędzia tylko dla adminów, kod konfigurujący analitykę, inicjalizacja error trackingu — to wszystko da się odłożyć do dynamicznych importów bez żadnego widocznego wpływu na doświadczenie użytkownika.

Przez miesiące nie zauważaliśmy 31 KB kodu biblioteki toastów w entry chunku. Sama poprawka okazała się trywialna, gdy już znaliśmy problem. Ta lekcja nie jest o toastach, tylko o założeniach, które przyjmujemy, pisząc import na górze pliku.

Czasem najlepszą optymalizacją wydajności jest wyrzucenie kodu stamtąd, gdzie nigdy nie powinien był trafić.