Все ли вы знаете о useCallback

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

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

Два пути

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

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

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

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

  render() {
    return (
      
    )
  }
}

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

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

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

const Test = () => {
  const onClick = () => {
    console.log('onClick');
  }
  
  return (
    
  )
}

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

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

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

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

689edcda9029a1d5b2c95cb2e8e494de.jpg

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

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

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

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

Викторина!

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

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

const Test = ({ title }) => {
  const someFunction = () => {
    console.log(title);
  }
  
  return (
    
  )
}

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

const Test = () => {
  const someFunction = useCallback((title) => {
    console.log(title);
  }, [title])
  
  return (
    
  )
}

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

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

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

44b1f701c853681de11c57898e23e7d4.jpg

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

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

Ответ

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

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

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

c2c15de17538f7d8994ea05f4a049982.jpg

Естественно мы получим результат 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 (
    
  )
}

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

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

const Test = ({ title }) => {
  const callback = () => {
    console.log(title);
  }
  
  // const deps = [title];
  
  // const someFunction = useCallback(callback, deps);
  
  return (
    
  )
}

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

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

d04b90edda334ea1490c104e41154b13.jpg

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

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

70a565377a7298f4e8fb7c9c49573127.jpg

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

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

const Cars = ({ cars }) => {
  return cars.map((car) => {
    return (
      
    )
  });
}

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

const Cars = ({ cars }) => {
  const onCarClick = (car) => {
    console.log(car.model);
  }
  
  return cars.map((car) => {
    return (
      
    )
  });
}

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

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

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

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

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

const Car = ({ car, onCarClick }) => {
  const onClick = () => onCarClick(car);
  
  return (
    
  )
}

В этом случае нет никакой пользы оборачивать метод в 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";

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

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

Чао

© Habrahabr.ru