Одна из самых интересных и сложных вещей в React — это не освоение каких-то продвинутых техник управления состояниями или правильное использование Context. Гораздо сложнее понять, как и когда мы должны разделять наш код на независимые компоненты и как правильно их компоновать. Я часто вижу, как разработчики попадают в две ловушки: либо они не выделяют их достаточно быстро, и в итоге получают огромные компоненты-«монолиты», которые делают слишком много вещей одновременно, и которые кошмарно поддерживать. Или, особенно после того, как они несколько раз обожглись на предыдущем паттерне, они извлекают компоненты слишком рано, что приводит к сложной комбинации множества абстракций, перепроектированному коду и, опять же, кошмару для поддержки.
Сегодня я хочу предложить несколько техник и правил, которые помогут определить, когда и как извлекать компоненты вовремя и как не попасть в ловушку чрезмерной инженерии. Но сначала давайте освежим в памяти некоторые основы: что такое композиция и какие паттерны композиции нам доступны?
Паттерны композиции компонентов React
Простые компоненты
Простые компоненты — это базовый строительный блок React. Они могут принимать реквизиты, иметь некоторое состояние и могут быть довольно сложными, несмотря на свое название. Компонент Button
, который принимает свойства title
и onClick
и отображает тег кнопки, является простым компонентом.
const Button = ({ title, onClick }) => <button onClick={onClick}>{title}</button>;
Любой компонент может рендерить другие компоненты — это композиция. Компонент Navigation
, который рендерит эту Button
— тоже простой компонент, который компонует другие компоненты:
const Navigation = () => {
return (
<>
// Rendering out Button component in Navigation component. Composition!
<Button title="Create" onClick={onClickHandler} />
... // some other navigation code
</>
);
};
С помощью этих компонентов и их композиции мы можем реализовать сколь угодно сложный пользовательский интерфейс. Технически, нам даже не нужны никакие другие паттерны и техники, все они — просто приятные мелочи, которые только улучшают повторное использование кода или решают только конкретные случаи использования.
Контейнерные компоненты
Контейнерные компоненты — это более продвинутая техника композиции. Единственное отличие от простых компонентов заключается в том, что они, помимо прочих реквизитов, позволяют передавать специальный реквизит children
, для которого у React есть свой собственный синтаксис. Если бы наша Button
из предыдущего примера принимала не title
, а children
, то она была бы написана следующим образом:
// the code is exactly the same! just replace "title" with "children"
const Button = ({ children, onClick }) => <button onClick={onClick}>{children}</button>;
Что ничем не отличается от title
с точки зрения Button
. Разница заключается в том, что на стороне потребителя синтаксис children
является специальным и выглядит как ваши обычные HTML теги:
const Navigation = () => {
return (
<>
<Button onClick={onClickHandler}>Create</Button>
... // some other navigation code
</>
);
};
В children
может входить что угодно. Мы можем, например, добавить туда компонент Icon
в дополнение к тексту, и тогда Navigation
будет состоять из компонентов Button
и Icon
:
const Navigation = () => {
return (
<>
<Button onClick={onClickHandler}>
<!-- Icon component is rendered inside button, but button doesn't know -->
<Icon />
<span>Create</span>
</Button>
...
// some other navigation code
</>
)
}
Navigation
контролирует то, что попадает в children
, с точки зрения Button
он просто отображает все, что хочет потребитель.
Мы рассмотрим практические примеры этой техники далее в статье.
Существуют и другие шаблоны композиции, такие как компоненты более высокого порядка, передача компонентов в качестве реквизитов или контекста, но их следует использовать только в очень специфических случаях. Простые компоненты и компоненты-контейнеры — это два основных столпа разработки React, и лучше довести их использование до совершенства, прежде чем пытаться внедрить более продвинутые техники.
Теперь, когда вы их знаете, вы готовы реализовать настолько сложный пользовательский интерфейс, насколько это вообще возможно!
Ладно, я шучу, я не собираюсь писать здесь статью типа «как нарисовать сову» ?.
Настало время для некоторых правил и рекомендаций, чтобы мы действительно могли нарисовать сову создавать сложные React-приложения с легкостью.
Когда наступает подходящее время для извлечения компонентов?
Основные правила разработки и декомпозиции React, которым я предпочитаю следовать, и чем больше я кодирую, тем сильнее я в них убеждаюсь, таковы:
- всегда начинать реализацию с самого верха
- извлекать компоненты только тогда, когда в этом есть реальная необходимость
- всегда начинать с «простых» компонентов, вводить другие техники композиции только тогда, когда в них есть реальная необходимость.
Любая попытка думать «заранее» или начинать «снизу вверх» с небольших повторно используемых компонентов всегда заканчивается либо чрезмерно сложным API компонентов, либо компонентами, в которых отсутствует половина необходимой функциональности.
И самое первое правило, когда компонент нужно декомпозировать на более мелкие — это когда компонент слишком большой. Для меня хороший размер компонента — это когда он полностью помещается на экране моего ноутбука. Если мне нужно прокручивать страницу, чтобы прочитать код компонента — это явный признак того, что он слишком большой.
Давайте начнем кодить сейчас, чтобы посмотреть, как это может выглядеть на практике. Сегодня мы собираемся реализовать типичную страницу Jira с нуля, не меньше (ну, вроде того, по крайней мере, мы собираемся начать ?).
Это скрин страницы проблемы из моего личного проекта, где я храню свои любимые рецепты, найденные в интернете ?. Здесь нам нужно реализовать, как вы можете видеть:
- верхнюю панель с логотипом, несколькими меню, кнопкой «создать» и строкой поиска
- боковую панель слева с названием проекта, сворачиваемыми разделами «планирование» и «разработка» с элементами внутри (также разделенными на группы), с безымянным разделом с пунктами меню под ним
- большой раздел «содержание страницы», где отображается вся информация о текущем выпуске.
Итак, давайте начнем кодировать все это в одном большом компоненте для начала. Вероятно, он будет выглядеть примерно так:
export const JiraIssuePage = () => {
return (
<div className="app">
<div className="top-bar">
<div className="logo">logo</div>
<ul className="main-menu">
<li>
<a href="#">Your work</a>
</li>
<li>
<a href="#">Projects</a>
</li>
<li>
<a href="#">Filters</a>
</li>
<li>
<a href="#">Dashboards</a>
</li>
<li>
<a href="#">People</a>
</li>
<li>
<a href="#">Apps</a>
</li>
</ul>
<button className="create-button">Create</button>
more top bar items here like search bar and profile menu
</div>
<div className="main-content">
<div className="sidebar">
<div className="sidebar-header">ELS project</div>
<div className="sidebar-section">
<div className="sidebar-section-title">Planning</div>
<button className="board-picker">ELS board</button>
<ul className="section-menu">
<li>
<a href="#">Roadmap</a>
</li>
<li>
<a href="#">Backlog</a>
</li>
<li>
<a href="#">Kanban board</a>
</li>
<li>
<a href="#">Reports</a>
</li>
<li>
<a href="#">Roadmap</a>
</li>
</ul>
<ul className="section-menu">
<li>
<a href="#">Issues</a>
</li>
<li>
<a href="#">Components</a>
</li>
</ul>
</div>
<div className="sidebar-section">sidebar development section</div>
other sections
</div>
<div className="page-content">... here there will be a lot of code for issue view</div>
</div>
</div>
);
};
Сейчас я не реализовал даже половины необходимых элементов, не говоря уже о логике, а компонент уже слишком большой, чтобы прочитать его одним взглядом. Посмотрите его в codesandbox. Это хорошо и ожидаемо! Поэтому, прежде чем двигаться дальше, пора разбить его на более управляемые части.
Единственное, что мне нужно сделать для этого, это создать несколько новых компонентов и скопировать-вставить в них код. У меня нет никаких примеров использования продвинутых техник (пока), поэтому все будет простым компонентом.
Я собираюсь создать компонент Topbar
, который будет содержать все, что связано с topbar, компонент Sidebar
для всего, что связано с sidebar, как вы можете догадаться, и компонент Issue
для основной части, которую мы не будем трогать сегодня. Таким образом, наш основной компонент JiraIssuePage
останется с этим кодом:
export const JiraIssuePage = () => {
return (
<div className="app">
<Topbar />
<div className="main-content">
<Sidebar />
<div className="page-content">
<Issue />
</div>
</div>
</div>
);
};
Теперь давайте посмотрим на реализацию нового компонента Topbar
:
export const Topbar = () => {
return (
<div className="top-bar">
<div className="logo">logo</div>
<ul className="main-menu">
<li>
<a href="#">Your work</a>
</li>
<li>
<a href="#">Projects</a>
</li>
<li>
<a href="#">Filters</a>
</li>
<li>
<a href="#">Dashboards</a>
</li>
<li>
<a href="#">People</a>
</li>
<li>
<a href="#">Apps</a>
</li>
</ul>
<button className="create-button">Create</button>
more top bar items here like search bar and profile menu
</div>
);
};
Если бы я реализовал там все элементы (строка поиска, все подменю, иконки справа), этот компонент также был бы слишком большим, поэтому его также нужно разделить. И это, пожалуй, более интересный случай, чем предыдущий. Потому что, технически, я могу просто извлечь из него компонент MainMenu
, чтобы сделать его достаточно маленьким.
export const Topbar = () => {
return (
<div className="top-bar">
<div className="logo">logo</div>
<MainMenu />
<button className="create-button">Create</button>
more top bar items here like search bar and profile menu
</div>
);
};
Но извлечение только MainMenu
сделало компонент Topbar
немного более трудночитаемым для меня. Раньше, когда я смотрел на Topbar
, я мог описать его как «компонент, который реализует различные вещи в верхней панели», и сосредоточиться на деталях только тогда, когда мне это было нужно. Теперь же описание будет выглядеть как «компонент, который реализует различные вещи в топбаре И составляет некоторый случайный компонент MainMenu
«. Поток чтения испорчен.
Это подводит меня ко второму правилу декомпозиции компонентов: при выделении более мелких компонентов не останавливайтесь на полпути. Компонент должен быть описан либо как «компонент, реализующий различные вещи», либо как «компонент, составляющий различные компоненты вместе», а не как то и другое.
Поэтому гораздо лучшая реализация компонента Topbar
выглядела бы следующим образом:
export const Topbar = () => {
return (
<div className="top-bar">
<Logo />
<MainMenu />
<Create />
more top bar components here like SearchBar and ProfileMenu
</div>
);
};
Теперь читать намного легче!
И точно такая же история с компонентом Sidebar
— слишком большой, если бы я реализовал все элементы, поэтому его нужно разделить:
export const Sidebar = () => {
return (
<div className="sidebar">
<Header />
<PlanningSection />
<DevelopmentSection />
other sidebar sections
</div>
);
};
Смотрите полный пример в codesandbox.
А затем просто повторяйте эти шаги каждый раз, когда компонент становится слишком большим. Теоретически, мы можем реализовать всю эту страницу Jira, используя только простые компоненты.
Когда пора вводить контейнерные компоненты?
Теперь самое интересное — давайте посмотрим, когда нам следует внедрять некоторые продвинутые техники и почему. Начнем с контейнерных компонентов.
Во-первых, давайте снова посмотрим на дизайн. Более конкретно — на разделы «Планирование» и «Разработка» в боковом меню.
У них не только одинаковый дизайн заголовка, но и одинаковое поведение: нажатие на заголовок сворачивает раздел, а в «свернутом» режиме появляется значок мини-стрелки. И мы реализовали это как два разных компонента — PlanningSection
и DevelopmentSection
. Конечно, я мог бы просто реализовать логику «коллапса» в обоих компонентах, в конце концов, это всего лишь вопрос простого состояния:
const PlanningSection = () => {
const [isCollapsed, setIsCollapsed] = useState(false);
return (
<div className="sidebar-section">
<div onClick={() => setIsCollapsed(!isCollapsed)} className="sidebar-section-title">
Planning
</div>
{!isCollapsed && <>...all the rest of the code</>}
</div>
);
};
Но:
- это довольно много повторений даже между этими двумя компонентами
- содержание этих разделов фактически различно для каждого типа проекта или типа страницы, так что еще больше повторений в ближайшем будущем
В идеале я хочу инкапсулировать логику поведения свернутых/развернутых элементов и дизайн заголовка, оставляя при этом различным разделам полный контроль над элементами, которые находятся внутри. Это идеальный случай использования компонентов Container. Я могу просто извлечь все из примера кода выше в компонент и передать пункты меню как children
. У нас будет компонент CollapsableSection
:
const CollapsableSection = ({ children, title }) => {
const [isCollapsed, setIsCollapsed] = useState(false);
return (
<div className="sidebar-section">
<div className="sidebar-section-title" onClick={() => setIsCollapsed(!isCollapsed)}>
{title}
</div>
{!isCollapsed && <>{children}</>}
</div>
);
};
и PlanningSection
(и DevelopmentSection
и все остальные будущие разделы) станут именно такими:
const PlanningSection = () => {
return (
<CollapsableSection title="Planning">
<button className="board-picker">ELS board</button>
<ul className="section-menu">... all the menu items here</ul>
</CollapsableSection>
);
};
Очень похожая история будет с нашим корневым компонентом JiraIssuePage
. Сейчас он выглядит следующим образом:
export const JiraIssuePage = () => {
return (
<div className="app">
<Topbar />
<div className="main-content">
<Sidebar />
<div className="page-content">
<Issue />
</div>
</div>
</div>
);
};
Но как только мы начнем реализовывать другие страницы, доступные из боковой панели, мы увидим, что все они выглядят точно так же — боковая панель и верхняя панель остаются неизменными, меняется только область «содержимое страницы». Благодаря работе по декомпозиции, которую мы проделали ранее, мы можем просто скопировать этот макет на каждую страницу — в конце концов, это не так много кода. Но поскольку все они абсолютно одинаковы, было бы неплохо просто извлечь код, реализующий все общие части, и оставить только компоненты, изменяющиеся для конкретных страниц. И снова идеальный случай для компонента «контейнер»:
const JiraPageLayout = ({ children }) => {
return (
<div className="app">
<Topbar />
<div className="main-content">
<Sidebar />
<div className="page-content">{children}</div>
</div>
</div>
);
};
И наша JiraIssuePage
(и будущие JiraProjectPage
, JiraComponentsPage
, и т.д., все будущие страницы, доступные из боковой панели) становится именно такой:
export const JiraIssuePage = () => {
return (
<JiraPageLayout>
<Issue />
</JiraPageLayout>
);
};
Если бы я хотел резюмировать это правило в одном предложении, оно могло бы звучать так: извлекайте компоненты-контейнеры, когда есть необходимость разделить некоторую визуальную или поведенческую логику, которая обертывает элементы, которые все еще должны находиться под контролем «потребителя».
Контейнерные компоненты — пример использования производительности
Еще один очень важный случай использования компонентов Container — это повышение производительности компонентов. Технически производительность немного отклоняется от темы разговора о композиции, но было бы преступлением не упомянуть ее здесь.
В реальной Jira компонент Sidebar является перетаскиваемым — вы можете изменять его размер, перетаскивая его влево и вправо за край. Как бы мы реализовали нечто подобное? Вероятно, мы бы ввели компонент Handle
, некоторое состояние для width
боковой панели, а затем слушали бы событие «mousemove». Рудиментарная реализация будет выглядеть примерно так:
export const Sidebar = () => {
const [width, setWidth] = useState(240);
const [startMoving, setStartMoving] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) return;
const changeWidth = (e: MouseEvent) => {
if (!startMoving) return;
if (!ref.current) return;
const left = ref.current.getBoundingClientRect().left;
const wi = e.clientX - left;
setWidth(wi);
};
ref.current.addEventListener('mousemove', changeWidth);
return () => ref.current?.removeEventListener('mousemove', changeWidth);
}, [startMoving, ref]);
const onStartMoving = () => {
setStartMoving(true);
};
const onEndMoving = () => {
setStartMoving(false);
};
return (
<div className="sidebar" ref={ref} onMouseLeave={onEndMoving} style={{ width: `${width}px` }}>
<Handle onMouseDown={onStartMoving} onMouseUp={onEndMoving} />
... the rest of the code
</div>
);
};
Однако здесь есть проблема: каждый раз, когда мы перемещаем мышь, мы вызываем обновление состояния, которое, в свою очередь, вызывает повторное отображение всего компонента Sidebar
. Хотя на нашей простейшей боковой панели это незаметно, при усложнении компонента «перетаскивание» ее может заметно запаздывать. Контейнерные компоненты — идеальное решение для этого: все, что нам нужно, это извлечь все тяжелые операции с состоянием в компонент Container и передать все остальное через children
.
const DraggableSidebar = ({ children }: { children: ReactNode }) => {
// all the state management code as before
return (
<div
className="sidebar"
ref={ref}
onMouseLeave={onEndMoving}
style={{ width: `${width}px` }}
>
<Handle onMouseDown={onStartMoving} onMouseUp={onEndMoving} />
<!-- children will not be affected by this component's re-renders -->
{children}
</div>
);
};
И наш компонент Sidebar
превратится вот в это:
export const Sidebar = () => {
return (
<DraggableSidebar>
<Header />
<PlanningSection />
<DevelopmentSection />
other Sections
</DraggableSidebar>
);
};
Таким образом, компонент DraggableSidebar
все еще будет перерисовываться при каждом изменении состояния, но это будет очень дешево, поскольку это всего лишь один div. И все, что приходит в children
, не будет затронуто обновлениями состояния этого компонента.
Посмотрите все примеры контейнерных компонентов в этом codesandbox. А чтобы сравнить пример использования плохого рендеринга, смотрите этот codeandbox. Обратите внимание на вывод консоли при перетаскивании боковой панели в этих примерах — компонент PlanningSection
постоянно логируется в «плохой» реализации и только один раз в «хорошей».
А если вы хотите узнать больше о различных паттернах и о том, как они влияют на производительность реакта, вам могут быть интересны эти статьи: Как писать производительный код React: правила, паттерны, «до» и «не», Почему пользовательские хуки react могут уничтожить производительность вашего приложения, Как писать производительные приложения React с помощью Context.
Принадлежит ли это состояние этому компоненту?
Еще один момент, помимо размера, который может сигнализировать о том, что компонент должен быть извлечен, — это управление состоянием. Или, если быть точным, управление состоянием, которое не имеет отношения к функциональности компонента. Позвольте мне показать вам, что я имею в виду.
Одним из пунктов боковой панели в настоящей Jira является пункт «Добавить ярлык», при нажатии на который открывается модальный диалог. Как бы вы реализовали это в нашем приложении? Сам модальный диалог, очевидно, будет собственным компонентом, но где бы вы представили состояние, которое его открывает? Что-то вроде этого?
const SomeSection = () => {
const [showAddShortcuts, setShowAddShortcuts] = useState(false);
return (
<div className="sidebar-section">
<ul className="section-menu">
<li>
<span onClick={() => setShowAddShortcuts(true)}>Add shortcuts</span>
</li>
</ul>
{showAddShortcuts && <ModalDialog onClose={() => setShowAddShortcuts(false)} />}
</div>
);
};
Вы можете увидеть нечто подобное повсюду, и в этой реализации нет ничего криминального. Но если бы я реализовывал его, и если бы я хотел сделать этот компонент идеальным с точки зрения композиции, я бы извлек это состояние и компоненты, связанные с ним, наружу. Причина проста — это состояние не имеет никакого отношения к компоненту SomeSection
. Это состояние управляет модальным диалогом, который появляется при нажатии на элемент shortcuts. Это делает чтение этого компонента немного сложнее для меня — я вижу компонент, который является «секцией», а следующая строка — какое-то случайное состояние, которое не имеет никакого отношения к «секции». Поэтому вместо вышеприведенной реализации я бы выделил элемент и состояние, которое на самом деле принадлежит этому элементу, в отдельный компонент:
const AddShortcutItem = () => {
const [showAddShortcuts, setShowAddShortcuts] = useState(false);
return (
<>
<span onClick={() => setShowAddShortcuts(true)}>Add shortcuts</span>
{showAddShortcuts && <ModalDialog onClose={() => setShowAddShortcuts(false)} />}
</>
);
};
И в качестве бонуса компонент секции становится намного проще:
const OtherSection = () => {
return (
<div className="sidebar-section">
<ul className="section-menu">
<li>
<AddShortcutItem />
</li>
</ul>
</div>
);
};
Посмотрите это в codesandbox.
По той же логике, в компоненте Topbar
я бы перенес будущее состояние, управляющее меню, в компонент SomeDropdownMenu
, все состояние, связанное с поиском, в компонент Search
, а все, связанное с открытием диалога «create issue», в компонент CreateIssue
.
Что делает компонент хорошим?
И последнее, перед тем как закончить на сегодня. В резюме я хочу написать «секрет написания масштабируемых приложений на React заключается в извлечении хороших компонентов в нужное время». Мы уже говорили о «подходящем моменте», но что именно является «хорошим компонентом»? После всего, что мы уже рассказали о композиции, я думаю, что готов написать определение и несколько правил.
Хороший компонент» — это компонент, который я могу легко прочитать и понять, что он делает, с первого взгляда.
У «хорошего компонента» должно быть хорошее самоописывающееся имя. Sidebar
для компонента, который отображает боковую панель — это хорошее название. CreateIssue
для компонента, который обрабатывает создание вопросов — хорошее название. SidebarController
для компонента, который отображает элементы боковой панели, специфичные для страницы «Issues» — не очень хорошее название (название указывает на то, что компонент имеет общее назначение, а не специфичен для конкретной страницы).
Хороший компонент» не делает того, что не имеет отношения к его заявленной цели. Topbar
компонент, который отображает только элементы в верхней панели и управляет только поведением верхней панели, является хорошим компонентом. Компонент Sidebar
, который управляет состоянием различных модальных диалогов — не лучший компонент.
Заключительные пункты
Теперь я могу это написать ?! Секрет написания масштабируемых приложений на React заключается в извлечении хороших компонентов в нужное время, не более того.
Что делает компонент хорошим?
- размер, который позволяет читать его без прокрутки
- название, указывающее, что он делает
- отсутствие нерелевантного управления состоянием
- легко читаемая реализация
Когда нужно разделить компонент на более мелкие?
- когда компонент слишком большой
- когда компонент выполняет тяжелые операции управления состоянием, которые могут повлиять на производительность
- когда компонент управляет неактуальным состоянием.
Каковы общие правила композиции компонентов?
- всегда начинайте реализацию с самого верха
- извлекайте компоненты только тогда, когда у вас есть для этого реальный сценарий использования, а не заранее
- всегда начинайте с простых компонентов, внедряйте продвинутые методы только тогда, когда они действительно необходимы, а не заранее.
На сегодня это все, надеюсь, вам понравилось и вы нашли это полезным! Увидимся в следующий раз ✌?
…
Первоначально опубликовано на https://www.developerway.com. На сайте есть больше статей, подобных этой ?
Подпишитесь на рассылку, подключитесь на LinkedIn или следите в Twitter, чтобы получить уведомление о выходе следующей статьи.