open atlas
↑ К треку
React с нуля до senior RCT · 02 · 02

Мемоизация: ссылочное равенство — это контракт

useMemo кэширует значение, useCallback — идентичность функции, React.memo пропускает ререндер при поверхностно равных props — всё это контракты над ссылочным равенством. Инлайновые объекты и children ломают memo; дешёвые или вечно меняющиеся deps делают мемоизацию убытком.

RCT Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Трейдинговый дашборд тормозил — ввод в фильтр тикеров ронял кадры. Прилетел PR с благими намерениями и заголовком «perf: memoize everything»: 47 вызовов useMemo, 31 useCallback, React.memo на каждом компоненте дерева. Код-ревью одобрило его на ощущениях. Через две недели Profiler рассказал другую историю: время коммитов выросло на 12%, а единственный компонент, который имел значение — таблица позиций на 3000 строк — всё так же перерисовывался на каждое нажатие клавиши. Каждая строка была обёрнута в React.memo, и каждая строка получала от родителя style={{ height: rowHeight }} и onSelect={() => select(row.id)}. Оба props пересоздавались на каждом рендере, оба проваливали поверхностное сравнение, поэтому React.memo прилежно сравнивал 3000 × 6 props на нажатие — и затем всё равно перерисовывал всё: вся цена, ноль выгоды. Тем временем 47 useMemo кэшировали вещи вроде firstName + ' ' + lastName, платя сравнение зависимостей на каждом рендере, чтобы избежать работы дешевле самого сравнения. Мемоизация — не приправа для производительности; это контракт о ссылочной идентичности, и контракт, который одна сторона молча нарушает, — чистые накладные расходы.

Что на самом деле кэширует каждый инструмент

Три инструмента, три разных кэшируемых сущности, один общий механизм. useMemo(fn, deps) вызывает fn во время рендера и кэширует её возвращаемое значение; на последующих рендерах, если каждая dep совпадает с предыдущим рендером по Object.is, возвращается кэш без вызова fn. useCallback(fn, deps) кэширует сам объект функции — он никогда не вызывает fn — и буквально равен useMemo(() => fn, deps). Он не делает функцию быстрее и не кэширует результаты её вызова; он сохраняет идентичность функции между рендерами. React.memo(Component) оборачивает компонент: когда родитель перерисовывается, React поверхностно сравнивает новый объект props с предыдущим — Object.is по каждому prop — и пропускает ререндер обёрнутого компонента (переиспользуя его последний вывод), если все props совпали.

Заметьте, чего в этом списке нет: ни один из них не предотвращает ререндеры от собственных изменений состояния компонента или от контекста, который он потребляет. React.memo фильтрует только ререндеры, распространяющиеся от родителя. И кэш useMemo живёт на экземпляр компонента, держит ровно одну запись (последнюю), а React оставляет за собой право его выбросить — это подсказка производительности, а не семантическая гарантия; именно поэтому мемоизированный код обязан давать тот же результат, что и немемоизированный.

Ссылочное равенство — контракт, и вот как его ломают

Все три инструмента отвечают на один вопрос: «это то же самое, что в прошлый раз?» — и отвечают идентичностью ссылки, а не структурным равенством. Это делает их компонуемыми и быстрыми (горстка сравнений указателей) и хрупкими ровно в одном месте: всё, что пересоздаётся во время рендера, — новое. Объектные литералы, литералы массивов, стрелочные функции и — то, что удивляет даже senior-кандидатов — JSX children. <Row>{label}</Row> создаёт новый объект React-элемента на каждом рендере; элемент — это просто { type, props, ... }, поэтому мемоизированный компонент с children получает каждый раз свежую ссылку в prop children, и поверхностное сравнение проваливается. Таблица из Hook — канонический случай:

const Row = memo(function Row({ item, style, onSelect }) {
  /* дорогое поддерево */
});

// ❌ memo побеждён дважды на строку, на каждом рендере родителя:
rows.map((item) => (
  <Row
    key={item.id}
    item={item}
    style={{ height: rowHeight }}        // новая идентичность объекта каждый рендер
    onSelect={() => select(item.id)}     // новая идентичность функции каждый рендер
  />
));

// ✅ стабильные ссылки — memo реально может сработать:
const style = useMemo(() => ({ height: rowHeight }), [rowHeight]);
const onSelect = useCallback((id) => select(id), [select]);
rows.map((item) => (
  <Row key={item.id} item={item} style={style} onSelect={onSelect} />
));

Вот зачем вообще существует useCallback: не чтобы оптимизировать функцию, а чтобы React.memo ребёнка (или массив deps другого хука) не видел новую ссылку на каждом рендере. Контракт транзитивен и хрупок: один инлайновый объектный prop в любом звене цепи — и каждый memo ниже делает работу сравнения впустую. Провал молчалив: ни предупреждения, ни ошибки — только Profiler, показывающий, что «мемоизированное» поддерево перерисовывается на каждом коммите. Продакшен-история в одну строку: команда мемоизировала компонент графика и отгрузила его; через полгода кто-то добавил margin={{ top: 8 }} в месте вызова, и график молча вернулся к перерисовке 60 раз в секунду, прожив так ещё два квартала, потому что ничего не сломалось — он просто жёг CPU.

Викторина

Компонент обёрнут в React.memo, но Profiler показывает его ререндер при каждом рендере родителя. Его props: value (число) и onChange (инлайновая стрелочная функция в месте вызова). Почему memo не срабатывает?

Когда мемоизация — чистый убыток

Мемоизация никогда не бесплатна. Каждый useMemo/useCallback стоит: аллокация массива deps на каждом рендере, поэлементный проход Object.is на каждом рендере, слот кэша в памяти на всю жизнь компонента, а на первом рендере — строго медленнее (запись в кэш плюс сама работа). React.memo стоит поверхностный проход по props на каждом рендере родителя. Эти издержки малы — сравнение deps занимает десятки наносекунд, — но выгода бывает ещё меньше, и тогда вы сделали код медленнее и хуже читаемым:

  • Вычисление дешевле сравнения. useMemo(() => first + ' ' + last, [first, last]): конкатенация строк сравнима по цене или дешевле двух проверок Object.is плюс аллокации массива. PR из Hook с 47 useMemo был в основном этим — чистые накладные расходы с ярлыком «perf».
  • Deps всё равно меняются каждый рендер. Мемоизация с dep, которая сама пересоздаётся на каждом рендере (инлайновый объект, нестабильная функция от немемоизированного родителя), — это 0% попаданий в кэш: полная цена мемоизации, ноль сэкономленной работы. Это худший случай — он выглядит оптимизированным.
  • Пропускаемое поддерево тривиально. React.memo на компоненте из двух span экономит микросекунды на пропуск, платя проход по props на каждом рендере родителя. Мемоизация окупается, когда избегаемая работа велика: ререндер поддерева в сотни компонентов (миллисекунды), сортировка O(n log n) по тысячам строк, перерисовка графика с layout-трэшингом.

Процедура решения эмпирическая, а не эстетическая. Когда хочется рассыпать useMemo по компоненту — сначала открой React DevTools Profiler, запиши медленное взаимодействие и посмотри, какие коммиты медленны и какие компоненты реально съедают время. Мемоизируй доказанно дорогое и всю цепочку ссылок, питающую его, — и проверь в Profiler, что bailout (пропуск ререндера — буквально «ранний выход») теперь происходит. Непроверенный memo — это memo, который, скорее всего, сломан инлайновым prop где-то в цепи, как 3000 строк из Hook.

Почему это работает

Почему React вообще заставляет делать это руками — почему не сравнивать props структурно или не кэшировать всё автоматически? Структурное сравнение неограниченно: глубокое сравнение дерева props на каждом рендере может стоить дороже самого ререндера, а циклы и функции делают его плохо определённым. Поэтому React выбрал самую дешёвую проверку — идентичность ссылки, — а стабильность ссылок оставил вам. Это же решение объясняет, почему проблему может закрыть компилятор: пересоздаётся ли значение на каждом рендере — статически анализируемо по коду. React Compiler (стабильные релизы начались в 2025) делает ровно это — авто-мемоизирует значения, функции и JSX на этапе сборки, опираясь на те же Rules of React, от которых уже зависит ваш ручной memo, и убирает большую часть ручных useMemo/useCallback из нового кода. Честная рамка: это заявленное направление, и оно работает на реальных кодовых базах, но оно предполагает компоненты, соблюдающие правила, а понимание ссылочного равенства остаётся несущим — это то, что компилятор автоматизирует, то, что вы отлаживаете, когда bailout не происходит, и то, на чём всё ещё работает каждая существующая кодовая база.

Профилирование перед мемоизацией

Конкретный рабочий процесс, который переживает код-ревью. Раз: воспроизведите тормоза и запишите их в Profiler — flame chart, ranked chart, включённое «why did this render?». Два: найдите дорогой коммит и компоненты, доминирующие в нём; типичные находки — широкий список, перерисовывающийся целиком, или один компонент с тяжёлым вычислением в рендере. Три: чините причину — часто это реструктуризация (опустить состояние вниз, чтобы нажатие клавиши не рендерило каркас страницы; передать JSX как children, чтобы статичная часть создавалась вызывающим один раз и сохраняла идентичность), и только потом мемоизация. Четыре: где мемоизация — ответ, применяйте её цепочкой: memo на дорогом ребёнке, useCallback/useMemo на каждой ссылке, которую этот ребёнок получает, — потому что цепь с одним нестабильным звеном — вся цена без выгоды. Пять: перезапишите профиль и подтвердите bailout. Числа дашборда из Hook после работы в этом порядке: коммит на нажатие 38 мс → 4 мс, достигнуто удалением 40 из 47 useMemo, правильной мемоизацией props строк и выносом состояния фильтра из родителя таблицы.

Викторина

Коллега пишет useMemo(() => items.filter(isActive), [items]), где items — новый массив из немемоизированного селектора на каждом рендере. Чего добился этот useMemo?

Вспомните перед уходом
  1. 01
    Сформулируйте точно, что кэшируют useMemo, useCallback и React.memo, и единый механизм под ними.
  2. 02
    Назовите три ситуации, где мемоизация — чистый убыток, и рабочий процесс, который их избегает.
Итог

Три инструмента мемоизации кэшируют три разные сущности над одним механизмом. useMemo кэширует вычисленное значение и пересчитывает, только когда dep проваливает Object.is против предыдущего рендера; useCallback кэширует идентичность функции, не вызывая её, и является сахаром над useMemo, возвращающим функцию; React.memo поверхностно сравнивает props поэлементно и пропускает ререндер обёрнутого компонента при полном совпадении — не делая ничего с ререндерами от собственного состояния компонента или потребляемого контекста. Общий контракт — ссылочное равенство: горстка сравнений указателей, намеренно выбранная вместо структурного сравнения, потому что глубокое равенство — неограниченная работа. Хрупкость контракта следует напрямую: всё пересозданное во время рендера — новая ссылка, поэтому инлайновый объект, инлайновая стрелка или JSX children (свежий объект элемента каждый рендер) молча побеждают каждый memo ниже по течению, оставляя цену сравнения без выгоды, без предупреждений — только Profiler покажет перерисовку поддерева. Мемоизация также измеримый убыток, когда вычисление дешевле бухгалтерии deps, когда dep меняет идентичность каждый рендер (0% попаданий) и когда пропускаемое поддерево тривиально. Поэтому дисциплина эмпирическая: профилируйте медленное взаимодействие, сначала предпочитайте структурные лечения — опускание состояния вниз, чтобы нажатие клавиши рендерило меньше, передачу статичного JSX как children ради сохранения идентичности, — затем мемоизируйте доказанно дорогое поддерево вместе со всей цепью ссылок, питающей его, и перепрофилируйте, чтобы подтвердить bailout. React Compiler автоматизирует ровно это на этапе сборки для компонентов, соблюдающих правила, и является заявленным направлением, убирая большую часть ручных useMemo и useCallback из нового кода — но автоматизирует он тот же контракт ссылочного равенства, который вы отлаживаете при несработавшем bailout и на котором держится каждая существующая кодовая база. Теперь, когда видишь мемоизированный компонент, который всё равно перерисовывается — знаешь, куда смотреть первым делом: один инлайновый объект или стрелка где-то в цепи props, молча ломающий весь memo ниже.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 7 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.