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

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

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

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

Мы с командой работаем над большим 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 легко закрывается нашей развитой системой наставничества сотрудников.