
Привет, Хабр! Меня зовут Егор Смирнов, я руководитель команды Backend for Frontend в VK. Одна из зон нашей ответственности — поддержка и развитие инфраструктуры для фронтенда ВКонтакте.
Веб-версии ВКонтакте в 2026 году исполнилось 20 лет. За это время наш фронтенд прошёл путь от AJAX до современного веб-приложения. Раньше мы вручную манипулировали DOM, сборка иконок превращалась в приключение, а деплой и откат занимали по полчаса. К середине 2026 года мы выстроили масштабируемую архитектуру, в которой компоненты, сборка и деплой работают как единая система.
Как мы полностью переработали архитектуру ВКонтакте за три последних года, расскажу в этой статье.
Первые шаги
Что у нас накопилось к 2023 году:
Подход к разработке мобильной и десктопной версии сильно разъехались. Код обеих версий жил в одном репозитории, деплоился вместе, но архитектуры настолько отличались, что приходилось зачастую выделять отдельного разработчика. Его опыт редко пересекался с опытом разработчика компьютерной версии. Во многом это пошло от того, что до 2021 года мобильную веб-версию приходилось поддерживать на древних браузерах вплоть до Opera Mini и Windows Phone 7
Фронтенд и бэкенд были связаны единой кодовой базой и релизным циклом. Приходилось встраивать вёрстку сайта в код бэкенда, а динамику добавлять через JavaScript. В итоге код бэкендера спокойно мог откатиться просто потому, что фронтендер в этом же релизе покрасил кнопку неправильно
Каждая команда проектировала свою архитектуру, из-за чего переход разработчиков между продуктами был трудным
Помимо этого, веб-разработчиков с каждым годом прибавлялось, а разработка усложнялась. У нас было 4 способа рендера компонента:
Бэкенд отдаёт HTML-компонент и добавляет для него JavaScript. Классика, но писать фронтенд в бэкенде уже не очень удобно, когда эти роли выполняют разные команды
Фронтенд сам рендерит компонент на собственной библиотеке рендера. Жить можно, но расширять сложно
Фронтенд-код, который компилируется в код бэкенда. Мы получали изоморфный компонент ценой тяжёлой поддержки кода компилятора и кучи багов
Бэкенд-компонент, который экспортируется в код фронтенда. Использовалось в нескольких местах, но всё равно требовало поддержки в коде сборки
Часто нужно было использовать компонент из разных систем, и это превращалось в очень долгое путешествие.
Было ещё 3 способа рисовать иконки и 3 способа показать модальное окно, которые визуально и функционально очень похожи, но несовместимы между собой.
Все эти вопросы привели нас к проекту по полной переработке всей архитектуры сайта. Конечно, простаивать продуктовая разработка не могла, и мы двигались итеративно.
Решили взять за основу React. В компании это было обкатанное решение — у нас с 2017 года развивалась публичная библиотека VKUI на React. Сразу придумать подходящую архитектуру невозможно, а готовые нам не подходили. Чтобы провести первые эксперименты, мы внедрили подход, похожий на React Islands, и наложили его поверх существующего AJAX-транспорта.
Наш механизм AJAX-навигации работал так: сайт перехватывает все нажатия по ссылкам, отправляет запросы на бэкенд через XHR или Fetch, а бэкенд после обработки отвечает нужным кодом и полями.
document.addEventListener("click", async (event) => { const link = event.target.closest("a"); if (!link) return; event.preventDefault(); const response = await fetch(link.href); const result = await response.json(); switch (result.code) { case 1: // Отображаем новую страницу document.querySelector("#content").innerHTML = result.html; // Выполняем JS, пришедший с бэкенда eval(result.js); break; case 2: // Переходим на другую страницу window.location.href = result.url; break; case 3: // Показываем ошибку showError(result.message); break; } });
На этом этапе страницы или виджеты на React загружались через AJAX, но вместо полноценной вёрстки вставляли React HTML root, инициализирующий данные для рендера страницы (id пользователя, открытый таб, фильтры), и вызывали рендер через исполнение скрипта, пришедшего с сервера, через eval. Получается, что роутинг всё ещё во многом зависел от прежней архитектуры, несмотря на то, что основные данные уже грузились через API.

Оптимизации загрузки
Как я упоминал, раньше мы рендерили содержимое сайта на сервере, а затем добавляли динамику через JavaScript — и это уносило большую часть работы на сторону бэкенда. React же работает иначе. Он рендерится на клиенте и требует для запуска увеличенное количество ресурсов клиента — это цена за инструмент.
Мы много работали с производительностью сайта во время внедрения библиотеки. Расскажу о пути одной из оптимизаций, а в конце поделюсь цифрами, которые мы выжали после внедрения всех оптимизаций.
При простом внедрении клиентского рендеринга вскрылась первая проблема: запросы в API отправлялись только после полной загрузки и старта JS-бандла.

На этом моменте родилась первая оптимизация — API Prefetch. Бэкенд на своей стороне вручную делает server-to-server запрос. Выглядело это так:
APIPrefetch::prefetch("users.get", [ 'user_id' => 260126670 ]);
Далее, при сборке ответа клиенту, нужно было лишь отправить API запросы, дождаться их завершения и отправить HTML, в который останется внедрить ответы.
$results = APIPrefetch::fetch(); $html .= "<script>"; $html .= "window.__PREFETCH__ = " . json_encode($results); $html .= "</script>";
Со стороны клиента нужно было провести такую же операцию: вызвать API-клиент с теми же параметрами и передать специальный параметр prefetch, разрешающий чтение серверного ответа. Если prefetch не был выполнен, например когда сервер перегружен, то клиент делал обычный запрос на API-эндпойнт. В итоге со стороны фронтенда всё работало прозрачно и требовало только добавления параметра, который нужен для явного объявления, что можно прочитать ответ с сервера.
const response = await api( 'users.get', { id: 260126670 }, { prefetch: true } );
Проблема первого байта
После внедрения первой версии оптимизации стало чуть лучше. Итоговое время рендера страницы уменьшилось, ведь мы перенесли все тяжёлые запросы внутрь сервера, у которого задержка сети в разы меньше. Клиент сразу получал JSON, готовый к использованию во время рендеринга страницы.
Это решение было не идеально — теперь сервер перед отдачей страницы ждал ответа API, а значит, замедлял получение первого байта пользователем.

В итоге мы вспомнили о старой технологии HTTP Streaming. Это стандартная часть протокола, начиная с версии HTTP/1.1 для асинхронной отдачи HTML, которая нам очень пригодилась. Вместо ожидания API при формировании ответа мы сразу отдавали весь контент сайта, а последним куском отправляли скрипт, который добавлял на страницу ответ.
<?php // layout сайта без </body> $html = getLayout($props); stream_response($html); $results = APIPrefetch::fetch(); $prefetch_str = "<script>"; $prefetch_str .= "window.__PREFETCH__ = " . json_encode($results); $prefetch_str .= "</script>"; // Закрываем body для валидной вёрстки stream_response($prefetch_str . "</body>");
Внедрение стриминга для Prefetch ускорило критический путь рендера благодаря параллельному исполнению JS-кода приложения и ожиданию ответа API.

Нюанс порядка исполнения
Оптимизация фронтенда — дело многогранное. Можно ускорять сеть, процессорное время, но нужно не забывать ещё один важный аспект — утилизация процессора. Наблюдение именно за этим параметром стало для меня открытием во время ускорения производительности React.
У вас может быть отлично оптимизированное приложение, в котором исполняется только нужный код, а ненужный перенесён в динамические части. Но если старт приложения начнётся только после того, как загрузится последний байт HTML, то пользователь всё равно будет работать с сайтом, который медленно загружается. Ваша цель — перенести как можно больше утилизации main thread в начало таймлайна на критическом пути рендеринга.

Вернёмся к API Prefetch: мы столкнулись с проблемой, вызванной добавлением стриминга. В JavaScript есть событие DOMContentLoaded. Оно срабатывает, когда браузер получил последний байт HTML, спарсил его и вставил в DOM. Это событие мы использовали как триггер для рендера сайта.
Случалась такая ситуация: мы отдавали пользователю страницу, браузер загружал все скрипты, исполнял их и был готов отрендерить содержимое, но вместо полезной работы он просто ждал последнего кусочка HTML, в котором приходили данные для API.
Сложнее всего было найти проблему, а решение получилось простое. Мы пробежались по всему коду и заменили ожидание DOMContentLoaded на кастомный коллбэк, который вызывался после того, как браузер вставил основные DOM-элементы, но до момента, когда пришёл ответ API.
<html> <head>...</head> <body> <div> <!-- App Layout --> </div> <script> // Если JS уже исполнился window.onLayoutReady?.(); // Если JS ещё не исполнился, он подхватит этот // флаг и исполнит коллбэк готовности сразу window.layoutReady = true; </script> <!-- Ожидание загрузки API Prefetch --> <script>window.__PREFETCH__ = {};</script> </body> </html>
Цена оптимизации
Внедрение API Prefetch сильно улучшило жизнь пользователям. В результате всех наших оптимизаций время полной отрисовки страниц уменьшилось: 4,4 с → 2 с на странице списка друзей и 3,1 с → 1,9 с в разделе фотографий.
Оптимизация улучшила жизнь пользователям, но сильнее привязала нас к коду бэкенда, который инициализировал React-страницу. Приходилось хардкодить те же самые параметры API, которые запрашивает код фронтенда. Это неудобно и ненадёжно, так как нет чётких контрактов.
Теперь, когда проблемы производительности были решены, мы приступили к следующему этапу.
Масштабирование
Когда мы говорим о React, часто имеем в виду SPA на базе одного из фреймворков с заранее фиксированной архитектурой. Во время первых попыток внедрения React у нас было разнообразие: кто-то внедрял React виджетом в страницу, кто-то пробовал Feature Sliced Design, а у кого-то была огромная папка components.
Если мы хотим прийти к единому стандарту, в котором любой разработчик может быстро переключаться между продуктовыми командами, нужна единая архитектура. Такая архитектура должна решать все ранее описанные проблемы. Спустя несколько итераций проектирования мы разработали собственный подход на базе идей MVVM:
Отделили бизнес-логику от логики отображения. Сервисы работы с данными теперь лежали в общей директории и могли переиспользоваться между платформами
Логика отображения и само отображение также разделены. Для логики отображения мы выбрали MobX, но благодаря разделению ответственности могли теперь заменить его на любую другую библиотеку управления состоянием
Сделали интеграцию зависимостей через DI
Роутинг объявлялся в JSON вне React и экспортировался на сервер. Это нужно, чтобы бэкенд мог находить нужный роут. Например, предзагружать нужные API-методы для конкретного роута, о чём я уже рассказал
Приняли единую файловую структуру и защитили следование её правилам через eslint-плагин boundaries
Чтобы не останавливать обновления продукта, мы зафиксировали 3 этапа миграции страницы:
AJAX-страницы. Такие страницы скрывают SPA и рендерят контент по-старому, вставляя вёрстку с прежнего сервера и исполняя кастомный JS-код. Это точка старта
React-страницы через AJAX-транспорт — это промежуточный вариант для команд, которые уже написали большую часть приложения на React. Но часть логики остаётся на бэкенде, поэтому здесь не будет работать простая клиентская навигация: код дождётся выполнения бэкенд-обработчика конкретного роута и только после этого выполнит клиентский переход
Честные клиентские страницы. Имеют полноценную клиентскую навигацию, а на бэкенде используют единый обработчик, без кастомных вызовов
Процесс перехода между вторым и третьим этапом устроен так. Мы не могли встраивать React в легаси-страницы, так как AJAX подменял их при каждом переходе. Чтобы обойти это ограничение, мы внедрились в уже существующую систему навигации: при переходе с легаси-страницы на React легаси-контент удалялся. В обратную сторону, из React-страницы на легаси, работало точно так же. Так сохранялась обратная совместимость между легаси-страницами и страницами на React.
const route = await reactRouter.match(url); if (route.isSPA) { legacyRoot.hide(); reactRoot.show(); return; } reactRoot.hide(); // ... старая AJAX-логика

Чтобы очертить границы и дать всем продуктовым командам понимание, что мы от них хотим, мы составили Definition of Done (DoD). Вот его примерное содержание:
Код использует единую принятую архитектуру
Используется клиентский роутер. Никакого кастомного кода на бэкенде. Для этого мы сделали специальный флаг в конфиге роута. Если сервер видит такой флаг, то он сразу отдаёт страницу без возможности прокинуть кастомную логику. Это дало нам гарантию чистоты компонента и позволило перейти со 2 на 3 этап
Используется единая дизайн-система с минимальным количеством кастомизации
Внедрены базовые оптимизации: используется API Prefetch, работает стриминг и настроены ленивые (динамические) импорты разделов
Отсутствие новых связей с легаси. У нас есть специальный плагин, который отслеживает импорты легаси-кода в новый и наоборот. Этот список не должен расти при переходе
Этот чек-лист помог нам организовать работу большого количества разработчиков и их менеджеров: разработчики поняли, что от них требуется, а менеджеры смогли корректно выделять время на каждую итерацию.
Благодаря этому плану все команды провели поэтапную миграцию на SPA, а для пользователя переходы между AJAX и React-разделами выглядели бесшовно.
Устройство бэкенд-обработчика
Я упомянул, что для полностью новых роутов у нас используется единый обработчик. Такой обработчик должен уметь матчить нужный роут, делать Prefetch, вставлять нужные переводы для конкретного роута и проводить авторизацию. Для этого бэкенд должен как-то получать информацию о роуте. Делать это вручную не имеет смысла.
Мы придумали и внедрили в сборку сайта формат манифестов, в которых содержится всё, что нужно бэкенду для отображения сайта:
Список собранных JS- и CSS-ассетов: эти ассеты вставляем в HTML
Список роутов и их параметры: какой тип у страницы (о нём рассказывал в части про DoD для переезда на React), нужна ли авторизация для этой страницы и прочие метаданные
Список асинхронных ассетов для конкретного роута: все разделы загружаются через динамические импорты
Ключи переводов для каждого роута
Правила для prefetch: содержат декларативные правила, для какого роута нужно загрузить какой API-метод и с какими параметрами
Такой подход к организации фронтенда позволил уместить всю информацию о фронтенде для бэкенда в один JSON-файл. Достаточно задеплоить этот файл на сервер, и пользователи начнут получать новую версию. Это дополнительно позволило производить деплои и откаты за 20 секунд на все серверы после завершения процесса сборки.
Итог
Это была самая крупная миграция в истории фронтенда ВКонтакте.
Мы достигли отличного компромисса между выбором привычных для рынка технологий и внедрением нишевых оптимизаций.
Наше решение понравилось и другим командам внутри компании. Мы объединили все наши наработки в библиотеку Frontend SDK. Уже сейчас её используют команды VK Видео и VK ID. Они внедрили её в свои SPA-приложения на React и получили быстрый деплой и откат, готовые решения для API Prefetch и предварительного рендера страниц с использованием SSG.
Вы можете внедрить похожий подход по деплою фронтенда. Я собрал демо, в котором можно поиграться с подобным устройством бэкенда: github.com/k-egor-smirnov/frontend-manifest-demo
Комментарии (4)

valerii_efremov
13.10.2026 16:04Стильно, модно, молодежно...
Время полной загрузки замеряли? У меня бывает грузится по пол минуты, а иногда совсем не открывается, висит скелетон/шиммер эффект. На разных устройствах и провайдерах.
Стало особенно невыносимо после перехода с com на ru, хотя не понимаю, как это могло повлиять.
Абсолютно не юзабельно, захожу в ВК раз в неделю.
Вас не жаль, за то что вы сделали с ютубом и телеграм в России. Монополия вас похоронила.

Marat1403
13.10.2026 16:04кто-то внедрял React виджетом в страницу, кто-то пробовал Feature Sliced Design, а у кого-то была огромная папка components -> Спустя несколько итераций проектирования мы разработали собственный подход на базе идей MVVM
как связаны между собой виджет, FSD, большая папка components и MVVM? Как одно могло заменяться другим, чтобы в итоге придти к третьему в качестве стандарта?
React-виджеты в страницах - это же про интеграцию компонентов в легаси страницу - тот самый React Islands, о котором рассказывалось в статье.
FSD и большая папка components - это про файловую структуру
MVVM - это про разделение ответственности, шаблон
у меня получилось связать только FSD и большую папку components - и я бы выбрал большую папку components (у меня код не на перфокартах, поэтому большие папки меня не пугают, а вот много мелких папок назначение которых постоянно нужно подсматривать в документации - пугает)
OlegZH
Интересно. А как получилось, что, теперь, нельзя напрямую из Мессенджера в профиль собеседника попасть? Раньше можно было ткнуть на картинку слева, и ты уже находишься в профиле собеседника. А сейчас надо несколько раз ткуть мышкой в разным местах. Или у меня ручки кривые и, просто, не в курсе, что есть прямой и короткий путь?