React-паттерн «Составной компонент» (compound component)
Разработка компонента для дизайн системы сильно отличается от разработки обычного компонента внутри web-приложения.
Во-первых: компонент должен быть гибким и покрывать как можно больше вариантов возможного использования.
Во-вторых: изменение компонента с большим количеством пользователей влечёт за собой неотвратимые размышления об обратной совместимости этих изменений.
В третьих: даже незначительное изменение компонента запускает дорогой и долгий релизный цикл, который включает в себя: создание тестов, описание изменений в системе документации, деплой изменений и другие прелести продуктовой разработки.
Чтобы доставка изменений была быстрой, безопасной и дешёвой, мы должны подумать об архитектуре компонента.
С этой задачей нам поможет паттерн «Составной Компонент».
Свойства компонента
Использование паттерна возможно только при наличии определенных свойств у компонента:
- У компонента должно быть общее внутреннее состояние, например: открыть/закрыть компонент, выбрать вид отображения и т.п.
- У компонента должна быть логика влияющая на поведение дочерних компонентов, например: выбрать активный элемент, раскрыть элементы, добавить элемент в список и т.п.
Реализация компонента
Для сравнения реализуем две версии компонента Аккордеон:
Далее попробуем расширить API компонентов, чтобы затем оценим сложность внесения изменений.
Обычный компонент
Реализуем обычный React-компонент с использованием props
:
interface Props { items: AccordionItemProps[] } interface ItemProps { title: React.ReactNode content: React.ReactNode } const Accordion = ({ items }: Props) => { const [active, setActive] = useState(0) const onClick = (index: number) => setActive(active === index ? -1 : index) return <div> {items.map(({ title, content }, index) => <div key={index}> <div onClick={() => onClick(index)}>{title}</div> <div>{active === index && content}</div> </div> )} </div> }
const items = [ { title: "Title 1", content: "1️⃣" }, { title: "Title 2", content: "2️⃣" }, { title: "Title 3", content: "3️⃣" }, ] const App = () => <Accordion items={items} />
Итог: получился классический глупый компонент, возможности которого ограничиваются обычным рендером данных.
Составной компонент
Теперь реализуем компонент на основе паттерна «Составной Компонент» подробно разобрав его архитектуру:
Главный компонент
- Управляет состоянием
- Выступает контроллером для дочерних компонентов обеспечивая их согласованную работу
- Использует
React Context API
для передачи общего состояния дочерним компонентам (во избежаниеprops-drilling
)
interface AccordionProps { children: React.ReactNode } interface AccordionContext { active: number toggle: (key: number) => void } const Ctx = createContext() const Accordion = (props: AccordionProps) => { const { children } = props // Управляем состоянием const [active, setActive] = useState(0) const toggle = (index) => { setActive(index === index ? -1 : index) } const ctx: AccordionContext = { active, toggle } // Передаем контекст return ( <Ctx.Provider value={ctx}> {Children.map(children, (child, index) => cloneElement(child, { index }) )} </Ctx.Provider> ) }
Дочерние компоненты
- Реагируют на изменение props (глупые компоненты)
- Берут логику, состояние и методы его для изменения из главного компонента через
React Context API
interface AccordionItem { index: number children: React.ReactNode } Accordion.Item = ({ index, children }: AccordionItem) => { return Children.map(children, (child) => cloneElement(child, { index }) ) }
Accordion.Header = ({ index, children }: AccordionItem) => { const { active, toggle } = useContext<AccordionContext>(Ctx) return <div onClick={() => toggle(index)}> {children} </div> }
Accordion.Content = ({ index, children }: AccordionItem) => { const { active } = useContext<AccordionContext>(Ctx) const isOpen = active === index return isOpen && <div>{children}</div> }
const items = [ { title: "Title 1", content: "1️⃣" }, { title: "Title 2", content: "2️⃣" }, { title: "Title 3", content: "3️⃣" }, ] <Accordion> {items.map(({ title, content }, index) => ( <Accordion.Item key={index}> <Accordion.Header>{title}</Accordion.Header> <Accordion.Content>{content}</Accordion.Content> </Item> ))} </Accordion>
Внесение изменений
Давайте внесём изменения в компонент:
Добавим кнопку «Закрыть»
const Accordion = ({ items, onClose }: Props) => { const [active, setActive] = useState(0) const onClick = (index) => setActive(active === index ? -1 : index) return <div> {items.map(({ title, content }, index) => <div key={index}> <div onClick={() => onClick(index)}>{title}</div> <div>{active === key && content}</div> <div onClick={() => toggle(-1)} onClose={onClose}>Закрыть</div> </div> )} </div> }
- Местоположение компонента жестко закреплено
- Изменить лэйбл и навесить хендлер можно только через
props
Accordion.Close = function ({ children, ...rest }: AccordionItem) { const { toggle } = useContext<AccordionContext>(Ctx) return ( <div onClick={() => toggle(-1)} {...rest}> {children} </div> ) }
const { Item, Header, Content, Close } = Accordion <Accordion> {items.map(({ title, content }, index) => ( <Item key={index}> <Header> {title} <Close onClick={() => console.log("was closed")}>×<Close/> </Header> <Content>{content}</Content> </Item> ))} </Accordion>
- Компонент может быть свободно расположен внутри
Accordion.Item
- На компонент можно навесить любой хэндлер без изменения
props
Добавим элемент «Разделитель»
Попробуем изменить вёрстку — добавим разделитель и поменяем местами заголовок и контент:
const Accordion = ({ items }: Props) => { const [active, setActive] = useState(0) const onClick = (index) => setActive(active === index ? -1 : index) return <div> {items.map(({ title, content }, index) => <div key={index}> <div>{active === index && content}</div> <hr /> <div onClick={() => onClick(key)}>{title}</div> </div> )} </div> }
const { Item, Header, Content, Close } = Accordion <Accordion> {items.map(({ title, content }, index) => ( <Item key={index}> <Content>{content}</Content> <hr /> <Header>{title}</Header> </Item> ))} </Accordion>
Недостатки
Тришейкинг
При включенном тришейкинге, в случае если компонент не используется, сборщик не добавляет компонент в исходный бандл. Но, как только компонент становится свойством объекта — сборщик теряет эту возможность. ???
Next.JS
Использование паттерна во в среде Next.JS приводит к ошибке. Ошибка связанна с разделением компонентов внутри фреймворка на клиентские и серверные.
Проблема решается указанием дерективы 'use client'
в начале файла, где используются составные компоненты
Выводы
- Паттерн наиболее полезен в реализации компонентов для дизайн-систем, где обновление API может быть дорогим и долгим процессом. Примеры таких компонентов:
RadioGroup
,TagGroup
,Select
,Dropdown
,Form
,Tabs
и т.п. - Паттерн добавляет гибкость компонентам, позволяя управлять внутренней структурой компонента без изменения
props
- Паттерн передает управление компонента от разработчика к пользователю, что является примером SOLID-концепции
инверсии контроля
- Паттерн может не подойти для приложений в которых используется Next.JS
Заключение
Если React-компонент внутри дизайн-системы обладает сложной логикой, управляет внутренним состоянием, имеет зависимые дочерние компоненты, требует гибкости и кастомизации, то он является хорошим кандидатом для реализации с использованием паттерна Compound Components