SPA (Single Page Application, одностраничное приложение) — это веб-приложение, которое браузер загружает как один HTML-документ, а дальше переключает экраны JavaScript-кодом: запрашивает данные у сервера и перерисовывает часть страницы без полной перезагрузки. Адрес при этом меняется, и у каждого экрана может быть своя ссылка.
Содержание
- Мини-словарь: SPA, MPA, SSR, SSG
- Как работает SPA по шагам
- Минимальный роутер на History API
- Сопоставление адреса с экраном: логика роутера
- History API или hash-роутинг
- SPA и SEO: что происходит с индексацией
- Плюсы и минусы SPA — с условиями
- Фреймворки для SPA
- Если не получилось
- Выводы
- Где применяется / связь с практикой
- FAQ
Ниже — клиентский роутинг с рабочим примером, отличие от MPA, SSR и SSG, индексация и выбор фреймворка.
Мини-словарь: SPA, MPA, SSR, SSG
| Термин | Где собирается HTML | Что происходит при переходе |
|---|---|---|
| MPA (многостраничное) | на сервере, на каждый запрос | браузер загружает новую страницу целиком |
| SPA с CSR (рендер на клиенте) | в браузере, из JavaScript | JS меняет часть DOM, страница не перезагружается |
| SSR (серверный рендер) | на сервере, на каждый запрос | первый экран приходит готовым HTML, дальше обычно работает как SPA |
| SSG (статическая генерация) | заранее, при сборке | готовые HTML-файлы, дальше может работать как SPA |
SPA — модель навигации, а не запрет на серверный рендер: Nuxt или Next.js отдают первый экран через SSR, «оживляют» его в браузере (гидратация) и дальше работают как SPA.
Как работает SPA по шагам
- Сервер отдает HTML-оболочку и ссылки на JS-бандл.
- JavaScript выполняется, определяет текущий путь и рисует нужный экран.
- Пользователь кликает по ссылке — скрипт перехватывает клик, отменяет обычный переход и меняет адрес через
history.pushState(). - Приложение запрашивает только данные (обычно JSON через
fetch) и перерисовывает нужный блок. - Кнопка «Назад» вызывает событие
popstate— роутер снова рисует экран по текущему адресу.
Это упрощенная схема чистого CSR: фреймворки добавляют подгрузку кода частями, кеш и предзагрузку, но цикл «адрес -> роутер -> экран» тот же.
Минимальный роутер на History API
Пример без фреймворков: три экрана и «не найдено». Файл index.html:
<!doctype html>
<html lang="ru">
<head><meta charset="utf-8"><title>Главная</title></head>
<body>
<nav>
<a href="/" data-link>Главная</a>
<a href="/courses" data-link>Курсы</a>
<a href="/courses/42" data-link>Курс 42</a>
</nav>
<main id="app"></main>
<script>
function view(path) {
if (path === "/") return ["Главная", "Добро пожаловать"];
if (path === "/courses") return ["Курсы", "Список курсов"];
const m = path.match(/^\/courses\/(\d+)$/);
if (m) return ["Курс " + m[1], "Страница курса " + m[1]];
return ["Не найдено", "Такой страницы нет"];
}
function render() {
const [title, text] = view(location.pathname);
document.title = title;
const h1 = document.createElement("h1");
h1.textContent = text;
document.getElementById("app").replaceChildren(h1);
}
document.addEventListener("click", (e) => {
const a = e.target.closest("a[data-link]");
if (!a || e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey) return;
e.preventDefault();
if (a.pathname !== location.pathname) history.pushState({}, "", a.pathname);
render();
});
window.addEventListener("popstate", render);
render();
</script>
</body>
</html>
Что увидите: при клике на «Курс 42» адрес станет /courses/42, заголовок вкладки — «Курс 42», а в Network не будет запроса нового документа. «Назад» вернет предыдущий экран, Ctrl/Cmd+клик по-прежнему открывает новую вкладку.
Ключевое: ссылки — настоящие <a href> (их видят роботы), текст идет через textContent, а не innerHTML (данные из адреса не станут разметкой — защита от XSS), document.title свой на каждом экране.
Граница примера: нужен веб-сервер с fallback, отдающий index.html на пути приложения. Через file:// pushState не сработает, а без fallback обновление на /courses/42 даст 404. Для локальной проверки подойдет, например, npx serve -s . (флаг -s включает режим одностраничного приложения).
Сопоставление адреса с экраном: логика роутера
В роутерах пути задаются шаблонами вроде /courses/:id. Логика сопоставления отдельно, запускается в Node.js:
function compile(pattern) {
const keys = [];
const source = pattern.replace(/:(\w+)/g, (_, key) => {
keys.push(key);
return "([^/]+)";
});
return { re: new RegExp("^" + source + "$"), keys };
}
const routes = [
{ pattern: "/", name: "home" },
{ pattern: "/courses", name: "courseList" },
{ pattern: "/courses/:id", name: "course" },
].map((r) => ({ ...r, ...compile(r.pattern) }));
function match(path) {
for (const r of routes) {
const m = path.match(r.re);
if (m) {
const params = Object.fromEntries(
r.keys.map((k, i) => [k, decodeURIComponent(m[i + 1])])
);
return { name: r.name, params };
}
}
return { name: "notFound", params: {} };
}
console.log(match("/courses/42"));
console.log(match("/courses"));
console.log(match("/courses/42/edit"));
Вывод:
{ name: 'course', params: { id: '42' } }
{ name: 'courseList', params: {} }
{ name: 'notFound', params: {} }
Типичная ошибка — регулярное выражение без якорей ^ и $: шаблон / найдется в любом пути, и первый маршрут «съест» остальные:
const loose = new RegExp("/");
console.log(loose.test("/courses/42")); // true - любой путь станет главной
const strict = new RegExp("^/$");
console.log(strict.test("/courses/42")); // false - совпадает только сам "/"
Исправление — якоря в compile, как в рабочем варианте; без них все три адреса вернули бы home.
History API или hash-роутинг
History API (/courses/42) |
Hash (/#/courses/42) |
|
|---|---|---|
| Нужна настройка сервера | да, fallback на index.html |
нет, часть после # серверу не отправляется |
| Вид адреса | обычный | с # |
| Индексация | каждый путь — отдельный URL | # поисковики обычно не считают отдельной страницей |
Hash-режим уместен для внутренних панелей и демо без доступа к серверу, для публичных страниц берут History API.
SPA и SEO: что происходит с индексацией
Google выполняет JavaScript: робот сначала сканирует HTML, а рендер ставится в очередь и может пройти позже. Яндекс тоже рендерит JS, но насколько полно и быстро это происходит для конкретного сайта, зависит от его настроек и проверяется в Вебмастере. Поэтому надежнее не рассчитывать на рендер, а отдавать важный контент в исходном HTML.
Что чаще всего ломает индексацию SPA:
— контент и ссылки появляются только после клика или скролла — робот их, скорее всего, не увидит;
— навигация на <div onclick> вместо <a href> — роботу нечего сканировать;
— один <title> и description на все экраны;
— soft 404: fallback отдает index.html с кодом 200 на любой путь, и несуществующие адреса выглядят для поиска как настоящие страницы. Лечится списком маршрутов на сервере (неизвестный путь — код 404) или SSR/SSG;
— долгий первый экран из-за большого бандла.
Практическое правило: для каталогов, статей и лендингов — SSR или SSG (Nuxt, Next.js, SvelteKit, Angular SSR), для личного кабинета и админки за логином — обычный CSR, индексировать там нечего.
Плюсы и минусы SPA — с условиями
| Свойство | Когда это правда | Когда нет |
|---|---|---|
| Быстрые переходы | после первой загрузки, передаются только данные | если каждый экран тянет новый крупный чанк |
| Меньше нагрузка на сервер | сервер отдает JSON вместо HTML | при SSR сервер снова рендерит страницы |
| Работа офлайн | если настроен Service Worker и кеш | сама по себе SPA офлайн не работает |
| Первый экран | с SSR/SSG — быстро | чистый CSR ждет загрузки и выполнения JS |
Про безопасность: SPA не «небезопасна по определению», но весь ее код публичен. Секреты в бандл не кладут, права проверяет сервер на каждом запросе, токен сессии обычно держат в cookie с HttpOnly, Secure, SameSite, а не в localStorage. Это базовые меры: нужны еще CSRF-защита, CSP и обновление зависимостей.
Фреймворки для SPA
| Задача | Что взять | Когда иначе |
|---|---|---|
| Интерфейс с плавной кривой входа | Vue + Vue Router, Nuxt для SSR | если в команде уже React |
| Большая экосистема и рынок вакансий | React + React Router, Next.js для SSR | если нужен фреймворк «все включено» |
| Крупный проект со строгой структурой | Angular (роутер, формы, DI из коробки) | для небольшого проекта избыточен |
| Минимум кода в бандле | Svelte + SvelteKit | если важна готовая экосистема библиотек |
React — библиотека интерфейса: роутинг и SSR добавляют пакеты и фреймворки поверх; у Vue и Angular роутер официальный.
Если не получилось
- Обновление страницы дает 404 — на сервере нет fallback на
index.htmlдля путей приложения. - «Назад» не меняет экран — нет обработчика
popstate. - Пустая страница и ошибка в консоли — относительные пути к скриптам (
src="app.js") на вложенном адресе ищутся не там; нужен абсолютный путь/app.js. - Страницы не попадают в поиск — проверьте, что видит робот (проверка URL в Search Console, проверка ответа сервера в Вебмастере), и переведите публичные страницы на SSR/SSG.
Выводы
- SPA загружает один HTML-документ, а дальше меняет экраны JavaScript-кодом и адрес через History API.
- SPA — про навигацию; SSR и SSG — про то, где собирается HTML, и их можно совмещать с SPA.
- Роутеру нужны настоящие ссылки, обработка
popstate, свойtitleна экран и fallback на сервере. - Для страниц из поиска важный контент надежнее отдавать в HTML через SSR или SSG, а неизвестным путям — код 404.
- Чистый CSR хорош для интерфейсов за логином, где индексация не нужна.
Где применяется / связь с практикой
Освойте тему на практике
SPA — основа личных кабинетов, админок и дашбордов. Если хочется собрать такое приложение на Vue 3 с роутингом, состоянием и серверным рендером в Nuxt, посмотрите курс по Vue.js. Разобрать отдельные темы фронтенда с практиками можно на бесплатных открытых уроках Otus.
FAQ
Можно ли сделать SPA без фреймворка?
Да, как в примере выше, но с ростом приложения придется самому писать вложенные маршруты, загрузку данных, кеш и управление состоянием.
PWA и SPA — одно и то же?
Нет. PWA — набор возможностей (манифест, Service Worker, установка, офлайн), который можно добавить и к SPA, и к многостраничному сайту.
Чем SPA отличается от мобильного приложения на React Native?
SPA рендерит DOM в браузере, React Native — нативные компоненты платформы; общие у них язык и компонентный подход.



