https://habrahabr.ru/post/346154/Введение
Скорее всего, многие люди, попробовав эти 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-м методам жизненного цикла компонента, где то он выглядит связанным, сравнения с предыдущими состояниями, легко что то забыть или сломать при обновлении. Давайте попробуем сделать этот код лучше.
Эту небольшую библиотеку я написал с целью сделать решение данного вопроса более лаконичным способом. Она состоит из двух компонентов высшего порядка (HoC, Higher Order Component):
-
reactRxProps
— преобразует входящие props
(с некоторыми исключениями) в Observables и передает их в ваш компонент
-
reactRxPropsConnect
— выносит логику работы с Observables из вашего компонента, позволяя сделать его без внутреннего состояния (stateless)
Воспользовавшись первым HoC, мы получим:
Пример компонента с использованием reactRxPropsimport 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Пример работы с библиотекой