Практически каждый разработчик проходит похожий путь.
В начале карьеры решение задачи выглядит как один большой файл. Главное — чтобы программа работала.
Со временем появляются первые функции. Затем — классы, сервисы, утилиты, модули. Постепенно приходит понимание, что большой объем кода сам по себе не является проблемой. Настоящая проблема возникает тогда, когда одна часть кода одновременно отвечает за множество разных задач.
Опытные разработчики стремятся разделять систему на небольшие самостоятельные части с четкими зонами ответственности. Такой код легче читать, тестировать, изменять и повторно использовать.
Интересно, что этот навык не всегда автоматически переносится между разными областями разработки.
Например, backend-разработчик может прекрасно проектировать архитектуру: выделять сервисы, репозитории, доменные модели, соблюдать принципы SOLID и разделения ответственности. Но, создавая Vue-приложение, он нередко начинает писать компоненты на тысячу строк, в которых вперемешку находятся шаблон, бизнес-логика, работа с API, обработчики событий и вспомогательные функции.
Конечно, frontend имеет свою специфику. Поэтому он требует отдельного разговора.
Содержание
- Единая ответсвенность
- Как делить страницу на компоненты
- Контейнерные и презентационные компоненты
- Composables
- Применение паттерна МVC для Vue.js
- Поток данных между компонентами
- Вместо вывода
- Полезные ссылки
Единая ответсвенность
Большой компонент почти никогда не появляется сразу. Он вырастает органически. Каждое изменение само по себе выглядит разумным. Добавить еще один ref — нормально. Еще один watch — тоже. Еще один computed — почему бы и нет. Несколько новых методов… Еще один v-if…
Через год оказывается, что никто уже не понимает компонент целиком.
Именно поэтому опытные разработчики стараются задавать себе вопрос: «Не появилась ли здесь новая ответственность, которую пора выделить в отдельный модуль?» И задавать его необходимо не когда компонент уже достиг тысячи строк, а значительно раньше.
Как делить страницу на компоненты
При выборе границ компонентов полезно задавать себе вопрос: может ли эта часть интерфейса существовать самостоятельно? Если ответ положительный, то, скорее всего, ее стоит вынести в отдельный компонент. Такой компонент получает данные через props, сообщает о действиях пользователя через события или другие механизмы взаимодействия и не знает, кто именно его использует. Благодаря этому его можно повторно применять в других местах приложения.
Например, на странице интернет-магазина отдельными компонентами могут быть карточка товара, блок фильтров, пагинация, корзина.
Важно понимать, что компонент — это не только способ повторного использования кода. Даже если какой-то блок встречается на сайте всего один раз, его все равно имеет смысл выделить в отдельный компонент, если это делает архитектуру страницы более понятной. Основная цель разделения — уменьшить когнитивную нагрузку на разработчика. Когда каждый файл отвечает за небольшой участок интерфейса, становится проще ориентироваться в проекте, искать ошибки и развивать функциональность.
Со временем разработчики обычно приходят к тому, что верхнеуровневые компоненты становятся все более «тонкими». Они практически не содержат деталей реализации, а лишь описывают структуру страницы и связывают между собой специализированные компоненты. Вся логика отображения оказывается распределенной по небольшим независимым модулям, каждый из которых имеет четкую ответственность. Такой подход делает код более предсказуемым, облегчает командную разработку и значительно упрощает сопровождение проекта в долгосрочной перспективе.
Поговорим теперь о том, какие типы компонентов можно выделить.
Контейнерные и презентационные компоненты
В 2015 Ден Абрамов (Dan Abramov) написал статью “Presentational and Container Components” (Презентационные и контейнерные компоненты). Он предлагал выделить 2 типа компонентов
- Презентационные, или глупые (Dumb Components)
- Контейнерные, или умные (Smart Components)
Первая группа компонентов не определяет как данные загружаются или менятся. Они только получают данные через свойства и занимаются отображением.
Вторая группа предоставляет данные для компонентов первой группы и для других контенерных компонентов. Они описывают как все работает.
Если нам потребуется изменить внешний вид чего-либо (например, дизайн кнопки), мы сможем сделать это, не затрагивая логику приложения. И наоборот, если нам понадобится изменить способ передачи или обработки данных, компоненты представления останутся неизменными, обеспечивая согласованность пользовательского интерфейса.
Хотя та статья была написана под React, эти подходы быстро нашли применение в коде разоработчиков на других JS библиотеках и фреймворках.
Composables
С выходом Composition API для этих же целей (разделение отображения и работы с данными) стало возможным использовать другой интсрумент — композиционные функции (Composables). Как мы обсуждали в предыдущей статье, они позволяют инкапсуловать и повторно использовать логику хранящую состояние (stateful logic).
Убрав логику работы с данными в Composables мы можем сделать код компонента гораздо более простым. Так в ряде случаев можно обходиться без разделения на презентационные и контейнерные компоненты.
Однако в контейнерные компоненты, презентационныеи компоненты и композиционные функции могут работать «в команде». Так мы подходим к теме MVC для Vue.
Применение паттерна МVC для Vue.js
Майкл Тиссен (Michael Thiessen) в своем курсе «Clean Components Toolkit» рассказывает про то, как можно применить MVC для Vue.
Когда говорят об MVC, многие представляют классическую серверную архитектуру, так как этот подход появился задолго до современных SPA‑фреймворков.
Главная идея MVC: отделить пользовательский интерфейс от бизнес-логики. Представление отвечает за отображение данных, модель — за состояние и правила работы приложения. Между ними есть посредник — контроллер, который координирует взаимодействие между двумя сторонами. Именно это разделение делает систему более модульной, масштабируемой и удобной в сопровождении.
Модель
Во Vue роль модели удобно отдать composables — композиционным функциям, в которых хранится состояние и бизнес-логика приложения.
Такие функции не зависят от интерфейса: они могут загружать данные, выполнять вычисления, управлять состоянием, обращаться к API и предоставлять готовый набор реактивных значений и методов.
Благодаря этому логика остается изолированной от компонентов и может переиспользоваться в разных местах приложения.
Представление
Роль представления выполняют обычные Vue-компоненты. Но в контексте MVC желательно, чтобы они были максимально простыми: получали данные через props, отображали интерфейс и сообщали о действиях пользователя через события.
Чем меньше в таком компоненте бизнес-логики, тем легче его понимать, тестировать и переиспользовать.
Контроллер
Контроллером можно считать специальные компоненты-координаторы, задача которых — связать composables (Model) и UI-компоненты (View).
Они получают данные из composables, передают их в презентационные компоненты, обрабатывают события пользователя и определяют общий сценарий работы страницы.
При этом контроллер обычно не содержит сложной бизнес-логики — он скорее организует взаимодействие между моделью и представлением.
В итоге
Такой подход хорошо сочетается и с Container / Presentational pattern, и с современным Composition API. В результате верхнеуровневые компоненты становятся «тонкими», бизнес-логика оказывается вынесенной в composables, а UI-компоненты остаются небольшими и предсказуемыми.
Главное преимущество не в том, что приложение «формально соответствует MVC». Важнее то, что каждая часть получает понятную ответственность.
Отсюда вытекают плюсы:
- Логику можно переиспользовать через composables.
- Интерфейс можно менять независимо от бизнес‑правил.
- Большие страницы перестают превращаться в компоненты на тысячу строк.
- Код становится проще тестировать и развивать.
На практике такой подход оказывается одним из самых удобных и понятных.
Поток данных между компонентами
После того как страницу предварительно разбили на компоненты (пока в уме или на рисунке), следующий вопрос: «откуда, куда и как передавать данные, нужные компонентам».
Свойства вниз, события вверх
Здесь, конечно, стоит помнить про подход «свойства вниз, события вверх» (Props Down, Events Up).
Данные всегда передаются от родительского компонента к дочернему через props. Когда пользователь взаимодействует с дочерним компонентом, он не изменяет данные родителя напрямую, а сообщает о произошедшем событии. Родитель получает это событие и уже сам решает, что делать дальше: изменить состояние, выполнить запрос к серверу, открыть диалог и т.д.
Благодаря этому ответственность компонентов становится более четкой. Родитель владеет данными, а дочерний компонент лишь отображает их и сообщает о действиях пользователя.
Если дочерний компонент начинает самостоятельно изменять состояние родителя, становится сложно понять, где именно меняются данные. По мере роста приложения такие скрытые зависимости делают код трудно сопровождаемым.
Пробрасывание свойств через много уровней
Prop drilling (буквально — «пробрасывание свойств через несколько уровней») — это ситуация, когда данные приходится передавать через цепочку компонентов, хотя промежуточные компоненты сами эти данные не используют.
На небольшом уровне вложенности это вполне нормально. Но по мере роста приложения появляются недостатки:
- изменение интерфейса одного компонента может потребовать изменить несколько промежуточных компонентов;
- становится сложнее понять, какие данные действительно использует каждый компонент;
- снижается переиспользуемость компонентов, поскольку они начинают зависеть от передачи «чужих» данных.
В результате дерево компонентов оказывается сильнее связано между собой.
Во Vue существует несколько способов избежать этой проблемы.
Поднять компонент выше
Иногда проблема говорит о том, что компонент находится не на своем месте. Возможно, его стоит переместить ближе к тому месту, где находятся необходимые данные.
Использовать глобальное состояние
Если данные используются в разных частях приложения, их часто имеет смысл хранить в глобальном хранилище, например в Pinia.
Тогда любой компонент может получить доступ к состоянию напрямую, не передавая его через десятки уровней вложенности.
Использовать provide / inject
Если данные нужны большому количеству вложенных компонентов, можно воспользоваться механизмом provide / inject.
Родитель предоставляет значение: provide(user)
Любой потомок может получить его напрямую: inject(user)
При этом промежуточные компоненты вообще не участвуют в передаче данных.
Иногда это нормально
Если данные проходят через один-два уровня компонентов, это совершенно нормально. Использование provide/inject или глобального хранилища ради того, чтобы избавиться от одной передачи props, может только усложнить архитектуру.
Кстати, ранее здесь уже была статья раскрывающая тему передачи данных на много уровней.
Вместо вывода
Хорошая архитектура редко появляется случайно. Она складывается из множества небольших решений: вовремя вынести компонент, не смешивать ответственность, сделать поток данных очевидным, выделить повторно используемую логику. Каждое такое решение кажется незначительным, но именно они определяют, останется ли проект удобным через год или превратится в набор огромных компонентов, которые страшно открывать.
Не стоит стремиться использовать все рассмотренные подходы одновременно. Достаточно понимать, какие задачи они решают, и применять их там, где они действительно делают код проще. Именно такой подход со временем позволяет создавать приложения, которые легко поддерживать и развивать.