Optymalizacja bundle'a JavaScript w 2026: tree shaking, code splitting i import maps

Praktyczny przewodnik po optymalizacji bundle'a JavaScript w 2026: tree shaking, code splitting przez dynamic import, import maps oraz kompresja Brotli. Z gotowymi konfiguracjami Vite 6, Webpacka 5 i bundlewatch dla CI.

Optymalizacja bundle JS 2026: tree shaking

Zaktualizowano: 2 czerwca 2026

Optymalizacja bundle'a JavaScript w 2026 to proces zmniejszania ilości kodu JS wysyłanego do przeglądarki przy użyciu trzech głównych technik: tree shakingu (usuwanie martwego kodu z modułów ESM), code splittingu (dzielenie bundle'a na mniejsze chunki ładowane na żądanie) oraz import maps (natywne mapowanie modułów bez bundlera). Razem potrafią zredukować rozmiar transferu nawet o 60–80%, poprawić metrykę INP o 100–300 ms i przyspieszyć LCP na wolniejszych urządzeniach mobilnych. W tym przewodniku pokażę dokładnie, jak je wdrożyć w 2026.

  • Tree shaking działa wyłącznie z modułami ESM i wymaga prawidłowo ustawionej flagi sideEffects w package.json. Najczęstszą przyczyną jego nieskuteczności jest import efektów ubocznych z CSS i polyfillów.
  • Code splitting przez import() w 2026 obsługują wszystkie współczesne bundlery natywnie; Vite 6 i Next.js 15 automatycznie dzielą chunki po route segmentach.
  • Import maps są wspierane przez wszystkie główne przeglądarki (Chrome 89+, Firefox 108+, Safari 16.4+). Pozwalają zrezygnować z bundlera dla małych projektów i CDN-owych dependencji.
  • esbuild i Rolldown (oparte na Rust) są obecnie 10–100× szybsze od Webpacka 5, a Vite 6 używa Rolldowna jako domyślnego bundlera produkcyjnego.
  • Brotli kompresja przy poziomie 11 redukuje rozmiar JS średnio o dodatkowe 18–22% względem gzip. W 2026 jest to standardowa konfiguracja wszystkich major CDN-ów.
  • Bundle budget na pierwszy załadowany JS powinien zmieścić się w 170 KB skompresowanym dla 75. percentyla urządzeń mobilnych (4G, mid-tier Android).

Czym jest optymalizacja bundle'a JavaScript?

Mówiąc krótko, optymalizacja bundle'a JavaScript to zbiór technik mających na celu zmniejszenie ilości kodu JS, który przeglądarka musi pobrać, sparsować, skompilować i wykonać przed wyrenderowaniem interaktywnej strony. W praktyce chodzi o cztery wymiary: rozmiar transferu (bajty po kompresji), rozmiar parsowania (bajty po dekompresji), liczbę żądań HTTP oraz priorytety ładowania poszczególnych chunków. W 2026 te wymiary mają bezpośredni wpływ na Core Web Vitals: zbyt duży bundle wydłuża LCP (przez render-blocking JS), psuje INP (przez długie zadania w głównym wątku) i pogarsza TTFB w SSR.

Szczerze mówiąc, wbrew temu co sugeruje wiele starszych poradników, sama minifikacja przez Terser albo SWC daje tylko 30–40% redukcji. Prawdziwy zysk pojawia się dopiero, gdy łączymy tree shaking, code splitting i agresywną kompresję Brotli. W jednym z moich ostatnich projektów (React 19 z biblioteką UI Mantine) zeszliśmy z 850 KB transferu do około 180 KB. Bez usuwania funkcjonalności. W kolejnych sekcjach pokażę każdą technikę z gotowym kodem produkcyjnym, który możesz wdrożyć w swoim projekcie jeszcze dziś.

Tree shaking w 2026: jak działa eliminacja martwego kodu?

Tree shaking to statyczna analiza grafu importów ESM, podczas której bundler oznacza eksporty nigdy nieużywane jako "martwe" i usuwa je z finalnego bundle'a. Termin pochodzi z Rollupa (2015) i opiera się na deterministycznej naturze modułów ECMAScript. W przeciwieństwie do CommonJS, gdzie require() może być wykonane warunkowo, statement import jest statycznie analizowalny już na etapie parsowania. Dlatego pierwszą i najważniejszą decyzją w 2026 jest konsekwentne używanie ESM w całym repozytorium, włącznie z zależnościami z node_modules.

Drugim warunkiem skutecznego tree shakingu jest prawidłowo zdefiniowane pole sideEffects w package.json. Bez niego bundler przyjmuje pesymistycznie, że każdy moduł może mieć efekt uboczny (np. modyfikację globala) i nie usuwa go, nawet jeśli żaden eksport nie jest używany. Oto poprawna konfiguracja dla biblioteki publikowanej na npm w 2026:

{
  "name": "@example/ui-kit",
  "version": "3.0.0",
  "type": "module",
  "main": "./dist/index.js",
  "module": "./dist/index.js",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    },
    "./styles.css": "./dist/styles.css"
  },
  "sideEffects": [
    "*.css",
    "./dist/polyfills.js"
  ]
}

Tablica sideEffects wymienia wzorce plików, które MAJĄ efekty uboczne. Wszystkie pozostałe są bezpieczne do usunięcia. Pliki CSS zawsze trzeba zachować (importują style, które są efektem ubocznym). Według oficjalnej dokumentacji Webpacka błędne ustawienie sideEffects: false potrafi wyłączyć ważne polyfille i jest najczęstszym źródłem regresji w produkcji.

Sprawdzenie skuteczności tree shakingu

Do weryfikacji polecam rollup-plugin-visualizer albo vite-bundle-visualizer. Pokazują dokładnie, które moduły zajmują miejsce po tree shakingu:

// vite.config.ts
import { defineConfig } from 'vite';
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  build: {
    rollupOptions: {
      plugins: [
        visualizer({
          filename: 'dist/stats.html',
          template: 'treemap',
          gzipSize: true,
          brotliSize: true,
        }),
      ],
    },
  },
});

Po buildzie otwórz dist/stats.html. Jeżeli widzisz tam pełną bibliotekę typu lodash zamiast pojedynczych funkcji, masz problem: importujesz przez import _ from 'lodash' zamiast import debounce from 'lodash/debounce' (lub jeszcze lepiej lodash-es, które wspiera tree shaking natywnie).

Code splitting: dynamic imports i lazy loading komponentów

Code splitting dzieli bundle na mniejsze chunki ładowane dopiero, gdy są potrzebne. W 2026 standardem jest dynamiczny import() ze specyfikacji ECMAScript, a wszystkie współczesne bundlery (Vite, Webpack, Rolldown, esbuild) traktują każdy import() jako punkt podziału. Najczęstsze wzorce to splitting per route (każda strona ma swój chunk) oraz splitting per komponent (modale, edytory, wykresy ładują się dopiero po interakcji).

Oto produkcyjny przykład dla React 19 z lazy loadem ciężkiego komponentu edytora:

import { lazy, Suspense, useState } from 'react';

// Edytor ~280 KB, ładujemy tylko gdy user kliknie "Edytuj"
const RichTextEditor = lazy(() =>
  import('./RichTextEditor').then((m) => ({ default: m.RichTextEditor }))
);

export function ArticleView({ article }) {
  const [editing, setEditing] = useState(false);

  return (
    <article>
      <h1>{article.title}</h1>
      {!editing ? (
        <>
          <div dangerouslySetInnerHTML={{ __html: article.html }} />
          <button onClick={() => setEditing(true)}>Edytuj</button>
        </>
      ) : (
        <Suspense fallback={<p>Ładowanie edytora…</p>}>
          <RichTextEditor initialContent={article.html} />
        </Suspense>
      )}
    </article>
  );
}

W ten sposób użytkownik, który tylko czyta artykuł (a to jakieś 90% ruchu), nigdy nie pobiera kodu edytora. Przekłada się to bezpośrednio na lepszy INP, bo główny wątek nie musi parsować 280 KB nieużywanego JS. Jeśli zależy ci na maksymalnym INP, warto zajrzeć do osobnego przewodnika o debugowaniu INP z Long Animation Frames API, gdzie pokazuję dokładne profilowanie długich zadań.

Preload i prefetch dla code-split chunków

Code splitting bez priorytetyzacji bywa pułapką, bo opóźnia kluczowe zasoby. Rozwiązanie to <link rel="modulepreload"> dla chunków potrzebnych natychmiast oraz Speculation Rules API dla nawigacji:

<!-- Modulepreload pobiera ESM równolegle z głównym dokumentem -->
<link rel="modulepreload" href="/assets/chunk-react-vendor.js" />
<link rel="modulepreload" href="/assets/chunk-router.js" />

<!-- Prerender następnej strony przy hover (Speculation Rules) -->
<script type="speculationrules">
{
  "prerender": [
    { "where": { "href_matches": "/article/*" }, "eagerness": "moderate" }
  ]
}
</script>

Import maps: natywna alternatywa dla bundlera

Import maps to standard W3C, który pozwala przeglądarce mapować specyfikatory modułów (np. react) na konkretne URL-e, bez potrzeby bundlera czy bare specifier transformacji. W 2026 są wspierane przez 96% globalnego ruchu (Chrome 89+, Firefox 108+, Safari 16.4+, Edge 89+) i stają się realną opcją dla małych aplikacji oraz hybryd SSR z island architecture.

Przykład prostej aplikacji bez bundlera, która wciąż używa React 19 z CDN:

<!DOCTYPE html>
<html lang="pl">
<head>
  <meta charset="utf-8" />
  <script type="importmap">
  {
    "imports": {
      "react": "https://esm.sh/[email protected]",
      "react-dom/client": "https://esm.sh/[email protected]/client",
      "@/": "/src/"
    }
  }
  </script>
</head>
<body>
  <div id="app"></div>
  <script type="module">
    import { createRoot } from 'react-dom/client';
    import { App } from '@/App.js';

    createRoot(document.getElementById('app')).render(<App />);
  </script>
</body>
</html>

Import maps + bundler hybrydowy

Ciekawe podejście to scenariusz, w którym bundler tworzy chunki, ale wspólne dependencje (np. React) ładujemy z CDN przez import map. Daje to lepszy cache między domenami i pozwala wyciągnąć ciężkie biblioteki z głównego bundle'a. Vite 6 i SvelteKit oferują tę opcję natywnie przez plugin vite-plugin-importmaps.

Webpack vs Vite vs Rollup vs esbuild, porównanie

Wybór bundlera w 2026 powinien być świadomy, bo różnice w czasie buildu i jakości tree shakingu są znaczące. Poniższa tabela podsumowuje najważniejsze wymiary po stabilizacji Rolldowna w Vite 6 (kwiecień 2026).

CechaWebpack 5Vite 6 (Rolldown)Rollup 4esbuild 0.25
Czas buildu (medium SPA)45–80 s3–6 s15–25 s0.5–2 s
HMR (dev)średninatychmiastowybrak natywnegonatychmiastowy
Jakość tree shakingudobradoskonaładoskonaładobra
Code splittingzaawansowanyzaawansowanyzaawansowanypodstawowy
Ekosystem pluginównajwiększyduży (kompatybilny z Rollup)dużyśredni
Wsparcie CSS/assetpełnepełneprzez pluginypodstawowe
Rekomendacja 2026legacydomyślny wybórbibliotekinarzędzia CLI

Krótkie podsumowanie. Dla nowej aplikacji w 2026 zacznij od Vite 6, bo łączy szybkość esbuilda w dev mode z jakością Rolldowna w produkcji. Webpack 5 ma sens tylko dla utrzymania starszych projektów albo specyficznych integracji (Module Federation w wersji 1.5+). Rollup wciąż króluje przy publikowaniu bibliotek na npm dzięki najczystszemu outputowi ESM. esbuild zostaw do narzędzi CLI, devserverów i serverless bundlowania, gdzie liczy się każda milisekunda.

Jak zmierzyć rozmiar bundle'a JavaScript?

Pomiar musi obejmować trzy poziomy: rozmiar transferu (brotli), rozmiar po dekompresji (parse cost) oraz koszt egzekucji w głównym wątku. Najszybszy sposób w 2026 to zestaw Lighthouse + Bundlewatch + WebPageTest. W CI pipeline polecam bundlewatch, który blokuje merge'y przekraczające zdefiniowany budżet:

// bundlewatch.config.json
{
  "files": [
    {
      "path": "dist/assets/index-*.js",
      "maxSize": "170 KB",
      "compression": "brotli"
    },
    {
      "path": "dist/assets/chunk-vendor-*.js",
      "maxSize": "90 KB",
      "compression": "brotli"
    }
  ],
  "ci": {
    "trackBranches": ["main", "production"]
  }
}

Dla głębszej analizy użyj zakładki Coverage w Chrome DevTools. Pokazuje ona, ile procent pobranego JS nigdy się nie wykonało. Jeśli widzisz tam wartości powyżej 60% dla głównego bundle'a, masz wyraźny sygnał do agresywniejszego code splittingu. Warto połączyć to z analizą krytycznej ścieżki renderowania, opisałem ją dokładnie w przewodniku o optymalizacji krytycznej ścieżki renderowania.

Kompresja Brotli i bundle budgets

Brotli przy poziomie 11 (max) jest dziś standardem dla statycznych zasobów. Według web.dev oraz HTTP Archive 2026, mediana redukcji względem gzip dla JavaScriptu wynosi 18–22%. Cloudflare, Fastly, Vercel i Netlify domyślnie serwują Brotli; własny Nginx wymaga modułu ngx_brotli:

# nginx.conf, Brotli dla statycznych assetów
brotli_static on;
brotli on;
brotli_comp_level 6;
brotli_types
  application/javascript
  application/json
  text/css
  text/html
  text/plain
  image/svg+xml;

# Brotli precompresji (poziom 11) z build pipeline
location ~* \.(js|css)$ {
  add_header Cache-Control "public, max-age=31536000, immutable";
}

Bundle budget to twardy limit, którego nie wolno przekroczyć. Rekomendacje na 2026 dla pierwszego paint paylodu (HTML + krytyczny JS + CSS): poniżej 170 KB skompresowane dla mid-tier Androida (75. percentyl), poniżej 100 KB, jeśli celujesz w rynki z wolniejszą siecią. Przekroczenie tych wartości praktycznie zawsze powoduje regresję LCP powyżej 2.5 s na urządzeniach 4G. Sam się o tym przekonałem, gdy w jednym projekcie e-commerce dorzucenie 40 KB do głównego chunka cofnęło LCP o pełne 600 ms na średnim Pixelu.

Najczęstsze błędy obniżające skuteczność tree shakingu

Po przeglądzie kilkudziesięciu projektów klientów w ostatnim roku zebrałem listę błędów, które systematycznie psują tree shaking nawet w dobrze skonfigurowanym buildzie:

  1. Import całej biblioteki zamiast pojedynczych funkcji. import * as utils from 'lodash' wciąga 70 KB; import debounce from 'lodash/debounce' wciąga 2 KB.
  2. Mieszanie CJS i ESM w monorepo. Jeden pakiet w CommonJS zatruwa cały graf, bo bundler nie wie, czy moduł ma side effects.
  3. Barrel files z efektami ubocznymi. index.ts reeksportujący wszystko z folderu wymusza importowanie całego folderu. Rozwiązanie: ESM exports w package.json z subpath exports.
  4. Polyfille importowane bezwarunkowo. Zamiast import 'core-js/stable' w głównym entry, użyj @babel/preset-env z useBuiltIns: 'usage', który dorzuca polyfille tylko dla używanych API.
  5. Brak "type": "module" w package.json. Bez tego Node i bundlery traktują pliki .js jako CommonJS i tracą tree shaking.
  6. Skrypty zewnętrzne ładowane synchronicznie. Tagi Google, Hotjar i podobne blokują główny wątek, przenieś je do Web Workerów (Partytown) jak opisałem w artykule o optymalizacji skryptów zewnętrznych.

Najczęściej zadawane pytania

Jak zmniejszyć rozmiar bundle'a JavaScript w 2026?

Połącz trzy techniki: tree shaking (poprzez ESM i poprawne sideEffects), code splitting (dynamiczny import() dla rzadko używanych komponentów) oraz kompresję Brotli na poziomie 11. W typowym projekcie React daje to redukcję rzędu 60–80%. Ustaw bundle budget 170 KB skompresowane dla pierwszego paint paylodu i pilnuj go w CI przez bundlewatch.

Czym różni się code splitting od lazy loading?

Code splitting to technika buildu, w której bundler rozdziela kod na osobne pliki. Lazy loading to wzorzec runtime: dopiero gdy komponent jest potrzebny, przeglądarka pobiera odpowiedni chunk. Code splitting bez lazy loadingu nie da zysku; lazy loading bez splittingu jest niemożliwy. W praktyce dynamiczny import() realizuje obie role jednocześnie.

Czy import maps zastąpią bundlery?

Nie w przewidywalnej przyszłości. Import maps świetnie radzą sobie z mapowaniem specyfikatorów, ale nie oferują tree shakingu, transpilacji TypeScript/JSX ani inteligentnego code splittingu. Najlepiej sprawdzają się w małych aplikacjach, micro-frontendach oraz scenariuszach, gdzie chcesz załadować wspólne dependencje (np. React) z CDN poza głównym bundle'em.

Jaki jest optymalny rozmiar bundle'a JS dla strony mobilnej?

Dla 75. percentyla urządzeń mobilnych (mid-tier Android, 4G) trzymaj pierwszy paint payload poniżej 170 KB skompresowanego Brotli. Po dekompresji to około 500 KB JS do sparsowania, co na Snapdragon 7 Gen 1 zajmuje około 250 ms. Powyżej tych wartości LCP regularnie przekracza próg 2.5 s, a INP wpada w zakres "needs improvement".

Czy esbuild jest lepszy niż Webpack?

Do dev serwera i prostych narzędzi CLI zdecydowanie tak, esbuild jest 50–100× szybszy. Do złożonych aplikacji produkcyjnych Vite 6 (oparty na Rolldown) jest obecnie lepszym wyborem, bo łączy szybkość esbuilda w dev mode z dojrzałością Rollupa w produkcji oraz pełnym wsparciem ekosystemu pluginów. Webpack 5 ma sens głównie dla legacy projektów i Module Federation.

Dlaczego tree shaking nie działa w moim projekcie?

Najczęstsze przyczyny w kolejności występowania: (1) importujesz bibliotekę w CommonJS zamiast ESM, (2) package.json nie ma flagi sideEffects lub jest ona błędnie ustawiona, (3) używasz barrel files (index.ts reeksportujących wszystko), (4) import jest dynamiczny ze zmienną w środku (import(\`./\${name}\`)). Uruchom vite-bundle-visualizer, by zobaczyć, które moduły są niepotrzebnie wciągane.

Editorial Team
O Autorze Editorial Team

Our team of expert writers and editors.