Как связка React и RxJS улучшила код и ускорила разработку мобильных приложений

Kate

Administrator
Команда форума
В компании SDVentures мы часто используем на проектах связку React + RxJS. Это довольно таки нетрадиционная связка, так что о ней мало что можно найти в интернете. Поэтому постараюсь рассказать о том, почему мы с командой стали её использовать и чем это может быть полезно вам.

8b81c991833465f91c2b62661de239f2.jpg

Много бизнес-логики мы пишем на клиенте

Большинство некритичной бизнес–логики во многих крупных проектах пишется на клиенте, а не бэкенде. Давайте остановимся тут чуть более подробнее и разберем, почему наша компания не стала исключением.

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

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

  1. Связанные с некритичными и индивидуальными для пользователя таймерами. Создание для каждого пользователя своего таймера на бекенде может чересчур нагружать систему;
  2. Где данные, которые необходимы для определения показа определенного функционала, уже присутствуют на клиенте. Быстрее считать данные на нём с помощью агрегации нескольких источников, чем тратить время на HTTP запрос к бэкенду.
  3. Связанные с A/B тестированием. Как обычно бывает, большая часть экспериментов признаются неудачными, поэтому эксперимент проще и быстрее проверить на клиентской стороне, а если он покажет результат, то уже сделать необходимые изменения на стороне бэкенда. Поверьте, так будет гораздо проще, чем сразу вносить несколько возможных веток в сложную распределенную систему.
Это все привело нас к тому, что в нашем клиенте появилось очень много сложных пайплайнов бизнес-логики.

Как мы дошли до связки React и RxJS?

К такой связке мы пришли постепенно и нам, конечно же, потребовалось некоторое время, чтобы на ней стало удобно разрабатывать что-то новое и поддерживать старый функционал. Раньше для описания клиентской бизнес-логики мы использовали Flux. Но описывать сложные пайплайны, упомянутые раньше, было неудобно, получалось нечитаемо. Юнит-тесты практически не писались, требовалось много бойлерплейта.

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

На тот момент активно развивались и пользовались популярностью фреймворки Redux и MobX, однако они не упрощали работу с пайплайнами относительно Flux, а Redux также требовал на порядок больше бойлерплейта. В общем, везде свои проблемы. У доброй части моих коллег уже имелся опыт работы с Rx* фреймворками в нативной мобильной и .Net разработке, поэтому тут поступило предложение рассмотреть вариант использования RxJS.

На просторах интернета и самого Хабра есть много замечательных статей по теме RxJS, в чем отличие Observable от Promise, поэтому не буду касаться этого в своем материале. Скажу только, что RxJS в общем случае — не замена MobX или Redux. Это просто отличный фреймворк для декларативного описания бизнес-логики. При желании его можно подружить с Redux — есть даже готовые библиотеки для реализации Middleware Redux на RxJS (redux-observable). Мы же при использовании RxJS реализовали свои lightweight сторы и не трогали другие React-ориентированные фреймворки.

А теперь, пожалуй, перейдем к тому, чем мы руководствовались при выборе RxJS:

  • Читабельность.
    1. Код становится декларативным. К этому нас побуждают ленивые вычисления, ведь Observable это лишь набор инструкций – он начнет выполняться, только если на него подписаться.
    2. Мы можем без проблем выделять и переиспользовать какие-то часто используемые Observable сценарии в отдельные переменные или методы.
    3. Большое количество встроенных операторов позволяют не тратить время на изучение того, что происходит в коде, а сразу понимать это по операторам.
    4. Удобные преобразования одной асинхронной операции в другую (аналог Promise.then, только декларативный) избавляют нас от callback hell.
  • Тестируемость. Фреймворк предоставляет хороший набор инструментов для удобного тестирования. Есть свой собственный способ тестирования через marble диаграммы, присутствуют удобные утилиты для тестирования сложной логики с таймерами.
  • Скорость разработки. RxJS содержит большое количество встроенных операторов для построения Observable. Он не ограничивается базовыми filter, map, merge, zip. Здесь есть удобные операторы для добавления логики троттлинга, можно удобно работать с таймерами, буферами, агрегациями и др.
Стоит вам только захотеть, и RxJS позволит сделать все реактивным и работать с различными событиями или действиями пользователя в виде наборов инструкций. Например, события браузера и других библиотек можно обернуть в Observable и использовать в своих RxJS пайпланах.

Типичный сценарий бизнес-логики​

Теперь вернемся к тому, как можно на RxJS описать типичный (для большинства продуктов компаний) сценарий сложной клиентской бизнес-логики. Представим следующие требования:

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

На RxJS функция отправки может выглядеть следующим образом:

/* Dependencies
function userIsUsingDemoMode(): Observable<boolean>

function updateUserCredentials(): Observable<Result<void, 'cancelled'>>

function executeSendPresentRequest(
present: Present,
recipientId: string
): Observable<Result<void, 'insufficient-funds'>>

function askUserForPresentSendingConfirmation(present: Present, recipientId: string): Observable<boolean>

function refillBalance(): Observable<Result<void, 'cancelled'>>

function showPresentSuccessfullySentPopup(present: Present, recipientId: string): void
*/

function sendPresent(present: Present, recipientId: string): Observable<Result<void, 'unauthorized' | 'insufficient-funds' | 'cancelled'>> {
const authorizeUser = userIsUsingDemoMode().pipe(
take(1),
switchMap(userIsUsingDemoMode => userIsUsingDemoMode ? updateUserCredentials() : of(Result.success()))
)

const sendPresentWithBalanceRefilling: Observable<Result<void, PresentSendingError>> = executeSendPresentRequest(
present,
recipientId
).pipe(
switchMap(result => {
return result.error === 'insufficient-funds'
? refillBalance().pipe(
switchMap(refillingResult => refillingResult.isSuccessful ? sendPresentWithBalanceRefilling : of(result))
)
: of(result)
})
)

return authorizeUser.pipe(
switchMap(authorizationResult => {
return authorizationResult.isSuccessful
? askUserForPresentSendingConfirmation(present, recipientId).pipe(
switchMap(confirmed => confirmed ? sendPresentWithBalanceRefilling : of(Result.failure('cancelled')))
)
: of(Result.failure('unauthorized'))
}),
tap(result => {
if (result.isSuccessful) {
showPresentSuccessfullySentPopup(present, recipientId)
}
})
)
}
Как вы видим, достаточно не простую асинхронную логику можно уместить в 30 строчек кода, при этом оставив ее читабельной.

Связка с React​

Перейдем к тому, как же подружить RxJS с React-ом. Для того, чтобы связать два этих мира мы используем самописные хуки.

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

export function useObservable<T>(src: () => Observable<T>, deps: DependencyList, initial: T): T
export function useObservable<T>(src: () => Observable<T>, deps: DependencyList): T | undefined
export function useObservable<T>(src: () => Observable<T>, deps: DependencyList, initial?: T): T | undefined {
const [latestValue, setLatestValue] = useState(initial)

const subscription = useRef<Subscription | undefined>(undefined)
const prevDeps = useRef(deps)

if (!subscription.current || !hookDepsAreEqual(deps, prevDeps.current)) {
if (subscription.current) {
prevDeps.current = deps
subscription.current.unsubscribe()
setLatestValue(initial)
}

subscription.current = src().pipe(catchErrorJustLog()).subscribe(setLatestValue)
}

useEffect(() => {
return () => {
if (subscription.current) {
subscription.current.unsubscribe()
}
}
}, [])

return latestValue
}
Как мы видим, хук подписывается на Observable и записывает в state значения, которые были из него получены. Если компонент анмаунтится, то происходит отписка от Observable. Если же меняются deps-ы, то происходит отписка от старого Observable и подписка на новый.

Однако эта версия не содержит оптимизации. В проекте мы используем версию, которая не вызывает перерисовку компонента, если значение из Observable выстрелит синхронно или если новое значение не отличается от старого:

export function useObservable<T>(src: () => Observable<T>, deps: DependencyList, initial: T): T
export function useObservable<T>(src: () => Observable<T>, deps: DependencyList): T | undefined
export function useObservable<T>(src: () => Observable<T>, deps: DependencyList, initial?: T): T | undefined {
const [, reload] = useState({})
const store = useRef<UseObservableHookStore<T>>({
value: initial,
subscription: undefined,
deps: deps,
subscribed: false
})
useEffect(() => {
const storeValue = store.current
return (): void => {
storeValue.subscription && storeValue.subscription.unsubscribe()
}
}, [])
if (!store.current.subscription || !hookDepsAreEqual(deps, store.current.deps)) {
if (store.current.subscription) {
store.current.subscription.unsubscribe()
store.current.value = initial
store.current.deps = deps
store.current.subscribed = false
}
store.current.subscription = src()
.pipe(catchErrorJustLog())
.subscribe(value => {
if (store.current.value !== value) {
store.current.value = value
if (store.current.subscribed) {
reload({})
}
}
})
store.current.subscribed = true
}
return store.current.value
}
Рассмотрим, как это можно использовать в React на примере компонента отображающего статус присутствия пользователя:

/* Dependencies
class UserPresence {
readonly presence: Observable<{ online: boolean, devices: string[] }
constructor(userId: string)
}
*/

type Props = {
userId: string
}

export const UserOnlineStatus = memo((props: Props) => {
const userIsOnline = useObservable(() => {
return new UserPresence(props.userId).presence.pipe(
map(it => it.online)
)
}, [props.userId])
return <span>{userIsOnline ? 'Online' : 'Offline'}</span>
})
Для выполнения действий (action-ов) у нас есть дополнительный хук useObservableAction, который при необходимости отпишется от Observable при анмаунте компонента.

export function useObservableAction<Args extends any[]>(
action: (...args: Args) => Observable<any>,
deps: DependencyList,
unsubscribeOnUnmount: boolean = true
): (...args: Args) => void {
const subscription = useRef<Subscription>()
useEffect(() => {
return (): void => {
if (subscription.current && unsubscribeOnUnmount) {
subscription.current.unsubscribe()
}
}
}, [])

return useCallback((...args) => {
if (subscription.current) {
subscription.current.unsubscribe()
}
subscription.current = action(...args).pipe(catchErrorJustLog()).subscribe()
}, deps)
}

/* Dependencies
class UserRelations {
constructor(userId: string)
userIsMarkedAsFavorite(targetId: string): Observable<boolean>
markUserAsFavorite(targetId: string, favorite: boolean): Observable<void>
}
*/

type Props = {
userId: string
targetId: string
}

export const ToggleFavoriteButton = memo((props: Props) => {
const userRelations = useMemo(() => new UserRelations(props.userId), [props.userId])

const targetUserIsMarkedAsFavorite = useObservable(() => userRelations.userIsMarkedAsFavorite(props.targetId), [props.targetId, userRelations])

const toggleFavorite = useObservableAction(() => {
return userRelations.markUserAsFavorite(props.targetId, !targetUserIsMarkedAsFavorite)
}, [targetUserIsMarkedAsFavorite, props.targetId, userRelations], false)


if (typeof targetUserIsMarkedAsFavorite === 'undefined') {
return null
}

return <button onClick={toggleFavorite}>Toggle Favorite</button>
})

Подведем итоги​

К чему мы пришли в сухом остатке? Пожалуй, стоит начать с того, что RxJS — не полноценный фреймворк. Он может использоваться для декларативного описания кода, но все еще остается проблема хранения и восстановления состояния, например, для SSR. Наша команда предпочла использовать самописные Rx сторы, которые мы называем DataModel. Они хранят состояние с возможностью указания ttl; поддерживают сериализацию и десериализацию, которую мы используем в SSR. Модели нашей бизнес-логики уже достают данные из DataModel и их комбинаций. Но никто не запрещает вам использовать Redux, MobX и другие фреймворки для работы с состояниями и иметь Rx прослойку для описания сложных асинхронных преобразований. Как говорится, на все воля ваша.

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

Без минусов, конечно, не обойтись. Поэтому тут я отмечу, что нам стало гораздо сложнее найти разработчиков в команду, потому что теперь желательно иметь опыт работы не только с React, но и с RxJS. Но минус незнания RxJS легко закрывается нашей развитой системой наставничества сотрудников.

 
Сверху