[Перевод] React useCallback() — полное руководство

Всем привет! На связи Spectr и рубрика «Что читают наши разработчики». Сегодня разберем оптимизацию функциональных компонентов за счет мемоизации функций обратного вызова с помощью useCallback ().

Этот материал — перевод статьи. Мы не меняем содержание и приводим слова автора так, как это было написано в оригинале. Если у вас есть предложения или свой опыт в подобных методиках, опишите их в комментариях — мы с радостью ответим и расскажем о своих практиках. Давайте обмениваться опытом!

Введение

Оптимизация производительности в современной веб-разработке имеет ключевое значение для создания более плавного пользовательского опыта. React, популярная библиотека JavaScript для создания пользовательских интерфейсов, предоставляет несколько хуков, которые помогают разработчикам эффективно управлять состоянием и побочными эффектами. Один из таких хуков — useCallback (), который играет важную роль в оптимизации функциональных компонентов. Это происходит за счет мемоизации функций обратного вызова.

Независимо от того, начинающий вы разработчик React или хотите углубить свои знания, это руководство предоставит вам полезную информацию о useCallback (), включая его синтаксис, применение, распространенные случаи использования и лучшие практики. Мы также сравним его с другим хуком — useMemo ().

Понимание функций обратного вызова в React

Прежде чем объяснять useCallback (), начнем с понимания функций обратного вызова. В JavaScript функция обратного вызова — это функция, которая передается в другую функцию в качестве аргумента и выполняется после наступления определенного события или действия.

Функции обратного вызова работают в React так же, как и в JavaScript. Также их можно передавать в дочерние компоненты через props. Это позволяет дочерним компонентам взаимодействовать с родительским компонентом, обеспечивая передачу данных и выполнение действий вверх по дереву компонентов.

Функции обратного вызова — неотъемлемая часть React-приложений, поскольку они выполняют такие задачи, как асинхронные операции (например, сетевые запросы) или обработка событий (например, кликов, отправки форм или других взаимодействий с пользователем). Например, обработчик события onClick кнопки может быть определен как функция обратного вызова, которая обновляет состояние React-компонента или выполняет какое-либо действие при нажатии на кнопку. 

Пример:

const MyComponent = () => {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);
  };

  return (
    
  );
};

В этом примере handleClick — это функция обратного вызова, которая увеличивает состояние count при каждом нажатии на кнопку.

На первый взгляд, функции обратного вызова кажутся очень удобными и выполняют свою задачу. Так зачем же нам нужен useCallback ()? Дело в том, что использование функции обратного вызова не всегда оптимально, как мы увидим в следующем разделе.

Проблема с функциями обратного вызова

Несмотря на их полезность в разработке React, особенно при обработке событий и взаимодействий, функции обратного вызова могут приводить к проблемам с производительностью, если их использовать неправильно. Основная проблема связана с тем, как React обрабатывает повторные рендеры компонентов и как JavaScript рассматривает функции как объекты.

Рассмотрим некоторые распространенные проблемы, связанные с функциями обратного вызова в React:

Необходимые повторные рендеры

В JavaScript функции считаются объектами. Это приводит к тому, что при каждом рендере или повторном рендере компонента любая функция внутри него пересоздается как новый объект. Таким образом, даже если логика функции остается абсолютно неизменной, React будет воспринимать ее как новую сущность каждый раз.

Рассмотрим следующий пример:

function ParentComponent() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    console.log('Button clicked');
  };

  return (
    

Count: {count}

); }

В этом примере каждый раз, когда значение count изменяется и ParentComponent перерендеривается, создается новая функция handleClick. С точки зрения React, эта новая функция отличается от той, которая была создана при предыдущем рендере, даже если ее функциональность осталась неизменной.

Сравнение prop и повторных рендеров дочерних компонентов

Когда новая функция создается и передается в качестве props, React воспринимает это как изменение props. Это может привести к повторному рендеру дочернего компонента, если он использует оптимизационные техники, такие как React.memo () или PureComponent.

Рассмотрим пример:

const ChildComponent = React.memo(({ onClick }) => {
  console.log('Child component rendered');
  return ;
});

Даже при использовании React.memo, который предназначен для предотвращения ненужных рендеров, ChildComponent будет перерендериваться каждый раз, когда ParentComponent рендерится, потому что он получает «новый» prop onClick при каждом рендере родительского компонента.

Устаревшие замыкания (Stale Closures)

Функции обратного вызова иногда могут «захватывать» устаревшие значения из своего окружения, что приводит к появлению трудноуловимых и сложных для исправления ошибок.

Рассмотрим пример:

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      console.log(`Count is: ${count}`);
    }, 1000);

    return () => clearInterval(timer);
  }, []);

  return ;
}

В этом компоненте счетчика функция обратного вызова, используемая в setInterval, всегда будет выводить 0 в консоль. Это происходит потому, что замыкание захватывает начальное значение count на момент первого рендера и не обновляется при изменении состояния.

Эти проблемы приводят к следующим последствиям для производительности:

  1. Каскадные повторные рендеры. В глубоко вложенных деревьях компонентов ненужные повторные рендеры могут каскадно распространяться вниз, вызывая значительную нагрузку на производительность.

  2. Ненужные вычисления. Каждый повторный рендер включает процесс согласования (reconciliation) в React, который может быть вычислительно затратным, особенно для сложных компонентов React.

  3. Увеличение потребления памяти. Постоянное создание новых экземпляров функций может привести к увеличению потребления памяти с течением времени.

Эти проблемы подчеркивают необходимость управления функциями обратного вызова в React-приложениях. useCallback () был разработан для решения этих проблем, и мы подробно рассмотрим его в следующих разделах.

Что такое useCallback ()

0cd3ef65ee50cf1d3587254ea257fbba.png

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

Синтаксис и параметры

Синтаксис useCallback () выглядит следующим образом:

const memoizedCallback = useCallback(() => {
  // Your callback logic here
}, [dependency1, dependency2, ...]);

Хук useCallback () принимает два аргумента:

1. Функция обратного вызова — это первый аргумент, который представляет функцию, которую нужно мемоизировать (запомнить).

2. Массив зависимостей — это второй аргумент, представляющий массив зависимостей, который определяет, когда функция обратного вызова должна быть пересоздана. Если какое-либо значение в этом массиве изменяется между рендерами, функция обратного вызова будет пересоздана.

Как работает useCallback ()

После добавления useCallback () React будет мемоизировать экземпляр переданной функции между повторными рендерами. Вот что происходит:

1. При начальном рендере React создает функцию и возвращает ее.

2. При последующих рендерах React будет:

  • проверять, изменились ли значения в массиве зависимостей;

  • если ничего не изменилось, он возвращает тот же экземпляр функции, что и при предыдущем рендере;

  • если что-то изменилось, React создает новый экземпляр функции и возвращает его.

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

Рассмотрим пример, который демонстрирует, как работает useCallback ():

function ParentComponent() {
      const [count, setCount] = useState(0);
    
      const increment = useCallback(() => {
        setCount((c) => c + 1);
      }, []); // Empty dependency array means this function never changes
    
      return (
        

Count: {count}

); } const ChildComponent = React.memo(({ onIncrement }) => { console.log('ChildComponent rendered'); return ; });

В этом примере:

increment — это мемоизированная функция, использующая useCallback (). Она будет пересоздана только в том случае, если массив зависимостей, который в данном случае пуст, изменится.

ChildComponent обернут в React.memo (), чтобы предотвратить ненужные повторные рендеры компонента. Он будет перерендериваться только в случае изменения props.

— Поскольку increment мемоизирована, ChildComponent не будет перерендериваться при изменении состояния count в ParentComponent.

Теперь, когда мы понимаем, как работает useCallback () и как правильно его использовать, давайте рассмотрим, почему знание и использование этого хука может быть полезным при разработке React-приложений.

Когда использовать useCallback ()

Вот несколько сценариев, когда использование useCallback () особенно полезно:

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

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

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

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

5. Передача функций обратного вызова в дочерние компоненты. Если ссылки на функцию изменяются, дочерние компоненты могут перерендериваться без необходимости. useCallback () помогает предотвратить это, мемоизируя функцию и гарантируя, что она изменится только тогда, когда изменятся ее зависимости.

Когда не использовать useCallback ()

Хотя useCallback () — мощный инструмент для оптимизации, его использование может быть излишним в следующих случаях:

1. Для простых компонентов React, которые не перерендериваются часто. Если компонент не рендерится часто, использование useCallback () не принесет значительной пользы и только добавит лишнюю сложность.

2. Когда функция обратного вызова используется только внутри компонента и не передается как props или не используется в массиве зависимостей. Если функция не передается в дочерние компоненты или не используется в useEffect (), мемоизация функции не имеет смысла. 

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

Важно всегда использовать хуки правильно, чтобы избежать ненужной сложности или проблем с производительностью в кодовой базе.

Как использовать useCallback (). Практические примеры

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

  1. Добавление товаров в «Избранное».

  2. Добавление товаров в «Корзину».

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

Создание React приложения

Настройте новое React приложение с помощью Vite, выполнив следующую команду:

npm create vite@latest

Выберите React в качестве фреймворка и завершите процесс установки. Также установите TailwindCSS для стилизации.

Запустите сервер разработки:

npm run dev

Теперь проверьте терминал, чтобы узнать порт, на котором работает приложение.

Настройка проекта Hygraph

Сначала зарегистрируйтесь на бесплатный аккаунт для разработчиков, если вы еще этого не сделали.

Чтобы использовать Hygraph в React приложении, выполните следующие шаги:

1. Клонируйте проект. Клонируйте этот стартовый проект Hygraph для электронной коммерции, чтобы быстро настроить витрину. После клонирования откройте проект.

2. Настройте доступ к API. В проекте Hygraph перейдите в Project Settings > API Access > Public Content API. Настройте разрешения для Public Content API, чтобы разрешить чтение данных без аутентификации. Нажмите «Yes, initialize defaults», чтобы добавить необходимые разрешения для High Performance Content API.

3. Установите переменную окружения. Найдите High Performance Content API и скопируйте URL. В корневой папке React приложения создайте файл `.env` и добавьте URL, как показано ниже:  

   VITE_HYGRAPH_HIGH_PERFORMANCE_ENDPOINT=YOUR_URL

Замените YOUR_URL на соответствующий URL.

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

query GetProducts {
    products {
      images {
        id
        url
      }
      name
      price
      id
    }
}

Этот запрос извлекает информацию о товарах, доступную через раздел Content. Теперь давайте получим и отобразим данные о товарах в нашем React-приложении.

Получение данных из Hygraph

Перейдите в терминал проекта и установите клиент GraphQL для получения данных:

npm add graphql-request

Откройте `App.js` и добавьте следующий код для получения и отображения продуктов:

import { useState, useEffect } from "react";
import { GraphQLClient, gql } from "graphql-request";
import "./App.css";

const endpoint = new GraphQLClient(
  import.meta.env.VITE_HYGRAPH_HIGH_PERFORMANCE_ENDPOINT
);

const GET_PRODUCTS_QUERY = gql`
  query GetProducts {
    products {
      images {
        id
        url
      }
      name
      price
      id
    }
  }
`;

function App() {
  const [products, setProducts] = useState([]);

  useEffect(() => {
    const fetchProducts = async () => {
      try {
        const data = await endpoint.request(GET_PRODUCTS_QUERY);
        setProducts(data.products);
      } catch (error) {
        console.error("Error fetching products:", error);
      }
    };
    fetchProducts();
  }, []);

  return (
    

Список продуктов

    {products.map((product) => (
  • {product.name}

    {product.name}

    Цена: ${(product.price / 100).toFixed(2)}

  • ))}
); } export default App;

Код выше получает данные из Hygraph с использованием High Performance Content API. Мы также использовали хук [useState] (https://hygraph.com/blog/usestate-react), который управляет состоянием данных продуктов, а хук `useEffect` обрабатывает получение данных.

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

5f91ff6b8e3feb7712b1b9973bf3527d.png

Теперь давайте добавим функцию для пометки продуктов как «избранных», чтобы продемонстрировать полезность useCallback ().

Добавление функционала «Избранное»

Сначала установите FontAwesome для иконок:

npm install @fortawesome/fontawesome-svg-core @fortawesome/free-solid-svg-icons @fortawesome/react-fontawesome

Обновите `App.js`, чтобы включить хук useCallback () и иконки FontAwesome:

import { useState, useEffect, useCallback } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faHeart } from "@fortawesome/free-solid-svg-icons";

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

Далее добавим состояние:

const [favorites, setFavorites] = useState({});

Это инициализирует переменную состояния favorites как пустой объект и предоставляет функцию setFavorites для обновления состояния. Этот объект функции позволяет переключаться между двумя состояниями, такими как «в избранном» или «не в избранном».

Далее, мы объявим useCallback ():

const handleFavorite = useCallback((id) => {
    setFavorites((prevFavorites) => ({
        ...prevFavorites,
        [id]: !prevFavorites[id],
    }));
}, []);

Здесь мы достигли следующего:

  1. Обернули функцию handleFavorite в useCallback (), чтобы запомнить ее и предотвратить ненужные пересоздания при повторных рендерах. Это особенно полезно для производительности, если продуктовый элемент будет использоваться в больших приложениях с множеством продуктов.

  2. Функция также переключает статус избранного продукта, обновляя состояние favorites.

Теперь мы завершим это, добавив следующий код под раздел «Цена»:

Здесь мы убедились, что:

  1. когда пользователь нажимает кнопку, она вызывает функцию handleFavorite, передавая ID продукта;

  2. цвет иконки сердца меняется в зависимости от статуса избранного, который хранится в объекте состояния favorites.

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

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

Реализация функции «Добавить в корзину»

Сначала обновите импорты FontAwesome, чтобы включить иконки корзины покупок:

import {
  faHeart,
  faCartShopping,
  faPlus,
  faMinus,
} from "@fortawesome/free-solid-svg-icons";

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

const [cart, setCart] = useState({});

const handleAddToCart = useCallback((product) => {
  setCart((prevCart) => {
    const quantity = prevCart[product.id]
      ? prevCart[product.id].quantity + 1
      : 1;
    return {
      ...prevCart,
      [product.id]: { ...product, quantity },
    };
  });
}, []);

const handleRemoveFromCart = useCallback((productId) => {
  setCart((prevCart) => {
    if (!prevCart[productId]) return prevCart;
    const newQuantity = prevCart[productId].quantity - 1;
    if (newQuantity > 0) {
      return {
        ...prevCart,
        [productId]: { ...prevCart[productId], quantity: newQuantity },
      };
    } else {
      const newCart = { ...prevCart };
      delete newCart[productId];
      return newCart;
    }
  });
}, []);

Здесь у нас есть две функции, которые выполняют следующие действия:

  1. handleAddToCart. Функция добавляет продукт в корзину или увеличивает его количество, если он уже есть в корзине.  

  2. handleRemoveFromCart. Эта функция уменьшает количество продукта в корзине или удаляет его, если количество достигает 0.  

Общие функции используют useCallback () для мемоизации и setCart для обновления состояния корзины.

Обновите файл, чтобы добавить элементы управления корзиной:

{cart[product.id] ? cart[product.id].quantity : 0}

Наконец, добавьте раздел для отображения содержимого корзины:

Корзина ({Object.keys(cart).length} товаров)

    {Object.values(cart).map((item, index) => (
  • {item.name} - ${(item.price / 100).toFixed(2)} Количество: {item.quantity}
  • ))}

Ваш пользовательский интерфейс теперь должен выглядеть следующим образом:

fa3f40a2d19e597f34c2eed787030e87.png

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

Вы можете получить доступ к коду на GitHub.

useCallback () и useMemo ()

В React хуки useCallback () и useMemo () оба используются для оптимизации производительности путем мемоизации функций и значений, но они предназначены для разных целей и применяются в разных сценариях.

Синтаксис useMemo () выглядит следующим образом и принимает два аргумента (аналогично useCallback ()):

  1. Функция для вычисления значения.

  2. Массив зависимостей.

const memoizedValue = useMemo(
      () => computeExpensiveValue(a, b),
      [a, b], // Массив зависимостей
    );

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

Критерии

useCallback ()

useMemo ()

Что мемоизируют

Мемоизирует функцию обратного вызова

Мемоизирует вычисленное значение

Возвращаемое значение

Возвращает мемоизированную функцию

Возвращает мемоизированное значение (может быть любого типа)

Использование

Обычно для оптимизации повторных рендеров дочерних компонентов и в массивах зависимостей других хуков

Для сложных и ресурсоемких вычислений, а также для создания объектов, которые должны оставаться стабильными

Рассмотрим следующий пример использования useMemo ():

import React, { useMemo } from 'react';

const computeExpensiveValue = (a, b) => {
  console.log('Computing expensive value...');
  let result = 0;
  for (let i = 0; i < 1000000000; i++) {
    result += a + b;
  }
  return result;
};

const App = () => {
  const a = 5;
  const b = 10;

  const expensiveValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

  console.log(`Expensive value for a=${a} and b=${b}: ${expensiveValue}`);

  return null;
};

export default App;

В этом примере useMemo () мемоизирует результат выполнения функции computeExpensiveValue (). Вычисление будет выполняться только тогда, когда значения `a` или `b` изменятся. Это позволяет избежать ненужных пересчетов при каждом рендере компонента.

Если бы мы использовали useCallback (), то мемоизировалась бы сама функция computeExpensiveValue (), но дорогостоящие вычисления выполнялись бы каждый раз при вызове этой функции, даже если `a` и `b` остались бы неизменными.

Теперь, когда мы разобрались с разницей между useCallback () и useMemo (), давайте рассмотрим, когда лучше использовать useCallback ().

Лучшие практики использования useCallback ()

  1. Используйте только на верхнем уровне компонента. Как и любой другой хук, useCallback () должен использоваться только на верхнем уровне компонента и вне любых циклов или условий.

  2. Сочетайте с [React.memo ()] (https://hygraph.com/blog/react-memo).   Используйте useCallback () вместе с React.memo () для дочерних компонентов, чтобы предотвратить их ненужный повторный рендер, если ссылка на функцию обратного вызова не изменилась.

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

  4. Используйте только при необходимости. Применяйте useCallback () только тогда, когда это действительно необходимо. Используйте его только в тех случаях, когда выгода для производительности очевидна.

Заключение

Это руководство предоставляет обзор React-хука useCallback (), начиная с описания решаемой им проблемы и заканчивая примерами использования и лучшими практиками его реализации.

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

© Habrahabr.ru