Улучшаем дизайн React приложения с помощью Compound components

от автора

Сегодня я хочу рассказать про один не очень популярный но очень классный паттерн в написании React приложений — Compound components.

Что это вообще такое

Compound components это подход, в котором вы объединяете несколько компонентов одной общей сущностью и общим состоянием. Отдельно от этой сущности вы их использовать не можете, тк они являются единым целым. Это как в BEM нельзя использовать E — элемент, отдельно от B — блока.

Самый наглядный пример такого подхода, который знают все фронты — это select с его option в обычном HTML.

<select name="meals">   <option value="pizza">Pizza</option>   <option value="pasta">Pasta</option>   <option value="borsch">Borsch</option>   <option value="fries">Fries</option> </select>

В «сложном компоненте» может быть сколько угодно разных элементов и они могут быть использованы в любом порядке, но все равно их будет объединять одно поведение и одно состояние.

Когда вам нужно задуматься об использовании Compound components

Я могу выделить 2 ситуации, где этот подход отлично работает:

Когда у вас есть несколько отдельных компонентов, но они являются частью чего-то одного и объединены одной логикой (как select в HTML).

Например вам нужно сделать табуляцию, ясное дело, что по отдельности каждый таб вы использовать не будете и вот тут хорошо подойдет Compound components.

import React from 'react';  import { Tabs } from 'tabs';  function MyTabs() {     return (         <Tabs onChange={()=> console.log('Tab is changed')}>             <Tabs.Tab>Pie</Tabs.Tab>             <Tabs.Tab className="custom-tab">Cake</Tabs.Tab>             <Tabs.Tab disabled={true} >Candies</Tabs.Tab>             <Tabs.Tab>Cookies</Tabs.Tab>         </Tabs>     ); }  export default MyTabs;

По моему выглядит весьма лаконично, понятно и по реактовски) У нас есть возможность кастомизировать каждый отдельный таб, передать ему любые пропсы, а так же задать какие-то параметры для всех табов сразу, ну и внутри компонента Tabs может быть написана какая-то общая логика. 

Сравните с тем, как это могло бы выглядеть без Compound components:

import React from 'react';  import { Tabs } from 'TabsWithoutCC';  function MyTabs() {     return (         <Tabs             onChange={()=> console.log('Tab is changed')}             tabs={[                 { name: "Pie" },                 { name: "Cake", className: 'custom-tab' },                 { name: "Candies", disabled: true },                 { name: "Cookies" }             ]}         />     ); }  export default MyTabs;

А вот во втором варианте применения, как мне кажется, раскрывается вся мощь Compound Components.

Приведу пример из жизни: я делал форму аутентификации пользователя в банке, стандартно она должна выглядеть примерно так: есть поле ввода логина, пароля, у них должен быть тайтл, кнопка «войти», и нужно задать темную тему для всех компонентов, использовать эту форму будут на десктопах и в мобильном приложении через web-view

import React from 'react';  import { Form, Input, Button, Title } from 'our-design-system';  function AuthForm({ theme }) {     return (         <div>             <Form theme={ theme }>       <div> <Title theme={ theme }>Логин</Title>       <Input theme={ theme } placeholder="Введите логин" type="text"/>       <div>       <div> <Title theme={ theme }>Пароль</Title>                 <Input theme={ theme } placeholder="Введите пароль" type="password"/>       <div>                 <Button theme={ theme } type="submit">Войти</Button>             </Form>         </div>     ); }  export default AuthForm;

Но помимо аутентификации по логину/паролю должна быть еще возможность залогиниться по номеру карты или по номеру счета. Что делать? Ну наверно добавить условие, в котором мы проверяем тип аутентификации:

import React from 'react';  import { Form, Input, Button, Title } from 'our-design-system';  function AuthForm({ isAccountAuth, theme }) {     return (         <div>             <Form theme={ theme }>                 isAccountAuth ? ( <div>                       <Title theme={ theme }>Номер карты или счета</Title> <Input theme={ theme } placeholder="Введите номер карты или счета" type="number"/>                     <div>                 ) : (                     <div>                       <Title theme={ theme }>Логин</Title>                       <Input theme={ theme } placeholder="Введите логин" type="text"/>                     <div>                     <div>                       <Title theme={ theme }>Пароль</Title>                       <Input theme={ theme } placeholder="Введите пароль" type="password"/>                     <div>                 )                 <Button theme={ theme } type="submit">Войти</Button>             </Form>         </div>     ); }  export default AuthForm;

Потом приходит бизнес и говорит, что в мобильном приложении поле ввода карты или счета должно отображаться без тайтла и выглядеть как банковская карта (слава богу компонент поля ввода в виде карты верстать не надо, он есть в библиотеке компонентов, но еще одно условие добавить придется).

import React from 'react';  import { Form, Input, Button, CardInput, Title } from 'our-design-system';  function AuthForm({ isAccountAuth, isWebview, theme }) {     return (         <div>             <Form theme={ theme }>                 { isAccountAuth && !isWebview && (                  <div>                       <Title theme={ theme }>Номер карты или счета</Title> <Input theme={ theme } placeholder="Введите номер карты или счета" type="number"/>                     <div>                 ) }                  { isAccountAuth && isWebview && <CardInput theme={ theme } placeholder="Введите номер карты или счета"/> }                  { !isAccountAuth && (                     <div>                       <Title theme={ theme }>Логин</Title>                       <Input theme={ theme } placeholder="Введите логин" type="text"/>                     <div>                     <div>                       <Title theme={ theme }>Пароль</Title>                       <Input theme={ theme } placeholder="Введите пароль" type="password"/>                     <div>                 )}                 <Button theme={ theme } type="submit">Войти</Button>             </Form>         </div>     ); }  export default AuthForm; 

Заметили что при каждом новом условии у нас появляются пропсы типа: isAccountAuth, isWebview. И это далеко не последнее, что нужно было учесть для каждого отдельного случая, я видел и побольше подобных «условных» пропсов. В общем суть я думаю вы поняли, наш компонент раздувается и обрастает кучей условий, код становится очень сложно читать и добавление чего-то нового причиняет боль и страдания (вам может показаться что мол норм читается, не так много кода, но тут я практически не передавал никаких пропсов, не использовал селекторы, не диспатчил ничего, тут нет никаких методов, которые кстати для каждого случая разные, в общем поверьте мне, полностью рабочий  продовский компонент выглядит устрашающе).

Думаю уже пришло время показать, как вообще реализовать Compound Component, давайте сделаем это на примере нашей формы:

import React from 'react';  import { Form, Input, Button, Title, CardInput } from 'our-design-system';  const AuthFormContext = React.createContext(undefined);  function AuthForm(props) {     const { theme } = props;     const memoizedContextValue = React.useMemo(         () => ({             theme,         }),         [theme],     );      return (         <AuthFormContext.Provider value={ memoizedContextValue }>             <Form>                 { props.children }             </Form>         </AuthFormContext.Provider>     ); }  function useAuthForm() {     const context = React.useContext(AuthFormContext);      if (!context) {         throw new Error('This component must be used within a <AuthForm> component.');     }      return context; }  AuthForm.Input = function FormInput(props) {     const { theme } = useAuthForm();     return <Input theme={theme} {...props} /> }; AuthForm.CardInput = function FormCardInput(props) {     const { theme } = useAuthForm();     return <CardInput theme={theme} {...props} /> }; AuthForm.Field = function Field({ children, title }) {     const { theme } = useAuthForm();     return (         <div>             <Title theme={ theme }>{ title }</Title>             { children }         </div>     ) }; AuthForm.SubmitButton = function SubmitButton(props) {     const { theme } = useAuthForm();     return <Button theme={theme} {...props} type="submit" /> };   export default AuthForm; 

Я все написал в одном файле, но вам ничего не мешает вынести каждый внутренний компонент в отдельный файл.

Давайте разберемся, что тут происходит. 

Во первых стоит отметить, что изначально в Compound Components задумывалось, что состояние всего компонента прокидывается через пропсы каждому внутреннему, но в данном варианте я показал, как это можно сделать через контекст. Причина проста, мы можем создавать любой уровень вложенности компонентов и у самых нижних все равно будет доступ к состоянию.

В нашей ситуации важно иметь возможность пробрасывать состояние на любой уровень вложенности, тк я написал компонент AuthForm.Field , который просто отрендерит любой компонент, переданный ему в качестве ребенка и добавит ему тайтл. Им мы будем оборачивать наши поля ввода.

Так вот, для того чтобы дети имели доступ к контексту, я написал кастомный хук useAuthForm.

Теперь тема, которую мы передаем в AuthForm пробрасывается каждому элементу нашего Compound компонента через контекст.

Чтобы у нас не происходило лишних ререндеров, мы используем useMemo для создания контекста.

А теперь давайте попробуем воспользоваться нашим компонентом.

Так он будет выглядеть там, где нужна аутентификация по логину/паролю:

import React from 'react';  import AuthForm from "./compound-form";  export default function LoginAuth() {     return (         <AuthForm theme={'dark'}>             <AuthForm.Field title="Логин">                 <AuthForm.Input type="text" placeholder="Введите логин" />             </AuthForm.Field>             <AuthForm.Field title="Пароль">                 <AuthForm.Input placeholder="Введите пароль" type="password" />             </AuthForm.Field>             <AuthForm.SubmitButton />         </AuthForm>     ) } 

Так, там где вход по карте и счету для десктопа:

import React from 'react';  import AuthForm from "./compound-form";  export default function AccountAuth() {     return (         <AuthForm theme={'dark'}>             <AuthForm.Field title="Номер карты или счета">                 <AuthForm.Input                     type="text"                     placeholder="Введите номер карты или счета"                 />             </AuthForm.Field>             <AuthForm.SubmitButton />         </AuthForm>     ) }

Так, там где вход по карте и счету для мобилы:

import React from 'react';  import AuthForm from "./compound-form";  export default function AccountAuth() {     return (         <AuthForm theme={'dark'}>             <AuthForm.CardInput                 type="text"                 placeholder="Введите номер карты или счета"             />             <AuthForm.SubmitButton />         </AuthForm>     ) }

В этом примере хорошо видно, что Compound Components превращает React компонент в конструктор с единой логикой, но части этого компонента можно использовать в любом порядке или не использовать вообще. А при добавлении какой-то новой бизнес логики нам не нужно вносить изменения в уже написанный код, мы просто добавляем новый подкомпонент.


Давайте сюда же добавлю довольно распространенный пример для Compound Components, где с его помощью можно написать аккордеон:

import React, {   createContext,   useContext,   useState,   useCallback,   useMemo } from "react"; import styled from "styled-components"; import { Icon } from "semantic-ui-react";  const StyledAccordion = styled.div`   border: solid 1px black;   border-radius: 4px;   margin: 10px; `;  const StyledAccordionItem = styled.button`   align-items: center;   background: none;   border: none;   display: flex;   font-weight: normal;   font-size: 1em;   justify-content: space-between;   padding: 10px;   text-align: left;   width: 100%;    &:focus {     box-shadow: 0 0 2px 1px black;   } `;  const Item = styled.div`   border-top: 1px solid black;    &:first-child {     border-top: 0;     border-top-left-radius: 4px;     border-top-right-radius: 4px;   }    &:last-child {     border-bottom-left-radius: 4px;     border-bottom-right-radius: 4px;   }    &:nth-child(odd) {     background-color: ${({ striped }) => (striped ? "#F0F0F0" : "transparent")};   } `;  const ExpandableSection = styled.section`   background: #e8f4f8;   border-top: solid 1px black;   padding: 10px;   padding-left: 20px; `;  const AccordionContext = createContext();  function useAccordionContext() {   const context = useContext(AccordionContext);   if (!context) {     // Error message should be more descriptive     throw new Error("No context found for Accordion");   }   return context; }  function Accordion({ children, defaultExpanded = "wine", striped = true }) {   const [activeItem, setActiveItem] = useState(defaultExpanded);   const setToggle = useCallback(     (value) => {       setActiveItem(() => {         if (activeItem !== value) return value;         return "";       });     },     [setActiveItem, activeItem]   );    const value = useMemo(     () => ({       activeItem,       setToggle,       defaultExpanded,       striped     }),     [setToggle, activeItem, striped, defaultExpanded]   );    return (     <AccordionContext.Provider value={value}>       <StyledAccordion>{children}</StyledAccordion>     </AccordionContext.Provider>   ); }  function ChevronComponent({ isExpanded }) {   return isExpanded ? <Icon name="chevron up" /> : <Icon name="chevron down" />; }  Accordion.Item = function AccordionItem({ value, children }) {   const { activeItem, setToggle, striped } = useAccordionContext();    return (     <Item striped={striped}>       <StyledAccordionItem         aria-controls={`${value}-panel`}         aria-disabled="false"         aria-expanded={value === activeItem}         id={`${value}-header`}         onClick={() => setToggle(value)}         selected={value === activeItem}         type="button"         value={value}       >         {children}         <ChevronComponent isExpanded={activeItem === value} />       </StyledAccordionItem>       <ExpandableSection         aria-hidden={activeItem !== value}         aria-labelledby={`${value}-header`}         expanded         hidden={activeItem !== value}         id={`${value}-panel`}       >         Showing expanded content about {value}       </ExpandableSection>     </Item>   ); }  export { Accordion }; 

И вот как он используется:

import React from "react"; import { Accordion } from "./Accordion"; import "./styles.css";  export default function App() {   return (     <div className="App">       <Accordion defaultExpanded="beer" striped>         <Accordion.Item value="cider">Cider</Accordion.Item>         <Accordion.Item value="beer">Beer</Accordion.Item>         <Accordion.Item value="wine">Wine</Accordion.Item>         <Accordion.Item value="milk">Milk</Accordion.Item>         <Accordion.Item value="patron">Café Patron</Accordion.Item>       </Accordion>     </div>   ); }

Подытожим

Паттерн Compound Components хорошо подходит, если вы делаете какую-то единую структуру, части которой хотелось бы сделать как отдельные компоненты, но в отрыве от этой структуры они использоваться не будут.

Так же, если вы видите, что у вашего компонента появляется куча пропсов типа: hasЧтоТоОдно=true, withЧтоТоДругое=true, showЧтоТоТретье=true, а внутри компонента появляется миллион условий, что рендерить а что не рендерить, то это явный знак, что стоит использовать Compound Components.

Это все что я хотел рассказать:) если у вас есть какие-то вопросы, примеры или вы считаете что я не прав, пишите, буду рад ответить, обсудить, поправить.


ссылка на оригинал статьи https://habr.com/ru/company/alfa/blog/647013/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *