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

Что мы получим в результате?

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

//Самая обычная кнопка
<Button onClick={showAlert}>Hello! It's button!</Button>

//Ссылка выглядящая как кнопка
<Button
  component={'a'}
  target="_blank"
  href="https://www.google.ru/"
>
  It's Google link!
</Button>

//Ссылка из react-router-dom которая выглядит как наша кнопка
//import { Link } from 'react-router-dom'
<Button component={Link} to="/link-route">
  It's router link!
</Button>

Все эти три элемента используют один и тот же компонент, и реализуют состояния disabled и loading которые работают одинаково во всех 3 случаях

Так же typescript нам во всех трех случаях помогает с пропами, т.e. он корректно указывает обязательные пропы и их типы

Что я буду использовать для создания компонента

  1. React

  2. Typescript

  3. SCSS - для удобства описания стилей

  4. clsx - утилита для условного построения строк className

Базовая структура

  1. Создадим папку "BaseButton", а в ней файл BaseButton.tsx. BaseButton компонент будет содержать базовую логику.

  2. Создадим папку "Button", а в ней файл Button.tsx. Button будет уже содержать стили и различные состояния отображения.

  3. Создадим в папке "Button" scss файл Button.module.scss, я буду использовать именно модуль, но если вам удобнее можете просто создать обычный файл стилей.

Реализация BaseButton

Давайте добавим в файл "BaseButton.tsx" код ниже и разберем его по частям:

import {
  Attributes,
  createElement,
  CSSProperties,
  ComponentType,
  ComponentPropsWithRef,
} from 'react'

export type BaseButtonComponent =
  | keyof JSX.IntrinsicElements
  | ComponentType<any>

type BaseProps<C extends BaseButtonComponent = 'button'> = {
  component?: C
  className?: string
  style?: CSSProperties
} & Attributes

export type BaseButtonProps<C extends BaseButtonComponent = 'button'> =
  C extends keyof JSX.IntrinsicElements
    ? Omit<ComponentPropsWithRef<C>, keyof BaseProps<C>> & BaseProps<C>
    : C extends ComponentType<infer P>
    ? P extends ComponentPropsWithRef<any>
      ? Omit<P, keyof BaseProps<C>> & BaseProps<C>
      : never
    : never

export default function BaseButton<C extends BaseButtonComponent = 'button'>({
  component = 'button',
  children,
  ...props
}: BaseButtonProps<C>) {
  return createElement(component, props, children)
}

Типы BaseButton

Тип BaseProps - он дженерик и определяет базовые пропы компонента: component, className, style
Проп component отвечает непосредственно за то, какой элемент мы будем рендерить, он наследуется от типа BaseButtonComponent и по умолчанию будет button это определяет запись <C extends BaseButtonComponent = 'button'>


Тип BaseButtonComponent определяет каким может быть наш компонент:

  • ComponentType (если посмотреть внутрь он будет выглядеть как type ComponentType<P = {}> = ComponentClass<P> | FunctionComponent<P>) т.e. в качестве пропа component мы можем передать класс или функциональный компонент

  • keyof JSX.IntrinsicElements (если мы заглянем в его определения то мы увидим такой код:

    interface IntrinsicElements {
      // HTML
      a: React.DetailedHTMLProps<React.AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement>;
      abbr: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>;
      address: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>;
      area: React.DetailedHTMLProps<React.AreaHTMLAttributes<HTMLAreaElement>, HTMLAreaElement>;
      article: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>;
      ...
      //и остальные валидные теги по порядку
    }

    т.e IntrinsicElements содержит перечисление всех валидных тэгов для реакта.
    C помощью команды keyof мы как раз получаем все ключи валидных тэгов, это выглядит для тайпскрипта как 'a' | 'abbr' | 'address' | 'area' | <и все по порядку>

Т.e. BaseButtonComponent может быть либо классом, либо функциональным компонентом, либо любым валидным тэгом для реакта

А теперь непосредственно разберем тип BaseButtonProps, если попытаться описать его тремя словами то это большое тернарное выражение, давайте рассмотрим его по частям

  1. Он у нас тоже является дженериком: <C extends BaseButtonComponent = 'button'>

  2. Если наш компонент наследуется от "валидных тэгов" то мы берем пропы этого валидного тэга с помощью ComponentPropsWithRef удаляем из него пропы с такими же ключами, которые содержаться в BaseProps (как мы помним это component, classNames, styles) и добавляем наши пропы которые содержаться в BaseProps . Eсли выражаться по другому, то мы просто перезаписываем все пропы компонента с ключами, которые содержаться в BaseProps на наши пропы BaseProps

    C extends keyof JSX.IntrinsicElements
        ? Omit<ComponentPropsWithRef<C>, keyof BaseProps<C>> & BaseProps<C> : <...>
  3. Если наш компонент наследуется от класса или функционального компонента, то мы сначала получаем с помощью оператора infer его пропы и записываем их в переменную P, а потом выполняем ту же самую процедуру перезаписывания с этими пропами

    C extends ComponentType<infer P>
        ? P extends ComponentPropsWithRef<any>
          ? Omit<P, keyof BaseProps<C>> & BaseProps<C>
  4. Если же компонент не удовлетворяет условиям то мы возвращаем never т.e. компонент не сможет содержать никаких пропов

Компонент BaseButton

А теперь перейдем непосредственно к реализации BaseButton

export default function BaseButton<C extends BaseButtonComponent = 'button'>({
  component = 'button',
  children,
  ...props
}: BaseButtonProps<C>) {
  return createElement(component, props, children)
}
  1. Закономерно наш компонент тоже является дженериком

  2. Пропы собираются рест оператором для, оставляем только component и children

  3. Сам же элемент создается с помощью функции реакта createElement, первым аргументом функции является сам компонент, вторым его пропы, третим чилдрены

    например можно написать так: createElement('div', { className: <какой то класс> }, 'текст')

    Так же можно реализовать его немного другим способом, как говориться на вкус и цвет :)

Реализация Button

Давайте добавим в файл Button.tsx код и вкратце посмотрим что он делает:

import clsx from 'clsx'

import BaseButton, {
  BaseButtonProps,
  BaseButtonComponent,
} from '../BaseButton/BaseButton'

import styles from './Button.module.scss'

export type ButtonProps<C extends BaseButtonComponent = 'button'> =
  BaseButtonProps<C> & {
    loading?: boolean
    disabled?: boolean
  }

export function Button<C extends BaseButtonComponent = 'button'>({
  className,
  loading,
  disabled,
  ...props
}: ButtonProps<C>) {
  return (
    <BaseButton<C>
      className={clsx(
        styles.button,
        {
          [styles.loading]: loading,
          [styles.disabled]: disabled,
        },
        className,
      )}
      {...(props as BaseButtonProps<C>)}
    />
  )
}
  1. Он использует наш компонент BaseButton

  2. Содержит дополнительные пропы disabled и loading, т.e. тип ButtonProps расширяет тип BaseButtonProps

  3. Тип BaseButtonProps так же является дженериком, что наследует тип компонента от BaseButtonComponent

  4. Т.к. BaseButton является дженериком, а так же наш компонент Button тоже является дженериком то мы указываем, что тип BaseButton является наследуемым от Button таким способом: <BaseButton<C> иначе компилятор ts не будет понимать, что мы от него хотим

  5. В зависимости от состояния disabled или loading мы добавляем разные стили нашей кнопке

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

Стили кнопки под спойлером
.button {
  outline: none;
  border: none;
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 60px;
  padding: 0 20px;
  font-size: 16px;
  font-weight: 500;
  text-align: center;
  text-decoration: none;
  color: #fff;
  background-color: #2196f3;
  border-radius: 10px;
  cursor: pointer;
  transition: background-color 0.2s ease-in-out;

  &:hover {
    background-color: #1976d2;
    color: #fff;
  }

  &:focus {
    outline: none;
    box-shadow: 0 0 0 3px rgba(33, 150, 243, 0.3);
  }

  &:active {
    background-color: #0d47a1;
    color: #fff;
  }

  &.disabled, &.loading {
    background-color: grey;
    pointer-events: none;

    &:hover {
      background-color: grey;
    }

    &:focus {
      box-shadow:none;
    }
  }

  &.loading {
    &:after {
      content: "";
      position: absolute;
      top: 6px;
      left: 6px;
      width: 6px;
      height: 6px;
      border-radius: 50%;
      border: 2px solid #fff;
      border-top-color: transparent;
      animation: loading 0.8s linear infinite;
    }
  }
}

@keyframes loading {
  to { transform: rotate(360deg); }
}

Примеры использования нашей кнопки

  • Обычная кнопка

    <Button onClick={showAlert}>Hello! It's button!</Button>
  • Ссылка

    <Button
      component={'a'}
      target="_blank"
      href="https://www.google.ru/"
    >
      It's Google link!
    </Button>
  • Роутер Link

    <Button component={Link} to="/link-route">
      It's router link!
    </Button>

Cсылки на codesandbox и github

codesandbox - кликайте по кнопкам, юзайте инструменты разработчика, чтобы исследовать элементы кнопок
github

Послесловие

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

type DistributiveOmit<T, K extends keyof any> = T extends any
  ? Omit<T, K>
  : never

Если статья вам оказалась полезна или вы хотите что то добавить -- вэлкам ту коментариес

Комментарии (6)


  1. divinity2000
    00.00.0000 00:00

    Было бы интересней если в качестве компонента можно было передать элемент со своими пропсами и уже в него инжектить события (этакий HOC)
    т.е:

    <Button component={<OtherComponent componentProp />} {...props}>
      Content
    </Button>

    Наверно для кнопки такой функционал избыточен, но все же имеет место быть


    1. progammer
      00.00.0000 00:00

      В официальной документации реакта это описано.


      1. divinity2000
        00.00.0000 00:00

        Ну, createElement и нативный есть, так что если убрать отсюда TS, то статья теряет смысл, так как это все тоже описано в доке)


    1. Alexandroppolus
      00.00.0000 00:00

      У этого подхода есть досадный минус: не работает проверка типов. Любое выражение вида < Comp ... /> возвращает тип JSX.Element, и Тайпскрипту непонятно, какие там пропсы, т.е. что туда можно и что нельзя.


      1. Fines_Unes Автор
        00.00.0000 00:00

        Немного не понял проблему, где именно не работает проверка типов? Можете накидать пример?


        1. Alexandroppolus
          00.00.0000 00:00
          +1

          type Props1 = {p: number};
          const Comp1: FC<Props1> = (props) => {...};
          
          type Props2 = {p: string};
          const Comp2: FC<Props2> = (props) => {...};
                                                
          type Props3 = {
            element: ReactElement<Props1>;
          }
          const Comp3: FC<Props3> = ({element}) => {
              // .....
              return (
                <> 
                  ... 
                  {cloneElement(element, {p: 123})}
                  ...
                </>
              );
          };
          // где-то в tsx
          ...
          <Comp3 element={<Comp2 p={'aa'}>}

          В Comp3 ожидается элемент с пропсами Props1, но невозбранно заезжает Comp2, и TS помалкивает, потому что у переданного значения тип JSX.Element, который по сути ReactElement< any>