Данная статья будет, прежде всего, полезна разработчикам, которые не работают с готовыми наборами компонентов, такими как, material-ui, а реализуют свои. Например, для продукта разработан дизайн, отражающий то, как должны выглядеть кнопочки, модальные окна и т.п. Чтобы грамотно реализовать такую дизайн-систему, потребуется всем её атомам добавлять хорошую поддержку их композиции. Иными словами, нужно добиться того, чтобы любой отдельно взятый компонент мог интегрироваться и идеально вписываться в больший составной компонент. А если он не вписывается, то было бы неплохо иметь простую поддержку его кастомизации. Как бы то ни было, это отдельная большая тема, и, возможно, я вернусь к ней в другой раз.
Лирика
Всем привет. Начинаю свой путь на хабре с простой, но полезной статьи. Как для меня, получилось слишком подробно, но всё же я пытался подстроиться под читателя, а не под себя. Перед написанием следующей статьи (если она будет) на более сложную тему, имею желание скорректировать своё изложение по фидбэку (если он будет).
Используемые термины:
- Визуальный компонент — компонент, который возвращает элемент, встраивающиеся в DOM. Например,
return (<div />)
. Компонент, который возвращает только другой компонент, визуальным трактовать не следует.
Введение
Когда вы разрабатываете компонент, вы не можете сделать его полностью универсальным. В голове вы всегда отталкиваетесь от конкретных вариантов его использования. Часто получается, что после разработки, ваши коллеги начинают «пихать этот компонент куда попало». Вы на них сердитесь: «Ну я же разрабатывал его не для этого! Он не предназначен для этой задачи!». Конечно, доработки неизбежны, и даже нужны. Но это не должны быть доработки по типу прокинуть новый пропс, чтобы увеличить отступ с 4px до 8px, который будет использоваться в одном-двух случаях из пятидесяти. Компоненты должны иметь настраиваемую внешнюю геометрию.
TypeScript, выручай
Рассмотрим интерфейс, который по смыслу нужно располагать, например, в
src/Library/Controls.ts
. К полям приведены краткие комментарии, ниже мы разберём их подробнее.export interface VisualComponentProps {
// Вложенные компоненты. Определено для компонента-функции
children?: React.ReactNode;
// Как и в css
className?: string;
// Активный флаг означает, что компонент не будет отрисован.
doNotRender?: boolean;
// Это будет отрисовано при doNotRender true
fallback?: JSX.Element;
// Как и в css
style?: React.CSSProperties;
}
Это интерфейс пропсов компонентов. Каких? Всех визуальных компонентов. Они должны применяться на его корневой элемент.
Пропсы каждого разрабатываемого визуального компонента следует расширять, используя этот интерфейс.
Сразу прошу обратить внимание на то, что все эти пропсы опциональны. Рассмотрим их.
children
есть в компонентах-классах React.Component, компонентах-функциях React.FC, но их нет в обычных функциях без задания типизации React.FC. Поэтому задаём его.className/style
используем аналогичные названия, как в обычном JSX'ном <div />. Не плодим семантику. Этот принцип тождественности названия используется, например, в пропсе для задания ссылки ref.doNotRender
используется как альтернатива наболевшему костыльному в JSX рендеру по условию. Применяя это решение, нам не нужно городить фигурные скобки в render методах, ухудшая читаемость кода. Сравните 2 фрагмента кода:
Virgin conditional rendering:
App.tsx
renderComponent() { const {props, state} = this; const needRender = state.something; return ( <PageLayout> <UIButton children={'This is a button'} /> {needRender && <UIButton children={'This is another button'} /> } </PageLayout> ); }
Chad пропс doNotRender:
App.tsx
renderComponent() { const {props, state} = this; const needRender = state.something; return ( <PageLayout> <UIButton children={'This is a button'} /> <UIButton children={'This is another button'} doNotRender={!needRender} /> </PageLayout> ); }
В первом варианте мы увеличиваем уровень вложенности нижней кнопки, хотя по смыслу её вложенность на том же уровне, что и верхняя. Это смотрится плохо в моём редакторе, где я использую tab шириной в 2 пробела, а здесь и того хуже.
Во втором варианте имеем равную вложенность, за минусом, что doNotRender может не броситься в глаза и разработчик не поймёт что происходит. Но, если в вашем проекте каждый визуальный компонент сделан по этому принципу, то эта проблема сразу уходит.
fallback
нужен, если мы не хотим рендеритьnull
приdoNotRender true
, а какой-то кастомный элемент. Используется по аналогии React Suspense, так как имеет схожий смысл (не плодим семантику)
Хочу показать, как это правильно использовать. Сделаем простенькую кнопочку.
Примечание: в коде ниже я также использую css-modules, sass и classnames.
UIButton.tsx
import * as React from 'react';
import { VisualComponentProps } from 'Library/Controls';
import * as css from './Button.sass';
import cn from 'classnames';
// Расширяем (наследуем) пропсы
export interface ButtonBasicProps {
disabled?: boolean;
}
export interface ButtonProps extends ButtonBasicProps, VisualComponentProps {}
export function UIButton(props: ButtonProps) {
// Не возвращаем undefined, иначе реакт будет материться
// "Nothing was returned from render."
if (props.doNotRender) return props.fallback || null;
// Объединяем все классы в строку
const rootClassNames = cn(
// Класс, описанный в sass
css.Button,
// Классы, которые можно прокинуть через props
props.className,
// Обычные классы компонента по условию
props.disabled && css._disabled
);
return (
<div
children={props.children}
className={rootClassNames}
style={props.style}
/>
)
}
App.tsx
renderComponent() {
const {props, state} = this;
const needRenderSecond = true;
return (
<PageLayout>
<UIButton
children={'This is a button'}
style={{marginRight: needRenderSecond ? 5 : null}}
/>
<UIButton
disabled
children={'This is another button'}
doNotRender={!needRenderSecond}
/>
</PageLayout>
);
}
Результат:
Рефлексия и заключение
Такими компонентами удобно оперировать, как div'ами, создавая различные обертки, композиции, специализации, выходя за рамки заложенного в них изначального функционала.
Можно возразить, что раз в дизайн-системе нет условных желтых кнопок, а разработчику нужно их сделать, значит проблема не в компонентах, а в том, что эту надобность создаёт. Тем не менее, реалии таковы, что такие ситуации возникают, и довольно часто. "… А жить-то надо! Надо жить." К тому же, принцип каскада css не всегда можно реализовать на практике, и у вас могут возникать случаи, когда ваши стили просто-напросто перекрываются более высокой специфичностью другого селектора (или описаны выше). Тут как раз выручает style.
Напоследок, добавлю пару (буквально) моментов.
- Учитывайте, что doNotRender не полностью повторяет поведение conditional rendering. У вас также будут выполняться методы жизненного цикла, просто render будет возвращать fallback или null. В некоторых сложных компонентах вы захотите избежать исполнения методов жизненного цикла. Для этого, вам просто нужно сделать built-in специализацию вашего компонента.
На примере UIButton: переименуйте UIButton в UIButtonInner и добавьте под ним следующий код:
UIButton.tsx
export function UIButton(props: ButtonProps) { if (props.doNotRender) return props.fallback || null; return <UIButtonInner {...props} />; }
P.S. Не совершайте ошибку рекурсивного вызова UIButton в данной функции!
- В редких случаях, когда могут независимо меняться стили на обертке и на оборачиваемом компоненте, вам может пригодиться следующий интерфейс
Library/Controls.ts
export interface VisualComponentWrapperProps extends VisualComponentProps { wrappedVisual?: VisualComponentProps; }
И его использованиеUIButton.tsx
interface ButtonSomeWrapperProps extends ButtonBasicProps, VisualComponentWrapperProps { myCustomProp?: number; } export function UIButtonSomeWrapper(props: ButtonSomeWrapperProps) { if (props.doNotRender) return props.fallback || null; const { // Пропсы VisualComponentProps обертки style, className, children, fallback, doNotRender, // VisualComponentProps оборачиваемого компонента wrappedVisual, // Собственные пропсы обертки myCustomProp, // Собственные пропсы оборачиваемого компонента ...uiButtonProps } = props; return ( <div style={style} className={className} > {myCustomProp} <UIButton {...wrappedVisual} {...uiButtonProps} /> {children} </div> ); }
Разработка приложения с использованием этого подхода значительно повысит реюзабельность ваших компонентов, снизит количество лишних костыльных стилей (речь о стилях, описанных в файлах стиля компонента исключительно для нужды других компонентов) и пропсов, добавит коду структурированности. На этом всё. В следующей статье начнем решать проблемы реюзабельности компонентов больше с точки зрения кода, нежели css. Либо напишу о чём-нибудь более интересном. Спасибо за внимание.