Бесшовный переезд с Perl на Nuxt: история одного 20-летнего монолита
- пятница, 14 августа 2026 г. в 00:00:11

Привет, Хабр! Меня зовут Эрнест Гилязов, я тимлид группы разработки интерфейсов Рунити. В этой статье расскажу, как мы переносим на новый стек проект Рег.ру, кодовой базе которого исполняется 20 лет, и почему в итоге Nuxt-приложение само раздает шапку и футер в легаси-монолит через обычный JSON-эндпоинт.
Навигация по тексту:
Сразу обозначу вводные, которые у нас были. Сотни лендингов на Perl и Template Toolkit — все проиндексированы поисковиками, потому менять URL нельзя. Дизайн важно сохранить так, чтобы пользователь не заметил подмены. Витрина должна стать каталогом товаров со страницами, которые редактируются через админку, а в связи с ребрендингом шапку с футером придется править чуть ли не каждый день. Задачу выполняет фронтенд-команда без выделенных девопсов.
Вернемся в 2006 год. Рег.ру тогда был монолитом: на бэкенде Perl, на фронте шаблонизатор Template Toolkit. Скриншот сайта того времени я достал из архива и сразу вспомнил Internet Explorer 6.
Для тех, кто не знает, что такое Template Toolkit (дальше для сокращения — TT), поясню. Это шаблонизатор для сайтов с бэкендом на Perl. Он подмешивает к HTML-разметке свой TTшный синтаксис, переиспользует блоки, выносит их в отдельные файлики и подключает на страницах. В шаблоне описываем условия и циклы, объявляем переменную с индексом текущей итерации, обращаемся к ключам хэшей. И это не всё: TT предлагает довольно богатый набор для управления структурой страницы. Для инструмента, выпущенного в XX веке, в целом хорошо.

За счет этих возможностей и переиспользуемых блоков мы реализовали лейауты страниц. Сверху, в head, подключаем заголовок страницы, стили и скрипты. Ниже собираем каркас страницы. Здесь используется компонент «контейнер» — обертка для произвольного содержимого. В него передается компонент заголовка, которому через ключевое слово params можно задать пропсы, например размер и выравнивание по центру. А дальше происходит то самое перемешивание HTML и шаблонизатора: вместе с HTML-тегами создаем сетку, в ней выстраиваем список и кнопочку.
Так мы разработали вообще все страницы всех разделов сайта. Клиент проходит три этапа взаимодействия с услугами: ознакомление, заказ и управление.
Знакомится клиент с услугами на витрине лендингов. Типичный лендинг — это заголовок, продающий текст, картинка и кнопочка «Купить». Как правило, статичная страница, задача которой перенести клиента в мастер заказа.
Мастер заказа уже поинтереснее, потому что процесс заказа у каждой услуги уникален. Где-то достаточно нажать кнопочку «Оплатить» и на почту придет письмо, деньги спишутся с карты и всё готово. Другие услуги требуют конфигурации: выбрать параметры, подвигать ползунки, поставить галочки, переключить тумблеры. Да и сам заказ может быть многоэтапным.
Личный кабинет тоже предусматривает динамику и вариативность. Какие-то услуги управляются просто кнопочками «включить» и «выключить», у других можно менять тарифы, добавлять гигабайты и мегагерцы.
Примерно спустя десять лет бизнес начинает расти: расширяется ассортимент, у каждой отдельной услуги становится больше возможностей. Любой бизнес мечтает быстро внедрять фичи, быстро тестировать гипотезы, быстро добавлять новые услуги. А мы начинаем замечать, что текущий стек эти потребности уже не закрывает. Мир развивается, прогресс не стоит на месте. А нам становится понятно, что стек превращается в бутылочное горлышко.
Второй и третий этапы (мастер заказа и личный кабинет) как раз и нуждались в расширении. Поэтому решили вынести их в отдельные фронтенд-приложения и перевести на новый стек. Но на какой? На тот момент на слуху было три основных инструмента: React, Vue и Angular. В компании тогда работало много верстальщиков, и мы искали инструмент с комфортным для них порогом вхождения: чем привычнее синтаксис и логика работы, тем лучше. По этим критериям многие, наверное, уже догадались, что мы выбрали. Мы выбрали Vue.js. У него более низкая кривая вхождения, а синтаксис для нас привычнее, чем у React и Angular. Еще нам понравилось, что Vue, в отличие от двух других кандидатов, немножечко подсказывает, как с ним работать. Времени на обучение не было, работать надо было уже сейчас, так что лишняя свобода действий нам была не нужна.
Разумеется, по щелчку пальцев переехать на новый стек мы не могли. За десять лет накопилось много услуг и страниц, у каждой услуги своя уникальная бизнес-логика. К тому же в 2017 году еще нельзя было отдать все это нейросети и попросить переписать. Нужно было делать все поэтапно.
Переезд мастера заказа особых трудностей не представлял, потому что услуги можно затаскивать в новый мастер по одной. Логика простая. Если услуга реализована в новом мастере заказа, бэкенд редиректит клиента туда. Если еще живет в старом, направляем в старый. Постепенно все услуги переехали, и старый мастер стал недоступен. На этом, по сути, все.
Личный кабинет обновлялся по той же протоптанной дорожке. Запилили MVP, затащили услуги по одной и дали пользователю кнопочку «попробовать новый». Изначально в новом кабинете был не весь функционал, поэтому старый у пользователя, конечно, никто не отнимал. Какое-то время существовали оба варианта. А когда в старом кабинете пропала необходимость, мы его просто выпилили.
Окей, мы молодцы: перевезли мастер заказа и личный кабинет на Vue.js. Но на картинке было еще третье приложение — витрина. Неужели про нее забыли? Нет, не забыли. Просто к ней не предъявлялись новые требования. Скорость разработки страничек на старом стеке бизнес устраивала, функционал тоже соответствовал: все так же можно было написать продающий текст, поставить красивую картинку и кнопочку на мастер заказа.
Нас, разработчиков, это в принципе тоже почти устраивало. Почему почти? Потому что иногда приходилось разрабатывать довольно сложные динамичные интерфейсы. На современных инструментах это делается удобно, а на старом стеке приходилось изобретать велосипеды.

Любой динамический интерфейс — это куча операций с документом: элементы нужно создавать, добавлять, перемещать, клонировать, управлять их атрибутами и классами. На чистом HTML и JavaScript это неэффективно. К тому же нам приходилось постоянно дублировать и догонять UI-кит дизайн-системы из Vue-приложений.
В 2024 году бизнес получает новый импульс и решается на большой шаг: витрина лендингов должна превратиться в большой живой каталог товаров. И к каталогу тут же добавились новые требования. Страницы товаров должны создаваться через админку, не руками разработчиков. Структура страницы товара тоже должна управляться через админку. Еще бизнес захотел переиспользовать виджеты, которые уже существовали в Vue-экосистеме и жили в мастере заказа и личном кабинете. И ко всему этому нам обязательно нужен был серверный рендеринг для SEO.
Реализовывать все это на старом стеке было нецелесообразно. Если с бэкендом для каталога еще можно было подумать (в итоге взяли довольно популярные и очевидные Python и Django), то с фронтендом все было куда очевиднее. У нас большая Vue-экосистема, у нас много Vue-разработчиков, и нам нужен SSR. Тут на помощь приходит Nuxt. Для тех, кто не знает: это Vue с серверным рендерингом. В 2024 году уже была стабильная версия Nuxt 3, у которой в основе Vue 3, Composition API и TypeScript.
И снова, спустя почти десять лет, мы сталкиваемся с задачей постепенного переезда на новый стек. Только теперь правила игры другие.
На старом стеке реализованы сотни страниц. Они приносят прибыль и проиндексированы в поисковых системах. Поэтому пойти по пути мастера заказа и личного кабинета и редиректить пользователей на новые адреса мы не можем. Нам важно сохранить старые URL. Просто взять и поменять дизайн на новых страницах тоже нельзя: не хотим спугнуть клиента новым интерфейсом. Переезжать нужно бесшовно.
Первая реализация была довольно простая: у нас были сроки и у нас были фронтендеры. Сделали так. Полностью продублировали сквозные элементы, шапку и футер, чтобы страницы на старом и новом стеке выглядели идентично. Получили два приложения, которые выглядят одинаково, но фактически это все-таки два разных приложения. И клиентов нужно как-то между ними распределять.
Мы делали все руками фронтендеров, без привлечения девопсов и админов, поэтому распределение собрали на уровне Nginx Ingress Controller. Создали правила, по которым Ingress направляет клиента либо в старое, либо в новое приложение. Запилили конфиг с перечнем URL, которые нужно отдавать в новое. Если клиент переходит на страницу, реализованную на новом стеке, отправляем его в Nuxt. Если нет, направляем в легаси-монолит.
Вам, наверное, интересно, почему именно так, ведь каждая новая страница требует ручного вмешательства? И что будет, если таких страниц станет много и конфиг разрастется на сотни строк? Почему бы это не автоматизировать? Мы, конечно, тоже об этом думали. Но сроки поджимали, времени на раскачку было не так много, и работали мы без девопсов. Поэтому выбрали самый простой, прозрачный и легко откатываемый вариант. Как только вышла новая страничка, добавляем ее в конфиг. Если все хорошо — супер. Если нет, легко и быстро откатываем.
Так и жили какое-то время. Старые странички переезжали на новый стек, новые сразу разрабатывались на новом. Иногда приходилось синхронизировать продублированные шапку и футер: продублировать ссылочку в старом и новом меню, сдвинуть кнопку на пять пикселей, чуть-чуть перекрасить логотип. Случалось это редко, и нам было ок. Но тут бизнес пришел с новостью, что нас ждет ребрендинг.
Для нас ребрендинг означает новый логотип, новую шапку, новый футер. Параллельно появилось важное требование от каталога товаров: навигацией меню в шапке нужно управлять через админку, и меняться она будет регулярно. Получается, нужно сверстать шапку на новом стеке, сверстать ее на старом и то же самое проделать с футером. И мы еще на старте понимаем, что синхронизировать ссылки в меню руками придется каждый день. Это просто нерационально. Тогда мы сказали бизнесу: давайте в этот раз сделаем нормально.

Суть идеи в том, чтобы у сквозных элементов был один единый источник правды (дальше буду все рассматривать на примере шапки). Разработчик вносит изменения в одном месте, и они распространяются и на старый, и на новый стек. Аналогично с правками контента: если мы добавили ссылочку в меню, то она появляется сразу и там, и тут.
Был вариант разработать шапку как отдельный сервис, который поставляет контент в оба стека. Но это потребовало бы вмешательства в инфраструктуру. А мы сделали так, как быстрее и как проще контролировать без привлечения девопсов. В новом стеке реализовали компонент-шапку и подставили ее в layout Nuxt, как это обычно и делается. И одновременно эта же шапка раздается наружу, внешним потребителям. В нашем случае внешний потребитель — легаси-приложение, и наружу уходят HTML-разметка,

Шаг 1. Два Vite-конфига
Создаем в приложении два конфига: клиентский и серверный. На клиентском все как обычно: импортируем Vue-приложение и монтируем его в DOM-узел. Единственное отличие от классического варианта в том, что вместо привычного createApp используем createSSRApp. В серверном энтрипоинте импортируем renderToString из vue/server-renderer. Создаем приложение через createSSRApp и рендерим его этой функцией в HTML-строку.
Шаг 2. Сборка
Билдим оба конфига и получаем артефакты, из которых нас больше всего интересуют два. В manifest.json лежат пути до JavaScript и стилей шапки. Эти данные мы отдадим наружу приложениям-потребителям. Серверный энтрипоинт после билда содержит экспорт функции, которая возвращает шапку в виде HTML-строки. Итого у нас есть и пути до скриптов со стилями, и сама разметка.
Шаг 3. Серверный обработчик
Теперь все это нужно отдать наружу. Для простоты понимания покажу псевдокод на Express:
import { renderApp } from './dist/server/entry.server.js' import manifest from './dist/client/.vite/manifest.json' with { type: 'json' } const HOST = process.env.HOST const getScript = () => { const src = manifest['src/entry.client.js'].file return `<script type="module" src="${HOST}/${src}"></script>` } const getStyles = () => { const cssEntries = manifest['src/entry.client.js'].css return cssEntries .map(entry => `<link rel="stylesheet" href="${HOST}/${entry}">`) .join('') } app.get('/header', async (req, res) => { const headHtml = ` ${getScript()} ${getStyles()} ` const appHtml = ` <div id="app"> ${await renderApp()} </div> ` res.json({ headHtml, appHtml }) })
Шаг 4. Потребление в легаси
Приложение-потребитель идет по новой ручке и получает JSON с двумя полями: headHtml со скриптом и стилями и appHtml с разметкой шапки. Подставляет их в свой layout. Вуаля: шапка выглядит идентично и в старом, и в новом приложении.

Мы все еще в пути, впереди много работы по замещению старого стека. Но некоторые итоги подвести уже можно.
Бизнес стал внедрять фичи быстрее и получил каталог товаров, который отвечает всем бизнес-требованиям. Меню теперь правят без разработчиков. А побочным положительным эффектом стал более легкий найм: нам больше не нужно обещать кандидатам на собеседованиях, что когда-то у нас будет хороший стек, а пока придется поработать на старом.
Разработчики перестали изобретать велосипеды. Эту энергию теперь можно потратить на изучение чего-то нового, расширение кругозора и повышение качества кода.
Ну и самое главное — наш клиент. Пользователь получил страницы, которые работают в разы быстрее, чем на старом стеке, и при этом не получил никакого регресса. Кому, как не нам, фронтендерам, понятно, насколько это важно: нажимаешь кнопочку и заранее знаешь, какую картину получишь. Клиент пользуется привычным для него интерфейсом, а переключение между страницами происходит бесшовно.
В комментариях задавайте вопросы и делитесь своим опытом переездов, обсудим!