Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Иногда простейшая реализация функциональности в конечном итоге создает больше проблем, чем пользы, только увеличивая сложность в другом месте. Конечным результатом является забагованная архитектура, которую никто не хочет трогать.
Статья была написана в 2017 году, но актуальна и по сей день. Нацелена на людей опытных в RxJS и Ngrx, либо желающих попробовать Redux в Angular.
Фрагменты кода были обновлены исходя из текущего синтаксиса RxJS и немного модифицированы для улучшения читабельности и простоты понимания.
Ngrx/store — это библиотека Angular, которая помогает сдерживать сложность отдельных функций. Одна из причин заключается в том, что ngrx/store охватывает функциональное программирование, которое ограничивает то, что может быть сделано внутри функции, для достижения большей разумности вне ее. В ngrx/store такие вещи как reducers (далее редукторы), selectors (далее селекторы) и операторы RxJS являются чистыми функциями.
Чистые функции проще тестировать, отлаживать, анализировать, распараллеливать и комбинировать. Функция чиста, если:
- при одинаковых входных данных она всегда возвращает одни и те же выходные данные;
- не имеет побочных эффектов.
Побочных эффектов невозможно избежать, но они изолированы в ngrx/store, так что остальная часть приложения может состоять из чистых функций.
Побочные эффекты
Когда пользователь отправляет форму, нам нужно внести изменения на сервере. Изменение на сервере и ответ клиенту является побочным эффектом. Это может быть обработано в компоненте:
this.store.dispatch({
type: 'SAVE_DATA',
payload: data,
});
this.saveData(data) // POST запрос к серверу
.pipe(map(res => this.store.dispatch({ type: 'DATA_SAVED' })))
.subscribe();
Было бы хорошо, если бы мы могли просто отправлять (dispatch) action (далее действие) внутри компонента, когда пользователь отправляет форму, и обрабатывать побочный эффект в другом месте.
Ngrx/effects — это middleware для обработки побочных эффектов в ngrx/store. Он прослушивает отправленные действия в потоке observable, выполняет побочные эффекты и возвращает новые действия немедленно или асинхронно. Возвращенные действия передаются в reducer.
Возможность обрабатывать побочные эффекты RxJS-способом делает код чище. После отправки начального действия SAVE_DATA
из компонента вы создаете класс эффектов для обработки остальных:
@Effect()
saveData$ = this.actions$.pipe(
ofType('SAVE_DATA'),
pluck('payload'),
switchMap(data => this.saveData(data)),
map(res => ({ type: 'DATA_SAVED' })),
);
Это упрощает работу компонента лишь до отправки действий и подписки на observable.
Легко злоупотребить Ngrx/effects
Ngrx/effects — очень мощное решение, поэтому им легко злоупотребить. Вот некоторые распространенные анти-паттерны ngrx/store, которые Ngrx/effects упрощает:
1. Дублированное состояние
Допустим, вы работаете над каким-то мультимедийным приложением, и у вас есть следующие свойства в дереве состояний:
export interface State {
mediaPlaying: boolean;
audioPlaying: boolean;
videoPlaying: boolean;
}
Поскольку аудио является типом мультимедиа, всякий раз, когда audioPlaying
имеет значение true, mediaPlaying
также должно иметь значение true. Итак, вот вопрос: «Как мне убедиться, что mediaPlaying обновляется при обновлении audioPlaying
?»
Неверный ответ: используйте Ngrx/effects!
@Effect()
playMediaWithAudio$ = this.actions$.pipe(
ofType('PLAY_AUDIO'),
map(() => ({ type: 'PLAY_MEDIA' })),
);
Правильный ответ: если состояние mediaPlaying
полностью предсказывается другой частью дерева состояний, то это не истинное состояние. Это производное состояние. Это принадлежит селектору, а не store.
audioPlaying$ = this.store.select('audioPlaying');
videoPlaying$ = this.store.select('videoPlaying');
mediaPlaying$ = combineLatest(this.audioPlaying$, this.videoPlaying$).pipe(
map(([audioPlaying, videoPlaying]) => audioPlaying || videoPlaying),
);
Теперь наше состояние может оставаться чистым и нормализованным, и мы не используем Ngrx/effects для чего-то, что не является побочным эффектом.
2. Сцепление действий c reducer
Представьте, что у вас есть эти свойства в вашем дереве состояний:
export interface State {
items: { [index: number]: Item };
favoriteItems: number[];
}
Затем пользователь удаляет элемент. Когда запрос на удаление возвращается, действие DELETE_ITEM_SUCCESS
отправляется, чтобы обновить состояние нашего приложения. В редукторе items
отдельный Item
удаляется из объекта items
. Но если этот идентификатор элемента был в массиве favoriteItems
, элемент, на который он ссылается, будет отсутствовать. Итак, вопрос в том, как я могу убедиться, что идентификатор удален из favoriteItems
при отправке действия DELETE_ITEM_SUCCESS
?
Неверный ответ: используйте Ngrx/effects!
@Effect()
removeFavoriteItemId$ = this.actions$.pipe(
ofType('DELETE_ITEM_SUCCESS'),
map(() => ({ type: 'REMOVE_FAVORITE_ITEM_ID' })),
);
Итак, теперь у нас будет два действия, отправляемых друг за другом, и два редуктора, возвращающие новые состояния друг за другом.
Правильный ответ: DELETE_ITEM_SUCCESS
может обрабатываться как редуктором items
, так и редуктором favoriteItems
.
export function favoriteItemsReducer(state = initialState, action: Action) {
switch (action.type) {
case 'REMOVE_FAVORITE_ITEM':
case 'DELETE_ITEM_SUCCESS':
const itemId = action.payload;
return state.filter(id => id !== itemId);
default:
return state;
}
}
Цель действий — отделить то, что произошло, от того, как должно измениться состояние. То, что произошло, было DELETE_ITEM_SUCCESS
. Задача редукторов — вызвать соответствующее изменение состояния.
Удаление идентификатора из favoriteItems
не является побочным эффектом удаления Item
. Весь процесс полностью синхронен и может быть обработан редукторами. Ngrx/effects не нужен.
3. Запрос данных для компонента
Вашему компоненту нужны данные из store, но сначала их нужно получить с сервера. Вопрос в том, как мы можем поместить данные в store, чтобы компонент мог их получить?
Болезненный способ: используйте Ngrx/effects!
В компоненте мы инициируем запрос, отправив действие:
ngOnInit() {
this.store.dispatch({ type: 'GET_USERS' });
}
В классе эффектов мы слушаем GET_USERS
:
@Effect
getUsers$ = this.actions$.pipe(
ofType('GET_USERS'),
withLatestFrom(this.userSelectors.needUsers$),
filter(([action, needUsers]) => needUsers),
switchMap(() => this.getUsers()),
map(users => ({ type: 'RECEIVE_USERS', users })),
);
Теперь предположим, что пользователь решает, что определенный route грузится слишком много времени, поэтому он с него переходит на другой. Чтобы быть эффективными и не загружать ненужные данные, мы хотим отменить этот запрос. Когда компонент будет уничтожен, мы отменим подписку на запрос, отправив действие:
ngOnDestroy() {
this.store.dispatch({ type: 'CANCEL_GET_USERS' });
}
Теперь в классе эффектов мы слушаем оба действия:
@Effect
getUsers$ = this.actions$.pipe(
ofType('GET_USERS', 'CANCEL_GET_USERS'),
withLatestFrom(this.userSelectors.needUsers$),
filter(([action, needUsers]) => needUsers),
map(([action, needUsers]) => action),
switchMap(
action =>
action.type === 'CANCEL_GET_USERS'
? of()
: this.getUsers().pipe(map(users => ({ type: 'RECEIVE_USERS', users }))),
),
);
Хорошо. Теперь другой разработчик добавляет компонент, которому требуется тот же HTTP-запрос (мы не будем делать никаких предположений о других компонентах). Компонент отправляет те же действия в тех же местах. Если оба компонента активны одновременно, первый компонент для своей инициализации инициирует HTTP-запрос. Когда второй компонент инициализируется, ничего дополнительного не произойдет, потому что needUsers
будет false
. Замечательно!
Затем, когда первый компонент будет уничтожен, он отправит CANCEL_GET_USERS
. Но второй компонент все еще нуждается в этих данных. Как мы можем предотвратить отмену запроса? Может быть заведем счетчик всех подписчиков? Я не собираюсь реализовывать это, но вы, полагаю, поняли суть. Мы начинаем подозревать, что есть лучший способ управления этими зависимостями данных.
Теперь предположим, что появляется еще один компонент, и он зависит от данных, которые невозможно извлечь до тех пор, пока данные users
не появятся в store. Это может быть подключение к веб-сокету для чата, дополнительная информация о некоторых пользователях или что-то еще. Мы не знаем, будет ли этот компонент инициализирован до или после подписки двух других компонентов на users
.
Лучшая помощь, которую я нашел для этого конкретного сценария, — это этот отличный пост. В его примере callApiY
требует, чтобы callApiX
уже был завершен. Я убрал комментарии, чтобы это выглядело менее пугающе, но не стесняйтесь читать оригинальный пост, чтобы узнать больше:
@Effect()
actionX$ = this.actions$.pipe(
ofType('ACTION_X'),
map(toPayload),
switchMap(payload =>
this.api.callApiX(payload).pipe(
map(data => ({ type: 'ACTION_X_SUCCESS', payload: data })),
catchError(err => of({ type: 'ACTION_X_FAIL', payload: err })),
),
),
);
@Effect()
actionY$ = this.actions$.pipe(
ofType('ACTION_Y'),
map(toPayload),
withLatestFrom(this.store.select(state => state.someBoolean)),
switchMap(([payload, someBoolean]) => {
const callHttpY = v => {
return this.api.callApiY(v).pipe(
map(data => ({
type: 'ACTION_Y_SUCCESS',
payload: data,
})),
catchError(err =>
of({
type: 'ACTION_Y_FAIL',
payload: err,
}),
),
);
};
if (someBoolean) {
return callHttpY(payload);
}
return of({ type: 'ACTION_X', payload }).merge(
this.actions$.pipe(
ofType('ACTION_X_SUCCESS', 'ACTION_X_FAIL'),
first(),
switchMap(action => {
if (action.type === 'ACTION_X_FAIL') {
return of({
type: 'ACTION_Y_FAIL',
payload: 'Because ACTION_X failed.',
});
}
return callHttpY(payload);
}),
),
);
}),
);
Теперь добавьте требование, что HTTP-запросы должны быть отменены, когда компоненты в них больше не нуждаются, и это станет еще более сложным.
. . .
Итак, почему так много проблем с управлением зависимостями данных, когда RxJS должен делать это действительно легко?
Хотя данные, поступающие с сервера, технически являются побочным эффектом, мне не кажется, что Ngrx/effects — лучший способ справиться с этим.
Компоненты — это интерфейсы ввода/вывода для пользователя. Они показывают данные и отправляют действия, произведенные им. Когда компонент загружается, он не отправляет никаких действий, произведенных этим пользователем. Он хочет показать данные. Это скорее похоже на подписку, нежели на побочный эффект.
Очень часто можно увидеть приложения, использующие действия для инициации запроса данных. Эти приложения реализуют специальный интерфейс для observable через побочные эффекты. И, как мы видели, этот интерфейс может стать очень неудобным и громоздким. Подписываться на, отписываться от и связывать сами observable гораздо проще.
. . .
Менее болезненный способ: компонент зарегистрирует свою заинтересованность в данных, подписавшись на них через observable
Мы создадим observable, которые содержат нужные HTTP-запросы. Мы увидим, насколько проще управлять несколькими подписками и цепочками запросов, зависящими друг от друга, используя чистый RxJS, нежели делать это через эффекты.
Создадим эти observable в сервисе:
requireUsers$ = this.store.pipe(
select(selectNeedUser),
filter(needUsers => needUsers),
tap(() => this.store.dispatch({ type: 'GET_USERS' })),
switchMap(() => this.getUsers()),
tap(users => this.store.dispatch({ type: 'RECEIVE_USERS', users })),
finalize(() => this.store.dispatch({ type: 'CANCEL_GET_USERS' })),
share(),
);
users$ = muteFirst(
this.requireUsers$.pipe(startWith(null)),
this.store.pipe(select(selectUsers)),
);
Подписка на users$
будет передаваться как на requireUsers$
, так и на this.store.pipe(select(selectUsers))
, но данные будут получены только от this.store.pipe(select(selectUsers))
(пример реализации muteFirst
и исправленный muteFirst
с ее тестом.)
В компоненте:
ngOnInit() {
this.users$ = this.userService.users$;
}
Поскольку эта зависимость данных теперь простой observable, мы можем подписаться и отписаться в шаблоне, используя async
pipe, и нам больше не нужно отправлять действия. Если приложение уходит с роута последнего компонента, подписанного на данные, HTTP-запрос отменяется или веб-сокет закрывается.
Цепочку зависимостей данных можно обрабатывать так:
requireUsers$ = this.store.pipe(
select(selectNeedUser),
filter(needUsers => needUsers),
tap(() => this.store.dispatch({ type: 'GET_USERS' })),
switchMap(() => this.getUsers()),
tap(users => this.store.dispatch({ type: 'RECEIVE_USERS', users })),
share(),
);
users$ = muteFirst(
this.requireUsers$.pipe(startWith(null)),
this.store.pipe(select(selectUsers)),
);
requireUsersExtraData$ = this.users$.pipe(
withLatestFrom(this.store.pipe(select(selectNeedUsersExtraData))),
filter(([users, needData]) => Boolean(users.length) && needData),
tap(() => this.store.dispatch({ type: 'GET_USERS_EXTRA_DATA' })),
switchMap(() => this.getUsers()),
tap(users =>
this.store.dispatch({
type: 'RECEIVE_USERS_EXTRA_DATA',
users,
}),
),
share(),
);
public usersExtraData$ = muteFirst(
this.requireUsersExtraData$.pipe(startWith(null)),
this.store.pipe(select(selectUsersExtraData)),
);
Вот параллельное сравнение вышеупомянутого метода с этим методом:
Использование чистого observable требует меньше строк кода и автоматически отписывается от зависимостей данных по всей цепочке. (Я пропустил операторы finalize
, которые изначально были включены, чтобы сделать сравнение более понятным, но даже без них запросы все равно будут соответственно отменены.)
Заключение
Ngrx/effects — прекрасный инструмент! Но рассмотрите эти вопросы, прежде чем использовать его:
- Это действительно побочный эффект?
- Действительно ли Ngrx/effects — лучший способ сделать это?