3.14 Компоненты более высокого порядка (старшие компоненты)


Компонент более высокого порядка (в документации Higher-Order Components – сокращенно HOC) - это продвинутая техника в React для повторного использования логики компонента. По существу, HOC не являются частью React API. Они - паттерн, который вытекает из композиционной природы React.


Компонент более высокого порядка или старший компонент - это функция, которая принимает компонент и возвращает новый компонент.


Код
    
  const EnhancedComponent = higherOrderComponent(WrappedComponent);
  

В то время как компонент преобразует props. в UI, старший компонент преобразует компонент в другой компонент.

HOC являются общими для сторонних библиотек React, таких как Redux connect и createFragmentContainer Relay.

В этой главе мы обсудим, почему старшие компоненты полезны и как писать свои собственные.



3.15.1 Использование старших компонентов для сквозной функциональности



Внимание!

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

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

Например, предположим, что у вас есть компонент NotificationList, который подписывается на внешний источник данных для отображения списка уведомлений:


Код
    
  class NotificationList extends React.Component {
    constructor() {
      super();
      this.onChange = this.onChange.bind(this);
      this.state = {
        // "NotificationDataStore"  - некоторый глобальный источник данных
        notifications: NotificationDataStore.getNotifications()
      };
    }

    componentDidMount() {
      // Подписаться на изменения, добавив слушателя события
      NotificationDataStore.addChangeListener(this.onChange);
    }

    componentWillUnmount() {
      // Удалить слушателя
      NotificationDataStore.removeChangeListener(this.onChange);
    }

    onChange() {
      // Обновление состояния компонента каждый раз, когда источник данных изменился
      this.setState({
        notifications: NotificationDataStore.getNotifications()
      });
    }

    render() {
      return (
        <div>
          {this.state.notifications.map((notification) => (
            <Notification key={notification.id} notification={notification} />
          ))}
        </div>
      );
    }
  }
  

Позже вы пишете компонент для подписки на одно уведомление, который следует аналогичному шаблону:


Код
    
  class NotificationDetails extends React.Component {
    constructor(props) {
      super(props);
      this.onChange = this.onChange.bind(this);
      this.state = {
        notification: NotificationDataStore.getNotification(props.id)
      };
    }

    componentDidMount() {
      NotificationDataStore.addChangeListener(this.onChange);
    }

    componentWillUnmount() {
      NotificationDataStore.removeChangeListener(this.onChange);
    }

    onChange() {
      this.setState({
        notification: NotificationDataStore.getNotification(this.props.id)
      });
    }

    render() {
    const notification = this.state.notification
      return (<div>
      <DetailsItem title="From" text={notification.from}/>
      <DetailsItem title="Date" text={notification.date}/>
      // ...
    </div>);
    }
  }
  

NotificationList и NotificationDetails не идентичны - они используют разные методы NotificationDataStore и выводят разные результаты. Но большая часть их реализации одинакова:

  • Сразу после монтирования добавляется слушатель изменений в NotificationDataStore.

  • Внутри слушателя setState вызывается каждый раз, когда изменяется источник данных.

  • При демонтаже слушатель изменений удаляется.

Вы можете себе представить, что в большом приложении этот шаблон: подписка на NotificationDataStore и вызов setState будет повторяться снова и снова. Следовательно нам нужна абстракция, которая позволит определить эту логику в одном месте и использовать ее всеми компонентами. Именно здесь выделяются компоненты более высокого порядка.



Мы можем написать функцию, которая создает компоненты, такие как NotificationList и NotificationDetails, которые подписываются на NotificationDataStore. Функция будет принимать в качестве одного из своих аргументов дочерний компонент, который получает данные подписки в как свойство. Назовем эту функцию addSubscription:


Код
    
  const NotificationListWithSubscription = addSubscription(
    NotificationList,
    (store) => store.getNotifications()
  );

  const NotificationDetailsWithSubscription = addSubscription(
    NotificationDetails,
    (store, props) => store.getNotification(props.id)
  );
  

Первый параметр - это оборачиваемый компонент. Второй параметр извлекает данные, которые нас интересуют, используя NotificationDataStore и текущие свойства.

Когда NotificationListWithSubscription и NotificationDetailsWithSubscription отрисовываются, NotificationList и NotificationDetails получат свойство data, с самыми последними данными, полученными из NotificationDataStore:


Код
    
  // Данная функция принимает компонент...
  function addSubscription(WrappedComponent, selectData) {
    // ...и возвращает другой компонент...
    return class extends React.Component {
      constructor(props) {
        super(props);
        this.onChange = this.onChange.bind(this);
        this.state = {
          data: fetchData(NotificationDataStore, props)
        };
      }

      componentDidMount() {
        // ...который позаботится о подписке...
        NotificationDataStore.addChangeListener(this.onChange);
      }

      componentWillUnmount() {
        NotificationDataStore.removeChangeListener(this.onChange);
      }

      onChange() {
        this.setState({
          data: fetchData(NotificationDataStore, this.props)
        });
      }

      render() {
        // ... и отрисует обёрнутый компонент с актуальными данными!
        // Обратите внимание, что мы передаем сквозь компонент дополнительные свойства
        return <WrappedComponent data={this.state.data} {...this.props} />;
      }
    };
  }
  

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

Вот и все! Обернутый компонент получает все свойства контейнера вместе с новым свойством prop, которые он использует для своей отрисовки. Старший компонент не имеет отношения к тому, как используются данные, а упакованный компонент не имеет отношения к тому, откуда пришли данные.

Так как addSubscription является обычной функцией, вы можете добавить столько аргументов, сколько захотите. Например, вы можете захотеть указать имя свойства data конфигурируемым, чтобы дополнительно изолировать старший компонент от упакованного компонента. Или же, вы можете принять аргумент, который конфигурирует shouldComponentUpdate, либо, который настраивает источник данных. Все это является возможным, поскольку старший компонент имеет полный контроль над тем, как он определен.

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



3.15.2 Не изменяйте оригинальный компонент. Используйте композицию


Сопротивляйтесь соблазну модифицировать прототип компонента (или каким-то иным образом его изменить) внутри старшего компонента.


Код
    
  function addErrorMessage(TargetComponent) {
    TargetComponent.prototype.showErrorMessage(text) {
      //...
    }
    // Тот факт, что мы возвращаем оригинальный входной
    // компонент - признак того, что он был изменен
    return TargetComponent;
  }

  // Может показывать сообщения об ошибках
  const ComponentWithErrorMesage = addErrorMessage(TargetComponent);
  

Здесь есть несколько проблем. Во-первых, входной компонент нельзя повторно использовать отдельно от модифицированного компонента. Более того, если вы примените еще один старший компонент к ComponentWithDebug, который также будет изменять например componentDidMount, то функция первого старшего компонента будет переопределена! Кроме того, такой старший компонент не будет работать с функциональными компонентами, которые не имеют методов жизненного цикла.

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

Вместо изменения оригинального компонента старшим компонентом вы должны использовать композицию, обертывая входной компонент в компонент-контейнер:


Код
    
  function addErrorMessage(TargetComponent) {
    return class extends React.Component {
      showErrorMessage(text) {
        this.setState({errorMessage: {isShowed: true, text: text});
      }
      render() {
        // Обертывает исходный компонент в контейнер без изменения - правильно!
        return <TargetComponent {...this.props} />;
      }
    }
  }
  

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

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



3.15.3 Соглашение: передавайте несвязанные с контейнером свойства в упакованный компонент


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

Старшие компоненты должны передавать свойства в исходный компонент, если они никак не связаны с его основной работой. Большинство старших компонентов содержат метод отрисовки, который выглядит примерно так:


Код
    
  render() {
    // Отфильтруйте специфические свойства старшего компонента, которые не должны быть переданы
    const { specificProp1, specificProp2, ...propsToPass } = this.props;

    // Свойства, которые вставляются в исходный компонент.
    // Это могут быть значения состояния или методы экземпляра
    const someProp = someStateOrInstanceMethod;
    // Передайте свойства в исходный компонент
    return (<TargetComponent someProp={someProp} {...propsToPass}/>);
  }
  

Это соглашение помогает обеспечить максимально гибкое и многоразовое использование старших компонентов.



3.15.4 Соглашение: максимальная компонуемость


Не все старшие компоненты выглядят одинаково. Иногда они принимают только один аргумент - завернутый компонент:


Код
    
  const MyComponentWithErrorMessage = errorMessageSupport(MyComponent);
  

Обычно старшие компоненты принимают дополнительные аргументы. В этом примере объект конфигурации используется для указания зависимостей данных компонента:


Код
    
  const MyComponentWithScroll = Scroller.create(MyComponent, config);
  

Наиболее распространенная запись старшего компонента выглядит следующим образом:


Код
    
  // Redux метод `connect`
  const ConnectedToReduxStoreNotification =
        connect(notificationSelector, notificationActions)(Notification);
  

Если вы разобьете её, легче понять, что происходит.


Код
    
  // connect - это функция, которая возвращает другую функцию
  const hoc = connect(notificationListSelector, notificationListActions);
  // Возвращаемая функция - старший компонент, который возвращает компонент,
  // который является подключаемым к хранилищу данных Redux
  const ConnectedToReduxStoreNotificationList = hoc(NotificationList);
  

Другими словами, connect - это функция более высокого порядка, которая возвращает компонент более высокого порядка!

Такая форма записи может показаться излишне запутанной или ненужной, но она имеет полезное свойство. Старшие компоненты с одним аргументом, такие как возвращаются функцией connect, имеют сигнатуру Component => Component. Функции, тип вывода которых совпадает с типом ввода, очень легко скомбинировать вместе.


Код
    
  // Вместо этого...
  const hoc = connect(notificationSelector)(addScrollSupport(OriginalComponent))

  // ... вы можете использовать утилиту композиции функций
  // compose(x, y, z) то же самое, что (...args) => x(y(z(...args)))
  const hoc = compose(
    // Оба аргумента - старшие компоненты
    connect(notificationSelector),
    addScrollSupport
  )
  const ResultComponent = hoc(OriginalComponent)
  

(Эта же особенность также позволяет использовать connect и другие старшие компоненты приведенного выше вида как декораторы, являющимися экспериментальным предложение JavaScript.)

Функция-утилита compose предоставляется многими сторонними библиотеками, включая lodash (как lodash.flowRight), Redux и Ramda.



3.15.5 Соглашение: оборачивайте отображаемое имя для простоты отладки


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



Наиболее распространенным методом является оборачивание отображаемого имени упакованного компонента. Поэтому, если ваш компонент более высокого порядка называется addSubscription, а отображаемое имя обернутого компонента - UserList, используйте имя для отображения AddSubscription (UserList):


Код
    
  function addSubscription(OriginComponent) {
    class SubscriptionSupport extends React.Component {/* ... */}
    SubscriptionSupport.displayName = `AddSubscription(${getDisplayName(OriginComponent)})`;
    return SubscriptionSupport;
  }

  function getDisplayName(OriginComponent) {
    return OriginComponent.displayName || OriginComponent.name || 'Component';
  }
  



3.15.6 Предостережения


Старшие компоненты имеют несколько предостережений, которые не сразу очевидны, если вы новичок в React.


3.15.6.1 Не используйте старшие компоненты внутри метода “render()”


Алгоритм сравнения React (называемый согласованием) использует идентификатор компонента, чтобы определить, следует ли обновлять существующее поддерево или удалить его и монтировать новое. Если компонент, возвращаемый из render, идентичен (===) компоненту из предыдущего вызова render, React рекурсивно обновляет поддерево, сравнивая его с новым. Если они не равны, предыдущее поддерево полностью демонтируется.

Обычно вам не нужно об этом думать. Но это имеет значение для старших компонентов, потому что вы не можете их применять к компоненту внутри его метода render:


Код
    
  render() {
    // Новая версия ImprovedComponent будет создаваться при каждом вызове render()
    // ImprovedComponentA !== ImprovedComponentB
    const ImprovedComponent = improve(OriginComponent);
    // Это приводит к демонтированию/перемонтированию всего поддерева
    return <ImprovedComponent />;
  }
  

Проблема здесь заключается не только в производительности - перемонтаж компонента приводит к потере состояния этого компонента и всех его потомков.

Вместо этого применяйте старшие компоненты вне определения исходного компонента, чтобы результирующий компонент был создан только один раз. Тогда его идентичность будет согласованной между отрисовками (вызовами render) компонента. Обычно – это именно то, что вам нужно.

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


3.15.6.2 Статические методы должны быть скопированы


Иногда полезно определить статический метод для компонента React. Например, контейнеры Relay предоставляют статический метод getFragment для облегчения композиции фрагментов GraphQL.

Однако, когда вы применяете старший компонент, исходный компонент упаковывается в компонент-контейнер. Это означает, что новый компонент не имеет каких-либо статических методов исходного компонента.


Код
    
  // Определение статического метода
  OriginComponent.staticMethod = function() {/*...*/}
  // Сейчас применим старший компонент
  const ImprovedComponent = improve(OriginComponent);

  // Полученный компонент не имеет статического метода
  typeof ImprovedComponent.staticMethod === 'undefined' // true
  

Чтобы решить эту проблему, вы можете скопировать методы в контейнер, прежде чем возвращать его:


Код
    
  function improve(OriginComponent) {
    class Improved extends React.Component {/*...*/}
    // Вам необходимо знать, какие методы копировать :(
    Improved.staticMethod = OriginComponent.staticMethod;
    return Improved;
  }
  

Однако для этого требуется, чтобы вы точно знали, какие методы необходимо скопировать. Вы можете использовать hoist-non-react-statics , для автоматического копирования всех статических методов, не связанных с React:


Код
    
  import hoistNonReactStatic from 'hoist-non-react-statics';
  function improve(OriginComponent) {
    class Improved extends React.Component {/*...*/}
    hoistNonReactStatic(Improved, OriginComponent);
    return Improved;
  }
  

Другим возможным решением является экспорт статического метода отдельно от самого компонента.


Код
    
  // Вместо...
  CustomComponent.staticMethod = staticMethod;
  export default CustomComponent;

  // ...экпортировать метод отдельно...
  export { staticMethod };

  // ...и в потребляющем модуле импортировать оба
  import CustomComponent, { staticMethod } from './CustomComponent.js';
  


3.15.6.3 Ссылки не передаются


Не смотря на то, что соглашением для компонентов более высокого порядка является передача всех свойств через упакованный компонент вниз по дереву иерархии, невозможно передать ref. Это происходит потому, что фактически - ref не является свойством как key, он обрабатывается React-ом специальным образом. Если вы добавите ref в элемент, компонент которого является результатом старшего компонента, ref будет ссылаться на экземпляр внешнего компонента-контейнера, а не на упакованный компонент.

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

Тем не менее, бывают случаи, когда refs являются необходимым аварийным выходом – иначе React бы их не поддерживал. Примером может являться фокусировка поля ввода, где вы можете потребовать обязательный контроль над компонентом. В этом случае одним из решений является передача обратного вызова ref в качестве обычного свойства, давая ему другое имя:


Код
    
  function TextField({ inputRef, ...rest }) {
    return <input ref={inputRef} {...rest} />;
  }

  // Оберните TextField в старший компонент
  const FocusedTextField = addFocus(TextField);

  // Внутри метода render компонента...
  <FocusedTextField
    inputRef={(input) => {
      // Этот коллбэк передается как обычное свойство
      this.inputElement = input
    }}
  />

  // Теперь вы можете вызывать необходимые методы
  this.inputElement.focus();
  

Это не идеальное решение. Разработчики рекомендуют, чтобы ссылки ref оставались заботой сторонних библиотек, а не требовали, чтобы вы вручную их обрабатывали. На данный момент ведется исследование путей решения этой проблемы, так что использование старших компонентов под наблюдением не находится.