Улучшаем качество кода React-приложения с помощью Compound Components

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Я люблю сталкиваться с трудностями. Но с такими, которые можно решить, подумать над интересным решением, подобрать технологию. Люблю быть в потоке, а после решения чувствую себя настоящим профессионалом.

Но есть кое-что, из-за чего я не люблю программировать. Как ни странно, это тоже трудности, только другого рода. Например, когда, чтобы пофиксить баг, приходится разбираться с легаси-компонентом, который написан на классах на 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: подводные камни и альтернативы

  • Как и зачем мы начали искать бизнес-инсайты в отзывах клиентов с помощью машинного обучения

Источник: https://habr.com/ru/company/alfa/blog/691976/


Интересные статьи

Интересные статьи

Представьте, что с одной стороны у вас есть видео на YouTube с интересными моментами из матча по Dota 2. А с другой стороны база данных всех матчей. Как для видео найти соответствующую запись в БД? Э...
Когда нужно обеспечить быстродействие и надежность дисковой системы, а также возможность горячей замены дисков без выключения сервера, большую помощь окажут дисковые контроллеры с кэш памятью и защитн...
К старту курса о Frontend-разработке мы решили поделиться переводом небольшого обзора визуальных атак, позволяющих получать закрытую информацию о пользователе вне зависим...
После написания предыдущей статьи по языку PERM и библиотеке Casbin, возникли вопросы. Причем не у одного человека, и я хотел ответить сначала в комментарии, но понял, что объем материала...
Доброго времени суток, Хабровчане! Хочу рассказать о том, как я недавно узнал о неких "хуках" в React. Появились они относительно недавно, в версии [16.8.0] от 6 февраля 2019 года (что по скор...