Этот пост является частью нашего курса Основы React. Если вам понравился этот пост, посмотрите его.

Если вы хотите прочитать аналогичную статью, но о компонентах более высокого порядка, ознакомьтесь с React High-Order Components

Прежде чем мы начнем, следует отметить два важных момента. Во-первых, то, о чем мы собираемся поговорить, - это просто образец. На самом деле это даже не React, а скорее компонентная архитектура. Во-вторых, эти знания не требуются для создания приложения React. Вы можете пропустить этот пост, никогда не узнать, о чем мы собираемся поговорить, и при этом создать отличные приложения на React. Однако, как и при создании чего-либо, чем больше у вас будет инструментов, тем лучше будет результат. Если вы пишете приложения React, вы оказываете себе медвежью услугу, не имея этого в своем «наборе инструментов». Вы не сможете далеко продвинуться в изучении разработки программного обеспечения, пока не услышите (почти культовую) мантру Don't Repeat Yourself или D.R.Y. Иногда можно зайти слишком далеко, но по большей части это стоящая цель. В этом посте мы рассмотрим самый популярный шаблон для выполнения DRY в кодовой базе React - компоненты высшего порядка. Однако, прежде чем мы сможем исследовать решение, мы должны сначала полностью понять проблему.

Допустим, мы занимались воссозданием панели инструментов, аналогичной 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, которые поставляются с React. Функция, которую мы передаем onMouseOver, будет вызываться при наведении курсора на компонент, а функция, которую мы передаем onMouseOut, будет вызываться, когда компонент больше не будет зависать. Чтобы сделать это способом React, мы добавим свойство состояния hovering к нашему компоненту, чтобы мы могли вызывать повторный рендеринг при изменении состояния 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 отлично работала, поэтому давайте снова воспользуемся тем же кодом.

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 раньше. Это не конец света (#shipit), но и не очень «СУХОЙ». Как вы видели, мы повторяем одну и ту же логику наведения в каждом из наших компонентов.

На этом этапе проблема должна быть достаточно ясной: мы хотим избежать дублирования нашей логики наведения в любое время, когда это потребуется новому компоненту. Итак, каково же решение? Прежде чем мы перейдем к этому, нам нужно освежить в памяти два фундаментальных аспекта React. Это компоненты, которые не отображают пользовательский интерфейс и не передают функции как свойства.

Нет компонентов пользовательского интерфейса

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

Однако так бывает не всегда. Вполне разумно иметь компоненты, которые действуют как компоненты «оболочки». Они несут ответственность за обработку некоторой логики, но вместо рендеринга собственного пользовательского интерфейса они просто рендерит другой компонент, передавая ему данные.

class Users extends React.Component {
  state = {
    users: null
  }
  componentDidMount() {
    getUsers()
      .then((users) => {
        this.setState({ users })
      })
  }
  render() {
    <Grid data={this.state.users} />
  }
}

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

Передача функций как реквизита

Как вы знаете, свойства являются частью API компонентов React, которые позволяют передавать данные в компонент.

<User id='tylermcginnis' />

Тогда внутри компонента User объект props будет иметь свойство id, ссылающееся на строку tylermcginnis.

function User (props) {
  const id = props.id // tylermcginnis
}

А что, если вместо передачи строки в качестве опоры мы передали функцию?

<User id={() => 'tylermcginnis'} />

Теперь объект props по-прежнему имеет свойство id, только теперь он не является строкой, а ссылается на функцию. Итак, чтобы получить идентификатор, нам нужно вызвать функцию.

function User (props) {
  const id = props.id() // tylermcginnis
}

А что, если бы мы хотели передать функции prop некоторые данные? Ну, это просто функция, поэтому мы можем делать это так же, как обычно, передавая ей аргумент.

function User (props) {
  const id = props.id(true) // tylermcginnis
}
<User id={(isAuthed) => isAuthed === true ? 'tylermcginnis' : null} />

Хорошо ... но какое отношение они имеют к проблеме, которую мы видели ранее, о дублировании нашей логики наведения в любое время, когда это необходимо новому компоненту? Что ж, мы можем объединить обе эти простые концепции, чтобы решить нашу проблему.

Во-первых, мы хотим создать компонент «Wrapper», который отвечает за управление состоянием при наведении курсора. Мы назовем его, естественно, Hover, и он будет содержать всю логику зависания, которую нам пришлось скопировать ранее.

class Hover 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}>
      </div>
    )
  }
}

Следующий вопрос - что должно Hover отображаться? Здесь в игру вступают знания о функциях. Давайте Hover получим опору под названием render. Эта render опора будет функцией, которой мы можем передать hovering состояние, и она вернет некоторый пользовательский интерфейс.

<Hover render={(hovering) =>
  <div>
    Is hovering? {hovering === true ? 'Yes' : 'No'}
  <div>
} />

Теперь последнее изменение, которое нам нужно сделать, - это наш Hover компонент. Все, что нам нужно сделать, это вызвать this.props.render, передав его this.state.hover.

class Hover 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}>
        {this.props.render(this.state.hovering)}
      </div>
    )
  }
}

Хорошо бы вы взглянули на это. Теперь, когда у нас есть Hover компонент, каждый раз, когда нам нужно, чтобы компонент знал о своем состоянии наведения, мы просто оборачиваем его в опору Hovers render.

Наконец, давайте вернемся к исходному коду, который у нас был, и посмотрим, как нам больше не нужно дублировать всю логику наведения, поскольку у нас есть наш Hover компонент.

Это то, что у нас было раньше.

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>
      </>
    )
  }
}
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}
        />
      </>
    )
  }
}
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}
        />
      </>
    )
  }
}
function App () {
  return (
    <>
      <Info />
      <TrendChart />
      <DailyChart />
    </>
  )
}

И теперь с нашим Hover компонентом, вместо того, чтобы каждый компонент дублировал логику наведения, мы можем обернуть каждый из них внутри render опоры, которую мы передаем в Hover, а затем передать аргумент hovering как опору.

function Info (props) {
  return (
    <>
      {props.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>
    </>
  )
}
function TrendChart (props) {
  return (
    <>
      {props.hovering === true
        ? <Tooltip id={this.props.id}/>
        : null}
      <Chart
        type='trend'
        onMouseOver={this.mouseOver}
        onMouseOut={this.mouseOut}
      />
    </>
  )
}
function DailyChart (props) {
  return (
    <>
      {props.hovering === true
        ? <Tooltip id={this.props.id}/>
        : null}
      <Chart
        type='daily'
        onMouseOver={this.mouseOver}
        onMouseOut={this.mouseOut}
      />
    </>
  )
}
function App () {
  return (
    <>
      <Hover render={(hovering) =>
        <Info hovering={hovering}>
      }>
      <Hover render={(hovering) =>
        <TrendChart hovering={hovering}>
      }>
      <Hover render={(hovering) =>
        <DailyChart hovering={hovering}>
      }>
    </>
  )
}

Этот паттерн, как вы, наверное, уже догадались, называется Render Props. Подводя итог в документации React, «термин render prop относится к методике совместного использования кода между компонентами React с использованием свойства, значение которого является функцией».

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

function User (props) {
  return (
    <div>
      {props.children}
    </div>
  )
}
<User>
  This is props.children
</User>

В приведенном выше примере пользовательский интерфейс будет отображать div со словами This is props.children внутри него.

А что, если бы вместо props.children была строка, это была функция? Как мы видели ранее, нам нужно будет вызвать его, чтобы получить значение.

function User (props) {
  return (
    <div>
      {props.children()}
    </div>
  )
}
<User>
  {() => This is props.children}
</User>

С нашими новыми знаниями о props.children давайте обновим наши предыдущие примеры. Теперь вместо Hover наличия render опоры, давайте избавимся от всего этого и используем вместо этого props.children.

function App () {
  return (
    <>
      <Hover>
        {(hovering) => <Info hovering={hovering}>}
      </Hover>
      <Hover>
        {(hovering) => <TrendChart hovering={hovering}>}
      </Hover>
      <Hover>
        {(hovering) => <DailyChart hovering={hovering}>}
      </Hover>
    </>
  )
}

Выглядит неплохо. Теперь нам нужно обновить Hover, чтобы вместо вызова this.props.render он вызывал this.props.children.

class Hover 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}>
        {this.props.children(this.state.hovering)}
      </div>
    )
  }
}

Отлично. Это лучше? Не совсем, просто все по-другому. Я предпочитаю это, но объективно лучше в этом нет ничего.

Если вы прочтете наш пост о Компонентах высшего порядка, то узнаете, что у HOC есть некоторые подводные камни. Самый большой был с инверсией управления и коллизиями имен. Поскольку вы должны передать свой компонент компоненту более высокого порядка, вы не можете контролировать, как он будет отображаться. Мы рассмотрели пример с withRouter HOC в 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)

Если наш игровой компонент уже получает match, location или history в качестве опоры, у нас будет конфликт имен, и его будет сложно отследить.

Возникает ли та же ошибка с Render Props? Неа. Вместо того, чтобы передавать компонент, мы передаем функцию. Затем, когда эта функция будет вызвана, ей будут переданы нужные нам данные. Никакой инверсии управления и никаких коллизий при именовании, так как мы можем решить, как будет отображаться компонент.

<Hover>
  {(hovering) => {
    // We can do whatever we want here.
    // We decide how and when to render the component
    return <Info anyNameWeWant={hovering} />
  }}
</Hover>

Теперь большой вопрос: следует ли использовать Render Props или компоненты более высокого порядка? Что ж, решать тебе. Теперь вы знаете, как использовать их оба, а это значит, что у вас достаточно информации, чтобы принять обоснованное решение для себя.

Изначально это было опубликовано на сайте tylermcginnis.com в рамках их курса Основы React.

Следуйте за Тайлером в Twitter