Введение
Скорее всего, многие люди, попробовав эти 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
Пример работы с библиотекой
