Next.js изнутри. Часть 8. Dev-mode.
Anastasia KotovaВведение
В седьмой части мы разобрали, как next build превращает исходники в артефакты, а в пятой и шестой — как серверный слой эти артефакты обслуживает. Обе картины опирались на одно и то же допущение: к моменту, когда придёт первый запрос, манифесты и чанки уже собраны, а список маршрутов известен.
В dev-режиме этого допущения нет. next dev живёт в режиме, где сборка идёт параллельно с обслуживанием запросов, а её результат должен доезжать до уже открытого браузера. В статье рассмотрим нюансы этого процесса подробнее.
Процессы внутри dev
Команда next dev использует внутри себя два процесса. Родительский процесс форкает дочерний и следит за ним. Вся логика (HTTP-сервер, бандлер, рендеринг) живёт именно в дочернем процессе.
Разделение существует ради сценария перезапуска. Дочерний процесс отдельно следит за файлами конфигурации, и когда next.config.js меняется, он завершается со специальным кодом выхода. Родитель реагирует на этот код и поднимает дочерний процесс заново с теми же опциями. Так применяется новый конфиг.
Внутри дочернего процесса живут знакомые нам router-server и render-server. Между ними вклинивается бандлер — Webpack или Turbopack — как долгоживущий объект, к которому можно обращаться в рантайме.
Рядом с основным процессом Next держит ещё несколько воркеров, например, один из них обслуживает getStaticPaths и generateStaticParams. Эти функции отвечают на вопрос, какие конкретные пути динамического маршрута считаются известными заранее. К моменту их вызова в dev-сервере уже загружены модули, отработали предыдущие запросы, накопилось состояние. Если реализация случайно на это состояние опирается, то в dev всё будет работать, но production-сборка упадёт. Поэтому Next создаёт под каждый вызов отдельный воркер и тут же его уничтожает, воспроизводя условия билда.
Маршруты без манифестов
В production-режиме на старте router-server считывает манифесты для получения информации о сборке (списки страниц, правила для middleware и т.д.) и дальше использует их при проверке файловой системы. В dev отдельного шага сборки нет, поэтому карту маршрутов строит вотчер. Под наблюдением оказываются директории app/ и pages/, а также точечный набор файлов: кандидаты на middleware (он же proxy) и instrumentation, .env-файлы и tsconfig.json с jsconfig.json.
На каждое изменение вотчер заново обходит файлы и пересобирает всё, что из них следует: списки страниц, роут-хендлеров, лэйаутов и слотов, матчеры middleware, набор статических файлов метаданных. Он же обнаруживает конфликты, когда один и тот же путь описан и в app/, и в pages/, и он же генерирует типы маршрутов в .next/types. Router-server использует эти данные для шага проверки файловой системы. Благодаря этому новая страница в dev появляется без перезапуска сервера. Если набор маршрутов изменился, вотчер дополнительно сообщает об этом в браузер клиентскому роутеру.
Компиляция по требованию
В production Next.js загружает уже собранные модули. В dev, если страница ещё не собрана, запрос ждёт, пока бандлер её соберёт.
Webpack сам по себе не имеет механизма «ленивой» компиляции: он собирает всё, что перечислено в его точках входа. Поэтому механизм написан в Next, поверх обычного API бандлера, и построен вокруг карты entrypoints, которая живёт в памяти процесса. Каждая запись в ней знает свой статус (добавлена, собирается, собрана), время последнего обращения и флаг «помечена на выгрузку» (о нём чуть ниже). Если запись уже есть и она собрана, обращение за страницей просто обновляет время активности и снимает флаг, а компиляция не запускается. Если записи нет, она создаётся со статусом «добавлена», и вот тогда сборку нужно запустить.
Список entrypoints пересобирается заново перед каждой сборкой, и участвуют в ней все живые записи. Невалидными при этом помечаются только модули изменившихся файлов, всё остальное берётся из кеша модулей. А вот работа поверх модулей — построение графа чанков, кодогенерация, хеширование — проходит по всей компиляции целиком.
Число entrypoints за долгую сессию растёт, а раз в сборке участвуют все они, растёт и стоимость каждой итерации. Поэтому неактивные страницы выгружаются. Раз в шесть секунд таймер обходит карту и помечает флагом всё, что уже собрано и к чему не обращались дольше минуты. Затем этап компиляции выбрасывает помеченные записи из карты, и модули, которые подключались только через них, из сборки уходят.
Сервер узнаёт, что страница «активна», из пингов, которые идут через тот же сокет, по которому приходят обновления. Pages Router шлёт свой pathname. App Router шлёт всё дерево состояния роутера целиком, и сервер продлевает жизнь всем entrypoints, которые из этого дерева следуют.
С Turbopack отдельного механизма как у Webpack нет: граф turbo-tasks вычисляется по требованию, а неиспользуемое просто не пересчитывается.
Канал обновлений
Обратный канал, по которому Next.js сообщает об обновлениях в сборке клиенту, — это обычный WebSocket. Обработчик такого соединения находится в router-server. Если сервер запущен в dev и путь начинается с /_next/hmr, соединение отдаётся бандлеру. Всё остальное идёт по обычному пути — через разрешение маршрутов, rewrites и, при необходимости, проксирование. Для сокетов также проверяется origin (список допустимых источников задаётся конфигом).
По этому каналу ходит довольно много типов сообщений. Есть жизненный цикл компиляции: началась сборка, закончилась сборка, синхронизация состояния при подключении. Есть изменения карты маршрутов: страница добавилась, страница удалилась. Есть классификация того, что именно изменилось: клиентские изменения, изменения только серверной части, изменения middleware, изменения серверных компонентов, изменение набора статически известных параметров. Есть и команда перезагрузить страницу.
Fast Refresh
Fast Refresh — это функция среды разработки React, которая мгновенно показывает изменения кода в браузере, сохраняя при этом текущее состояние компонентов (например, введённый текст или открытые вкладки).
Fast Refresh можно перепутать или объединить с Hot Module Replacement, но уровни у них разные. HMR — общий механизм бандлера. Про React он ничего не знает и сам по себе не может решить, что делать с состоянием компонентов. Fast Refresh же умеет найти смонтированный компонент, подменить его реализацию и определить, можно ли сохранить состояние. Реализует его пакет react-refresh из репозитория React, а Next включает этот пакет в свой dev-режим.
Для клиентской сборки в dev включается флаг, который доезжает до SWC как опция react-трансформа. Трансформ регистрирует каждый компонент модуля под стабильным идентификатором и вычисляет для него сигнатуру — слепок того, какие хуки и в каком порядке использованы. Параллельно в сборку добавляется рантайм react-refresh, который умеет по этим идентификаторам находить смонтированные компоненты и подменять их реализацию.
Из этого напрямую вытекают следующие правила. Если модуль экспортирует только компоненты, реализацию можно заменить, сохранив состояние. Если сигнатура хуков изменилась — состояние восстановить нельзя, компонент перемонтируется. Если модуль экспортирует что-то помимо компонентов, гарантий нет: обновление уходит вверх по графу зависимостей и в пределе может дойти до полной перезагрузки страницы.
И главное ограничение: флаг включается только для клиентского слоя. Fast Refresh физически не существует для серверного кода.
Серверные изменения
Серверный компонент в браузере не живёт — там есть только результат его рендеринга. Поэтому на этом уровне работает другой механизм.
По окончании компиляции Next на сервере сравнивает множества изменившихся страниц по разным целевым средам и раскладывает изменения по категориям. Правки middleware дают одно сообщение, правки серверной части страниц Pages Router — другое, правки серверных компонентов ведут к третьему.
Параллельно нужно избавиться от старых модулей на самом сервере. Собранные серверные чанки лежат на диске и подгружаются обычным require, поэтому Next вычищает их из кеша модулей Node.js.
На клиенте сообщение о серверных изменениях обрабатывается так. Если страница сейчас в состоянии ошибки, просто происходит полная перезагрузка. В нормальном же случае вызывается refresh роутера внутри startTransition: клиент запрашивает новый RSC Payload и накатывает его поверх текущего дерева.
Кастомный сервер в dev
Про какие нюансы dev-режима нужно знать, когда мы используем кастомный сервер?
Вызов next({ dev: true }) поднимает полноценный dev-режим со всей описанной выше машинерией. Работает это потому, что handle — обработчик router-server, а бандлер вживлён именно в router-server, так что вместе с маршрутизацией мы получаем и сборку по требованию.
Дальше начинаются нюансы. Первый и самый заметный: HMR ходит по WebSocket, то есть через событие upgrade нашего HTTP-сервера, а handle — обработчик обычных запросов. Если апгрейд не пробросить в Next отдельно, страницы будут открываться нормально, но обновляться перестанут.
Также стоит помнить, что наш server.js не проходит через компилятор и не находится под вотчером, так что его изменения не подхватываются ничем. Перезапуск процесса при правке собственного сервера придётся организовать самостоятельно.
Итого
Dev- и prod-режимы в Next.js отличаются и в ряде других аспектов.
Кеширование в dev намеренно почти отключено. Предзагрузка ссылок при появлении во вьюпорте также выключена: она потребовала бы компилировать все страницы, на которые ведут видимые ссылки. При этом предзагрузка по наведению осталась.
Сборка тоже другая: нет минификации, нет production-стратегии разделения на чанки, зато есть source maps и development-сборка React с её проверками. Поверх всего этого React в Strict Mode рендерит компоненты дважды.
Из этого следует, что любые измерения производительности в dev измеряют только dev. Этот режим оптимизирован под цикл правки, а не под скорость ответа, и потому ничего не говорит о том, как приложение поведёт себя в бою.