Next.js изнутри. Часть 4. App Router: навигация, кеш и мутации.

Next.js изнутри. Часть 4. App Router: навигация, кеш и мутации.


Введение

В предыдущей части мы проследили, как страница App Router рождается на сервере и доезжает до браузера. В этой части рассмотрим жизнь страницы после загрузки. Разберём, как работает клиентская навигация без перезагрузки, как Next.js заранее подтягивает контент через многоуровневый Segment Cache и как Server Actions дают вызывать серверные функции прямо из клиента. Всё это опирается на структуры, описанные в прошлой части.

Клиентская навигация

Пользователь кликает по <Link> или вызывает useRouter().push(). В этот момент страница не перезагружается, так как работает клиентский роутер.

При навигации клиент шлёт на тот же URL запрос, но с несколькими служебными заголовками (их можно посмотреть во вкладке Network):

RSC: 1
Next-Router-State-Tree: <структура маршрута, которая есть у клиента>
Next-Url: <текущий URL, нужен для interception-роутов>

RSC: 1 сообщает серверу, что нужен не HTML, а Flight-ответ.

Next-Router-State-Tree — это та самая структура (FlightRouterState), описывающая, что у клиента уже есть. Значение этого заголовка — это URL-encoded JSON, так что прочитать его можно через JSON.parse(decodeURIComponent(...)).

Также к URL добавляется cache-busting параметр (?_rsc=<хеш>). Многие CDN, закешировав HTML по «голому» URL, могут отдавать тот же HTML в ответ на RSC-запрос. Уникальный параметр разводит HTML- и Flight-ответы по разным ключам кеша.

Получив запрос с присланной структурой, сервер решает, с какого общего layout'а начинать рендер. Он идёт одновременно по своему дереву маршрута и по структуре от клиента, сравнивая сегмент за сегментом. Пока сегменты совпадают, сервер спускается глубже, ничего не рендеря. Как только сегменты расходятся или клиент явно отметил ветку для изменения, сервер рендерит поддерево с этой точки и отдаёт только его.

Клиент получает патч и вмердживает его в своё дерево. Сегменты, которые не изменились, сохраняют свои узлы, а с ними и состояние: позицию скролла, открытые аккордеоны, введённый в формы текст. Это прямое следствие того, что на каждой границе сидит LayoutRouter, выбирающий контент из кеша по своему слоту: затронутый слот берёт новый контент, остальные — прежний.

Отсюда происходит и экономия: при переходе между двумя страницами с общим layout сервер пришлёт только изменившуюся часть, а общий layout не будет ни рендериться, ни пересылаться.

Segment Cache и prefetch

Prefetch это предзагрузка страниц по ссылкам: Next.js заранее, ещё до клика, подтягивает данные для страниц, на которые пользователь может перейти, чтобы сама навигация прошла мгновенно. По умолчанию это происходит для каждого <Link>, когда он попадает в зону видимости, а также при наведении. Роутер в фоне запрашивает Flight-ответ целевого маршрута и кладёт в кеш.

В свежих версиях навигация и prefetch перестроены на архитектуру Segment Cache. Её идея — разделить клиентский кеш на два уровня:

  • Route Cache — структуры маршрутов (деревья сегментов);
  • Segment Cache — отдельные сегменты с их контентом.

В ранней модели prefetch тянул дерево маршрута целиком до первой границы loading. Теперь же клиент сначала запрашивает только лёгкую структуру маршрута, а потом — отдельные сегменты, которых ему не хватает. Сегменты, общие для нескольких маршрутов (например, корневой layout), кешируются один раз и переиспользуются при навигации куда угодно.

Если открыть Network, видно, что наведение на одну ссылку порождает не один запрос, а пачку. Это как раз прямое следствие посегментной модели. Сначала клиент отдельным запросом тянет дерево маршрута (с заголовком Next-Router-Segment-Prefetch: /_tree), а затем шлёт по запросу на каждый недостающий сегмент, где у каждого в Next-Router-Segment-Prefetch лежит путь до этого сегмента. Все prefetch-запросы помечены Next-Router-Prefetch: 1 (плюс обычный RSC: 1), так что в Network их легко отличить от навигационных.

Может показаться что такое количество запросов может бить по производительности, но это не так, и вот почему:

  • дедупликация и кеш. Перед запросом клиент смотрит в Segment Cache: если сегмент уже там (или запрос к нему уже в процессе), то повторно не запрашивает.
  • переиспользование общих сегментов. Корневой layout и прочие общие куски выкачиваются однократно на всё приложение, а не заново под каждую ссылку.
  • запросы мелкие и параллельные. Один запрос — один сегмент; а по HTTP/2 они мультиплексируются по одному соединению.
  • это управляемые задачи. Prefetch'ем рулит планировщик с приоритетами: он умеет в том числе притормаживать и отменять неактуальные задачи (например, когда увели мышку).

Если для конкретной ссылки такое поведение нежелательно, prefetch можно ослабить или выключить через проп prefetch у <Link>.

Серверная сторона заранее готовит контент так, чтобы его можно было резать на сегменты, и помечает каждый подсказками — нужно ли его вообще запрашивать отдельно, есть ли под ним граница loading. Тут есть нюанс с тем, какие маршруты поддерживают посегментный prefetch: при включённых Cache Components (cacheComponents: true в next.config) — все, а без них — только полностью статические страницы, потому что их посегментные ответы заготавливаются на этапе статической генерации (билд или ISR), а не на лету.

Server Actions

Server Actions — это вызов серверной функции прямо из клиента, без ручного написания API-маршрута.

На сборке каждая функция с директивой "use server" получает стабильный идентификатор — по сути хеш. Сервер заводит карту «идентификатор → реальная функция», которая хранится в файлах server-reference-manifest.

На клиенте вызов такой функции превращается в POST-запрос: в заголовке Next-Action едет этот идентификатор, а в теле — сериализованные аргументы. Сервер по идентификатору находит нужную функцию, декодирует аргументы и исполняет её.

Ответ возвращается как Flight-поток (Content-Type: text/x-component), а в нём лежат две разные вещи:

  • возвращаемое значение функции — то, что мы написали в return внутри экшена (в полезной нагрузке это поле a, от action result);
  • обновлённое дерево маршрута — результат ре-рендера страницы (поле f, от flight).

Вот важный нюанс: ре-рендер происходит не всегда, а только если экшен инвалидировал кеш, например, вызвал revalidatePath() / revalidateTag() или сделал redirect(). Тогда сервер понимает, что данные на странице могли устареть, перерендеривает текущий маршрут и кладёт обновлённый Flight рядом с возвращаемым значением. Клиент применит его тем же механизмом, что и при навигации, и UI сразу покажет свежие данные без отдельного запроса. Если же экшен ничего не инвалидирует, поле f приедет пустым, и в ответе будет только возвращаемое значение.

Если идентификатор неизвестен (например, запрос прилетел от старого деплоя), сервер сразу отвечает «такого экшена нет» и не запускает дальнейшую обработку — ни выполнение функции, ни возможный ре-рендер маршрута.

Отдельного внимания заслуживает безопасность замыканий. Серверный экшен можно объявить инлайн, прямо внутри компонента, — и тогда он может захватывать переменные из окружающей области видимости. Например, экшен pickInline ниже использует secret, объявленный в компоненте этажом выше.

// TagPicker.tsx — серверный компонент
import { pickTag } from './actions/tag';

export async function TagPicker() {
  const secret = await getServerToken();  // значение, известное только серверу

  // secret захвачен замыканием инлайнового экшена → его зашифруют
  async function pickInline(tagId: string) {
    'use server';
    await pickTag(secret, tagId);
  }

  return (
    <>
      <form action={pickInline.bind(null, 'hot')}>...</form>
    </>
  )
}

В этом и кроется риск. Чтобы клиент потом смог вызвать такой экшен, захваченная переменная должна незаметно для тебя уехать на клиент и вернуться обратно (клиент обязан прислать её при вызове). А в замыкание экшена легко попадает что-то серверное — токен, ключ, внутренний id.

Чтобы это не превратилось в дыру в безопасности, такие захваченные переменные шифруются. Происходит это на сборке ключом, единым для деплоя. Идентификатор экшена при этом приписывается к данным как контрольная сумма и проверяется при расшифровке, что защищает от подмены. На клиенте оседает только шифротекст, прочитать или подменить его там нельзя — расшифровка возможна лишь на сервере, у которого есть ключ.

Важно понимать границу: шифруется только захват через замыкание. Аргументы, переданные экшену явным ****.bind(null, …), не шифруются, поэтому они едут открытым JSON и в исходнике страницы, и в теле POST-запроса. Скрытое замыкание прячут, чтобы поймать случайную утечку, а явный переданный аргумент — нет.

// TagPicker.tsx — серверный компонент
import { pickTag } from './actions/tag';

export async function TagPicker() {
  const secret = await getServerToken();  // значение, известное только серверу

  return (
    <>
      {/* secret передан явным .bind → поедет открытым */}
      <form action={pickTag.bind(null, secret, 'hot')}>...</form>
    </>
  )
}

Ещё одно следствие архитектуры Server Actions: формы с ними работают даже без JavaScript. Если экшен привязан к <form action={...}>, браузер отправит обычный POST, а сервер обработает его, отрендерит и вернёт страницу.

Итого

Жизнь страницы после загрузки в App Router — это двустороннее общение клиента с сервером поверх Flight. Цена за всю эту модель — сложность: два рантайма, собственный протокол, многоуровневый кеш и неочевидные границы серверного и клиентского кода. Но в совокупности оно даёт то, ради чего всё затевалось, — минимум JavaScript в браузере при сохранении SSR и стриминга.

Report Page