Skip to main content

Размышления о React

Источник.

Необходимый минимум

Когда машина умнее тебя

  1. Выполняй статический анализ кода с помощью ESLint. Используй плагин eslint-plugin-react-hooks для перехвата ошибок, связанных с неправильным использованием хуков.

Установка

npm i -D eslint-plugin-react-hooks

# or
yarn add -D eslint-plugin-react-hooks

Настройки eslint

{
"extends": [
// ...
"plugin:react-hooks/recommended"
]
}

Или:

{
"plugins": [
// ...
"react-hooks"
],
"rules": {
// ...
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}

При использовании create-react-app данный плагин включается в проект автоматически.

  1. Включай строгий режим. На дворе 2022 год, в конце концов.
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
)
  1. Не обманывай React о зависимостях. Исправляй предупреждения/ошибки, связанные с зависимостями, возникающие при использовании хуков useEffect, useCallback и useMemo. Для обеспечения актуальности колбеков без выполнения лишних ререндеров можно использовать такой паттерн.

  2. Не забывай добавлять ключи при использовании map для рендеринга списка элементов.

  3. Используй хуки только на верхнем уровне. Не вызывай их в циклах, условиях и вложенных функциях.

  4. Разберись с тем, что означает предупреждение Can't perform state update on unmounted component (невозможно обновить состояние размонтированного компонента).

  5. Избегай появления белого экрана смерти путем использования предохранителей на разных уровнях приложения. Предохранители можно также использовать для отправки сообщений в сервис мониторинга ошибок, такой как Sentry - статья о том, как это сделать.

  6. Если в консоли инструментов разработчика в браузере имеются предупреждения или ошибки, на это есть причина!

  7. Не забывай про tree-shaking (тряску дерева).

  8. Prettier (или похожие инструменты) избавляет от необходимости заботиться о единообразном форматировании кода за счет автоматизации этого процесса.

  9. TypeScript сделает твою жизнь намного проще.

  10. Для открытых проектов настоятельно рекомендую использовать Code Climate. Автоматическое обнаружение "дурно пахнущего" кода мотивирует на борьбу с техническим долгом.

  11. Next.js - отличный метафреймворк.

Код - это необходимое зло

"Лучший код - это его отсутствие. Каждая новая строка кода, которую ты написал, это код, который кому-то придется отлаживать, код, который кому-то придется читать и понимать, код, который кому-то придется поддерживать." Jeff Atwood

"Одним из самых продуктивных дней в моей жизни был день, когда я просто удалил 1000 строк кода." Eric S. Raymond

TL;DR

  1. Думай перед добавлением новой зависимости.
  2. Пиши код в стиле, характерном для React.
  3. Не умничай! YAGNI

Думай перед добавлением новой зависимости.

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

Возможно, тебе это не нужно

  1. Действительно ли тебе нужен Redux? Может быть. Но не забывай, что React - сам по себе отличный инструмент для управления состоянием.

  2. Действительно ли тебе нужен Apollo Client? Apollo Client предоставляет большое количество прикольных "фич". Однако его использование существенно увеличивает размер сборки. Если в приложении используются фичи, которые не являются уникальными для Apollo Client, попробуй заменить его на такие библиотеки, как SWR или react-query(https://react-query.tanstack.com/comparison) (или обойтись вообще без библиотек).

  3. Axios? axios - отличная библиотека, возможности которой нелегко реализовать с помощью нативного fetch. Но если единственной причиной использования axios является более привлекательный интерфейс, реализуй обертку над fetch - статья о том, как это сделать.

  4. Lodash/Underscore? Вам не нужен lodash/underscore.

  5. Moment.js? Вам не нужен momentjs.

  6. Возможно, для "темизации" (режим light/dark) тебе не нужен контекст. Попробуй использовать для этого переменные CSS.

  7. Возможно, тебе не нужен JavaScript. Современный CSS - очень мощный инструмент. Вам не нужен JavaScript.

Пиши код в стиле, характерном для React.

  1. Избегай сложных условий и используй короткие вычисления (оператор &&).
  2. Вместо традиционных циклов (for, for in, for of и т.д.) используй цепочки из встроенных функций высшего порядка (map, filter, reduce, find, some и др.). Но не забывай, что в некоторых случаях традиционные циклы могут быть более производительными.

Не умничай!

"Что произойдет с моим софтом в будущем? Возможно, случится то и это. Давайте сразу это реализуем, раз уж мы все равно разрабатываем эту часть приложения. Это позволит предвосхитить дальнейшее развитие проекта".

Никогда так не делай! Реализуй только те вещи, которые нужны в данный момент. Не считай себя Нострадамусом.

Код, над которым ты работал, должен стать лучше, чем был

Если ты встретил код, который "дурно пахнет", исправь его. Если исправить его сложно или на это нет времени, хотя бы пометь его комментарием (FIXME или TODO) с краткой характеристикой проблемы. Пусть все об этом знают. Это покажет другим, что тебе не все равно, а также добросовестное отношение к тому, что ты делаешь.

Примеры "дурно пахнущего" кода:

  • ❌  методы или функции принимают большое количество аргументов
  • ❌  сложные логические проверки
  • ❌  длинные строки кода, помещенные в одну строку
  • ❌  синтаксически идентичный дублирующийся код (такой код может иметь разное форматирование)
  • ❌  функции или методы, выполняющие много задач
  • ❌  классы или компоненты, включающие много функций или методов
  • ❌  сложная логика в одной функции или методе
  • ❌  функции или методы с большим количеством инструкций return
  • ❌  код с одинаковой структурой (названия переменных могут отличаться)

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

Ты можешь сделать лучше

  • setState (из useState) и dispatch (из useReducer) не нужно помещать в массив зависимостей таких хуков, как useEffect и useCallback, поскольку React гарантирует их стабильность.
// ❌
const decrement = useCallback(() => setCount(count - 1), [count, setCount])
const decrement = useCallback(() => setCount(count - 1), [count])

// ✅
const decrement = useCallback(() => setCount((c) => c - 1), [])
  • если useMemo или useCallback не имеют зависимостей, скорее всего, ты неправильно их используешь
// ❌
const MyComponent = (props) => {
const fnToCall = useCallback((str) => `hello ${str || 'world'}`, [])
const aConstant = useMemo(() => ({ x: 1, y: 3 }), [])

// ...
}

// ✅
const A_CONSTANT = { x: 1, y: 3 }
const fnToCall = (str) => `hello ${str || 'world'}`
const MyComponent = (props) => {
// ...
}
  • оборачивай кастомный контекст в хук: это не только сделает интерфейс лучше, но и позволит ограничиться одним импортом
// ❌
import { useContext } from 'react'
import { SomeContext } from './context'

export default function App() {
const somethingFromContext = useContext(SomeContext)
// ...
}

// ✅
export function useSomeContext() {
const context = useContext(SomeContext)
if (!context) {
throw new Error('useSomeContext может использоваться только внутри SomeProvider')
}
}

import { useSomeContext } from './context'

export default function App() {
const somethingFromContext = useSomeContext()

// ...
}
  • думай о том, как компонент будет использоваться, перед тем, как писать код

Дизайн для счастья

"Любой дурак может писать код, который понимает компьютер. Хорошие программисты пишут код, который понимают люди." Martin Fowler

"Соотношение времени, которое мы тратим на чтение кода, и времени, которое мы тратим на написание кода, составляет 10 к 1. Мы постоянно читаем старый код перед тем, как писать новый. Поэтому если ты хочешь быстро писать код, быстро выполнять задачи, если хочешь, чтобы писать код было легко, пиши такой код, который будет легко читать." Robert C. Martin

TL;DR

  1. Избегай сложного состояния за счет удаления лишнего.
  2. Передавай банан, а не гориллу, держащую банан, вместе с джунглями (старайся передавать примитивы в качестве пропов).
  3. Компоненты должны быть маленькими и простыми - принцип единственной ответственности!
  4. Дублирование лучше плохой абстракции (избегай преждевременных / неуместных обобщений).
  5. Решай проблему передачи пропов за счет композиции. Context не единственное решение проблемы распределения состояния.
  6. Разделяй большие useEffect на несколько небольших.
  1. Извлекай логику в хуки и вспомогательные функции.
  2. Большой компонент лучше разделить на logical и presentational компоненты (не обязательно, зависит от ситуации).
  3. Старайся, чтобы в зависимостях useEffect, useCallback и useMemo находились только примитивы.
  4. Зависимостей этих хуков не должно быть слишком много.
  5. Вместе нескольких useState лучше использовать один useReducer, особенно в случае, когда некоторые значения состояния зависят от других значений или предыдущего состояния.
  6. Context не должен быть глобальным для всего приложения. Он должен находиться максимально близко к компонентам, потребляющим контекст. Такая техника называется "размещением совместного состояния" (state collocation).

Избегай ненужных состояний

Пример 1

Задача

Отобразить следующую информацию о треугольнике:

  • длину каждой стороны
  • гипотенузу
  • периметр
  • площадь

Треугольник - это объект { a: number, b: number } из API. a и b - две короткие стороны правильного треугольника.

Плохое решение

const Triangle = () => {
const [triangleInfo, setTriangleInfo] = useState<{a: number, b: number} | null>(null)
const [hypotenuse, setHypotenuse] = useState<number | null>(null)
const [perimeter, setPerimeter] = useState<number | null>(null)
const [area, setArea] = useState<number | null>(null)

useEffect(() => {
fetchTriangle().then((t) => setTriangleInfo(t))
}, [])

useEffect(() => {
if (!triangleInfo) return

const { a, b } = triangleInfo
const h = computeHypotenuse(a, b)
setHypotenuse(h)
const _area = computeArea(a, b)
setArea(newArea)
const p = computePerimeter(a, b, h)
setPerimeter(p)
}, [triangleInfo])

if (!triangleInfo) return null

// рендеринг
}

Решение получше

const Triangle = () => {
const [triangleInfo, setTriangleInfo] = useState<{a: number, b: number} | null>(null)

useEffect(() => {
fetchTriangle().then((t) => setTriangle(t))
}, [])

if (!triangleInfo) return null

const { a, b } = triangleInfo
const h = computeHypotenuse(a, b)
const area = computeArea(a, b)
const p = computePerimeter(a, b, h)

// рендеринг
}

Пример 2

Задача

Разработать компонент, который будет делать следующее:

  • получать список уникальных точек от API
  • предоставлять кнопку для сортировки точек по x или y (по возрастанию)
  • предоставлять кнопку для изменения maxDistance (каждый раз увеличивая его значение на 10, начальным значением должно быть 100)
  • отображать только те точки, которые находятся не дальше maxDistance от центра (0, 0)
  • предположим, что список включает всего 100 точек, поэтому о производительности можно не беспокоиться (в случае большого количества элементов, можно мемоизировать некоторые вычисления с помощью useMemo())

Плохое решение

type SortBy = 'x' | 'y'
const toggle = (current: SortBy): SortBy => current === 'x' ? 'y' : 'x'

const Points = () => {
const [points, setPoints] = useState<{x: number, y: number}[]>([])
const [filteredPoints, setFilteredPoints] = useState<{x: number, y: number}[]>([])
const [sortedPoints, setSortedPoints] = useState<{x: number, y: number}[]>([])
const [maxDistance, setMaxDistance] = useState<number>(100)
const [sortBy, setSortBy] = useState<SortBy>('x')

useEffect(() => {
fetchPoints().then((r) => setPoints(r))
}, [])

useEffect(() => {
const sorted = sortPoints(points, sortBy)
setSortedPoints(sorted)
}, [points, sortBy])

useEffect(() => {
const filtered = sortedPoints.filter((p) => getDistance(p.x, p.y) < maxDistance)
setFilteredPoints(filtered)
}, [sortedPoints, maxDistance])

const otherSortBy = toggle(sortBy)
const pointsToDisplay = filteredPoints.map((p) => <li key={`${p.x}-${p.y}`}>({p.x}, {p.y})</li>)

return (
<>
<button onClick={() => setSortBy(otherSortBy)}>
Сортировать по {otherSortBy}
<button>
<button onClick={() => setMaxDistance(maxDistance + 10)}>
Увеличить максимальную дистанцию
<button>
<p>Отображаются точки, которые находятся ближе, чем {maxDistance} от центра (0, 0).<br />
Точки отсортированы по: {sortBy} (по возрастанию)</p>
<ol>{pointToDisplay}</ol>
</>
)
}

Решение получше

// здесь также можно использовать `useReducer()`
type SortBy = 'x' | 'y'
const toggle = (current: SortBy): SortBy => current === 'x' ? : 'y' : 'x'

const Points = () => {
const [points, setPoints] = useState<{x: number, y: number}[]>([])
const [maxDistance, setMaxDistance] = useState<number>(100)
const [sortBy, setSortBy] = useState<SortBy>('x')

useEffect(() => {
fetchPoints().then((r) => setPoints(r))
}, [])

const otherSortBy = toggle(sortBy)
const filteredPoints = points.filter((p) => getDistance(p.x, p.y) < maxDistance)
const pointToDisplay = sortPoints(filteredPoints, sortBy).map((p) => <li key={`${p.x}|${p.y}`}>({p.x}, {p.y})</li>)

return (
<>
<button onClick={() => setSortBy(otherSortBy)}>
Сортировать по {otherSortBy}
<button>
<button onClick={() => setMaxDistance(maxDistance + 10)}>
Увеличить максимальную дистанцию
<button>
<p>Отображаются точки, которые находятся ближе, чем {maxDistance} от центра (0, 0).<br />
Точки отсортированы по: {sortBy} (по возрастанию)</p>
<ol>{pointToDisplay}</ol>
</>
)
}

Передавай банан

"Хотел банан, а получил гориллу, держащую банан, вместе с джунглями." Joe Armstrong

В качестве пропов должны передаваться примитивы (по-возможности).

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

Пример

Задача

Создать компонент MemberCard, включающий 2 компонента: Summary и SeeMore. MemberCard принимает проп id. MemberCard использует хук useMember для получения следующей информации на основе id:

type Member = {
id: string
firstName: string
lastName: string
title: string
imgUrl: string
webUrl: string
age: number
bio: string
// еще 100 полей
}

Компонент SeeMore должен отображать age и bio из member, а также предоставлять кнопку для отображения/скрытия этой информации.

Компонент Summary должен рендерить изображение member. Также он должен отображать title, firstName и lastName. Клик по имени member должен перенаправлять на его профиль. Summary также может иметь дополнительный функционал.

Плохое решение

const Summary = ({ member }: { member: Member }) => {
return (
<>
<img src={member.imgUrl} />
<a href={member.webUrl} >
{member.title}. {member.firstName} {member.lastName}
</a>
</>
)
}

const SeeMore = ({ member }: { member: Member }) => {
const [seeMore, setSeeMore] = useState<boolean>(false)
return (
<>
<button onClick={() => setSeeMore(!seeMore)}>
{seeMore ? "Скрыть" : "Показать"}
</button>
{seeMore && <>Возраст: {member.age} | Биография: {member.bio}</>}
</>
)
}

const MemberCard = ({ id }: { id: string }) => {
const member = useMember(id)
return <><Summary member={member} /><SeeMore member={member} /></>
}

Решение получше

const Summary = ({ imgUrl, webUrl, header }: { imgUrl: string, webUrl: string, header: string }) => {
return (
<>
<img src={imgUrl} />
<a href={webUrl}>{header}</a>
</>
)
}

const SeeMore = ({ componentToShow }: { componentToShow: ReactNode }) => {
const [seeMore, setSeeMore] = useState<boolean>(false)
return (
<>
<button onClick={() => setSeeMore(!seeMore)}>
{seeMore ? "Скрыть" : "Показать"}
</button>
{seeMore && <>{componentToShow}</>}
</>
)
}

const MemberCard = ({ id }: { id: string }) => {
const { title, firstName, lastName, webUrl, imgUrl, age, bio } = useMember(id)
const header = `${title}. ${firstName} ${lastName}`
return (
<>
<Summary {...{ imgUrl, webUrl, header }} />
<SeeMore componentToShow={<>Возраст: {age} | Биография: {bio}</>} />
</>
)
}

Компоненты должны быть маленькими и простыми

Что означает принцип единственной ответственности (single responsibility principle)?

Это означает, что компонент должен решать только одну задачу.

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

Как понять, что компонент отвечает данному критерию?

Попробуй описать компонент одним предложением. Если компонент решает одну задачу, у тебя получится это сделать. Если же тебе приходится использовать союзы "и" или "или", тогда компонент, скорее всего, нарушает принцип единственной ответственности.

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

Пример

Задача

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

Функционал должен быть примерно следующим:

  • нажатие каждой кнопки приводит к открытию модального окна, где можно выбирать и "сохранять" товары
  • при бронировании товара рядом с соответствующей кнопкой должна отображаться галочка
  • пользователь должен иметь возможность редактировать заказ (добавлять или удалять товары) даже после бронирования
  • при наведении на кнопку курсора должен отображаться компонент WavingHand
  • кнопка должна блокироваться при отсутствии товаров определенной категории
  • при наведении курсора на заблокированную кнопку должна отображаться подсказка Недоступно
  • фон заблокированной кнопки должен быть gray
  • фон "забронированной" кнопки должен быть green
  • фон обычной кнопки должен быть red
  • кнопка для каждой категории должна иметь уникальную подпись и иконку

Плохое решение

type ShopCategoryTileProps = {
isBooked: boolean
icon: ReactNode
label: string
componentInsideModal?: ReactNode
items?: { name: string, quantity: number }[]
}

const ShopCategoryTile = ({
icon,
label,
items
componentInsideModal,
}: ShopCategoryTileProps ) => {
const [openDialog, setOpenDialog] = useState(false)
const [hover, setHover] = useState(false)
const disabled = !items || items.length === 0

return (
<>
<Tooltip title="Недоступно" show={disabled}>
<StyledButton
className={disabled ? "gray" : isBooked ? "green" : "red" }
disabled={disabled}
onClick={() => disabled ? null : setOpenDialog(true) }
onMouseEnter={() => disabled ? null : setHover(true)}
onMouseLeave={() => disabled ? null : setHover(false)}
>
{icon}
<StyledLabel>{label}<StyledLabel/>
{!disabled && isBooked && <FaCheckCircle/>}
{!disabled && hover && <WavingHand />}
</StyledButton>
</Tooltip>
{componentInsideModal &&
<Dialog open={openDialog} onClose={() => setOpenDialog(false)}>
{componentInsideModal}
</Dialog>
}
</>
)
}

Решение получше

// разделяем компонент на части
const DisabledShopCategoryTile = ({ icon, label }: { icon: ReactNode, label: string }) => (
<Tooltip title="Недоступно">
<StyledButton disabled={true} className="gray">
{icon}
<StyledLabel>{label}</StyledLabel>
</StyledButton>
</Tooltip>
)

type ShopCategoryTileProps = {
icon: ReactNode
label: string
isBooked: boolean
componentInsideModal: ReactNode
}

const ShopCategoryTile = ({
icon,
label,
isBooked,
componentInsideModal,
}: ShopCategoryTileProps ) => {
const [opened, setOpened] = useState(false)
const [hovered, setHovered] = useState(false)
const open = useCallback(() => setOpenDialog(!opened), [])
const hover = useCallback(() => setHover(!hovered), [])

return (
<>
<StyledButton
disabled={false}
className={isBooked ? "green" : "red"}
onClick={open}
onMouseEnter={hover}
onMouseLeave={hover}
>
{icon}
<StyledLabel>{label}<StyledLabel/>
{isBooked && <FaCheckCircle/>}
{hovered && <WavingHand />}
</StyledButton>
{opened &&
<Dialog onClose={open}>
{componentInsideModal}
</Dialog>
}
</>
)
}

Еще одно плохое решение из одного реального проекта

const ShopCategoryTile = ({
item,
offers,
}: {
item: ItemMap;
offers?: Offer;
}) => {
const dispatch = useDispatch();
const location = useLocation();
const history = useHistory();
const { items } = useContext(OrderingFormContext)
const [openDialog, setOpenDialog] = useState(false)
const [hover, setHover] = useState(false)
const isBooked =
!item.disabled && !!items?.some((a: Item) => a.itemGroup === item.group)
const isDisabled = item.disabled || !offers
const RenderComponent = item.component

useEffect(() => {
if (openDialog && !location.pathname.includes("item")) {
setOpenDialog(false)
}
}, [location.pathname]);
const handleClose = useCallback(() => {
setOpenDialog(false)
history.goBack()
}, [])

return (
<GridStyled
xs={6}
sm={3}
md={2}
item
booked={isBooked}
disabled={isDisabled}
>
<Tooltip
title="Недоступно"
placement="top"
disableFocusListener={!isDisabled}
disableHoverListener={!isDisabled}
disableTouchListener={!isDisabled}
>
<PaperStyled
disabled={isDisabled}
elevation={isDisabled ? 0 : hover ? 6 : 2}
>
<Wrapper
onClick={() => {
if (isDisabled) {
return;
}
dispatch(push(ORDER__PATH));
setOpenDialog(true);
}}
disabled={isDisabled}
onMouseEnter={() => !isDisabled && setHover(true)}
onMouseLeave={() => !isDisabled && setHover(false)}
>
{item.icon}
<Typography variant="button">{item.label}</Typography>
<CheckIconWrapper>
{isBooked && <FaCheckCircle size="26" />}
</CheckIconWrapper>
</Wrapper>
</PaperStyled>
</Tooltip>
<Dialog fullScreen open={openDialog} onClose={handleClose}>
{RenderComponent && (
<RenderComponent item={item} offer={offers} onClose={handleClose} />
)}
</Dialog>
</GridStyled>
)
}

Дублирование лучше плохой абстракции

Избегайте преждевременной / неуместной генерализации. Если реализация простой фичи требует написания большого количества кода, изучите другие способы ее реализации. Советую взглянуть на The Wrong Abstraction.

Советы по производительности

"Преждевременная оптимизация - корень зла." Tony Hoare

"Одно точное измерение лучше тысячи экспертных мнений." Grace Hopper

TL;DR

  1. Если тебе кажется, что что-то работает медленно, убедись в этом с помощью специальных инструментов, например, профилировщика инструментов React-разработчика.
  2. Используй useMemo() только для "дорогих" вычислений.
  3. Используй memo(), useMemo() и useCallback() для предотвращение повторного рендеринга. При этом, количество зависимостей должно быть небольшим, а сами зависимости, преимущественно, должны быть примитивами.
  4. Убедись, что memo(), useMemo() и useCallback() решают задачу (действительно ли они предотвращают ререндеринг?).
  5. Почини медленный рендеринг перед тем, как чинить ререндеринг.
  6. Размещай состояние максимально близко к компонентам, которые его используют. Это облегчит изучение кода и сделает приложение быстрее.
  7. Context должен быть логически отделен от остальной части приложения. Через провайдер должно передаваться минимально возможное количество значений. Это объясняется тем, что компоненты, потребляющие контекст, будут подвергаться повторному рендерингу при изменении любого значения, содержащегося в контексте, даже если они не используют это значение (эту проблему можно решить с помощью useMemo()).
  8. Контекст можно оптимизировать посредством разделения state и dispatch.
  9. Разберись с ленивой загрузкой и разделением кода.
  10. "Виртуализируй" списки (с помощью react-virtual или похожих решений).
  11. Меньший размер сборки, как правило, означает более быстрое приложение.
  12. Какую библиотеку для работы с формами использовать? Никакую. Но если очень хочется, то советую взглянуть на react-hook-form.