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. он корректно указывает обязательные пропы и их типы
Что я буду использовать для создания компонента
React
Typescript
SCSS - для удобства описания стилей
clsx - утилита для условного построения строк className
Базовая структура
Создадим папку "BaseButton", а в ней файл
BaseButton.tsx
.BaseButton
компонент будет содержать базовую логику.Создадим папку "Button", а в ней файл
Button.tsx
.Button
будет уже содержать стили и различные состояния отображения.Создадим в папке "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
, если попытаться описать его тремя словами то это большое тернарное выражение, давайте рассмотрим его по частям
Он у нас тоже является дженериком:
<C extends BaseButtonComponent = 'button'>
-
Если наш компонент наследуется от "валидных тэгов" то мы берем пропы этого валидного тэга с помощью
ComponentPropsWithRef
удаляем из него пропы с такими же ключами, которые содержаться в BaseProps (как мы помним этоcomponent
,classNames
,styles
) и добавляем наши пропы которые содержаться вBaseProps
. Eсли выражаться по другому, то мы просто перезаписываем все пропы компонента с ключами, которые содержаться вBaseProps
на наши пропыBaseProps
C extends keyof JSX.IntrinsicElements ? Omit<ComponentPropsWithRef<C>, keyof BaseProps<C>> & BaseProps<C> : <...>
-
Если наш компонент наследуется от класса или функционального компонента, то мы сначала получаем с помощью оператора
infer
его пропы и записываем их в переменнуюP
, а потом выполняем ту же самую процедуру перезаписывания с этими пропамиC extends ComponentType<infer P> ? P extends ComponentPropsWithRef<any> ? Omit<P, keyof BaseProps<C>> & BaseProps<C>
Если же компонент не удовлетворяет условиям то мы возвращаем
never
т.e. компонент не сможет содержать никаких пропов
Компонент BaseButton
А теперь перейдем непосредственно к реализации BaseButton
export default function BaseButton<C extends BaseButtonComponent = 'button'>({
component = 'button',
children,
...props
}: BaseButtonProps<C>) {
return createElement(component, props, children)
}
Закономерно наш компонент тоже является дженериком
Пропы собираются рест оператором для, оставляем только component и children
Сам же элемент создается с помощью функции реакта 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>)}
/>
)
}
Он использует наш компонент
BaseButton
Содержит дополнительные пропы
disabled
иloading
, т.e. типButtonProps
расширяет типBaseButtonProps
Тип
BaseButtonProps
так же является дженериком, что наследует тип компонента отBaseButtonComponent
Т.к.
BaseButton
является дженериком, а так же наш компонентButton
тоже является дженериком то мы указываем, что тип BaseButton является наследуемым отButton
таким способом:<BaseButton<C>
иначе компилятор ts не будет понимать, что мы от него хотимВ зависимости от состояния
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
Если статья вам оказалась полезна или вы хотите что то добавить -- вэлкам ту коментариес
divinity2000
Было бы интересней если в качестве компонента можно было передать элемент со своими пропсами и уже в него инжектить события (этакий HOC)
т.е:
Наверно для кнопки такой функционал избыточен, но все же имеет место быть
progammer
В официальной документации реакта это описано.
divinity2000
Ну, createElement и нативный есть, так что если убрать отсюда TS, то статья теряет смысл, так как это все тоже описано в доке)
Alexandroppolus
У этого подхода есть досадный минус: не работает проверка типов. Любое выражение вида < Comp ... /> возвращает тип JSX.Element, и Тайпскрипту непонятно, какие там пропсы, т.е. что туда можно и что нельзя.
Fines_Unes Автор
Немного не понял проблему, где именно не работает проверка типов? Можете накидать пример?
Alexandroppolus
В Comp3 ожидается элемент с пропсами Props1, но невозбранно заезжает Comp2, и TS помалкивает, потому что у переданного значения тип JSX.Element, который по сути ReactElement< any>