Свой сборщик: граф импортов, обёртка и реестр

сборщики≈ 12 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

В таблице «что делает сборка» из статьи про фреймворки есть строки «Склеивает и режет на куски» и «Ставит хеш в имя файла». За ними стоит работа со строками, которая заканчивается до запуска кода, — и её можно собрать самому. Сегодня пять шагов: граф импортов, обёртка модуля, реестр с кешем, кусок по import() и хеш в имени. По ходу дела тот же механизм найдётся в настоящем выводе Vite, esbuild, webpack и Turbopack.

Разделы темы:·

Браузер умеет модули и без сборщика: подключите точку входа с type="module", и остальные файлы он подтянет сам по цепочке import. Мешают три вещи. Голое имя пакета вроде import dayjs from "dayjs" браузер не разрешит: адресом такое имя работает только для инструментов, которые знают про node_modules. Пакет, разложенный на модули, приезжает файлами — у lodash-es их сотни, и каждый стал бы отдельным запросом. А JSX и аннотации типов движку не разобрать вовсе. Всё это сборщик решает до запуска, и начинает он с чтения импортов.

Файлы проекта для сборщика — строки. Возьмём три: main импортирует cart, cart — money. Функция importsOf достаёт из текста пути импортов регуляркой, resolve превращает "./cart.js" в полный адрес от папки файла, а graph обходит зависимости в глубину и отмечает пройденные файлы в seen, чтобы общий модуль не встал в очередь дважды.

Прогноз: в каком порядке встанут файлы? Обход начинается с main.js — окажется ли он первым? И напечатается ли «money выполнен»?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первым встал money.js, а точка входа — последней. Всё решает место строки order.push(file): она стоит после цикла, и файл попадает в очередь, только когда пройдены все его зависимости. Значит, всё, что файлу нужно, в очереди всегда стоит раньше него. А «money выполнен» не напечаталось: код модулей никто не запускал, граф выстроен одним чтением текста.

Обход графа вы уже писали, когда считали клиентскую часть от файла с 'use client'. Там важен был только набор файлов, и хватало очереди с сортировкой в конце. Сборщику нужен ещё и порядок — поэтому обход в глубину и запись в очередь на выходе из файла, а не на входе.

Регулярка в importsOf — сознательное упрощение: она видит только импорт с фигурными скобками и двойными кавычками. Где она подводит всерьёз, покажет последний раздел.

Эти строки — не выдумка для песочницы. На диске лежит тот же маленький проект, и дальше его соберут настоящие Vite и esbuild. Отличие одно: в проекте есть четвёртый файл, editor.js, и main грузит его по нажатию кнопки. В песочнице он появится в разделе про куски.

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, учебный проект без зависимостей; macOS arm64, Node 26.5.0, 14.09.2026
// src/money.js
console.log("money выполнен");
export function rub(n) {
return n + " ₽";
}
// src/cart.js
import { rub } from "./money.js";
export function total(prices) {
return rub(prices.reduce((a, b) => a + b, 0));
}
// src/editor.js
console.log("editor выполнен");
export function open() {
return "редактор открыт";
}
// src/main.js
import { total } from "./cart.js";
console.log(total([120, 230]));
document.querySelector("button").addEventListener("click", async () => {
const { open } = await import("./editor.js");
console.log(open());
});
// index.html
<!doctype html>
<html lang="ru">
<body>
<button>Редактор</button>
<script type="module" src="/src/main.js"></script>
</body>
</html>

Очередь есть, но просто склеить файлы подряд нельзя. У каждого модуля своя область видимости: rub из money не должна встретиться с одноимённой функцией из соседнего файла. А строка import { rub } from "./money.js" в склеенном файле пошла бы за файлом, которого на сервере больше нет. Своя область у кода бывает внутри функции — это приём IIFE, которым прятали переменные до модулей. Сборщик берёт его почти без изменений, только функция получает два параметра: require, чтобы брать чужое, и exports, чтобы отдавать своё.

files, resolve и graph из прошлого примера переехали в подложку. wrap делает две замены: строку импорта — на вызов require, объявление export function — на запись в exports. Прогноз: во что превратится первая строка cart.js и какой путь окажется в скобках у require?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

В скобках "/src/money.js", а не "./money.js": путь разрешён на сборке, и рантайму искать уже нечего — ему достаточно найти строку в реестре. Вторая замена сделала из объявления запись: exports.total = function total. Функция та же, но теперь она лежит в объекте, который получит всякий, кто позовёт require с адресом cart.

Осталось сложить обёртки туда, где их найдут по адресу, и написать саму require. В разделе про CommonJS статьи о модулях она описана словами — находит файл, выполняет его один раз, запоминает exports в кеше — и показана реестром на четырёх строках, без функции и без кеша. Дерево require в один файл склеивали ещё Browserify и webpack; теперь такой рантайм целиком пишет наш сборщик.

bundle кладёт обёртки в объект modules — адрес → функция — и дописывает к ним рантайм: объект cache и функцию require. Первый вызов с адресом заводит в кеше пустой exports, выполняет тело модуля и оставляет в этом объекте всё, что модуль записал. Следующий вызов с тем же адресом тело уже не трогает и сразу отдаёт запомненный объект. Весь бандл — одна строка, и запускает её new Function.

Чтобы кешу было что делать, main в примере импортирует money и сам, в обход cart: теперь этот модуль нужен двоим. Прогноз: сколько раз напечатается «money выполнен» и что выйдет раньше — эта строка или «350 ₽»?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
«money выполнен» — одна строка, и раньше цены. Модулей в бандле по-прежнему три: второй импорт money граф не удвоил. При запуске main первым попросил "/src/money.js" — тело выполнилось, exports с функцией rub остался в кеше. Потом cart попросил тот же адрес и получил готовый объект, второго выполнения не было. Это не оптимизация, а обещание модулей: тело выполняется один раз, сколько бы файлов его ни импортировали. Без кеша у cart и main оказалось бы по своей копии money со своими переменными.

Пустой exports кладётся в кеш до выполнения тела, а не после. Почему именно так, выяснится в первой задаче, где два файла импортируют друг друга.

Реестр «строка → функция» вы уже встречали в своём Server Action: там по идентификатору искали функцию действия, а наполнял реестр манифест, собранный сборщиком.

Откройте кусок настоящей прод-сборки — и узнаете ту же функцию. Вот маленькое приложение: страница app/page.tsx рисует клиентский компонент Price из ui/price.tsx, а тот берёт rub из lib/money.ts. Next собрал его в режиме webpack; ниже кусок страницы и первые байты рантайма.

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, next 16.3.5 в режиме --webpack, react 19.3.0; macOS arm64, Node 26.5.0, 14.09.2026; лог сборки опущен, id модуля входа и имя page-… зависят от пути к проекту — у вас будут свои
$ cat .next/static/chunks/app/page-*.js
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[974],{3376:(e,s,n)=>{"use strict";n.d(s,{Price:()=>u});var t=n(5155),r=n(2115);function u({base:e}){let[s,n]=(0,r.useState)(1);return(0,t.jsxs)("button",{onClick:()=>n(s+1),children:[s," \xd7 ",e+" ₽"]})}},4168:(e,s,n)=>{Promise.resolve().then(n.bind(n,3376))}},e=>{e.O(0,[441,794,358],()=>e(e.s=4168)),_N_E=e.O()}]);
$ head -c 400 .next/static/chunks/webpack-*.js
(()=>{"use strict";var e,t,r,o,n,i,a,u,l={},c={};function s(e){var t=c[e];if(void 0!==t)return t.exports;var r=c[e]={exports:{}},o=!0;try{l[e](r,r.exports,s),o=!1}finally{o&&delete c[e]}return r.exports}s.m=l,e=[],s.O=(t,r,o,n)=>{if(r){n=n||0;for(var i=e.length;i>0&&e[i-1][2]>n;i--)e[i]=e[i-1];e[i]=[r,o,n];return}for(var a=1/0,i=0;i<e.length;i++){for(var[r,o,n]=e[i],u=!0,l=0;l<r.length;l++)(!1&n||

function s(e) — это наш require: смотрит в кеш c[e] и, если там пусто, заводит c[e]={exports:{}} и зовёт функцию модуля из реестра l[e]. Кусок страницы пополняет реестр записью 3376:(e,s,n)=>{…} — это модуль с Price, и три его параметра — module, exports и require, только под другими именами.

Без флага то же приложение собирает Turbopack, сборщик Next по умолчанию, и кусок устроен похоже: запись в глобальном массиве TURBOPACK — id модуля 20501 и функция, которая получает помощника t. t.i(…) берёт чужой модуль по id, t.s([…]) записывает свои экспорты.

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, next 16.3.5 (Turbopack), react 19.3.0; macOS arm64, Node 26.5.0, 14.09.2026; лог сборки опущен
$ cat .next/static/chunks/0uk_002q-0_-r.js
(globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["object"==typeof document?document.currentScript:void 0,20501,t=>{"use strict";var e=t.i(43476),i=t.i(71645);t.s(["Price",0,function({base:t}){let[c,o]=(0,i.useState)(1);return(0,e.jsxs)("button",{onClick:()=>o(c+1),children:[c," × ",t+" ₽"]})}],20501)}]);

Короткие имена e, s, n и t в обоих кусках дал минификатор, а не авторы сборщиков, — откуда они и как вернуть исходные, разобрано в конце раздела про хеш.

И одна оговорка, без которой свой dist/ вас удивит: функция на каждый модуль бывает не всегда. В прод-сборке соседние ESM-модули склеиваются. Rolldown, сборщик внутри Vite, и esbuild поднимают их код в одну общую область — без реестра и без обёрток, а совпавшие имена переименовывают. webpack и Turbopack кладут склейку в одну функцию реестра: в обоих кусках выше отдельного модуля money нет, rub встроена прямо в тело Price — это e+" ₽" и t+" ₽". Функция на модуль остаётся там, где склеивать нельзя или некогда: у пакетов на CommonJS, в режиме разработки у webpack и Turbopack и в Metro, где каждый файл регистрируется фабрикой. Вот как Rolldown оборачивает CommonJS-пакет React в шаблоне Vite:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, шаблон react-ts из create-vite 9.2.1, vite 8.3.0 (Rolldown 1.2.8); macOS arm64, Node 26.5.0, 14.09.2026; лог сборки опущен
$ cat dist/assets/index-*.js # выдержки, пропуски отмечены …
//#region \0rolldown/runtime.js
var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
//#endregion
…
//#region node_modules/react/cjs/react.production.js
var require_react_production = /* @__PURE__ */ __commonJSMin(((exports) => {
var REACT_ELEMENT_TYPE = Symbol.for("react.transitional.element");
…
//#region src/App.tsx
var import_jsx_runtime = (/* @__PURE__ */ __commonJSMin(((exports, module) => {
module.exports = require_react_jsx_runtime_production();
})))();
…
//#region src/main.tsx
(0, import_client.createRoot)(document.getElementById("root")).render(/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react.StrictMode, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(App, {}) }));
//#endregion

__commonJSMin — тот же require с кешем, только на один модуль: первый вызов создаёт { exports: {} }, выполняет тело cb и запоминает mod, следующие сразу отдают mod.exports.

Зачем резать бандл, разобрано в статье про динамический импорт: всё, что нужно не сразу, не должно задерживать первую загрузку. Здесь — как именно режется. В проект приходит четвёртый файл, editor.js, и main зовёт его через import().

split кладёт в main.js граф от точки входа. Для каждого import("./x.js") в тексте main он строит граф x и выбрасывает из него всё, что уже взято в main, — остаток уезжает в свой файл. Сам вызов переписывается: вместо import() в main встаёт require.load("editor.js"), который грузит файл, а следом — обычный require. У webpack такая функция называется __webpack_require__.e. Рантайм run в подложке изображает сеть: файл «приходит» через 50 мс и дописывает свои модули в реестр.

Прогноз: какие модули окажутся в каждом файле и в каком порядке выйдут шесть строк — две про файлы и четыре от самих модулей?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

В editor.js лёг один модуль. money и cart редактор не импортирует, а если бы импортировал — они уже в main, и второй раз их не везут. Порядок строк повторяет сеть: main выполнился целиком, со своими «money выполнен» и «350 ₽», и только когда кусок «пришёл», в реестре появился editor и выполнился он. Вызов import() превратился в три шага: загрузить файл, пополнить реестр, отдать модуль.

На месте setTimeout у настоящего рантайма сеть. webpack вставляет в страницу тег script с адресом куска, а Vite оставляет браузеру настоящий import().

Настоящий кусок устроен так же. Vite на тех же четырёх файлах с диска положил editor в отдельный файл, а import("./editor.js") в тексте main переписал на имя этого файла и обернул своим помощником __vitePreload: тот сначала подгружает то, что куску нужно заранее (здесь список пуст), и только потом зовёт import().

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, vite 8.3.0 (Rolldown 1.2.8), проект без vite.config; macOS arm64, Node 26.5.0, 14.09.2026
vite v8.3.0 building client environment for production...
transforming...
✓ 8 modules transformed.
rendering chunks...
computing gzip size...
dist-a/index.html 0.17 kB │ gzip: 0.16 kB
dist-a/assets/editor-C4gor-ux.js 0.15 kB │ gzip: 0.15 kB
dist-a/assets/index-BWqSUuqh.js 3.91 kB │ gzip: 1.60 kB
✓ built in 27ms
$ cat dist-a/assets/index-BWqSUuqh.js # только свои модули: вставки Vite (modulepreload-polyfill, preload-helper) пропущены
//#region src/money.js
console.log("money выполнен");
function rub(n) {
return n + " ₽";
}
//#endregion
//#region src/cart.js
function total(prices) {
return rub(prices.reduce((a, b) => a + b, 0));
}
//#endregion
//#region src/main.js
console.log(total([120, 230]));
document.querySelector("button").addEventListener("click", async () => {
const { open } = await __vitePreload(async () => {
const { open } = await import("./editor-C4gor-ux.js");
return { open };
}, []);
console.log(open());
});
//#endregion
$ cat dist-a/assets/editor-C4gor-ux.js
//#region src/editor.js
console.log("editor выполнен");
function open() {
return "редактор открыт";
}
//#endregion
export { open };

Модули проекта в главном файле идут в той же очерёдности, что у graph: money, cart, main. Только обёрток нет: Vite склеил их в одну область, как говорилось в разделе про реестр, а свой служебный код вставил рядом — в выводе он пропущен.

Последний шаг — имя файла. Браузер кеширует файлы по адресу, и если назвать бандл просто main.js, после выката посетитель рискует получить из кеша вчерашний. Поэтому в имя вписывают хеш — короткую свёртку содержимого. Свёртку возьмём простую, FNV-1a: пройти по символам, смешать каждый с накопленным числом и напечатать восемь шестнадцатеричных знаков.

Прогноз: совпадут ли имена у двух одинаковых строк? И насколько похожими выйдут имена у строк, которые различаются одним словом?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Одинаковый текст — одинаковое имя, сколько ни считай: хеш зависит только от содержимого. Одно слово — и имя меняется целиком, а не в паре знаков. На этом держится весь приём: по адресу с хешем содержимое не изменится никогда, поэтому файл можно держать в кеше сколько угодно, а новая версия придёт под новым именем.

Теперь применим хеш к кускам. emit называет каждый кусок хешем его текста, вписывает новое имя туда, где main этот кусок зовёт, и только потом считает хеш самого main. Правка — одно слово в editor.js: «открыт» становится «готов». Главный прогноз статьи: сколько имён сменится — одно или два?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Два. Имя editor сменилось честно — поменялся его текст. main никто не трогал, но его текст всё равно другой: в нём записано имя куска, require.load("editor-….js"). Новое имя куска — новый текст main — новый хеш. Это и называют каскадом: правка в глубине поднимается по цепочке файлов, которые зовут друг друга по имени, и посетитель заново скачивает файлы, где не поменялось ни строчки кода.

Настоящий Vite ведёт себя так же. Та же правка в editor.js на диске, вторая сборка рядом с первой — и два главных файла отличаются одной строкой:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, vite 8.3.0, тот же проект; macOS arm64, Node 26.5.0, 14.09.2026; без chunkImportMap эти имена повторяются в любой папке
vite v8.3.0 building client environment for production...
transforming...
✓ 8 modules transformed.
rendering chunks...
computing gzip size...
dist-b/index.html 0.17 kB │ gzip: 0.16 kB
dist-b/assets/editor-5B3bnyWZ.js 0.15 kB │ gzip: 0.15 kB
dist-b/assets/index-CCnhTR4r.js 3.91 kB │ gzip: 1.61 kB
✓ built in 26ms
$ ls dist-a/assets dist-b/assets
dist-a/assets:
editor-C4gor-ux.js index-BWqSUuqh.js
dist-b/assets:
editor-5B3bnyWZ.js index-CCnhTR4r.js
$ diff dist-a/assets/index-*.js dist-b/assets/index-*.js
114c114
< const { open } = await import("./editor-C4gor-ux.js");
---
> const { open } = await import("./editor-5B3bnyWZ.js");

Лечат каскад так: main зовёт кусок не настоящим именем, а постоянным ключом, а соответствие «ключ → имя» живёт снаружи. У Vite для этого есть экспериментальная настройка build.chunkImportMap. Соответствие она кладёт в HTML картой импортов — тегом <script type="importmap">, по которому браузер сам подставляет настоящий адрес, когда модуль просит ключ. Та же правка, две сборки:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, vite 8.3.0, тот же проект; build.chunkImportMap помечен в типах Vite как экспериментальный; macOS arm64, Node 26.5.0, 14.09.2026; имена здесь зависят от пути к проекту — у вас будут свои; рядом с index.html в dist лежит ещё importmap.json; логи сборок опущены
$ cat dist-map-a/index.html
<!doctype html>
<html lang="ru">
<script type="importmap">{"imports":{"/assets/editor-jJuy8LbY.js":"/assets/editor-C4gor-ux.js","/assets/index-CoTYe_Gc.js":"/assets/index-6wzeuDfm.js"}}</script>
<script type="module" crossorigin src="/assets/index-6wzeuDfm.js"></script>
<body>
<button>Редактор</button>
</body>
</html>
$ cat dist-map-b/index.html # после правки editor.js
<!doctype html>
<html lang="ru">
<script type="importmap">{"imports":{"/assets/editor-jJuy8LbY.js":"/assets/editor-5B3bnyWZ.js","/assets/index-CoTYe_Gc.js":"/assets/index-6wzeuDfm.js"}}</script>
<script type="module" crossorigin src="/assets/index-6wzeuDfm.js"></script>
<body>
<button>Редактор</button>
</body>
</html>
$ ls dist-map-a/assets dist-map-b/assets
dist-map-a/assets:
editor-C4gor-ux.js index-6wzeuDfm.js
dist-map-b/assets:
editor-5B3bnyWZ.js index-6wzeuDfm.js
$ grep -n editor dist-map-a/assets/index-*.js
114: const { open } = await import("./editor-jJuy8LbY.js");

Главный файл сохранил имя: в его тексте стоит ключ, и после правки ключ тот же. Сменились только те места, где настоящее имя куска записано на самом деле, — сам кусок и карта в index.html. Имена в этом листинге у вас будут другими: с картой они зависят ещё и от пути к проекту, поэтому смотрите на устройство, а не на буквы.

У Next каскад тоже есть, только на уровень выше. Правка клиентского компонента без import() меняет имя одного его куска: адреса кусков клиентских компонентов лежат в HTML, в потоке серверных компонентов и в манифесте, а не в текстах соседних кусков. А кусок, который грузится через import(), вписан в текст куска, который его грузит, — и правка ленивого модуля снова меняет два имени, как у нашего emit.

Первый опыт — на приложении из раздела про реестр. В ui/price.tsx знак умножения меняется на букву x, и списки файлов .next/static до и после сборки сравниваются:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, next 16.3.5 (Turbopack); macOS arm64, Node 26.5.0, 14.09.2026
3d2
< chunks/0uk_002q-0_-r.js
6a6
> chunks/3sal6owct-fme.js
8,10c8,10
< lnvsrQGUDC5qt70KKY3r-/_buildManifest.js
< lnvsrQGUDC5qt70KKY3r-/_clientMiddlewareManifest.js
< lnvsrQGUDC5qt70KKY3r-/_ssgManifest.js
---
> lLv0wNQb8xgTzPdqmXs9L/_buildManifest.js
> lLv0wNQb8xgTzPdqmXs9L/_clientMiddlewareManifest.js
> lLv0wNQb8xgTzPdqmXs9L/_ssgManifest.js

Сменилось имя одного куска — того, где лежит Price, — и папка с идентификатором сборки, в которой три манифеста. Остальные куски, включая рантайм Turbopack, имена сохранили: index.html после правки ссылается на те же шесть кусков и на новый вместо старого.

Второй опыт — на копии того же приложения. Кнопка теперь грузит lib/editor.ts через import(). После первой сборки меняется строка в editor.ts, и приложение собирается снова:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, next 16.3.5 (Turbopack), react 19.3.0, NEXT_TELEMETRY_DISABLED=1; macOS arm64, Node 26.5.0, 14.09.2026; логи сборок опущены
$ npx next build && (cd .next/static/chunks && md5 -r *.js | sort -k2) > md5-a.txt
$ grep -o 'Promise.all(\["static[^]]*\]' .next/static/chunks/*.js
.next/static/chunks/09t_yyx8g5q4a.js:Promise.all(["static/chunks/0dht6p-z_0a44.js"]
$ sed -i '' 's/editor open/editor ready/' lib/editor.ts && npx next build && (cd .next/static/chunks && md5 -r *.js | sort -k2) > md5-b.txt
$ diff md5-a.txt md5-b.txt
1d0
< 8de341f91d18c3ce1a0ab862f054211e 09t_yyx8g5q4a.js
4c3,4
< 22e731aba4cc74c95e28f1503dce6bd3 0dht6p-z_0a44.js
---
> 3f81cba8763f570bb7705d1058252b95 0q516g7srd-8j.js
> 1dfbdd1b31f30f90608b91482b7036fe 14r83tn062i25.js
$ cat .next/static/chunks/0q516g7srd-8j.js # кусок с Price после правки editor.ts
(globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["object"==typeof document?document.currentScript:void 0,25781,t=>{t.v(e=>Promise.all(["static/chunks/14r83tn062i25.js"].map(e=>t.l(e))).then(()=>e(65848)))},20501,t=>{"use strict";var e=t.i(43476),n=t.i(71645);t.s(["Price",0,function({base:o}){let[i,s]=(0,n.useState)(1);return(0,e.jsxs)("button",{onClick:()=>{s(i+1),t.A(25781).then(t=>console.log(t.open()))},children:[i," × ",o+" ₽"]})}],20501)}]);
$ cat .next/static/chunks/14r83tn062i25.js
(globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["object"==typeof document?document.currentScript:void 0,65848,t=>{"use strict";t.s(["open",0,function(){return"editor ready"}])}]);
$ grep -o '"\[project\]/ui/price.tsx[^}]*}' .next/server/app/page_client-reference-manifest.js
"[project]/ui/price.tsx":{"id":20501,"name":"*","chunks":["/_next/static/chunks/3fntmmi971322.js","/_next/static/chunks/0q516g7srd-8j.js"],"async":false}
$ grep -o 'chunks/[a-z0-9_-]*\.js' .next/server/app/index.html | sort -u
chunks/0cegfsgm6lvdz.js
chunks/0cz1d0mv5g_q7.js
chunks/0q516g7srd-8j.js
chunks/27t_qfc-3_lzs.js
chunks/3fntmmi971322.js
chunks/3l04zcqx63h3y.js
chunks/turbopack-36t3xf3k-le2x.js

Из восьми кусков сменились ровно два: ленивый — у него другой текст — и кусок с Price, в тексте которого записан адрес ленивого, Promise.all(["static/chunks/…"]). Новое имя куска с Price попало в манифест и в index.html, а сам ленивый кусок в HTML не упомянут вовсе. Это каскад из emit, только до HTML он доходит через кусок компонента.

Отсюда правило раздачи, общее для всех сборщиков. Файл с хешем в имени можно кешировать навсегда, с пометкой immutable: под этим именем другого содержимого не будет. А HTML отдают с no-cache: именно он несёт новые имена, и вчерашний HTML из кеша поведёт посетителя к вчерашним файлам. Как эти заголовки выставляют папке с кусками и страницам, разобрано в статье про статический экспорт.

Сборки Vite выше шли с --minify false, чтобы код можно было прочитать. Без этого флага файл ещё и сжимается. Та же мини-сборка, плюс карта кода:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, vite 8.3.0 (Rolldown 1.2.8), тот же проект; macOS arm64, Node 26.5.0, 14.09.2026; скрипт node -e сокращён до …
vite v8.3.0 building client environment for production...
transforming...
✓ 8 modules transformed.
rendering chunks...
computing gzip size...
dist-sm/index.html 0.17 kB │ gzip: 0.16 kB
dist-sm/assets/editor-g12u_Hpd.js 0.15 kB │ gzip: 0.16 kB │ map: 0.29 kB
dist-sm/assets/index-DemfjphC.js 2.24 kB │ gzip: 1.14 kB │ map: 0.93 kB
✓ built in 54ms
$ grep -o 'console.log(`money.*function t(t){return e(t.reduce((e,t)=>e+t,0))}' dist-sm/assets/index-*.js # свои модули после минификации
console.log(`money выполнен`);function e(e){return e+` ₽`}function t(t){return e(t.reduce((e,t)=>e+t,0))}
$ tail -c 120 dist-sm/assets/index-DemfjphC.js
…console.log(e())});
//# sourceMappingURL=index-DemfjphC.js.map
$ cat dist-sm/assets/editor-g12u_Hpd.js
console.log(`editor выполнен`);function e(){return`редактор открыт`}export{e as open};
//# sourceMappingURL=editor-g12u_Hpd.js.map
$ node -e '…JSON.parse(map)…'
sources: ["../../src/money.js","../../src/cart.js","../../src/main.js"]
sourcesContent[0]: "console.log(\"money выполнен\");\nexport function rub(n) {\n return n + \" ₽\";\n}\n"

rub и total стали e и t, пробелы и переносы исчезли. Это работа минификатора: имена внутри модуля снаружи никому не видны, а короткие весят меньше. Та же работа дала e, s, n в кусках Next. Вернуть исходные имена умеет карта кода — файл .map, на который указывает последняя строка //# sourceMappingURL. В карте лежат sources, пути к исходникам, и sourcesContent — сами исходники, текстом целиком.

Карта нужна отладчику в браузере и сборщику отчётов об ошибках. Но выложенная рядом с бандлом, она раздаёт исходник всем желающим. Поэтому Next в прод-сборке браузерных карт не пишет: productionBrowserSourceMaps по умолчанию выключен, и в .next/static/chunks нет ни одного .map. Vite в режиме разработки, наоборот, встраивает карту прямо в конец отданного модуля: разработчику свой исходник прятать не от кого. Как по карте адрес из отчёта о падении превращается в имя функции, разобрано в статье о крэшах после релиза.

Сборщик собран: граф, обёртка, реестр, куски, хеш. Самое хрупкое в нём — самое первое, importsOf. Регулярка не знает, что читает: код, комментарий или строку. Прогноз: сколько зависимостей она найдёт в файле, где настоящий импорт один?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Три. Первую регулярка вытащила из комментария — так выглядит закомментированная старая строка. Вторую — из строки-подсказки внутри кода. Сборщик с такой регуляркой пошёл бы искать ./legacy.js и ./money.js, которых этот файл не импортирует: удалённый файл уронил бы сборку, а существующий встал бы в граф этого файла без всякого импорта.

Поэтому настоящие сборщики импорты регуляркой не ищут. Они отдают файл компилятору, получают от него дерево, где комментарий — это комментарий, а строка — строка, и берут импорты из узлов этого дерева. Компилятор файла — второй слой сборки, ему посвящена следующая статья. А как граф, куски и манифест устроены внутри Next, где один граф делится на сервер и клиент, — статья после неё.

Напоследок — те же четыре файла с диска через esbuild с включённой нарезкой на куски. Сравните с тем, что напечатал split в разделе про куски, и со сборкой Vite:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, esbuild 0.28.2, тот же проект; macOS arm64, Node 26.5.0, 14.09.2026; без --charset=utf8 esbuild экранирует кириллицу
out-esb-utf8/main-4QZXQP2H.js 393b
out-esb-utf8/editor-RMAIEGH2.js 138b
⚡ Done in 7ms
$ cat out-esb-utf8/main-4QZXQP2H.js
// src/money.js
console.log("money выполнен");
function rub(n) {
return n + " ₽";
}
// src/cart.js
function total(prices) {
return rub(prices.reduce((a, b) => a + b, 0));
}
// src/main.js
console.log(total([120, 230]));
document.querySelector("button").addEventListener("click", async () => {
const { open } = await import("./editor-RMAIEGH2.js");
console.log(open());
});

Два файла, как у нас: в главном money, cart и main, в куске — editor, а import() ведёт к имени с хешем. Обёрток нет: esbuild, как и Rolldown, поднял код модулей в одну область. Граф, куски и хеш у настоящих сборщиков те же, что у нашего. Обёртка и реестр у Rolldown и esbuild появляются там, где модули нельзя склеить, а webpack и Turbopack держат реестр всегда — только кладут в одну его функцию сразу несколько модулей.

  • Сборщик работает со строками до запуска: читает import из текста и выстраивает граф так, что зависимости стоят в очереди раньше тех, кто их импортирует.
  • Модуль становится функцией с параметрами require и exports: импорт превращается в вызов с уже разрешённым путём, экспорт — в запись в объект.
  • Реестр «адрес → функция» и кеш exports дают главное обещание модулей: тело выполняется один раз, сколько бы файлов его ни импортировали. В прод-сборке соседние ESM-модули склеиваются, а функция на модуль остаётся у CommonJS-пакетов.
  • Всё, что достижимо только через import(), уезжает в отдельный кусок, а сам вызов переписывается в загрузку файла и require.
  • Имя файла — хеш содержимого. Имя куска записано в тексте того, кто его грузит, поэтому правка в глубине меняет несколько имён; каскад лечат постоянным ключом и картой имён в HTML.
  • Файлы с хешем кешируют навсегда, HTML — no-cache. А искать import регуляркой нельзя: комментарий и строка её обманывают, нужен компилятор файла.

1/4Граф main.js → cart.js → money.js обходится в глубину, и файл встаёт в очередь после своих зависимостей. Какой файл окажется в очереди первым?

2/4money.js импортируют и cart.js, и main.js. Сколько раз выполнится тело money в бандле с реестром и кешем?

3/4Поменяли строку в editor.js, который main грузит через import(). Почему у main тоже сменилось имя?

4/4Почему файлы с хешем в имени кешируют надолго, а HTML отдают с no-cache?

Круг из двух файлов

Файл a.js импортирует b.js, а b.js — a.js. Граф и бандл с реестром из статьи ждут в подложке. Предскажите обе строки вывода — порядок в очереди и то, что увидит b, — и только потом запускайте.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Для сравнения — те же два файла настоящими модулями: Node запускает их напрямую, а esbuild склеивает в один файл.

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, Node 26.5.0, esbuild 0.28.2; macOS arm64, 14.09.2026
$ node a.mjs
b видит function
$ npx esbuild a.mjs --bundle --format=iife --charset=utf8 | node
b видит function

Вывод бандла:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Порядок — по правилу первого раздела: обход зашёл в a, из него в b, из b снова в a, но a уже в seen, поэтому b встал в очередь первым.

При запуске require("/src/a.js") кладёт в кеш пустой exports и начинает тело a. Первая же его строка просит b, а b просит a — и кеш отдаёт тот самый объект, в который a ещё ничего не записал. Деструктуризация const { a } = … забирает поле сразу, и в b остаётся undefined, хотя строкой ниже a свою функцию запишет. Если бы рантайм клал exports в кеш только после выполнения тела, было бы хуже: a и b звали бы друг друга, пока не кончится стек.

Настоящий ESM печатает function: объявления функций в модуле готовы до выполнения его первой строки, и esbuild, склеивая оба файла в одну область, это сохраняет. Круг зависимостей целиком разберём в части VI.

Главный файл без каскада

Вот emit из раздела про хеш вместе с каскадом: после правки editor.js обе строки печатают «новое имя». Перепишите emit так, чтобы main.js сохранял имя, а editor.js по-прежнему получал новое. Настоящее имя куска при этом не должно потеряться: рантайм возьмёт его где-то ещё, только не из текста main.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Каскад идёт через одну строку — ту, что вписывает имя куска в текст main. Её и убираем: main зовёт кусок постоянным ключом "editor.js", а настоящее имя остаётся в names.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь вывод:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Соответствие «ключ → настоящее имя» — это names без main.js, и отдают его отдельно от main: в HTML, который и так уходит с no-cache. Рантайму остаётся в require.load сначала заглянуть в эту карту и только потом грузить файл. Так устроен build.chunkImportMap у Vite: ключ в тексте главного файла, карта импортов в index.html.

Экспорт, который никто не импортирует

Из статичности import растёт tree shaking: сборщик читает исходники, не запуская их, и видит, что из модуля никто не берёт. Сделайте первый шаг сами. В money.js появилась функция usd, которую не импортирует ни один файл. Допишите unusedExports: она должна вернуть такие экспорты по всему графу — строками вида «файл: имя».

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Два прохода по графу: сначала собрать, что импортируют, — с полным адресом файла, потому что одно и то же имя могут экспортировать разные модули; потом пройти по экспортам и оставить те, которых в собранном нет.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Настоящий сборщик на этом месте выбросил бы usd из бандла. Модуль money целиком он не выбросит, даже если бы из него не брали ничего: на верхнем уровне там console.log, а побочный эффект сборщик обязан сохранить. И точно так же, как в последнем разделе, настоящий сборщик читает импорты и экспорты из дерева компилятора, а не регуляркой.