Потихоньку продолжаю дописывать посты из заметок. В прошлый раз — элементы и файберы, то есть где React держит работу. Теперь — что он с ней делает, когда прилетает обновление. И почему я больше не сужу по счётчику ререндеров, хотя четыре года назад сам учил их считать.
Обновление идёт в две фазы
Render-фаза: React идёт по файберам, зовёт функции компонентов и сверяет свежие элементы с прошлыми. Весь код из тела компонента крутится здесь, чистый JS. Commit-фаза: разница применяется к DOM, тут же бегут
useLayoutEffect. Разницы нет — DOM не трогается.У браузера свой рендер — раскладка и пиксели, и он нужен, только если коммит реально тронул DOM.
Отсюда тезис: вызов функции копеечный, дорого то, что в её теле.
Пример
function Search({ products }) {
const [query, setQuery] = useState('');
return <>
<input value={query} onChange={e => setQuery(e.target.value)} />
<Results products={products} query={query} />
</>;
}
function Results({ products, query }) {
console.count('Results render');
const rows = rankProducts(products, query); // тяжёлый расчёт
return <Table rows={rows} />;
}
Вводим текст, счётчик растёт. Легко решить:
Results ререндерится слишком часто, срочно в memo.Но
memo здесь даже не поможет: query меняется с каждым символом, а с ним и пропсы Results.Счётчик отвечает не на тот вопрос
Консоль ответила на один вопрос: сколько раз React вызвал функцию. Сколько занял каждый вызов, дошёл ли результат до DOM, из-за React ли вообще тормозит — этого в ней нет.
Один вызов функции не равен одному изменению экрана. Под
<StrictMode> в dev счётчик удваивается сам по себе. С транзишенами (`startTransition`, `useDeferredValue`) React может прервать или выбросить начатый проход: лог остался, результата нет. В примере их нет — ввод срочный и рендерится синхронно.Бывает и наоборот:
Results вызвался один раз, но rankProducts занял столько, что ввод стал дёрганым. По счётчику эти случаи не различить.При этом счётчик не бесполезен: «рендерится ли оно вообще без нужды» он показывает за пять секунд. Молчит про то, сколько это стоит.
Что я измеряю вместо этого
Открываю Performance в Chrome, запись, один символ в поле, стоп после обновления таблицы. Трейс ровно того сценария, который тормозит.
На dev-сборке React 19.2+ там сами появляются React Performance tracks, без расширения. Scheduler показывает, срочное обновление или фоновое, и сколько заняли Render, Commit и эффекты; Components — сколько заняли конкретные компоненты и их эффекты. Рядом — обычный JS, layout и paint браузера.
На React до 19.2 — вкладка Profiler в React DevTools: flamegraph по коммитам плюс настройка «Record why each component rendered».
На моём демо к посту один символ — это «+2» в консоли и около четверти секунды на каждый вызов
Results на треке. Первое ни о чём, второе — уже диагноз.Что делать дальше, трек тоже подсказывает:
— широкий Render в
Results, а пропсы реально поменялись: memo не спасёт, лечу сам расчёт;— React отработал быстро, а дальше тянутся layout и paint: ререндеры ни при чём, смотрю, что коммит сделал с DOM;
—
Results дёргается часто, но копейками: оставляю в покое.Где это ломается
Dev-сборка медленнее боевой: проверки, двойной рендер Strict Mode, инструментация. Трейс тут ищет подозрительное место, а не точные цифры — за ними в профилировочную сборку.
И трейс — про одно взаимодействие. Копеечный рендер на каждый скролл в нём выглядит невинно, а в сумме уже нет. Если поддерево от изменившегося стейта не зависит, его render лучше не запускать вовсе — это про структуру дерева и colocation, следующий пост.
Резюме
— render — чистый JS, DOM меняется в коммите, браузер рисует, только если коммит что-то тронул;
— количество вызовов не показывает задержку: вызов копеечный, дорого то, что внутри;
— сначала записать одно тормозящее взаимодействие и найти дорогой этап, потом оптимизировать;
— счётчик остаётся для вопроса «рендерится ли вообще» — с него начнётся пост про colocation.