Перетяжка, Карта дня
Перетяжка, Карта дня
Перетяжка, Карта дня

Какие есть паттерны в React и для чего они нужны: часть 1

Аватар Юсуп Изрипов
для
Логотип компании Tproger
Tproger
Отредактировано

В этой части Юсуп Изрипов рассказывает, что такое Container & Presentational Components, Higher-Order Component (HOC) и паттерн Render Props в React и что с ними делать.

2К открытий7К показов
Какие есть паттерны в React и для чего они нужны: часть 1

В мире React термин «паттерн» означает какой-то проверенный подход к решению задачи, а не шаблон проектирования из классической книги. За годы разработки вокруг React сформировались свои распространённые паттерны — способы организовать компоненты и логику так, чтобы код получался понятным, поддерживаемым и переиспользуемым.

Меня зовут Юсуп Изрипов, я сеньор разработчик в VK. Работаю над продуктами, которыми ежедневно пользуются миллионы человек.

В этих статьях я расскажу вам о самых популярных паттернах и приведу примеры кода. Мы рассмотрим, когда каждый из них может пригодиться, а также отметим их плюсы и минусы. Поговорим о классических приёмах вроде контейнеров и HOC, эволюции к хукам, также обязательно рассмотрим новые паттерны появившиеся в последних версиях React.

Container + Presentational Components

На первом месте работы ещё во время разработки на Vue мы с подачи нашего тимлида решили ввести этот паттерн. Глобально у нас были так называемые «умные» и «тупые» компоненты (или как я тактично называл их на демо — визуальные). Как вы наверняка догадались, роль Container у нас исполняли «умные» компоненты, а роль Presentational — «тупые». В чём, собственно, суть этого паттерна? Слышали выражение «разделяй и властвуй»? Паттерн Container & Presentational Components (контейнерные и презентационные компоненты) ровно об этом: он разделяет логику (данные и взаимодействие с ними) и отображение (UI) на разные компоненты.

Онлайн-курс «JAVA-разработчик» от EdMe.pro
  • постоянный доступ
  • бесплатно
  • онлайн
tproger.ru

Presentational Components отвечают только за то, как что-то выглядит. Они получают данные через props и отображают их, больше ничего. Это, как правило, чистые функциональные компоненты, часто без собственного состояния (ну разве что мелкий UI-стейт типа «раскрыт ли dropdown»). Им всё равно, каким образом взять список пользователей — они просто ожидают условный props.users и отображают его в соответствии с дизайном.

Container Components, напротив, знают, что показать и откуда это взять, но не занимаются тем, как это отображается. Они содержат в себе всю логику: могут загрузить данные, подписаться на store или контекст, хранить состояние, а рендерят в презентационных компоненты, передавая им готовые данные. Контейнер может вообще не иметь собственного HTML, кроме того, что приходит от дочернего презентационного компонента. Его задача — это взаимодействие с данными.

Зачем же нужен такой подход? Во-первых, лучшая разделённость ответственности (UI отдельно, данные отдельно) упрощает понимание и поддержку приложения. Во-вторых, улучшается переиспользование: один визуальный компонент вероятно использовать с разными источниками данных через разные контейнеры. Дизайнеры могут менять внешний вид компонента в одном месте, не затрагивая бизнес-логику. И тестировать тоже проще: можно отдельно протестировать логику контейнера (без верстки) и отдельно визуальный компонент (с моком данных).

			// Пример: презентационный компонент списка пользователей

function UserList({ users }) {
  return (
    
      {users.map(user => (
        {user.name}
      ))}
    
  );
}

// Контейнерный компонент, загружающий пользователей

function UserListContainer() {
  const [users, setUsers] = useState([]);
  useEffect(() => {
    fetch('/api/users')
      .then(res => res.json())
      .then(data => setUsers(data));
  }, []);
  return ;
}
		

В этом примере UserList не содержит никакого стейта, не подписан на store или контекст, лишь отображает список. Ему всё равно, как и где получают пользователей — просто принимает проп users и выводит его. Контейнер UserListContainer же занимается работой с данными: делает fetch, сохраняет результат в useState, и потом рендерит UserList, прокидывая в него данные. Благодаря такому делению компонент UserList легко переиспользовать — хоть для локальных данных, хоть для данных из Redux или context — достаточно написать другой контейнер.

Курсы дизайна с помощью в трудоустройстве
  • постоянный доступ
  • бесплатно
  • онлайн
tproger.ru

Конечно, не всегда нужно городить пару компонентов вместо одного. Этот паттерн полезен, когда приложение растёт: вы начинаете замечать, что пропсы идут через несколько уровней просто транзитом, или один компонент слишком перегружен логикой. Тогда вы «вытаскиваете» логику в контейнер, а UI — в презентационный компонент, и код сразу становится чище. Это не обязательное правило, а приём для рефакторинга по мере необходимости.

Стоит отметить, что с появлением React-хуков граница между логикой и отображением несколько размылась. Теперь можно выносить логику в кастомные хуки и вызывать их прямо внутри компонента, вместо того чтобы создавать отдельный контейнер-класс, как это делали до 2018 года. Тем не менее, принцип «держи логику отдельно от представления» по-прежнему полезен. Даже с хуками можно структурировать код, разделяя функциональность: написать хук useUsersData() для получения пользователей и применять его в разных компонентах (вместо дублирования запроса).

Плюсы: чёткое разделение обязанностей, возможность переиспользовать и заменять части независимо (UI-компонент можно переиспользовать с разными данными), облегчение тестирования.

Минусы: появляется больше файлов/компонентов, чем могло бы быть, что может казаться избыточным для мелких случаев. Иногда чрезмерное дробление на «глупые» и «умные» компоненты лишь усложняет структуру, если паттерн применён не к месту. Как говорится, включайте голову — не каждую кнопку нужно выделять в отдельный контейнер.

Higher-Order Component (HOC)

Когда я впервые услышал термин HOC, он показался мне чем-то из математики. Но на практике всё горадо прозаичнее: HOC — это всего лишь функция, которая принимает React-компонент и возвращает новый компонент, оборачивая исходный дополнительной функциональностью. Проще говоря, HOC — это «обёртка». Мы помещаем один компонент внутрь другого, чтобы на выходе получить расширенную версию переданного в HOC компонента.

Зачем это может понадобиться? Представим, у нас есть несколько разных компонентов, и всем им нужно что-то общее: например, обработка ошибок или подписка на внешние данные. Можно было бы скопировать этот код в каждый из компонентов, но куда элегантнее написать HOC один раз и применить ко всем. Классический пример — Redux-функция connect: вы пишете export default connect(mapState)(MyComponent), и ваш компонент получает пропсы из глобального стейта.

connect — как раз и есть HOC, который инъектирует данные из Redux в компонент, не требуя от вас переписывать все под Redux вручную.

Создать свой HOC тоже несложно. Супер банальный пример — сделаем HOC, который добавляет компоненту стейт счётчика:

			function withCounter(WrappedComponent) {
  // Возвращаем новый компонент-обёртку
  return function WithCounter(props) {
    const [count, setCount] = useState(0);
    // Передаем внутрь исходного компонента счётчик и функцию для увеличения
    return  setCount(c => c + 1)} {...props} />;
  };
}
// Использование HOC:
function ClickButton({ count, increment, label }) {
  return {label}: {count};
}
const EnhancedButton = withCounter(ClickButton);
		

Здесь withCounter — HOC, он возвращает новый функциональный компонент WithCounter, который внутри себя использует useState и передаёт состояние и функцию увеличения внутрь WrappedComponent. В итоге EnhancedButton — это улучшенная версия ClickButton, которая умеет считать клики, даже если исходный ClickButton об этом не знал.

Плюсы: один HOC может добавить функциональность множеству компонентов сразу — не надо копировать код везде. Логику обновляется в одном месте (внутри HOC) — и все обёрнутые компоненты получают изменения. HOC можно комбинировать: например, обернуть компонент сначала в HOC, добавляющий тему оформления, потом в HOC, добавляющий логирование, и т.д. В итоге получим компонент, обладающий сразу несколькими дополнительными возможностями.

Минусы: за такую магию мы платим усложнением структуры. Когда компонентов обёрток становится много, React-дерево раздувается, и возникает эффект «матрёшки». В DevTools вы могли видеть что-то вроде: Connect(withRouter(WithTheme(MyComponent))) — разобраться, что к чему, становится сложнее. Дебаг таких цепочек — тоже удовольствие то ещё, приходится пробираться через несколько уровней абстракций. Кроме того, HOC часто прокидывают пропсы во внутренний компонент, что чревато конфликтами имён (нужно следить, чтобы, например, prop.title от HOC не перезаписал пропс title, который вы передали самому компоненту). Ещё нюанс — HOC усложняют типизацию в TypeScript (надо правильно описывать generic для пропсов), но это выходит за рамки нашей темы.

React-разработчики со временем несколько охладели к HOC. В официальной документации прямо сказано: «компоненты высшего порядка не так часто используются в современном React-коде». Отчасти их вытеснили хуки, тем не менее, HOC никуда не делись: их продолжают применять сторонние библиотеки — тот же Redux, Relay и другие. Да, и в старом проекте вы почти наверняка встретите хотя бы пару HOC. Поэтому понимать этот паттерн стоит. Просто имейте в виду современные альтернативы и используйте HOC там, где это действительно необходимо.

Паттерн Render Props

Следующий паттерн я бы назвал «перевёрнутый HOC». Render Props — это подход, когда компонент сам не рендерит что-то внутри себя, а принимает функцию (часто через проп render или просто используя детей как функцию) и вызывает её, чтобы получить содержимое. То есть мы передаём компоненту инструкцию, что именно отрендерить, а он сам обеспечивает, когда и с какими данными вызвать эту инструкцию.

Представьте компонент <MouseTracker> для отслеживания положения курсора. Классически он может хранить x, y в своём состоянии и отрисовывать, скажем, <p>Mouse at (x, y)</p>. Но что, если мы хотим переиспользовать эту логику уже с другим UI? Паттерн Render Props предлагает сделать компонент <Mouse>, который не определяет жёстко JSX внутри себя, а вызывает функцию, переданную через проп (или children функцию), передавая ей координаты. Эта функция сама решит, что рисовать. Таким образом, <Mouse> инкапсулирует логику (слежение за мышкой), а отображение делегирует наружу.

Пример: реализуем компонент-утилиту <FilteredList items={...} filter={...}>, который отображает список на основе передаваемого фильтра. Вместо того чтобы жёстко прописывать разметку элемента списка, сделаем его с render проп через children:

			function FilteredList({ items, filter, children }) {
  const filtered = items.filter(filter);
  // Вызываем функцию-ребёнка для каждого элемента, оборачивая в 
  return {filtered.map(item => children(item))};
}
// Использование:
 n % 2 === 0}>
  {item => {item}}

		

Здесь <FilteredList> знает, как отфильтровать массив (items.filter(filter)), но не знает, как отрисовать каждый элемент. Вместо этого он вызывает функцию, которую мы передали в качестве дочернего элемента (children), для каждого элемента списка. Эта функция возвращает <li> для каждого item. В результате логика фильтрации инкапсулируется внутри FilteredList, а конкретное отображение списка задаётся извне. Мы могли бы так же использовать этот компонент для массива объектов, отрисовывая, например, товары — достаточно передать другую children-функцию.

Паттерн Render Props здорово повышает гибкость компонентов. Мы можем переиспользовать <FilteredList> для списков чего угодно — чисел, пользователей, товаров — просто изменяя функцию отображения. Другой пример: компонент <Mouse> может предоставлять координаты курсора, а внешний код решит, просто вывести текст, нарисовать по координатам картинку или вызвать какую-то совершенно другую логику — не нужно делать несколько вариаций компонента для каждого кейса.

Плюсы: Render Props позволяет компоненту-провайдеру (в примере выше FilteredList является провайдером данных) быть максимально универсальным, а конкретную разметку делегировать наружу. Многие библиотеки воспользовались этим паттерном: например, React Router (до версии 6) позволял вместо компонента страницы передать проп render в <Route> — функцию, которая отрисует JSX на основе параметров маршрута. Formik предлагал компонент <Formik> с функцией-ребёнком для рендеринга формы. Downshift (библиотека для автокомплитов) — тоже классический пример паттерна render props.

Минусы: главное неудобство — излишний шум в JSX. Код с вложенными функциями бывает тяжело читать. В нашем простом примере всё компактно, но представьте, если у вас будет несколько уровней таких компонентов: <Foo>{foo => ( <Bar>{bar => ( ... )}</Bar> )}</Foo> — легко получить «оберточный ад» из стрелочных функций прямо в разметке. Это значительно затруднит отладку такого кода при возникновении каких-либо проблем. К тому же, каждый раз при рендере создаётся новая функция, что может негативно сказаться на производительности, если таких компонентов много (React конечно оптимизирует функции в пропсах через механизм сравнения, но всё же). Также возникает неявная связь: внешний код должен знать, какие аргументы ожидает функция. TypeScript конечно помогает, но при чтении кода не сразу видно, что children, например, это не просто элемент, а функция.

Как и HOC, паттерн Render Props сейчас используется реже. Многие задачи, решаемые через него, теперь элегантнее с точки зрения кода решаются хуками, в официальной документации это также отмечено. Но всё же понимать его нужно, потому что легаси-код и некоторые библиотеки всё ещё работают на нём. Если видите, что компонент принимает функцию в виде пропса (чаще всего называется render или передаваётся через детей), знайте — это он, Render Props.

В следующей части расскажу про хуки и кастомные хуки, а также про Compound Components и Серверные компоненты и Suspense.
Следите за новыми постами
Следите за новыми постами по любимым темам
2К открытий7К показов