Бързина: кога се рисува наново
Накратко: В React компонентът се рисува наново при промяна на състоянието си или на родителя; първо се мери, после memo, useMemo и useCallback спират излишното рисуване.
Проблем
Всяка буква рисува целия списък
Горе има поле за търсене и бутон „Търси“. Списъкът се филтрира едва след „Търси“. ProductList пише в конзолата всеки път, когато се рисува. Напиши „мляко“ в полето и гледай конзолата.
Конзола
Пет букви, пет нови реда „рисувам ProductList“. Списъкът е същият: още не си натиснал „Търси“. Тук продуктите са осем и не се усеща. В истинския каталог са 2000, всеки с картинка и цена, и полето започва да изостава след пръстите.
Идеята
Кога компонентът се рисува наново
Компонентът се рисува наново, когато се сменят данните, които показва. Но кое значи „данните му“, двете библиотеки решават различно. И двете дават верен резултат; разликата е колко работа вършат.
React рисува наново компонента, чието състояние се смени, и всичките му деца, по цялото дърво надолу. По подразбиране не проверява дали props на детето са същите. Тук draft е в App, затова всяка буква рисува App, ProductList и всяка карта.
Това обикновено е бързо: React сравнява резултата със старата страница и сменя в DOM само разликата. Скъпо става, когато поддървото е голямо или сметката в него е тежка.
Първо мери, после поправяй. Брояч в конзолата, както тук, е най-простото мерене. React DevTools имат раздел Profiler: записваш, пишеш в полето и виждаш кой компонент колко пъти се е рисувал и за колко милисекунди. Поправяш само рисуването, което е и излишно, и бавно.
Виж го
Виж кой светва
const ProductList = memo(function ProductList({ products, onAdd }) { console.log(`рисувам ProductList: ${++renders}`); return products.map((p) => <ProductCard key={p.id} product={p} onAdd={onAdd} />);});
function App() { const [draft, setDraft] = useState(''); const [query, setQuery] = useState(''); const [count, setCount] = useState(0); const visible = useMemo(() => filterByName(PRODUCTS, query), [query]); const add = useCallback(() => setCount((c) => c + 1), []); return ( <> <input value={draft} onChange={(e) => setDraft(e.target.value)} /> <button onClick={() => setQuery(draft)}>Търси</button> <ProductList products={visible} onAdd={add} /> </> );}Стъпка по стъпка, с думи
- App пази текста в полето (draft) и търсенето (query). ProductList рисува картите. Конзолата брои колко пъти се рисува списъкът.
- Всеки клавиш сменя draft. React рисува наново App, а с него и всяко дете: ProductList и всяка карта, макар че списъкът е същият.
- memo(ProductList) сравнява новите props със старите. useMemo пази същия масив, докато query е същото, а useCallback пази същата add.
- Сега клавишът рисува само App и полето. Props на ProductList са същите, затова той е пропуснат, а броячът стои.
- „Търси“ сменя query. Масивът е нов, затова ProductList се рисува. Това рисуване е нужно: списъкът вече показва два продукта.
- Първо мери: брояч в конзолата или Profiler в DevTools. Поправяй само рисуванията, които са и излишни, и бавни.
memo. А сравнението има смисъл само ако props наистина са същите: масивът и функцията трябва да се пазят между рисуванията.Пробвай
Само „Търси“ рисува списъка
Направи така, че писането в полето и „Добави“ да не рисуват ProductList наново. Само „Търси“ трябва да го рисува, защото само тогава списъкът се сменя. Проверката пише „мляко“, добавя два продукта, натиска „Търси“ и брои редовете „рисувам ProductList“ в конзолата.
Конзола
Решение
App.jsximport { useCallback, useMemo, useState } from 'react';
import ProductList from './ProductList.jsx';
import { PRODUCTS } from './products.js';
export default function App() {
const [draft, setDraft] = useState('');
const [query, setQuery] = useState('');
const [count, setCount] = useState(0);
const visible = useMemo(() => PRODUCTS.filter((p) => p.name.toLowerCase().includes(query.toLowerCase())), [query]);
const add = useCallback(() => setCount((c) => c + 1), []);
function search(e) {
e.preventDefault();
setQuery(draft);
}
return (
<>
<header>
<form role="search" onSubmit={search}>
<input type="search" aria-label="Търсене" value={draft} onChange={(e) => setDraft(e.target.value)} />
<button>Търси</button>
</form>
<p>В кошницата: {count}</p>
</header>
<main>
<ProductList products={visible} onAdd={add} />
</main>
</>
);
}
ProductList.jsximport { memo } from 'react';
import { money } from './products.js';
let renders = 0;
function ProductList({ products, onAdd }) {
console.log(`рисувам ProductList: ${++renders}`);
return (
<ul>
{products.map((p) => (
<li key={p.id}>
<span>{p.name}, {money(p.price)}</span>
<button type="button" aria-label={`Добави ${p.name}`} onClick={() => onAdd(p)}>
Добави
</button>
</li>
))}
</ul>
);
}
export default memo(ProductList);
products.jsexport const PRODUCTS = [
{ id: 1, name: 'Бял хляб', price: 1.2 },
{ id: 2, name: 'Пълнозърнест хляб', price: 1.9 },
{ id: 5, name: 'Прясно мляко', price: 1.6 },
{ id: 6, name: 'Кисело мляко', price: 0.9 },
{ id: 7, name: 'Сирене', price: 7.9 },
{ id: 8, name: 'Кашкавал', price: 9.5 },
{ id: 9, name: 'Масло', price: 3.4 },
{ id: 10, name: 'Яйца (10 бр.)', price: 2.8 },
];
export const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
header { display: flex; justify-content: space-between; align-items: center; gap: 8px; background: #f3efe6; padding: 8px 16px; }
main { padding: 0 16px; }
ul { list-style: none; padding: 0; }
li { display: flex; gap: 8px; align-items: center; margin: 6px 0; }
li span { flex: 1; }
button, input { font: inherit; }
Трите части вървят заедно:
- В
ProductList.jsx:export default memo(ProductList);. Сега React сравнява props, преди да рисува. - В
App.jsx:const visible = useMemo(() => PRODUCTS.filter(…), [query]);. Масивът е същият, докатоqueryе същото. const add = useCallback(() => setCount((c) => c + 1), []);. Функцията е същата при всяко рисуване. СъсsetCount((c) => c + 1)тя не четеcount, затова празният списък[]е верен.useCallback(() => setCount(count + 1), [])запомня първотоcountи кошницата спира на 1: проверката го хваща.
Само memo не стига: филтърът и функцията са нови при всяко рисуване на App, и memo вижда нови props.
Пробвай
Намери грешката
ProductList е в memo, а списъкът пак се рисува при всяка буква. Кой ред е виновен?Избери реда с грешката.
add е нова стрелка при всяко рисуване на App. memo вижда нов onAdd и рисува ProductList. Трябва const add = useCallback(() => setCount((c) => c + 1), []);.Пробвай
Попълни
Как скъпият списък остава същият между рисуванията?
const visible = (
() => PRODUCTS.filter((p) => p.name.includes(query)),
[query],
);useMemo: пуска функцията при първото рисуване и пак само когато някоя зависимост от [query] се смени. Иначе връща същия масив.Идеята
Дълъг списък: рисувай по-малко
Най-бързият елемент е този, който не е нарисуван. Човек вижда 10–20 продукта на екрана, а не 2000. Затова дългият списък рисува част:
- „Покажи още“: първите 20, после още 20 при клик. Помни се само колко са показани.
- Страници: „1, 2, 3 …“, често с номера в адреса (
/?page=2), за да се споделя. - Виртуален списък (windowing): рисува само редовете, които се виждат, и ги сменя при превъртане. Пише се трудно, затова се ползва библиотека: TanStack Virtual или react-window.
Ключовете от урока за списъците помагат и тук: с key={p.id} добавените 20 продукта са нови елементи, а старите остават, без да се правят наново.
Идеята
Мързеливо зареждане
Не всяка част от страницата трябва веднага. Отзивите за продукта, картата с магазините, редакторът на профила: може да ги зареди едва когато човекът ги отвори. Така първото отваряне тегли по-малко код.
import { lazy, Suspense } from 'react';
const Reviews = lazy(() => import('./Reviews.jsx'));
<Suspense fallback={<p>Зареждам отзивите…</p>}> <Reviews /></Suspense>import('./Reviews.jsx') е функция, която зарежда файла и връща Promise. lazy прави от нея компонент. Докато файлът идва, Suspense показва fallback.
Конзола
Отвори конзолата: редът от reviews.js излиза едва след клика. Честно казано, в пясъчника всички файлове вече са в страницата, затова тук import() само пуска файла по-късно. В истинския проект Vite слага всеки такъв файл отделно и браузърът го тегли от сървъра при първия клик. Същото важи за страниците: с React Router всяка страница може да е lazy.
По-точно, за любопитните
React Compiler. Ако проектът ползва React Compiler, той слага такова запомняне сам при сглобяването на кода, и ръчните memo, useMemo и useCallback стават рядко нужни. Документацията на React го препоръчва за нов код и оставя useMemo и useCallback като начин за точен контрол.
Състоянието надолу. Друга поправка без memo: полето със своя draft става отделен компонент SearchBox, който съобщава нагоре само при „Търси“ (onSearch(draft)). Тогава буквите сменят състояние в SearchBox и App изобщо не се рисува. Това е правилото „толкова високо, колкото трябва, и не по-високо“ от урока за повдигането.
StrictMode. В режим на разработка <StrictMode> рисува всеки компонент по два пъти, за да хване нечисти компоненти. Броячът тогава расте с по две. В събраната версия това го няма.
useMemo е подсказка. React може да изхвърли запомнения резултат и да смята наново. Кодът трябва да е верен и без него.
Режимът на разработка е по-бавен. Предупрежденията и проверките струват време. Мери бързината на събраната версия (vite build и vite preview), а не само при разработка.
Упражнение
Покажи още
Каталогът има 10 продукта и рисува всичките. Покажи първите 4, а под тях „Показани 4 от 10“ и бутон „Покажи още“, който добавя още 4. Когато всички са показани, бутонът изчезва. Проверката брои продуктите след всеки клик и гледа текста и бутона.
Конзола
Решение
App.jsximport { useState } from 'react';
import { PRODUCTS, money } from './products.js';
const PAGE = 4;
export default function App() {
const [limit, setLimit] = useState(PAGE);
const shown = PRODUCTS.slice(0, limit);
return (
<main>
<ul>
{shown.map((p) => (
<li key={p.id}>
<span>{p.name}, {money(p.price)}</span>
</li>
))}
</ul>
<p>{`Показани ${shown.length} от ${PRODUCTS.length}`}</p>
{limit < PRODUCTS.length && (
<button type="button" onClick={() => setLimit(limit + PAGE)}>Покажи още</button>
)}
</main>
);
}
products.jsexport const PRODUCTS = [
{ id: 1, name: 'Бял хляб', price: 1.2 },
{ id: 2, name: 'Пълнозърнест хляб', price: 1.9 },
{ id: 5, name: 'Прясно мляко', price: 1.6 },
{ id: 6, name: 'Кисело мляко', price: 0.9 },
{ id: 7, name: 'Сирене', price: 7.9 },
{ id: 8, name: 'Кашкавал', price: 9.5 },
{ id: 9, name: 'Масло', price: 3.4 },
{ id: 10, name: 'Яйца (10 бр.)', price: 2.8 },
{ id: 11, name: 'Ябълки (1 кг)', price: 2.2 },
{ id: 12, name: 'Банани (1 кг)', price: 2.6 },
];
export const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
header { display: flex; justify-content: space-between; align-items: center; gap: 8px; background: #f3efe6; padding: 8px 16px; }
main { padding: 0 16px; }
ul { list-style: none; padding: 0; }
li { display: flex; gap: 8px; align-items: center; margin: 6px 0; }
li span { flex: 1; }
button, input { font: inherit; }
Пазиш само колко са показани: const [limit, setLimit] = useState(4);. Показаните са изчислени: PRODUCTS.slice(0, limit). Бутонът е {limit < PRODUCTS.length && <button …>}, а кликът е setLimit(limit + 4). Обикновена променлива let limit = 4 се сменя при клик, но React не рисува наново: проверката го хваща.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Изчислено състояние · Вдигане на състоянието · Ефекти · Рутиране
- Задачи по темата: Цяло приложение