Привет, Хабр! Так вышло, что на текущем месте работы я попал под сокращение, а значит путь к собеседованиям открыт. Как раз вчера случилось одно (видимо, из многих), на котором зашла речь про 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 <button onClick={handleClick}>{state}</button>;
};

Специально наплодил немного бесполезных операций и переменных, чтобы было, чему создаваться заново и занимать память. Следом создаю компонент 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 <button onClick={handleClick}>{state}</button>;
};

Чтобы все мои 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 (
    <div className="App">
      <h1>Usecallback hook test</h1>
      <h2>Click to 'Update state' and check log's</h2>
      <button onClick={updateState}>Update state</button>
      <div>
        {someArray.map((value) => (
          // key, завязанный на state - это чтобы ну прям точно всё перерисовалось
          <Item value={value} key={value + state} />
        ))}
      </div>
    </div>
  );

Результаты

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

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

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

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

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

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

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

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

Выводы

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

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

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


  1. pkolt
    29.04.2024 13:03
    +9

    Иногда useCallback и useMemo используется не для оптимизации как таковой, а чтобы избежать повторных вызовов с useEffect. Например если вы передаете handler в дочерний компонент, а в дочернем этот handler используется в useEffect. Без useCallback ваш handler каждый раз будет новым, что приведет к повторным вызовам useEffect.

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


  1. TerraV
    29.04.2024 13:03
    +10

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


    1. funca
      29.04.2024 13:03
      +4

      но функции же создаются заново!!

      Функции создаются заново в любом случае. useCallback отбрасывает новый экземпляр и возвращает предыдущий, если зависимости не изменились. Это почи, что базовый JS.

      По тексту похоже, что они с собеседующим просто не поняли друг друга.


      1. gen1lee
        29.04.2024 13:03

        По тексту очевидно, что автор статьи действительно не понимает этого, а значит и самых базовых принципов работы хуков. Собеседующий так и не смог добиться от автора статьи правильного объяснения почему там useCallback не нужен, даже "забайтив" фразой/вопросом "ну функции же создаются заново".

        PS. Количество лайков статьи многое говорит об аудитории хабра.


  1. Chamie
    29.04.2024 13:03

    А почему new Date().getTime() а не performance.now()?


    1. KarRis
      29.04.2024 13:03

      А в чем разница?


      1. victor-homyakov
        29.04.2024 13:03
        +1

        Разница в точности измерений (гранулярности значений) в некоторых браузерах.

        performance.now():

        • в Chrome даёт точность до десятых долей миллисекунды

        • в Node.js - до сотых

        • в Firefox и Safari - до миллисекунды

        Date.now() даёт точность до миллисекунды (как и требует спецификация).

        Некоторые браузеры, позиционирующие себя как безопасные, раньше на хайпе атак Spectre и Meltdown специально ухудшали точность замеров и performance.now() и Date.now() до пяти миллисекунд. Сейчас не знаю, давно не проверял.


      1. Chamie
        29.04.2024 13:03

        Счётчик performance.now() именно под бенчмарки производительности заточен. Не зависит от часовых поясов, засыпания тредов и прочего, плюс, должен давать бо́льшую точность, если его специально не загрубили для защиты от Side Channel атак.


  1. RuGrof
    29.04.2024 13:03

    Обычно мнение о useCallback делится на до и после коммита с оборачиванием компонентов для какой нибудь виртуализации. Чтоб намекнуть людям, что такая проблема как-бы есть, сейчас допиливают React Compiler который автоматически будет это делать.

    https://react.dev/blog/2024/02/15/react-labs-what-we-have-been-working-on-february-2024


  1. dan_sw
    29.04.2024 13:03
    +4

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

    Перехватить синтетическое событие на этапе погружения можно. Это делается с помощью добавления постфикса Capture в атрибут обработчика какого-либо события в JSX.

    Например, следующий код перехватывает событие клика на этапе погружения и всплытия:

    // ...
    <div className="wrapper">
          <div
            className="container"
            onClickCapture={(e) => {
              console.log("Погружение");
            }}
            onClick={(e) => {
              console.log("Всплытие");
            }}
          ></div>
    </div>
    // ...

    Ну и в консоли браузера вывод будет правильный:

    Подробнее о SyntheticEvent можно почитать здесь: https://ru.react.js.org/docs/events.html

    Рекомендую автору поправить этот момент, т.к. синтетические события на этапе погружения обрабатывать (или перехватывать) можно.


  1. victor-homyakov
    29.04.2024 13:03

    Вы же понимаете, что замеряли скорость работы development-сборки реакта и что в production-сборке результаты измерений будут другими?

    В development-сборке есть дополнительные проверки, они занимают дополнительное время, и на этом фоне искомая разница в один вызов useCallback на один ReactElement может быть незаметна. Пример - профайл десяти нажатий на кнопку "Update state" в приложении по вашей ссылке в Chrome. useCallback там есть, но он практически незаметен:


  1. wxe
    29.04.2024 13:03
    +1

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


    1. gen1lee
      29.04.2024 13:03

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


  1. Armason
    29.04.2024 13:03

    Эх, классика. К сожалению, кроме дефолтных eventLoop/closures/async js на каждом первом собесе Вас еще ждут и другие приключения (помимо стандартных заблуждений насчет пользы повсеместного использования useMemo/useCallback). Собеседующие, которые будут спрашивать про разницу между const/let/var и всецело ожидать ответа в духе "var не нужен, он deprecated, а let и конст это круто" (утрируя). На этом этапе, если рассказать про overhead от обслуживания TDZ, обычно круглые глаза. Да, на проекте девелопер не должен писать вары руками, но это явно не "deprecated мусор" и в теории на определенных проектах можно подумать о транспайле в вары, например на этапе билда проекта.
    Вот интересный issue по этой теме - https://github.com/microsoft/TypeScript/issues/52924.
    Вообще, мне кажется, каждый для себя должен выделить перечень "ред флагов" на собеседовании, после которых уже можно сделать выводы о компетенции самих собеседующих либо об их подготовке к этому самому собеседованию. Иногда такое ощущение, что перед собесом просто в гугле забивается "топ 20 вопросов по js" и дело с концом.
    Автору желаю терпения и в конце концов найти хорошую команду.


  1. tyrus
    29.04.2024 13:03
    +1

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