React и Typescript: Создание динамического компонента на примере кнопки

—

от автора

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

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


ссылка на оригинал статьи https://habr.com/ru/post/721862/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *