Как стать автором
Обновить

Какая настоящая цена useMemo?

JavaScript *ReactJS *

Привет, Хабр!

В одной из предыдущих моих статей “Все ли вы знаете о useCallback?” мы оценивали когда стоит использовать useCallback, а когда это избыточно. Та статья вызвала большой интерес, поэтому было решено сделать похожую статью на тему: "когда стоит использовать менее популярный хук useMemo, а когда не стоит" (данная статья является расшифровкой видео).

Пишем конвертер валют

Чтобы анализ был нагляднее, начнем как всегда с примера. Допустим мы хотим написать конвертер валют. Мы вводим доллары, и автоматически получаем ту же сумму в евро, русских рублях, белорусских рублях и гривнах.

Давайте набросаем такой компонент. Введенное значение в долларах будем хранить в state. Потом передаем его в кастомный хук и от туда уже получаем все остальные значения валют. Останется дело за малым, сверстать интерфейс для пользователя:

const Converter = () => {
  const [dollars, setDollars] = useState(0);
  
  const { euros, rubles, belRubles, hryvnia } = useConverte(dollars);

  // ...
} 

Но нас больше интересует не компонент, а сам хук расчета валют. Поэтому рассмотрим имплементацию кастомного хука.

export default (dollars) => {
  const euros = (dollars * DOLLARS_TO_EUROS_RATE).toFixed(2);
  const rubles = (dollars * DOLLARS_TO_RUBLES_RATE).toFixed(2);
  const belRubles = (dollars * DOLLARS_TO_BEL_RUBLES_RATE).toFixed(2);
  const hryvnia = (dollars * DOLLARS_TO_HRYVNIA_RATE).toFixed(2);

  return {
    euros,
    rubles,
    belRubles,
    hryvnia,
  };
};

Как мы видим, здесь целый ряд примитивных операций конвертации одной валюты в другую.

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

export default (dollars) => {
  return useMemo(() => {
    const euros = (dollars * DOLLARS_TO_EUROS_RATE).toFixed(2);
    const rubles = (dollars * DOLLARS_TO_RUBLES_RATE).toFixed(2);
    const belRubles = (dollars * DOLLARS_TO_BEL_RUBLES_RATE).toFixed(2);
    const hryvnia = (dollars * DOLLARS_TO_HRYVNIA_RATE).toFixed(2);

    return {
      euros,
      rubles,
      belRubles,
      hryvnia,
    };
  }, [dollars]);
};

Изменение небольшое и читабельность сильно не пострадала. Ну и самая главная цель - улучшить перфоманс компонента, вроде как достигнута.

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

Учимся считать

Для того чтобы, что-то измерить, нужны какие-то единицы измерения. Для того чтобы их обозначить, рассмотрим следующий пример:

let text = 'Hello world';

Его можно разделить на целых 3 базовых элемента:

  • Создание переменной let text, мы можем посчитать кол-во созданных.

  • Операция присваивания =, также легко поддается счету.

  • Выделение памяти 'Hello world', в нашем случае память для хранения строки.

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

Учимся выделять память

Я остановился на моменте выделения памяти. Чтобы лучше понять как она выделяется, я нашел статью на MDN “Memory Management”. Там, в примере, хорошо показано, когда выделяется память:

var n = 123; // allocates memory for a number
var s = 'azerty'; // allocates memory for a string

var o = {
  a: 1,
  b: null
}; // allocates memory for an object and contained values

// (like object) allocates memory for the array and
// contained values
var a = [1, null, 'abra'];

function f(a) {
  return a + 2;
} // allocates a function (which is a callable object)

Интересно, что когда создается объект выделяется память под сам объект и отдельно под каждое его свойство:

var x = {
  a: {
    b: 2
  }
};
// 2 objects are created. One is referenced by the other as one of its properties.
// The other is referenced by virtue of being assigned to the 'x' variable.
// Obviously, none can be garbage-collected.

Точно такая же логика и с массивом, каждое его значение, это новая ячейка в памяти. Если интересно можете более подробно изучить статью по ссылке на MDN.

Оставшиеся критерии

А мы перейдем к оставшимся единицам измерения:

  • количество if конструкций

  • количество алгоритмических операция * / + - Math.round() .toFixed(2)

Итоговые базовые единицы измерения

Итого у нас получилось целых 5 базовых величин:

  • Количество переменных

  • Количество присваиваний

  • Количество раз выделения памяти

  • Количество if конструкций

  • Количество алгоритмических операций

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

Начнем считать!

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

Разные сценарии рендера компонента

Как вы знаете из моей предыдущей статьи “Первое погружение в исходники хуков”, под одним хуком useMemo скрыты две функции mountMemo и updateMemo. Таким образом можно насчитать три разных сценария.

  1. Первый рендер, когда в любом случае произойдет вычисление валют и при этом выполнится функция mountMemo -> calculateCurrencies() + mountMemo()

  2. Обновление значения в долларах. В этом случае снова произойдет пересчет валют и выполнится функция updateMemo ->calculateCurrencies() + updateMemo()

  3. Рендер произошел по какой-либо другой причине и updateMemo вернул мемоизированный результат -> updateMemo()

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

Дисклеймер!

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

Считаем код вычисления валют

Начнем с кода вычисления валют. Напомню он выглядит следующим образом:

const euros = (dollars * DOLLARS_TO_EUROS_RATE).toFixed(2);
const rubles = (dollars * DOLLARS_TO_RUBLES_RATE).toFixed(2);
const belRubles = (dollars * DOLLARS_TO_BEL_RUBLES_RATE).toFixed(2);
const hryvnia = (dollars * DOLLARS_TO_HRYVNIA_RATE).toFixed(2);

Здесь мы видим создание 4-ех переменных, 4-ре присваивания, 8-ем алгебраических операций (4-ре умножения и 4-ре округления). И т.к. мы работаем с примитивами, это значит после всех операций выделяется память под вычисленные значения разных валют.

Итог следующий:

  • 4 - Количество переменных

  • 4 - Количество присваиваний

  • 4 - Количество раз выделения памяти

  • 0 - Количество if конструкций

  • 8 - Количество алгоритмических операций

На первый взгляд кажется, что количество действий действительно большое, но давайте теперь рассмотрим сколько стоит хук updateMemo.

Считаем updateMemo

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

useMemo(() => { ... }, [...]); // 2 раза выделели память

Дальше уже пойдем в исходники updateMemo. Метод updateMemo вы найдете в пакете react-reconcilier в файле ReactFiberHooks.new.js:

function updateMemo<T>(
  nextCreate: () => T,
  deps: Array<mixed> | void | null,
): T {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  if (prevState !== null) {
    // Assume these are defined. If they're not, areHookInputsEqual will warn.
    if (nextDeps !== null) {
      const prevDeps: Array<mixed> | null = prevState[1];
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        return prevState[0];
      }
    }
  }
  const nextValue = nextCreate();
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

Здесь начать хочется с параметров, мы передали сюда функцию первым параметром и массив вторым, а это значит что мы создали две переменные и положили в них ссылки на функцию и на массив:

function updateMemo<T>(
  nextCreate: () => T, // создали переменную
  deps: Array<mixed> | void | null, // создали переменную
): T { ... }

Сократим подсчеты

Я уже представляю ваши мысли: "И что он сейчас будет писать портянку текста как считал каждую строчку кода". Я не буду описывать текстом, как же все это считал, я покажу вам сразу результаты промежуточных расчетов. Я называю расчеты промежуточными, т.к. я остановил свои подсчеты на изучении функции updateWorkInProgressHook() и даже не считал сколько внутри обходится использование функции invariant(), т.к. мне показалось этих цифр достаточно, чтобы понять суть происходящего.

Промежуточные результаты следующие:

  • 6 - Количество переменных

  • 7 - Количество присваиваний

  • 3.75 - Количество раз выделения памяти (значения не целые из-за if конструкция)

  • 4.5 - Количество if конструкций (значения не целые из-за if конструкция)

  • 0 - Количество алгоритмических операций

А если вам интересно проследить как я считал, смотрите видео привязанное к минуте

Сравниваем промежуточные результаты

Как вы видите, даже на текущем этапе эти цифры почти одинаковые, а если мы продолжим подсчеты, тогда updateMemo будет становится все дороже и дороже.

Таким образом можно прийти к выводу, что вычислить значение валют скорей всего дешевле, чем получить мемоизированое значение из useMemo

А я вам напомню, что это казался самым выгодным вариантом из трех:

  1. calculateCurrencies() + mountMemo()

  2. calculateCurrencies() + updateMemo()

  3. updateMemo()<- Вот этот

А насколько тогда получается невыгодным первый рендер или рендер, когда значение долларов меняется. И самый смешной случай получится, если у нас компонент рендериться, только лишь по одной причине, когда значение доллара меняется, а это значит, что useMemo никогда не вернет мемоизированное значение.

Итоги:

  1. Удостоверьтесь, что ваши вычисления действительно настолько тяжелые, что их стоит завернуть в useMemo

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

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

P.S.

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

Теги:
Хабы:
Всего голосов 5: ↑4 и ↓1 +3
Просмотры 11K
Комментарии Комментарии 9