Turbopack внутри Next: один граф на сервер и клиент
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Всю часть вы запускали next dev и next build, и за этими командами работала сборка, которую статья про фреймворки описала одной строкой таблицы: Turbopack настроен заранее. Две прошлые статьи разобрали сборку на части: свой сборщик строил граф, реестр и куски, компилятор файла переводил файл по дереву. Сегодня — что стоит за командами Next: какой сборщик назван в первой строке лога, почему у Turbopack один граф на сервер и клиент, как он ленится и кеширует, чем иначе устроен Vite и что изменилось по сравнению с webpack, которым Next собирался до Turbopack и собирается до сих пор, если попросить.
Приложение — то же, что в статье про свой сборщик: страница app/page.tsx рисует клиентский компонент Price из ui/price.tsx, где стоит "use client", а Price берёт rub из lib/money.ts. Рядом вторая страница, app/admin/page.tsx, — один заголовок. Приложение собрано дважды: командой next build и командой next build --webpack. Прогноз: чем будут отличаться два лога?
Первая строка называет сборщик: (Turbopack) без флага и (webpack) с флагом --webpack. В 16-й версии Next Turbopack — сборщик по умолчанию и для next build, и для next dev, а webpack остался запасным путём. Дальше логи почти совпадают. Running TypeScript — отдельный шаг, та же проверка типов, что у tsc: сам сборщик типы только стирает. Таблицу маршрутов в конце читать учит статья про режимы отрисовки.
Различаются куски. webpack собирает имя из маршрута и хеша — app/page-c7cc6525f8ec2e9b.js, а Turbopack называет куски непрозрачно — 0uk_002q-0_-r.js, и по имени не понять, что внутри. Что лежит в этом куске, покажет следующий раздел.
Отдельной программы Turbopack в проекте нет. Пакет turbopack на npm — заглушка, а сам сборщик скомпилирован в нативный модуль Next: пакет @next/swc-… под вашу платформу, в котором живёт и компилятор файла SWC. Где такого модуля нет, например на FreeBSD, Next берёт сборку SWC на WebAssembly, и Turbopack там не работает — остаётся --webpack. Сам webpack Next тоже везёт внутри себя, ставить его отдельно не нужно.
Сайт, который вы читаете, тоже собирает Next:
Флаг --turbopack остался в скрипте с тех пор, когда Turbopack для сборки включали руками. В 16-й версии он ничего не меняет — первая строка та же, (Turbopack), — и из скриптов сайта его убрали вместе с этой главой. Нужен этот флаг в одном случае, о нём — в разделе про webpack. А заканчивается сборка сайта не сервером, а папкой файлов: учебник собран статическим экспортом.
В статье про границу клиента вы считали клиентскую часть обходом графа: всё, до чего дотянутся импорты файла с "use client", уезжает в браузер. В Next этот обход делает сборщик, и вопрос у него не «браузер или сервер». ui/price.tsx нужен трём сторонам. Серверным компонентам — ссылкой: на месте Price в их выводе стоит ссылка на модуль, а не его код. Серверной отрисовке первого HTML — кодом: кнопку нужно нарисовать в разметку. И браузеру — кодом, который эту кнопку оживит.
Turbopack держит все три стороны в одном графе модулей и помечает каждый модуль слоем. Пометки видны в идентификаторах модулей, которые dev-сервер пишет в свои файлы. Прогноз: сколько записей с разными пометками окажется у ui/price.tsx и в каких слоях будет lib/money.ts, который импортирует только Price?
Слоёв три: [app-rsc] — серверные компоненты, [app-ssr] — отрисовка клиентских компонентов в HTML на сервере, [app-client] — браузер. А записей у ui/price.tsx четыре: в слое серверных компонентов он встречается дважды. Запись (client reference proxy) — та самая ссылка-заглушка, которую получает page.tsx вместо кода. Вторая, (ecmascript), — модуль того же файла в том же слое; зачем он там, листинг не говорит, и гадать не будем. Числа слева — сколько раз строка нашлась в файлах сборки, это счётчик uniq -c, а не число слоёв.
lib/money.ts есть только в [app-ssr] и [app-client]: серверные компоненты его не импортируют, он пришёл вслед за Price. Страницы — только в [app-rsc]. Это та же заразность по импортам, только посчитанная сборщиком: всё ниже границы ушло в клиентские слои, а по другую сторону осталась ссылка. Граница режет граф, а не папки проекта: один и тот же файл лежит в нескольких слоях, если до него дотягиваются с разных сторон.
Итог разреза — манифест клиентских ссылок. В статье про свою границу читатель находил по строке I модуль и брал его из бандла через манифест. Вот манифест, строка I и кусок в настоящей сборке того же приложения:
Через все три выдержки идёт одно число — 20501. В манифесте это id модуля ui/price.tsx и два куска, которые нужно загрузить. Строка 4:I[20501,[…]] в потоке несёт тот же id и те же куски — ровно то, что в статье про свою границу было упрощено до одного адреса. А кусок 0uk_002q-0_-r.js кладёт в массив globalThis.TURBOPACK функцию модуля с тем же номером: это реестр из статьи про свой сборщик, где этот кусок уже встречался.
Манифест пишет сборка, а не React: только она знает, в какие куски легли модули. В режиме webpack его пишет плагин Next — к этому вернёмся в разделе про webpack.
Граф на три слоя — много работы, и dev-сервер не делает её целиком. Он собирает то, что открыли в браузере, и не повторяет то, что уже сделал. Оба правила видны на модели. Ключ работы — файл, сторона и хеш содержимого; функция hash из статьи про свой сборщик едет в подложке. compile идёт по импортам от открытого маршрута, а на файле с "use client" отдаёт серверу заглушку и переходит на клиентскую сторону. Сторон в модели две, а не три слоя: для ключа этого хватает.
Прогноз: сколько работ насчитает первый заход на /, второй заход и заход после правки в money? И соберётся ли /admin?
Четыре, ноль, одна. Первый заход прошёл страницу, заглушку Price для сервера, сам Price и money на клиентской стороне; клиентская сторона в модели одна, а в листинге слоёв ей соответствуют два слоя, [app-ssr] и [app-client]. Второй заход нашёл все четыре ключа в done. После правки новый хеш только у money, и новый ключ только у него: ключ Price собран из его собственного текста, а текст не менялся. Каскада из статьи про свой сборщик здесь нет — ключ работы не вписан ни в чей текст. /admin не собирался: туда никто не заходил.
Это модель ключа, ленивости и разреза, а не движок. У настоящего Turbopack кеш хранит результаты вплоть до отдельных функций, а куски собираются отдельно от компиляции модулей.
Настоящий dev-сервер ведёт себя так же. Лог next dev, пока в браузере открывали /, ещё раз / и потом /admin:
Строка GET /admin появилась только после захода на /admin — до него страница не собиралась. Первый GET / занял 848 мс, повторный — 19: собирать уже нечего, осталась отрисовка. Приложение крошечное, поэтому смотрите на порядок чисел, а не на сами числа.
Кеш переживает и перезапуск. В Next из этих листингов Turbopack по умолчанию пишет его на диск: для next dev — в .next/dev/cache/turbopack, для next build — в .next/cache/turbopack. Выключают это настройками experimental.turbopackFileSystemCacheForDev и experimental.turbopackFileSystemCacheForBuild. Вторая сборка того же приложения сразу после первой из начала статьи:
Compiled successfully in 134ms против 1502 мс у первой сборки: на порядок быстрее, и снова это порядок, а не замер. Выигрыш есть, только если .next/cache дожил до следующей сборки. Сборка в чистой папке — в CI или в слое сборки образа — кеша не находит и каждый раз идёт холодной, пока папку с кешем не сохранить и не вернуть явно. Тот же принцип — не делать работу, ключ которой уже есть, — встретится и для данных: в статье про свой кеш ключ собирают из того, что вошло в функцию.
Ни ленивость, ни кеш на диске Turbopack не придумал. next dev --webpack тоже собирает маршрут при первом заходе и хранит кеш webpack в .next/dev/cache/webpack. Чем Turbopack отличается на самом деле, разберём в разделе про webpack.
Vite тоже держит dev-сервер и тоже собирает проект, но в разработке отдаёт код иначе. Сравнивать число скриптов на странице бессмысленно: ниже два разных проекта — приложение Next из этой статьи и шаблон Vite с картинками и стилями. Сравнивать стоит другое — сколько модулей проекта приходит одним ответом сервера. Прогноз: у какого инструмента в одном файле dev-сервера окажется больше одного модуля проекта?
У Vite в разработке один ответ — один модуль. /src/main.tsx и /src/App.tsx пришли отдельно, и даже index.css с App.css — отдельными ответами. Склеены заранее только зависимости: react.js в /node_modules/.vite/deps/ — один ответ на весь пакет. У Next в том же режиме куски: в одном _0ngubum._.js шесть модулей, среди них lib/money.ts и ui/price.tsx. А после vite build скрипт на странице один: бандл Vite собирает только на сборке.
Отдаваемый модуль Vite переписывает на лету. Начало main.tsx, каким его получает браузер, уже было в статье про компилятор файла: голые имена пакетов заменены адресами в /node_modules/.vite/deps/, импорт App стал адресом /src/App.tsx — за ним браузер придёт следующим запросом, — а в хвосте встроена карта кода:
Горячая замена, последняя строка таблицы из статьи про фреймворки, видна в обоих списках: у Vite это /@vite/client и /@react-refresh, у Next — куски hmr-client. В списке ответов vite preview этих запросов уже нет, а среди кусков next build из начала статьи нет кусков hmr-client.
Почему Turbopack бандлит и в разработке, объясняет его документация: отдавать нативные модули по одному хорошо на маленьком приложении, а большое замедляет число запросов. По той же причине у самого Vite появился экспериментальный режим разработки с бандлом. А если серверная половина Next вашему приложению не нужна, Vite с роутером — честный выбор.
Шаблон создан и собран так:
Скрипт build шаблона сначала зовёт tsc -b: Vite, как и Turbopack, типы не проверяет, проверка — отдельная команда. TypeScript шаблон ставит шестой версии.
До Turbopack Next собирался webpack-ом, и с флагом --webpack собирается им до сих пор. Устроен webpack проще, чем выглядят его конфиги. Конфиг называет точки входа, и webpack обходит граф сразу от всех: в глубину, с пометкой пройденных файлов, как свой сборщик, только входов несколько. Каждый файл проходит цепочку лоадеров из правил module.rules. Лоадер — функция над текстом файла, и в конце цепочки должен получиться JavaScript: так webpack делает модуль из чего угодно, от картинки до TypeScript, и так же зовёт компилятор файла — babel-loader отдаёт текст Babel.
Всё остальное делают плагины. Плагин — объект с методом apply(compiler): webpack передаёт ему компилятор, и плагин подписывается на его события — hooks.beforeCompile.tap до компиляции, hooks.emit.tap перед записью файлов — и получает внутренние объекты сборки. По словам README webpack, этим же интерфейсом сделана большая часть возможностей самого webpack. apply здесь — просто имя метода, который зовёт webpack, а не apply функции: plugin.apply(compiler) — обычный вызов метода с одним аргументом.
Модель ниже — проект из модели Turbopack, только вместо компонента картинка ui/logo.svg. hook — событие: плагин подписывается через tap, компилятор зовёт подписчиков через call. webpack() подключает плагины, объявляет beforeCompile, собирает граф от всех точек входа через лоадеры и объявляет emit. Имена — как у настоящего API. Прогноз: что напечатается раньше, beforeCompile или emit, и попадёт ли в сборку app/admin/page.js, если в браузере открыта только /?
beforeCompile — первым: плагин подписался до сборки, и компилятор объявил событие раньше, чем прочёл хоть один файл. В emit все пять модулей, app/admin/page.js и lib/db.js тоже: голый webpack не знает, что открыто в браузере, и собирает всё, что достижимо от точек входа. Порядок имён в строке другой, чем у своего сборщика: там файл вставал в очередь выполнения после своих зависимостей, а здесь модуль записывается при первом заходе — это список собранного, а не очередь. Третья строка — картинка, ставшая модулем JavaScript. Сравните с последней строкой модели Turbopack: там было «/admin собирался: false».И сразу оговорка, без которой сравнение webpack с моделью Turbopack соврёт. Next на webpack в разработке всё подряд не собирает: он сам добавляет страницу в компиляцию при первом заходе на неё и хранит кеш webpack на диске, а webpack в режиме наблюдения пересобирает только изменённый модуль. Голый dev-сервер webpack держит собранное в памяти и умеет слать браузеру горячие обновления, а Next в режиме --webpack пишет сборку на диск, в .next/dev. Разница с Turbopack не «лениво против сразу», а в том, где живут ленивость и кеш.
Где они живут у Next на webpack, видно через функцию webpack() в next.config.ts. Next с самого начала собирался webpack-ом, а вскоре отдал его настройку наружу этой функцией — туда и дописывают свои лоадеры и плагины. Функция ниже ничего не меняет, только печатает, для какого компилятора её позвали и сколько у него плагинов:
Функция вызвана трижды: для сервера на Node, для сервера в среде edge — урезанной, без половины API Node, — и для браузера. Это три компилятора webpack, и у каждого свой список плагинов и свой кеш — три папки в .next/cache/webpack. Большая часть плагинов в этих списках — код самого Next, хотя есть и встроенные плагины webpack вроде DefinePlugin. Манифест клиентских ссылок из раздела про один граф в этом режиме пишет плагин Next ClientReferenceManifestPlugin в компиляторе браузера. Ленивость Next достраивает поверх трёх компиляторов, а каждая пересборка — новый проход компиляции со всеми событиями плагинов.
Turbopack устроен иначе. Граф один, а сервер и клиент — слои в нём, как в листинге слоёв. Ленивость и кеш — свойства самого движка, и кеш, как пишет документация Turbopack, хранит результаты вплоть до отдельных функций. Скорость здесь сравнивать не будем: цифры в этих листингах сняты на крошечном приложении.
Что из мира webpack Turbopack понимает? Лоадеры — да. Он исполняет их библиотекой loader-runner из проекта webpack — так пишет документация turbopack.rules, — а вписывают их в конфиг, как в следующем разделе. Ограничения два: годятся только лоадеры, которые возвращают JavaScript, а их опции — только данные, то есть примитивы, объекты и массивы, но не функции и не модули через require(). Лоадеры, которые команда Next проверила, перечислены в той же документации. Плагины — нет. Плагин написан против событий и объектов компилятора webpack, как reportPlugin в модели, а у Turbopack таких событий нет. Документация говорит прямо: плагины webpack не поддерживаются, функция webpack() из next.config не читается. У части плагинов есть замена настройкой самого Next — например, у DefinePlugin, — но это другой механизм, а не тот же плагин под Turbopack; такие замены разбирает последняя статья главы.
Отсюда ошибка, которую Next печатает, найдя в конфиге функцию webpack() без ключа turbopack:
Сборка упала, и next dev с таким конфигом тоже не работает: напечатает Ready, следом ту же ошибку и завершится. Выхода три, и выбирают по тому, что лежит в webpack().
| Выход | Что происходит | Когда годится |
|---|---|---|
перенести настройки в turbopack | лоадеры переезжают в turbopack.rules, алиасы — в resolveAlias | в webpack() только то, что переносится |
next build --webpack | сборка идёт webpack-ом со всеми плагинами из webpack() | без плагина не обойтись; платформа без нативного Turbopack |
--turbopack или пустой turbopack: {} | сборка идёт Turbopack-ом, а webpack() не вызывается вовсе | из webpack() не нужно ничего |
Третий выход коварнее, чем выглядит. Руководство по переходу на 16-ю версию предлагает флаг --turbopack, подсказка TIP в самой ошибке — пустой turbopack: {}. Та же функция-счётчик, в конфиг дописан пустой ключ:
Сборка зелёная, а функция не вызвана ни разу; с флагом --turbopack без ключа — так же. Вместе с плагинами молча пропадают и вписанные в webpack() лоадеры и алиасы, поэтому этот выход годится, только если из функции действительно ничего не нужно. Если ключ webpack добавила чужая обёртка конфига — об этом строка NOTE в ошибке, — это ещё не довод, что он лишний: сначала посмотрите, что обёртка туда кладёт.
И последнее имя из этого мира — Rspack: сборщик с API webpack, написанный на Rust. Когда он уместен и что переедет в него из webpack.config, разбирает та же последняя статья главы.
Лоадер переезжает в turbopack.rules. Конфиг ниже собран из двух примеров документации Next: @svgr/webpack делает из файла .svg компонент React, а алиас в resolveAlias подменяет пакет — импорт underscore загрузит lodash. Страница админки пользуется обоими:
Правило в rules выбирает файлы не регуляркой, как test у webpack, а шаблоном имени — "*.svg". loaders — та же цепочка, что в use. А as: "*.js" велит обрабатывать результат лоадера как файл .js: лоадер вернул модуль, а не картинку. Без этой строки Turbopack попробует разобрать вывод лоадера как SVG и остановит сборку ошибкой Processing image failed. В собранной странице картинка стала разметкой svg, а версия пришла из lodash, хотя в импорте написано underscore.
Babel в rules вписывать не нужно: если в проекте найдётся babel-конфиг, Turbopack включит Babel сам — что при этом печатает лог и когда такой файл лучше удалить, разобрано в статье про компилятор файла.
Главное место, где Babel в Next всё ещё нужен, — React Compiler. Флаг reactCompiler включает его как плагин Babel из пакета babel-plugin-react-compiler. У Turbopack есть экспериментальный порт компилятора на Rust, которому этот пакет не нужен. Включается он второй настройкой рядом с первой — так в документации:
С обеими настройками сборка прошла без пакета Babel, и лог честно предупреждает об эксперименте. Кусок с Price читается так же, как в статье про React Compiler: o=(0,r.c)(8) заводит восемь ячеек кеша, а проверки o[0]!==_ пересчитывают значение, только когда поменялся вход. Новое здесь — не сам кеш, а то, что его поставил Turbopack, а не Babel. Без второй настройки тот же конфиг ищет Babel-плагин и падает. Порт работает только под Turbopack: с --webpack, по документации, эта настройка — ошибка.
Ещё две вещи в Next пришли из Vite: import.meta.env с полями вроде DEV, PROD и MODE и import.meta.glob работают только под Turbopack, а переменные VITE_ Next не читает.
- За
next devиnext buildпо умолчанию стоит Turbopack, скомпилированный в нативный модуль Next; webpack включают флагом--webpack. Какой сборщик работал, пишет первая строка лога. - У Turbopack один граф на сервер и клиент. Файл с
"use client"живёт в нём слоями: ссылкой у серверных компонентов, кодом для HTML на сервере и кодом для браузера. Манифест клиентских ссылок и строкаIв потоке — продукт этого разреза. - В разработке собирается только открытое, а ключ работы строится из входа, поэтому повторная работа не делается. Кеш лежит на диске и переживает перезапуск, но сборка в чистой папке его не видит.
- Vite в разработке отдаёт модули проекта по одному и склеивает заранее только зависимости, а бандл собирает на сборке. Turbopack бандлит и в разработке.
- webpack обходит граф сразу от всех точек входа. Лоадер — функция над текстом файла, плагин — подписка на события компилятора. Next на webpack — три компилятора, а у Turbopack слои одного графа.
- Лоадеры переезжают в
turbopack.rules, алиасы — вresolveAlias, плагины не переезжают. Пустойturbopack: {}глушит ошибку, но выбрасывает функциюwebpack()целиком.
1/4В первой строке лога next build в скобках стоит (webpack). Что это значит?
2/4Компонент в ui/price.tsx начинается с "use client", его рисует серверная страница. В каких слоях графа Turbopack окажется этот файл?
3/4В модели ключ работы — файл, сторона и хеш содержимого. Поправили lib/money.ts, из которого Price берёт rub. Сколько работ даст следующий заход на /?
4/4В next.config проекта есть функция webpack() с лоадером для .svg и плагином. Что из этого заработает под Turbopack?
Админка тоже берёт money
В модели из раздела про ленивость серверная страница app/admin/page.tsx теперь сама импортирует lib/money.ts. Предскажите все три строки вывода — число работ на каждом заходе и ключи money — и только потом запускайте. Затем допишите в конец правку money, как в разделе, и заходы на оба маршрута: сколько работ будет теперь?
Вывод:
Первый заход — те же четыре работы, что в разделе. Заход на /admin даёт три: саму страницу, db и money на серверной стороне. Ключ money с клиентской стороны уже лежит в done, но он не подходит: сторона — часть ключа, и серверная версия — отдельная работа. Файл, до которого дотягиваются с обеих сторон границы, собирается дважды.
После правки:
печатает правка money, снова /: работ 1 и и /admin: работ 1: у каждой стороны по одному новому ключу.
В настоящем Next так же. Если в app/admin/page.tsx импортировать rub из lib/money и зайти на обе страницы, у lib/money.ts к слоям [app-ssr] и [app-client] добавится третий, [app-rsc] (next dev 16.3.5, тот же grep по .next/dev, 14.09.2026):
Все подходящие правила
В модели webpack лоадеры выбирает rules.find — берётся первое подходящее правило. Настоящий webpack применяет к файлу все подходящие правила: их лоадеры встают в одну цепочку в порядке правил, а выполняется цепочка справа налево. Ниже у ui/logo.svg два правила, и второе модель пропускает. Перепишите выбор лоадеров так, как это делает webpack, и предскажите, что напечатается.
Лоадеры всех подходящих правил, в порядке правил:
flatMap — тот же map, только массивы use всех правил склеиваются в один. Цепочка — [svgLoader, roleLoader], и reduceRight проходит её с конца: сначала roleLoader правит текст картинки, потом svgLoader делает из него модуль. Вывод:
Порядок здесь не формальность. Если пройти ту же цепочку слева направо, roleLoader получит уже готовый модуль и вставит кавычки внутрь строки без экранирования:
Такой файл не разберётся. Поэтому лоадер, который превращает файл в JavaScript, в use пишут первым, а текстовые правки — после него: выполнятся они раньше.
Пустой turbopack и пропавший алиас
В проекте на Next 16 функция webpack() в конфиге добавляет лоадер для .svg и алиас underscore → lodash, и с флагом --webpack страница админки собирается. Чтобы заглушить ошибку про webpack-конфиг, в конфиг дописали пустой turbopack: {} — и сборка упала:
Объясните, почему сборщик не нашёл underscore, хотя алиас в конфиге есть. Какая ошибка встретится следующей, если перенести в turbopack только алиас? И как должен выглядеть конфиг, с которым next build без флагов соберёт страницу?
Ключ turbopack заглушил ошибку, и сборка пошла Turbopack-ом, а он функцию webpack() не вызывает вовсе. Алиас жил внутри неё — значит, для Turbopack его нет, и underscore ищется как настоящий пакет, которого в проекте нет.
Если перенести один алиас, импорт найдётся, но лоадер для картинки тоже остался в webpack(). Turbopack отдаст .svg как файл-ресурс, Logo окажется объектом, а не компонентом, и сборка упадёт позже, на отрисовке страницы (проверено на next 16.3.5, 14.09.2026):
Переносить нужно обе настройки — это конфиг из раздела про next.config:
Функцию webpack() рядом можно оставить: next build собирает Turbopack-ом по ключу turbopack, а next build --webpack — по функции, и в той же проверке оба варианта собрали страницу с картинкой и версией lodash. Но тогда одни и те же настройки живут в двух местах, и менять их придётся парой.