CodeWorlds
Powrót do kolekcji
Przewodnik14 min czytaniaZespół CodeWorlds

esbuild, bundler w Go i jego realne granice

esbuild w wersji 0.28.2 na licencji MIT. Kiedy uruchamiać go wprost, dlaczego numer wersji wciąż zaczyna się od zera i co odcina go od starych przeglądarek.

esbuild, bundler w Go i jego realne granice

esbuild to bundler i minifikator JavaScriptu napisany w Go przez Evana Wallace'a. Bieżąca wersja w rejestrze npm to 0.28.2 z 8 sierpnia 2026 roku, licencja MIT. Dziś rzadko uruchamia się go wprost, bo częściej siedzi wewnątrz innych narzędzi, a Vite w wersji ósmej przesunął go z zależności bezpośredniej do opcjonalnej zależności równorzędnej.

Co esbuild robi i czego nie robi

Biblioteka ma dwa główne wejścia. transform przyjmuje pojedynczy łańcuch znaków i zwraca przetworzony kod, nie dotykając systemu plików i nie rozwiązując importów. build czyta pliki z dysku, rozwiązuje importy, skleja je w jeden lub kilka plików wyjściowych i zapisuje wynik. Do tego dochodzą warianty synchroniczne transformSync i buildSync, funkcja context do pracy przyrostowej, analyzeMetafile do raportu o rozmiarze wyniku oraz initialize i stop do sterowania procesem potomnym.

Wbudowane loadery obejmują js, jsx, ts, tsx, json, css, local-css, text, base64, dataurl, file, binary, copy i empty. Formaty wyjścia to iife, cjs i esm. Ustawienie platform przyjmuje wartości browser, node albo neutral i zmienia naraz domyślne wartości kilku innych opcji, między innymi format, mainFields i conditions. To pierwsza rzecz, którą sprawdzam, gdy wynik budowania wygląda inaczej niż się spodziewałem.

Lista rzeczy, których esbuild nie robi, jest w dokumentacji zapisana wprost jako decyzja, a nie jako zaległość. Autor wyklucza z rdzenia obsługę innych języków frontendowych, czyli Elma, Svelte, Vue i Angulara, sprawdzanie typów TypeScriptu, API do manipulacji drzewem składniowym, hot module replacement oraz module federation. Sam opisuje esbuild jako linker dla weba: narzędzie, które umie przekształcić i skleić JavaScript oraz CSS, a wszystko, co dzieje się wcześniej, powinno być kodem zewnętrznym.

Skala użycia jest myląca, jeśli czytać ją wprost. W tygodniu od 13 do 19 sierpnia 2026 roku pakiet esbuild zanotował 226,5 miliona pobrań, Vite 143,0 miliona, Rollup 102,6 miliona, a webpack 46,5 miliona. Ta pierwsza liczba mówi o obecności w drzewie zależności setek tysięcy projektów, a nie o liczbie osób, które napisały własny skrypt budujący na jego API.

Wersja, licencja i zawartość opublikowanej paczki

Licencja jest tu spójna w sposób rzadko spotykany i warto to napisać wprost, bo w wielu projektach z tej kolekcji tak nie jest. Plik LICENSE.md w repozytorium evanw/esbuild zawiera tekst MIT z notą „Copyright (c) 2020 Evan Wallace”. Pole license w rejestrze npm dla pakietu esbuild ma wartość MIT. Opublikowane archiwum wersji 0.28.2 zawiera ten sam plik LICENSE.md obok README.md, katalogu bin, katalogu lib i skryptu install.js. Trzy źródła, jedna odpowiedź.

Jest jednak jedno zastrzeżenie, które ujawnia się dopiero po rozpakowaniu tego, co faktycznie się uruchamia. Pakiet esbuild nie zawiera programu wykonywalnego. Deklaruje za to dwadzieścia sześć zależności opcjonalnych z binariami dla poszczególnych platform, od @esbuild/darwin-arm64 przez @esbuild/linux-x64 po @esbuild/openharmony-arm64. Archiwum @esbuild/darwin-arm64 w wersji 0.28.2 zawiera dokładnie trzy elementy: bin/esbuild, package.json oraz README.md. Pliku licencyjnego tam nie ma, a README.md to trzy zdania odsyłające do repozytorium, bez tekstu licencji. Metadane rejestru deklarują MIT, więc skaner zależności zobaczy poprawną wartość, ale samo archiwum z binarium jej nie niesie. Jeśli w firmie kompletujesz plik z tekstami licencji dla dystrybuowanego produktu, tekst MIT trzeba wziąć z pakietu nadrzędnego albo z repozytorium.

Sposób dostarczenia binarium ma jeszcze jedną konsekwencję. Pakiet esbuild uruchamia skrypt postinstall o treści node install.js, który odnajduje właściwe binarium wśród zależności opcjonalnych, sprawdza zgodność jego wersji i w razie potrzeby dociąga brakujący pakiet osobnym wywołaniem npm install. Instalacja z pominięciem skryptów albo z pominięciem zależności opcjonalnych zostawia niedziałające narzędzie. Zmienna środowiskowa ESBUILD_BINARY_PATH pozwala wskazać binarium ręcznie, co ratuje sytuację w obrazach kontenerów budowanych bez dostępu do sieci. Pole engines wymaga Node w wersji co najmniej 18.

Numer wersji poniżej 1.0 i odwrócona semantyka

Pakiet ma za sobą 482 opublikowane wersje od listopada 2017 roku i wciąż numer główny wynosi zero. To nie jest zaniedbanie. W sekcji o gotowości produkcyjnej autor nazywa esbuild późną fazą bety i podaje dwa powody: dzielenie kodu jest nadal prymitywne, a społeczność mniejsza niż wokół innych narzędzi JavaScriptu.

Praktyczna konsekwencja dotyczy zakresów wersji i łatwo się na niej sparzyć. W esbuildzie wersje łatki są przeznaczone na zmiany zgodne wstecz, a wersje mniejsze na zmiany niezgodne wstecz. To odwrotnie niż w typowym czytaniu semantycznego wersjonowania, gdzie niezgodność sygnalizuje numer główny. Zapis ^0.28.0 w pliku package.json przypadkiem daje właściwe zachowanie, bo dla wersji zerowych menedżer pakietów i tak zawęża zakres do łatek. Natomiast ~0.28 interpretowane szeroko, >=0.28.0 albo ręczne podbicie do kolejnej wersji mniejszej mogą wciągnąć zmianę łamiącą. Dokumentacja zaleca przypięcie dokładnej wersji albo pary numerów główny i mniejszy.

Drugie ryzyko jest organizacyjne. Projekt ma jednego głównego autora, który wprost napisał, że nie prowadzi obecnie aktywnego rozwoju funkcji, bo jego bieżące projekty nie obejmują dużej bazy kodu webowego. Deklaruje utrzymanie i cykliczne wydania, w tym obsługę nowej składni JavaScriptu i TypeScriptu, oraz zamiar powrotu do większych prac. Wydania faktycznie wychodzą, 0.27.0 w listopadzie 2025, 0.28.0 w kwietniu 2026, 0.28.2 w sierpniu 2026. Ale plan zakłada, że narzędzie osiągnie stan w miarę stabilny i przestanie przyrastać o funkcje. Jeśli w Twojej organizacji kryterium wyboru zależności to liczba aktywnych utrzymujących, esbuild tego kryterium nie spełnia i lepiej wiedzieć o tym przed wdrożeniem niż po nim.

Kiedy sięgnąć po esbuild wprost

Pierwszy sensowny przypadek to budowanie biblioteki bez frameworka. Masz katalog src, chcesz wypuścić ESM i CommonJS, deklaracje typów generuje osobno tsc, a zależności mają zostać na zewnątrz paczki. To trzydzieści linii skryptu i budowanie liczone w setkach milisekund.

Code
JavaScript
import * as esbuild from 'esbuild'

const shared = {
  entryPoints: ['src/index.ts'],
  bundle: true,
  platform: 'neutral',
  target: ['es2022', 'node18'],
  sourcemap: 'linked',
  packages: 'external',
  logLevel: 'info'
}

await esbuild.build({
  ...shared,
  format: 'esm',
  outfile: 'dist/index.mjs'
})

await esbuild.build({
  ...shared,
  format: 'cjs',
  outfile: 'dist/index.cjs'
})

Ustawienie packages: 'external' zostawia wszystkie importy pakietów nierozwiązane, więc w paczce ląduje tylko Twój kod. Bez niego bundle: true wciągnie do wyniku całe drzewo zależności, co dla biblioteki publikowanej do rejestru jest niemal zawsze błędem. Alternatywą jest wyliczenie nazw w external, gdy część zależności ma zostać wbudowana, a część nie.

Drugi przypadek to szybkie skrypty i jednorazowe przekształcenia. Funkcja transform nie dotyka dysku i nadaje się do przetwarzania kodu w pamięci, na przykład w narzędziu, które kompiluje fragment TypeScriptu przed wykonaniem.

Code
JavaScript
import * as esbuild from 'esbuild'

const source = 'const answer: number = 42\nexport default answer\n'

const result = await esbuild.transform(source, {
  loader: 'ts',
  format: 'esm',
  target: 'node20',
  minifyWhitespace: true,
  sourcemap: 'inline'
})

console.log(result.code)
console.log(result.warnings.length)

Trzeci przypadek to pakowanie funkcji dla środowiska serwerowego, gdzie liczy się rozmiar archiwum i czas zimnego startu. Tu platform: 'node', format: 'cjs' albo esm, minify: true i wskazanie w external tych modułów, które dostawca udostępnia w środowisku uruchomieniowym.

Czwarty przypadek jest negatywny i wart osobnego zdania. Jeśli pracujesz w Bun albo w Deno, obydwa środowiska mają własne wbudowane ścieżki budowania i dokładanie esbuilda dorzuca tylko kolejne binarium. Podobnie w projekcie na Vite 8: skoro narzędzie przeszło na Rolldown, ręczne dopinanie esbuilda ma sens tylko wtedy, gdy używasz go do czegoś innego niż budowanie samej aplikacji.

Twarde ograniczenia: stare przeglądarki, typy i dzielenie kodu

Ustawienie target przyjmuje nazwy środowisk z numerami wersji: chrome, deno, edge, firefox, hermes, ie, ios, node, opera, rhino, safari, a także wersje języka w rodzaju es2020. Domyślna wartość to esnext. Obecność ie na liście łatwo odczytać jako obietnicę, którą narzędzie spełnia tylko częściowo. Dokumentacja mówi wprost, że esbuild potrafi obniżyć większość nowszej składni najwyżej do es6, więc przy celu es5 po prostu zgłasza błąd w miejscu nieobsługiwanej konstrukcji. Do tego dochodzi brak automatycznego wstrzykiwania wypełniaczy: target dotyczy składni, nie API, a Promise czy Array.prototype.flat trzeba doimportować samodzielnie, na przykład z core-js.

Praktycznie oznacza to, że projekt z wymogiem obsługi Internet Explorera albo bardzo starych przeglądarek mobilnych nie zbuduje się esbuildem bez dodatkowego przebiegu przez Babel. Dla większości nowych projektów to bez znaczenia, ale w utrzymywanym systemie firmowym z listą wspieranych przeglądarek sprzed dekady to kryterium wykluczające.

Brak sprawdzania typów działa inaczej, niż podpowiada intuicja. esbuild usuwa adnotacje typów i kompiluje plik po pliku, nigdy nie widząc całego programu naraz. Kod z błędem typu przejdzie przez budowanie bez ostrzeżenia. Sprawdzanie zostaje zadaniem osobnego wywołania tsc --noEmit, co opisuję szerzej w artykule o TypeScript. Ten podział ma zaletę, bo budowanie nie czeka na analizę typów, i wadę, bo w potoku ciągłej integracji trzeba pamiętać o obu krokach. Warto zauważyć, że to samo rozwiązanie stosują dziś praktycznie wszystkie szybkie bundlery.

Dzielenie kodu jest najsłabszym punktem i autor sam tak je opisuje. Działa wyłącznie z formatem esm, wymaga ustawienia outdir zamiast outfile, a dokumentacja odnotowuje znany problem z kolejnością instrukcji importu między wygenerowanymi fragmentami. Bez włączonego splitting wyrażenie import() nie tworzy osobnego pliku, tylko zamienia się w Promise.resolve().then(() => require()), zachowując asynchroniczną semantykę, ale wciągając kod do tego samego wyniku. Dla aplikacji, w której podział na trasy ma realnie skracać czas pierwszego ładowania, to argument za Rollupem, Rolldownem albo webpackiem.

Code
JavaScript
import * as esbuild from 'esbuild'

// splitting dziala tylko z format: 'esm' i wymaga outdir
await esbuild.build({
  entryPoints: ['src/home.ts', 'src/about.ts'],
  bundle: true,
  splitting: true,
  format: 'esm',
  outdir: 'dist',
  chunkNames: 'chunks/[name]-[hash]',
  entryNames: '[dir]/[name]-[hash]',
  metafile: true
})

Wtyczki, tryb kontekstu i metafile

Wtyczka to obiekt z polem name i funkcją setup, która dostaje obiekt build. Rejestruje się na nim onResolve, onLoad, onStart, onEnd i onDispose, ma się dostęp do initialOptions, do build.resolve oraz do pełnej kopii biblioteki pod build.esbuild. Filtry w onResolve i onLoad to wyrażenia regularne wykonywane po stronie Go, więc muszą być zapisane w składni obsługiwanej przez tamtejszy silnik, bez lookbehind.

Code
JavaScript
const envPlugin = {
  name: 'env',
  setup(build) {
    build.onResolve({ filter: /^env$/ }, (args) => ({
      path: args.path,
      namespace: 'env-ns'
    }))

    build.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({
      contents: JSON.stringify(process.env),
      loader: 'json'
    }))

    build.onEnd((result) => {
      console.log(`bledy: ${result.errors.length}`)
    })
  }
}

await import('esbuild').then((esbuild) =>
  esbuild.build({
    entryPoints: ['src/app.ts'],
    bundle: true,
    outfile: 'dist/app.js',
    plugins: [envPlugin]
  })
)

Granica jest tutaj wyraźna. Wtyczka podmienia treść pliku jako łańcuch znaków i decyduje o rozwiązywaniu ścieżek, ale nie dostaje drzewa składniowego i nie może go modyfikować. Wszystko, co wymaga transformacji na poziomie AST, trzeba zrobić przed przekazaniem kodu do esbuilda albo w innym narzędziu.

Funkcja context zwraca obiekt z metodami rebuild, watch, serve, cancel i dispose. Trzyma stan między przebiegami, więc kolejne budowania są szybsze, a serve uruchamia prosty serwer plików statycznych na potrzeby pracy lokalnej. To nie jest zamiennik serwera deweloperskiego z hot module replacement, bo tego esbuild nie ma i mieć nie będzie.

Code
JavaScript
import * as esbuild from 'esbuild'

const ctx = await esbuild.context({
  entryPoints: ['src/app.ts'],
  bundle: true,
  outdir: 'public',
  sourcemap: true
})

await ctx.watch({ delay: 50 })
const { hosts, port } = await ctx.serve({ servedir: 'public', port: 8000 })
console.log(`nasluchuje na ${hosts[0]}:${port}`)

process.on('SIGINT', async () => {
  await ctx.dispose()
  process.exit(0)
})

Opcja metafile: true dokłada do wyniku obiekt z sekcjami inputs i outputs. Dla każdego wejścia podaje rozmiar w bajtach i listę importów, dla każdego wyjścia rozmiar, wkład poszczególnych wejść w bajtach, listę eksportów i nazwę pliku wejściowego. analyzeMetafile zamienia to na czytelny raport tekstowy. To najprostszy dostępny sposób, by odpowiedzieć na pytanie, która zależność odpowiada za przyrost rozmiaru paczki, i działa bez dodatkowych wtyczek.

esbuild na tle alternatyw

CechaesbuildRollupRolldownwebpackSWC
ImplementacjaGoJavaScript z natywnym rdzeniem w RustRustJavaScriptRust
Bieżąca wersja0.28.24.62.51.2.55.109.21.16.1
LicencjaMITMITMITMITApache 2.0
Rolabundler i minifikatorbundlerbundlerbundlertranspilator i minifikator
Sprawdzanie typówbrakbrakbrakbrakbrak
Dzielenie kodutylko format esmpełnepełnepełnenie dotyczy
Model wtyczekonResolve i onLoadhaki Rollupahaki zgodne z Rollupemloadery i wtyczkiwtyczki w WebAssembly

Najciekawsza zmiana ostatniego roku dotyczy Vite. W wersji 7.3.6 pakiet vite miał esbuild wśród zależności bezpośrednich obok rollup. W wersji 8.2.2 zależnością bezpośrednią jest rolldown w wersji 1.2.x, a esbuild przeniósł się do zależności równorzędnych z zakresem ^0.27.0 || ^0.28.0 i wpisem optional: true. W praktyce nowy projekt na Vite 8 nie ściąga esbuilda wcale, chyba że coś innego w drzewie zależności go potrzebuje. Kierunek jest czytelny: jeden silnik w Rust obsługuje i tryb deweloperski, i budowanie produkcyjne, zamiast dwóch narzędzi o rozjeżdżających się zachowaniach.

To nie znaczy, że esbuild przestaje mieć sens. Znaczy tyle, że przestaje być domyślną warstwą pod frameworkiem i wraca do roli, w której jest najlepszy: samodzielnego, przewidywalnego narzędzia do prostych budowań. Podobny podział ról widać w innych częściach zestawu narzędziowego, gdzie Biome przejmuje formatowanie i analizę statyczną, a Turborepo zarządza pamięcią podręczną zadań w monorepozytorium.

Warto znać kierunek, w którym idzie ekosystem wokół Vite. Rolldown to bundler w Rust z interfejsem wtyczek Rollupa, budowany po to, żeby zastąpić układ, w którym tryb deweloperski szedł przez esbuild, a produkcyjny przez Rollup. Dwa różne narzędzia w jednym potoku dawały różne wyniki i to jest właściwy powód tej zmiany, ważniejszy niż sama szybkość. Przed wdrożeniem sprawdź jednak granice zgodności z Rollupem, bo obietnica zgodnego interfejsu wtyczek nie oznacza pełnego pokrycia.

Typowe błędy

Ustawienie target: 'es5' w nadziei na obsługę starych przeglądarek. Budowanie przerwie się błędem przy pierwszej konstrukcji, której esbuild nie umie obniżyć poniżej es6. Poprawna reakcja to albo podniesienie minimalnej wersji przeglądarki, albo dołożenie osobnego przebiegu przez Babel.

Traktowanie budowania jako bramki jakości typów. esbuild wypisze poprawny plik z kodu, w którym tsc zgłosiłby kilkanaście błędów. W potoku ciągłej integracji tsc --noEmit musi być osobnym krokiem, inaczej błędy typów wyjdą dopiero w środowisku uruchomieniowym.

Włączenie splitting: true przy formacie cjs albo bez outdir. W obu przypadkach dostaniesz błąd konfiguracji, a nie po cichu gorszy wynik, ale komunikat bywa mylnie odczytywany jako awaria narzędzia.

Budowanie biblioteki z bundle: true bez packages: 'external' ani listy w external. Do publikowanej paczki trafia wtedy skopiowany kod wszystkich zależności, z rozmiarem i konfliktami wersji u odbiorcy.

Instalacja w obrazie kontenera z pominięciem skryptów instalacyjnych lub zależności opcjonalnych. Bez postinstall i bez pakietu z binarium esbuild nie ma czego uruchomić. W środowisku bez dostępu do sieci właściwym rozwiązaniem jest wskazanie ścieżki przez ESBUILD_BINARY_PATH.

Zapis wersji szerszy niż łatka, na przykład >=0.28.0. Przy odwróconej semantyce esbuilda kolejna wersja mniejsza może zawierać zmianę niezgodną wstecz i wejdzie do projektu bez ostrzeżenia.

Dokładanie esbuilda obok Vite 8 z przyzwyczajenia. Ta zależność nie jest już potrzebna do budowania aplikacji i tylko zwiększa liczbę binariów pobieranych przy instalacji.

FAQ

Czy esbuild nadaje się na produkcję mimo numeru wersji 0.x?

Autor określa go jako późną fazę bety, stabilną, ale niekompletną. Jest używany produkcyjnie od lat, między innymi wewnątrz Vite do wersji siódmej oraz w Amazon CDK. Warunek jest jeden: przypnij dokładną wersję albo parę numerów główny i mniejszy, bo w tym projekcie zmiana wersji mniejszej oznacza możliwą niezgodność wstecz.

Czy esbuild sprawdza typy TypeScriptu?

Nie i nie będzie. Usuwa adnotacje i kompiluje plik po pliku, nie widząc całego programu. Sprawdzanie typów uruchamiasz osobno przez tsc --noEmit, najlepiej równolegle do budowania, bo oba kroki są od siebie niezależne.

Jak zbudować bibliotekę w formatach ESM i CommonJS?

Dwa wywołania build z tym samym zestawem opcji i różnymi wartościami format oraz outfile, z packages: 'external', żeby zależności nie trafiły do wyniku. Deklaracje typów generuje tsc --emitDeclarationOnly, bo esbuild plików .d.ts nie tworzy.

Dlaczego Vite 8 nie instaluje esbuilda?

Bo od tej wersji zależnością bezpośrednią jest Rolldown, a esbuild figuruje jako opcjonalna zależność równorzędna z zakresem ^0.27.0 || ^0.28.0. W Vite 7.3.6 był jeszcze zwykłą zależnością obok Rollupa.

Czy esbuild obsłuży Internet Explorera?

Nazwa ie jest na liście dopuszczalnych celów, ale obniżanie składni działa najwyżej do es6, a wypełniacze dla brakujących API nie są dodawane automatycznie. Realnie do IE potrzebny jest dodatkowy przebieg przez Babel i ręczny import wypełniaczy.

Co się stanie, gdy zainstaluję pakiet bez skryptów instalacyjnych?

Skrypt postinstall nie odnajdzie i nie zweryfikuje binarium, więc wywołanie esbuild zakończy się błędem. Rozwiązania są dwa: dopuścić skrypt instalacyjny dla tego pakietu albo wskazać gotowe binarium zmienną ESBUILD_BINARY_PATH.

Czytaj dalej

Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie