- PVSM.RU - https://www.pvsm.ru -
Последние несколько лет веб‑разработка вышла далеко за рамки создания «сайтов по макетам для клиентов». Огромная часть индустрии сегодня — это сложные внутренние экосистемы: ERP‑системы, CRM, админ‑панели и бэк‑офисы. Те инструменты, которые скрыты от глаз обычного пользователя, но на которых держится вся операционная работа бизнеса.
Так произошло и в моем случае. Когда я пришла в финтех‑компанию, на входе мне честно сказали:
Мы пишем бэк‑офис. Проект не публичный, массового трафика и внешней красоты не будет. Но если тебя это не смущает — задачи могут быть интересными.
В итоге решение, которое я разработала, не просто «закрыло задачи», а подтолкнуло меня к написанию этой статьи. Подход, о котором пойдет речь, решает фундаментальную проблему синхронизации фронтенда и бэкенда, высвобождает ресурсы разработки и отвечает на запрос, который актуален для любой растущей системы.
Дисклеймер: так как я задала некий тон повествованию, давайте условимся: данный лайфхак подойдет далеко не всем, но даже если вы не работаете с высокой степенью повторяемости интерфейсов, где, по сути, есть несколько типовых страниц, а в них только меняются данные, то мой опыт также вам поможет в «распиле» монолитов. По факту вы увидите наглядную демонстрацию применения Strangler Fig Pattern (удушающего паттерна), и, возможно, это поможет вам в вашем проекте.
Ситуация: меня поставили на проект, который представлял собой типичную ERP‑систему. Пользователи — аналитики, бухгалтеры и менеджеры.
По смыслу они должны смотреть на разные модули, искать по ним данные, фильтровать их, вносить отчеты, изменения...
Функционал системы максимально прозаичен:
таблицы вывода данных с фильтрами;
формы создания и редактирования сущностей;
просмотр записей;
набор модалок (дублирующих те же таблицы и формы);
функционал формирования отчетов.
Что я увидела «под капотом»? Для каждой системы были созданы десятки «седых» папок для вывода каждого отдельного модуля — их было уже полсотни. Для каждой модели существовал стандартный набор: компоненты вывода, API‑слой, Store и «монстр‑роутер» на 2000 строк с генераторами.
Копая глубже, замечаешь закономерность: это очень топорная копипаста одной и той же структуры с изменением нескольких полей. Система росла в геометрической прогрессии, а поддерживать этот снежный ком становилось всё труднее.
Менеджер придумывает фичу.
Бэкенд проектирует API.
Фронтенд копипастой и правками рисует интерфейс.
Автоматическое (часто формальное) ревью фронта.
Тесты и ревью на бэкенде.
Выкатка в прод.
Процесс, мягко говоря, не быстрый. Изменение одного поля или связи требовало прохождения всей цепочки заново. В итоге типовые задачи занимали столько же времени, сколько уникальные фичи, а TTM (Time‑to‑Market) стремился в бесконечность.
Логика повторялась, страницы были типовыми, и тут мой Тимлид озвучил запрос:
Я бы хотел убрать из этой цепочки фронтенд‑разработчика почти совсем, свести его работу к минимуму. Как — я не знаю, но цель такая есть.
Я не стала предлагать переписывать всё с нуля (бизнес бы не позволил). Я применила Strangler Fig Pattern: новый движок MDUI начал «прорастать» внутри старой системы, забирая на себя одну фичу за другой.
План действий был такой:
Выявить модули страниц, которые можно быстро «зашаблонить».
Предложить контракт для бэкенда.
Перейти к пересмотру архитектуры в сторону FSD‑like подхода.
Цель: за 3 месяца показать видимый результат и продолжить его развивать при успешном релизе.
После пары дней настройки проекта, анализа текущей системы и даже выполнения пары текущих задач, я составила план переезда, того самого удушения легаси, переезд в новую архитектуру.
Тут стоит сказать, что так как тимлид был очень заинтересован в таком подходе, дополнительно убеждать бэкендеров не нужно было, хотя подход «работает — не трогай» очевидно процветал.
После пары дней анализа я составила план «удушения» легаси. Тимлид был заинтересован, поэтому убеждать бэкендеров не пришлось, хотя подход «работает — не трогай» в команде процветал.
В наличии было: Vue 3 (но без использования Composables), TS (но часто «для галочки»), Naive UI, Pinia и Vue Router. И очень много топорных шаблонов.
Я видела следующее: данных много, они тяжелые. Есть таблицы по 15 полей, много связей и вложенных структур. С чего начать?
За основу была взята страница уведомлений. На нее заходит не так много пользователей, она «дешевая» по времени, но наглядно демонстрирует все киллер‑фичи подхода.
Встал вопрос: как строить фронт? Отдавать всю структуру сразу или разделить на метаданные и чистые данные? Мы выбрали гибридный подход:
Сначала бэкенд отдает общую структуру 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 нужны бэкенду).
Данная структура едина для любых моделей. Поля фильтров часто совпадают с полями в формах, а значит, бэкенд тоже может абстрагировать эти схемы у себя.
Самый важный вопрос при внедрении 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)
Самое «вкусное» произошло с роутером. Раньше для каждой новой сущности (пользователи, счета, транзакции) приходилось описывать отдельный маршрут с импортом конкретного компонента.
Было:
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-информацию.
Здесь начинается самое интересно��: как сделать систему максимально динамичной и при этом легко расширяемой? Основным инструментом стал стандартный компонент 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 определяет нужную стратегию отрисовки.
Чтобы система оставалась масштабируемой, я разделила логику отрисовки на типы. Это позволяет не раздувать один файл, а делегировать создание 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.
Резонный вопрос: «Зачем использовать 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-like структуру проекта:
Страницы (Pages): Содержат универсальные шаблоны, которые определяют флоу работы с данными.
Виджеты (Widgets): Например, w-table или w-forms, которые получают структуру из Store и используют рендереры для отрисовки своих частей.
Фичи (Features): Сама логика рендера (component-renderer) разработана как независимая фича, доступная всей системе.
Разделение ответственности: Фронтенд больше не тратит время на создание однотипных страниц. Работа сводится к развитию ядра системы (рендереров) и созданию сложных UI-виджетов.
Скорость изменений: Если нужно изменить поведение всех текстовых инпутов в системе, достаточно поправить baseRenderComponent.ts или мапинг в formElements.ts. Логика обновится мгновенно на всех 50+ страницах проекта.
Читаемость: Компоненты становятся максимально легкими, так как вся «черная работа» по интерпретации метаданных скрыта за фасадом рендерера.
Когда я говорила про паттерн «удушения», это не было просто красивым термином. Переход на 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+ страниц системы.
Самое важное изменение произошло в самой сути моей работы. Я больше не поставщик бесконечных однотипных форм. Теперь то, чем я занимаюсь, по большей части является инженерией в чистом виде.
Вместо верстки страниц я проектирую адаптеры и валидаторы, которые страхуют систему от некорректных данных.
Вместо настройки роутов я создаю сложные рендереры, способные обрабатывать динамические запросы и трансформации данных на лету.
Для бизнеса это обернулось колоссальной экономией «человекочасов». Проект стал развиваться кратно быстрее, а фронтенд‑разработчик превратился в архитектора, создающего инструменты для всей команды.
Легаси успешно «задушено», и его полное выпиливание из проекта — лишь вопрос времени и планового рефакторинга.
Да, я все еще буду заниматься разработкой отдельных фич, компонентов и других бизнес‑задач. Да, я буду заниматься оптимизацией и ускорением текущего проекта. Но даже так, это куда более интересная работа, чем можно себе представить. :-)
Автор: ghaechka
Источник [1]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/refactoring/440202
Ссылки в тексте:
[1] Источник: https://habr.com/ru/articles/980684/?utm_source=habrahabr&utm_medium=rss&utm_campaign=980684
Нажмите здесь для печати.