Последние несколько лет веб‑разработка вышла далеко за рамки создания «сайтов по макетам для клиентов». Огромная часть индустрии сегодня — это сложные внутренние экосистемы: ERP‑системы, CRM, админ‑панели и бэк‑офисы. Те инструменты, которые скрыты от глаз обычного пользователя, но на которых держится вся операционная работа бизнеса.
Так произошло и в моем случае. Когда я пришла в финтех‑компанию, на входе мне честно сказали:
Мы пишем бэк‑офис. Проект не публичный, массового трафика и внешней красоты не будет. Но если тебя это не смущает — задачи могут быть интересными.
В итоге решение, которое я разработала, не просто «закрыло задачи», а подтолкнуло меня к написанию этой статьи. Подход, о котором пойдет речь, решает фундаментальную проблему синхронизации фронтенда и бэкенда, высвобождает ресурсы разработки и отвечает на запрос, который актуален для любой растущей системы.
Дисклеймер: так как я задала некий тон повествованию, давайте условимся: данный лайфхак подойдет далеко не всем, но даже если вы не работаете с высокой степенью повторяемости интерфейсов, где, по сути, есть несколько типовых страниц, а в них только меняются данные, то мой опыт также вам поможет в «распиле» монолитов. По факт�� вы увидите наглядную демонстрацию применения Strangler Fig Pattern (удушающего паттерна), и, возможно, это поможет вам в вашем проекте.
1. Проблема
Ситуация: меня поставили на проект, который представлял собой типичную ERP‑систему. Пользователи — аналитики, бухгалтеры и менеджеры.
По смыслу они должны смотреть на разные модули, искать по ним данные, фильтровать их, вносить отчеты, изменения...
Функционал системы максимально прозаичен:
таблицы вывода данных с фильтрами;
формы создания и редактирования сущностей;
просмотр записей;
набор модалок (дублирующих те же таблицы и формы);
функционал формирования отчетов.
Что я увидела «под капотом»? Для каждой системы были созданы десятки «седых» папок для вывода каждого отдельного модуля — их было уже полсотни. Для каждой модели существовал стандартный набор: компоненты вывода, API‑слой, Store и «монстр‑роутер» на 2000 строк с генераторами.
Копая глубже, замечаешь закономерность: это очень топорная копипаста одной и той же структуры с изменением нескольких полей. Система росла в геометрической прогрессии, а поддерживать этот снежный ком становилось всё труднее.
Как выглядел процесс разработки (TTM):
Менеджер придумывает фичу.
Бэкенд проектирует API.
Фронтенд копипастой и правками рисует интерфейс.
Автоматическое (часто формальное) ревью фронта.
Тесты и ревью на бэкенде.
Выкатка в прод.
Процесс, мягко говоря, не быстрый. Изменение одного поля или связи требовало прохождения всей цепочки заново. В итоге типовые задачи занимали столько же времени, сколько уникальные фичи, а TTM (Time‑to‑Market) стремился в бесконечность.
Логика повторялась, страницы были типовыми, и тут мой Тимлид озвучил запрос:
Я бы хотел убрать из этой цепочки фронтенд‑разработчика почти совсем, свести его работу к минимуму. Как — я не знаю, но цель такая есть.
Я не стала предлагать переписывать всё с нуля (бизнес бы не позволил). Я применила Strangler Fig Pattern: новый движок MDUI начал «прорастать» внутри старой системы, забирая на себя одну фичу за другой.
План действий был такой:
Выявить модули страниц, которые можно быстро «зашаблонить».
Предложить контракт для бэкенда.
Перейти к пересмотру архитектуры в сторону FSD‑like подхода.
Цель: за 3 месяца показать видимый результат и продолжить его развивать при успешном релизе.
2. Разработка
После пары дней настройки проекта, анализа текущей системы и даже выполнения пары текущих задач, я составила план переезда, того самого удушения легаси, переезд в новую архитектуру.
Тут стоит сказать, что так как тимлид был очень заинтересован в таком подходе, дополнительно убеждать бэкендеров не нужно было, хотя подход «работает — не трогай» очевидно процветал.
В наличии было: Vue 3 (но без использования Composables), TS (но часто «для галочки»), Naive UI, Pinia и Vue Router. И очень много топорных шаблонов.
Я видела следующее: данных много, они тяжелые. Есть таблицы по 15 полей, много связей и вложенных структур. С чего начать?
Этап 1. Определение контракта.
За основу была взята страница уведомлений. На нее заходит не так много пользователей, она «дешевая» по времени, но наглядно демонстрирует все киллер‑фичи подхода.
Встал вопрос: как строить фронт? Отдавать всю структуру сразу или разделить на метаданные и чистые данные? Мы выбрали гибридный подход:
Сначала бэкенд отдает общую структуру ERP под конкретного пользователя.
Фронт запрашивает структуру (метаданные) конкретной вкладки.
И только потом — получение чистых данных.
В пункте № 2 кроется причина, почему я назвала это Meta‑Driven UI, а не Server‑Driven. Рисует всё по‑прежнему фронтенд, но по строгой мета‑схеме от бэкенда.
Пример мета‑схемы:
// GET /bff/meta/users
{
"title": "Пользователи",
"table": {
"columns": [
{
"attribute": "name",
"label": "Имя",
"props": { /* свойства отрисовки */},
"renderItem": {
"type": "tagLink", // Компонент для рендера ссылок
"props": { /* свойства рендера */ }
}
},
]
},
"filter": [
{
"attribute": "name",
"label": "Имя",
"component": {
"type": "input",
"props": {
"placeholder": ""
}
}
},
],
"_query": {
"_with": "userRole" // Подсказка для API, какие связи подтянуть
}
}Что дает фронтенду такая структура:
Понимание, где находится пользователь (заголовок страницы и для хлебных крошек, описания).
Полное описание таблицы: какие колонки рисовать и как именно (текст или кастомный компонент через
renderItem).Состав фильтров.
Параметры для последующего запроса данных (какие
relationsнужны бэкенду).
Данная структура едина для любых моделей. Поля фильтров часто совпадают с полями в формах, а значит, бэкенд тоже может абстрагировать эти схемы у себя.
Этап 2. Реализация на фронтенде
Самый важный вопрос при внедрении MDUI: когда именно мы должны получать структуру страницы? В стандартных SPA мы привыкли, что компонент монтируется и сам идет за своими данными. Но в системе, где интерфейс диктует бэкенд, нам нужно знать, «что рисовать», еще до того, к��к пользователь увидит страницу.
Жизненный цикл загрузки страницы
Флоу работы нашего движка выглядит так:
Navigation Guards: Пользователь переходит по ссылке. Вместо простой проверки прав,
router.beforeEachделает запрос на получение метаданных страницы (BFF/Meta).RBAC на стороне бэкенда: Огромный плюс такого подхода — фронтенду больше не нужно хранить логику ролей. Если у пользователя нет доступа, бэкенд просто не отдаст мета-схему или вернет 403. Фронт в этом случае «разворачивает» пользователя на 404 или страницу логина.
Кэширование в Store: Полученная структура сохраняется в Pinia. Это позволяет реализовать систему вкладок и мгновенный переход «назад» без лишних запросов к BFF.
Рендеринг и данные: Когда мета-схема готова и сохранена, мы пускаем пользователя на страницу. Компонент-рендерер видит структуру и инициирует запрос к чистым данным.
Для получения данных я использовала стратегию SWR (Stale-While-Revalidate). Для реализации был взят легковесный swrv. Конечно, с дальнейшим переходом на PiniaColada.
Структура страницы (мета) обычно статична, а сами данные обновляются часто. Такой гибридный подход (Fetch для структуры + SWR для данных) позволил сделать интерфейс визуально очень быстрым: пользователь мгновенно видит каркас страницы, пока данные подгружаются в фоне.
Структура проекта
Чтобы не плодить хаос, я начала внедрять FSD-like структуру. На первом этапе (когда нужно было быстро показать результат) это выглядело как выделение общих шаблонов:
.
└── src/
├── app/ # Инициализация приложения
├── store/ # Стор
├── templates/ # Те самые "Мастер-шаблоны" для MDUI
│ ├── list-page # Шаблон для списков
│ ├── form-page # Шаблон для создания/редактирования
│ └── view-page # Шаблон для детального просмотра
│ index.ts # Вся структура хранится в едином файле для удобства доступа
└── shared/ # Атомарные компоненты (UI-kit на базе Naive UI)Магия маршрутизации: из 2000 строк в одну
Самое «вкусное» произошло с роутером. Раньше для каждой новой сущности (пользователи, счета, транзакции) приходилось описывать отдельный маршрут с импортом конкретного компонента.
Было:
export const accounts: RouteRecordRaw[] = [
{
path: 'users',
name: RoutesNameEnum.USERS,
component: () => import('@/views/Users/Users.vue'),
meta: {
title: 'Пользователи',
accessScopes: [`${RESOURCE_ACCOUNT}.${ACTION_VISIT}`]
}
},
// ... и так еще 50 раз для каждого модуля
]Стало (MDUI): Мы перешли на динамические параметры. Теперь роутеру все равно, какая модель перед ним — он просто передает управление общему шаблону.
// Ниже – вполне обычная структура, не так ли?
const recordRoute: Readonly<RouteRecordRaw[]> = [
{
path: 'list',
redirect: { name: 'dashboard' },
children: [
{
// Это лишь пример того, как это может выглядеть.
// Исходно код содержит также и для зависимых путей маршруты, но не только.
path: ':model/:id?',
name: 'list',
component: templates.ListPage,
meta: {
baseTemplate: 'Просмотр всех данных'
},
},
]
},
// Похожим образом описаны формы редактирования, просмотра и тд.
];Теперь, чтобы добавить в систему новый раздел (например, «Справочник валют»), фронтенд-разработч��к вообще не нужен. Бэкенд просто регистрирует новый эндпоинт в BFF, а роутер по параметру :model подхватывает нужную мета-схему и отрисовывает страницу.
Хитрый момент: :id мы используем универсально. Если он есть в URL — движок понимает, что нужно открыть форму редактирования или детальный просмотр, подставляя этот ID в фильтры или API-запросы автоматически. Причем просмотр реализуется через query, тут уж на что ваша фантазия горазда.
Самое главное было сохранить визуально предыдущую структуру и ссылки, скрыть от пользователя фильтры или другую meta-информацию.
Этап 3. Рендер "под капотом"
Здесь начинается самое интересное: как сделать систему максимально динамичной и при этом легко расширяемой? Основным инструментом стал стандартный компонент Vue 3 — <component>, предназначенный для динамической отрисовки.
Точка входа в наш движок выглядит следующим образом:
<template>
<component :is="setComponent" class="create-base__content" />
</template>
<script setup lang="ts">
const setComponent = () => {
return renderComponent(
props?.component?.type || 'input',
props?.component || {},
value,
// ... пропсы и события
);
};
</script>Через функцию setComponent вызывается диспетчер, который по ключу из мета-схемы BFF определяет нужную стратегию отрисовки.
Диспетчер компонентов (The Registry)
Чтобы система оставалась масштабируемой, я разделила логику отрисовки на типы. Это позволяет не раздувать один файл, а делегировать создание VNode специализированным функциям.
// renderers/index.ts
export function renderComponent(type: iComponentType, ...) {
switch (type) {
case 'date-picker':
return renderDatepicker({ fieldConfig, model, onUpdate });
case 'search-select':
return renderSearchSelect(fieldConfig, fetch, model, onUpdate);
case 'upload':
return renderUpload(fieldConfig, model, onUpload);
// ...
default:
return baseRenderComponent(type, fieldConfig, model, onUpdate);
}
}Такой подход позволяет передавать для каждого компонента свой специфичный набор параметров, будь то функции загрузки файлов для upload или конфигурация поиска для search-select.
Почему Render-функции (h) лучше шаблонов в MDUI?
Резонный вопрос: «Зачем использовать h(), если есть привычные <template>?». Ответ — абсолютная гибкость.
В MDUI компоненты должны быть не просто визуальными оболочками, а «умными кирпичиками», способными на лету трансформировать данные под нужды бизнеса и API. Идеальный пример — работа с датами. Нам нужно отображать календарь в локальном формате пользователя, но отправлять на бэкенд всегда UTC.
// renderers/datepickerRenderer.ts
return h(NDatePicker, {
...fieldConfig.bind,
type: fieldConfig.props.type,
format: fnsDisplayFormat.value, // Локальный формат для юзера
value: timestampValue.value,
'onUpdate:value': onUpdateValue, // Авто-конвертация в UTC для бэкенда
// ...
});Благодаря функциям рендеринга вся эта сложная логика инкапсулирована внутри одной функции. Бэкенд просто говорит: «Хочу поле даты», а движок сам знает, как его отформатировать, валидировать и в каком виде вернуть данные в API.
Контракт и типизация
Для стабильности системы важно строго описать контракт между фронтендом и бэкендом. Я использовала TypeScript для определения типов компонентов и их пропсов, что минимизирует риск падения приложения из-за некорректных данных от BFF.
// component-renderer/entities/rendererTypes.ts
export type iComponentType =
| 'date-picker' | 'input' | 'select' | 'search-select'
| 'upload' | 'code' | 'checked-input';
export interface iBaseComponentProps {
[key: string]: any;
}
// Пример типизации для сложного компонента даты
export interface iDatepickerConfig extends iBaseComponentProps {
format: string;
valueFormat: string;
type: 'date' | 'datetime';
}Вместо громоздких v-if/else в шаблонах, я использовала функциональный подход. Это позволяет динамически выбирать нужную функцию отрисовки для каждого типа компонента из метаданных.
// component-renderer/renderers/index.ts
export function renderComponent(
type: iComponentType,
fieldConfig: iComponentPropsType,
model: ModelRef<string>,
onUpdate: (v: any) => void,
// ... остальные зависимости (fetch, upload и т.д.)
) {
switch (type) {
case 'date-picker':
case 'datetime-picker':
return renderDatepicker(fieldConfig as iDatepickerConfig, model, onUpdate);
case 'input':
case 'select':
// Базовый рендерер для простых компонентов Naive UI
return baseRenderComponent(type, fieldConfig as iBaseComponentProps, model, onUpdate);
...
default:
console.warn(`[Component Renderer] Unknown component type: "${type}"`);
return null;
}
}Дальше можете догадаться: рендер возвращает также h(), рисуя компонент со своей логикой. Очень удобно потом менять логику, если заказчик захотел что-то поменять или изменился контракт.
Архитектура и FSD
Такой подход идеально ложится в FSD-like структуру проекта:
Страницы (
Pages): Содержат универсальные шаблоны, которые определяют флоу работы с данными.Виджеты (
Widgets): Например,w-tableилиw-forms, которые получают структуру из Store и используют рендереры для отрисовки своих частей.Фичи (
Features): Сама логика рендера (component-renderer) разработана как независимая фича, доступная всей системе.
Что это дает на практике?
Разделение ответственности: Фронтенд больше не тратит время на создание однотипных страниц. Работа сводится к развитию ядра системы (рендереров) и созданию сложных UI-виджетов.
Скорость изменений: Если нужно изменить поведение всех текстовых инпутов в системе, достаточно поправить
baseRenderComponent.tsили мапинг вformElements.ts. Логика обновится мгновенно на всех 50+ страницах проекта.Читаемость: Компоненты становятся максимально легкими, так как вся «черная работа» по интерпретации метаданных скрыта за фасадом рендерера.
Этап 4. Удушение legacy

Когда я говорила про паттерн «удушения», это не было просто красивым термином. Переход на MDUI происходил итерационно, позволяя системе эволюционировать, не прерывая поставку новых фич.
От прототипа к архитектуре
Весь процесс занял несколько месяцев. Первым «подопытным» стал модуль уведомлений. На его реализацию ушло около 3–4 двухнедельных спринтов (при наличии других задач).
На старте: вся структура компонентов была жестко описана на фронтенде. Мы проверяли саму концепцию рендеринга и то, насколько удобно будет бэкенду готовить такие данные.
В процессе: как только прототип доказал свою стабильность, мы приступили к полноценному выносу метаданных на сторону BFF (Backend for Frontend).
Планомерная экспансия
После утверждения контрактов начался масштабный переезд. Чтобы система не превратилась в «генератор пустых форм», были реализованы ключевые инфраструктурные элементы:
Динамические маршруты и Navigation Guards: Мы заменили статический роутер на систему, где путь
/list/:modelавтоматически подтягивает нужный мастер-шаблон.Система экшенов: Она была уже реализована ранее, но была доработана и также реализована как фича в новой системе. Логика рендера экшенов такая же, как и в фильтрах и таблицах. Позволило не только объединить общие логики взаимодействия с системой на всех страницах, но и реализовать специфичные действия для определенного типа страниц в проекте.
Глобальные виджеты: Появились универсальные модальные окна и всплывающие уведомления, работающие по тем же принципам метаданных. Тут уж выбирайте реализацию самих модалок по себе. :)
Адаптеры и отказоустойчивость
Одной из главных проблем Metadata-Driven подхода является риск получить от бэкенда «битую» или неполную структуру. Чтобы система не падала с ошибкой в рантайме, мы внедрили слой адаптеров и валидаторов.
В структуре проекта появились специализированные модули:
queryValidator: Проверяет входящие параметры запросов, предотвращая отправку некорректных данных на сервер.requestAdapter: Нормализует данные из API под формат, который ожидает наш рендерер. Это позволяет фронтенду оставаться независимым от изменений в структурах БД.filterQueryAdapter: Отвечает за преобразование сложных фильтров бэкенда в понятные для UI-компонентов состояния.
Итоги
В результате этой экспансии структура проекта приобрела четкий, предсказуемый вид. Старый код всё еще доживает свой век в изолированных папках, но всё новое — от модулей до мельчайшей логики — строится исключительно в рамках новой архитектуры.
Что мы получили в цифрах и фактах:
TTM сократился в 6–7 раз. Если раньше на создание типового модуля уходило несколько дней из-за необходимости синхронной работы фронта и бэка, то теперь бэкенд-разработчик разворачивает новую страницу за 15-20 минут.
Бэкенд-автономия. Коллеги из смежного отдела теперь могут самостоятельно изменять интерфейсы и добавлять фичи, не дожидаясь ресурсов фронтенда. Моя помощь требуется только тогда, когда нужна специфическая бизнес-логика или совершенно новый компонент отрисовки.
Смерть копипасты. Мы больше не плодим «раздутые папки» с одинаковыми Store и API-слоями. Весь проект держится на переиспользуемых «умных компонентах» — рендерерах.
Массовые исправления. Если в интерфейсе находится баг, он правится в одном месте (в соответствующем рендерере или хелпере) и исправление мгновенно раскатывается на все 50+ страниц системы.
Как изменилась роль фронтенда?
Самое важное изменение произошло в самой сути моей работы. Я больше не поставщик бесконечных однотипных форм. Теперь то, чем я занимаюсь, по большей части является инженерией в чистом виде.
В��есто верстки страниц я проектирую адаптеры и валидаторы, которые страхуют систему от некорректных данных.
Вместо настройки роутов я создаю сложные рендереры, способные обрабатывать динамические запросы и трансформации данных на лету.
Для бизнеса это обернулось колоссальной экономией «человекочасов». Проект стал развиваться кратно быстрее, а фронтенд‑разработчик превратился в архитектора, создающего инструменты для всей команды.
Легаси успешно «задушено», и его полное выпиливание из проекта — лишь вопрос времени и планового рефакторинга.
Да, я все еще буду заниматься разработкой отдельных фич, компонентов и других бизнес‑задач. Да, я буду заниматься оптимизацией и ускорением текущего проекта. Но даже так, это куда более интересная работа, чем можно себе представить. :-)
