Почему необдуманное ковровое покрытие проекта мемоизацией хуже, чем её полное отсутствие? Мемоизация не дешёвая! Она замедляет TTI проекта, поэтому её необдуманное использование может навредить. Давайте разберём пять принципов оптимизации и посмотрим, когда от мемоизации будет реальный профит, а когда от её использования лучше воздержаться.

Привет, Хабр! Меня зовут Нугзар Гагулия. У меня 10 лет коммерческого опыта в компаниях различного масштаба, в том числе в Яндекс и Альфа-банк. Я выступаю на Google I/O и Google Dev Fest, пишу статьи на Хабре, контрибьючу и менторю. Эта статья написана по мотивам моего доклада для FrontendConf 2022. Чтобы найти и задать вопросы об этой и других статьях, меня легко можно найти по нику NookieGrey в соцсетях и Телеграм. Я с удовольствием на них отвечу.

За последние два года я разработал порядка пяти проектов, и в каждом из них возникал вопрос мемоизации. Когда мы разрабатываем на React, всегда интересно, как используются хуки и React Memo. Где-то мы просто оборачивали компоненты, где-то — callback’и, хендлеры в useCallback, а где-то полностью покрывали всё мемоизацией, или даже игнорировали её. Каждый раз возникал вопрос, как работать с мемоизацией, что нужно оборачивать, а что нет.

Я часто слышал от коллег предложение оборачивать всё подряд и первое время соглашался с ними. Но на ревью задумался, действительно ли React устроен так, что требует любое действие оборачивать в useMemo, useCallback и memo? Я отправился гуглить и искать ответ на Хабр, а ещё пошел в Дискорд Реакта, создал тестовый проект и прогнал его. Затем отправился в реальные проекты и создал новые.  В этой статье я расскажу, что у меня из всего этого вышло:

  • Исходники useMemo;

  • Бенчмарки;

  • Когда использовать;

  • Когда не использовать;

  • Влияние на реальные проекты;

  • Принципы оптимизации.

Исходники useMemo

Начнем с исходников useMemo, залезем прямо в дебри React и посмотрим, как всё работает изнутри.

function useMemo(create, deps) {
  var dispatcher = resolveDtspatcher( );
  return dispatcher.useMemo(create, deps);
}
function resolveDispatcher() {
  var dispatcher = ReactCurrentDispatcher.current;
  if (dispatcher === null) {
   error('Hooks can only be called inside of the body of a function component.');
  }
  return dispatcher; 
}

В React.useMemo это вызов dispatcher. Мы проверяем, вызывается ли хук в процессе рендера. Как вы знаете, хуки можно вызывать только внутри компонентов, и здесь проверяется именно это. Дальше при вызове useMemo из dispatcher, срабатывает логика React. Код самого файла react.js на этом заканчивается, то есть useMemo вызывает dispatcher, который определяется в экспорте.

var ReactSharedInternals = {
  ReactCurrentDispatcher: ReactCurrentDtspatcher,
  //...
};
exports.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED = ReactSharedInternals;


//---> react-dom.js

Если посмотрим, где оно определяется в react-dom.js, то обнаружим инверсию зависимостей. Обычно, когда мы что-то импортируем, то пишем «импорт». Здесь мы экспортируем объект, у которого меняются свойства и возникает небольшая инверсия.

function renderWithHooks(current, ...) {
  if (current !== null && current.memoizedState !== null) {
  ReactCurrentDispatcher.current = HooksDispatcherOnUpdateInDEV;

  }else {
  ReactCurrentDispatcher.current = HooksDispatcherOnMountInDEV;
  }

}

В браузерном react-dom есть семь мест, где определяется dispatcher. Здесь мы видим только два из них: dispatchOnUpdate, dispatchOnMount. Сейчас мы не будем рассматривать глубокую логику работы с dispatcher. Но я хотел бы немного обратить ваше внимание на dispatchOnUpdate.

HooksDispatcherOnUpdatelnDEV = {
  useCallback: function (callback, deps) {/**/},
  useEffect: function (create, deps) {/**/},
  useMemo: function (create, deps) {
    currentHookNameInDev = 'useMemo';
    updateHookTypesDev();
    var prevDispatcher = ReactCurrentDispatcher.current;
    ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV;
    try {
      return updateMemo(create, deps);
    } finally {
      ReactCurrentDispatcher.current = prevDispatcher;
    }
  }
};

Он представляет набор хуков, в которых тоже происходит много разных проверок, разной логики. Но самое главное для нас это updateMemo, то есть функция, которая отвечает за саму мемоизацию: return updateMemo(create, deps);

Давайте залезем внутрь и посмотрим, что там происходит.

function updateMemo(nextCreate, deps) {
  var hook = updateWorkInProgressHook();
  var nextDeps = deps == undefined ? null : deps;
  var prevState = hook.nenoizedState;
  if (prevstate !== null && nextDeps !== null) {
    var prevDeps = prevState[1];
    if (areHookInputsEqual(nextDeps, prevDeps)) {
      return prevState[0];
    }
  }
  var nextValue = nextCreate();
  hook.memoizedState = [nextValue, nextDeps];
  return nextValues;
}

На самом деле, всё элементарно просто. Мы проверяем зависимости - deps.

   if (areHookInputsEqual(nextDeps, prevDeps)) {
      return prevState[0];
    }

Если они равны, то возвращаем мемоизированное значение. Если они отличаются, мы генерируем новое значение и возвращаем уже новый стейт.

  var nextValue = nextCreate();
  hook.memoizedState = [nextValue, nextDeps];
  return nextValues;

В принципе здесь много работы с dispatch и проверками. Обратите внимание на первую строчку — updateWorkInProgressHook. В ней мы рассмотрим, что из себя представляют хуки. 

В статьях про хуки часто упоминается, что хук — это элемент массива, и React обращается к нему по индексу. Но можете спокойно говорить, что это не так. Хук — это элемент линкед листа, то есть набор атрибутов, необходимых для работы самого хука, и ссылка на next.

function updateWorkInProgressHook() {
  var newHook = {
    memoizedState: currentHook.memoizedState,
    baseState: currentHook.baseState,
    baseQueue: currentHook.baseQueue,
    queue: currentHook.queve,
    next: null
  };
  if (workInProgressHook === null) {
    cerrentlyRenderingFiber$1.memoizedState = workInProgressHook = newHook;
  } else {
    workInProgressHook = workInProgressHook.next = newHook;
  }
  return workInProgressHook;
}

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

Мы конечно посмотрели не всю логику, не затронули много моментов про проверки, dispatch и т.п. 

function updateMemo{nextCreate, deps) {
  var hook = updateWorkInProgressHook();
  var nextDeps = deps === undefined ? null : deps;
  var prevState = hook.memoizedState;
  if (prevState !== null && nextDeps !== null) {
    var prevDeps = prevState[1];
    if (areHookInputsEqual(nextDeps, prevDeps)) {
      return prevstate[@];
    }
  }
  var nextValue = nextCreate();
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

Из-за этого можно сделать вывод, что хук сам по себе — updateMemo, useCallback, memo – не дешевые.

Я сделал тестовый бенчмарк проект и замерил в нём производительность.

Сначала не использовал мемоизацию вообще, и проект отрендерился за 138 миллисекунд. Затем постепенно начал добавлять мемоизацию, и проект увеличил время первого рендера.

Первый рендер — от 141 до 149 миллисекунд. Если мы обернём полностью весь наш проект в мемоизацию, то он будет рендериться 150 миллисекунд.

А это влияет на TTI – time to interaction, время, которое необходимо приложению до того, как оно будет готово к показу пользователю. TTI одна из самых важных метрик, на которые SEOшники обращают внимание. Важнее быстрее показать контент пользователю, чем сделать работу приложения более шустрой. Важнее первый рендер, чем апдейты.

Несмотря на то, что мемоизация часть оптимизации, React Memo заточен именно под последующие рендеры. То есть в ущерб первому рендеру и TTI мы увеличиваем скорость последующих рендеров. Поэтому каждый раз, когда мы будем использовать мемоизацию в React, стоит задуматься, стоит ли оно того.

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

Триплет — это полный набор из memo, useMemo и useCallback. На графике видно, что как бы мы ни использовали useMemo, апдейты никак не отличаются. Единственно магия происходит только когда мы полностью обернули весь наш компонент в мемоизацию. Если использовали memo, useMemo & useCallback на компоненте: в нижней строчке ноль, то есть компонент не перерендерился. Только в случае, если компонент обёрнут в memo, все props-ы обернуты в useMemo, а хендлеры обёрнуты в useCallback — работает мемоизация. 

Частично оборачивать хендлеры или только props-ы, или только компоненты не имеет никакого смысла. Апдейты будут всё равно происходить. После этого я пошел в реальный проект.

И замерил таймеры, бенчмарки и затем убрал memo из проекта полностью. С помощью переопределения базовой функции React.memo

Мы помним, что мемоизация работает в триплете. Если убрать один элемент, то мемоизация не будет работать. Изходя из этого вывода я просто убрал мемоизацию из проекта и сравнил.

Видно, что графики одинаковые, а значит необдуманное использование мемоизации не приносит профита.

Но я подумал, может, я что-то неправильно сделал и может неправильно убрал мемоизацию?

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

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

Сравнив замеры и получив результат, мы видим, что мемоизация существенно влияет для повторных рендеров только в одном из десяти случаев.

И эти данные доказывают то, что необдуманное полное покрытие проекта не приносит профита, зато увеличивает TTI.

Для того чтобы понять как правильно мемоизировать давайте поговорим о принципах оптимизации.

Пять принципов оптимизации

Изучив бенчмарки и реальные проекты, я пришел к пяти принципам мемоизации и оптимизации:

  1. Принцип необходимости. 

  2. Принцип ресурсов. 

  3. Принцип усложнения кода или спичек. 

  4. Принцип бутылочного горлышка. 

  5. Принцип вреда.

Принцип целесообразности

Представьте себе, что наше приложение рендерится 20 миллисекунд. Мы его оптимизировали, полностью покрыли мемоизацией и теперь оно рендерится 18 миллисекунд. 10% ускорения — вроде нормальная метрика. А давайте взглянем с другой стороны: глаз человека моргает 150 миллисекунд, то есть наше приложение успеет отрендериться вместо семи — восемь раз, пока человек моргнет. С этой стороны — так себе метрика. Поэтому первый принцип можно запомнить благодаря ассоциации с Дон Кихотом, который борется с ветряными мельницами. Это первый принцип потребности: нужна ли нам вообще мемоизация? Возьмите голый проект, уберите из него мемоизацию и посмотрите, будет ли он тормозить. React работает так быстро, что не будет. Я проверил на двух своих проектах.

Принцип ресурсов

Известное видео 86 400 долларов в день — про наши потребности, возможности, ресурсы. Нам нужно задумываться про масштабируемость, гибкость, отсутствие уязвимостей, сохранение пользовательских данных. Нам нужно задумываться про баги, пожелания пользователей, новые фичи, которые хочется внедрить. Наравне с этим находится оптимизация. Нам нужно задумываться про скорость показа нашего приложения. Кроме этого, нам нужно время на сон, отдых, дейлики, на митинги и на все это неприятное дело. Всем ведь хотелось бы разрабатывать 24 часа в сутки? :)

Поэтому нужно задумываться о том, готовы ли мы в ущерб этим факторам задумываться об оптимизации и мемоизации. Например, готовы ли мы оторвать кусок от архитектуры или от багфикса и погрузиться в оптимизацию, мемоизацию. Даже если готовы, стоит задуматься о том, что мемоизация добавляет дополнительную абстракцию, то есть усложняет наш код. 

Принцип усложнения кода или принцип спичек

Как говорил Дональд Кнут, 70-90% своего времени мы не должны задумываться о спичечной оптимизации, преждевременная оптимизация — это корень зла. Стив Макконнелл также говорил, что главный императив разработки ПО, это управление сложностью, то есть оборачивая весь наш проект в memo, мы увеличиваем сложность и нарушаем императив. Мы усложняем наш код, увеличиваем время ревью и поддержку проекта.

Допустим, мемоизация так нужна, что мы выделили на неё ресурсы и поняли, что она принесет больше пользы, чем вреда. С чего же нужно начать и чем завершить мемоизацию?

Принцип бутылочного горлышка

Вот пример: машины, КПП и 50 полос объединяются в четыре полосы. Так выглядит проблема оптимизации — нам не нужно расширять всю дорогу. Если вместо 50 полос станет 70 — водителям вряд ли станет легче :)

Думаю, что вы прекрасно знаете ответ на этот вопрос. Нам нужно задуматься о бутылочном горлышке. Если мы решили мемоизировать, оптимизировать, то самое главное правило — это правило бутылочного горлышка, что общая производительность приложения зависит от самого медленного компонента. Каждый раз, когда мы задумываемся об оптимизации, должны находить именно медленные компоненты.

Принцип выгоды

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

Существует психологическая ловушка, при которой люди соглашаются коврово покрывать проект мемоизацией, все компоненты, props-ы, думая, что ничего плохого от этого не будет. И что если мы покроем всё, то бутылочное горлышко тоже заденется. Может быть, будет профит, может быть, не будет, но не будет и вреда. Но это не так.

Мемоизация замедляет проект. Поэтому необдуманное использование мемоизации вредит. Самое главное в оптимизации — это не абстрактность кода, хотя часто думают, что так код будет работать быстрее. По факту в среде, в которой запускается код — происходит иначе. Реальные бенчмарки сильно отличаются от абстрактной логики. Если есть потребность задуматься об оптимизации, мемоизации и вы прошли по всем пунктам, обязательно используйте замеры и профайлер. В React есть встроенный инструмент для замера рендера, который называется Profiler. Покройте проект Профайлером, найдите узкие места, оптимизируйте и получите профит на практике, оптимизация в теории — это зло.

Когда React-мемоизацию не стоит использовать

Использовать не нужно в двух случаях:

  • когда нужно полностью покрыть проект мемоизацией

  • когда нужно предотвратить избыточный рендер

1) Для коврового покрытия проекта. Есть отличный твит от Дэна Абрамова. У него спрашивают,  «Почему бы не занести React.memo внутрь библиотеки по умолчанию? Не ускорит ли это рендер? Может нужно провести бенчмарки?». А он отвечает: «Почему вы не используете Lodash memoize (в разработке не на React) для каждой функции? Не ускорит ли это их? Нужны ли нам для этого бенчмарки? Действительно?».

2) Ниже хороший пример, где хочется обернуть useCallback, но мы этого делать не будем:

export function Page() {
  const [state, setState] = useState(0);
  const handler = (event) => {
    setState(event.target value)
  }
  return (
    <Form>
       <Input volue={state} onChange={handler}/>
       {/*...*/}
    </Form>
  )
}

Ведь input сам по себе очень быстро перерендеривается, а стейт и так меняется. Здесь нет никакой пользы от мемоизации. Это пример, когда не нужно использовать мемоизацию для предотвращения избыточного рендера для коврового покрытия.

Когда использовать React мемоизацию

  • В зависимостях useEffect;

  • Для дорогих вычислений;

  • Для высоконагруженных компонентов.

useEffect приходит в голову первым. На самом деле, это 99% случаев, когда нужно использовать мемоизацию. Давайте посмотрим на примере: 

import {createContext, useCallback, useEffect} from “react”;

const Context = createContext({});

export function NotifyContext({children}) {
  const notify = useCallback(() => {
    //...
  },[]);
  return (
    <Context Provider value={{notify}}>
      {children}
    </Context.Provider>
  )
}

Мы видим обычный контекст с нотификацией, которая пробрасывает функцию вниз по дереву. Предположим, что есть какой-нибудь alert или модалка. Мы можем использовать нотификацию в отвалившемся запросе, в useEffect, асинхронном сайд эффекте и т.д.

import {useContext} from “react";

export function Component() {
  const {notify} = useContext(Context);

  useEffect(() => {
    fetch()
      .then(() => {
      //...
    })
      .catch(error => {
      notify(error.message)
    })
  }, [notify])
}

Чтобы пробросить нотификацию в useEffect, нам нужно добавить её в зависимости. А чтобы всё работало правильно по правилам React, мы должны обернуть функцию в useCallback. Отличный пример, когда его можно использовать.

Супердорогие вычисления. Предположим, нам нужно посчитать десятитысячные или миллионные элементы числа Фибоначчи.

export function Component(count) {
  const fibonacci = useMemo(() => {
    calculate(count);
  }, [count];

  return (
    <>
      { /* .. */}
    </>
  )
}

Это классика мемоизации, ведь в этом случае она должна работать по стандартам. Если у нас супердорогие вычисления, мы оборачиваем их в useMemo.

Самый интересный момент — это очень дорогое дерево для рендера.

const VeryExpensiveTree = memo(VeryExpensiveTreeComponent ) ;

export function Page() {
  const [state, setState] = useState(0);

  const handler = useCallback((event) => {
    setState(event.target.value)
}, [])

return (
  <Wrapper>
    <OtherComponent state={state}/>
    <VeryExpensiveTree handler={handler}/>
  </Wrapper>
)}

Какое-то дерево затрачивает много времени на рендер. Хочу обратить ваше внимание на обёртку компонента в memo. И все props-ы — в useCallBack и в useMemo.

Выводы

Мемоизация недешевая. Она работает только тогда, когда мы оборачиваем компонент в memo, и когда все porps-ы обёрнуты в useMemo и useCallback.

Мемоизацию можно не использовать. Если вы не разработчик библиотек, если у вас не суперсложные компоненты с кучей графиков, анимаций, чартов, можете просто забыть про мемоизацию в React.

Комментарии (0)