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

Начиная с версии ReactJS 16.8 в наш обиход вошли хуки.  Этот функционал вызвал много споров, и на это есть свои причины. В данной статье мы рассмотрим одно из самых популярных заблуждений использования хуков и заодно разберемся стоит ли писать компоненты на классах (данная статья является расшифровкой видео).

Два пути

Как вы знаете в реакте есть 2 вариант написания компонента, с помощью классов и с помощью функций. И каждый вариант по своему взаимодействует с методами. Давайте рассмотрим оба варианта:

Метод в классе

Первый вариант, это использовать классы:

class Test extends Component {
  onClick = () => {
    console.log('onClick');
  }

  render() {
    return (
      <button onClick={this.onClick}>
        test
      </button>
    )
  }
}

В данном варианте мы добавили метод onClick классу Test и при создании инстанса класса, этот метод создается 1 раз и в рендере мы уже используем ссылку на этот метод onClick={this.onClick}, таким образом при каждом рендере мы обращаемся всегда к одной и той же ссылке и не пересоздаем метод класса. Эта конструкция всем, кто давно в профессии, привычна и понятна даже если вы недавно пришли в React с другого языка программирования.

Метод в функции

Второй способ создания компонента является использование функции:

const Test = () => {
  const onClick = () => {
    console.log('onClick');
  }
  
  return (
    <button onClick={onClick}>
      test
    </button>
  )
}

В таком подходе, чтобы создать обработчик onClick, мы описываем тело функции прямо внутри render, потому что все тело функции и есть render, другого варианта в принципе не существует, если вы хотите использовать props.

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

Классы лучше чем функции?

Чтобы разобраться с этим вопросом я полез в React документацию в секцию вопросы и ответы и нашел там следующий вопрос:

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

Кажется есть один "вариантик" сэкономить

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

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

Викторина!

Сейчас мы рассмотрим 2 примера и Вы попытаетесь ответить кто круче!

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

const Test = ({ title }) => {
  const someFunction = () => {
    console.log(title);
  }
  
  return (
    <button onClick={someFunction}>
      click me!
    </button>
  )
}

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

const Test = () => {
  const someFunction = useCallback(() => {
    console.log(title);
  }, [title])
  
  return (
    <button onClick={someFunction}>
      click me!
    </button>
  )
}

Для пользователя ничего не изменилось, console.log(title), точно так же вызывается при нажатии на кнопку.

Внимание вопрос

В каком из вариантов написания компонента функция присваемая переменной someFunction создается реже?

 

Даем минутку подумать...

 

 

 

 

Аккуратно ответ!

 

 

Ответ

И правильный ответ ни в каком! Да именно, никакой оптимизации useCallback нам не дал, функция создается ровно столько же раз, как и до оптимизации. Более того, мы наоборот ухудшили перфоманс нашего компонента.

Разбираем ответ

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

Естественно мы получим результат function. По синтаксису это было очевидно. Чтобы понять как работает черный ящик, давайте сами напишем имплементацию useCallback.

Пишем свой useCallback

useCallback - это функция, которая принимает 2 параметра, callback и deps.

function useCallback (callback, deps) {

}

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

const prevState = {
  callback: null,
  deps: null,
}

function useCallback(callback, deps) {

}

Теперь рассмотрим разные случаи: если deps не существует либо в prevState, либо в новых данных, тогда нужно сохранить текущие параметры и вернуть callback.

const prevState = {
  callback: null,
  deps: null,
}

function useCallback(callback, deps) {
  if (!prevState.deps || !deps) {
    prevState.callback = callback;
    prevState.deps = deps;
    
    return callback;
  }

}

Если же deps ��уществуют. Тогда сравниваем какой-либо функцией массивы и если они совпадают, тогда возвращаем мемоизированную функцию.

const prevState = {
  callback: null,
  deps: null,
}

function useCallback(callback, deps) {
  if (!prevState.deps || !deps) {
    prevState.callback = callback;
    prevState.deps = deps;
    
    return callback;
  }
  
  if (shallowEqual(deps, prevState.deps)) {
    return prevState.callback;
  }
  
}

Ну и если deps не совпадают, тогда снова сохраняем параметры и возвращаем текущий callback.

const prevState = {
  callback: null,
  deps: null,
}

function useCallback(callback, deps) {
  if (!prevState.deps || !deps) {
    prevState.callback = callback;
    prevState.deps = deps;
  
    return callback;
  }
  
  if (shallowEqual(deps, prevState.deps)) {
    return prevState.callback;
  }
  
  prevState.callback = callback;
  prevState.deps = deps;
  
  return callback;
}

Вроде бы мы покрыли все кейсы

Какие выводы из этого мы можем сделать?

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

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

const Test = ({ title }) => {
  const callback = () => {
    console.log(title);
  }
  
  const deps = [title];
  
  const someFunction = useCallback(callback, deps);
  
  return (
    <button onClick={someFunction}>
      click me!
    </button>
  )
}

Тут становится совсем очевидно, что мы на каждый рендер создаем не то что функцию, а еще и массив с зависимостями, а потом еще и прокручиваем все это через useCallback.

Если мы просто закомментируем создание зависимостей и вызов useCallback и передадим параметр callback напрямую в onClick, тогда кажется перфоманс компонента должен улучшиться, ведь мы убрали посредника, который не нес никакой пользы для перфоманса.

const Test = ({ title }) => {
  const callback = () => {
    console.log(title);
  }
  
  // const deps = [title];
  
  // const someFunction = useCallback(callback, deps);
  
  return (
    <button onClick={callback}>
      click me!
    </button>
  )
}

По итогу мы вернулись к начальной ситуации. Когда просто создавали функцию на каждый рендер.

Получается, в данном случае использовать хук useCallback - это не значит улучшить перфоманс, а скорее  совсем наоборот, ухудшить перфоманс

А для чего тогда нужен useCallback ?

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

Получается основная идея не в улучшении перформанса в конкретном компоненте, а скорее использование useCallback выгодно только в случае передачи функции как props. Давайте рассмотрим еще один пример.

Допустим у нас есть список машин, который мы хотим отобразить:

const Cars = ({ cars }) => {
  return cars.map((car) => {
    return (
      <Car key={car.id} car={car} />
    )
  });
}

Тут нам понадобилось добавить обработчик клика на машину. Мы создаем метод onCarClick и передаем его в компонент Car.

const Cars = ({ cars }) => {
  const onCarClick = (car) => {
    console.log(car.model);
  }
  
  return cars.map((car) => {
    return (
      <Car key={car.id} car={car} onCarClick={onCarClick} />
    )
  });
}

В такой ситуации на каждый рендер компонента Cars у нас создается новая функция onCarClick, соответственно, не важно Car  это PureComponent или обернут в memo, все машины всегда будут заново рендерится, т.к. получают каждый раз новую ссылку на функцию.

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

const Cars = ({ cars }) => {
  const onCarClick = useCallback((car) => {
    console.log(car.model);
  }, []);
  
  return cars.map((car) => {
    return (
      <Car key={car.id} car={car} onCarClick={onCarClick} />
    )
  });
}

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

А если заглянуть внутрь компонента Car. Там мы создадим еще одну функцию, которая свяжет onCarClick и объект car.

const Car = ({ car, onCarClick }) => {
  const onClick = () => onCarClick(car);
  
  return (
    <button onClick={onClick}>{car.model}</button>
  )
}

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

Итоги

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

import { useLocation } from "react-router-dom";

import { useSelector } from "react-redux";

import { useLocalObservable } from "mobx-react-lite";

import { useTranslation } from "react-i18next";

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

А если вам понравилось данная статья, то здесь есть еще немного интересного.

Чао