Введение
В процессе работы с большой базой кода приложений React мы столкнулись с 3 категориями ошибок. Они не относились к разряду ошибок времени компиляции или времени выполнения, а представляли собой непредвиденное поведение кода. Перечислим их.
- Компонент не обновляется по событию пользователя.
- Компонент частично обновляется по событию пользователя.
- Компонент отображается неожиданно.
Первая инстинктивная реакция — “сразиться со злом, где бы мы его не обнаружили”.
Однако даже многократное применение инструкций print
не облегчило поиск ошибок. Именно тогда пришло понимание, что некоторые части кода относятся к антипаттернам. Мы потратили немало времени на их изучение и характеристику, чтобы избежать подобных ошибок в дальнейшем. В этой статье мы поделимся результатами проведенного исследования.
Паттерны и антипаттерны в React
Код React считается правильным паттерном, если:
- компонент допускает переиспользование;
- упрощается процесс ревью и отладки.
Обратите внимание, что код все еще считается паттерном даже при добавлении большего числа строк или дополнительных рендеров для достижения вышеуказанных целей.
Почему даже опытные разработчики попадают в ловушку антипаттернов?
- Код React, написанный в соответствии с паттерном, удивительным образом похож на код антипаттерна.
- Паттерн кажется настолько очевидным, что его игнорируют.
Как определить антипаттерн?
Подсказка #1. Хук без массива зависимостей
В React разные части кода связаны друг с другом посредством зависимостей. Эти фрагменты взаимозависимого кода совместно поддерживают состояние приложения в должном виде. Следовательно, написание фрагмента кода, лишенного зависимости, приведет к появлению ошибок.
В связи с этим будьте внимательны при использовании хуков useState
, useRef
и т. д., поскольку они не принимают массивы зависимостей.
Подсказка #2. Технология вложения вместо композиции
Выделяют 2 механизма структурирования компонентов React.
- Композиция. Все дочерние компоненты располагают одинаковыми данными.
- Вложение. Каждый дочерний компонент может обладать разными данными.
Рассмотрим сценарий, при котором мы находим ошибку в компоненте Child 3
.
Если бы мы структурировали компоненты по принципу композиции, нам бы не пришлось просматривать код Child 1
и Child 2
, так как оба они независимы. Исходя из этого, временная сложность отладки равнялась бы O (1)
.
Однако структурирование компонентов путем вложения потребовало бы проверки всех дочерних компонентов, предшествующих Child 3
, для выявления источника ошибки. В этом случае временная сложность отладки составила бы O (n)
, где n
— количество дочерних компонентов, располагающихся поверх Child 3
.
Таким образом, можно сделать вывод, что вложение, по сравнению с композицией, усложняет процесс отладки.
Пример приложения
Продемонстрируем на примере приложения различные паттерны и антипаттерны.
Ожидаемое поведение приложения
При нажатии на статью в левом навигационном меню она открывается справа. Далее следуют 2 действия.
- Вычисление. Общее количество символов в статье рассчитывается по формуле
(num_chars(title) + num_chars(text)
и отображается на экране. - Сетевой запрос. В зависимости от общего количества символов в статье через сетевой запрос выбирается смайлик с определенной эмоцией и отображается на экране. По мере увеличения количества символов эмоциональный спектр смайлика меняется от грустного до веселого.
Создание приложения
От правильного подхода к созданию приложения нас отделяют 4 шага.
- Подход Incorrect (неправильный). Приложение работает не так, как мы задумывали: при выборе новой статьи ни вычисление, ни сетевой запрос не выполняются.
- Подход Partially correct (частично правильный). Приложение работает как положено, но при выборе новой статьи наблюдается эффект мерцания.
- Подход Correct but suboptimal (правильный, но не оптимальный). Приложение работает должным образом, без мерцания DOM, но выполняет ненужные сетевые запросы.
- Подход Correct and optimal (Правильный и оптимальный). Приложение работает согласно плану: без мерцания DOM и ненужных сетевых запросов.
По данной ссылке представлена встроенная “песочница” приложения. Изучите каждый подход, нажав на соответствующую опцию в верхней навигационной панели. Проверьте, как работает приложение при нажатии на статью в левом навигационном меню.
Структура кода
Вы можете открыть “песочницу” по ссылке.
Директория src/pages
содержит страницы, соответствующие каждому шагу. Файл для каждой страницы в src/pages
включает компонент ArticleContent
, внутри которого находится изучаемый код. Для дальнейшей совместной работы вы можете выбрать соответствующий файл в “песочнице” или обратиться к фрагменту кода, представленного в статье.
Рассмотрим антипаттерны и паттерны, применяемые в 4-х вышеупомянутых подходах.
Антипаттерн #1. Использование Props
или context в качестве начального состояния
В подходе Incorrect props
и context
используются в качестве начального состояния для useState
и useRef
. Из строки 21 файла Incorrect.tsx
следует, что общее количество символов вычисляется и хранится как состояние.
import { useCallback, useEffect, useState } from "react";
import { useGetArticles } from "../hooks/useGetArticles";
import { useGetEmoji } from "../hooks/useGetEmoji";
import { Articles } from "../types";
import { Navigation } from "../components/Navigation";
const styles: { [key: string]: React.CSSProperties } = {
container: {
background: "#FEE2E2",
height: "100%",
display: "grid",
gridTemplateColumns: "10rem auto"
},
content: {}
};
const ArticleContent: React.FC<{
article: Articles["articles"]["0"];
}> = (props) => {
// Шаг 1. Вычисление длины для получения соответствующей эмоции
const [length] = useState<number>(
props.article.text.length + props.article.title.length
);
// Шаг 2. Получение изображение смайлика с эмоцией из бэкенда
const emotions = useGetEmoji();
// Шаг 3. Установка эмоции в момент получения ее изображения из бэкенда
const [emotion, setEmotion] = useState<string>("");
useEffect(() => {
if (emotions) {
setEmotion(emotions["stickers"][length]);
}
}, [emotions, length]);
return (
<div>
<div>
<h2>{props.article.title}</h2>
<div>{props.article.text}</div>
</div>
<h3
dangerouslySetInnerHTML={{
__html: `Total Length ${length} ${emotion}`
}}
/>
</div>
);
};
const Incorrect: React.FC = () => {
const articles = useGetArticles();
const [currentArticle, setCurrentArticle] = useState<
Articles["articles"]["0"] | null
>();
const onClickHandler = useCallback((article) => {
setCurrentArticle(article);
}, []);
return (
<div style={styles.container}>
<Navigation articles={articles} onClickHandler={onClickHandler} />
<div style={styles.content}>
{currentArticle ? <ArticleContent article={currentArticle} /> : null}
</div>
</div>
);
};
export default Incorrect;
Из-за этого антипаттерна при выборе новой статьи не выполняются ни вычисление, ни сетевой запрос.
Антипаттерн #2. Удаление и восстановление
Реабилитируемся за подход Incorrect, воспользовавшись антипаттерном “Удаление и восстановление”.
Удаление функционального компонента подразумевает удаление всех хуков и состояний, созданных во время первого вызова функции. Восстановление означает повторный вызов функции, как если бы она никогда прежде не вызывалась.
Отметим, что родительский компонент может задействовать свойство key
для удаления компонента и его восстановления при каждом изменении key
. И да, все верно — вы можете работать с ключами вне циклов.
Конкретно по задаче: мы реализуем антипаттерн “Удаление и восстановление” с помощью свойства key
в процессе рендеринга потомка ArticleContent
родительского компонента PartiallyCorrect
в файле PartiallyCorrect.tsx
(строка 65).
import { useCallback, useEffect, useState } from "react";
import { Navigation } from "../components/Navigation";
import { useGetArticles } from "../hooks/useGetArticles";
import { useGetEmoji } from "../hooks/useGetEmoji";
import { Articles } from "../types";
const styles: { [key: string]: React.CSSProperties } = {
container: {
background: "#FEF2F2",
height: "100%",
display: "grid",
gridTemplateColumns: "10rem auto"
},
content: {}
};
const ArticleContent: React.FC<{
article: Articles["articles"]["0"];
}> = (props) => {
// Шаг 1. Вычисление длины для получения соответствующей эмоции
const [length] = useState<number>(
props.article.text.length + props.article.title.length
);
// Шаг 2. Получение изображение смайлика с эмоцией из бэкенда
const emotions = useGetEmoji();
// Шаг 3. Установка эмоции в момент получения ее изображения из бэкенда
const [emotion, setEmotion] = useState<string>("");
useEffect(() => {
if (emotions) {
setEmotion(emotions["stickers"][length]);
}
}, [emotions, length]);
return (
<div>
<div>
<h2>{props.article.title}</h2>
<div>{props.article.text}</div>
</div>
<h3
dangerouslySetInnerHTML={{
__html: `Total Length ${length} ${emotion}`
}}
/>
</div>
);
};
const PartiallyCorrect: React.FC = () => {
const articles = useGetArticles();
const [currentArticle, setCurrentArticle] = useState<
Articles["articles"]["0"] | null
>();
const onClickHandler = useCallback((article) => {
setCurrentArticle(article);
}, []);
return (
<div style={styles.container}>
<Navigation articles={articles} onClickHandler={onClickHandler} />
<div style={styles.content}>
{/** Step 4. Using key to force destroy and recreate */}
{currentArticle ? (
<ArticleContent article={currentArticle} key={currentArticle.id} />
) : null}
</div>
</div>
);
};
export default PartiallyCorrect;
Приложение работает как положено, но при выборе новой статьи наблюдается эффект мерцания. Как следствие, этот антипаттерн выдает частично правильный результат.
Паттерн #1. Внутреннее состояние в JSX
Вместо антипаттерна “Удаление и восстановление” в подходе Correct but suboptimal задействуем повторный рендеринг.
Повторный рендеринг подразумевает повторный вызов функционального компонента React с сохранением хуков во всех вызовах функций. Обратите внимание, что в антипаттерне “Удаление и восстановление” все хуки сначала удаляются, а потом заново восстанавливаются.
Реализация повторного ренедеринга предполагает совместное использование useEffect
и useState
. В качестве начального значения для useState
устанавливается null
или undefined
. А его фактическое значение вычисляется и присваивается после запуска useEffect
. В данном паттерне мы обходным путем решаем вопрос отсутствия массива зависимостей в useState
с помощью useEffect
.
Обратите внимание, как мы перевели вычисление общего количества символов в JSX (строка 44) в Suboptimal.tsx
и применяем props
(строка 33) в качестве зависимости в useEffect
(строка 25).
import { useCallback, useEffect, useState } from "react";
import { Navigation } from "../components/Navigation";
import { useGetArticles } from "../hooks/useGetArticles";
import { useGetEmoji } from "../hooks/useGetEmoji";
import { Articles } from "../types";
const styles: { [key: string]: React.CSSProperties } = {
container: {
background: "#FEFCE8",
height: "100%",
display: "grid",
gridTemplateColumns: "10rem auto"
},
content: {}
};
const ArticleContent: React.FC<{
article: Articles["articles"]["0"];
}> = (props) => {
// Шаг 2. Получение изображение смайлика с эмоцией из бэкенда
const emotions = useGetEmoji();
// Шаг 3. Установка эмоции в момент получения ее изображения из бэкенда
const [emotion, setEmotion] = useState<string>("");
useEffect(() => {
if (emotions) {
setEmotion(
emotions["stickers"][
props.article.text.length + props.article.title.length
]
);
}
}, [emotions, props]);
return (
<div>
<div>
<h2>{props.article.title}</h2>
<div>{props.article.text}</div>
</div>
<h3
dangerouslySetInnerHTML={{
__html: `Total Length ${
props.article.text.length + props.article.title.length
} ${emotion}`
}}
/>
</div>
);
};
const Suboptimal: React.FC = () => {
const articles = useGetArticles();
const [currentArticle, setCurrentArticle] = useState<
Articles["articles"]["0"] | null
>();
const onClickHandler = useCallback((article) => {
setCurrentArticle(article);
}, []);
return (
<div style={styles.container}>
<Navigation articles={articles} onClickHandler={onClickHandler} />
<div style={styles.content}>
{currentArticle ? <ArticleContent article={currentArticle} /> : null}
</div>
</div>
);
};
export default Suboptimal;
Данный паттерн позволяет избавиться от эффекта мерцания, но при этом сетевой запрос на получение смайликов выполняется при каждом изменении props
. Таким образом, даже если количество символов не меняется, выполняется ненужный запрос на получение одного и тоже смайлика.
Паттерн #2. Применение Props в качестве зависимости в useMemo
В рамках данного подхода сделаем все правильно и оптимально. А путь к идеальному начинался с антипаттерна #1: использование props
или context
в качестве начального состояния.
Решение заключается в применении props
в качестве зависимости в useMemo
. Путем переноса вычисления общего количества символов в хук useMemo
в Optimal.tsx
(строка 22) мы предотвращаем сетевой запрос на получение смайлика при условии неизменности общего количества символом.
import { useCallback, useEffect, useMemo, useState } from "react";
import { Navigation } from "../components/Navigation";
import { useGetArticles } from "../hooks/useGetArticles";
import { useGetEmoji } from "../hooks/useGetEmoji";
import { Articles } from "../types";
const styles: { [key: string]: React.CSSProperties } = {
container: {
background: "#F0FDF4",
height: "100%",
display: "grid",
gridTemplateColumns: "10rem auto"
},
content: {}
};
const ArticleContent: React.FC<{
article: Articles["articles"]["0"];
}> = (props) => {
// Шаг 1. Вычисление длины для получения соответствующей эмоции
const length = useMemo<number>(
() => props.article.text.length + props.article.title.length,
[props]
);
// Шаг 2. Получение изображение смайлика с эмоцией из бэкенда
const emotions = useGetEmoji();
// Шаг 3. Установка эмоции в момент получения ее изображения из бэкенда
const [emotion, setEmotion] = useState<string>("");
useEffect(() => {
if (emotions) {
setEmotion(emotions["stickers"][length]);
}
}, [emotions, length]);
return (
<div>
<div>
<h2>{props.article.title}</h2>
<div>{props.article.text}</div>
</div>
<h3
dangerouslySetInnerHTML={{
__html: `Total Length ${length} ${emotion}`
}}
/>
</div>
);
};
const Optimal: React.FC = () => {
const articles = useGetArticles();
const [currentArticle, setCurrentArticle] = useState<
Articles["articles"]["0"] | null
>();
const onClickHandler = useCallback((article) => {
setCurrentArticle(article);
}, []);
return (
<div style={styles.container}>
<Navigation articles={articles} onClickHandler={onClickHandler} />
<div style={styles.content}>
{currentArticle ? <ArticleContent article={currentArticle} /> : null}
</div>
</div>
);
};
export default Optimal;
Заключение
Проработав материал статьи, мы выяснили, что использование props
и context
в качестве начального состояния и подход “Удаление и восстановление” попадают в категорию антипаттернов. А вот подходы с применением начального состояния в JSX и props
в качестве зависимости в useMemo
относятся к правильным паттернам. Напомним также, что следует внимательно задействовать хуки без массива зависимостей и технологию вложения для структурирования компонентов React.
Читайте также:
- Создание многократно используемых компонентов React оптимальным способом
- Разбираемся с Render Props и HOC в React
- 5 способов стилизовать компоненты React
Читайте нас в Telegram, VK и Дзен
Перевод статьи Darshita Chaturvedi: How We Reduced Bugs in Our React Codebase