Каждый сайт однажды оказывается перед выбором, который незаметно определяет его судьбу: грузить каждую страницу заново или подменять содержимое без перезагрузки. Этот выбор редко делают осознанно, чаще его диктуют мода, стек и первая строчка технического задания, а расплачиваться потом приходится скоростью, позициями в поиске и ночами отладки. Разница между одностраничным и многостраничным подходом - это не спор о вкусах, а инфраструктурное решение, у которого есть цена, измеряемая годами эксплуатации.
Две философии одного адреса и разница между перезагрузкой и подменой
Многостраничное приложение, или MPA, работает так, как интернет работал первые двадцать лет: каждый адрес - отдельный HTML-документ, сервер отдаёт его целиком, браузер разбирает и показывает. Переход по ссылке означает новый сетевой запрос, полную отрисовку с нуля и обнуление всего, что было на предыдущей странице. Одностраничное приложение, или SPA, один раз грузит каркас из HTML и JavaScript, а дальше само решает, какой фрагмент интерфейса показать. Адресная строка при этом меняется, но сетевого перехода нет: скрипт стирает старый экран и рисует новый.
У обоих подходов есть честные сильные стороны. MPA даёт предсказуемость: любой адрес самодостаточен, его можно сохранить в закладки, переслать коллеге, отдать роботу, и даже с отключённым JavaScript такой сайт остаётся читаемым. SPA даёт скорость повторных переходов: код уже загружен, смена экрана занимает миллисекунды, состояние интерфейса переживает переход между разделами. Проблемы начинаются там, где достоинства одного подхода выставляют счёт на чужой территории. Мгновенность SPA рушится на первом визите из-за тяжёлого бандла, а простота MPA оборачивается задержкой на каждом клике, если сервер не успевает собирать страницы или кэш промахнулся.
History API и правила по которым браузер разрешает менять адрес без перезагрузки
Сердце клиентской маршрутизации - History API. Метод pushState добавляет новую запись в историю сессии и меняет адрес в строке, не трогая содержимое страницы. Метод replaceState обновляет текущую запись вместо добавления новой. Событие popstate приходит, когда пользователь нажимает кнопку назад или вперёд, но не при вызове pushState - об этой детали спотыкается каждый второй новичок. Ограничение тоже есть: новый адрес обязан оставаться в пределах того же сайта, иначе браузер выбросит ошибку безопасности. Объект состояния, переданный в pushState, возвращается вместе с событием popstate, поэтому в него складывают минимальный контекст для восстановления экрана.
Минимальный роутер на чистом JavaScript умещается в несколько строк:
function goTo(path) {
history.pushState({ path }, '', path);
render(path);
}
window.addEventListener('popstate', (event) => {
const path = event.state ? event.state.path : location.pathname;
render(path);
});
render(location.pathname);
Сопоставление адреса со страницей в самописном роутере обычно держат на таблице правил:
const routes = [
{ pattern: /^\/$/, view: () => import('./views/home.js') },
{ pattern: /^\/items\/(\d+)$/, view: () => import('./views/item.js') }
];
async function render(path) {
for (const r of routes) {
const match = path.match(r.pattern);
if (match) {
const mod = await r.view();
mod.mount(match[1]);
return;
}
}
document.title = '404';
}
До появления History API адрес меняли через хеш: путь вида catalog#/items/42 не вызывал сетевого запроса и не ломал историю браузера. Хеш-маршрутизация до сих пор встречается в легаси-коде, в прототипах и во встраиваемых виджетах, где до сервера не дотянуться. У объекта history есть ещё одно полезное свойство scrollRestoration со значениями auto и manual: оно сообщает браузеру, кто отвечает за восстановление позиции прокрутки при возврате назад, сам браузер или клиентский роутер.
Для продакшна обычно берут готовый инструмент: React Router, добравшийся до восьмой мажорной версии, Vue Router, роутер SvelteKit или TanStack Router. Все они внутри опираются на тот же History API, добавляя сопоставление путей с шаблонами, вложенные маршруты, guards и предзагрузку данных.
Метрики Core Web Vitals показывают реальную цену каждого перехода
Скорость сегодня измеряют не ощущениями, а числами. Набор Core Web Vitals задаёт три порога: Largest Contentful Paint должен укладываться в 2,5 секунды от начала загрузки, Interaction to Next Paint - в 200 миллисекунд, Cumulative Layout Shift - в 0,1. Оценку снимают на 75-м процентиле визитов, то есть в поле, у реальных людей, а не в лаборатории на быстрой машине. Показатель INP в 2024 году заменил собой First Input Delay, потому что старая метрика ловила только первый клик и не замечала последующих провисаний интерфейса.
SPA бьёт по всем трём порогам в момент первого визита. Пока грузится и инициализируется бандл, человек смотрит на пустой экран или прелоадер, и это напрямую ложится в LCP. Каждый лишний мегабайт JavaScript оборачивается секундами парсинга на среднем телефоне. Когда бандл распухает до двух-трёх мегабайт, гидратация блокирует главный поток, клики подвисают, и счёт выставляет уже INP. MPA честнее на первом кадре: сервер может отдать лёгкий HTML, который браузер показывает до прихода стилей и картинок. Зато каждый переход снова оплачивается сетевым запросом, и здесь всё решает качество кэша и близость CDN.
Отдельная ловушка SPA - водопады запросов: сначала браузер тянет оболочку, потом скрипт, скрипт запрашивает данные, данные ждут рендера. Четыре круга туда-обратно вместо одного, а на мобильной сети каждый круг стоит сотни миллисекунд. MPA в этом смысле прямолинейнее: один запрос, один ответ, и кэш документа на уровне CDN закрывает большинство повторных визитов. Поэтому зрелые одностраничники переносят загрузку данных на сервер или запускают запрос данных параллельно с загрузкой модуля страницы.
Код-сплиттинг лечит бандл хирургически: страница каталога грузит только собственный модуль.
const page = await import('./pages/catalog.js');
page.mount(document.querySelector('main'));
Измерить происходящее на стороне посетителя помогает Navigation Timing:
const [nav] = performance.getEntriesByType('navigation');
console.log('TTFB', Math.round(nav.responseStart));
console.log('DOM готов', Math.round(nav.domContentLoadedEventEnd));
Руководство RAIL отводит 100 миллисекунд на отзыв интерфейса на действие пользователя, и именно этот порог делает переходы SPA субъективно мгновенными. Анализатор source-map-explorer покажет, что именно лежит в бандле, а бюджет производительности в CI не даст ему снова уползти за лимит.
Поисковые роботы и семантика адресов при клиентской маршрутизации
Когда весь сайт живёт в одном HTML, роботу достаётся пустая оболочка. История знает годы, когда каталоги на чистом SPA пропадали из выдачи: контент рисовался скриптом, до которого краулер не доходил. Отсюда вырос целый пласт серверной инфраструктуры - пререндеринг, серверный рендеринг и гидратация. Next.js и Nuxt отдают готовый HTML с сервера, SvelteKit собирает страницу по запросу, Astro выпускает статические страницы и оживляет только те островки, которым нужен интерактив. React Router с седьмой мажорной версии слился с фреймворком Remix и получил собственный слой серверного рендеринга, а сегодня линия версий дошла до восьмой.
Для MPA проблема индексации не существует в принципе: адрес и документ неразделимы. Но и SPA умеет быть честным, если первый ответ сервера уже содержит контент. Практический вывод скучен и надёжен: адрес, по которому приходят из поиска, должен отдавать содержимое без выполнения скриптов, а внутренние переходы могут быть какими угодно быстрыми.
Гибрид между двумя мирами собирается прямо на уровне сборки. Часть сайта рендерится на сервере, часть работает как остров с клиентским состоянием, а тяжёлый редактор грузится отдельным чанком по требованию. Такой конструктор снимает дихотомию лучше любых споров в комментариях: страницам, которые важны для поиска, достаётся статика, а интерактивным сценариям - скорость уже загруженного кода.
Гибридные схемы и серверные редиректы вместо спора двух лагерей
Зрелые команды давно не выбирают одну из двух крайностей, а собирают сайт из смешанных материалов. Инструмент htmx второй мажорной версии переворачивает саму задачу: сервер возвращает фрагменты HTML, клиент только подставляет их на место, и многостраничный сайт незаметно получает отзывчивость одностраничника. Похожим путём идут Turbo и Unpoly. Анимация переходов, которую так любят в SPA, теперь доступна и многостраничным сайтам через View Transition API: спецификация покрывает переходы и внутри документа, и между документами, в Chromium-линейке оба сценария уже работают.
Разогреть будущие переходы помогает предзагрузка. Правила Speculation Rules позволяют заранее подтянуть следующую вероятную страницу или даже пререндерить её в фоне:
<script type="speculationrules">
{
"prefetch": [{
"source": "document",
"where": { "href_matches": "/*" },
"eagerness": "conservative"
}]
}
</script>
Сервис-воркер добавляет третий слой памяти между сетью и браузером. Инструменты Workbox прекешируют оболочку приложения и обновляют её по стратегии stale-while-revalidate: первый визит пишет файлы в кэш, повторные берут их с диска мгновенно, свежая версия подъезжает в фоне. Для MPA кэширование HTML опаснее: устаревшая страница с ценами хуже, чем задержка в полсекунды. Оболочку кэшируют смело, а ответы сервера и документы - осторожно, с версионированием.
Имена файлов со встроенным хешем - ещё одна точка, где обе архитектуры встречаются. Сборщики выдают бандлам имена вида app-4f2a91.js, и такие файлы кэшируют на год: содержимое изменилось, изменилось имя, старая копия просто больше не запрашивается. С HTML правило обратное: документ проверяется каждый раз, потому что именно он ссылается на свежие имена ассетов.
Старые адреса при любых реформах закрывают серверные редиректы, а глубоким ссылкам SPA нужен запасной выход на оболочку:
location = /old/catalog {
return 308 /catalog;
}
location / {
try_files $uri $uri/ /index.html;
}
Первая секция - постоянный переезд с кодом 308, вторая - классический fallback одностраничника: сервер отдаёт оболочку, а клиентский роутер уже сам показывает нужный экран по адресу.
Долгосрочная стоимость владения маршрутизацией и план безопасной миграции
Годы эксплуатации выявляют то, чего не видно на демо. В SPA накапливаются наслоения роутеров и обёрток, хрупкие переходы между версиями фреймворка: бандл, который в первый год весил 180 килобайт, к пятому тихо дорастает до мегабайта, и никто не помнит, какая его половина нужна. В MPA копятся дубли адресов, остатки старых структур и цепочки редиректов, по которым робот ходит туда-обратно. Оба хвоста чистятся только дисциплиной: бюджетом на бандл, картой редиректов и автотестами маршрутов.
Миграция между архитектурами без потери адресов сводится к понятной последовательности шагов:
- снять полную карту действующих адресов из логов сервера, файла sitemap и аналитики переходов;
- построить таблицу соответствия старых путей новым и закрепить код 308 за постоянным переездом;
- настроить серверные редиректы и проверить, что каждая цепочка не длиннее одного перехода;
- закрыть автотестами каждый редирект и каждую новую страницу до переключения трафика;
- после переключения следить за ошибками 404 и индексацией не меньше квартала.
Такую процедуру проходят и при переезде с MPA на SPA, и на обратном пути, и при внедрении островной архитектуры на части сайта. Чаще всего в миграции теряются мелочи, которых нет в таблице редиректов: параметры запроса после вопросительного знака, регистр букв в путях, завершающий слэш в конце адреса. Робот считает /items и /items/ разными страницами, а человек разницы не замечает. Канонический адрес в разметке и единый стиль написания путей в коде закрывают этот класс проблем до того, как он попадёт в логи.
Автотесты маршрутов стоят дешевле всего: сценарий в Playwright на переход по адресу, кнопку назад и восстановление формы ловит регрессии роутера до релиза, а не во время утреннего пика. Выбор между SPA и MPA в итоге сводится к вопросу, за что платить: за скорость первого визита или за скорость последующих экранов, за простоту сервера или за простоту клиента. Побеждает команда, которая посчитала оба счёта заранее и закрепила цифры в бюджете, а не та, что просто встала на сторону красивой аббревиатуры из презентации.