Разделение ответственности в React. Как использовать контейнерные и презентационные компоненты.
2 года назад·6 мин. на чтение
Многие новички в React объединяют логику и представление в одном и том же компоненте. И они могут не знать, почему важно разделять эти две вещи.
В таком случае может обнаружиться, что нужно внести большие изменения в файл. Затем придется вносить много переделок, чтобы разделить логику и представление.
Это происходит из-за того, что разработчик может не знать о разделении ответственности и таком шаблоне как презентационные и контейнерные компоненты (presentational and container components). В этой статье рассмотрим этот паттерн, чтобы смягчить эту проблему на ранних этапах жизненного цикла разработки проекта.
Чтобы решить эту проблему и придерживаться разделения ответственности, мы должны разделить эти две части — то есть запрос данных и их представление в пользовательском интерфейсе — на два разных компонента.
Шаблон контейнеры и презентационные компоненты (smart/dummy components) поможет нам решить эту проблему.
Вот что делает этот компонент:
Ниже приведены некоторые причины, по которым нам требуются контейнерные и презентационные компоненты:
Компонент
Файл с типами.
Компонент
Компонент
Теперь мы можем просто удалить компонент-контейнер
Что такое разделение ответственности?
Разделение ответственности — это концепция, которая широко используется в программировании. В нем говорится, что логика, выполняющая разные действия, не должна группироваться или объединяться вместе. Например, то, что мы обсуждали во вводной части, нарушает разделение задач, потому что мы поместили логику выборки данных и представления данных в один и тот же компонент.Что такое контейнерные и презентационные компоненты?
Контейнерные компоненты
Это компоненты, которые предоставляют, создают или хранят данные для дочерних компонентов. Единственная работа компонента-контейнера — обработка данных. Он не состоит из собственного пользовательского интерфейса. Скорее, он состоит из презентационных компонентов в качестве своих дочерних элементов, которые используют эти данные. Простым примером может быть компонент с именемFetchUserContainer
, который состоит из некоторой логики, которая извлекает данные всех пользователей.
Презентационные компоненты
Это компоненты, основной обязанностью которых является представление данных в пользовательском интерфейсе. Они принимают данные из компонентов контейнера. Эти компоненты не имеют состояния, если только им не требуется собственное состояние для отображения пользовательского интерфейса. Они не изменяют данные, которые они получают. Примером этого может быть компонентUserList
, который просто отображает всех пользователей.
Зачем нам нужны эти компоненты?
Чтобы понять это, возьмем простой пример. Мы хотим отобразить список сообщений, которые мы получаем из JSON placeholder API.// DisplayPosts.tsx import { useEffect, useState } from "react"; interface ISinglePost { userId: number; id: number; title: string; body: string; } /* Пример того как НЕ нужно объединять логику и отображение */ export default function DisplayPosts() { const [posts, setPosts] = useState<ISinglePost[] | null>(null); const [isLoading, setIsLoading] = useState<Boolean>(false); const [error, setError] = useState<unknown>(); // Логика useEffect(() => { (async () => { try { setIsLoading(true); const resp = await fetch("https://jsonplaceholder.typicode.com/posts"); const data = await resp.json(); setPosts(data); setIsLoading(false); } catch (err) { setError(err); setIsLoading(false); } })(); }, []); // Представление return isLoading ? ( <span>Loading... </span> ) : posts ? ( <ul> {posts.map((post: ISinglePost) => ( <li key={`item-${post.id}`}> <span>{post.title}</span> </li> ))} </ul> ) : ( <span>{JSON.stringify(error)}</span> ); }
- Он имеет 3 переменные состояния:
posts
,isLoading
иerror
. - У нас есть хук
useEffect
, который состоит из бизнес-логики. Здесь мы извлекаем данные из jsonplaceholder API с помощьюfetch
. - Когда данные извлекаются, мы сохраняем их в переменной состояния
posts
, используяsetPosts
. - Мы также гарантируем, что переключаем значения
isLoading
иerror
во время соответствующих сценариев. - Мы поместили всю эту логику в асинхронную функцию.
- Возвращаем посты в виде списка и отображаем их.
- Они помогают нам создавать слабосвязанные компоненты.
- Они помогают нам поддерживать разделение ответственности.
- Рефакторинг кода становится намного проще.
- Код становится более организованным и удобным в сопровождении
- Это значительно упрощает тестирование.
Пример компонента-представления и контейнера
Для примера будем использовать тот же пример, что и выше — получение данных из JSON placeholder API. Разберемся со структурой файлов. Нашим контейнерным компонентом будетPostContainer
. У нас будет два презентационных компонента:
Posts
: компонент с неупорядоченным списком.SinglePost
: компонент, отображающий элемент списка.
components
. Теперь, когда мы знаем, что куда помещать, давайте начнем с компонента-контейнера: PostContainer
.
Компонент PostContainer
// components/PostContainer.tsx import { useEffect, useState } from "react"; import { ISinglePost } from "../Definitions"; import Posts from "./Posts"; export default function PostContainer() { const [posts, setPosts] = useState<ISinglePost[] | null>(null); const [isLoading, setIsLoading] = useState<Boolean>(false); const [error, setError] = useState<unknown>(); useEffect(() => { (async () => { try { setIsLoading(true); const resp = await fetch("https://jsonplaceholder.typicode.com/posts"); const data = await resp.json(); setPosts(data.filter((post: ISinglePost) => post.userId === 1)); setIsLoading(false); } catch (err) { setError(err); setIsLoading(false); } })(); }, []); return isLoading ? ( <span>Loading... </span> ) : posts ? ( <Posts posts={posts} /> ) : ( <span>{JSON.stringify(error)}</span> ); }
Приведенный выше код просто содержит логику выборки данных. Эта логика присутствует в хукеcomponents/Definitions.ts export interface SinglePost { userId: number; id: number; title: string; body: string; }
useEffect
. Этот компонент-контейнер передает эти данные презентационному компоненту Posts
.
Давайте взглянем на презентационный компонент Posts
.
Компонент Posts
Как видите, это простой файл, состоящий из тега// components/Posts.tsx import { ISinglePost } from "../Definitions"; import SinglePost from "./SinglePost"; export default function Posts(props: { posts: ISinglePost[] }) { return ( <ul style={{ display: "flex", flexDirection: "column", alignItems: "center" }} > {props.posts.map((post: ISinglePost) => ( <SinglePost {...post} /> ))} </ul> ); }
ul
— неупорядоченного списка. Затем этот компонент отображает посты, которые передаются в качестве пропса. Мы передаем каждый объект поста в компонент SinglePost
.
Существует еще один презентационный компонент, который отображает элемент списка, это тег li
. Он отображает заголовок и тело сообщения.
Компонент SinglePost
Эти презентационные компоненты просто отображают данные на экране. Вот и все. Они не делают ничего другого. Поскольку здесь они просто отображают данные, они также будут иметь собственные стили. Теперь, когда мы настроили компоненты, давайте посмотрим, что удалось достичь:// components/SinglePost.tsx import { ISinglePost } from "../Definitions"; export default function SinglePost(props: ISinglePost) { const { userId, id, title, body } = props; return ( <li key={`item-${userId}-${id}`} style={{ width: 400 }}> <h4> <strong>{title}</strong> </h4> <span>{body}</span> </li> ); }
- Концепция разделения ответственности в этом примере не нарушается.
- Написание модульных тестов для каждого компонента становится проще.
- Сопровождаемость и читабельность кода намного лучше. Таким образом, наша кодовая база стала намного более организованной.
Как заменить контейнерные компоненты на React хуки
Начиная с React 16.8 стало намного проще создавать и разрабатывать компоненты с помощью функциональных компонентов и хуков. Здесь мы воспользуемся этими возможностями и заменим компонент-контейнер хуком.Что дает это улучшение:// hooks/usePosts.ts import { useEffect, useState } from "react"; import { ISinglePost } from "../Definitions"; export default function usePosts() { const [posts, setPosts] = useState<ISinglePost[] | null>(null); const [isLoading, setIsLoading] = useState<Boolean>(false); const [error, setError] = useState<unknown>(); useEffect(() => { (async () => { try { setIsLoading(true); const resp = await fetch("https://jsonplaceholder.typicode.com/posts"); const data = await resp.json(); setPosts(data.filter((post: ISinglePost) => post.userId === 1)); setIsLoading(false); } catch (err) { setError(err); setIsLoading(false); } })(); }, []); return { isLoading, posts, error }; }
- Извлечена логика, которая присутствовала в компоненте
PostContainer
, в хук. - Этот хук вернет объект, содержащий значения
isLoading
,posts
иerror
.
PostContainer
. Затем, вместо того, чтобы передавать данные контейнера презентационным компонентам в качестве пропса, мы можем напрямую использовать этот хук внутри презентационного компонента Posts
.
Внесем следующие изменения в компонент Posts
.
Используя хуки, мы устранили дополнительный слой компонента, который присутствовал поверх этих презентационных компонентов. С хуками мы достигли тех же результатов, что и с шаблоном контейнерные/презентационные компоненты.// components/Posts.tsx import { ISinglePost } from "../Definitions"; import usePosts from "../hooks/usePosts"; import SinglePost from "./SinglePost"; export default function Posts(props: { posts: ISinglePost[] }) { const { isLoading, posts, error } = usePosts(); return ( <ul style={{ display: "flex", flexDirection: "column", alignItems: "center" }} > {isLoading ? ( <span>Loading...</span> ) : posts ? ( posts.map((post: ISinglePost) => <SinglePost {...post} />) ) : ( <span>{JSON.stringify(error)}</span> )} </ul> ); }
Итоги
Итак, в этой статье мы рассмотрели:- Разделение ответственности.
- Контейнерные и презентационные компоненты
- Зачем нам нужны эти компоненты
- Как хуки могут заменить компоненты-контейнеры
Где должна быть бизнес-логика в React приложении
год назад·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
. Класс хорош тем, что он объединяет всю связанную логику в одну часть, что делает его изолированным и простым в обслуживании.
Размещение всей связанной логики в одном месте имеет и другие преимущества. Во-первых, такое разделение делает тестирование простым и надежным, поскольку компоненты зависят от модели (а не от исходных данных).
Нам не нужно готовить данные с нулевым значением или значения вне границ предусмотренных значений для тестов компонентов. Точно так же модель тестирования больше фокусируется на данных и логике (пустое значение, проверка и запасное значение). Во-вторых, согласованность повышает вероятность его повторного использования в других сценариях. Наконец, если нам нужно переключиться на другую стороннюю службу, нам нужно только изменить модели, и представления могут остаться нетронутыми.
По мере того, как создается все больше и больше моделей, нам может понадобиться целый слой для них. Эта часть кода не знает о существовании компонентов пользовательского интерфейса и связана исключительно с бизнес-логикой.