[JavaScript, ReactJS] Какая настоящая цена useMemo?

Автор Сообщение
news_bot ®

Стаж: 6 лет 9 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
02-Мар-2021 11:31

Привет хабр!В одной из предыдущих моих статей “Все ли вы знаете о 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. Таким образом можно насчитать три разных сценария.
  • Первый рендер, когда в любом случае произойдет вычисление валют и при этом выполнится функция mountMemo -> calculateCurrencies() + mountMemo()
  • Обновление значения в долларах. В этом случае снова произойдет пересчет валют и выполнится функция updateMemo ->calculateCurrencies() + updateMemo()
  • Рендер произошел по какой-либо другой причине и 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А я вам напомню, что это казался самым выгодным вариантом из трех:
  • calculateCurrencies() + mountMemo()
  • calculateCurrencies() + updateMemo()
  • updateMemo()<- Вот этот
А насколько тогда получается невыгодным первый рендер или рендер, когда значение долларов меняется. И самый смешной случай получится, если у нас компонент рендериться, только лишь по одной причине, когда значение доллара меняется, а это значит, что useMemo никогда не вернет мемоизированное значение.Итоги:
  • Удостоверьтесь, что ваши вычисления действительно настолько тяжелые, что их стоит завернуть в useMemo
  • Если все причины рендера текущего компонента указаны в зависимостях useMemo, тогда использование useMemo избыточно, так как тяжеловесная функция будет и так выполняться при каждом рендере.
  • Не забывайте, в этом выпуске, мы считали спички, для расширения кругозора, и если у вас политика на проекте не смотря ни на что все мемоизировать, я думаю, что это не скажется на перфомансе вашего проекта. Помните об этом!
P.S.Хорошим дополнением к этой статье могли бы быть бенчмарки, но банчмарки могут лишь доказать, что действительно использование useMemo ухудшает перфоманс, но не отвечает почему именно так происходит. Поэтому, этот странный подход с подсчетом переменных был выбран, чтобы более явно показать, что мемоизация отнюдь не бесплатная и построчно рассмотреть, как мы платим за мемоизацию.
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_javascript, #_reactjs, #_react, #_hook, #_hooks, #_memoization, #_javascript, #_reactjs
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 22-Ноя 14:44
Часовой пояс: UTC + 5