Szybsze wczytywanie stron Next.js i Gatsby dzięki szczegółowemu podziałowi na segmenty

Nowsza strategia dzielenia pakietów webpacka w Next.js i Gatsby minimalizuje duplikowanie kodu, aby poprawić wydajność wczytywania stron.

Chrome współpracuje z narzędziami i ramami w ekosystemie JavaScript open source. Niedawno dodaliśmy kilka nowych optymalizacji, aby poprawić wydajność wczytywania Next.js i Gatsby. W tym artykule omawiamy ulepszoną strategię podziału na części, która jest teraz domyślnie używana w obu tych ramach.

Wprowadzenie

Podobnie jak wiele innych frameworków internetowych, Next.js i Gatsby używają webpacka jako podstawowego narzędzia do tworzenia pakietów. Wersja 3 webpacka umożliwia wyprowadzanie modułów udostępnianych między różnymi punktami wejścia w jednym (lub kilku) fragmencie „commons”.CommonsChunkPlugin Udostępniony kod można pobrać osobno i wcześniej zapisać w pamięci podręcznej przeglądarki, co może poprawić wydajność wczytywania.

Ten wzorzec stał się popularny w wielu ramach aplikacji jednostronicowych, które stosowały konfigurację punktu wejścia i pakietu w taki sposób:

Konfiguracja wspólnego punktu wejścia i pakietu

Chociaż jest to praktyczne rozwiązanie, koncepcja łączenia całego kodu udostępnionego modułu w jedną część ma swoje ograniczenia. Moduły, które nie są udostępniane w każdym punkcie wejścia, mogą być pobierane na potrzeby tras, które ich nie używają. W efekcie pobierany jest większy kod niż to konieczne. Jeśli na przykład page1 wczytuje fragment common, wczytuje kod dla moduleC, mimo że page1 nie używa moduleC. Z tego powodu, wraz z kilkoma innymi, webpack w wersji 4 usunął tę wtyczkę na rzecz nowej: SplitChunksPlugin.

Ulepszone dzielenie na części

Domyślne ustawienia SplitChunksPlugin sprawdzają się w przypadku większości użytkowników. Wiele podzielonych fragmentów jest tworzonych w zależności od liczby warunków, aby zapobiec pobieraniu powtórzonego kodu na różnych trasach.

Jednak wiele frameworków internetowych, które korzystają z tego plugina, nadal stosuje podejście „single-commons” do podziału na fragmenty. Na przykład Next.js wygenerowałby pakiet commons zawierający dowolny moduł używany na ponad 50% stron oraz wszystkie zależności frameworka (react, react-dom itd.).

const splitChunksConfigs = {
  …
  prod: {
    chunks: 'all',
    cacheGroups: {
      default: false,
      vendors: false,
      commons: {
        name: 'commons',
        chunks: 'all',
        minChunks: totalPages > 2 ? totalPages * 0.5 : 2,
      },
      react: {
        name: 'commons',
        chunks: 'all',
        test: /[\\/]node_modules[\\/](react|react-dom|scheduler|use-subscription)[\\/]/,
      },
    },
  },

Chociaż dodanie kodu zależnego od frameworku do wspólnego fragmentu oznacza, że można go pobrać i zapisać w pamięci podręcznej dla dowolnego punktu wejścia, heurystyka oparta na wykorzystaniu, która polega na dodawaniu typowych modułów używanych na ponad połowie stron, nie jest zbyt skuteczna. Zmiana tego współczynnika może mieć jeden z 2 możliwych skutków:

  • Jeśli zmniejszysz współczynnik, zostanie pobrany większy niepotrzebny kod.
  • Jeśli zwiększysz współczynnik, więcej kodu zostanie powielonych na wielu trasach.

Aby rozwiązać ten problem, Next.js wprowadził inną konfigurację dla SplitChunksPlugin, która ogranicza niepotrzebny kod dla dowolnej ścieżki.

  • Każdy wystarczająco duży moduł innej firmy (większy niż 160 KB) jest dzielony na osobne fragmenty.
  • Dla zależności frameworka (react, react-dom itd.) tworzony jest osobny fragment frameworks.
  • Utworzono tyle udostępnionych fragmentów, ile było potrzebnych (maksymalnie 25).
  • Minimalny rozmiar generowanego fragmentu został zmieniony na 20 KB.

Ta strategia dzielenia na drobne elementy przynosi następujące korzyści:

  • Czas wczytywania stron został skrócony. Wygenerowanie wielu wspólnych fragmentów zamiast jednego pozwala zminimalizować ilość niepotrzebnego (lub duplikowanego) kodu w przypadku dowolnego punktu wejścia.
  • Ulepszone buforowanie podczas nawigacji. Dzielenie dużych bibliotek i zależności od frameworka na osobne fragmenty zmniejsza prawdopodobieństwo unieważnienia pamięci podręcznej, ponieważ obie te rzeczy rzadko się zmieniają, dopóki nie nastąpi aktualizacja.

Całą konfigurację przyjętą przez Next.js znajdziesz w sekcji webpack-config.ts.

Więcej żądań HTTP

SplitChunksPlugin określił podstawę szczegółowego dzielenia na części, a zastosowanie tego podejścia w ramach takich frameworków jak Next.js nie było zupełnie nową koncepcją. Wiele frameworków nadal używało jednak jednej strategii heurystycznej i „wspólnych” pakietów z kilku powodów. Dotyczy to również obaw, że znacznie więcej żądań HTTP może negatywnie wpłynąć na wydajność witryny.

Przeglądarki mogą otworzyć ograniczoną liczbę połączeń TCP z pojedynczym źródłem (w przypadku Chrome jest to 6), więc zminimalizowanie liczby fragmentów generowanych przez pakiet może zapewnić, że łączna liczba żądań nie przekroczy tego progu. Dotyczy to jednak tylko HTTP/1.1. Multipleksowanie w HTTP/2 umożliwia przesyłanie wielu żądań jednocześnie za pomocą jednego połączenia z jednego źródła. Inaczej mówiąc, nie musimy się martwić ograniczaniem liczby fragmentów emitowanych przez nasz pakiet.

Wszystkie główne przeglądarki obsługują HTTP/2. Zespoły Chrome i Next.js chciały sprawdzić, czy zwiększenie liczby żądań przez podzielenie pojedynczego pakietu „commons” Next.js na wiele współdzielonych fragmentów w jakikolwiek sposób wpłynie na wydajność wczytywania. Na początek zmierzono wydajność pojedynczej witryny, zmieniając maksymalną liczbę równoczesnych żądań za pomocą usługi maxInitialRequests.

Wydajność wczytywania strony przy zwiększonej liczbie żądań

W przypadku średnio 3 wyników z wielu prób na jednej stronie internetowej czasy load, rozpoczęcia renderowaniapierwszego wyrenderowania treści były mniej więcej takie same przy różnych wartościach maksymalnej początkowej liczby żądań (od 5 do 15). Co ciekawe, zauważyliśmy niewielki wzrost obciążenia tylko po agresywnym podzieleniu na setki żądań.

Wydajność wczytywania stron przy setkach żądań

Okazało się, że utrzymywanie się poniżej progu niezawodności (20–25 żądań) zapewnia odpowiednią równowagę między wydajnością wczytywania a skutecznością pamięci podręcznej. Po przeprowadzeniu testów bazowych wybrano wartość 25 jako maxInitialRequest.

Zmiana maksymalnej liczby żądań wykonywanych równolegle spowodowała, że powstało więcej niż 1 wspólny pakiet. Odpowiednie rozdzielenie ich dla każdego punktu wejścia znacznie zmniejszyło ilość niepotrzebnego kodu na tej samej stronie.

Zmniejszenie ładunku JavaScript dzięki zwiększeniu podziału na fragmenty

W tym eksperymencie modyfikowaliśmy tylko liczbę żądań, aby sprawdzić, czy będzie to miało negatywny wpływ na wydajność wczytywania strony. Wyniki wskazują, że ustawienie wartości maxInitialRequests na stronie testowej na 25 było optymalne, ponieważ zmniejszyło rozmiar danych JavaScript bez spowolnienia działania strony. Łączna ilość kodu JavaScript potrzebna do odświeżenia strony pozostała mniej więcej taka sama, co wyjaśnia, dlaczego wydajność wczytywania strony niekoniecznie poprawiła się po zmniejszeniu ilości kodu.

webpack używa domyślnego minimalnego rozmiaru 30 KB dla generowanego fragmentu. Jednak połączenie wartości maxInitialRequests 25 z minimalnym rozmiarem 20 KB dało lepsze wyniki w zakresie buforowania.

Zmniejszenie rozmiaru za pomocą szczegółowych fragmentów

Wiele platform, w tym Next.js, korzysta z przekierowywania po stronie klienta (obsługiwanego przez JavaScript), aby wstrzykiwać nowe tagi skryptu przy każdym przejściu na inną stronę. Ale jak można określić te dynamiczne fragmenty w czasie kompilacji?

Next.js używa pliku manifestu kompilacji po stronie serwera, aby określić, które wygenerowane fragmenty są używane przez różne punkty wejścia. Aby udostępnić te informacje klientowi, utworzyliśmy skrócony plik manifestu kompilacji po stronie klienta, który mapuje wszystkie zależności dla każdego punktu wejścia.

// Returns a promise for the dependencies for a particular route
getDependencies (route) {
  return this.promisedBuildManifest.then(
    man => (man[route] && man[route].map(url => `/_next/${url}`)) || []
  )
}
Dane wyjściowe z wielu udostępnionych fragmentów w aplikacji Next.js.

Ta nowsza strategia dzielenia na części została po raz pierwszy wdrożona w Next.js za pomocą flagi, gdzie była testowana na grupie wczesnych użytkowników. Wielu użytkowników zaobserwowało znaczne zmniejszenie całkowitej ilości kodu JavaScript używanego w całej witrynie:

Witryna Łączna zmiana w JS % różnicy
https://www.barnebys.com/ -238 KoB -23%
https://sumup.com/ -220 KB -30%
https://www.hashicorp.com/ -11 MB -71%
Zmniejszenie rozmiaru kodu JavaScript – we wszystkich trasach (skompresowany)

Ostateczna wersja została domyślnie wysłana w wersji 9.2.

Gatsby

Gatsby stosował wcześniej takie samo podejście do definiowania wspólnych modułów na podstawie heurystyki opartej na sposobie użycia:

config.optimization = {
  …
  splitChunks: {
    name: false,
    chunks: `all`,
    cacheGroups: {
      default: false,
      vendors: false,
      commons: {
        name: `commons`,
        chunks: `all`,
        // if a chunk is used more than half the components count,
        // we can assume it's pretty global
        minChunks: componentsCount > 2 ? componentsCount * 0.5 : 2,
      },
      react: {
        name: `commons`,
        chunks: `all`,
        test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
      },

Po optymalizacji konfiguracji webpacka w celu zastosowania podobnej strategii dzielenia na części, zauważyli oni znaczne zmniejszenie rozmiaru kodu JavaScript w wielu dużych witrynach:

Witryna Łączna zmiana w JS % różnicy
https://www.gatsbyjs.org/ -680 KB –22%
https://www.thirdandgrove.com/ -390 KB -25%
https://ghost.org/ -1,1 MB –35%
https://reactjs.org/ -80 KoB -8%
Zmniejszenie rozmiaru kodu JavaScript – we wszystkich trasach (skompresowany)

Aby dowiedzieć się, jak zaimplementowano tę logikę w konfiguracji webpacka, która jest domyślnie dostarczana w wersji 2.20.7, zapoznaj się z pull requestem.

Podsumowanie

Koncepcja dostarczania szczegółowych fragmentów nie jest charakterystyczna tylko dla Next.js, Gatsby czy webpack. Każdy programista powinien rozważyć udoskonalenie strategii dzielenia aplikacji na części, jeśli stosuje on dużą metodę pakietowania „commons”, niezależnie od używanego frameworku lub modułu pakietującego.

  • Jeśli chcesz zobaczyć, jak te same optymalizacje dzielenia na części działają w zwykłej aplikacji React, zapoznaj się z tym przykładem aplikacji React. Używa on uproszczonej wersji szczegółowej strategii dzielenia na części i może Ci pomóc w zaimplementowaniu w swojej witrynie podobnej logiki.
  • W przypadku podsumowania domyślnie tworzone są poszczególne fragmenty. Jeśli chcesz ręcznie skonfigurować zachowanie, zapoznaj się z manualChunks.