javascript

Упрощаем ReactJS компоненты с помощью RxJs

  • воскресенье, 7 января 2018 г. в 03:12:59
https://habrahabr.ru/post/346154/
  • ReactJS
  • JavaScript


Введение


Скорее всего, многие люди, попробовав эти 2 библиотеки в достаточной степени, думали о том, как продуктивно использовать их вместе. RxJs сам по себе не блещет простотой — множество функций, определенно, отталкивают новичков. Однако, изучив и приняв его, мы получаем очень гибкий инструмент для работы с асинхронным кодом.

Я подразумеваю, что, читая эту публикацию, вы хорошо знаете ReactJS и, хотя бы, представляете суть RxJs. Я не буду использовать Redux в примерах, но все, что будет написано ниже, прекрасно проецируется и на связку React + Redux.

Мотивация


У нас есть компонент, который должен произвести некоторые асинхронные/тяжелые действия (назовем их «пересчет») над его props и отобразить результат их исполнения. В общем случае, мы имеем 3 типа props:

  1. Параметры, при изменении которых мы должны сделать пересчет и произвести рендеринг
  2. Параметры, при изменении которых мы должны использовать значение предыдущего пересчета и провести рендеринг
  3. Параметры, изменение которых не требуют ни пересчета ни рендеринга, однако, они повлияют на следующий пересчет

Очень важно, чтобы мы не делали лишних движений и производили пересчет и рендер только в необходимых случаях. Для примера, рассмотрим компонент, который по переданному параметру считает и отображает число Фибоначчи. У него следующие входные данные:

  1. className — css класс который надо повесить на рутовый элемент (2-й тип)
  2. value — число по которое используется для вычислений (1-й тип)
  3. 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):

  1. reactRxProps — преобразует входящие props (с некоторыми исключениями) в Observables и передает их в ваш компонент
  2. 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);


Какие плюсы по сравнению с оригинальным компонентом:

  1. Вся логика о том когда надо делать пересчет и рендеринг в одном месте
  2. Нет дублирующегося кода
  3. Нет сравнений с предыдущим состоянием
  4. Всегда можем автоматически отписаться от любого Observable с помощью takeUntil(this.props.exist$)
  5. Вся логика о том, что нам не нужны не актуальные результаты вычислений заключена в запуске 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
Пример работы с библиотекой