Как сократить ошибки в базе кода React

Введение

В процессе работы с большой базой кода приложений React мы столкнулись с 3 категориями ошибок. Они не относились к разряду ошибок времени компиляции или времени выполнения, а представляли собой непредвиденное поведение кода. Перечислим их.

  • Компонент не обновляется по событию пользователя. 
  • Компонент частично обновляется по событию пользователя. 
  • Компонент отображается неожиданно. 

Первая инстинктивная реакция  —  “сразиться со злом, где бы мы его не обнаружили”. 

Инструкции print, в атаку!!!

Однако даже многократное применение инструкций print не облегчило поиск ошибок. Именно тогда пришло понимание, что некоторые части кода относятся к антипаттернам. Мы потратили немало времени на их изучение и характеристику, чтобы избежать подобных ошибок в дальнейшем. В этой статье мы поделимся результатами проведенного исследования. 

Паттерны и антипаттерны в React

Код React считается правильным паттерном, если: 

  • компонент допускает переиспользование; 
  • упрощается процесс ревью и отладки. 

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

Почему даже опытные разработчики попадают в ловушку антипаттернов?

  1. Код React, написанный в соответствии с паттерном, удивительным образом похож на код антипаттерна. 
  2. Паттерн кажется настолько очевидным, что его игнорируют. 

Как определить антипаттерн?

Подсказка #1. Хук без массива зависимостей

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

В связи с этим будьте внимательны при использовании хуков useState, useRef и т. д., поскольку они не принимают массивы зависимостей.

Подсказка #2. Технология вложения вместо композиции 

Выделяют 2 механизма структурирования компонентов React.

  1. Композиция. Все дочерние компоненты располагают одинаковыми данными. 
  2. Вложение. Каждый дочерний компонент может обладать разными данными. 

Рассмотрим сценарий, при котором мы находим ошибку в компоненте Child 3.

Если бы мы структурировали компоненты по принципу композиции, нам бы не пришлось просматривать код Child 1 и Child 2, так как оба они независимы. Исходя из этого, временная сложность отладки равнялась бы O (1)

Однако структурирование компонентов путем вложения потребовало бы проверки всех дочерних компонентов, предшествующих Child 3, для выявления источника ошибки. В этом случае временная сложность отладки составила бы O (n), где n  —  количество дочерних компонентов, располагающихся поверх Child 3.

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

Пример приложения

Продемонстрируем на примере приложения различные паттерны и антипаттерны.

Ожидаемое поведение приложения 

При нажатии на статью в левом навигационном меню она открывается справа. Далее следуют 2 действия.

  1. Вычисление. Общее количество символов в статье рассчитывается по формуле (num_chars(title) + num_chars(text) и отображается на экране.
  2. Сетевой запрос. В зависимости от общего количества символов в статье через сетевой запрос выбирается смайлик с определенной эмоцией и отображается на экране. По мере увеличения количества символов эмоциональный спектр смайлика меняется от грустного до веселого.

Создание приложения 

От правильного подхода к созданию приложения нас отделяют 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.

Читайте также:

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи Darshita Chaturvedi: How We Reduced Bugs in Our React Codebase

Предыдущая статьяПоврежден жесткий диск? Python спешит на помощь!
Следующая статьяОсновы CI/CD