Next.js изнутри. Часть 9. Способы оптимизации.

Next.js изнутри. Часть 9. Способы оптимизации.

Anastasia Kotova

Введение

Оптимизация в Next.js — это следствие решений, которые мы разбирали во всех предыдущих частях. Поэтому в этой части мы собираем вместе то, что уже видели, и добавляем несколько механизмов, о которых не успели поговорить до этого.

Статика как оптимизация по умолчанию

Самый дешёвый запрос — тот, который не требует рендеринга вообще. Next.js старается определить это на этапе сборки: если маршрут не читает cookies(), headers() или другие динамические API, он рендерится один раз в next build и дальше отдаётся как готовый HTML.

Возможность отдавать статику, которая при этом умеет обновляться, называется ISR (Incremental Static Regeneration). Идея в модели stale-while-revalidate: истёкшая по revalidate страница не блокирует пользователя пересчётом — ему отдаётся текущая, устаревшая версия, а пересчёт запускается в фоне и подменяет запись в кеше для следующих запросов.

После того как router-server разрешил маршрут и передал его в render-server, тот перед рендером идёт в инкрементальный кеш, построив ключ из пути и параметров. Дальше развилка:

  • записи нет — рендерим, кладём результат в кеш вместе со значением revalidate, взятым из конфигурации маршрута;
  • запись есть и не протухла — отдаём сохранённый HTML и RSC Payload, рендер не вызывается вообще;
  • запись есть, но протухла — отдаём её как есть, а рендер уходит в фон и по завершении перезаписывает запись.

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

Partial Prerendering

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

Partial Prerendering (PPR) снимает это ограничение, разрешая статике и динамике сосуществовать в одном ответе. На этапе сборки Next.js рендерит страницу и на границах Suspense останавливается: то, что снаружи границ, попадает в статическую HTML-оболочку, а то, что внутри — превращается в postponed state, сериализованное состояние, откуда рендеринг можно продолжить позже.

На запросе сервер сразу отдаёт готовую оболочку, потому что она уже лежит в кеше, как обычная статика, а затем стартует рендер с сохранённого postponed state и досылает динамические куски в тот же поток, заполняя оставленные дыры.

У модели есть характерная ловушка: чтение searchParams или любого другого динамического API прямо в компоненте страницы делает динамическим всё, что вокруг него, потому что границы Suspense между этим чтением и корнем страницы нет — а значит, нет и точки, в которой рендер можно было бы приостановить, отложив только эту часть. Чтение динамических данных стоит опускать в дочерний компонент и уже его оборачивать в Suspense — тогда в дыру уходит только он, а не вся страница.

Разбиение бандла

В седьмой части мы разбирали, как Next.js выносит React и себя самого во framework-чанк, а крупные библиотеки из node_modules — в отдельные lib-чанки при превышении ~160 КБ. Эта логика работает без нашего участия и одинаково для всех маршрутов.

Рядом с ней работают SWC-трансформы modularizeImports и optimizePackageImports, которые мы тоже упоминали в седьмой части. Когда мы пишем import { Button } from 'ui-kit', а ui-kit/index.ts реэкспортирует сотни компонентов, наивный бандлинг рискует затащить в граф их все. Трансформ переписывает такой импорт в точечный, прямо на конкретный модуль. Разница между двумя вариантами в том, откуда берётся правило переписывания: modularizeImports требует задать шаблон пути в конфиге руками, optimizePackageImports строит карту экспортов сам и поэтому работает автоматически.

А вот next/dynamic — это уже инструмент, которым управляем мы, а не бандлер. Он построен поверх Loadable, форка библиотеки react-loadable. Компонент, обёрнутый в dynamic(() => import('./Component')), попадает не в основной чанк маршрута, а в отдельный, который бандлер выделяет по границе import(), и загружается по требованию, а не при первом заходе на страницу.

У этого механизма есть несколько практических деталей. По умолчанию Loadable ждёт 200 мс, прежде чем показать состояние загрузки — чтобы не мигать спиннером на быстрых загрузках. Также можно передать ssr: false и полностью исключить компонент из серверного рендера.

next/image

Компонент Image решает конкретную проблему: браузер не знает заранее, какого размера будет картинка, из-за чего страница дёргается, пока она не загрузится. Image требует указать width/height (или использовать статический импорт, откуда размеры выводятся сами) и резервирует место под картинку ещё до её загрузки.

Дальше в дело вступает серверный оптимизатор — эндпоинт /_next/image. Router-server, разобрав путь, отдаёт такой запрос в оптимизатор изображений. На вход оптимизатор принимает url, ширину w и качество q. Внутри происходит трансформация через sharp: изображение приводится к запрошенной ширине и перекодируется в формат, который, судя по заголовку Accept, поддерживает браузер. Результат такой трансформации кешируется на диске в .next/cache/images, чтобы в следующий раз можно было отдать уже посчитанный вариант, не вызывая sharp заново.

С точки зрения того, что мы отдаём в браузер, Image сам генерирует srcset на основе deviceSizes, так что браузер выбирает подходящий по своему вьюпорту размер.

next/font

Шрифты — источник сразу двух проблем: лишний внешний запрос и сдвиг макета, пока шрифт не подгрузился. next/font решает обе на этапе сборки.

Для этого используется next-font-loader — один из специализированных загрузчиков бандлера. В случае с next/font/local он читает файлы из проекта, считает метрики, генерирует @font-face. В случае с next/font/google к этому добавляется загрузка: лоадер идёт за CSS на fonts.googleapis.com, вытаскивает оттуда ссылки на файлы и скачивает их, чтобы положить рядом с остальной статикой приложения.

Момент загрузки — это момент обработки модуля лоадером. В продакшен-сборке это выполнение next build, а в dev шрифт скачивается тогда, когда впервые компилируется импортирующая его страница. Разной оказывается и цена неудачи: dev-режим на недоступный Google Fonts ответит предупреждением и fallback-шрифтом, продакшен-сборка упадёт. Для CI без выхода наружу это означает, что next/font/google придётся либо проксировать, либо заменять на next/font/local.

Вторая часть оптимизаций — автоматический подбор метрик шрифта для fallback. next/font знает метрики (ascent, descent, line gap) как целевого шрифта, так и системных шрифтов, которые браузер покажет, пока веб-шрифт не догрузился, и генерирует для fallback-шрифта корректировки через size-adjust и связанные CSS-свойства. Смысл в том, чтобы placeholder текста занимал столько же места, сколько займёт финальный шрифт, и переключение между ними не двигало вёрстку.

next/script

Сторонние скрипты — аналитика, чаты, виджеты — классический источник блокировки рендера. Браузер должен их скачать и выполнить прежде, чем продолжить строить страницу. next/script даёт явный контроль над тем, когда это происходит, через параметр strategy:

  • beforeInteractive — до гидратации, для критичного кода (антифрод, полифилы);
  • afterInteractive — сразу после гидратации;
  • lazyOnload — в простое браузера, для всего некритичного;
  • worker — в отдельном воркере через Partytown, в стороне от главного потока.

По умолчанию (afterInteractive) скрипт не мешает первому рендеру вообще — он загружается параллельно и выполняется, когда основной поток уже освободился после гидратации.

Трассировка файлов

В седьмой части, разбирая шаги next build, мы упоминали трассировку файлов. Её задача — статически проанализировать, какие файлы действительно нужны каждому серверному входу: не только наш код, но и транзитивные зависимости из node_modules. Результат — список минимально необходимых файлов, и именно из него режим output: 'standalone' собирает компактную директорию для деплоя.

С точки зрения оптимизации он не ускоряет ответ пользователю, но зато сокращает размер образа, а вместе с ним и время сборки контейнера, время выкладки и холодный старт на serverless-платформах.

Итого

Next.js старается сдвинуть как можно больше работы на этап сборки и максимально сузить то, что остаётся на рантайм. Статика и ISR сдвигают в билд сам рендеринг, PPR — ту часть рендеринга, которая не зависит от запроса. next/font сдвигает в билд загрузку и хостинг шрифтов, а SWC-трансформы и next/dynamic принимают решение о том, что попадёт в первый чанк. next/image — исключение: он не может сдвинуть в билд трансформацию для произвольных внешних изображений, поэтому вместо этого кеширует результат так, чтобы трансформация произошла только один раз для каждой комбинации URL, ширины и качества.


На этом мой цикл подходит к концу. Мы начали с общей архитектуры и того, из каких слоёв фреймворк вообще состоит, разобрали оба роутера, спустились в серверный слой, открыли сборку и dev-режим — и в последней части посмотрели на всё это со стороны оптимизации.

Мне кажется, главное, что даёт такой разбор, — это способность объяснить поведение фреймворка, а не запомнить его. Почему middleware видит запрос раньше редиректа из кода страницы, почему серверный компонент не может вызвать useState, почему замеры производительности в dev ничего не говорят о продакшене, почему одно обращение к кукам в глубине дерева делает динамической всю страницу. Это всё — следствия из устройства, которое мы разобрали.

Я пишу о сложных вещах в разработке в том числе для того, чтобы самой в них лучше разобраться. Если хочется читать такие разборы регулярно — подписывайтесь на мой Telegram-канал)

Report Page