RxConnect — когда React встречает RxJS

в 11:58, , рубрики: javascript, React, reactive, ReactJS, redux, rx, rxjs

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

Введение

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

Существуют разные способы реагирования на интерактивные события в 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 позволило нам избежать ненужного кода и потенциальных ошибок управления подписками.

Ссылки

Автор: bsideup

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js