Next.js изнутри. Часть 7. Сборка.
Anastasia KotovaВведение
В предыдущих частях мы уже смотрели, что может появиться в директории .next/ после выполнения команды next build. Теперь посмотрим, как этот результат генерируется: какой механизм превращает .tsx-файлы в артефакты, которые потом обслуживают запросы пользователя.
Компилятор и бандлер
Когда мы говорим про сборку Next.js-приложения, за этим стоят две разные задачи.
Первая — трансформация отдельного файла: взять один .tsx, убрать из него типы, превратить JSX в вызовы React, заминифицировать и т.д. Эту работу делает компилятор. Ключевая его особенность в том, что он смотрит на файл изолированно. Он не знает, кто этот файл импортирует, какие ещё модули есть в проекте, что из этого попадёт на клиент, а что останется на сервере. Один файл на входе — один файл на выходе.
Вторая задача — построение графа зависимостей и бандлинг: пройти от точек входа по всем импортам, собрать граф модулей, разложить его на чанки, понять, какой код общий, а какой уникален для маршрута, вырезать неиспользуемое и разложить всё это по целевым средам — клиент, Node.js-сервер, Edge. Это работа бандлера, и он, в отличие от компилятора, видит приложение целиком.
В Next.js роли распределены так: компилятор — это SWC, а бандлер — Webpack, Turbopack или, экспериментально, Rspack. При этом Turbopack использует SWC внутри себя для трансформации отдельных файлов — только, в отличие от Webpack, который дёргает SWC как внешний нативный модуль через N-API, Turbopack сам написан на Rust и подключает SWC как обычную библиотеку.
Исторически стек Next.js двигался в одном направлении. Сначала это была классическая для JavaScript связка: Babel как компилятор, Terser как минификатор, Webpack как бандлер. В версии 12 Babel по умолчанию заменили на SWC — компилятор на Rust, что ускорило трансформацию в разы. Затем появился Turbopack, нацеленный на то, чтобы заменить связку SWC+Webpack единым Rust-инструментом. По итогу, как и в других частях фронтенда, в Next.js происходит миграция тулинга с JavaScript на Rust ради скорости.
SWC
SWC (Speedy Web Compiler) — компилятор на Rust, отвечающий за трансформацию отдельных файлов. На каждый .ts, .tsx или .js он выполняет предсказуемый набор преобразований: стирает TypeScript-аннотации, превращает JSX в вызовы рантайма React, при необходимости понижает современный синтаксис под список поддерживаемых браузеров, а на этапе оптимизации минифицирует код.
SWC написан на Rust, а Next.js исполняется в Node.js, поэтому связь между ними — нативный модуль через N-API, механизм, позволяющий вызывать Rust-код напрямую, без отдельного процесса. Устроено это так: под каждую комбинацию платформы и архитектуры опубликован свой пакет — @next/swc-linux-x64-gnu, @next/swc-darwin-arm64 и так далее. При установке Next.js подтягивается тот, что подходит нужной системе. Если подходящего нативного бинарника не нашлось, есть запасной вариант — сборка на WebAssembly. Она медленнее нативной, но работает где угодно.
Помимо базовых преобразований, SWC применяет набор трансформаций, специфичных для Next.js. В продакшене он может вырезать вызовы console.* и убирать служебные атрибуты вроде data-testid. Для CSS-in-JS есть трансформы, которые обеспечивают стабильные имена классов и корректную работу при серверном рендеринге.
Отдельного внимания заслуживают modularizeImports и optimizePackageImports — они разворачивают импорты из баррель-файлов. Это практически важная штука. Когда мы пишем import { Button } from 'ui-kit', а ui-kit/index.ts реэкспортирует сотни компонентов, наивный бандлинг рискует затащить в граф их все. Трансформ переписывает такой импорт в точечный, прямо на конкретный модуль, чтобы в бандл попало только то, что реально используется. modularizeImports делает это по заданному в конфиге шаблону пути, optimizePackageImports же работает автоматически, поскольку строит карту экспортов.
Наконец, именно на уровне SWC обрабатываются директивы "use client" и "use server" — за это отвечают трансформы serverComponents и serverActions. Здесь серверные экшены получают свои идентификаторы, а границы между серверным и клиентским кодом размечаются для последующих шагов бандлера.
Babel также поддерживается в Next.js как альтернативный вариант. Если в проекте есть конфигурация Babel (.babelrc или babel.config.js), Next.js автоматически переключается на Babel. Но за это приходится платить отключением SWC-трансформаций, а значит, потерей скорости и части оптимизаций.
Чтобы трансформация не упиралась в один поток, есть пул воркеров. Это собственная инфраструктура Next поверх обычных worker_threads из Node.js, которой управляет сам нативный биндинг SWC: он через метод registerWorkerScheduler просит Next создавать и завершать потоки, а Next выступает лишь фабрикой этих потоков. Так работа компилятора раскладывается по нескольким ядрам.
Webpack
Webpack — бандлер, на котором Next.js прожил большую часть своей истории, и именно его конфигурация до сих пор задаёт эталон того, как должна выглядеть итоговая сборка. Даже после перехода на Turbopack полезно понимать Webpack-модель, потому что Turbopack во многом воспроизводит её результат.
Next.js не собирает приложение одним проходом. Функция построения конфигурации вызывается отдельно под каждую целевую среду — client, server для Node.js-рантайма и edge для Edge-рантайма. У каждой среды свои внешние зависимости, свой формат вывода, свой рантайм. Клиентский бандл должен работать в браузере, серверный — иметь доступ к Node.js API, edge — укладываться в ограничения Web-совместимой среды. По сути это три разных компилятора Webpack, результаты которых потом сшиваются в единую сборку.
Слои
Внутри каждой среды webpack использует механизм слоёв (layers). Со слоями мы уже сталкивались в третьей части, когда говорили про две сборки React — RSC-рантайм (серверный React без useState и useEffect, сериализующий дерево в Flight-поток) и SSR/клиентский рантайм (обычный React). Переключает их условие экспорта react-server в package.json самого React, и тогда мы отметили, что Next включает это условие точечно для слоя в бандлере.
Слой — это некий ярлык, который бандлер вешает на модуль. Сам по себе он не меняет содержимое модуля, но меняет две вещи в том, как модуль обрабатывается: какие условия применяются при разрешении его импортов и через какие загрузчики он проходит. Полный список слоёв выглядит так:
const WEBPACK_LAYERS_NAMES = {
shared: 'shared',
reactServerComponents: 'rsc',
serverSideRendering: 'ssr',
actionBrowser: 'action-browser',
apiNode: 'api-node',
apiEdge: 'api-edge',
middleware: 'middleware',
instrument: 'instrument',
edgeAsset: 'edge-asset',
appPagesBrowser: 'app-pages-browser',
pagesDirBrowser: 'pages-dir-browser',
pagesDirEdge: 'pages-dir-edge',
pagesDirNode: 'pages-dir-node',
};
Ключевые для разделения клиента и сервера — это rsc (серверные компоненты), ssr и app-pages-browser (клиентский бандл App Router).
Главное, что делают слои, — управляют разрешением модулей. Для слоя rsc Next дописывает условие react-server в начало списка условий резолвера. Поэтому import ... from 'react' внутри серверного компонента разрешается в урезанную серверную сборку React (где нет useState и useEffect), тогда как ровно тот же импорт в слое ssr или в клиентском слое разрешается в обычный React. Таким образом, серверный компонент не может случайно вызвать клиентский хук, потому что в его слое этого хука не существует.
Загрузчики и плагины
Внутри Webpack SWC подключён как загрузчик (next-swc-loader), через который проходит каждый модуль. Слои определяют и то, какой конфиг SWC-загрузчика достанется модулю: Next собирает отдельные экземпляры лоадера под каждый слой. Но помимо next-swc-loader, в цепочке участвует ещё несколько специализированных загрузчиков:
next-flight-loaderобрабатывает модули RSC, размечая границы серверного и клиентского кода;next-app-loaderпревращает файловые конвенции App Router —page,layout,loading,error— в модули маршрутов;next-font-loaderберёт на себяnext/font: скачивает и инлайнит шрифты на этапе сборки, чтобы убрать лишний сетевой запрос в рантайме;- цепочка из
postcss-loader,lightningcss-loaderиmini-css-extractпрогоняет стили через PostCSS-плагины и извлекает CSS в отдельные файлы.
Если загрузчики трансформируют отдельные модули, то плагины работают со сборкой целиком. Часть из них как раз и генерирует те манифесты, что мы разбирали во второй части.
Особняком стоят flight-manifest-plugin и flight-client-entry-plugin. Именно здесь физически появляется разделение на сервер и клиент. Плагин flight-client-entry-plugin обходит граф модулей, находит границы "use client" и для каждой серверной точки входа создаёт соответствующую ей клиентскую. А flight-manifest-plugin генерирует манифест клиентских ссылок, по которому рантайм сопоставляет плейсхолдеры из RSC Payload с реальными клиентскими чанками. Всё, о чём мы говорили в предыдущих частях про Flight Protocol, опирается на то, что происходит на этом шаге сборки.
Разделение на чанки
То, как Webpack решает, что положить в какой файл, задаётся самим Next.js в секции optimization.splitChunks. Для клиентской production-сборки логика следующая. Отдельно выделяется каркасный, framework-чанк — в него попадает код, который меняется реже всего: React, ReactDOM, сам Next.js. Держать его отдельно выгодно ради долгого кеширования в браузере. Крупные зависимости из node_modules выносятся в lib-чанки: если размер библиотеки больше ~160 КБ (160000 байт), то она получает отдельный чанк и не увеличивает общий бандл. Небольшой рантайм самого Webpack выносится в отдельный runtime-чанк.
Turbopack
Turbopack — бандлер, который Vercel пишет специально под Next.js. В версии 16 он стал бандлером по умолчанию, а Webpack остался доступен через флаг.
В основе Turbopack лежит система инкрементальных вычислений turbo-tasks. Идея в том, чтобы моделировать всю сборку как граф мемоизированных функций.
Устройство Turbopack
turbo-tasks оперирует несколькими примитивами. Функции — это единицы исполнения и инвалидации; конкретный вызов функции с аргументами называется задачей (task). Значения — это данные, которые функции создают и возвращают. Ссылка на результат задачи — это Vc (Value Cell, «ячейка значения»): не само значение, а указатель на ячейку, содержимое которой может измениться при пересчёте. Когда одна задача читает Vc другой, между ними образуется зависимость, и turbo-tasks её запоминает.
Все задачи и их зависимости образуют граф задач. Дальше начинается инкрементальность. Когда что-то меняется — например, содержимое файла — система помечает соответствующую задачу как «грязную», и инвалидация распространяется снизу вверх по графу: от изменившегося листа к тем задачам, что от него зависят. Пересчитывается только затронутый подграф; всё, чего изменение не коснулось, остаётся как есть.
Инкрементальность легко воспринять как dev-фичу — пересобираем только то, что поменялось при сохранении файла. Но и в продакшене у неё есть не менее важное проявление. Результаты задач можно сохранять на диск между запусками. Это персистентный кеш сборки: повторный next build, локальный или в CI с сохранённым кешом, не начинает с нуля, а переиспользует незатронутые результаты. Так инкрементальная модель ускоряет production-билды.
Поверх turbo-tasks выстроен бандлер, разбитый на Rust-пакеты, например:
turbopack-coreсодержит граф модулей и алгоритм разбиения на чанки;turbopack-ecmascriptотвечает за обработку JS и TS и опирается на SWC;turbopack-cssобрабатывает стили;turbopack-resolveразрешает импорты в модули;turbopack-nodeумеет исполнять Node.js-код прямо внутри графа, например для получения данных на этапе сборки.
Главное архитектурное отличие от Webpack — единый граф. Webpack запускает отдельные компиляторы под client, server и edge и сшивает результаты. Turbopack же строит один граф зависимостей на все целевые среды сразу.
Как Next.js управляет Turbopack
С точки зрения Next.js Turbopack — это нативный модуль, доступный через N-API, которому Next создаёт объект Project для общения с ним и передаёт опции сборки. Next запрашивает у Turbopack через этот Project точки входа, а затем Turbopack собирает из результатов такие же манифесты, какие генерирует и Webpack. Серверный слой, который мы разбирали в пятой части, не должен знать, каким бандлером собрано приложение — он читает манифесты в едином формате независимо от того, Webpack их произвёл или Turbopack.
Другие шаги в next build
Компиляция и бандлинг — центральный, но далеко не единственный этап сборки. Внутри сборки приложения на Next.js запускается длинный конвейер, где бандлинг — лишь один из шагов.
Сначала генерируется идентификатор сборки (buildId). Затем загружается и валидируется next.config.js, разрешаются redirects, rewrites и headers. Дальше Next обходит app/ и pages/, обнаруживает все маршруты и строит по ним карту — routes-manifest. И только после этого наступает компиляция: webpackBuild или turbopackBuild, в зависимости от выбранного бандлера. Здесь работают SWC и бандлер, генерируются чанки и большинство манифестов.
После компиляции Next трассирует используемые файлы (об этом ниже), а затем анализирует каждый маршрут, определяя его стратегию рендеринга: что можно предрендерить статически, а что придётся считать на каждый запрос. Маршруты, помеченные как статические, тут же и предрендериваются в HTML. Под конец, если включён standalone-вывод, формируется самодостаточная директория для деплоя, а в консоль печатается дерево со значками ○, ● и ƒ у каждого маршрута и размерами чанков.
Отдельно стоит выделить трассировку файлов. Её задача — статически проанализировать, какие файлы реально нужны для работы каждого серверного входа: не только наш код, но и транзитивные зависимости из node_modules. Результат — это список минимально необходимых файлов, из которого режим output: 'standalone' собирает компактную директорию, содержащую только то, что действительно используется в рантайме, без всего node_modules целиком.
Итого
За словом «сборка» в Next.js скрываются два слоя — компилятор, работающий с файлом изолированно, и бандлер, видящий приложение целиком. Оба этих слоя прямо сейчас активно мигрируют из мира JavaScript в мир Rust. Babel уступил место SWC, а Webpack постепенно заменяется на Turbopack.
За гибкость приходится платить сложностью: три (с учётом Rspack) возможных бандлера, три целевые среды, слои, манифесты, трассировка файлов — и всё это должно давать на выходе совместимый результат, который серверный слой обслужит, не зная деталей сборки.