Введение
Скорее всего, многие люди, попробовав эти 2 библиотеки в достаточной степени, думали о том, как продуктивно использовать их вместе. RxJs сам по себе не блещет простотой — множество функций, определенно, отталкивают новичков. Однако, изучив и приняв его, мы получаем очень гибкий инструмент для работы с асинхронным кодом.
Я подразумеваю, что, читая эту публикацию, вы хорошо знаете ReactJS и, хотя бы, представляете суть RxJs. Я не буду использовать Redux в примерах, но все, что будет написано ниже, прекрасно проецируется и на связку React + Redux.
Мотивация
У нас есть компонент, который должен произвести некоторые асинхронные/тяжелые действия (назовем их «пересчет») над его
props
и отобразить результат их исполнения. В общем случае, мы имеем 3 типа props
:- Параметры, при изменении которых мы должны сделать пересчет и произвести рендеринг
- Параметры, при изменении которых мы должны использовать значение предыдущего пересчета и провести рендеринг
- Параметры, изменение которых не требуют ни пересчета ни рендеринга, однако, они повлияют на следующий пересчет
Очень важно, чтобы мы не делали лишних движений и производили пересчет и рендер только в необходимых случаях. Для примера, рассмотрим компонент, который по переданному параметру считает и отображает число Фибоначчи. У него следующие входные данные:
-
className
— css класс который надо повесить на рутовый элемент (2-й тип) -
value
— число по которое используется для вычислений (1-й тип) -
useServerCall
— параметр который позволяет вычислять посредством запроса на сервер, либо локально (3-й тип)
Пример компонента
import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
//Функция, производящая вычисления и возвращающая Promise
import calculateFibonacciExternal from './calculateFibonacci';
export default class Fibonacci extends React.Component {
//Определение типов параметров, описанных выше
static propTypes = {
className: PropTypes.string,
value: PropTypes.number.isRequired,
useServerCall: PropTypes.bool.isRequired,
};
//Внутреннее состояние компонента. Будем его обновлять что бы
//произвести рендеринг
state = {
loading: true,
fibonacci: null,
};
//Компонент скоро будет отображен
componentWillMount() {
//У нас еще нет никаких результатов вычислений - начнем работу
//с того, что их запросим
this.calculateFibonacci(this.props.value, this.props.useServerCall, (fibonacci) => {
this.setState({
fibonacci: fibonacci,
loading: false,
});
});
}
//Компонент получил новые props
componentWillReceiveProps(nextProps) {
//Если изменилось value - делаем пересчет
if(nextProps.value !== this.props.value) {
this.setState({
loading: true,
});
this.calculateFibonacci(nextProps.value, nextProps.useServerCall, (fibonacci) => {
this.setState({
fibonacci: fibonacci,
loading: false,
});
});
}
}
//Нужно ли обновлять компонент
shouldComponentUpdate(nextProps, nextState) {
//Ну по факту нужно во всех случаях, кроме изменения useServerCall
return this.props.className !== nextProps.className ||
this.props.value !== nextProps.value ||
this.state.loading !== nextState.loading ||
this.state.fibonacci !== nextState.fibonacci;
}
//Обязательно отметим, что компонент был удален и нам больше не интересны
//любые результаты вычислений, которые были недавно запущены
componentWillUnmount() {
this.unmounted = true;
}
unmounted = false;
calculationId = 0;
//Мы не хотим получать результаты старых вычислений, поэтому пришлось
//обернуть функцию и отсеивать их
calculateFibonacci = (value, useServerCall, cb) => {
const currentCalculationId = ++this.calculationId;
calculateFibonacciExternal(value, useServerCall).then(fibonacci => {
if(currentCalculationId === this.calculationId && !this.unmounted) {
cb(fibonacci);
}
});
};
//Ну и простенький рендер
render() {
return (
<div className={ classnames(this.props.className, this.state.loading && 'loading') }>
{ this.state.loading ?
'Loading...' :
`Fibonacci of ${this.props.value} = ${this.state.fibonacci}`
}
</div>
);
}
}
Получилось как то сложно: весь код размазан по 4-м методам жизненного цикла компонента, где то он выглядит связанным, сравнения с предыдущими состояниями, легко что то забыть или сломать при обновлении. Давайте попробуем сделать этот код лучше.
Представляю react-rx-props
Эту небольшую библиотеку я написал с целью сделать решение данного вопроса более лаконичным способом. Она состоит из двух компонентов высшего порядка (HoC, Higher Order Component):
-
reactRxProps
— преобразует входящиеprops
(с некоторыми исключениями) в Observables и передает их в ваш компонент -
reactRxPropsConnect
— выносит логику работы с Observables из вашего компонента, позволяя сделать его без внутреннего состояния (stateless)
Воспользовавшись первым HoC, мы получим:
Пример компонента с использованием reactRxProps
import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { reactRxProps } from 'react-rx-props';
import { Observable } from 'rxjs';
import calculateFibonacciExternal from './calculateFibonacci';
//Преобразуем возвращаемый Promise в Observable для удобства.
const calculateFibonacci = (...args) => Observable.fromPromise(calculateFibonacciExternal(...args));
class FibonacciReactRxProps extends React.Component {
//Обратите внимание, что принимаем мы уже Observables
//$ добавляется к именам по соглашению об именовании (можно отключить)
static propTypes = {
className: PropTypes.string,
value$: PropTypes.instanceOf(Observable).isRequired,
useServerCall$: PropTypes.instanceOf(Observable).isRequired,
exist$: PropTypes.instanceOf(Observable).isRequired,
};
//Тут нам все еще нужно внутреннее состояние
state = {
loading: true,
fibonacci: null,
};
//Всю логику о том как и когда надо обновлять компонент распишем здесь
componentWillMount() {
//useServerCall мы просто сохраняем, никакой пересчет или рендеринг не нужен
this.props.useServerCall$.subscribe(useServerCall => {
this.useServerCall = useServerCall;
});
//value мы сохраняем и запускаем пересчет при каждом изменении
this.props.value$.switchMap(value => {
this.value = value;
this.setState({
loading: true,
});
return calculateFibonacci(value, this.useServerCall)
.takeUntil(this.props.exist$); //Нам не интересен результат если компонент удален
}).subscribe(fibonacci => {
this.setState({
loading: false,
fibonacci: fibonacci,
});
});
//Мы ничего не написали про className, но как видно из propTypes - он не является
//Observable. Получается при его изменении компонент сделает рендеринг.
//Можно сконфигурировать props, которые не нужно преобразовывать в Observables
}
//Тут ничего не изменилось
render() {
return (
<div className={ classnames(this.props.className, this.state.loading && 'loading') }>
{ this.state.loading ?
'Loading...' :
`Fibonacci of ${this.value} = ${this.state.fibonacci}`
}
</div>
);
}
}
//Применяем HoC, указав его вводные данные (это не обязательно)
export default reactRxProps({
propTypes: {
className: PropTypes.string,
value: PropTypes.number.isRequired,
useServerCall: PropTypes.bool.isRequired,
},
})(FibonacciReactRxProps);
Какие плюсы по сравнению с оригинальным компонентом:
- Вся логика о том когда надо делать пересчет и рендеринг в одном месте
- Нет дублирующегося кода
- Нет сравнений с предыдущим состоянием
- Всегда можем автоматически отписаться от любого Observable с помощью
takeUntil(this.props.exist$)
- Вся логика о том, что нам не нужны не актуальные результаты вычислений заключена в запуске
switchMap
Однако, компонент все еще имеет внутреннее состояние, что усложняет его тестирование. Давайте воспользуемся вторым HoC:
Пример компонента без внутреннего состояния
import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { reactRxProps, reactRxPropsConnect } from 'react-rx-props';
import { compose } from 'recompose';
import { Observable } from 'rxjs';
import calculateFibonacciExternal from './calculateFibonacci';
const calculateFibonacci = (...args) => Observable.fromPromise(calculateFibonacciExternal(...args));
class FibonacciReactRxProps extends React.Component {
//Принимаем данные уже в том виде в котором их легко сможем
//отобразить без использования внутреннего состояния
static propTypes = {
className: PropTypes.string,
value: PropTypes.number,
fibonacci: PropTypes.number,
};
//Соответственно, отображаем
render() {
return (
<div className={ classnames(this.props.className, this.props.loading && 'loading') }>
{ this.props.loading ?
'Loading...' :
`Fibonacci of ${this.props.value} = ${this.props.fibonacci}`
}
</div>
);
}
}
//compose помогает применить несколько HoC к одному компоненту
export default compose(
//Тут мы ничего не меняли
reactRxProps({
propTypes: {
className: PropTypes.string,
value: PropTypes.number.isRequired,
useServerCall: PropTypes.bool.isRequired,
},
}),
reactRxPropsConnect({
//Принимаем те же props что принимал компонент в предыдущем примере
propTypes: {
className: PropTypes.string,
value$: PropTypes.instanceOf(Observable).isRequired,
useServerCall$: PropTypes.instanceOf(Observable).isRequired,
exist$: PropTypes.instanceOf(Observable).isRequired,
},
//Сюда ушла вся логика работы с Observables
//По сути тот же код, только:
//this -> model
//this.props -> props
//this.setState -> render
connect: (props, render) => {
const model = {};
props.useServerCall$.subscribe(useServerCall => {
model.useServerCall = useServerCall;
});
props.value$.switchMap(value => {
model.value = value;
render({
loading: true,
});
return calculateFibonacci(model.value, model.useServerCall)
.takeUntil(props.exist$);
}).subscribe(fibonacci => {
render({
loading: false,
value: model.value,
fibonacci: fibonacci,
});
});
},
})
)(FibonacciReactRxProps);
Компонент потерял внутреннее состояние, а так же всю логику, связанную с Observables, и стал элементарным для тестирования, ровно как и новая функция connect.
Надеюсь, вам понравился данный подход и вы тоже решите его попробовать. Я пытался найти библиотеки с данной функциональностью, но, к сожалению, мой поиск не дал результатов.
Ссылки:
Библиотека React Rx Props
Пример работы с библиотекой
Комментарии (6)
klimentRu
06.01.2018 22:36Может лучше Angular использовать? Он и с rxJs дружит, и сервисы есть, чтоб подобные вещи из компонента выносить.
DontRelaX Автор
06.01.2018 22:41+1React тоже отлично с ним дружит, просто это не столь популярно. Предлагать заменить React на Angular для внедрения RxJs — это достаточно кардинальные меры, не говоря о вкусах людей.
klimentRu
07.01.2018 07:01В Angular то же самое было бы сделано красивее и проще. Думаю тут дело не во вкусах. React и Angular просто инструменты и нужно использовать более удобный (если писать новое приложение).
PaulMaly
07.01.2018 03:17Не спора ради, чисто для сравнения то же самое на SvelteJS:
Fibonacci.html<div class="{{className}}"> {{#await fibonacci}} Loading... {{then result}} Fibonacci of {{value}} = {{result}} {{/await}} </div> <script> import calculateFibonacci from './calculateFibonacci'; export default { data: () => ({ className: '', useServerCall: false, value: 0, fibonacci: 0 }), oncreate() { const observer = this.observe('value', (value) => { let fibonacci = calculateFibonacci(value, this.get('useServerCall')); this.set({ fibonacci }); }); this.on( 'destroy', () => observer.cancel()); } }; </script>
AndreyRubankov
С моей точки зрения, стало сложнее.
1. Вы стали описывать типы пропертей компонента в нескольких местах (копипаст)
2. Теперь у вас есть метод render() внутри самого React-компонента и есть еще какой-то коллбек render(args) для connectRxProps
3. compose со последующим запутанным куском кода – лишь все усложняет, и это для тривиального примера, что будет, если пример будет более сложным?
4. Теперь у вас в коде компонента есть 2 способа изменить состояние этого компонента: this.setState и render( state )
ps: в последнем примере вы потеряли пропертю loading
DontRelaX Автор
1. Это не обязательно
2. Ну не какой то, а описаный в API. Очень часто API какие то вещи добавляет
3. compose достаточно популярная библиотека, что бы кого то запутать. Запутанный дальнейший код является таковым только пока вы мало работали с Observables
4. Ну их всегда было два: изменить props или state. С учетом, что мы получили stateless компонент — смысл делать setState утрачен.
5. loading не потерян — он передается через props