Привет, Хабр!
В одной из предыдущих моих статей “Все ли вы знаете о 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
ухудшает перфоманс, но не отвечает почему именно так происходит. Поэтому, этот странный подход с подсчетом переменных был выбран, чтобы более явно показать, что мемоизация отнюдь не бесплатная и построчно рассмотреть, как мы платим за мемоизацию.
Apathetic
Мемоизировать крохотный O(1) алгоритм — это genius.jpg конечно
hahenty
Ну это для текущего момента О(1). А для «исторических» запросов на построение трендов будет уже O(random) (ну там бэкенд отвалился, источник курсов валют тормозит) — тут уж помемоизировать неплохо, что-нибудь.
justboris
Текущая реализация работает на клиенте. Чтобы перенести запросы на сервер нужно писать отдельную обвязку и делать все асинхронным. useMemo тут преждевременная оптимизация в чистом виде.
Fi1osof
Как правило, мемоизируется не для того, чтобы уменьшить нагрузку для высчитывания внутри текущего компонента (хотя часто и для этого то же), а для того, чтобы во вложенные компоненты передавать одну и ту же переменную, а не каждый раз высчитанную заново, чтобы предотвратить ререндер вложенных компонентов.