Данный перевод является русскоязычной интерпретацией документации, которую я сам и написал, поэтому не стесняйтесь задавать вопросы.


Введение


Обрабатывать пользовательский ввод может быть не так просто, как кажется. Мы же не хотим отправлять запросы на сервер пока пользователь всё ещё набирает свой запрос? И, конечно же, пользователь должен всегда видеть результат на последний запрос, который он отослал.


Существуют разные способы реагирования на интерактивные события в React приложениях, и, по моему мнению, реактивный подход (благодаря таким библиотекам, как RxJS или Bacon) — один из самых лучших. Вот только для того, чтобы использовать RxJS и React одновременно, Вам придётся иметь дело с жизненным циклом React компонента, вручную управлять подписками на потоки и так далее. Хорошая новость — всё это можно делать автоматически с помощью RxConnect — библиотеки, разработанной в процессе миграции с Angular на React в ZeroTurnaround.



Мотивация


Сначала был React. И было Благо.


… Но затем люди поняли, что делать API запросы и раскидывать состояние приложения по разным компонентам — это не хорошо. И появилась Flux архитектура. И стало хорошо.


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


Вот только появилась другая проблема — стало сложно делать простые вещи, а каждый чих (такой как поле ввода логина) должен проходить через action creator-ы, reducer-ы, и храниться в глобальном состоянии. И тут все вспомнили, что у React компонента… может быть локальное состояние! Как хорошо подметил Dan:


Используйте состояние React компонента там, где это неважно для глобального состояния приложения и когда оно (локальное состояние) не меняется путём сложных трансформаций. Например, состояние checkbox-а, поле формы.
Используйте Redux как хранилище состояния для глобального состояния либо для состояния, которое изменяется путём сложных трансформаций. Например, кэш пользователей, или черновик статьи, вводимой пользователем.
Другими словами, делайте то, что кажется наименее странным ( неприемлемым ).

И RxJS как никогда лучше подходит для того, чтобы управлять этим локальным состоянием.


Рассмотрим пример:


Мы напишем простейший таймер, отображающий сколько секунд прошло, без RxJS или других библиотек:


class Timer extends React.Component {
    state = {
        counter: 0
    }

    componentWillMount() {
        setInterval(
            () => this.setState(state => ({ counter: state.counter + 1 })),
            1000
        )
    }

    render() {
        return <div>{ this.state.counter }</div>
    }
}

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


Значит, нам надо убедиться, что мы отпишемся от интервала перед тем, как компонент будет удалён:


class Timer extends React.Component {
    state = {
        value: 0
    }

    intervalRef = undefined;

    componentWillMount() {
        this.intervalRef = setInterval(
            () => this.setState(state => ({ value: state.value + 1 })),
            1000
        )
    }

    componentWillUnmount() {
        clearInterval(this.intervalRef);
    }

    render() {
        return <div>{ this.state.value }</div>
    }
}

Эта проблема настолько популярна, что существует даже библиотека для этого: https://github.com/reactjs/react-timer-mixin


А теперь представьте, что для каждого Promise-а, интервала, и любой другой асинхронщины нам придётся писать свои обработчики подписывания и отписывания, отдельные библиотеки. Хорошо, что есть RxJS — абстракция, позволяющая обрабатывать такие вещи реактивно.


Тот же самый пример, но используя один только RxJS, будет выглядеть примерно так:


class Timer extends React.Component {

    state = {
        value: 0
    }

    subscription = undefined;

    componentWillMount() {
        this.subscription = Rx.Observable.timer(0, 1000).timestamp().subscribe(::this.setState);
    }

    componentWillUnmount() {
        this.subscription.dispose();
    }

    render() {
        return <div>{ this.state.value }</div>
    }
}

Вот только не многовато ли кода для такой простой задачи? И что, если разработчик забудет вызвать dispose на подписке? И, раз у нас уже есть состояние в виде Rx.Observable.timer, зачем нам дублировать его в виде локального состояния компонента?


Вот тут-то нам и поможет RxConnect:


import { rxConnect } from "rx-connect";

@rxConnect(
    Rx.Observable.timer(0, 1000).timestamp()
)
class Timer extends React.PureComponent {
    render() {
        return <div>{ this.props.value }</div>
    }
}

(С примером можно поиграться на http://codepen.io/bsideup/pen/wzvGAE )


RxConnect реализован в виде Компонента Высшего Порядка и берёт на себя всю рутину по управлению подпиской, что делает Ваш код безопасней и улучшает читаемость. Так же компонент теперь есть функция от свойств, т.е. "Pure", что значительно упрощает тестирование за счёт отсутсвия внутреннего состояния.


А начиная с React 0.14 мы можем использовать функции как React компоненты без состояния, за счёт чего код можно превратить в одну строчку:


const Timer = rxConnect(Rx.Observable.timer(0, 1000).timestamp())(({ value }) => <div>{value}</div>)

Правда я нахожу вариант с классом гораздо более читаемым.


Жизненный пример


Таймеры — это хорошо, но чаще всего нам приходится иметь дело с скучными API и разного рода сервисами, поэтому давайте разберём более реалистичный пример — поиск статей с Wikipedia.


Компонент


Начнём с самого компонента:


class MyView extends React.PureComponent {
    render() {
        const { articles, search } = this.props;

        return (
            <div>
                <label>
                    Wiki search: <input type="text" onChange={ e => search(e.target.value) } />
                </label>

                { articles && (
                    <ul>
                        { articles.map(({ title, url }) => (
                            <li><a href={url}>{title}</a></li>
                        ) ) }
                    </ul>
                )  }
            </div>
        );
    }
}

Как Вы могли заметить, он ожидает два свойства:


  • articles — массив статей (обратите внимание, компонент ничего не знает про то, откуда они берутся)
  • search — функция, которую он будет вызывать когда пользователь вводит что-то в поле ввода.

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


На заметку: RxConnect работает с существующими React компонентами без модификаций


Реактивный компонент


Самое время связать наш компонент с внешним миром:


import { rxConnect } from "rx-connect";

@rxConnect(Rx.Observable.of({
    articles: [
        {
            title: "Pure (programming Language)",
            url: "https://en.wikipedia.org/wiki/Pure_(programming_language)"
        },
        {
            title: "Reactive programming",
            url: "https://en.wikipedia.org/wiki/Reactive_programming"
        },
    ]
}))
class MyView extends React.PureComponent {
    // ...
}

(Поиграться: http://codepen.io/bsideup/pen/VKwKGv )


Здесь мы сымитировали данные, подложив статичный массив из двух элементов, и мы видим, что компонент из отображает! Ура!


**На заметку: функция, переданная в метод rxConnect должна вернуть Observable свойств компонента.


Реактивный интерактивный компонент


Всё конечно клёво, но… Поиск? Пользователь до сих пор не может взаимодействовать с нашим компонентом. Требования были:


  • Он должен искать на Wikipedia когда пользователь вводит запрос
  • Он должен игнорировать результат всех предыдущих запросов если пользователь вводит новый запрос

Благодаря RxJS мы можем легко реализовать это:


import { rxConnect, ofActions } from "rx-connect";

function searchWikipedia(search) {
    return Rx.DOM
        .jsonpRequest(`https://en.wikipedia.org/w/api.php?action=opensearch&search=${search}&format=json&callback=JSONPCallback`)
        .pluck("response")
        // Wikipedia имеет очень странный формат данных o_O
        .map(([,titles,,urls]) => titles.map((title, i) => ({ title, url: urls[i] })))
}

@rxConnect(() => {
    const actions = {
        search$: new Rx.Subject()
    }

    const articles$ = actions.search$
        .pluck(0) // нас интересует первый переданный аргумент
        .flatMapLatest(searchWikipedia)

    return Rx.Observable.merge(
        Rx.Observable::ofActions(actions),

        articles$.map(articles => ({ articles }))
    )
})
class MyView extends React.PureComponent {
    // ...
}

(Поиграться: http://codepen.io/bsideup/pen/rrNrEo ВНИМАНИЕ! Не вводите слишком быстро, иначе Вы упрётесь в ограничение API по кол-ву запросов (см. дальше) )


Отлично, работает! Мы печатаем и мы видим результат.


Пройдёмся по коду шаг-за-шагом:


const actions = {
    search$: new Rx.Subject()
}

Здесь мы создаём объект из действий пользователя. Они являются Субъектами. Вы можете объявить столько субъектов, сколько Вы хотите.


Видите знак $ в конце имени действия? Это специальная нотация в RxJS чтобы идентифицировать поток данных. RxConnect опустит его, и компонент получит его в виде свойства search.


Но действия сами по себе ничего не делают, мы должны реагировать на них с помощью реакций:


const articles$ = actions.search$
    .pluck(0) // select first passed argument
    .flatMapLatest(searchWikipedia)

Сейчас у нас есть только одна реакция — на поиск, но их может быть много, вот почему мы объединяем потоки всех реакций в один:


return Rx.Observable.merge(
    Rx.Observable::ofActions(actions),

    articles$.map(articles => ({ articles }))
)

Поток статей будет преобразован в свойство articles нашего компонента.


Реактивный интерактивный компонент, учитывающий ограничения API


В текущей реализации мы запрашиваем API каждый раз как пользователь вводит новый символ в поле ввода. Это означает что если пользователь вводит слишком часто, например, 10 символов в секунду, то мы пошлём 10 запросов в секунду. Но пользователь хочет видеть лишь результат для последнего запроса, когда он перестал печатать. И такая ситуация — отличный пример для чего мы выбираем RxJS — потому что он расчитан обрабатывать такие ситуации!


Немного модифицируем нашу реакцию:


actions.search$
    .debounce(500) // <-- RxJS рулит!
    .pluck(0)
    .flatMapLatest(searchWikipedia)

(Поиграться: http://codepen.io/bsideup/pen/gwOLdK (не бойтесь вводить настолько быстро, на сколько можете)


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


На заметку: изучайте RxJS, он шикарен:)


Реактивный интерактивный компонент, учитывающий ограничения API, с вниманием к деталям


Напечатайте что-нибудь в поле ввода. После того, как Вы увидели результаты, введите что-нибудь ещё. Старые результаты остаются на экране пока мы не получим ответ от сервера на новый запрос. Это не очень красиво, но мы можем легко это исправить.


Помните я сказал, что мы комбинируем потоки данных, а значит наш компонент реактивен? Благодаря этому, сделать очистку предыдущих результатов не сложней чем отправлять пустой объект одновременно с тем, как мы отправляем запрос на сервер, но до его результата:


actions.search$
    .debounce(500)
    .pluck(0)
    .flatMapLatest(search =>
        searchWikipedia(search)
            .startWith(undefined) // <-- Наш поток начинается с undefined, а потом, когда пришёл ответ, завершается ответом от сервера
    )

Результат: http://codepen.io/bsideup/pen/mAbaom


Redux


В целях сократить размер статьи, я не стану охватывать тему Redux-а, скажу лишь, что RxConnect отлично работает с Redux и позволяет так же реактивно связывать ваши компоненты, заменяя @connect. Например:


@rxConnect((props$, state$, dispatch) => {
    const actions = {
        logout: () => dispatch(logout()),
    };

    const user$ = state$.pluck("user").distinctUntilChanged();

    return Rx.Observable.merge(
        Rx.Observable::ofActions(actions),

        user$.map(user => ({ user })),
    );
})
export default class MainView extends React.PureComponent {
    // ...
}

Пример: https://github.com/bsideup/rx-connect/tree/master/examples/blog/
Демо: https://bsideup.github.io/rx-connect/examples/blog/dist/


Заключение


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


Ссылки


Поделиться с друзьями
-->

Комментарии (24)


  1. justboris
    05.09.2016 17:09

    У вас в примерах используются декораторы, хотя можно написать тоже самое обычными функциями. Вот ссылка на официальную позицию по поводу декораторов и react. Да и в целом, чем меньше экзотики в документации, тем лучше.


    1. bsideup
      05.09.2016 18:53

      Про декораторы есть в README и документации. В целом, я нахожу их лаконичней, но каждому своё и пользователь в праве писать без декораторов, библиотека этого не требует.


      1. justboris
        05.09.2016 18:58
        +1

        Ясно. В любом случае спасибо за статью, материал хороший!


    1. chico
      06.09.2016 16:38

      ладно декораторы, но bind operator — это очень очень плохо


      1. bsideup
        06.09.2016 17:15

        А можно аргументацию чем это плохо?:)


        1. xGromMx
          06.09.2016 18:00

          Тем что это не стабильная фича и не факт что она вообще будет добавлена


          1. bsideup
            06.09.2016 18:11
            -1

            У меня она работает стабильно. И разве early adoption и массовое использовние не есть путь к принятию фичи? :)


        1. justboris
          06.09.2016 18:54

          +1, я даже и не заметил, что там используется bind-оператор.


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


  1. justboris
    05.09.2016 17:24
    +3

    Еще не очень понятно зачем rxConnect знает про redux store.
    Можно же использовать redux-connect и rx-connect вместе


    function MyComponent({value}) {}
    
    const mapStateToProps = state => {user: state.user};
    
    const mapObservable = props$ => {
        const user$ = props$.pluck("user").distinctUntilChanged();
    
        return Rx.Observable.merge(
            Rx.Observable::ofActions({
              logout: props$.logout
            }),
    
            user$.map(user => ({ user })),
        );
    }
    
    connect(mapStateToProps, {logout})(rxConnect(mapObservable)(MyComponent));
    

    connect сложит все необходимые данные и действия в props, откуда их сможет достать rxConnect. И не нужно лазить в контекст и зависеть от деталей реализации react-redux.


    1. bsideup
      05.09.2016 18:51

      Вы правы, можно и так :) Правда это даст чуть больше оверхеда за счёт ещё одного HoC по пути к Вашему компоненту плюс немного синтаксической перегрузки, хотя идея мне нравится :)


      1. bsideup
        05.09.2016 19:15
        +2

        На самом деле мне на столько понравилась идея, что скорей всего я так и поступлю и уберу поддержку Redux в след. версии. HoC обещали оптимизировать в след. версиях React и свести оверхед почти что к нулю, а вот композиция коннекторов — это мне прям очень нравится. Спасибо за идею :)


  1. xGromMx
    05.09.2016 23:27
    -1

    А можно еще просто это взять https://github.com/redux-observable/redux-observable


    1. bsideup
      06.09.2016 09:21
      +2

      Вот только общего у них — только зависимость на RxJS.

      Redux-Observable — это middleware, в то время как RxConnect — это Higher Order Component для написания реактивных компонент. Причём никто не мешает использовать оба — Redux-Observable для обработки глобальных action-ов и RxConnect чтобы состояние связывать с компонентом и обработки локального состояния


  1. raveclassic
    06.09.2016 15:42

    О, привет, неожиданно видеть тебя в реакте ;)
    По теме, у нас как раз одной из целей идет внедрение Rx, так что ты как нельзя вовремя.
    Решение отличное, но первое, что бросается в глаза — ручная сборка/разборка ключей (угу, тот самый $). Неубедительно как-то.
    Но, как уже упомянули выше, очевидно напрашивается отрезание redux-логики, что даст возможность считать все входные ключи в декораторе за стримы без дополнительной фильтрации.


    1. bsideup
      06.09.2016 16:03

      Привет привет :)

      Стучись в скайп ( bsideup ) если будут вопросы :)

      Ручная сборка\разборка ключей? о_О Чё то я не понял, можешь пояснить? Вроде как минимум ручного в либе :)


      1. raveclassic
        06.09.2016 17:12

        Ну я имел в виду добавление $ в конец ключа, т.е. указываю я в декораторе search$, но пропса в компонент придет search. Неконсистентно чёт.


        1. bsideup
          06.09.2016 17:14

          так… это ж… фича! :D


          Суть в том, что ты объявляешь Subject search$, а в компонент придёт search = (...args) => search$.onNext(args), т.е. это не 1 к 1.


          1. raveclassic
            06.09.2016 17:24

            Эм… но… зачем? :)
            Ну правда, указываю search — приходит search, вроде проще некуда ) ну а если нужно указать тип, то для этого лучше взять ts/flow


            1. bsideup
              06.09.2016 17:25

              причём тут тип то? о_О это вообще не про типизацию :D


              https://github.com/bsideup/rx-connect/blob/master/src/mapActionCreators.js#L3-L21


              может этот блок кода объяснит :)


              1. raveclassic
                06.09.2016 18:26

                Да видел я код )
                Сам говоришь, что наличие $ — это соглашение в Rx, то есть «своего рода тип», и я тут действительно не про типизацию.
                Я к тому, что не вижу наличие еще одного контракта действительно оправданным. Ну, т.е. на текущий момент, этот доллар нужен для отделения обычных пропсов от стримов и, как следствие, различной их обработки. Но ведь если не хэндлить все пропсы в rx-connect, а отдать это редаксу, то это доллары станут не нужны.
                Либо я упускаю из виду какую-то основную идею, либо просто не люблю доллары :D
                Код погонять сейчас нет возможности.


                1. bsideup
                  06.09.2016 18:42

                  Если писать без долларов, то тогда тот же самый код будет выглядеть примерно так:


                  @rxConnect(() => {
                      const search = new Rx.Subject();
                  
                      const articles$ = search
                          .pluck(0) // нас интересует первый переданный аргумент
                          .flatMapLatest(searchWikipedia)
                  
                      return Rx.Observable.merge(
                          Rx.Observable.of({ search: (...args) => search.onNext(args) }),
                  
                          articles$.map(articles => ({ articles }))
                      )
                  })

                  что имхо гораздо грязней


                  1. raveclassic
                    06.09.2016 19:35

                    Блин, не могу тебе сейчас накидать пример, чего хотелось бы достичь, с телефона не удобно :(
                    Но, в целом, стало понятно, зачем это соглашение.


                  1. raveclassic
                    06.09.2016 19:55

                    Ага, чет затупил :D


  1. Strate
    07.09.2016 12:04
    -1

    Вот только появилась другая проблема — стало сложно делать простые вещи, а каждый чих (такой как поле ввода логина) должен проходить через action creator-ы, reducer-ы, и храниться в глобальном состоянии.

    Можно просто изначально использовать mobx и всё. Никаких проблем нет — код проще, обновления компонентов атомарнее.