Компоненты высшего порядка с Recompose

Компоненты высшего порядка используются когда мы хотим добавить или переопределить некоторые свойства уже созданного компонента, но не хотим дублировать код. Эта техника часто спасает от дублирования кода, помогая делать его более чистым. Еще данный подход применим, кода мы хотим вмешаться в жизненный цикл компонента.

Что же представляют собой компоненты высшего порядка? (сокращенно hoc). Hoc - по сути, декораторы и представляют собой функции, которые на вход принимают компонент и возвращают функцию (компонент)  или измененный класс компонента.

const hoc = (BaseComponent) => (props) => <BaseComponent {...props} />

В данном примере показан простейший случай, просто для понимания, что такое hoc. И, конечно, такая конструкция не несет в себе никакой полезной нагрузки. Если мы немного ее модифицируем, а точнее, сделаем так, чтобы она на вход принимала некий набор свойств, сохраняла их в замыкании и затем возвращала функцию компонента, то мы сможем переопределять нужные нам свойства.

const overrideProps = (overrideProps) => (BaseComponent) => (props) => <BaseComponent {...props ...overrideProps} />

Для чего еще можно применить данную технику? Еще можно вместо функции возвращать класс компонента. Это нам даст возможность вмешиваться в жизненный цикл компонента.

const invisibleMixin = (BaseComponent) => 
  class extend Component {
    shouldComponentUpdate() {
      return false;
    }
    render() {
      return (
        <BaseComponent ...this.props />
      )
    }
  };

Если мы какой-то компонент теперь обернем в данный, то этот компонент никогда не будет отображен.

const invisibleMan = invisibleMixin(<Man />)

Получается, что основные цели применения подхода компонентов высшего порядка следующие:

1. Переопределение свойств уже созданных компонентов или их расширение;

2. Интеграция в жизненный цикл компонентов;

3. Расширение возможностей stateless компонентов.

Для более легкого и всестороннего использования данного подхода можно использовать библиотеку Recompose. Она помогает элегантно и без особых усилий реализовывать данный подход.

Давайте рассмотрим ее основные возможности. 

Первая функция, которую мы рассмотрим, будет compose. Как вы уже догадались, она позволяет соединять несколько функций из данной библиотеки вместе вместо того, чтобы оборачивать их друг за другом:

const enhance = compose(
  setDisplayName('Car'),
  setPropTypes({
    model: React.PropTypes.string.isRequired,
    speed: React.PropTypes.number.isRequired
  }),
     connect()
);

const Car = enhance(({model, speed, dispatch}) =>
  <div className="Car" onClick={() => dispatch({type: "CAR_SELECTED"})}>
    {model} : {speed}
  </div>
)

<Car model="Type-S" speed="200" /> 

Из данного примера видно, что мы соединили три функции вместе. Первые две - это просто два мутатора нашего исходного компонента, которые добавляют полезные свойства нашему расширенному компоненту. Первая устанавливает читаемое имя в redux-tools, вторая добавляет типы для свойств компонента.  Ну а третья - это функция из библиотеки redux (в данном случае она просто для примера и не несет какой-либо полезной нагрузки).

Следующие функции, которые мы рассмотрим будут withState и withHandlers. Данные функции позволяют задать состояние и различные обработчики  для функциональных stateless компонентов. Функция withState принимает на вход три аргумента:

1. Свойство, которое будем менять;

2. Функция, которая будет менять это свойство;

3. Первоначальное состояние.

Допустим у нас есть stateless компонент, к которому мы хотим добавить возможность скрываться по клику на определенный элемент:

const CarTypesList = (props) => 
  <div className="carList">
    <div>Honda</div>
    <div>Subaru</div>
    <div>Nissan</div>
  </div>;

Давайте обернем наш компонент в функцию withState и добавим интерактивности:

const CarModel = withState('listShow', 'showListHandler', false)(
  ({model, listShow, showListHandler}) =>
  <span onClick={() => showListHandler((x) => !x)}>
    {model}
    {listShow && <CarTypesList />}
  </span>
)

Вот так вот мы добавили к stateless-компоненту локальное состояние, которое мы можем теперь использовать, чтобы скрывать и показывать наш список типов машин по клику на наш тип машины. Аналогичным образом мы, например, могли бы добавить возможность показывать и скрывать какое-либо сообщение при наведении курсора мыши.

Теперь рассмотрим функцию withHandlers. Давай перепишем наш пример выше с помощью данной функции. В общем случае данная функция принимает два аргумента:

  • Начальное состояние;
  • Объект со списком функций, которые данное состояние меняют.

Но мы используем функцию compose чтобы withState определить наше состояние, а функцией withHandlers - определить функции обработчики для изменения состояния компонента:

const withToggle = compose(
     withState('isShow', 'toggleVisible', false),
     withHandlers({
          show: ({toggleVisible}) => (e) => toggleVisible(true),
          hide: ({toggleVisible}) => (e) => toggleVisible(false),
          toggle: ({toggleVisible}) => (e) => toggleVisible((current) => !current) 
     })
);

const CarModel = withToggle(
     ({model, isShow, toggle}) =>
     <span onClick={toggle}>
          {model}
          {isShow && <CarTypesList />}
     </span>
)

const Speed = ({value}) =>
     <span>Скорость {value} км/ч</span>;

const Car = enhance(({model, speed}) =>
<div className="Car">
     <Speed value={speed}/> : <CarModel model={model}/>
</div>
);

<Car model="Nissan" speed={500}/>

Наш hoc компонент withToggle может быть применен еще для показа всплывающей подсказки. Давайте добавим всплывающую подсказку к компоненту Speed, чтобы он показывал нам формулу, по которой рассчитывается скорость:

const Tooltip = withToggle(
  ({tooltip, children, isShow, hide, show}) =>
    <span>
      { isShow && <div className="Tooltip">{tooltip}</div> }
      <span onMouseEnter={ show } onMouseLeave={ hide }>
        {children}
      </span>
    </span>
);

<Tooltip tooltip="Формула: V = S/t"><Speed value={speed}/></Tooltip>

Для этого мы создали новый компонент, в который завернем наш компонент Speed. Компонент Tooltip был создан с помощью функции withToggle. На вход он принимает свои свойства, дочерние компоненты и свойство состояния isShow и две функции hide и show. Далее мы привязываем два обработчика события внутри компонента и выводим дочерние компоненты.

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

Теперь давайте рассмотрим возможность интеграции в жизненный цикл компонента. Интеграция в жизненный цикл компонентов возможна, как мы знаем, в методах жизненного цикла компонента, которые определены с помощью классов. Например, в методах componentDidMount или componentShouldUpdate и т.д. Однако, в библиотеке Recompose есть метод, который позволит нам добавить методы жизненного цикла в функциональные компоненты. Это метод lifecycle.

Давайте добавим к нашим компонентам Car возможность показывать марку или нет, и еще флаг "разрешено удаление машины" или "нет". Т.е. добавим два свойства showModel и canCarDelete.

Для начала нам надо добавить наш hoc, который будет реализовывать lifecycle методы.

const withConfig = lifecycle({
      componentWillMount() {
            this.setState({config: {}});
      },
      componentDidMount() {
            carPromise.then(config => {
                  this.setState({config});
            });
      }
});

В данном коде carPromise - это объект Promise, который возвращает mock данные нашей конфигурации через 3 секунды.

const mockConfig = {
  showModel : true,
  canCarDelete: true
};

function fetchCarPrivs() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(mockConfig);
    }, 3000);
  });
}

const carPromise = fetchCarPrivs();

После этого добавляем наш withConfig к уже имеющимся функциям в compose:

const enhance = compose(
  setDisplayName('Car'),
  setPropTypes({
    model : PropTypes.string.isRequired,
    speed : PropTypes.number.isRequired,
    type : PropTypes.string.isRequired,
    showModel : PropTypes.bool,
    canCarDelete: PropTypes.bool,
  }),
  withConfig
);

Так получаем новый hoc компонент для расширения нашего компонента машины Car:

const Car = enhance(({type, model, speed, config}) => {
  return (<div className="Car">
    <Tooltip tooltip="Формула: V = S/t">
      <Speed value={speed}/>
    </Tooltip>
    : {type} {config.showModel && <CarModel model={model}/>} 
    {config.canCarDelete && <button>X</button>}
  </div>);
});

Вот и все! Теперь наши свойства showModel и canCarDelete попадут в наш компонент асинхронно, после трех секунд ожидания. Конечно, это всего лишь заглушка для реального кейса, но кто нам мешает в данном коде вызвать метод получения данных с сервера?

Мы научились получать данные с сервера (в примере - mock данные). А что если данные о компоненте загружаются долго? Тогда можно уведомить пользователя о том, что они загружаются. Давайте на время загрузки реализуем соответствующее сообщение. В этом нам поможет замечательная функция branch. Суть это функции следующа: она принимает два обязательных аргумента:

1. Функция, которая возвращает статус компонента по загрузке, т.е. загружаются данные или нет;

2. Компонент, который необходимо отображать пока данные загружаются (спиннер).

Как только функция из первого аргумента возвращает false, то отображается наш исходный компонент. Итак, вынесем в отдельный компонент информацию о марке машины и кнопке с удалением. И добавим компонент спинера, он довольно простой:

const CarWideInfo = ({model,config}) =>
     <span>{config.showModel && <CarModel model={model}/>} {config.canCarDelete && <button>X</button>} </span>;

const Spinner = () =>
     <span className="Loading">Загрузка...</span>;

Далее добавим в наш state новый флаг isLoading. В методе componentWillMount установим его в true, ну а когда данные загружены, то уже в false.

Потом с помощью метода branch добавляем наш hoc компонент (withSpinner) и функцией compose получаем окончательный hoc компонент с маппингом конфигурации компонента и спиннером (enhanceInfo). В заключении оборачиваем наш CarWideInfo в получившийся hoc компонент и уже его используем в приложении:

const withSpinner = branch(
  ({loading}) => loading,
  renderComponent(Spinner)
);

const enhanceInfo = compose(
  withConfig,
  withSpinner
);

const SpinnerCarWideInfo = enhanceInfo(CarWideInfo);

Давайте теперь добавим компонент со списком машин. Это довольно простой компонент. Он позволит нам вывести список наших машин. А теперь представим ситуацию, что нам надо вывести только определенную марку машин, например Honda. Для создания таких фильтров нам поможет функция mapProps:

export const CarList = ({ cars, type }) =>
<div className="carList">
     <h3>{type}</h3>
     {cars && cars.map((car) => <Car key={car.id} {...car}/>)}
</div>;

Эта функция позволит нам сделать отфильтрованный список машин. Она принимает на вход список свойств, а возвращает компонент с расширенным списком свойств. Мы сделаем функцию, которая будет принимать на вход марку машины (type), сохранять ее в замыкании и возвращать функцию mapProps. Это и будет hoc - компонент фильтра.

import {mapProps} from 'recompose';

export default (type) => mapProps(({cars}) => ({
     type,
     cars: cars.filter(c => c.type === type)
}));

После этого, применив данную функцию к компоненту списка машин, мы можем получать компоненты, которые отображают исключительно определенную марку машин:

import filterByType from './helpers/FilterByType';

export const HondaCars = filterByType('Honda')(CarList);
export const NissanCars = filterByType('Nissan')(CarList);
export const SubaruCars = filterByType('Subaru')(CarList);

Вот так мы получили возможность фильтровать наши машины, получая при этом новые компоненты.

Библиотека Recompose имеет еще много интересных функций, с которыми можно ознакомится в официальном репозитории. Использование этой библиотеки позволяет писать краткий и выразительный код, что сказывается на его сопровождении в лучшую сторону. Хотя, в конечном итоге хочется подчеркнуть, что Recompose не панацея, а построение рассмотренных компонентов возможно и другими способами. 

Скачать исходный код можно как всегда по ссылке:

git clone https://github.com/dionic-i/recompose-example.git

После загрузки переходим в папку проекта:

npm i
npm run start

 

  hoc, recompose

  Смотреть все посты