И снова о useCallback

Привет, Хабр! Так вышло, что на текущем месте работы я попал под сокращение, а значит путь к собеседованиям открыт. Как раз вчера случилось одно (видимо, из многих), на котором зашла речь про useCallback.

Предыстория

Изначально собеседующих было двое. Во время теории, когда меня спросили про хук useCallback, я ответил, что его использование имеет смысл только тогда, когда функция передаётся из родителя в дочерний компонент, а сам дочерний компонент обёрнут в memo. В таком случае ссылка на функцию из пропсов, обёрнутую в useCallback, останется неизменной, если родитель был перерисован, и мы избежим лишней перерисовки дочернего компонента. Собственно, данный вопрос даже на Хабре разбирался неоднократно, в том числе с залезанием в исходники (например, вот). Здесь следует понимать, что даже если мы всё сделали так, как написано выше, но дочерний компонент принимает прочие аргументы (помимо мемоизированной функции), и эти прочие аргументы изменились — всё, ваш useCallback из родителя официально бесполезен. Уже на таком этапе. И вроде бы двое собеседующих со мной согласились, но следом прозвучал вопрос «а вы использовали useCallback в проектах?», что говорит о том, что моя трактовка посчиталась ошибочной. Как оказалось, с пониманием использования этого хука проблемы куда глубже

Конфликт

В конце интервью начался лайвкодинг, в процессе которого подключился ещё один разработчик. По окончании процесса написания кода, когда начались уточняющие вопросы, свежепришедший человек спросил «а почему обработчики не в useCallback?». Мы рендерили простой jsx, внутри которого не было компонентов, которые принимают на вход хоть что-то — только дивы и несколько кнопок, на которые вешались те самые обработчики без использования мемоизации. На мой вопрос «а зачем», прозвучал ответ «но функции же создаются заново!». Да, создаются, в случае, если ваш компонент, в котором эти функции объявляются, перерисовывается. Правда тогда вашей главной проблемой и основной статьёй расходов ресурсов становится отнюдь не пересоздание функции, а как раз перерисовка компонента. На вопрос, какой перфоманс мы получим от оборачивания обработчиков в useCallback именно в данном случае, собеседующий ответить не смог

Отказ

В тот же день, вечером, от HR компании приходит отказ, причиной которого стали 3 пункта:

  • замыкания (1.5 года не был за собесах, а на практике использовал их крайне редко)

  • погружение (честно не знал, что в синтетических ивентах нельзя его перехватывать)

  • слабое понимание хуков

И последний пункт стал триггером) помимо хука-героя истории, мы бегло обсуждали useState, useRef и useMemo — никаких проблем не было, собеседующие во всём со мной согласились. Да и нечего там особо трактовать двояко. А значит причиной появления этого пункта стал разговоры про useCallback, причём расклад получается следующий: это у собеседующих какие-то странные представления о том, когда нужно применять хук, но слабое понимание хуков у меня. Такие дела)

Я честно засомневался -, а вдруг действительно затраты на создание новых функций при каждом рендере не такие уж и крохотные, как мне кажется? Давайте разберёмся

Тесты

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

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

const someArray = [];
for (let i = 0; i < 8000; i++) {
  someArray.push(i);
}
// получаем массив из 8 тысяч элементов

Дальше, с помощью блокирующего useLayoutEffect в родителе я получаю текущее время в миллисекундах через new Date ().getTime () и кладу его в ref. Следом срабатывает уже обычный useEffect, тоже получает текущее время и выводит в консоль разницу между временем из рефа и текущим. таким образом мы узнаём приблизительное время первоначального рендера

useLayoutEffect(() => {
    const now = new Date().getTime();
    time.current = now;
  }, []);

  useEffect(() => {
    const now = new Date().getTime();
    console.log(`time is ${now - time.current}`);
  }, []);

Настало время выхода главного гостя, useCallback! Создаю компонент-кнопку с обработчиком handleClick внутри, называю его ItemWithoutCallback

export const Item = ({ value }) => {
  const [state, setState] = useState(value);

  const handleClick = () => {
    const x = value * value;
    const y = value + value;
    const z = x * y * value;
    const result = z + x;
    setState((prev) => prev - result);
  };

  return ;
};

Специально наплодил немного бесполезных операций и переменных, чтобы было, чему создаваться заново и занимать память. Следом создаю компонент ItemWithCallback, куда копирую код из ItemWithoutCallback и оборачиваю handleClick в useCallback с пустым массивом зависимостей

export const Item = ({ value }) => {
  const [state, setState] = useState(value);

  const handleClick = useCallback(() => {
    const x = value * value;
    const y = value + value;
    const z = x * y * value;
    const result = z + x;
    setState((prev) => prev - result);
  }, []);

  return ;
};

Чтобы все мои 8к кнопок перерисовывались — завожу локальный стейт в родителе, для изменения которого создаётся отдельная кнопка. А также немного дорабатываю useEffect, чтобы можно было трекать не только первоначальный рендер, но и последующие

const [state, setState] = useState(0);
  const time = useRef();

  useLayoutEffect(() => {
    const now = new Date().getTime();
    time.current = now;
  }, []);

  useEffect(() => {
    // первый лог - время первоначального рендера
    // все последующие логи - время на перерисовку
    const initialOrUpdate = state === 0 ? "Initial render" : "Update";
    const now = new Date().getTime();
    console.log(`${initialOrUpdate} time is ${now - time.current}`);
  }, [state]);

  const updateState = () => {
    setState((prev) => (prev += 1));
    const now = new Date().getTime();
    time.current = now;
  };

Ну и наконец мапим наш большой массив в том же родителе, чтобы приступить к тестам

 return (
    

Usecallback hook test

Click to 'Update state' and check log's

{someArray.map((value) => ( // key, завязанный на state - это чтобы ну прям точно всё перерисовалось ))}
);

Результаты

Пусть первым тестируется ItemWithCallback, мы же здесь за этим.

Минимальное время первоначального рендера 132ms, максимальное 197 ms.

Минимальное время перерисовки 8к кнопок 46ms, максимальное 71ms.

Переходим к тестам ItemWithoutCallback, заменив один импорт другим.

Минимальное время первоначального рендера 127ms, максимальное 195 ms.

Разница на уровне погрешности.

Минимальное время перерисовки 8к кнопок 46ms, максимальное 65ms.

c09c76be4433af3a07b8d0062c0ce64c.png

Снова разница на уровне погрешности. И это для 8к элементов на странице, каждый из который гарантировано перерисовывается!

Выводы

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

Тем, кто собеседует кого-либо — пожалуйста, хоть немного сомневайтесь в своих «знаниях», если сталкиваетесь с противоположным мнением. Всем добра :-)

© Habrahabr.ru