company_banner

Простые советы по написанию чистого кода React-компонентов

Автор оригинала: Iskander Samatov
  • Перевод
  • Tutorial
Автор материала, перевод которого мы публикуем сегодня, делится советами, которые помогают делать чище код React-компонентов и создавать проекты, которые масштабируются лучше, чем прежде.



Избегайте использования оператора spread при передаче свойств


Начнём с анти-паттерна, с приёма работы, которым лучше не пользоваться в тех случаях, когда для этого нет конкретных, обоснованных причин. Речь идёт о том, что следует избегать использования оператора spread ({...props}) при передаче свойств от родительских компонентов дочерним.

Передача свойств таким образом может, на самом деле, ускорить разработку. Но этот приём значительно усложняет обнаружение ошибок в коде. Разработчик начинает меньше доверять своим компонентам, это усложняет их рефакторинг, и, как результат, ошибки появляются в них раньше, чем в случаях, когда оператор spread для передачи свойств не используется.

Объявляйте функции, использующие несколько параметров, применяя объекты с параметрами


Если функция принимает несколько параметров — хорошо будет поместить их все в объект. Вот как это может выглядеть:

export const sampleFunction = ({ param1, param2, param3 }) => {
    console.log({ param1, param2, param3 });
}

Если подходить к созданию сигнатур своих функций именно так — можно получить несколько заметных преимуществ перед обычным способом объявления функций:

  1. Вам больше не придётся беспокоиться о порядке, в котором функции передаются аргументы. Я несколько раз сталкивался с проблемой, когда передача аргументов функции в неправильном порядке приводила к возникновению ошибки.
  2. При работе с редакторами, в которых используется IntelliSense (в наши дни это — практически все редакторы), в нашем распоряжении окажется приятная возможность автодополнения при вводе аргументов функции.

При работе с обработчиками событий используйте функции, возвращающие функции-обработчики


Если вы знакомы с функциональным программированием — то знайте, что то, о чём я хочу сейчас рассказать, напоминает каррирование. Суть в том, что тут применяется заблаговременная установка некоторых параметров функции.

Взгляните на этот пример:

export default function SampleComponent({ onValueChange }) {
    const handleChange = (key) => {
        return (e) => onValueChange(key, e.target.value)
    }

    return (
        <form>
            <input onChange={handleChange('name')} />
            <input onChange={handleChange('email')} />
            <input onChange={handleChange('phone')} />
        </form>
    )
}

Как видите, такое оформление функции-обработчика событий помогает поддерживать дерево компонентов в чистоте.

Используйте объекты, хранящие пары ключ-значение вместо условного оператора


Если нужно выводить различные элементы, основываясь на некоей логике, я посоветовал бы пользоваться объектами, хранящими данные в формате ключ-значение (коллекциями записей), а не выражениями if/else.

Вот пример, в котором используется if/else:

const Student = ({ name }) => <p>Student name: {name}</p>
const Teacher = ({ name }) => <p>Teacher name: {name}</p>
const Guardian = ({ name }) => <p>Guardian name: {name}</p>

export default function SampleComponent({ user }) {
    let Component = Student;
    if (user.type === 'teacher') {
        Component = Teacher
    } else if (user.type === 'guardian') {
        Component = Guardian
    }

    return (
        <div>
            <Component name={user.name} />
        </div>
    )
}

А вот — пример использования объекта, хранящего соответствующие значения:

import React from 'react'

const Student = ({ name }) => <p>Student name: {name}</p>
const Teacher = ({ name }) => <p>Teacher name: {name}</p>
const Guardian = ({ name }) => <p>Guardian name: {name}</p>

const COMPONENT_MAP = {
    student: Student,
    teacher: Teacher,
    guardian: Guardian
}

export default function SampleComponent({ user }) {
    const Component = COMPONENT_MAP[user.type]

    return (
        <div>
            <Component name={user.name} />
        </div>
    )
}

Использование этой простой стратегии делает компоненты более декларативными и упрощает понимание их кода. Это, кроме того, облегчает расширение функционала программы через добавление новых элементов в коллекцию записей.

Оформляйте в виде хуков компоненты, рассчитанные на многократное использование в проекте 


Я считаю весьма полезным паттерн, о котором хочу рассказать в этом разделе.

Возможно, вы сталкивались или столкнётесь с ситуацией, когда окажется, что какие-то компоненты постоянно используются в разных частях приложения. Если им для работы нужно хранить что-то в состоянии приложения, это значит, что их можно обернуть в хуки, предоставляющие им это состояние. Удачными примерами подобных компонентов можно назвать всплывающие окна, тост-уведомления, простые модальные окна. Например — вот компонент, в котором, с использованием хука, реализовано простое модальное окно для подтверждения выполнения некоей операции:

import ConfirmationDialog from 'components/global/ConfirmationDialog';

export default function useConfirmationDialog({
    headerText,
    bodyText,
    confirmationButtonText,
    onConfirmClick,
}) {

    const [isOpen, setIsOpen] = useState(false);

    const onOpen = () => {
        setIsOpen(true);
    };

 

    const Dialog = useCallback(
        () => (
            <ConfirmationDialog
                headerText={headerText}
                bodyText={bodyText}
                isOpen={isOpen}
                onConfirmClick={onConfirmClick}
                onCancelClick={() => setIsOpen(false)}
                confirmationButtonText={confirmationButtonText}
            />
        ),
        [isOpen]
    );

    return {
        Dialog,
        onOpen,
    };

}

Пользоваться этим хуком можно так:

import React from "react";
import { useConfirmationDialog } from './useConfirmationDialog'

function Client() {
  const { Dialog, onOpen } = useConfirmationDialog({
    headerText: "Delete this record?",
    bodyText:
      "Are you sure you want to delete this record? This cannot be undone.",
    confirmationButtonText: "Delete",
    onConfirmClick: handleDeleteConfirm,
  });

  function handleDeleteConfirm() {
    //TODO: удалить
  }

  const handleDeleteClick = () => {
    onOpen();
  };

  return (
    <div>
      <Dialog />
      <button onClick={handleDeleteClick} />
    </div>
  );
}

export default Client;

Такой подход к абстрагированию компонентов избавляет программиста от необходимости написания больших объёмов шаблонного кода для управления состоянием приложения. Если вы хотите узнать о нескольких полезных хуках React — взгляните на этот мой материал.

Разделяйте код компонентов на части


Следующие три совета посвящены грамотному разделению компонентов на части. Опыт подсказывает мне, что лучший способ поддержания проекта в таком состоянии, чтобы проектом было бы удобно управлять, заключается в том, чтобы стремиться делать компоненты как можно компактнее.

▍Использование обёрток


Если вам сложно найти способ разбиения большого компонента на части — взгляните на функционал, предоставляемый каждым из его элементов. Некоторые элементы, например, существуют ради решения узкоспециализированных задач, например, для поддержки механизма drag-and-drop.

Вот пример компонента, реализующего drag-and-drop с использованием react-beautiful-dnd:

import React from 'react'
import { DragDropContext, Droppable } from 'react-beautiful-dnd';

 
export default function DraggableSample() {
    function handleDragStart(result) {
        console.log({ result });
    }

    function handleDragUpdate({ destination }) 
        console.log({ destination });
    }

    const handleDragEnd = ({ source, destination }) => {
        console.log({ source, destination });
    };

    return (
        <div>
            <DragDropContext
                onDragEnd={handleDragEnd}
                onDragStart={handleDragStart}
                onDragUpdate={handleDragUpdate}
            >
                <Droppable droppableId="droppable" direction="horizontal">
                    {(provided) => (
                        <div {...provided.droppableProps} ref={provided.innerRef}>
                            {columns.map((column, index) => {
                                return (
                                    <ColumnComponent
                                        key={index}
                                        column={column}
                                    />
                                );
                            })}
                        </div>
                    )}
                </Droppable>
            </DragDropContext>
        </div>
    )
}

А теперь взгляните на тот же компонент после того, как мы переместили drag-and-drop-логику в компонент-обёртку:

import React from 'react'

export default function DraggableSample() {
    return (
        <div>
            <DragWrapper>
                {columns.map((column, index) => {
                    return (
                        <ColumnComponent
                            key={index}
                            column={column}
                        />
                    );
                })}
            </DragWrapper>
        </div>
    )
}

Вот — код обёртки:

import React from 'react'
import { DragDropContext, Droppable } from 'react-beautiful-dnd';

export default function DragWrapper({children}) {
    function handleDragStart(result) {
        console.log({ result });
    }

    function handleDragUpdate({ destination }) {
        console.log({ destination });
    }

    const handleDragEnd = ({ source, destination }) => {
        console.log({ source, destination });
    };

    return (
        <DragDropContext
            onDragEnd={handleDragEnd}
            onDragStart={handleDragStart}
            onDragUpdate={handleDragUpdate}
        >
            <Droppable droppableId="droppable" direction="horizontal">
                {(provided) => (
                    <div {...provided.droppableProps} ref={provided.innerRef}>
                        {children}
                    </div>
                )}
            </Droppable>
        </DragDropContext>
    )
}

В результате код компонента можно очень быстро прочесть и в общих чертах разобраться с тем, какую именно задачу решает этот компонент. А весь функционал, имеющий отношение к механизму drag-and-drop, размещён в компоненте-обёртке. С кодом, реализующим этот механизм, теперь тоже гораздо проще и удобнее работать.

▍Разделение обязанностей


Тут я хочу рассказать о моём любимом методе разделения больших компонентов на части.

В контексте React «разделение обязанностей» означает отделение тех частей компонента, которые ответственны за загрузку и преобразование данных, от тех частей, которые отвечают исключительно за отображения дерева элементов.

Паттерн «хук» появился именно благодаря идее разделения обязанностей. Можно и даже нужно оборачивать в хуки всю логику, ответственную за взаимодействие с некими API или с глобальным состоянием.

Например — давайте посмотрим на следующий компонент:

import React from 'react'
import { someAPICall } from './API'
import ItemDisplay from './ItemDisplay'

export default function SampleComponent() {
    const [data, setData] = useState([])

    useEffect(() => {
        someAPICall().then((result) => {
            setData(result)
        })
    }, [])

    function handleDelete() {
        console.log('Delete!');
    }

    function handleAdd() {
        console.log('Add!');
    }

    const handleEdit = () => {
        console.log('Edit!');
    };

    return (
        <div>
            <div>
                {data.map(item => <ItemDisplay item={item} />)}
            </div>
            <div>
                <button onClick={handleDelete} />
                <button onClick={handleAdd} />
                <button onClick={handleEdit} />
            </div>
        </div>
    )
}

Ниже показано то, что получилось после его рефакторинга, в ходе которого его код разделён на части с применением пользовательских хуков. А именно — вот сам компонент:

import React from 'react'
import ItemDisplay from './ItemDisplay'

export default function SampleComponent() {
    const { data, handleDelete, handleEdit, handleAdd } = useCustomHook()

    return (
        <div>
            <div>
                {data.map(item => <ItemDisplay item={item} />)}
            </div>
            <div>
                <button onClick={handleDelete} />
                <button onClick={handleAdd} />
                <button onClick={handleEdit} />
            </div>
        </div>
    )
}

Вот — код хука:

import { someAPICall } from './API'

export const useCustomHook = () => {
    const [data, setData] = useState([])

    useEffect(() => {
        someAPICall().then((result) => {
            setData(result)
        })
    }, [])

    function handleDelete() {
        console.log('Delete!');
    }

    function handleAdd() {
        console.log('Add!');
    }

    const handleEdit = () => {
        console.log('Edit!');
    };

    return { handleEdit, handleAdd, handleDelete, data }
}

▍Хранение кода каждого компонента в отдельном файле


Часто программисты пишут код компонентов примерно так:

import React from 'react'

export default function SampleComponent({ data }) {

    export const ItemDisplay = ({ name, date }) => (
        <div>
            <h3><font color="#3AC1EF">▍{name}</font></h3>
            <p>{date}</p>
        </div>
    )

    return (
        <div>
            <div>
                {data.map(item => <ItemDisplay item={item} />)}
            </div>
        </div>
    )
}

И, хотя тут нет ничего ужасно неправильного, нельзя рекомендовать писать код именно так. У перемещения ItemDisplay в отдельный файл нет никаких минусов. При этом плюсом такого подхода можно назвать тот факт, что, после переноса кода компонента в отдельный файл, компоненты проекта оказываются слабее связаны друг с другом. Их, кроме того, легче будет расширять.

Итоги


Написание чистого кода, в основном, сводится к тому, чтобы внимательно и вдумчиво относиться к своему делу и находить время на изучение удачных и неудачных паттернов. Хорошие решения стоит включать в свои проекты, а анти-паттерны лучше обходить стороной. Собственно говоря, применение удачных паттернов помогает писать более чистый код React-компонентов. Паттерны, которыми я тут поделился, очень помогают мне в работе. Надеюсь, они пригодятся и вам.

Что вы посоветовали бы тем, кто хочет сделать код своих React-компонентов чище?

RUVDS.com
VDS/VPS-хостинг. Скидка 10% по коду HABR

Комментарии 20

    +3
    неплохие советы за исключением второго. Если пользоваться Typescript или Flow и прописывать JSDoc в особо важных случаях, то никаких проблем с порядком переменных у внимательного разработчика быть не должно. Явная передача параметров делает вызов функции понятнее и чище. К тому же это даёт возможность «каррировать» функцию. А вот подход, описаный тут, может привести к багам в гораздо большем количестве случаев.

    Остальные советы вполне полезные, особенно про использование хуков. Хотя про оборачивание часто используемых компонентов в хуки — мне кажется немного бесполезным. Зачем хук если сам компонент может следить за своим состоянием? Зачем плодить сущности и усложнять, если можно просто использовать компонент обёртку с состоянием?
      0

      Второй совет полезен в случае, когда у функции много параметров. Например, обертка над апи-запросом, в котором вдобавок еще часть параметров необязательная.

        0
        Ну в отдельных случаях, конечно, имеет смысл так делать. И, в общем-то, если посмотреть на интерфейсы многих библиотек, то можно увидеть в каких. Но я бы не стал этот приём как бест практис рекомендовать и все параметры на объект заменять, как предлагается в этой статье.
      +1

      В совете "используйте объекты вместо условных операторов"
      const Component = COMPONENT_MAP[user.type]
      разве не придётся обвешивать это проверками на наличие этого user.type, дефолтным значением? Тогда как switch или if/else просто по привычке заставят об этом подумать.

        0

        Вообще обычно в этом есть смысл когда возможных компонентов много или заранее они неизвестны. В противном случае проще и чище будет вообще внутри jsx описать.

          0

          Привычки бывают разные. У одних они одни, у других другие. Так что кого-то заставят, а кого-то и нет — неважно в каком стиле код писать.

            0

            В случае Typescript/Flow вы не сможете забыть про это. В случае JS у вас оно точно так же упадёт в runtime. React не даст вам отрендерить в качестве компоненты undefined. Т.е. ручная проверка в духе:


            or else throw new Error('Unknown user type: ${user.type}`);

            будет иметь плюс только в большей понятности самой ошибки.

              0

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


              Я бы вывел ошибку, но сбросил тип на дефолтный, чтобы всё приложение из-за этого не падало… интересно, насколько такой подход сейчас считается правильным?

                +1
                если данные пришли "странные"

                Хех. Так?


                const sum = (a: number, b: number): number => {
                  if (typeof a !== 'number' || !isFinite(a)) {
                     throw new Error(`wrong input: ${a}`);
                  }
                  if (typeof b !== 'number' || !isFinite(b)) {
                     throw new Error(`wrong input: ${b}`);
                  }
                  return a + b;
                }

                Я правильно понял вашу мысль? :)


                Я бы вывел ошибку, но сбросил тип на дефолтный, чтобы всё приложение из-за этого не падало…

                Такой подход имеет место быть в некоторых исключительных случаях. Нужно очень хорошо понимать последствия и критичность каждого отдельно взятого места где вы просто проглатываете ошибки, попутно отправляя их в какой-нибудь багтрекер. И точно знать что их потом там кто-нибудь читает и реагирует.


                Наиболее корректный путь для большинства приложений — падать в случае ошибки. Совершенно не обязательно целиком. Пусть это будет форма, отдельно взятая панелька, может быть модуль, может даже какая-нибудь хитрая система обработки ошибок. Но не молча игнорировать её, надеясь на авось. Особенно если это хоть как-то касается финансов вашего пользователя.


                Если же надёжность для вас серьёзный приоритет:


                1. Берём язык со статической системой типов (e.g. Typescript)
                2. Выкручиваем его на самый строгий режим (strict: true & type linters для eslint)
                3. При необходимости пишем свои правила для линтера
                4. Весь внешний input (скажем ответы сервера и сторонних сервисов) проводим через что-нибудь вроде io-ts
                5. Тесты и QA ;)
            –1
            Использование этой простой стратегии делает компоненты более декларативными и упрощает понимание их кода.

            Серьезно? Что же, давайте более подробно проанализируем этот кусок кода


            import React from 'react'
            
            const Student = ({ name }) => <p>Student name: {name}</p>
            const Teacher = ({ name }) => <p>Teacher name: {name}</p>
            const Guardian = ({ name }) => <p>Guardian name: {name}</p>
            
            const COMPONENT_MAP = {
                student: Student,
                teacher: Teacher,
                guardian: Guardian
            }
            
            export default function SampleComponent({ user }) {
                const Component = COMPONENT_MAP[user.type]
            
                return (
                    <div>
                        <Component name={user.name} />
                    </div>
                )
            }

            Сколько раз ревьюеру (который видит этот кусок кода первый раз) нужно будет промотать глазами чтобы понять что происходит в коде?
            1) видим строчку COMPONENT_MAP[user.type] — ага, нужно найти взглядом эту переменную из внешнего скоупа и понять что там хранится,
            2) видим строчку рендера какого-то компонента <Component name={user.name} /> — опять нужно найти взглядом переменную из внешнего скоупа и понять что записано в переменную Component.
            3-6) и наконец по значениям-ссылкам на три компонента которые были записаны в COMPONENT_MAP мы теперь должны найти каждый компонент и понять что он рендерит


            А теперь сравним это с такой версией


            import React from 'react'
            
            export default function SampleComponent({ user }) {
                return (
                    <div>
                      {user.type == "student" && 
                         <p>Student name: {user.name}</p>
                      }
                      {user.type == "teacher" && 
                         <p>Teacher name: {user.name}</p>
                      }
                      {user.type == "guardian" && 
                         <p>Guardian name: {user.name}</p>
                      }
                    </div>
                )
            }

            Сколько раз в этой версии разработчику нужно будет мотать глазами чтобы понять что происходит в коде? Не проще ли становится код когда вообще не нужно мотать глазами? Не является ли вторая версия более "декларативной" ?

              +1
              Серьезно?

              Да


              А теперь сравним это с такой версией

              У вас просто использование компонента сводится к:


              <p>Guardian name: {user.name}</p>

              Более реалистичный пример будет включать в себя 5-7 props. И тогда вы получаете либо груду copy-paste, либо код где props высчитываются в одном месте, а используются в другом через spread operator, что тоже не добавляет читаемости.


              Использование hashMap + key настолько избитый и широкоиспользуемый подходы, что я сильно удивлён что есть люди, которых это удивляет.


              Вот из недавнего в моей практике. В приложении есть около 15 видов уведомлений. Я подключаю нужный компонент по типу самого уведомления. Все компоненты имеют единый API. Я не пишу 15 IF-ов. Я пишу hashmap (типизированный с TS).


              const notificationsMap: Record<NotificationType, React.ComponentType<{ notification: Notification }> = { ... };
              
              const NotificationItem = ({ notification }: { notification: Notification }) => <notificationsMap[notification.type] {...{ notification }}/>;

              код получается примерно таким. И да, разумеется, чтобы "понять всё" придётся побегать по файлам. Ну а как иначе то? Сделав тут портянку IF-ов проблема никуда не исчезнет.


              В случае TS это особенно удобно ввиду того что без лишних телодвижений вы автоматически получаете exhaustive проверку.

                0
                Вам показали упрощенный пример, с небольшим компонентом и небольшим вариантов(3), увеличив кол-во и того и другого — вы заметите, что подход автора статьи имеет место быть, я бы даже сказал предпочтительнее
                  0
                  Нет, не проще. Ваш код нарушает принцип DRY.
                  0
                  Очень полезно, благодарю за такую понятную статью.
                    +1

                    За совет #3 нужно увольнять.

                      0
                      Речь идёт о том, что следует избегать использования оператора spread ({...props}) при передаче свойств от родительских компонентов дочерним.


                      Я бы не был столь категоричен. Есть случаи, когда иначе никак: обёртки (wrappers).
                        0

                        Бывает удобно запретить spread props линтером для всего проекта и разрешить для каталога shared components

                          0
                          То есть глобально запрещаем, а для определённых директорий разрешаем?
                            0

                            Все верно

                        0
                        В последнем примере кода один export внутри другого. Вы уверены, что так стоит писать?

                        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                        Самое читаемое