Зачем фреймворк поверх React

Next.js≈ 11 минут

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

Всё, что вы написали в этой части, держится на одной строке: createRoot(...).render(<App />). Она вешает приложение на пустой узел уже готовой страницы — и в этом же её граница. Второго адреса у приложения нет, до выполнения скрипта на экране нет ничего, а до браузера код не доедет без сборки. Фреймворк начинается ровно там, где эта строка заканчивается.

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

React рисует в узел, который на странице уже есть. Значит, страница откуда-то берётся — и в приложении на одном React она выглядит одинаково у всех: <div id="root"></div>, тег скрипта с бандлом и больше ничего. Всё остальное, что увидит посетитель, — результат выполнения этого скрипта.

Насколько «ничего» — видно прямо в песочнице: у неё в разметке ровно тот же пустой #root. Прежде чем жать Run, решите, что напечатает строка «сразу после render» — разметку с заголовком или пустоту.

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

Пусто оба раза. Первый — понятно: скрипт только начался. Второй интереснее: render() ничего не рисует, а ставит работу в очередь — это тот самый триггер из статьи «Триггер, рендер, коммит». Разметка появляется позже, когда React дойдёт до коммита. На странице с настоящим бандлом к этим миллисекундам добавляется скачивание и разбор нескольких сотен килобайт кода.

ШагЧто на экране
Пришёл HTMLпусто: в разметке один div без содержимого
Скачивается бандлпусто
Бандл разбирается и выполняетсяпусто
Первая отрисовкакаркас и заглушки «Загрузка…»
Ушёл и вернулся запрос за данныминаконец содержимое

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

Вторая половина беды в том, что страницу читают не только люди. Карточка ссылки в мессенджере, превью в соцсети, парсер прайсов, поисковый робот — все они берут то, что пришло в HTML. Гуглбот скрипты выполняет, но откладывает это на отдельный проход; остальные чаще всего не выполняют вовсе. В пустом #root им читать нечего.

Проверить чужое приложение просто: «Просмотр кода страницы» в контекстном меню, а не инспектор. Инспектор показывает уже собранное дерево, а исходный код — то самое, что пришло по сети.

Они лежат в том же единственном index.html — значит, одни на все адреса приложения. Из кода их поменять можно (document.title, запись в <meta>), но случится это уже после выполнения скрипта, а карточка ссылки собирается раньше и по исходному HTML. Поэтому у клиентских приложений все ссылки в мессенджере выглядят одинаково: одно название, одно описание, одна картинка.

Фреймворк решает это тем, что HTML для каждого адреса собирается на сервере — вместе с его метаданными. Как это устроено в Next.js, разбирает статья «Метаданные, sitemap и картинки для соцсетей» в главе про кеш и прод.

Пересчитайте словарь, который вы выучили за эту часть: компоненты, пропсы, состояние, эффекты, контекст, один корень. В нём нет слов «адрес», «сервер», «файл», «сборка». React — библиотека рендеринга: она превращает состояние в дерево узлов и обновляет его. На этом её обязанности заканчиваются.

Задача живого приложенияКто её решает, если у вас только React
Показывать разное на /tasks и /tasks/42вы: свой роутер
Ответить на /tasks/42, когда его открыли напрямуювы: настройка сервера
Отдать первый HTML не пустымвы: свой серверный рендер
Разрезать бандл, чтобы не грузить весь код сразувы: настройка сборщика
Начать загрузку данных до отрисовки, а не послевы: своя схема
Свой заголовок и описание у каждого адресавы
Ключи и запросы, которым нельзя в браузервы: отдельный сервер

Это не претензия. Библиотека сознательно не лезет туда, где ответ зависит от проекта: React живёт и в приложении на весь экран, и в одном виджете внутри чужой страницы, и в мобильном React Native, где никакого HTML нет вовсе. Зато и документация React честно советует новое приложение начинать с фреймворка — не из вежливости, а потому что перечисленное всё равно придётся собрать.

А часть пунктов библиотека закрыть и не может. Серверные компоненты, действия сервера, потоковая отдача — это не функции, которые «достаточно вызвать»: они требуют, чтобы кто-то один одновременно владел сборкой, сервером и клиентом, знал, какой кусок кода куда уехал, и умел передать между ними готовый результат. React определяет протокол, а реализует его фреймворк. Поэтому «взять React и добрать нужное по вкусу» упирается не в лень, а в границу возможного.

Можно, и годами так и делали: роутер отдельным пакетом, сборщик настраивается руками, сервер на Express рендерит разметку, к нему прикручивается загрузка данных и разрезание бандла. Собирается это за неделю, и первые полгода работает.

Цена приходит позже. Обновился React — связка рассыпалась в трёх местах сразу. Нужен новый человек в команду — учить его придётся не React, а вашей сборке, про которую нет ни статьи, ни ответа на Stack Overflow. В итоге получается фреймворк, только без документации и с единственным сопровождающим, который однажды уйдёт.

Возьмём самое частое требование: два экрана и адрес, который можно скинуть коллеге. В React для этого нет ничего — значит, пишем сами. Инструменты знакомы: адрес меняет history.pushState, о нажатой кнопке «назад» сообщает событие popstate на window, а хранить текущий путь будет обычное состояние.

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

Настоящий переход тут не показать: у песочницы нет своего адреса, pushState в изолированном кадре запрещён. Зато сопоставление проверяется без адресной строки — и уже оно ломается на втором вызове.

Вторая строка и есть первая трещина. Адрес /tasks/42 в списке маршрутов не найден, потому что строки сравниваются целиком, а номер задачи заранее неизвестен. Значит, нужен разбор пути на сегменты, потом — параметры запроса после ?, потом — вложенность. Так и начинается свой роутер: с тридцати строк, которые честно работают на двух экранах.

Что роутер обязан уметьЧто придётся дописать самому
Адреса с переменной частьюразбор сегментов и передача id в компонент
Прокруткунаверх при переходе — и обратно на прежнее место по кнопке «назад»
Доступностьперенос фокуса и объявление нового экрана для скринридера
Незнакомый адрессвой экран 404 — и правильный код ответа, а не 200 на всё
Общую шапкураскладка, которая при переходе не пересоздаётся и не теряет состояние
Весразрезание бандла по экранам: иначе первый заход тянет код всех страниц
Данныезапрос после отрисовки — значит, заглушка на каждом переходе

Но даже когда всё это написано, остаётся пункт, который клиентский код не закроет в принципе. Посетитель стоит на /tasks/42 и нажимает F5. Браузер идёт с этим адресом на сервер, сервер ищет такой файл или папку и не находит — 404. Ваш роутер к этому моменту ещё не существует: его код лежит в бандле, а бандл подключён из index.html, который сервер как раз и не отдал.

Лечится это на сервере: отдавать index.html на любой неизвестный адрес. То есть настройкой, которая не живёт нигде в вашем React-приложении — её пишут отдельно в конфиге хостинга, и она первой теряется при переезде. В Next.js этой проблемы не решают, а не создают: маршрут — это папка в проекте, и сервер знает про него из той же файловой структуры. Как именно — следующая статья главы.

Между вашим файлом и браузером обязательно кто-то стоит. Причина первая — синтаксис: <h1>Привет</h1> посреди кода для любого движка JavaScript синтаксическая ошибка, а аннотации типов из части III движок не понимает тем более. И то и другое существует только до сборки.

Причина вторая — импорты. Строку import { useState } from "react" браузер разобрать может, а выполнить нет: голые имена пакетов он без карты импортов — тега, где каждому имени прописан адрес, — не разрешает, ему нужен путь к файлу. Кто-то должен пройти по всем импортам, найти пакеты в node_modules и склеить это в файлы, которые можно отдать по сети.

Что делает сборкаЧто будет без неё
Переводит JSX и типы в обычный JavaScriptошибка на первом же теге в коде
Разрешает импорты пакетовбраузер не знает, где искать "react"
Склеивает и режет на кускилибо сотни запросов, либо один кусок на всё приложение
Ставит хеш в имя файлалибо устаревший кеш у посетителя, либо перекачка каждый раз
Подставляет переменные окруженияадрес API прописан прямо в коде
Держит dev-сервер с горячей заменойперезагрузка страницы на каждую правку

Эта половина закрывается и без фреймворка: npm create vite даёт сборку, dev-сервер и горячую замену за одну команду, и для виджета, админки или внутренней панели этого правда достаточно. Но Vite намеренно останавливается на границе браузера: он готовит файлы. Кто отдаст первый HTML, что ответит на /tasks/42, где выполнится код, которому нельзя в браузер, — вопросы не к нему. Сервер приносит фреймворк сверху; на том же Vite, кстати, работает и React Router в режиме фреймворка. А в Next.js эту половину закрывает встроенный сборщик Turbopack — как именно, разобрано в главе про сборку.

Главное в Next.js не количество возможностей, а то, что решения уже приняты и одинаковы во всех проектах. Папка app — это карта адресов. Файл page.tsx внутри неё — страница. Компонент по умолчанию выполняется на сервере, а в браузер уезжает только то, что вы туда отправили сами.

ФайлЧто это
app/layout.tsxобщая обёртка: html, body, шапка, меню
app/page.tsxстраница по адресу /
app/tasks/page.tsxстраница по адресу /tasks

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

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

А дальше эта договорённость окупается на каждом пункте из списка, с которого мы начали.

Чего нет в ReactЧто делает Next.jsГде в учебнике
Адреса и переходымаршрут — папка, переход — компонент Linkэта глава
Пустой первый HTMLразметка приходит с сервера готовой и дорисовывается частямиглава про серверные компоненты
СборкаTurbopack настроен заранее: JSX, TypeScript, стили, разрезание по маршрутамработает с первой команды
Код, которому нельзя в браузерсерверные компоненты: ключи и запросы к базе остаются на сервереглава про серверные компоненты, статья «Серверный компонент: код, который не уехал в браузер»
Отправка формыдействия сервера: функция на сервере вызывается прямо из формыглава про изменение данных, статья «Server Actions: форма без fetch»
Кеш и скоростьстатика, ревалидация, кеширование по частямглава про кеш и прод, статья «Статика, динамика и всё между ними»
Заголовки, sitemap, картинки для соцсетейметаданные объявляются рядом со страницейглава про кеш и прод

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

За снятые заботы платят тремя счетами. Первый — сервер: приложение на Next.js по умолчанию не файлы, а процесс Node, который где-то работает, что-то ест и иногда падает. Статический экспорт из главы про кеш и прод возвращает вас к обычным файлам, но вместе с сервером отключается половина возможностей.

Второй счёт — договорённости. Имя файла теперь значит больше, чем его содержимое, и «серверный это компонент или клиентский» становится вопросом к каждому новому файлу. Третий — скорость изменений: Next за несколько лет дважды заметно переучил своих пользователей — сначала переездом на App Router, затем правилами кеширования, — и половина статей в интернете описывает уже не тот фреймворк.

Поэтому фреймворк нужен не всегда. Виджет расчёта доставки, встраиваемый в чужую страницу; админка за логином, которую не индексируют и открывают с рабочего ноутбука; панель внутри Electron или расширения браузера; один интерактивный кусок на в остальном обычном сайте — во всех этих случаях пустой первый HTML никому не мешает, а сервер только добавит хлопот. Vite и роутер отдельным пакетом честнее.

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

Next.js не единственный: React Router и TanStack Start решают тот же список. В учебнике мы берём Next.js, потому что серверные возможности React обкатываются вместе с ним — и потому что на нём собран сайт, который вы сейчас читаете.

  • React рисует в уже существующий узел: страница, адрес, сервер и сборка в его обязанности не входят по замыслу.
  • Клиентское приложение отдаёт браузеру пустой контейнер, и всё содержимое появляется только после загрузки и выполнения бандла — со всеми последствиями для скорости, ссылок и роботов.
  • Свой роутер пишется за вечер и упирается в сервер: прямой заход на /tasks/42 клиентский код перехватить не успевает.
  • JSX, типы и импорты из пакетов до браузера не доезжают — шаг сборки обязателен всегда, и Vite закрывает ровно его.
  • Next.js закрывает список целиком, потому что владеет и сборкой, и сервером: адрес — это папка, серверные возможности React без такого хозяина невозможны в принципе.
  • Цена — процесс на сервере, чужие договорённости и скорость изменений; за формой входа и в виджете фреймворк не нужен.

1/4Приложение на одном React выложено на статический хостинг. Посетитель стоит на /tasks/42 и нажимает F5. Что произойдёт, если хостинг не настраивали?

2/4Сразу после root.render(<Hello />) код читает root.innerHTML. Что там окажется?

3/4Ссылку на клиентское React-приложение отправили в мессенджер, и карточка получилась пустой. Почему?

4/4Что из перечисленного нельзя добавить в React отдельным пакетом, не привлекая фреймворк?

Сосчитать шаги до первого слова

Клиентское приложение: пустой index.html, бандл на 300 КБ, список задач загружается запросом из useEffect. Выпишите по порядку всё, что должно случиться, прежде чем посетитель увидит первую задачу. Затем отметьте, какие из этих шагов исчезают, если сервер отдаст сразу готовый HTML со списком.

Шесть шагов, и каждый ждёт предыдущего:

1. запрос за адресом — сервер отдаёт HTML с пустым #root;
2. браузер видит тег скрипта и запрашивает бандл;
3. бандл едет по сети (на медленной мобильной связи это самый долгий шаг);
4. движок разбирает и выполняет код, React монтирует дерево;
5. первая отрисовка — на экране заглушка «Загрузка…»;
6. эффект отправляет запрос за задачами, ответ возвращается, происходит вторая отрисовка.

Если HTML приходит с сервера готовым, шаги 5 и 6 из ожидания пропадают: список виден сразу после первого запроса, а бандл догружается уже поверх видимой страницы. Шаги 2–4 остаются — интерактив всё равно включается кодом, — но пользователь их не ждёт, а читает.

Отсюда и правило: убирать надо не килобайты, а ожидания в цепочке. Именно поэтому первый HTML — задача фреймворка, а не сборщика.

Адрес с переменной частью

Наивный матчер сравнивает строки целиком, поэтому шаблон /tasks/:id не совпадает ни с чем. Научите его понимать сегменты, начинающиеся с двоеточия: такой сегмент подходит к любому непустому значению, а само значение попадает в params. Число сегментов должно совпадать.

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

Разбиваем оба пути на сегменты и идём по ним парами. Сегмент-шаблон с двоеточием забирает значение в params, обычный — обязан совпасть буквально.

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

Двадцать строк — и это только начало. Дальше понадобятся необязательные сегменты, «хвост» произвольной длины, приоритет между /tasks/new и /tasks/:id, регистр, слеш на конце. Каждый пункт — известная ловушка с известным решением, и переписывать их в каждом проекте заново незачем.

Нужен ли здесь фреймворк

Три проекта. Для каждого решите, брать Next.js или хватит сборщика с роутером, и назовите решающий довод — он в каждом случае один.

А. Сайт студии: услуги, блог, страницы кейсов, форма заявки.
Б. Панель мониторинга серверов: открывается за VPN после входа, живёт на одном экране, обновляет графики раз в секунду.
В. Виджет расчёта доставки, который партнёры вставляют к себе на страницу одним тегом скрипта.

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

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

В — противопоказан. Довод: страницу отдаёт чужой сайт, вашего HTML в ней нет вовсе. Задача сводится к одному небольшому бандлу, который монтируется в переданный узел, — сборщика достаточно, а весь остальной фреймворк просто некуда приложить.