Анатомия React для начинающих. Урок 10. Контекст, фрагменты и порталы

Плейлист со всеми уроками: https://www.youtube.com/playlist?list=PLvWwA9iDlhHBQ6razvwomGcUIfQm4fk6D

GitHub-репозиторий с готовыми приложениями: http://bit.ly/gh_course_react_beginner


Сейчас мы разберем контекст, фрагменты и порталы в Реакте и поймем зачем все это нужно и вообще может ли быть полезно.

КОНТЕКСТ

Итак, контекст. Это очень полезная вещь, когда нам нужно прокинуть пропсы через несколько промежуточных компонентов вниз. Рассмотрим такой пример:

import React from 'react'
import ReactDOM from 'react-dom'
import './styles.css'

const ThemeContext = React.createContext()

class App extends React.Component {
  render() {
    return (
      <ThemeContext.Provider value={{ themeColor: '#55aa77' }}>
        <div className='appWrapper'>
          <Header />
          <Main />
          <Sidebar />
        </div>
      </ThemeContext.Provider>
    )
  }
}

const Header = () => (
  <div className='sectionHeader'>
    <Button active text='Your orders' />
    <Button text='Settings' />
    <Button text='Logout' />
  </div>
)

const orders = [
  {
    date: '02.08.17',
    amount: '2500',
    buyerName: 'Evgeniy Egelsky'
  },
  {
    date: '17.11.17',
    amount: '1500',
    buyerName: 'Alex Feel'
  },
  {
    date: '20.05.18',
    amount: '3000',
    buyerName: 'Rite Timo'
  }
]

const Main = () => (
  <div className='sectionMain'>
    <table>
      <thead>
        <tr>
          <th>Date</th>
          <th>Amount, rub</th>
          <th>Buyer name and surname</th>
        </tr>
      </thead>

      <tbody>
        {orders.map((order, i) => (
          <tr key={i}>
            <td>{order.date}</td>
            <td>{order.amount}</td>
            <td>{order.buyerName}</td>
          </tr>
        ))}
      </tbody>
    </table>

    <Button text='Show more..' />
  </div>
)

const Sidebar = () => (
  <div className='sectionSidebar'>
    <p className='usersOnline'>Users online: 1572</p>
    <Button text='Go to chat..' />
  </div>
)

class Button extends React.Component {
  static contextType = ThemeContext

  render() {
    return (
      <button
        style={{ backgroundColor: this.context.themeColor }}
        className={this.props.active ? 'btnActive' : ''}
      >
        {this.props.text}
      </button>
    )
  }
}

ReactDOM.render(<App />, document.getElementById('root'))

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

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

Также, у нас есть компонент Button — кнопка, которая умеет применять цвет, пришедший ей сверху.

Можно видеть что все в App обернуто в ThemeContext.Provider. Это и есть контекст, который будет пробрасываться во все компоненты, лежащие внутри App на любом уровне вложенности.

Компонент-контекст создается выше, путем вызова метода React.createContext(). В скобках же задается некоторое значение по умолчанию. Это может быть строка, число, объект.. В зависимости от того, сколько данных вам нужно передать через контекст вниз. Можно инициализировать пустым значением, то есть в скобочки не передавать ничего.

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

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

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

Итак, после создания самого контекста, мы оборачиваем в него внутренности нашего приложения и для атрибута value задаем то, что контекст будет передавать. У нас это объект с полем themeColor и значением цвета.

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

Давайте для доказательства того, что это работает, изменю цвет в контексте.. Работает.

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

Меняем контекст из вложенных компонентов

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

import React from 'react'
import ReactDOM from 'react-dom'
import './styles.css'

const ThemeContext = React.createContext()

class App extends React.Component {
  state = {
    themeColor: '#55aa77'
  }

  changeThemeColor = (newColor) => {
    this.setState({
      themeColor: newColor
    })
  }

  render() {
    return (
      <ThemeContext.Provider value={{
        themeColor: this.state.themeColor,
        changeThemeColor: this.changeThemeColor
      }}>
        <div className='appWrapper'>
          <Header />
          <Main />
          <Sidebar />
        </div>
      </ThemeContext.Provider>
    )
  }
}

const Header = () => (
  <div className='sectionHeader'>
    <Button active text='Your orders' />
    <Button text='Settings' />
    <Button text='Logout' />
  </div>
)

const orders = [
  {
    date: '02.08.17',
    amount: '2500',
    buyerName: 'Evgeniy Egelsky'
  },
  {
    date: '17.11.17',
    amount: '1500',
    buyerName: 'Alex Feel'
  },
  {
    date: '20.05.18',
    amount: '3000',
    buyerName: 'Rite Timo'
  }
]

const Main = () => (
  <div className='sectionMain'>
    <table>
      <thead>
        <tr>
          <th>Date</th>
          <th>Amount, rub</th>
          <th>Buyer name and surname</th>
        </tr>
      </thead>

      <tbody>
        {orders.map((order, i) => (
          <tr key={i}>
            <td>{order.date}</td>
            <td>{order.amount}</td>
            <td>{order.buyerName}</td>
          </tr>
        ))}
      </tbody>
    </table>

    <Button text='Show more..' />
  </div>
)

class Sidebar extends React.Component {
  constructor(props) {
    super(props)
    this.input = React.createRef()
  }

  static contextType = ThemeContext

  handleChangeColor = (e) => {
    const value = this.input.current.value
    this.input.current.value = ''

    if (value.match(/^#[0-9,A-F]{6}$/i)) {
      this.context.changeThemeColor(value)
    } else {
      return
    }
  }

  render() {
    return (
      <div className='sectionSidebar'>
        <p className='usersOnline'>Users online: 1572</p>
        <Button text='Go to chat..' />
        <hr/>

        <p>Change color to:</p>
        <label>
          <input ref={this.input} placeholder='Full HEX color..' />
        </label>
        <br/><br/>
        <Button text='Change' clickHandler={this.handleChangeColor} />
      </div>
    )
  }
}

class Button extends React.Component {
  static contextType = ThemeContext

  render() {
    return (
      <button
        style={{ backgroundColor: this.context.themeColor }}
        className={this.props.active ? 'btnActive' : ''}
        onClick={this.props.clickHandler ? this.props.clickHandler : () => {}}
      >
        {this.props.text}
      </button>
    )
  }
}

ReactDOM.render(<App />, document.getElementById('root'))

Мы завели в App стейт со значением цвета, а также метод с setState, который умеет значение цвета менять.
В контекст мы теперь передаем объект, где цвет берется из стейта, а также есть еще и ссылка на тот метод из класса App, что умеет менять цвет. Так мы сможем его вызывать из компонентов ниже, передавая новый цвет в качестве аргумента.

Header и Main остаются теми же самыми. А вот класс Sidebar мы превращаем в компонент-контейнер и всю разметку помещаем в метод render. Там мы добавили виджет для смены цвета с подписью, полем ввода и кнопкой. В компонент кнопки передаем пропс clickHandler, который обрабатывается соответствующим методом выше. Там мы берем значение из input’а. Однако чтобы это работало, нужно из обработчика кнопки взять значение инпута, но event-объект указывает совсем не на нужный нам инпут. Эту проблему легко решить, используя ref’ы, которые мы еще не разбирали. Однако с ними все просто. В конструкторе создаем поле метода с подходящим названием — в нашем случае пусть будет просто input и туда поместится результат вызова функции React.createRef(), а в render просто для пропса ref задаем этот this.input. Готово, мы теперь можем из любого метода компонента обращатся к инпуту и брать его значение.

Так вот, в обработчике кнопки сохраняем значение, затем обнуляем поле ввода и сохраненное значение подвергаем проверке на регулярное выражение вот такого формата. Оно говорит, что если ввод не соответствует формату из шести символов, где могут фигурировать только цифры от 0 до 9 и буквы от А до F, и который предваряется решеткой, то мы просто выходим из функции. Иначе же вызываем из того самого конекста метод для смены цвета, определенный в App. Он меняет стейт там на введенный цвет и это вызывает перерисовку. Поэтому наша тема и меняет цвет. В частности, кнопки.

Еще замечу, что конекст в Sidebar работает, потому что я добавил static-поле contextType по аналогии с Button.
Ну и последнее здесь это обработчик клика, который пробрасывается в компонент Button. Я переписал сам Button так, чтобы этот обработчик навешивался напосредственно на элемент button, взяв его из переданного пропса clickHandler. Если он там определен, то навешиваем его, в противном же случае это будет пустая стрелочная функция в качестве болванки.

Разбиваем код на отдельные файлы

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

Дело в том, что как только мы это сделаем, контекст больше не будет доступен для всех компонентов и придется переделать некоторые вещи. А именно — экспортировать Consumer, то есть потребитель контекста из App, чтобы иметь возможность его импортировать в любом нужном компоненте. Provider, как вы наверное уже догадались, это тот, кто контекст предоставляет.

Итак, я разделил наше приложение таким образом, что каждый компонент занимает свой файл. index.js рисует корневой компонент , который находится здесь. Кроме него есть три основных наших секции Header, Main и Sidebar, а также реиспользуемый компонент Button.

App.js:

import React from 'react'

import Header from './Header'
import Main from './Main'
import Sidebar from './Sidebar'

const { Provider, Consumer } = React.createContext()

class App extends React.Component {
  state = {
    themeColor: '#55aa77'
  }

  changeThemeColor = (newColor) => {
    this.setState({
      themeColor: newColor
    })
  }

  render() {
    return (
      <Provider value={{
        themeColor: this.state.themeColor,
        changeThemeColor: this.changeThemeColor
      }}>
        <div className='appWrapper'>
          <Header />
          <Main />
          <Sidebar />
        </div>
      </Provider>
    )
  }
}

export { Consumer }

export default App

Создание контекста находится в App. При этом, вот этой строкой мы извлекаем из контекста отдельно провайдер и отдельно консьюмер, деструктурируя их. Ранее мы сохраняли все в переменную ThemeContext и брали оттуда компонент провайдера через точку. Теперь же оборачиваем наш App в Provider, по-прежнему отправляя туда значение из стейта и метод для его смены из нижележащих компонентов.

Внизу App кроме экспорта по умолчанию, мы делаем еще и недефолтный экспорт где указываем Consumer. Теперь пойдем в компонент Button, который через контекст считывает цвет.

Button.js:

import React from 'react'
import { Consumer } from './App'

class Button extends React.Component {
  render() {
    return (
      <Consumer>
        {({ themeColor }) => (
          <button
            style={{ backgroundColor: themeColor }}
            className={this.props.active ? 'btnActive' : ''}
            onClick={this.props.clickHandler ? this.props.clickHandler : () => {}}
          >
            {this.props.text}
          </button>
        )}
      </Consumer>
    )
  }
}

export default Button

Статическое поле теперь удалено, а вся прошлая разметка обернута в компонент Consumer, который мы вот таким образом импортировали из App.

Внутри Consumer у нас функция, которая получает аргументом весь контекст. Тут я для удобства сразу извлек оттуда themeColor с цветом. Хотя можно было записать и так..

Далее я возвращаю ту разметку, что была ранее для кнопки, поменяв this.context.themeColor на просто themeColor, чтобы взять его из этого замыкания. И это работает.

Sidebar.js:

import React from 'react'
import Button from './Button'
import { Consumer } from './App'

class Sidebar extends React.Component {
  constructor(props) {
    super(props)
    this.input = React.createRef()
  }

  handleChangeColor = (e, changeThemeColor) => {
    const value = this.input.current.value
    this.input.current.value = ''

    if (value.match(/^#[0-9,A-F]{6}$/i)) {
      changeThemeColor(value)
    } else {
      return
    }
  }

  render() {
    return (
      <Consumer>
        {({ changeThemeColor }) => {
          return (
            <div className='sectionSidebar'>
              <p className='usersOnline'>Users online: 1572</p>
              <Button text='Go to chat..' />
              <hr/>

              <p>Change color to:</p>
              <label>
                <input ref={this.input} placeholder='Full HEX color..' />
              </label>
              <br/><br/>
              <Button text='Change' clickHandler={e => this.handleChangeColor(e, changeThemeColor)} />
            </div>
          )
        }}
      </Consumer>
    )
  }
}

export default Sidebar

С Sidebar чуть сложнее. Static-поле также исчезло, а вся разметка обернута в Consumer, импортированный из App. Деструктурируем метод changeThemeColor из контекста, а в обработчике клика у нас стрелочная функция, которая получает event-объект. При клике эта функция выполняется и дергается уже метод handleChangeColor, куда передается и event-объект и метод handleChangeColor, которым мы и пользуемся.

Как видно, все это вполне работает. Consumer создается здесь один раз, а при импорте в других компонентах на него делается ссылка. Поэтому сколько бы раз мы в разных компонентах не импортировали Consumer, это будет ссылка на один и тот же объект. Так это работает. Этот механизм распространяется и на все остальные подобные случаи.

Уменьшаем и упрощаем пример

Однако, проанализировав этот код спустя какое-то время после подготовки материала данного урока, я понял, что можно переписать его намного оптимальнее, если не избавляться от static-полей contextType. И дело вот в чем. В React версии 16.6 к новому механизму конекстов, который был представлен уже в версии 16.3, добавилось это самое static-поле contextType. Оно позволяет на заворачивать наш компонент в компонент с контекстом и не добавлять лишней вложенности за счет вот этой функции внутри.

Я переделал предыдущий пример и теперь все работает вот так. Создание нашего контекста вынесено в отдельный файл — ThemeContext.js, который мы из него по дефолту экспортируем.

ThemeContext.js:

import React from 'react'

const ThemeContext = React.createContext()

export default ThemeContext

Затем импортируем его а App.js и называем ThemeContext.Provider. Создание контекста из App.js, разумеется, теперь убрано. А в Button.js и Sidebar.js мы эти контексты импортируем, но лишь для того, чтобы в классе компонента задать статическое свойство contextType, которому будет присвоено значение нашего ThemeContext. Это простое действие позволяет нам как внутри JSX-разметки, так и в методах класса, обращаться к контексту через this.context.

App.js:

import React from 'react'

import Header from './Header'
import Main from './Main'
import Sidebar from './Sidebar'

import ThemeContext from './ThemeContext'

class App extends React.Component {
  state = {
    themeColor: '#55aa77'
  }

  changeThemeColor = (newColor) => {
    this.setState({
      themeColor: newColor
    })
  }

  render() {
    return (
      <ThemeContext.Provider value={{
        themeColor: this.state.themeColor,
        changeThemeColor: this.changeThemeColor
      }}>
        <div className='appWrapper'>
          <Header />
          <Main />
          <Sidebar />
        </div>
      </ThemeContext.Provider>
    )
  }
}

export default App

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

Button.js:

import React from 'react'
import ThemeContext from './ThemeContext'

class Button extends React.Component {
  static contextType = ThemeContext

  render() {
    return (
      <button
        style={{ backgroundColor: this.context.themeColor }}
        className={this.props.active ? 'btnActive' : ''}
        onClick={this.props.clickHandler ? this.props.clickHandler : () => {}}
      >
        {this.props.text}
      </button>
    )
  }
}

export default Button

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

Sidebar.js:

import React from 'react'
import Button from './Button'
import ThemeContext from './ThemeContext'

class Sidebar extends React.Component {
  constructor(props) {
    super(props)
    this.input = React.createRef()
  }

  static contextType = ThemeContext

  handleChangeColor = () => {
    const value = this.input.current.value
    this.input.current.value = ''

    if (value.match(/^#[0-9,A-F]{6}$/i)) {
      this.context.changeThemeColor(value)
    } else {
      return
    }
  }

  render() {
    return (
      <div className='sectionSidebar'>
        <p className='usersOnline'>Users online: 1572</p>
        <Button text='Go to chat..' />
        <hr/>

        <p>Change color to:</p>
        <label>
          <input ref={this.input} placeholder='Full HEX color..' />
        </label>
        <br/><br/>
        <Button text='Change' clickHandler={this.handleChangeColor} />
      </div>
    )
  }
}

export default Sidebar

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

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

И это будет работать за счет механизма замыканий в JavaScript.

Старый механизм контекстов

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

Это стоит рассмотреть, потому что много библиотек используют именно его и, по моему мнению, он местами даже был удобнее и проще. Поддержка старого варианта будет осуществляться на протяжении релизов 16-й мажорной версии Реакта. На момент выпуска этого ролика, актуальной была версия 16.6.0.

Разберем старые контексты прямо по докам.

class Button extends React.Component {
  render() {
    return (
      <button style={{background: this.props.color}}>
        {this.props.children}
      </button>
    );
  }
}

class Message extends React.Component {
  render() {
    return (
      <div>
        {this.props.text} <Button color={this.props.color}>Delete</Button>
      </div>
    );
  }
}

class MessageList extends React.Component {
  render() {
    const color = "purple";
    const children = this.props.messages.map((message) =>
      <Message text={message.text} color={color} />
    );
    return <div>{children}</div>;
  }
}

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

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

import PropTypes from 'prop-types';

class Button extends React.Component {
  render() {
    return (
      <button style={{background: this.context.color}}>
        {this.props.children}
      </button>
    );
  }
}

Button.contextTypes = {
  color: PropTypes.string
};

class Message extends React.Component {
  render() {
    return (
      <div>
        {this.props.text} <Button>Delete</Button>
      </div>
    );
  }
}

class MessageList extends React.Component {
  getChildContext() {
    return {color: "purple"};
  }

  render() {
    const children = this.props.messages.map((message) =>
      <Message text={message.text} />
    );
    return <div>{children}</div>;
  }
}

MessageList.childContextTypes = {
  color: PropTypes.string
};

Здесь мы для компонента MessageList cоздаем метод getChildContext, где возвращаем объект с тем, что станет нашим контекстом. А затем добавляем статическое поле childContextTypes, где говорим, что color должен быть строкой.

Теперь в Message нет никаких промежуточных пропсов.

А вот в Button мы контекст считываем, просто добавив статическое поле contextTypes и написав там то же самое, что и в MessageList. И в итоге используем значение из контекста, обращаясь к нему через this.context.

Контекст также пробрасывается в методы жизненного цикла дополнительным аргументом. Вот тут его можно видеть:

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

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

ФРАГМЕНТЫ

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

Однако используя фрагменты можно все-таки обойтись без этой лишней прокладки. Речь про компонент React.Fragment.

В доках простой пример на эту тему:

class Table extends React.Component {
  render() {
    return (
      <table>
        <tr>
          <Columns />
        </tr>
      </table>
    );
  }
}

Рендерится таблица, в строке которой находится компонент, который возвращает ее ячейки:

class Columns extends React.Component {
  render() {
    return (
      <div>
        <td>Hello</td>
        <td>World</td>
      </div>
    );
  }
}

В обычном случае мы вернем несколько , обернутых в див, но это несемантично — внутри tr могут находиться только td.

Поэтому return компонента Columns оборачиваем в компонент React.Fragment:

class Columns extends React.Component {
  render() {
    return (
      <React.Fragment>
        <td>Hello</td>
        <td>World</td>
      </React.Fragment>
    );
  }
}

и это дает в итоге в DOM‘е то что нужно:

<table>
  <tr>
    <td>Hello</td>
    <td>World</td>
  </tr>
</table>

Есть и короткий синтаксис фрагмента в виде пустых угловых скобок, однако нужно удостовериться что ваша версия Babel равна 7-й и выше. Иначе не заработает.

Fragment поддерживает только специальный атрибут key, и пока только его. Позже обещают добавить и поддержку произвольных пропсов.

В принципе с фрагментами все.

ПОРТАЛЫ

И последняя для этого урока функция React’а — порталы. Звучит очень круто, хотя по сути все намного менее фантастично, хотя и удобно.

Идея порталов в том, чтобы рисовать компонент где-то внутри ReactDOM-дерева, но в реальном DOM-дереве разметка этого компонента размещается внутри ноды, указанной как точка назначения портала. Причем они могут быть в совершенно разных ветвях этого дерева.

Это нужно для того, чтобы например, прорвать контейнер с overflow: hidden для показа подсказки или чего-то еще. Или, чтобы отрисовать компонент Реакта вне root-ноды. Бывает так, что Реакт-приложение является частью статической страницы. В общем, применения этому можно придумать и самостоятельно. Главное поймите суть и когда надо, оно у вас всплывет в виде решения той или иной проблемы.

Я подготовил пример (https://github.com/makewebme/course_react_beginner/tree/master/lesson-10/05-portals) — это некий интернет-магазин с товарами, которые имеют картинки разного размера внутри контейнера с overflow: hidden для того, чтобы можно было показывать изображения разных размеров и чтобы при этом все товары имели всегда предсказуемые размеры.

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

App.js:

import React from 'react'
import Item from './Item'

import pig from './img/pig.jpg'
import notebook from './img/notebook.png'
import car from './img/car.png'

class App extends React.Component {
  items = [
    {
      image: pig,
      price: 1000,
      desc: 'Nice little pig'
    },
    {
      image: notebook,
      price: 80000,
      desc: 'Comfortale notebook with high performance'
    },
    {
      image: car,
      price: 10000000,
      desc: 'Lux sport car'
    }
  ]

  render() {
    return (
      <>
        <h1>Things Shop</h1>

        <div className='appWrapper cont'>
          {this.items.map((item) => <Item item={item} />)}
        </div>

        <p className='cont'>
          Lorem ipsum dolor sit amet, et sapiente!
        </p>
      </>
    )
  }
}

export default App

В App.js у нас есть массив с товарами и данными о них. Картинки импортированы выше. Рисуем заголовок выше и параграф ниже. А по центру каждый товар выводим через компонент Item , мэпясь по массиву this.items и передавая информацию о каждом конкретном товаре через пропс item.

Item.js:

import React from 'react'
import { findDOMNode } from 'react-dom'
import Description from './Description'

class Item extends React.Component {
  state = {
    descriptionVisible: false
  }

  toggleDescription = (visibility) => {
    this.setState((state) => ({
      descriptionVisible: visibility
    }))

    const desc = document.querySelector('#itemDescription')
    const { x, y, width, height } = findDOMNode(this).getBoundingClientRect()

    if (visibility) {
      desc.classList.add('visible')
      desc.style.left = `${x}px`
      desc.style.top = `${y + height}px`
      desc.style.width = `${width}px`
    } else {
      desc.classList.remove('visible')
    }
  }

  render() {
    const { image, price, desc } = this.props.item

    return (
      <div
        className='item'
        onMouseEnter={this.toggleDescription.bind(this, true)}
        onMouseLeave={this.toggleDescription.bind(this, false)}
      >
        <img className='image' src={image} />
        {
          this.state.descriptionVisible
          ? <Description price={price} desc={desc} /> : null
        }
      </div>
    )
  }
}

export default Item

В Item.js имется div c классом item у которого высота ограничена 250 пикселами и имеется CSS-свойство overflow: hidden, чтобы ничего за его границы не вылезало. Картинка же внутри него спозиционирована абсолютно, выровнена по центру и растягивается на 100 процентов ширины. Пропорции сохраняются, так как высоту img мы не трогаем.

Description.js:

import React from 'react'
import { createPortal } from 'react-dom'

class Description extends React.Component {
  render() {
    const { price, desc } = this.props

    return createPortal(
      <>
        <p>Price: {price}</p>
        <p>Description: {desc}</p>
      </>,
      document.querySelector('#itemDescription')
    )
  }
}

export default Description

Компонент Description мы показываем только тогда, когда в стейте descriptionVisible равен true. Внутрь Description пропсами передаются деструктурированные price и desc.

По структуре компонентов React выходит, что Description находится внутри Item. Но в реальном DOM будет иначе, потому что внутри Description вместо простого возврата JSX-разметки из render, вызываем функцию createPortal из ReactDOM. Первый аргумент это разметка, а второй — заселекченная из DOM-дерева нода. Причем прописана она должна быть в HTML-файле в папке public. У меня это div#itemDescription. Именно туда поместится разметка. Вне React-приложения.

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

По их возникновению я вызываю один и тот же метод компонента — toggleDescription — которому с помощью джаваскриптового bind привязываю первым аргументом true для onMouseEnter и false для onMouseLeave. Первым аргументом в bind передается значение this. Я оставляю его тем что и был раньше — указателем на сам компонент.

За счет передачи true/false в аргументе visibility у меня будет true в одном случае и false в другом. И я всегда знаю что сейчас нужно сделать — показать подсказку или скрыть ее. Это значение мы «сет-cтейтим» в descriptionVisible, что вызовет перерисовку Item и подстановку разметки в портал.

Далее мы селектим ноду на которую указывает портал и сохраняем в desc. После, с помощью findDOMNode из ReactDOM берем DOM-ноду, корневую для нашего компонента Item и вызываем JS-метод getBoundingClientRect. Это дает нам объект, из которого мы извлекаем абсолютные координаты этой ноды от верхнего левого угла документа и ее размеры.

И проверка — если visibility равно true, значит мы показываем Description и нужно подставить координаты. Делаем это обращаясь к стилям ноды напрямую. В обратных скобках интерполируем значения координат и размеров.

Для left это смещение по оси x, для top — смещение по оси y плюс высота Item‘а. Ширина будет равна ширине Item‘а.
Ну и добавляем класс visible, потому что изначально у нас Description скрыт через display: none.

В противном же случае, мы удаляем класс visible и подсказка исчезает.

Если бы порталы мы не использовали, то было бы невозможно вытащить компонент Description за пределы Item‘а для которого установлено свойство overflow: hidden. А порталы решают эту проблему, как вы уже поняли.

Вот и все. Надеюсь идея порталов вам ясна. Кстати, такие вот красивые бэкграунды на чистом CSS, можно найти на сайте http://lea.verou.me/. Рекомендую.

Заключение

На этом данный урок подошел к концу. Надеюсь было полезно.

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

Удачи и в следующем уроке мы продолжим изучать фишки Реакта.