Архитектура фронтенда
Пайплайны сборки: как исходник становится байтами, которые кэширует браузер
Срабатывает алерт перфа: главный бандл за ночь вырос со 180 KB до 410 KB. Никто не добавлял фичу. git blame находит одну строку — import _ from "lodash" вместо import debounce from "lodash/debounce". Целая библиотека уехала в бандл, потому что lodash — это CommonJS, и бандлер не смог сделать tree-shaking. Один импорт, 200 KB мёртвого кода, и три недели пользователи на медленных соединениях платили за это, прежде чем алерт перешёл порог.
Пайплайн — это пять стадий, а не один чёрный ящик
bun run build выглядит как одна команда, но это конвейер с отдельными станциями, и баги живут на стыках между ними.
- Resolve — превратить каждый
import "x"в реальный файл на диске. Обойтиnode_modules, учесть картыexports, выбрать ESM или CommonJS, пройти по алиасам путей изtsconfig. Отсюда берутся «module not found» и «два раза разные версии одного пакета». - Transform — переписывание пофайлово: TypeScript → JS, JSX →
createElement, современный синтаксис down-level под твои браузерные таргеты. Это горячий цикл — он запускается раз на файл, тысячи раз. - Bundle — сшить граф модулей в несколько файлов, решить границы code-splitting, поднять и дедуплицировать общие модули. Здесь происходит tree-shaking.
- Minify — переименовать локали, выкинуть пробелы и мёртвые ветки, схлопнуть AST. Terser — аккуратный, но медленный стандарт; минификатор esbuild сильно быстрее и почти такой же компактный.
- Emit — записать финальные файлы в
dist/, с именами по контент-хэшу и source maps.
Ментальная модель сеньора: transform пофайловый и до неприличия параллельный; bundle — на весь граф и последовательный. Этот раздел объясняет, почему один инструмент бывает быстр на одной стадии и медленен на другой, и почему Vite использует два инструмента (об этом ниже).
Почему инструменты на Go и Rust в 10–100× быстрее
Старый дефолт — Babel для transform, Terser для minify — написан на JavaScript и однопоточен по природе. esbuild (Go) и SWC (Rust) скомпилированы, многопоточны и не перепарсивают AST между проходами. Выигрыш не маленький. Собственные бенчмарки esbuild показывают сборку большого React-приложения за низкие сотни миллисекунд там, где Babel/Webpack тратят десятки секунд — обычно говорят про 10–100× в зависимости от нагрузки. Реальные миграции сообщают о сокращении стадии сборки на 93–96% (шаг transform в 30s падает до ~1–2s).
Почему так быстрее? Три причины, которые накапливаются: компилируемый язык с реальными потоками вместо event loop, единый общий AST, прогоняемый через parse/transform/minify, вместо перепарсинга на каждой границе инструментов, и агрессивное переиспользование памяти. Трейдофф, который взвешивает сеньор: у Babel всё ещё богатейшая экосистема плагинов (кастомные макросы, экзотические трансформы, легаси-таргеты), поэтому команды, которым нужен конкретный плагин Babel, иногда держат его для одного файла и гоняют esbuild/SWC для остального.
| Инструмент | Язык | Стадия | Скорость vs JS-базлайн |
|---|---|---|---|
| Babel | JS | transform | 1× (базлайн) |
| SWC | Rust | transform | ~20× быстрее |
| esbuild | Go | transform + bundle + minify | ~10–100× быстрее |
| Terser | JS | minify | 1× (аккуратный, медленный) |
Tree-shaking и три вещи, которые его ломают
Tree-shaking выкидывает экспорты, которые никто не импортирует. Он работает только на статических ES-модулях, потому что бандлер должен на этапе сборки доказать, что биндинг не используется. Три вещи ломают это доказательство, и каждая — реальный продовый инцидент:
- CommonJS.
module.exportsвычисляется в рантайме — бандлер не может статически узнать, какие ключи ты используешь.import _ from "lodash"(CJS) тащит всю библиотеку: импорт одного толькоisArrayтак стоит около 24 KB против ~46 байт изlodash-es(ESM). Фикс — использовать ESM-сборку (lodash-es) или глубокие импорты (lodash/debounce). - Сайд-эффекты. Если модуль делает работу при импорте (регистрирует полифил, мутирует глобал, инжектит CSS), бандлер обязан его сохранить, даже если экспорты выглядят неиспользуемыми, — выкинуть его значило бы изменить поведение. Именно это означает поле
"sideEffects": falseвpackage.json: обещание бандлеру, что импорт этого файла ничего наблюдаемого не меняет, поэтому неиспользуемые части можно безопасно выкинуть. Соври в этом поле — отгрузишь сломанную сборку; не укажи его на чистой библиотеке — отгрузишь мёртвый код. - Down-level в CommonJS.
@babel/preset-envиз Babel может превратить твои хорошие ESMimport/exportвrequire/module.exportsдо того, как их увидит бандлер, — тихо превращая shakeable-граф в непрозрачный. Тогда бандлер сохраняет всё. Это самое коварное: исходник выглядит shakeable, а стадия transform тихо это ломает.
Почему это работает
Флаг "sideEffects" — это контракт, а не подсказка. false означает «каждый файл в этом пакете чист при импорте». Если хотя бы один файл инжектит CSS как сайд-эффект, его надо перечислить: "sideEffects": ["*.css", "./src/polyfill.js"]. Ошибёшься в безопасную сторону (заявишь сайд-эффекты, которых нет) — потеряешь лишь часть оптимизации. Ошибёшься в опасную (заявишь чистоту, которой нет) — бандлер вырежет код, от которого зависит приложение: сборка проходит CI и падает в рантайме.
Dev и prod гоняют разные инструменты — и тут прячутся баги паритета
Современные dev-серверы (Vite) не бандлят в разработке. Они отдают твой исходник как нативные ES-модули прямо в браузер и дают браузеру запрашивать модули по требованию — поэтому dev-сервер стартует мгновенно независимо от размера приложения, а апдейты HMR прилетают за единицы или низкие десятки миллисекунд: меняется один модуль, а не пересобранный бандл. Прод другой: Vite бандлит через Rollup (и всё чаще Rolldown), потому что отдавать тысячи небандленых запросов модулей по сети было бы катастрофически медленно.
Два разных пути кода — esbuild/нативный-ESM в dev, Rollup-бандл в prod — означают, что dev и prod могут расходиться. Разный резолв модулей, разный code-splitting, разная обработка сайд-эффектов. Классический симптом — «работает у меня, сломано в проде»: циклический импорт, который нативный ESM терпит, а бандлер переупорядочивает, или зависимость, которую tree-shaking выкинул в прод-сборке, но которая полностью присутствует в dev. Привычка сеньора: гонять реальную прод-сборку локально (vite build && vite preview) до того, как ей доверять, и ставить смоук-тест против собранного вывода в CI, а не только против dev-сервера.
Сборка вашей команды на Webpack + Babel занимает 45s, а HMR 3–4s. Хочешь быстрее итерироваться, не рискуя продом. Выбери ход.
Бандл раздувается после того, как кто-то пишет import _ from 'lodash'. Почему tree-shaking не убрал неиспользуемые функции?
Фича работает в dev-сервере Vite, но ломается после vite build. Какая самая вероятная структурная причина?
Source maps и контент-хэши: стадия emit отрабатывает своё
Два вывода финальной стадии решают, отлаживаем ли prod и ждут ли вернувшиеся пользователи. Source maps мапят минифицированный, забандленный код dist обратно на исходник, чтобы стек-трейс указывал на Button.tsx:42, а не на index-a1b2c3.js:1:88431. Отгружай их в свой трекер ошибок (приватно), даже когда не выставляешь публично, иначе каждая продовая ошибка нечитаема.
Контент-хэширование — это контракт кэширования. Стадия emit называет файлы по хэшу их содержимого — index-a1b2c3.js — поэтому имя файла меняется только когда меняются байты. Это позволяет отдавать статику с Cache-Control: max-age=31536000, immutable (год), и браузер никогда не перекачивает неизменившийся чанк. Поменяй одну строку — изменится хэш только этого чанка; всё остальное остаётся в кэше. Классическая ошибка — вендорный код (React, твои крупные зависимости) делит чанк с кодом приложения; тогда каждый деплой приложения бьёт и вендорный кэш. Вынеси вендор в отдельный чанк, чтобы он держал свой хэш через твои частые деплои приложения.
Расставь стадии пайплайна сборки от исходника к отгруженному артефакту:
- 1 Resolve — превратить каждый импорт в реальный файл (node_modules, карты exports, алиасы)
- 2 Transform — пофайлово: TS→JS, JSX→createElement, down-level синтаксиса (esbuild/SWC/Babel)
- 3 Bundle — сшить граф модулей, code-split, tree-shake неиспользуемых экспортов
- 4 Minify — переименовать, выкинуть пробелы, срезать мёртвые ветки (Terser/esbuild)
- 5 Emit — записать файлы с контент-хэшем + source maps в dist/
- 01Коллега импортирует утилиту из CommonJS-пакета, и бандл растёт на 200 KB. Объясни, почему tree-shaking провалился и три класса вещей, которые его ломают.
- 02Почему баги «работает в dev, ломается в prod» случаются с инструментами вроде Vite, и как сеньор их предотвращает?
Пайплайн сборки — это пять стадий: resolve, transform, bundle, minify, emit, — и стыки между ними и есть место, где живут баги. Transform пофайловый и параллельный, поэтому компилируемые инструменты вроде esbuild (Go) и SWC (Rust) обгоняют JavaScript-овые Babel и Terser в 10–100× и срезают реальные шаги сборки на 90%+; bundle — на весь граф и последовательный, и именно там происходит tree-shaking. Tree-shaking-у нужны статические ES-модули, чтобы доказать, что экспорт не используется, поэтому его ломают CommonJS (рантайм-экспорты), сайд-эффекты (если package.json их честно не объявляет) и трансформы, которые down-level-ят ESM в CommonJS до взгляда бандлера, — любой из них может отгрузить целую библиотеку, которой ты не пользуешься. Dev и prod часто гоняют разные инструменты (нативный ESM + esbuild в dev, Rollup в prod), поэтому всегда валидируй реальную прод-сборку, чтобы ловить баги паритета рано. Наконец, стадия emit отрабатывает своё через source maps для отлаживаемых продовых ошибок и имена файлов по контент-хэшу, которые позволяют кэшировать статику на год, сбрасывая только те чанки, что реально изменились.