null

ReactJS: работа с дочерними компонентами

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

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

Пошаговая инструкция на портале Госуслуги.


В простейшем случае компонент представляет собой список (StepsList) последовательно пронумерованных шагов (StepItem), каждый из которых состоит из заголовка (StepTitle) и описания шага (StepDescription). Использование компонента будет выглядеть, как показано ниже.

<StepsList>
   <StepItem>
      <StepTitle>Шаг 1</StepTitle>
      <StepDescription>Описание шага 1</StepDescription>
   </StepItem>
     
   <StepItem>
      <StepTitle>Шаг 2</StepTitle>
      <StepDescription>Описание шага 2</StepDescription>
   </StepItem>
</StepsList>

Опишем все необходимые части компонента. Первые три компонента достаточно простые.

Файл StepTitle.jsx

import ReactDOM from 'react-dom';
import React, {Component} from 'react';

export default class StepTitle extends Component {
  render() {
    return (
      <h3 className="step-title">
        {this.props.children}
      </h3>
    );
  }
};

Файл StepDescription.jsx

import ReactDOM from 'react-dom';
import React, {Component} from 'react';

export default class StepDescription extends Component {
  render() {
    return (
      <div className="step-description">
        {this.props.children}
      </div>
    );
  }
};

Файл StepItem.jsx

import ReactDOM from 'react-dom';
import React, {Component} from 'react';

export default class StepItem extends Component {
  render() {
    return (
      <div className="step">
        <div className={"step-number " + this.props.CSSClasses}>
          {this.props.stepLabel}
        </div>
        <div className="step-container">
          <div className="step-content">
            {this.props.children}
          </div>
        </div>
      </div>
    );
  }
};

Так как шаги нумеруются последовательно, номер шага будем формировать автоматически, а затем передавать компоненту StepItem через props. Делать это логично в родительском компоненте StepsList, т.к. он может иметь доступ ко всем шагам и знать их порядок.

Нам понадобится функция перебора массива дочерних компонентов. В React для этого есть метод React.Children.map(), возвращающий новый массив. Этот метод полезен тем, что корректно обрабатывает ситуацию, когда дочерний элемент всего один, и вместо массива в this.props.children содержится сам элемент.

React.Children.map(this.props.children, (child, index) => {
  return child;
})

Далее нам нужно отфильтровать все элементы внутри StepsList, не являющиеся экземпляром StepItem.

if (React.isValidElement(child) && child.type === StepItem) {}

Метод React.isValidElement(elem) проверяет, является ли компонент компонентом React. Свойство child.type содержит класс, экземпляром которого является компонент.

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

React.cloneElement(child, {
  newprop: value,
})

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

Файл StepsList.jsx

import ReactDOM from 'react-dom';
import React, {Component} from 'react';
import StepItem from './StepItem';

export default class StepsList extends Component {
  render() {
    const childrenWithProps = React.Children.map(this.props.children,
      (child, index) => {
        if (React.isValidElement(child) && child.type === StepItem) {
          return React.cloneElement(child, {
            stepLabel: index+1,
          })
        }
      }
    );
        
    return (
      <div className="instruction">
        {childrenWithProps}
      </div>
    );
  }
};

Усложним немного код, добавив возможность создавать специальные шаги, помечаемые не номером, а каким-нибудь значком. Например, чтобы обозначить первый и последний шаг. поскольку наши “специальные” шаги не будут иметь номера, добавим переменную offset, чтобы нумерация не сбивалась.

Также будем назначать таким шагам определённые css-классы, чтобы при необходимости выделить их стилями. Количество блоков if соответствует количеству различный типов шагов, у нас их два: шаг предусловий и финальный шаг. При этом шаг не может одновременно принадлежать к двум типам.

Файл StepsList.jsx

import ReactDOM from 'react-dom';
import React, {Component} from 'react';
import StepItem from './StepItem';

export default class StepsList extends Component {
  render() {
    let offset = 1;
    const childrenWithProps = React.Children.map(this.props.children,
      (child, index) => {
        if (React.isValidElement(child) && child.type === StepItem) {
          let label = index+offset;
          let class = "";
     
          if (child.props.precondition) {
            label = "\u25CF"; // see: Numeric character reference
            class = "step-precondition";
            offset--;
          }
          if (child.props.final) {
            label = "\u2714";
            class = "step-final";
          }
          
          return React.cloneElement(child, {
            stepLabel: label,
            CSSClasses: class
          })
        }
      }
    );
        
    return (
      <div className="instruction">
        {childrenWithProps}
      </div>
    );
  }
};

Теперь при создании инструкции мы можем задавать шагам тип. В случае с первым и последним шагом тип им можно было задавать автоматически, но выбранный подход работает в общем случае с любым шагом даже из середины списка. Для каких-то шагов можно не делать пропуск (offset--;), и считать их тоже.

Файл Instruction.jsx

import ReactDOM from 'react-dom';
import React, {Component} from 'react';
import StepItem from './StepList';
import StepItem from './StepItem';
import StepItem from './StepTitle';
import StepItem from './StepDescription';

export default class Instruction extends Component {
  render() {        
    return (
     <StepsList>
       <StepItem precondition>
        <StepTitle>Шаг 1</StepTitle>
        <StepDescription>Описание шага 1</StepDescription>
        </StepItem>
     
        <StepItem>
         <StepTitle>Шаг 2</StepTitle>
         <StepDescription>Описание шага 2</StepDescription>
        </StepItem>

        <StepItem final>
         <StepTitle>Шаг 3</StepTitle>
         <StepDescription>Описание шага 3</StepDescription>
        </StepItem>
      </StepsList>
    );
  }
};

Добавим немного стилей.

Файл style.css

.instruction {
  margin-top: 20px;
}
.step:last-of-type .step-container {
  border-color: transparent;
}
.step .step-number {
  display: inline-block;
  width: 30px;
  height: 30px;
  background: #fff;
  border-radius: 15px;
  line-height: 30px;
  text-align: center;
  float: left;
  color: #333;
  font-size: 13px;
  border: 2px solid #1f9b90;
  box-sizing: border-box;
}
.step .step-container {
  margin-left: 15px;
  padding: 0px 10px 10px 30px;
  border-left: 2px solid #1f9b90;
  max-width: 600px;
}
.step .step-content {
  background: #C6E9E6;
  border-radius: 5px;
  padding: 15px;
  color: #333;
  font-size: 14px;
   position: relative;
}
.step .step-content:before {
  display: inline-block;
  content: "";
  border: 10px solid transparent;
  border-right: 10px solid #C6E9E6;
  position: absolute;
  right: 100%;
  top: 5px;
}
.step .step-title {
  margin: 0px 0px 10px 0px;
}
.instruction .step-precondition,
.instruction .step-final {
  font-size: 14px;
  color: #fff;
  background: #1f9b90;
}

Вид получившегося компонента в действии: