RxConnect — коли React зустрічає 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.Вами.timer(0, 1000).timestamp().subscribe(::this.setState);
}

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

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

От тільки чи не забагато коду для такої простої задачі? І що, якщо розробник забуде викликати розпоряджатися на підписці? І, раз у нас вже є стан у вигляді
Rx.Вами.timer
, навіщо нам дублювати його у вигляді локального стану компонента?
Ось тут-то нам і допоможе RxConnect:
import { rxConnect } from "rx-connect";

@rxConnect(
Rx.Вами.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.Вами.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.Вами.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
має повернути
Вами
властивостей компонента.
Реактивний інтерактивний компонент
Все звичайно класно, але… Пошук? Користувач досі не може взаємодіяти з нашим компонентом. Вимоги були:
  • Він повинен шукати на 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.Вами.merge(
Rx.Вами::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 argument passed
.flatMapLatest(searchWikipedia)

Зараз у нас є тільки одна реакція — на пошук, але їх може бути багато, ось чому ми об'єднуємо потоки всіх реакцій в один:
return Rx.Вами.merge(
Rx.Вами::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.Вами.merge(
Rx.Вами::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 дозволило нам уникнути непотрібного коду і потенційних помилок управління підписками.
Посилання
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.