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

https://tylermcginnis.com/react-higher-order-components/
  • Перевод
Недавно мы публиковали материал о функциях высшего порядка в JavaScript, направленный на тех, кто изучает JavaScript. Статья, перевод которой мы публикуем сегодня, предназначена для начинающих React-разработчиков. Она посвящена компонентам высшего порядка (Higher-Order Components, HOC).



Принцип DRY и компоненты высшего порядка в React


Вам не удастся достаточно далеко продвинуться в деле изучения программирования и не столкнуться с почти что культовым принципом DRY (Don’t Repeat Yourself, не повторяйтесь). Иногда его последователи заходят даже слишком далеко, но, в большинстве случаев, к его соблюдению стоит стремиться. Здесь мы поговорим о самом популярном паттерне React-разработки, позволяющем обеспечить соблюдение принципа DRY. Речь идёт о компонентах высшего порядка. Для того чтобы понять ценность компонентов высшего порядка, давайте сначала сформулируем и поймём проблему, для решения которой они предназначены.

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


Панель управления и всплывающие подсказки

Для того чтобы реализовать подобный функционал, можно воспользоваться несколькими подходами. Вы решили поступить так: определять, находится ли указатель над отдельным компонентом, после чего решать — показывать для него подсказку или нет. Тут существуют три компонента, которые нужно оснастить подобным функционалом. Это Info, TrendChart и DailyChart.

Начнём с компонента Info. Прямо сейчас он представляет собой простую SVG-иконку.

class Info extends React.Component {
  render() {
    return (
      <svg
        className="Icon-svg Icon--hoverable-svg"
        height={this.props.height}
        viewBox="0 0 16 16" width="16">
          <path d="M9 8a1 1 0 0 0-1-1H5.5a1 1 0 1 0 0 2H7v4a1 1 0 0 0 2 0zM4 0h8a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4zm4 5.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z" />
      </svg>
    )
  }
}

Теперь нам надо сделать так, чтобы этот компонент мог бы определять, находится ли над ним указатель мыши или нет. Для этого можно использовать события мыши onMouseOver и onMouseOut. Функция, которую передают onMouseOver, будет вызвана в том случае, если указатель мыши попал в область компонента, а функция, переданная onMouseOut, будет вызвана тогда, когда указатель покинет пределы компонента. Для того чтобы организовать всё это так, как принято в React, мы добавляем к компоненту свойство hovering, хранящееся в состоянии, что позволяет нам выполнить повторный рендеринг компонента, показывая или скрывая подсказку, в том случае, если это свойство меняется.

class Info extends React.Component {
  state = { hovering: false }
  mouseOver = () => this.setState({ hovering: true })
  mouseOut = () => this.setState({ hovering: false })
  render() {
    return (
      <>
        {this.state.hovering === true
          ? <Tooltip id={this.props.id} />
          : null}
        <svg
          onMouseOver={this.mouseOver}
          onMouseOut={this.mouseOut}
          className="Icon-svg Icon--hoverable-svg"
          height={this.props.height}
          viewBox="0 0 16 16" width="16">
            <path d="M9 8a1 1 0 0 0-1-1H5.5a1 1 0 1 0 0 2H7v4a1 1 0 0 0 2 0zM4 0h8a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4zm4 5.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z" />
        </svg>
      </>
    )
  }
}

Получилось неплохо. Теперь нам надо добавить тот же функционал к ещё двум компонентам — TrendChart и DailyChart. Вышеописанный механизм для компонента Info отлично работает, то, что не сломано, чинить не надо, поэтому давайте воссоздадим то же самое в других компонентах, воспользовавшись тем же самым кодом. Переработаем код компонента TrendChart.

class TrendChart extends React.Component {
  state = { hovering: false }
  mouseOver = () => this.setState({ hovering: true })
  mouseOut = () => this.setState({ hovering: false })
  render() {
    return (
      <>
        {this.state.hovering === true
          ? <Tooltip id={this.props.id}/>
          : null}
        <Chart
          type='trend'
          onMouseOver={this.mouseOver}
          onMouseOut={this.mouseOut}
        />
      </>
    )
  }
}

Вы вероятно уже поняли — что делать дальше. То же самое можно сделать и с последним нашим компонентом — DailyChart.

class DailyChart extends React.Component {
  state = { hovering: false }
  mouseOver = () => this.setState({ hovering: true })
  mouseOut = () => this.setState({ hovering: false })
  render() {
    return (
      <>
        {this.state.hovering === true
          ? <Tooltip id={this.props.id}/>
          : null}
        <Chart
          type='daily'
          onMouseOver={this.mouseOver}
          onMouseOut={this.mouseOut}
        />
      </>
    )
  }
}

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

Проблема, которая перед нами стоит, сейчас должна стать предельно ясной. Это — повторяющийся код. Для её решения мы хотим избавиться от необходимости копирования одного и того же кода в тех случаях, когда то, что мы уже реализовали, нужно новому компоненту. Как же её решить? Прежде чем мы об этом поговорим, остановимся на нескольких концепциях программирования, которые значительно облегчат понимание предлагаемого здесь решения. Речь идёт о коллбэках и функциях высшего порядка.

Функции высшего порядка


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

function add (x, y) {
  return x + y
}

function addFive (x, addReference) {
  return addReference(x, 5)
}

addFive(10, add) // 15

Если вы к такому поведению не привыкли, то вышеприведённый код может показаться вам странным. Поговорим о том, что здесь происходит. А именно, мы передаём функцию add функции addFive в виде аргумента, переименовываем её в addReference и после этого вызываем.

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

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

function add (x,y) {
  return x + y
}

function higherOrderFunction (x, callback) {
  return callback(x, 5)
}

higherOrderFunction(10, add)

Этот паттерн должен показаться вам знакомым. Дело в том, что если вы пользовались, например, методами массивов JavaScript, работали с jQuery или с lodash, то вы уже использовали и функции высшего порядка и коллбэки.

[1,2,3].map((i) => i + 5)

_.filter([1,2,3,4], (n) => n % 2 === 0 );

$('#btn').on('click', () =>
  console.log('Callbacks are everywhere')
)

Вернёмся к нашему примеру. Что если, вместо создания лишь функции addFive, мы хотим создать ещё и функцию addTen, и addTwenty, и другие подобные. Учитывая то, как реализована функция addFive, нам, для создания вышеупомянутых функций на её основе, придётся копировать её код и менять его.

function add (x, y) {
  return x + y
}

function addFive (x, addReference) {
  return addReference(x, 5)
}

function addTen (x, addReference) {
  return addReference(x, 10)
}

function addTwenty (x, addReference) {
  return addReference(x, 20)
}

addFive(10, add) // 15
addTen(10, add) // 20
addTwenty(10, add) // 30

Надо отметить, что код у нас получился не таким уж и кошмарным, но ясно видно, что многие фрагменты в нём повторяются. Наша цель заключается в том, чтобы мы могли бы создавать столько функций, добавляющих некие числа к переданным им числам (addFive, addTen, addTwenty, и так далее), сколько нам нужно, минимизировав при этом дублирование кода. Может быть для достижения этой цели нам надо создать функцию makeAdder? Эта функция может принимать некое число и ссылку на функцию add. Так как цель этой функции заключается в создании новой функции, добавляющей переданное ей число к заданному, мы можем сделать так, чтобы функция makeAdder возвращала бы новую функцию, в которой задано некое число (вроде числа 5 в makeFive), и которая могла бы принимать числа для сложения с этим числом.

Взглянем на пример реализации вышеописанных механизмов.

function add (x, y) {
  return x + y
}

function makeAdder (x, addReference) {
  return function (y) {
    return addReference(x, y)
  }
}

const addFive = makeAdder(5, add)
const addTen = makeAdder(10, add)
const addTwenty = makeAdder(20, add)

addFive(10) // 15
addTen(10) // 20
addTwenty(10) // 30

Теперь мы можем создавать столько add-функций, сколько нужно, и при этом минимизировать объём дублирования кода.

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

Всё это хорошо, но при чём тут React и вышеописанная проблема дублирования кода обработки событий мыши при создании новых компонентов, которым нужна эта возможность? Дело в том, что точно так же, как функция высшего порядка makeAdder помогает нам минимизировать дублирование кода, то, что называется «компонент высшего порядка», поможет нам справиться с той же проблемой в React-приложении. Однако тут всё будет выглядеть несколько иначе. А именно, вместо схемы работы, в ходе которой функция высшего порядка возвращает новую функцию, которая вызывает коллбэк, компонент высшего порядка может реализовать собственную схему. А именно, он способен возвратить новый компонент, который рендерит компонент, играющий роль «коллбэка». Пожалуй, мы уже очень много всего наговорили, поэтому пора перейти к примерам.

Наша функция высшего порядка


Эта функция отличается следующими особенностями:

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

function higherOrderFunction (callback) {
  return function () {
    return callback()
  }
}

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


Этот компонент можно охарактеризовать так:

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

function higherOrderComponent (Component) {
  return class extends React.Component {
    render() {
      return <Component />
    }
  }
}

Внедрение HOC


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

state = { hovering: false }
mouseOver = () => this.setState({ hovering: true })
mouseOut = () => this.setState({ hovering: false })

Учитывая это, нам надо, чтобы наш компонент высшего порядка (назовём его withHover) мог бы инкапсулировать код обработки событий мыши, а затем передавал бы свойство hovering компонентам, которые он рендерит. Это позволит нам предотвратить дублирование соответствующего кода, разместив его в компоненте withHover.

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

const InfoWithHover = withHover(Info)
const TrendChartWithHover = withHover(TrendChart)
const DailyChartWithHover = withHover(DailyChart)

Затем, когда то, что возвратит withHover, будет отрендерено, это будет исходным компонентом, которому передано свойство hovering.

function Info ({ hovering, height }) {
  return (
    <>
      {hovering === true
        ? <Tooltip id={this.props.id} />
        : null}
      <svg
        className="Icon-svg Icon--hoverable-svg"
        height={height}
        viewBox="0 0 16 16" width="16">
          <path d="M9 8a1 1 0 0 0-1-1H5.5a1 1 0 1 0 0 2H7v4a1 1 0 0 0 2 0zM4 0h8a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4zm4 5.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z" />
      </svg>
    </>
  )
}

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

  • Принимать аргумент Component.
  • Возвращать новый компонент.
  • Рендерить аргумент Component, передавая ему свойство hovering.

▍Приём аргумента Component


function withHover (Component) {

}

▍Возврат нового компонента


function withHover (Component) {
  return class WithHover extends React.Component {

  }
}

▍Рендеринг компонента Component с передачей ему свойства hovering


Сейчас перед нами встаёт следующий вопрос: как добраться до свойства hovering? На самом деле, код для работы с этим свойством мы уже писали. Нам лишь надо добавить его в новый компонент, а затем передать ему свойство hovering при рендеринге компонента, переданного компоненту высшего порядка в виде аргумента Component.

function withHover(Component) {
  return class WithHover extends React.Component {
    state = { hovering: false }
    mouseOver = () => this.setState({ hovering: true })
    mouseOut = () => this.setState({ hovering: false })
    render() {
      return (
        <div onMouseOver={this.mouseOver} onMouseOut={this.mouseOut}>
          <Component hovering={this.state.hovering} />
        </div>
      );
    }
  }
}

Я предпочитаю рассуждать об этих вещах следующим образом (и так об этом говорится в документации к React): компонент преобразует свойства в пользовательский интерфейс, а компонент высшего порядка преобразует компонент в другой компонент. В нашем случае мы преобразуем компоненты Info, TrendChart и DailyChart в новые компоненты, которые, благодаря свойству hovering, знают о том, находится ли над ними указатель мыши.

Дополнительные замечания


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

Если вы взглянете на наш HOC withHover, можно заметить, что у него имеется, как минимум, одно слабое место. Он подразумевает, что компонент-получатель свойства hovering никаких проблем с этим свойством не испытает. В большинстве случаев, вероятно, такое предположение оправдано, но может случиться так, что подобное недопустимо. Например, что если у компонента уже есть свойство hovering? В таком случае возникнет коллизия имён. Поэтому в компонент withHover можно внести изменение, которое заключается в том, чтобы позволить пользователю этого компонента указывать то, какое имя должно носить свойство hovering, передаваемое компонентам. Так как withHover — это всего лишь функция, давайте перепишем её так, чтобы она принимала второй аргумент, который задаёт имя свойства, передаваемое компоненту.

function withHover(Component, propName = 'hovering') {
  return class WithHover extends React.Component {
    state = { hovering: false }
    mouseOver = () => this.setState({ hovering: true })
    mouseOut = () => this.setState({ hovering: false })
    render() {
      const props = {
        [propName]: this.state.hovering
      }

      return (
        <div onMouseOver={this.mouseOver} onMouseOut={this.mouseOut}>
          <Component {...props} />
        </div>
      );
    }
  }
}

Теперь мы задаём, благодаря механизму значений параметров по умолчанию ES6, стандартное значение второго аргумента как hovering, но если пользователь компонента withHover хочет это изменить, он может передать, в этом втором аргументе, то имя, которое ему нужно.

function withHover(Component, propName = 'hovering') {
  return class WithHover extends React.Component {
    state = { hovering: false }
    mouseOver = () => this.setState({ hovering: true })
    mouseOut = () => this.setState({ hovering: false })
    render() {
      const props = {
        [propName]: this.state.hovering
      }

      return (
        <div onMouseOver={this.mouseOver} onMouseOut={this.mouseOut}>
          <Component {...props} />
        </div>
      );
    }
  }
}

function Info ({ showTooltip, height }) {
  return (
    <>
      {showTooltip === true
        ? <Tooltip id={this.props.id} />
        : null}
      <svg
        className="Icon-svg Icon--hoverable-svg"
        height={height}
        viewBox="0 0 16 16" width="16">
          <path d="M9 8a1 1 0 0 0-1-1H5.5a1 1 0 1 0 0 2H7v4a1 1 0 0 0 2 0zM4 0h8a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4zm4 5.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z" />
      </svg>
    </>
  )
}

const InfoWithHover = withHover(Info, 'showTooltip')

Проблема в реализации withHover


Возможно, вы заметили ещё одну проблему в реализации withHover. Если проанализировать наш компонент Info, можно заметить, что он, кроме прочего, принимает свойство height. То, как у нас сейчас всё устроено, означает, что height будет установлено в undefined. Причина подобного заключается в том, что компонент withHover — это компонент, ответственный за рендеринг того, что ему передано в виде аргумента Component. Сейчас мы никаких свойств, кроме созданного нами hovering, компоненту Component не передаём.

const InfoWithHover = withHover(Info)

...

return <InfoWithHover height="16px" />

Свойство height передаётся компоненту InfoWithHover. А чем является этот компонент? Это — тот компонент, который мы возвращаем из withHover.

function withHover(Component, propName = 'hovering') {
  return class WithHover extends React.Component {
    state = { hovering: false }
    mouseOver = () => this.setState({ hovering: true })
    mouseOut = () => this.setState({ hovering: false })
    render() {
      console.log(this.props) // { height: "16px" }

      const props = {
        [propName]: this.state.hovering
      }

      return (
        <div onMouseOver={this.mouseOver} onMouseOut={this.mouseOut}>
          <Component {...props} />
        </div>
      );
    }
  }
}

Внутри компонента WithHover this.props.height равняется 16px, но в дальнейшем мы ничего с этим свойством не делаем. Нам нужно сделать так, чтобы это свойство было бы передано аргументу Component, который мы рендерим.

render() {
      const props = {
        [propName]: this.state.hovering,
        ...this.props,
      }

      return (
        <div onMouseOver={this.mouseOver} onMouseOut={this.mouseOut}>
          <Component {...props} />
        </div>
      );
}

О проблемах работы с компонентами высшего порядка сторонней разработки


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

При использовании HOC происходит инверсия управления. Представьте, что мы используем компонент высшего порядка, который разработан не нами, наподобие HOC withRouter React Router. В соответствии с документацией, withRouter передаст свойства match, location и history обёрнутому им компоненту при его рендеринге.

class Game extends React.Component {
  render() {
    const { match, location, history } = this.props // From React Router

    ...

  }
}

export default withRouter(Game)

Обратите внимание, что мы не создаём элемент Game (то есть — <Game />). Мы полностью передаём наш компонент React Router и доверяем этому компоненту не только рендеринг, но и передачу правильных свойств нашему компоненту. Выше мы уже сталкивались с этой проблемой, когда говорили о возможном конфликте имён при передаче свойства hovering. Для того чтобы это исправить, мы решили позволить пользователю HOC withHover применять второй аргумент для настройки имени соответствующего свойства. Используя чужой HOC withRouter мы такой возможности не имеем. Если в компоненте Game уже используются свойства match, location или history, то, можно сказать, нам не повезло. А именно, нам либо придётся менять эти имена в нашем компоненте, либо отказаться от использования HOC withRouter.

Итоги


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

Уважаемые читатели! Пользуетесь ли вы компонентами высшего порядка в React?

  • +29
  • 7,3k
  • 7

RUVDS.com

1006,00

RUVDS – хостинг VDS/VPS серверов

Поделиться публикацией

Похожие публикации

Комментарии 7
    +2
    Не пользуюсь, но думаю, что нужно обязательно попробовать, коллеги давно говорили о возможности их использования. Спасибо за доходчивый перевод.
      +1

      Если используете какой-нибудь redux, значит уже использовали с помощь connect


      export default connect(Component,  ...)**
      0

      В данном случае, наверное, красивее писать так


      {hovering && <Tooltip id={this.props.id} />}
        0
        <>...</> это вместо React.Fragment?
          0
          Это и есть реакт.фрагмент.
          0
          По поводу последней проблемы, ничто не мешает написать свой адаптер withAdaptedRouter
            0
            У меня такой вопрос. Рано или поздно абстракция дает течь или обнаруживаются фатальные недостатки. Мы встаем перед дилеммой: писать новый код на component2.0 или делать рефакторинг старого кода и для нового кода продолжать использовать component1.0. В идеале видится следование второму пути. Но может выйти так что постоянный рефакторинг съест гору времени, а через пару месяцев окажется что концепция была выбрана неверно и нужно опять переписывать.
            Возможно стоит выбрать первый путь и согласится что система всегда будет в состоянии «эволюции», что старые ее части, очевидно, будут устаревать. И важно что при разработке это будет учитываться заранее (пример: app/api/v1).
            И это касается классов, компонентов и даже целых модулей. Есть ли тут серебряная пуля?

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое