Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Я люблю сталкиваться с трудностями. Но с такими, которые можно решить, подумать над интересным решением, подобрать технологию. Люблю быть в потоке, а после решения чувствую себя настоящим профессионалом.
Но есть кое-что, из-за чего я не люблю программировать. Как ни странно, это тоже трудности, только другого рода. Например, когда, чтобы пофиксить баг, приходится разбираться с легаси-компонентом, который написан на классах на 300 строк кода. Разбираясь уже второй час, ловлю себя на мысли, что уже 10 минут просто смотрю в экран, а в голове «из-за угла» выглядывает мысль «Псс, парень, программирование — это не твое». Такие задачи не вызывают удовлетворения.
Если у вас есть компоненты с кучей условий, которые сложно читать, ревьюить и понимать, что там происходит, то эта статья для вас. Здесь я поделюсь подходом, который поможет уменьшить большие и страшные React-компоненты.
Примечание. Весь код, приведенный ниже, условный. В нём нет useEffect’ов, обработчиков, и прочего.
История жизни одной формы авторизации
Всё начиналось как обычно: стандартная форма авторизации, заголовок, два инпута с логином и паролем, и кнопка submit.
import React from ‘react’;
import { Form, Input, Button, Title } from ‘our-design-system’;
function AuthForm() {
return (
<div>
<Title>Войти в интернет-банк</Title>
<Form>
<Input placeholder=”Введите логин” type=”text”/>
<Input placeholder=”Введите пароль” type=”password”/>
<Button type=”submit”>Войти</Button>
</Form>
</div>
);
}
export default AuthForm;
Новые условия. Внезапно мы узнаем, что вообще-то нужно ещё авторизоваться по номеру карты или счёта. Запрос идёт в тоже место, заголовок тот же, кнопочка та же, но только вот нужно добавить «всего» 2 поля. Недолго думая, делаем что-то подобное.
function AuthForm({ authType, theme }) {
const [accountType, setAccountType] = useState(‘account’);
const changeAccountType = () => {setAccountType(‘card’)};
return (
<div>
<Title theme={ theme }>Войти в интернет-банк</Title>
<Form theme={ theme }>
{ authType === “login”
? (<div class=”login-form”>
<Input theme={ theme } placeholder=”Введите логин” type=”text”/>
<Input theme={ theme } placeholder=”Введите пароль” type=”password”/>
</div>)
: (<div class=”card-form”>
{
accountType === ‘card’
? <Input theme={ theme } placeholder=”Введите номер карты” type=”number”/>
: <Input theme={ theme } placeholder=”Введите номер счета” type=”number”/>
}
</div>
}
{ authType === “account” &&
<Button
theme={ theme }
type=”button”
onClick={changeAccountType}
>Войти по { accountTypes [accountType] }</Button> }
<Button theme={ theme } type=”submit”>Войти</Button>
</Form>
</div>
);
}
Да, прямо в форму добавляем новый пропс (authType
), который определяет тип аутентификации по логину-паролю или номеру карты/счёта. Внутри рендера делаем тернарник. Мы выбираем: будем рендерить поле логина-пароля или номера карты/счёта.
Внизу ещё есть кнопка, которая как раз переключает эти инпуты (она не нужна, если входим по логину).
Итого у нас появилось 2 новых условия в нашем компоненте.
Ещё новые условия. Дальше оказывается, что наша форма должна отображаться в мобильном приложении — пользователи приложения должны аутентифицироваться через нашу форму. В этом нет ничего особенного — просто не должен отображаться заголовок.
Сказано-сделано — добавляем еще один пропс isWebview
, в котором мы проверяем: отображаем форму через вебвью или нет.
function AuthForm({ authType, theme, isWebview, }) {
const [accountType, setAccountType] = useState(‘account’);
const changeAccountType = () => {setAccountType(‘card’)};
return (
<div>
{ !isWebview && <Title theme={ theme }>Войти в интернет-банк</Title>
<Form theme={ theme }>
{
{ authType === “login”
? (<div class=”login-form”>
<Input theme={ theme } placeholder=”Введите логин” type=”text”/>
<Input theme={ theme } placeholder=”Введите пароль” type=”password”/>
</div>)
: (<div class=”card-form”>
{
accountType === ‘card’
? <Input theme={ theme } placeholder=”Введите номер карты” type=”number”/>
: <Input theme={ theme } placeholder=”Введите номер счета” type=”number”/>
}
</div>
}
{ authType === “account” &&
<Button
theme={ theme }
type=”button”
onClick={changeAccountType}
>Войти по { accountTypes [accountType] }</Button> }
<Button theme={ theme } type=”submit”>Войти</Button>
</Form>
</div>
);
}
Также добавляем условие «Не показывать заголовок, если мы в мобильном приложении».
Редизайн. Проходит время и «случается» редизайн мобильного приложения. Естественно, нам тоже нужно обновляться. Это довольно простая доработка — меняем поля ввода карты или счета на одно поле. Соответственно, мы убираем кнопку, которая меняет эти поля местами при нажатии.
Замечательно, меньше полей — меньше проблем, меньше работы, верно?
Почти. Нюанс в том, что пользователи мобильных приложений не побегут дружно обновлять мобильное приложение: кто-то сидит через старую версию, кто-то через новую. Мы-то отображаем через вебвью — у нас всего одна версия, нам приходится поддерживать два разных варианта этой формы.
Что мы делаем? Правильно — добавляем еще один пропс на проверку дизайна (isNewDesignWebview
), и ещё один вложенный тернарник.
function AuthForm({ authType, theme, isWebview, isNewDesignWebview }) {
const [accountType, setAccountType] = useState(‘account’);
const changeAccountType = () => {setAccountType(‘card’)};
return (
<div>
{ !isWebview && <Title theme={ theme }>Войти в интернет-банк</Title>
<Form theme={ theme }>
{
isNewDesignWebview
? <CardInput theme={ theme } placeholder=‘’Введите номер карты или счета’’/>
: authType === ‘’login’’
? (<div class=”login-form”>
<Input theme={ theme } placeholder=”Введите логин” type=”text”/>
<Input theme={ theme } placeholder=”Введите пароль” type=”password”/>
</div>)
: (<div class=”card-form”>
{
accountType === ‘card’
? <Input theme={ theme } placeholder=”Введите номер карты” type=”number”/>
: <Input theme={ theme } placeholder=”Введите номер счета” type=”number”/>
}
</div>
}
{
authType === “account” && !isNewDesignWebview &&
<Button
theme={ theme }
type=”button”
onClick={changeAccountType}
>Войти по { accountTypes [accountType] }</Button>
}
<Button theme={ theme } type=”submit”>Войти</Button>
</Form>
</div>
);
}
Естественно, внизу ещё одно условие, что для нового дизайна кнопка нам не нужна.
Итого. У нас есть форма: без логики, просто рендер, 3 условных пропса, по которым мы определяем, что конкретно будем рендерить, в тех или иных случаях, и 7 (новых) условий.
Кажется, что всё очень-очень плохо. Мы кричим в монитор, что не хотим это всё поддерживать, и идём в интернет, чтобы найти решение проблемы.
Но никуда идти не надо, у меня для вас уже есть одно решение.
Compound components
Небольшая вводная. Наверняка вы знаете, как выглядят селекты (<select>
) в HTML.
<select name=”Office”>
<option value=”Dwight”>Schrute</option>
<option value=”Micheal”>Scott</option>
<option value=”Jim”>Halpert</option>
<option value=”Pam”>Beesly</option>
</select>
Это какая-то сущность, которая наполняется опшенами (<options>
). При этом опшены не могут существовать вне селекта. По отдельности селекты и опшены бесполезны, а вместе работают как составные компоненты, создавая единую логику.
Compound components использует подобную систему: нельзя использовать элементы Compound components вне основного большого компонента. В этом подходе мы объединяем несколько компонентов общей сущностью и общим состоянием. Отдельно от этой сущности их использовать нельзя — они единое целое.
Немного забегая вперёд, покажу как выглядит наша форма аутентификации, если мы применим к ней этот подход.
export default function LoginAuth( ) {
return (
<AuthForm theme={ ‘dark’}>
<AuthForm.AuthTitle/>
<AuthForm.LoginInput/>
<AuthForm.PasswordInput/>
<AuthForm.SubmitButton/>
</AuthForm}>
)
}
У нас есть форма с элементами. Вне формы элементы не могут существовать. В самой форме зашита логика, которая передается каждому элементу.
Примечание. Может смутить то, что мы вызываем наши элементы через точку, но это такой синтаксис.
Подход Compound components похож на методологию BEM.
У нас есть блок — форма аутентификации;
есть элементы — заголовок, инпуты;
а модификаторы — это пропсы;
у самих элементов тоже могут быть какие-то пропсы как модификаторы.
Интересно то, что мы можем использовать элементы в разных ситуациях.
Переписываем форму с помощью Compound components
Давайте перепишем наш компонент и на его примере покажу как Compound components работает.
import {
CardAccount, AuthCardInput, LoginInput, PasswordInput, SubmitButton, AuthTittle
} from ‘./components’;
const AuthFormContext = React,createContext(undefined);
function AuthForm(props) {
const { theme } = props;
const memoizedContextValue = React.useMemo{
( ) => ({ theme }),
[theme],
);
return (
<AuthFormContext.Provider value={ memoizedContextValue }>
<Form onSubmit={ submitForm } >
{ props.children }
</Form>
</AuthFormContext.Provider>
);
}
export function useAuthContext( ) {
const context = React.useContext(AuthFormContext);
if ( !context) {
throw new Error(‘This component must be used within a <AuthForm> component.’);
}
return context;
}
AuthForm.AuthTitle = AuthTitle;
AuthForm.LoginInput = LoginInput;
AuthForm.PasswordInput = PasswordInput;
AuthForm.CardAccount = CardAccount;
AuthForm.AuthCardInput = AuthCardInput;
AuthForm.SubmitButton = SubmitButton;
Разберем по частям.
Для общей логики мы используем контекст, и можем передавать актуальные данные в форму на любой уровень вложенности. Мы создаем контекст, но его не экспортим.
const AuthFormContext = React,createContext(undefined);
Здесь мы создаем наши данные для всех элементов и мемоизируем их. Естественно, сами элементы тоже нужно обернуть будет в мемо, чтобы мемоизация работала.
function AuthForm(props) {
const { theme } = props;
const memoizedContextValue = React.useMemo{
( ) => ({ theme }),
[theme],
);
Пробрасываем контекст в нашу форму.
return (
<AuthFormContext.Provider value={ memoizedContextValue }>
<Form onSubmit={ submitForm } >
{ props.children }
</Form>
</AuthFormContext.Provider>
);
Здесь защита от дурака.
if ( !context) {
throw new Error(‘This component must be used within a <AuthForm> component.’);
}
С помощью неё мы не сможем использовать наши элементы вне формы (да и не надо). В каждом элементе зашита какая-то бизнес-логика, которую мы не хотим выдирать из этого компонента.
Если мы хотим переиспользовать отдельно внутренние элементы нашего сложного компонента (поля, кнопки и т.п.), то Compound Components нам не подходит.
В этой же «защите» создаём кастомный хук, в котором вызываем наш контекст и проверяем его наличие. Если контекста нет — выбрасываем ошибку. Это значит, что кто-то попытался использовать элемент вне формы.
Последнее — записываем в статические свойства все наши элементы.
AuthForm.AuthTitle = AuthTitle;
AuthForm.LoginInput = LoginInput;
AuthForm.PasswordInput = PasswordInput;
AuthForm.CardAccount = CardAccount;
AuthForm.AuthCardInput = AuthCardInput;
AuthForm.SubmitButton = SubmitButton;
Как это используется?
Вот наша форма авторизации по логину и паролю с заголовком.
<AuthForm theme={ ‘dark’ }>
<AuthForm.AuthTitle/>
<AuthForm.LoginInput/>
<AuthForm.PasswordInput/>
<AuthForm.SubmitButton/>
</AuthForm}>
Вот форма авторизации уже по карте или счету.
<AuthForm theme={ ‘dark’ }>
<AuthForm.AuthTitle/>
<AuthForm.CardAccount/>
<AuthForm.SubmitButton/>
</AuthForm}>
Это форма для пользователей мобильных приложений со старым дизайном.
<AuthForm theme={ ‘dark’ }>
<AuthForm.AuthCardInput/>
<AuthForm.SubmitButton/>
</AuthForm}>
А это форма для пользователей с новым дизайном.
<AuthForm theme={ ‘dark’ }>
<AuthForm.CardAccount/>
<AuthForm.SubmitButton/>
</AuthForm}>
Мы вынесли логику условий из компонента (рендера) на уровень выше, туда, где мы используем этот компонент. Мне кажется такой вариант нагляднее: нам не нужно лезть в компонент, чтобы понять какие нам нужно пропсы прокинуть в компонент, чтобы отобразился заголовок и т.д. Мы всё выбираем сами.
Сравним рендеры. Оценим масштаб: как рендер выглядел раньше с множеством разных условий, и как он выглядит сейчас.
Как используется обычный компонент:
<AuthForm
theme="dark"
authType="account"
isWebview={true}
isNewDesignWebview={true}
/>
Его рендер:
return (
<div>
{ !isWebview && <Title theme={ theme }>Войти в интернет-банк</Title> }
<Form theme={ theme }>
{
isNewDesignWebview
? <CardInput theme={ theme } placeholder="Введите номер карты или счета"/>
: authType === "login"
? (<div class="login-form">
<Input theme={ theme } placeholder="Введите логин" type="text"/>
<Input theme={ theme } placeholder="Введите пароль" type="password"/>
</div>)
: (<div class="card-form">
{
accountType === 'card'
? <Input theme={ theme } placeholder="Введите номер карты" type="number"/>
: <Input theme={ theme } placeholder="Введите номер счета" type="number"/>
}
</div>)
}
{
authType === "account" && !isNewDesignWebview &&
<Button
theme={ theme }
type="button"
onClick={changeAccountType}
>Войти по { accountTypes[accountType] }</Button>
}
<Button theme={ theme } type="submit">Войти</Button>
</Form>
</div>
);
Теперь как используется Compound Component:
<AuthForm theme={'dark'}>
<AuthForm.AuthCardInput/>
<AuthForm.SubmitButton/>
</AuthForm>
И его рендер:
return (
<AuthFormContext.Provider value={ memoizedContextValue }>
<Form onSubmit={ submitForm } >
{ props.children }
</Form>
</AuthFormContext.Provider>
);
А это вынесенные элементы (здесь только те элементы, что используются в примере, но в других примерно тоже самое, потому что в нашей форме нет никакой бизнес логики):
export default function AuthCardInput() {
const { theme } = useAuthContext();
return (
<CardInput theme={ theme } placeholder="Введите номер карты или счета"/>
)
}
export default function SubmitButton() {
const { theme, submitForm } = useAuthContext();
return (
<Button theme={ theme } type="submit" onClick={ submitForm }>
Войти
</Button>
)
}
Когда стоит использовать Compound components?
Когда вы хотите объединить несколько компонентов (элементов) в одну сущность. Это могут быть не простые селекты с опшенами, а, например, компонент с табуляцией.
Когда мы видим, что рендер становится перегружен из-за множества пропсов. Это как раз ситуация, как у нас с формой, в которой много пропсов. При этом в самом рендере много условий отображения компонента.
Статья подготовлена на основе выступления на online-конференции HolyJS. Запись выступления доступна в группе Alfa Digital в ВК, там также есть записи докладов с других конференций и митапов. Также подписывайтесь на Телеграм-канал Alfa Digital Jobs — там мы постим новости, опросы, видео с митапов, иногда шутим.
Рекомендуем почитать.
Неподатливые soft-skills: почему нам всё ещё нужен эмоциональный интеллект
Data Science Meet Up #2: LTV, Uplift, совершенство и Reject/Inference
Как снимать логи с устройств на Android и iOS: разбираемся с инструментами
Как мы переходили на React-router v6: подводные камни и альтернативы
Как и зачем мы начали искать бизнес-инсайты в отзывах клиентов с помощью машинного обучения