Управление зависимостями данных — важная тема для меня. За годы работы с React, Redux и другими библиотеками из экосистемы мы разработали несколько решений этой проблемы. Вот наша история.

В предыдущей статье я поделился с вами некоторой базовой информацией о технологиях, которые мы используем для создания приложений React, о том, как мы разделяем проблемы с помощью React, Redux и >редукс-сага. В этой статье я расскажу вам, как мы управляем зависимостями данных в наших приложениях. Под зависимостями данных я подразумеваю данные, которые приложение должно запрашивать у какой-либо службы. Давайте конкретизируем и скажем, что используемая нами служба — это REST API, но это может быть и любая другая служба. Под данными я подразумеваю любые данные, которые нужны приложению или которые у него есть.

Презентационные и контейнерные компоненты

Я должен начать с объяснения структуры компонентов React, которую мы используем. Наши приложения включают два типа компонентов React: презентационные и контейнерные компоненты. Презентационные компоненты в большинстве случаев представляют собой компоненты без состояния, принимающие данные через свойства и разметку рендеринга. Они умеют только рендерить интерфейс. Контейнерные компоненты, с другой стороны, знают о бизнес-логике и обычно оборачивают презентационные компоненты некоторыми HOC (например, react-redux connect и т. д.). Если вы хотите узнать больше о концепции, вы можете найти исходную статью здесь.

Итак, первое, что нам абсолютно необходимо сделать, это внедрить данные в презентационные компоненты с помощью компонентов-контейнеров. А в наших приложениях Redux данные берутся из хранилища Redux через HOC connect.Но как получить эти зависимости в хранилище из REST API?

Использование компонента-контейнера

Как я уже упоминал выше, компоненты контейнера знают о бизнес-логике. Они знают, какие зависимости данных им нужны. Почему бы тогда не использовать эти компоненты для запроса зависимостей данных? Мы можем использовать Жизненный цикл React:

  • componentDidMount: когда компонент монтируется, он запрашивает данные у REST API,
  • componentDidUpdate: когда компонент обновляется, он может дополнительно запросить новые данные,
  • и componentWillUnmount: когда компонент размонтируется, он может удалить ненужные данные.

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

Удаление данных

Что касается удаления данных, нам не нужно ничего, кроме Redux. Вам нужна функция редуктора, которая прослушивает заданное действие и удаляет данные из хранилища.

Запрос данных

Запрос зависимостей данных становится немного сложнее, но совсем чуть-чуть. Как я упоминал в предыдущей статье, мы используем redux-saga для обработки бизнес-логики. Таким образом, должна быть сага (мы называем функцию генератора для промежуточного программного обеспечения саги сагой), прослушивающая действие, запрашивающее данные. Затем эта сага вызывает REST API и впоследствии отправляет новое действие для сохранения зависимостей данных в хранилище Redux.

Это все, что вам нужно сделать, чтобы получить данные, необходимые для компонента. Когда компонент-контейнер монтируется, он запрашивает зависимости данных через какую-то сагу, а когда его размонтируют, он удаляет данные, которые ему больше не нужны.

Зависимости маршрутизации данных

Идея, которую я показал вам выше, проста, но решение может оказаться довольно сложным. Представьте себе ситуацию, когда у вас есть несколько компонентов с одинаковыми зависимостями данных, отображаемых одновременно. Каждый из компонентов запрашивает зависимости данных, каждый из компонентов удаляет данные. Вы должны как-то синхронизировать действия, и даже тогда вы получите вызовы кода, который вам не нужен. Для данной страницы приложения достаточно было бы запросить и удалить данные только один раз.

Содержание страницы

Допустим, у вас есть компонент, который отображает контент для данной страницы. Мы можем назвать этот компонент контейнером страницы. Контейнер страницы может запрашивать все необходимые данные в содержимом страницы, а затем удалять данные при изменении страницы. Другие компоненты содержимого страницы будут просто отображать данные из хранилища. Они не будут запрашивать данные сами, контейнер страницы сделает это за них.

Эта идея восходит к тем временам, когда каждая страница рендерилась на сервере, и данные для данной страницы вводились во время рендеринга сервера. Чтобы все это работало в приложении React, вам просто нужен react-router.

Изменение мышления

Посмотрим на ситуацию с другой точки зрения. Нам действительно нужно делать все это, запрашивая логику данных в каком-то компоненте? Было бы лучше перенести всю бизнес-логику (запрашивающую данные) в редукционную сагу. К счастью для нас, есть пакет connected-react-router, который вставляет информацию о текущем маршруте в хранилище Redux. При каждом изменении маршрута отправляется действие LOCATION_CHANGE. Мы можем прослушать это действие и на основе текущего маршрута запросить данные, которые нужны всем компонентам содержимого страницы.Благодаря такому изменению мышления мы можем упростить управление зависимостями данных и даже обеспечить лучшее разделение задач.

МаршрутЗависимостиСага

Поэтому я написал то, что мы называем runRouteDependencies. Это утилита, которая поможет вам управлять зависимостями данных в вашем приложении. Я считаю, что это лучше всего объяснить на примере, вот он:

export function* userDetailSaga(params) {
   yield put(requestUser(params.id));
}
export const handlers = {
   '/user/:id': userDetailSaga,
};
export function* routeDependenciesSaga() {
   yield takeEvery(LOCATION_CHANGE, runRouteDependencies, handlers);
}

routeDependenciesSa — это сага по управлению зависимостями ваших данных. Он прослушивает каждый LOCATION_CHANGE и вызывает runRouteDependencies с объектом handlers. Он вызывает указанную сагу для запроса всех зависимостей данных на основе текущего маршрута (детали пользователя в приведенном выше примере).

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

Возвращаясь к компонентам контейнера

Использование routeDependenciesSaga казалось правильным. Управление зависимостями данных в одном файле было легко отлаживать, а также читать и понимать. Когда вам нужно было что-то изменить или добавить новые зависимости, вам просто нужно было найти правильный файл. Но у этого подхода есть некоторые минусы.

Проблемы routeDependenciesSaga

Проблема аутентификации
Представьте, что у вас есть приложение с некоторыми маршрутами, которые проходят аутентификацию. Когда пользователь приходит на маршрут, вы должны показать форму входа, если пользователь не входит в систему. LOCATION_CHANGE отправляется и запрашиваются зависимости данных. Но вам нужен, например, какой-то пользовательский токен для доступа к данным. Таким образом, запрос терпит неудачу, потому что пользователь не вошел в систему. Это приводит к более сложной routeDependenciesSaga, чем та, что выше. Вам нужно много знать об аутентификации в routeDependenciesSaga, чтобы она работала правильно.

Проблема удаления данных
Еще одна проблема — удаление данных из хранилища, когда вы уходите с маршрута. LOCATION_CHANGE сообщает вам текущий маршрут. Поэтому вы знаете, что вы ввели маршрут, и вы должны запросить данные. Но вы никогда не знаете, какой маршрут вы только что покинули. Когда вы создаете какое-то сложное приложение с большим количеством зависимостей данных, это может вызвать проблемы с производительностью.

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

Как решить проблемы

Действительно ли стоит иметь все управление зависимостями данных в redux-saga? У нас были некоторые споры по этому поводу с ребятами из моей команды разработчиков, но, кажется, мне удалось объяснить им преимущества возврата к контейнерным компонентам.

Я не говорю, что мы больше не используем redux-saga. Зависимости данных по-прежнему запрашиваются через саги. Но мы не используем routeDependenciesSaga. У нас есть компоненты контейнера, запрашивающие данные. Это решает проблему аутентификации. Поскольку компонент контейнера страницы не отображается, когда пользователь не вошел в систему, он не будет запрашивать зависимости данных. Так что запрос не пройдет. Когда он отображается, вы можете быть уверены, что пользователь вошел в систему и может запросить данные.

Решение проблемы удаления данных также приходит с компонентами контейнера. Когда компонент размонтируется, он может отправить действие, чтобы указать Redux удалить данные, которые ему больше не нужны.

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

Компонент высшего порядка fetchDependencies

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

export const UserContainer = compose(
   connect(
      (states) => ({
         showLoader: selectUserPending(state)
      }),
      dispatch => ({
         askForData: id => {
            dispatch(getUser(id));
         },
         deleteData: id => {
            dispatch(clearUser(id));
         },
      }),
   ),
   fetchDependencies({
      onLoad: ({ id, askForData }) => {
         askForData(id);
      },
      onUnload: ({ id, deleteData }) => {
         deleteData(id);
      },
      shouldReFetch: (oldProps, newProps) => 
          (oldProps.id !== newProps.id),
   }),
   loadable,
)(User);

Как видите, контейнер ничего не знает о текущем маршруте. Все, что он делает, это запрашивает данные о монтировании компонента и удаляет данные о размонтировании компонента. Он повторно запрашивает данные при изменении реквизита.

Добавление контекста React-маршрутизатора

Существует еще один HOC, который называется routeDependencies. HOC добавляет доступ к контексту реактивного маршрутизатора, чтобы вы могли управлять своими данными при изменении маршрута. Это действительно полезно, когда вы используете строку запроса для хранения состояния приложения или при использовании react-router@4 с его динамической маршрутизацией.

Вывод

Это в основном то, как мы сейчас управляем зависимостями данных в наших приложениях. Мы используем шаблон компонента представления/контейнера для компонентов React, мы храним данные в хранилище Redux, мы используем саги для бизнес-логики и используем HOC fetchDependencies и routeDependencies, чтобы запрашивать данные и удалять данные, которые нам не нужны.

Статья может быть немного запутанной. Я показал вам кое-что о компонентах контейнеров, затем переключился на саги, а затем вернулся к контейнерам, но немного подробнее. Однако именно так мы разработали текущее решение с HOC. Такое ощущение, что это лучшее решение, которое мы нашли до сих пор. Он поощряет использование возможностей, которые React дает вам для упрощения вашей бизнес-логики, и дает вам простой способ написания повторно используемых компонентов. И это по-прежнему поощряет разделение интересов.

PS: Во время написания статьи мне пришла в голову идея. Не очень понятно, как хранить данные в хранилище Redux и как их получать для отображения в презентационных компонентах. Моя следующая статья будет об управлении данными Redux.

PPS: Если вы заинтересованы в реализации обоих HOC, ознакомьтесь с нашим пакетом npm @ackee/chris (fetchDependencies, routeDependencies )!

PPPS: HOC написаны таким образом, что их можно легко переписать в React hooks! Я с нетерпением жду возможности переписать его).