5 ошибок производительности, которые убивают ваше React-приложение
Большинство React-приложений медленнее, чем могли бы быть. Вот 5 наиболее распространенных ошибок производительности, которые я вижу в продакшн-коде — и как исправить каждую из них.

За годы я просмотрел сотни кодовых баз React. Клиентские проекты, репозитории с открытым исходным кодом, внутренние инструменты, MVP стартапов. Одни и те же проблемы с производительностью возникают снова и снова. Это не экзотические пограничные случаи — это базовые, предотвратимые ошибки, которые накапливаются, пока приложение не начинает тормозить, а пользователи не начинают уходить.
Вот пять наиболее частых ошибок, которые я вижу, с реальными исправлениями, которые вы можете применить уже сегодня.
1. Ненужные повторные рендеры из-за отсутствия мемоизации
Это самая распространенная проблема производительности в React-приложениях. Родительский компонент перерисовывается, и каждый дочерний компонент перерисовывается вместе с ним — даже если их пропсы не изменились. Умножьте это на глубокое дерево компонентов с десятками элементов в списке, и вы получите приложение, которое заикается при каждом нажатии клавиши.
Проблема:
function Dashboard({ user }) {
const [searchQuery, setSearchQuery] = useState("");
const stats = calculateStats(user.transactions);
return (
<div>
<SearchBar value={searchQuery} onChange={setSearchQuery} />
<StatsPanel stats={stats} />
<TransactionList transactions={user.transactions} />
</div>
);
}
Каждый раз, когда searchQuery меняется, calculateStats запускается снова — даже если user.transactions не изменился. StatsPanel и TransactionList также перерисовываются без всякой причины.
Решение:
function Dashboard({ user }) {
const [searchQuery, setSearchQuery] = useState("");
const stats = useMemo(
() => calculateStats(user.transactions),
[user.transactions]
);
const handleSearchChange = useCallback(
(value: string) => setSearchQuery(value),
[]
);
return (
<div>
<SearchBar value={searchQuery} onChange={handleSearchChange} />
<MemoizedStatsPanel stats={stats} />
<MemoizedTransactionList transactions={user.transactions} />
</div>
);
}
const MemoizedStatsPanel = memo(StatsPanel);
const MemoizedTransactionList = memo(TransactionList);
Здесь произошло три вещи:
useMemoпредотвращает запускcalculateStatsпри каждом рендере. Он пересчитывает только тогда, когдаuser.transactionsдействительно меняется.useCallbackстабилизирует ссылку на обработчик изменений, чтобыSearchBarне видел "новую" функцию при каждом рендере.memoоборачивает дочерние компоненты, чтобы они пропускали повторный рендеринг, когда их пропсы поверхностно равны.
Предостережение: не оборачивайте каждый компонент в memo вслепую. Сначала профилируйте с помощью React DevTools Profiler. Мемоизация имеет свою цену — само поверхностное сравнение. Применяйте ее там, где это важно: дорогостоящие вычисления, большие списки и компоненты, которые часто перерисовываются с неизменными пропсами.
2. Раздутые бандлы из-за отсутствия разделения кода
Я регулярно вижу React-приложения, которые поставляют все в одном JavaScript-бандле. Панель администратора, страница настроек, редко посещаемый справочный центр — все загружается заранее при первом посещении страницы. Пользователь хотел увидеть целевую страницу, а его браузер только что загрузил 2 МБ JavaScript, который ему пока не нужен.
Проблема:
import AdminDashboard from "./pages/AdminDashboard";
import Settings from "./pages/Settings";
import HelpCenter from "./pages/HelpCenter";
import Landing from "./pages/Landing";
function App() {
return (
<Routes>
<Route path="/" element={<Landing />} />
<Route path="/admin" element={<AdminDashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/help" element={<HelpCenter />} />
</Routes>
);
}
Статические импорты означают, что сборщик включает все в один чанк. Независимо от того, какой маршрут посещает пользователь, он загружает все это.
Решение:
import { lazy, Suspense } from "react";
const AdminDashboard = lazy(() => import("./pages/AdminDashboard"));
const Settings = lazy(() => import("./pages/Settings"));
const HelpCenter = lazy(() => import("./pages/HelpCenter"));
import Landing from "./pages/Landing"; // Keep critical path static
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<Landing />} />
<Route path="/admin" element={<AdminDashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/help" element={<HelpCenter />} />
</Routes>
</Suspense>
);
}
React.lazy с динамическим import() указывает сборщику разделить эти страницы на отдельные чанки. Они загружаются по требованию, когда пользователь переходит по этому маршруту. Ваш начальный бандл остается небольшим, а время до интерактивности значительно сокращается.
Если вы используете Next.js, вы получаете разделение кода на основе маршрутов бесплатно с App Router. Но вам все равно нужно целенаправленно подходить к разделению на уровне компонентов для тяжелых сторонних библиотек:
import dynamic from "next/dynamic";
const RichTextEditor = dynamic(() => import("@/components/RichTextEditor"), {
loading: () => <EditorSkeleton />,
ssr: false,
});
Это критически важно для таких библиотек, как редакторы форматированного текста, библиотеки для построения диаграмм или рендереры PDF, которые могут легко добавить 200-500 КБ к вашему бандлу.
3. Состояние, расположенное слишком высоко в дереве компонентов
Модель рендеринга React — сверху вниз. Когда состояние компонента меняется, он перерисовывается — и то же самое происходит со всем его поддеревом. Когда вы поднимаете состояние на вершину дерева "для удобства", вы говорите React перерисовывать все, что находится ниже, при каждом изменении состояния.
Проблема:
function App() {
const [selectedFilter, setSelectedFilter] = useState("all");
const [isModalOpen, setIsModalOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
return (
<Layout>
<Header />
<Sidebar filter={selectedFilter} onFilterChange={setSelectedFilter} />
<MainContent>
<SearchBar query={searchQuery} onChange={setSearchQuery} />
<ProductList filter={selectedFilter} />
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
</MainContent>
<Footer />
</Layout>
);
}
Ввод текста в строку поиска вызывает обновление состояния в App. Это приводит к повторному рендерингу Header, Sidebar, Footer, ProductList и Modal — ни один из которых не интересуется поисковым запросом.
Решение:
Переместите состояние вниз, туда, где оно фактически используется:
function App() {
return (
<Layout>
<Header />
<SidebarWithFilter />
<MainContent>
<SearchSection />
<ProductList />
</MainContent>
<Footer />
</Layout>
);
}
function SearchSection() {
const [searchQuery, setSearchQuery] = useState("");
return (
<div>
<SearchBar query={searchQuery} onChange={setSearchQuery} />
<SearchResults query={searchQuery} />
</div>
);
}
Теперь ввод текста в строку поиска перерисовывает только SearchSection и его дочерние элементы. Все остальное остается нетронутым.
Принцип прост: состояние должно находиться как можно ближе к компонентам, которые его читают. Если двум соседним компонентам требуется одно и то же состояние, поднимите его к их ближайшему общему родителю — но не выше. Для действительно глобального состояния (аутентификация, тема, локаль) используйте библиотеку управления состоянием, которая поддерживает выборочные подписки, например Zustand или Jotai, чтобы только компоненты, использующие определенный срез состояния, перерисовывались при изменении этого среза.
4. Неоптимизированные изображения в Next.js
Изображения обычно являются самыми тяжелыми активами на веб-странице. Я видел приложения Next.js, где разработчики использовали обычные теги <img> с массивными, несжатыми PNG-файлами. Результат: страница, которая загружает 8 МБ изображений, нулевая ленивая загрузка, отсутствие адаптивного размера и время Largest Contentful Paint, от которого Lighthouse плачет.
Проблема:
function ProjectCard({ project }) {
return (
<div className="card">
<img src={`/images/${project.cover}`} alt={project.title} />
<h3>{project.title}</h3>
<p>{project.description}</p>
</div>
);
}
Это подает изображение в полном разрешении в его исходном размере, независимо от области просмотра или устройства пользователя. Без преобразования формата, без ленивой загрузки, без адаптивного srcset.
Решение:
import Image from "next/image";
function ProjectCard({ project }) {
return (
<div className="card">
<Image
src={`/images/${project.cover}`}
alt={project.title}
width={600}
height={400}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
placeholder="blur"
blurDataURL={project.blurHash}
/>
<h3>{project.title}</h3>
<p>{project.description}</p>
</div>
);
}
Компонент Image в Next.js автоматически предоставляет вам несколько вещей:
- Автоматическое преобразование формата в WebP или AVIF в зависимости от поддержки браузером
- Адаптивный размер через атрибут
sizes— браузер загружает только тот размер, который ему нужен - Ленивая загрузка по умолчанию — изображения, находящиеся за пределами видимой области, не загружаются, пока пользователь не прокрутит страницу к ним
- Заполнитель с размытием — показывает легкий предварительный просмотр во время загрузки полного изображения, устраняя смещение макета
Для изображений, находящихся в верхней части страницы (героические баннеры, основной контент), добавьте priority, чтобы отключить ленивую загрузку и предварительно загрузить их:
<Image src={heroImage} alt="Hero" priority sizes="100vw" />
Одно это изменение может сократить вес страницы на 60-80% и значительно улучшить ваши показатели Core Web Vitals.
5. Блокировка основного потока тяжелыми вычислениями
Основной поток браузера обрабатывает рендеринг, пользовательский ввод, анимацию и выполнение JavaScript — все это в одном потоке. Когда вы выполняете тяжелое вычисление синхронно, все остальное зависает. Пользователь нажимает кнопку, и ничего не происходит в течение 500 мс. Анимации дергаются. Прокрутка перестает отвечать.
Проблема:
function AnalyticsDashboard({ rawData }) {
const [processedData, setProcessedData] = useState(null);
useEffect(() => {
// This blocks the main thread for 200-800ms
const result = processLargeDataset(rawData); // Sorting, filtering, aggregating 50k+ rows
setProcessedData(result);
}, [rawData]);
if (!processedData) return <Spinner />;
return <Charts data={processedData} />;
}
processLargeDataset выполняется синхронно в основном потоке. Если это занимает 400 мс, весь пользовательский интерфейс зависает на 400 мс. Пользователь не может прокручивать, печатать или взаимодействовать с чем-либо.
Решение — перенести в Web Worker:
// workers/dataProcessor.ts
self.onmessage = (event: MessageEvent) => {
const rawData = event.data;
const result = processLargeDataset(rawData);
self.postMessage(result);
};
function AnalyticsDashboard({ rawData }) {
const [processedData, setProcessedData] = useState(null);
const workerRef = useRef<Worker | null>(null);
useEffect(() => {
workerRef.current = new Worker(
new URL("../workers/dataProcessor.ts", import.meta.url)
);
workerRef.current.onmessage = (event: MessageEvent) => {
setProcessedData(event.data);
};
workerRef.current.postMessage(rawData);
return () => {
workerRef.current?.terminate();
};
}, [rawData]);
if (!processedData) return <Spinner />;
return <Charts data={processedData} />;
}
Тяжелое вычисление теперь выполняется в отдельном потоке. Основной поток остается свободным для обработки пользовательских взаимодействий, анимации и рендеринга. Пользовательский интерфейс остается отзывчивым все время.
В менее экстремальных случаях, когда вычисление занимает 50-150 мс, вы можете разбить работу на более мелкие части, используя requestIdleCallback или scheduler.yield(), вместо того чтобы запускать полноценный Worker. Но для всего, что обрабатывает большие наборы данных, генерирует отчеты или запускает сложные алгоритмы, Web Workers не являются опцией — они необходимы.
Совокупный эффект
Ни одна из этих ошибок сама по себе не приведет к сбою вашего приложения. Именно это делает их опасными. Каждая из них добавляет 100-300 мс задержки, и пользователи ощущают общую сумму. Компонент, который перерисовывается без необходимости, плюс раздутый бандл, плюс неоптимизированные изображения, плюс вычисление в основном потоке означает, что ваше приложение становится интерактивным за 4 секунды вместо 1.
Профилируйте, прежде чем оптимизировать. Используйте React DevTools Profiler для поиска горячих точек повторного рендеринга. Используйте Lighthouse и Web Vitals для измерения реального влияния на пользователя. Используйте вкладку "Сеть" вашего браузера, чтобы обнаружить слишком большие бандлы и изображения. Используйте вкладку "Производительность" для выявления длительных задач, блокирующих основной поток.
Это шаблоны, которые я применяю в каждом проекте, который я создаю — от ресторанных платформ до медицинских SaaS-систем. Производительность — это не функция, которую вы добавляете позже. Это качество, которое вы поддерживаете с первого коммита.
Связанные проекты
RestoHub
Рестораны перестают терять 30% в пользу Uber Eats — они получают собственную систему заказов, меню, сайт и программу лояльности в одной платформе. Полноценный опыт уровня Uber Eats, но ресторан оставляет себе каждый доллар.
Poulet Rouge
Создал полное цифровое присутствие для Poulet Rouge — сети ресторанов с 100+ локациями в Квебеке. Интерактивный локатор заведений, виджеты онлайн-заказов, калькулятор нутриентов и двуязычное SEO, которое реально ранжируется в Google.