Angular для младших разработчиков: опасности и сокровища RxJS
В этой статье мы покажем вам, как избежать известных опасных ловушек RxJS, и поделимся некоторыми полезными фрагментами.
Опасные операторы
Некоторые операторы могут вызывать бесконечные циклы или заставлять ваш observable вести себя не так, как вы ожидаете. Давайте начнем с самого опасного.
combineLatestWith()
Это очень удобный оператор — обычно вы хотите передать туда одну или несколько наблюдаемых объектов и отреагировать на них.
// Mocks of the sources, just for illustration
const showNames$: Observable<boolean> = of(false);
const showEmails$: Observable<boolean> = of(false);
const users$ = of([{name: 'a', email: 'a@a'}]);
// Now let's create an observable
// using `combineLatestWith()`
const usersList = users$.pipe(
combineLatestWith(
showNames$,
showEmails$,
),
// when `showNames$` OR `showEmails$` generate a new value,
// we'll get latest values from all of them:
map(([users, showNames, showEmails]) => {
if (showEmails && showNames) {
return users;
}
return users.map((user) => ({
...user,
name: showNames ? user.name : '',
email: showEmails ? user.email : ''
}));
})
);
Если хотя бы один из наблюдаемых, переданных в combLatestWith()
, испускает, вы получите последние испускаемые значения из каждого наблюдаемого. Но это удобство сопряжено с двумя опасными ловушками:
#1: Он не будет излучаться до тех пор, пока каждая наблюдаемая не создаст хотя бы одно значение.
Если вы подписываетесь на какие-то горячие наблюдаемые, у вас нет гарантии, что вы получите какие-либо значения в момент подписки.
Каждый наблюдаемый объект, переданный в combineLatestWith()
, должен быть перепроверен - вы должны быть уверены, что будет выдано хотя бы одно значение.
Если вы не уверены в этом, используйте startWith()
и передайте туда какое-то значение по умолчанию, которое ваш код будет ожидать и может обрабатывать. В противном случае слишком легко создать «вечно висящий» наблюдаемый объект, когда задействована функция CombineLatestWith()
.
const usersList = users$.pipe(
combineLatestWith(
showNames$.pipe(startWith(true)), // 👈
showEmails$.pipe(startWith(true)),
),
map(([users, showNames, showEmails]) => {
//
})
);
#2: Если ваш код вызовет одно из наблюдаемых (переданных в combLatestWith()
) для выдачи нового значения, вы создадите бесконечный цикл.
Пример:
observable$.pipe(
combineWithLatest(toggle$),
map(([value, currentToggleValue]) => {
if (value && currentToggleValue) {
toggle$.next(true); // infinite loop
}
})
);
distinctUntilChanged()
Очень удобный оператор, что может пойти не так?
Когда вам нужно обнаружить изменения в строках, числах и других примитивных типах, все в порядке. Но если вам нужно обнаружить изменение в объекте, то этот оператор может работать не так, как вы ожидаете — если объект остается прежним, и изменился только какой-то ключ или подключей — этот оператор не будет уведомлять вас об изменениях.
В таких случаях вы можете установить аргумент «компаратор» или использовать оператор distinctUntilKeyChanged().
forkJoin()
В основном вы будете использовать его для параллельного выполнения нескольких запросов и получения ответов, когда все они будут завершены.
В этом операторе есть 3 опасные вещи:
# 1: Как сказано в документации: “Если в какой-либо момент возникнут какие-либо наблюдаемые ошибки, forkJoin также выдаст ошибку и немедленно откажется от подписки на другие наблюдаемые”.
В большинстве случаев это не то, что вы ожидаете, но обходной путь довольно прост: добавьте catchError(() => of(someValue))
к каждому запросу:
let ob$ = forkJoin([
req1.pipe(catchError(() => of(someValue))),
req2.pipe(catchError(() => of(someValue))),
//...
]);
// or, if you have requests in an array
let ob$ = forkJoin(
requests.map(req => req.pipe(catchError(() => of(someValue))))
);
Хотя, не возвращайте EMPTY
, из-за следующего пункта:
# 2: Как сказано в документации: “...всякий раз, когда какая-либо из заданных наблюдаемых завершается без выдачи какого-либо значения, forkJoin также завершится в этот момент, и он также ничего не выдаст, даже если у него уже есть некоторые последние значения из других наблюдаемых”.
Это случается не так часто, но все же может случиться (если какой-то наблюдаемый в списке вернул EMPTY
или фактически не завершился). И в вашем приложении это может создать довольно неприятную ошибку — это будет выглядеть так, как будто ваше приложение не отвечает и ничего не происходит.
Чтобы избежать этого, вы можете использовать defaultIfEmpty()
:
const ob$ = forkJoin(
requests.map(req => req.pipe(
catchError(() => of(someValue)),
defaultIfEmpty(someValue)
))
);
#3: Как сказано в документации: «если есть наблюдаемое, которое никогда не завершится, forkJoin тоже никогда не завершится».
Вполне возможно, что запросы HTTP (XHR) зависнут. В этом случае простым решением является оператор timeout()
.
Для других (не-HTTP, горячих) наблюдаемых есть вероятность, что вы подписываетесь на просмотр уже отправленного события. Общего решения для таких случаев нет.
takeUntil()
Опасности этого оператора легко избежать: он должен быть последним в последовательности операторов. С двумя исключениями: это должно быть перед shareReplay({refCount: false})
и перед операторами агрегирования (список).
Это может показаться немного утомительным, но вы можете просто делегировать управление линтеру и забыть об этом.
Неосторожное использование switchMap()
Знаете ли вы разницу между switchMap()
, concatMap()
, mergeMap()
и ExhaustMap()
? Оказывается, люди небрежно используют switchMap()
в тех местах, где это может вызвать нежелательные эффекты.
Это не значит, что switchMap()
плохой или имеет какие-то недостатки — это суперполезный и хороший оператор, просто люди им злоупотребляют.
Несвязанные методы
Люди ленивы, поэтому мы можем написать такой код, как этот:
ob$.pipe(
catchError(this.errHandler)
);
Это будет работать до тех пор, пока this.errHandler()
не использует this
— потому что this
будет что-то неожиданное.
Этого довольно легко избежать:
ob$.pipe(
catchError(() => this.errHandler())
);
Операторы сокровища
takeUntil()
Этот оператор позволит вам избежать утечек памяти в Angular Components — просто добавьте его (последним, как уже было сказано) в список операторов перед subscribe()
, и проблема решена! Это самое простое и удобное решение.
Для этого есть супер-удобный линтер.
Вам не нужен этот оператор, если вы подписываетесь с помощью async
канала.
finalize()
При составлении последовательности операторов, если вы думаете, что «после того, как все это закончится, мы должны сделать это», вы можете использовать finalize()
.
Это намного надежнее, чем просто добавить какой-то код в subscribe()
или map()
, tap()
, catchError()
— этот оператор будет выполняться, даже если наблюдаемое завершится без выдачи каких-либо значений. А в некоторых случаях это чистое золото.
ob$.pipe(
tap(() => this.patchState({loading: true})),
finalize(() => this.patchState({loading: false})),
exhaustMap(() => this.api.createNewSomething())
);
first()
Используйте этот оператор, когда вы ожидаете, что наблюдаемый объект выдаст только одно значение (например, HTTP-запрос), но вы не можете этого гарантировать — обычно это происходит в функциях/методах/эффектах, где вы принимаете какой-либо наблюдаемый объект в качестве аргумента.
Без этого оператора могут появиться очень любопытные побочные эффекты и ошибки, если наблюдаемый объект начнет выдавать несколько значений, когда ваш код к этому не готов.
throttleTime()
Если вы когда-нибудь пытались воспроизвести эту логику: «принять первое сгенерированное значение, запустить какой-то побочный эффект, затем игнорировать любые последовательные значения в течение N миллисекунд, затем, если за это время было сгенерировано какое-то новое значение — также сгенерировать его», то threadthrow()
спасет ваш день.
Он работает аналогично debounceTime()
, хотя его аргумент конфигурации имеет leading
и trailing
параметры, и это поможет вам создать более сложное поведение.
Пример логики, упомянутой выше:
input$.pipe(
throttleTime(250, undefined, {leading: true, traling: true}),
distinctUntilChanged(),
switchMap((input) => this.api.loadItems(value))
);
tapResponse()
И последним в этом списке будет сторонний оператор (не от RxJS).
Вы можете взять его из библиотеки ngrx/component-store.
Этот оператор поможет вам не забывать обрабатывать случаи ошибок:
ob$.pipe(
tapResponse(
(result) => this.handle(result),
// the next argument is required, so you will not forget it
(err) => console?.error(err)
)
);