Применение шаблонов проектирования во Vue.js: шаблон Стратегия
Эта статья посвящена проблеме, с которой многие из нас сталкиваются при разработке фронтенд приложений (иногда даже не осознавая, что это проблема): разбросанные элементы логики, реализованные в различных компонентах, хуках, утилитах и т. д.
Давайте углубимся в детали проблемы и способы ее решения. Как следует из названия, для ее решения мы будем использовать шаблон стратегии.
Проблема: хирургия дробовиком (Shotgun Surgery)
Shotgun Surgery — это такой подход к архитектуре кода, в котором любые модификации требуют внесения множества мелких изменений во многих разных местах.
![Shotgun Surgery Code Smell](https://res.cloudinary.com/practicaldev/image/fetch/s--JJPN-RJ3--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://refactoring.guru/images/refactoring/content/smells/shotgun-surgery-01-2x.png)
(image source: https://refactoring.guru/smells/shotgun-surgery)
Как это может происходить в проекте? Давайте представим, что нам нужно создать ценовые карты (pricing cards) для продукта, и нам нужно будет менять цену, валюту, стратегию скидок и сообщения в зависимости от того, откуда пришел клиент:
![Pricing Cards](https://res.cloudinary.com/practicaldev/image/fetch/s--FL8Lun6j--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5iz9oso7ozaqm0qibd76.png)
Большинство из нас, вероятно, реализовали бы ценовую карту следующим образом:
- Создали компоненты:
PricingCard
,PricingHeader
,PricingBody
. - Добавили бы вспомогательные функции:
getDiscountMessage
(in utils/discount.js),formatPriceByCurrency
(in utils/price.js). - Добавили бы компонент
PricingBody
которые высчитывает конечную цену.
Структура проекта могла бы выглядеть так
... components PricingCard.vue PricingCardBody.vue PricingCardHeader.vue utils discount.js price.js types.js App.vue
Архив с полной версией проекта на Vue.js с Composition API
Теперь давайте представим, что нам нужно изменить тарифный план для определенный страны или добавить новый тарифный план для другой страны. Что вам придется делать с приведенной выше реализацией? Вам нужно будет изменить по крайней мере 3 места и добавить больше условных выражений в и без того запутанные блоки if-else:
- Изменить компонент
PricingBody
. - Изменить функцию
getDiscountMessage
. - Изменить функцию
formatPriceByCurrency
.
Если вы уже слышали о S.O.L.I.D, мы уже нарушаем первые два принципа: принцип единой ответственности (The Single Responsibility Principle) и принцип открытости-закрытости (The Open-Closed Principle).
Решение: Шаблон стратегии
Шаблон стратегии довольно прост. Мы можем просто представить, что каждый из наших тарифных планов для стран — это стратегия. И в этом классе стратегии мы реализуем всю связанную логику для этой стратегии.
Предположим, вы знакомы с ООП, у нас может быть абстрактный класс (PriceStrategy), который реализует общую/общую логику, а затем стратегия с другой логикой будет наследовать этот абстрактный класс.
Поменять структуру проекта на:
... components PricingCard.vue PricingCardBody.vue PricingCardHeader.vue services priceStrategies PriceStrategy.js JapanPriceStrategy.js StandardPriceStrategy.js VietnamPriceStrategy.js utils discount.js price.js types.js App.vue
Абстрактный класс PriceStrategy будет выглядит следующим образом:
import { Country, Currency } from '../../utils/types.js'; class PriceStrategy { country = Country.AMERICA; currency = Currency.USD; discountRatio = 0; getCountry() { return this.country; } formatPrice(price) { return [this.currency, price.toLocaleString()].join(''); } getDiscountAmount(price) { return price * this.discountRatio; } getFinalPrice(price) { return price - this.getDiscountAmount(price); } shouldDiscount() { return this.discountRatio > 0; } getDiscountMessage(price) { const formattedDiscountAmount = this.formatPrice( this.getDiscountAmount(price) ); return `It's lucky that you come from ${this.country}, because we're running a program that discounts the price by ${formattedDiscountAmount}.`; } } export default PriceStrategy;
И мы просто передаем созданную стратегию в качестве prop компоненту PricingCard:
<PricingCardHeader /> <div class="divider" /> <PricingCardBody :price="props.price" :strategy="props.strategy" />
Опять же, если вы знакомы с ООП, мы используем здесь не только наследование, но и полиморфизм.
Полная реализация решения:
И давайте снова зададим тот же вопрос: как добавить новый тарифный план для новой страны? В этом решении нам просто нужно добавить новый класс стратегии, и нам не нужно изменять какой-либо существующий код. Поступая таким образом, мы также удовлетворяем S.O.L.I.D.
Заключение
Итак, обнаружив архитектуру проекта по типу — Shotgun Surgery — в нашей кодовой базе, мы применили шаблон проектирования — Strategy Pattern — для его решения. Наша структура кода исходила из этого:
![Before - Shotgun Surgery](https://res.cloudinary.com/practicaldev/image/fetch/s--lReSIAq1--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7wn31uolfo6xfy3mh8fo.png)
и была преобразована в:
![After - Strategy Pattern](https://res.cloudinary.com/practicaldev/image/fetch/s--0cxWn-uO--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xheeb2splcwj2kkqwsfo.png)
Теперь наша логика живет в одном месте и больше не разбросана по многим местам.