Где должна быть бизнес-логика в React приложении
2 года назад·6 мин. на чтение
В этой статье мы подробно рассмотрим работу с бизнес-логикой в React
Мы уже подробно разбирали масштабируемую структуру React приложения, то, как называть наши файлы, когда использовать хуки для управления побочными эффектами и т.д.:
В этой статье мы подробно рассмотрим работу с бизнес-логикой.
Во многих случаях разработчики пишут бизнес логику прямо в компонентах. Даже опытные разработчики ограничиваются вынесением этих вычислений в кастомные хуки или какие-либо вспомогательные функции. Но все еще это оставляет проблему нерешенной. Дело в том, что даже если у нас есть более мелкие компоненты и логика перемещена в хуки или хэлперы, они буквально разбросаны повсюду неорганизованно. Возьмем, к примеру, приложение онлайн магазина, если мы хотим изменить логику в
Также иногда бывает нужно преобразовать аббревиатуры в текст такие как VIC или NSW, но нам нужно показать их в полном тексте на странице как Victoria или New South Wales.
Единственное, что нужно изменить, это заменить
cart
, скорее всего, нам также придется изменить модули product
и validation
. И нам обычно приходится менять как хэлперы, так и представления (не говоря уже о связанных с ними тестах).
Как обстоят дела в React
Рассмотрим проблему на более высоком уровне. Если вы внимательно посмотрите на React и согласитесь, что он отвечает только за визуальную часть нашего приложения, многие проблемы будут решены автоматически. Независимо от того, используем ли мы традиционные шаблоны MVC/MVP или их вариант MVVM, если React — это V, очевидно, нам нужно что-то еще, чтобы заполнить роль M или VM в приложении. Среди многих проектов я также обнаружил, что многие хорошие практики, которые мы используем в бэкенде, не признаны в мире фронтенда, такие как слоеная структура, паттерны проектирования и т. д. Одна из возможных причин заключается в том, что фронтенд относительно молодой и ему нужно некоторое время, чтобы наверстать упущенное. Например, в типичном приложении Spring MVC у нас были быcontroller
, service
и repository
, и каждый разработчик принимает причину такого разделения: controller
не содержит бизнес-логики, service
не знает, как модель отображается или сериализуется для пользователей, а repository
работает только о доступом к данным. Однако во фронтенд-приложениях на React из-за отсутствия встроенной поддержки (например, отсутствия контроллеров или слоя репозитория) мы вместо этого пишем этот код в компоненты. И это приведет к тому, что бизнес-логика будет повсюду. Итерации станут медленными, а качество кода низким.
Утечка бизнес-логики
Мы можем назвать эту ситуацию утечкой бизнес-логики, имея в виду, что бизнес-логика должна была быть размещена в правильное место, и по какой-то причине была размещена неправильно. Хотя у нас нет подходящего механизма для правильного размещения, в результате бизнес логика написана везде где удобно (в компонентах, хуках и вспомогательных функциях). Сложно уловить такую утечку в коде. Вы должны уделять больше внимания, чтобы увидеть такие ситуации. Вот несколько распространенных симптомов, которые я обнаружил:- Использование преобразователей данных
- x.y.z
- Защитное программирование
Использование преобразователей данных
Эту паттерн легко обнаружить: если вы делаетеmap
для преобразования данных, вы, вероятно, пересекаете два ограниченных контекста (что может привести к утечке логики). Мы все видели или, возможно, писали такой код, как:
В приведенном выше фрагменте то, что возвращает бэкэнд, не совсем соответствует тому, что потребляет UI, поэтому нам нужно преобразовать полученные данные. Мы можем использовать сервис, разработанный другой командой, или использовать сторонний сервис (например, Google Search API). Таким образом, казалось бы, безобидный код нарушил здесь несколько принципов:fetch(`https://example.com/api/addresses`) .then((r) => r.json()) .then((data) => { const addresses = data.map((item: RemoteAddress) => ({ street: item.streetName, address: item.streetAddress, postcode: item.postCode })) setAddresses(addresses) });
- Компонент должен знать тип
RemoteAddress
- Компоненту необходимо определить новый тип
Address
(setAddresses
) data.map
выполняет низкоуровневое сопоставление
Симптом x.y.z (нарушение закона Деметры)
Если вы используете более одного оператора точки.
, вероятно, это означает, что отсутствуют некоторые концепции. person.deliveryAddress
лучше, чем person.primaryAddress.street.streetNumber + person.primaryAddress.suburb
так как первый вариант правильно скрывает детали.
Приведенный ниже код показывает, что ProductDialog
слишком много знает о product
, и как только структура product
изменится, нам придется менять множество мест (тесты и компоненты)
Здесь мы имеем дело с данными, а не с моделью. Таким образом,const ProductDialog = (props) => { const { product } = props; if(product.item.type === 'Portion') { //do something } }
product.isPortion()
будет более значимым, чем проверка необработанных данных.
Защитное программирование
Во многих проектах люди склонны делать слишком много в компоненте, и это создает много шума в коде. Например:Обратите внимание, что мы проверяем на null и предоставляем запасное значение в компоненте. Однако мы должны выполнять этот тип логики в специально отведенном месте.const ProductDetails = (props) => { const { product } = props const { item } = product const { media } = item as MenuItem const title = (media && media.name) || '' const description = (media && media.description) || '' return ( <div> {/* product details */} </div> ) }
Как решить проблему?
На практике мы можем попробовать двухэтапный подход к решению проблемы.- Регулярный рефакторинг
- Создание моделей
Регулярный рефакторинг
Во-первых, мы можем выполнить рефакторинг, как обычно в других случаях, когда мы видим некоторую логику в компонентах React. Например, переместив логику/вычисления из:- Использования преобразователей данных
- x.y.z
- Защитного программирования
const transformAddress: Address = (address: RemoteAddress) => { return ({ street: datum.streetName, address: datum.streetAddress, postcode: datum.postCode }) } //... const addresses = data.map(transformAddress)
Точно так же мы можем использовать функцию, для проверкиconst states = { vic: "Victoria", nsw: "New South Wales", //... }; const transformAddress: Address = (address: RemoteAddress) => { return { street: address.streetName, address: address.streetAddress, postcode: address.postCode, state: states[address.state.toLowerCase()] }; };
title
и description
и вывода запасного значения:
По мере добавления все больше и больше логики, такойconst getTitle = (media) => (media && media.name) || '' const getDescription = (media) => (media && media.description) || ''
transformAddress
и getTitle
, они будут перемещаться в helpers.ts
, в конечном итоге у нас будет огромный файл. Это означает, что он станет нечитаемым и будет иметь высокие затраты на обслуживание. Мы можем разделить файл на модули, но связи между этими функциями могут затруднить их понимание. Это похоже на проблему, с которой мы сталкивались до объектно-ориентированного программирования - у нас слишком много модулей и функций в каждом модуле, и слишком сложно ориентироваться в них. Другими словами, нам нужен лучший способ организации этих вспомогательных функций.
К счастью, нам не нужно изобретать велосипеды. Нам может помочь объектно-ориентированное программирование. Просто используя классы и инкапсуляцию в ООП, мы можем легко сгруппировать эти функции и сделать код намного более читабельным. Чтобы сгруппировать код создадим модели.
Создание моделей
Короче говоря, создание моделей — это объединение данных и поведения, сокрытие деталей и обеспечение общего API для потребителей. Например, мы не должны использоватьproduct.item.type === 'Portion'
, вместо этого мы должны создать класс Product
, и у него есть isPortion
для их потребителей. Это очень распространено в бэкенд-сервисах, но не получило широкого распространения в мире фронтенда.
Причина в том, что, как упоминалось выше, люди упускают из виду, что React отвечает только за визуализацию. И здоровое фронтенд-приложение должно иметь и другие части. Ему нужны модели и логика для взаимодействия с серверной частью, даже для ведения логирования.
Возвращаясь к приведенному выше примеру, определив класс Address
для замены анонимной функции внутри data.map
, мы получим:
Нет никакой разницы в использовании:class Address { constructor(private addr: RemoteAddress) {} get street() { return this.addr.streetAddress; } get postcode() { return this.addr.postcode; } }
const AddressLine = ({ address }: { address: Address }) => ( <li> <div className="result">{address.street}</div> </li> );
transformAddress
на new Address
:
И для частного члена/функции для перевода названия штата:const addresses = data.map((addr: RemoteAddress) => new Address(addr))
Структура теперь намного точнее.private readonly states = { vic: "Victoria", nsw: "New South Wales", //... }; get state() { return this.states[this.addr.state.toLowerCase()]; }
states
теперь является приватным членом класса Address
. Класс хорош тем, что он объединяет всю связанную логику в одну часть, что делает его изолированным и простым в обслуживании.
Размещение всей связанной логики в одном месте имеет и другие преимущества. Во-первых, такое разделение делает тестирование простым и надежным, поскольку компоненты зависят от модели (а не от исходных данных).
Нам не нужно готовить данные с нулевым значением или значения вне границ предусмотренных значений для тестов компонентов. Точно так же модель тестирования больше фокусируется на данных и логике (пустое значение, проверка и запасное значение). Во-вторых, согласованность повышает вероятность его повторного использования в других сценариях. Наконец, если нам нужно переключиться на другую стороннюю службу, нам нужно только изменить модели, и представления могут остаться нетронутыми.
По мере того, как создается все больше и больше моделей, нам может понадобиться целый слой для них. Эта часть кода не знает о существовании компонентов пользовательского интерфейса и связана исключительно с бизнес-логикой.
Итоги
Инкапсуляция бизнес-логики, даже в контексте тонких клиентов, является относительно большой темой. В этой статье мы рассмотрели несколько симптомов утечки бизнес-логики и то, как с ними бороться. Проводя регулярный рефакторинг, мы можем гарантировать, что компоненты отвечают только за рендеринг данных и не должны выполнять какие-либо вычисления или сопоставление данных. Мы должны разделить эту логику на чистые файлы JavaScript (а не jsx/tsx). И с помощью создания моделей мы можем использовать объекты только для того, чтобы скрыть детали доступа к данным. Преимущества этого подхода заключаются в том, что тестирование как модели, так и представлений значительно упрощается, легче отслеживать изменения бизнес-требований и гораздо более простой код в представлениях (поскольку большая часть этого делается в моделях).Структура React приложения
2 года назад·6 мин. на чтение
Как структурировать React приложение и как организовать React приложение.
Не существует единого мнения о том, как правильно организовать React-приложение. React дает вам много свободы, но вместе с этой свободой приходит ответственность за выбор собственной архитектуры. Часто бывает так, что тот, кто создает приложение в самом начале, сваливает почти все в папку
Обратите внимание, что в каталоге
components
. Но есть способ получше. Нужно подходить к организации своих приложений обдуманно, чтобы их было легко использовать, понимать и расширять.
В этой статье рассмотрим вариант структуры Реакт приложения, который интуитивно понятен и может масштабироваться с ростом React приложения. Основная концепция, заключается в том, чтобы сделать архитектуру ориентированной на функциональность, а не на типы. Организовать только общие компоненты на глобальном уровне и собрать в один модуль все остальные связанные сущности вместе.
Технологии
Поскольку эта статья будет носить характер мнения. Сделаем несколько предположений о том, какие технологии будут использоваться в проекте:- Приложение - React (Hooks)
- Глобальное управление состоянием - Redux, Redux Toolkit
- Маршрутизация - React Router
- Стили - CSS, Sass, Styled Components
- Тестирование - Jest, React Testing Library
tests
на верхнем уровне.
Все, что здесь написано, может быть применимо, если вы используете обычный Redux, а не Redux Toolkit. Можно настроить ваш Redux как feature slices.
Также будет удобно визуализировать созданные компоненты с помощью, например, Storybook. Также покажем, как проект будет выглядеть с этими файлами, если вы решите использовать его в своем проекте.
Для примера будем использовать приложение “Библиотека”, который имеет страницу со списком книг, страницу со списком авторов и систему аутентификации.
Структура каталога
Структура каталогов верхнего уровня будет выглядеть следующим образом:assets
- глобальные статические файлы, такие как изображения, svg, логотип компании и т.д.components
- глобальные общие/повторно используемые компоненты, такие как макеты (обертки), компоненты форм, кнопкиservices
- JavaScript модулиstore
- глобальное хранилище Reduxutils
- утилиты, помощники, константы и т.п.views
- можно также назватьpages
, здесь будет содержаться большая часть приложения. Лучше сохранять привычные соглашения, где это возможно, поэтомуsrc
содержит все,index.js
является точкой входа, аApp.js
устанавливает аутентификацию и маршрутизацию.
Также в проекте могут присутствовать некоторые дополнительные папки, которые у вас могут быть, такие как. └── /src ├── /assets ├── /components ├── /services ├── /store ├── /utils ├── /views ├── index.js └── App.js
types
, если это проект TypeScript, middlewares
(промежуточное программное обеспечение), если необходимо, возможно, контекст для работы с Context API и т.д.
Псевдонимы (алиасы)
Полезно настроить систему на использование псевдонимов, чтобы все, что находится в папке компонентов, можно было импортировать как@components
, изображения как @assets
и т. д. Если у вас Webpack, это делается через конфигурацию resolve
.
Это просто облегчает импорт из любого места в проекте и перемещение файлов без изменения импорта, и вы никогда не получите что-то вродеmodule.exports = { resolve: { extensions: ['js', 'ts'], alias: { '@': path.resolve(__dirname, 'src'), '@assets': path.resolve(__dirname, 'src/assets'), '@components': path.resolve(__dirname, 'src/components'), // ...etc }, }, }
../../../../../../../components/
.
Компоненты
В папкеcomponents
компоненты сгруппированы по типам - формы, таблицы, кнопки, макеты и т.д. Специфика зависит от конкретного приложения.
В данном примере предполагается, что вы либо создаете собственную систему форм, либо создаете привязку к существующей системе форм (например, комбинируя Formik
и Material UI
). В этом случае вы создадите папку для каждого компонента (TextField
, Select
, Radio
, Dropdown
и т.д.), а внутри будет файл для самого компонента, стили, тесты и Storybook, если он используется.
Component.js
- собственно компонент ReactComponent.styles.js
- файл стилей для компонентаComponent.test.js
- тестыComponent.stories.js
- файл Storybook.
. └── /src └── /components ├── /forms │ ├── /TextField │ │ ├── TextField.js │ │ ├── TextField.styles.js │ │ ├── TextField.test.js │ │ └── TextField.stories.js │ ├── /Select │ │ ├── Select.js │ │ ├── Select.styles.js │ │ ├── Select.test.js │ │ └── Select.stories.js │ └── index.js ├── /routing │ └── /PrivateRoute │ ├── /PrivateRoute.js │ └── /PrivateRoute.test.js └── /layout └── /navigation └── /NavBar ├── NavBar.js ├── NavBar.styles.js ├── NavBar.test.js └── NavBar.stories.js
components/forms
есть файл index.js
. Часто справедливо советуют избегать использования файлов index.js
, поскольку они не являются явными, но в данном случае это имеет смысл - в конечном итоге он будет индексом всех форм и будет выглядеть примерно так:
Затем, когда вам понадобится использовать один или несколько компонентов, вы можете легко импортировать их все сразу.// src/components/forms/index.js import { TextField } from './TextField/TextField' import { Select } from './Select/Select' import { Radio } from './Radio/Radio' export { TextField, Select, Radio }
Иногда лучше использовать этот подход, чем создаватьimport { TextField, Select, Radio } from '@components/forms'
index.js
внутри каждой папки внутри forms
, так что теперь у вас есть только один index.js
, который фактически индексирует всю директорию, в отличие от десяти файлов index.js
только для того, чтобы упростить импорт для каждого отдельного файла.
Сервисы
Каталогservices
менее важен, чем компоненты, но если вы создаете простой модуль JavaScript, который используется остальной частью приложения, он может быть полезен. Обычный пример - модуль LocalStorage
, который может выглядеть следующим образом:
Пример сервиса:. └── /src └── /services ├── /LocalStorage │ ├── LocalStorage.service.js │ └── LocalStorage.test.js └── index.js
// src/services/LocalStorage/LocalStorage.service.js export const LocalStorage = { get(key) {}, set(key, value) {}, remove(key) {}, clear() {}, }
import { LocalStorage } from '@services' LocalStorage.get('foo')
Хранилище (стор, store)
Глобальное хранилище данных будет содержаться в директорииstore
- в данном случае Redux. Каждая функциональность будет иметь свою папку, в которой будет содержаться слайс Redux Toolkit, а также экшены и тесты. Эта настройка также может быть использована с обычным Redux, вы просто создадите файл .reducers.js
и .actions.js
вместо slice
. Если вы используете саги, это может быть .saga.js
вместо .actions.js
для действий Redux Thunk.
Вы также можете добавить что-то вроде. └── /src ├── /store │ ├── /authentication │ │ ├── /authentication.slice.js │ │ ├── /authentication.actions.js │ │ └── /authentication.test.js │ ├── /authors │ │ ├── /authors.slice.js │ │ ├── /authors.actions.js │ │ └── /authors.test.js │ └── /books │ ├── /books.slice.js │ ├── /books.actions.js │ └── /books.test.js ├── rootReducer.js └── index.js
ui
секции стора для обработки модальных окон, всплывающих уведомлений, переключения боковой панели и других глобальных состояний пользовательского интерфейса, что, лучше, чем иметь const [isOpen, setIsOpen] = useState(false)
повсюду.
В rootReducer вы импортируете все свои фрагменты и объединяете их с помощью combineReducers, а в index.js настраиваете магазин.
Утилиты (Utils)
Нужна ли вашему проекту папкаutils
- решать вам. Но обычно существуют некоторые глобальные функции, такие как валидация и конвертеры, которые могут легко использоваться в различных разделах приложения. Если вы упорядочите их - не будете иметь один файл helpers.js
, содержащий тысячи функций, - она может стать полезным дополнением к организации вашего проекта.
Опять же, папка. └── src └── /utils ├── /constants │ └── countries.constants.js └── /helpers ├── validation.helpers.js ├── currency.helpers.js └── array.helpers.js
utils
может содержать все, что вы захотите, и что, по вашему мнению, имеет смысл держать на глобальном уровне. Если вам не нравятся "многоуровневые" имена файлов, вы можете просто назвать его validation.js
. Однако, это облегчает навигацию по именам файлов при поиске в IDE.
Views
Здесь находится основная часть вашего приложения: в каталогеviews
. Любая страница в вашем приложении - это "представление" (view). В этом небольшом примере представления довольно хорошо согласуются со стором Redux, но не обязательно, что стор и представления будут полностью совпадать, поэтому они разделены. Кроме того, книги могут тянуть за собой авторов, и так далее.
Все, что находится внутри представления, является сущностью, которая, скорее всего, будет использоваться только в этом конкретном представлении - BookForm
, который будет использоваться только на маршруте /books
, и AuthorBlurb
, который будет использоваться только на маршруте /authors
. Это могут быть специфические формы, модальные окна, кнопки, любые компоненты, которые не будут глобальными.
Преимущество хранения всего в домене вместо объединения всех страниц в компоненты/страницы заключается в том, что это позволяет легко взглянуть на структуру приложения и узнать, сколько в нем представлений верхнего уровня, и понять, где находится все, что используется только этим представлением. Если есть вложенные маршруты, вы всегда можете добавить папку вложенных представлений в основной маршрут.
Хранение всего в папках может показаться раздражающим, если вы никогда не создавали свой проект таким образом - вы всегда можете сделать его более плоским или перенести тесты в свой собственный каталог, имитирующий остальную часть приложения.. └── /src └── /views ├── /Authors │ ├── /AuthorsPage │ │ ├── AuthorsPage.js │ │ └── AuthorsPage.test.js │ └── /AuthorBlurb │ ├── /AuthorBlurb.js │ └── /AuthorBlurb.test.js ├── /Books │ ├── /BooksPage │ │ ├── BooksPage.js │ │ └── BooksPage.test.js │ └── /BookForm │ ├── /BookForm.js │ └── /BookForm.test.js └── /Login ├── LoginPage │ ├── LoginPage.styles.js │ ├── LoginPage.js │ └── LoginPage.test.js └── LoginForm ├── LoginForm.js └── LoginForm.test.js