Как-то раз решил один фронтендер всё покрыть в useCallback
Как-то раз решил один фронтендер всё покрыть в useCallback

Привет! Сегодня поговорим про стандартные способы оптимизации web-приложения в экстремистской библиотеке React. Мотивацией послужило некоторое количество кода, который я видел. Связан он с использованием API React не по назначению или без учета каких-то очевидных проверок на производительность и тонкостей (с натяжкой).

Какие вообще мы знаем хуки, методы, способы?

Самые популярные в React (говорим о версии 16.8+) функции для оптимизации: хуки useCallback и useMemo, метод React.memo. Разберемся для чего они.

Его величество useCallback - возвращает мемоизированный колбэк.

Неповторимый useMemo - возвращает мемоизированное значение.

Господин High Order Component (HOC) React.memo - поверхностно сравнивает компоненты между отрисовками и если входные параметры (props) не изменились, то не вызывает рендер компонента, то есть мемоизирует компонент.

Интересно, сколько начинающих разработчиков разбежалось после "мемоизация, мемоизация и еще раз мемоизация"? О том, что такое HOC сегодня говорить не будем, несложная концепция, связанная с композицией.

Так что же такое мемоизация?

По сути банальное кэширование значений. Да, вот так просто. Как это работает? В документации в целом был описан алгоритм, по которому все работает. Напоминаю: в хуки useMemo и useCallback мы передаем вторым параметром массив зависимостей и если какая-то зависимость изменяется, то высчитываем значение заново (ну или пересоздаем функцию), если нет - возвращаем результат предыдущих вычислений. Так как нам очевидно, что если у нас есть переменная sum, которая содержит сумму чисел a и b, то нам не обязательно заново складывать a и b между рендерами, если мы это уже делали и переменные не изменились.

По сути это и есть вся идея мемоизации, для лучшего понимания оставлю пример мемоизирования на чистом JS, вдруг кто-то еще не сталкивался.

Мемоизация на JS
const memo = (callback) => {

    // здесь будем хранить результаты вызовов функции
    const cache = {};

    // ну вот и понадобилось замыкание:)
    return (...args) => {
        // тут создаем ключ, по которому достанем/сохраним результат
        // можно лучше, но сделаем пока что так
        const key = JSON.stringify(args);

        // очевидно достаем кэш, если он есть
        if (key in cache) {
            return cache[key];
        }

        const result = callback(...args);
        cache[key] = result; // кэшируем

        return result;
    };
};

const sum = (a, b) => {
    console.log('Call sum', a, b);

    return a + b;
};

// мемоизируем функцию
const memoSum = memo(sum);

// проверяем
memoSum(1, 2);
memoSum(100, 31);
memoSum(1, 2);
memoSum(1, 2);
memoSum(1, 2);
memoSum(0, 9);
Результаты вызова
Результаты вызова

Получили: сокращение количество вызовов.

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

А как же все-таки это все поможет в React приложении?

Создадим простое приложение а-ля to-do list и будем оптимизировать.

Компонент App (корневой)
import "./App.css";
import ItemList from "./components/ItemList/ItemList";

function App() {
  return (
    <div className="App">
      <ItemList />
    </div>
  );
}

export default App;

Компонент ItemList
import React, { useState } from "react";
import styles from "./ItemList.module.css";
import Item from "../Item/Item";

const ItemsInitState = [
    {
      id: "1dcdf741-5140-45c1-ac2d-8512339c20df",
      label: "First Item",
    },
    {
      id: "f87f7a2d-92ab-4890-909a-0795699e7f21",
      label: "Second Item",
    },
    {
      id: "8a6ff044-80fb-4fd7-b021-9eed7f9ffc24",
      label: "Third Item",
    },
];

const ItemList = () => {
    const [items, setItems] = useState(ItemsInitState);

    const remove = (id) =>
      setItems((prev) => prev.filter((item) => item.id !== id));

    return (
        <div className={styles.ItemList}>
            {
                items.map((item) => <Item item={item} remove={remove} />)
            }
        </div>
    );
};

export default ItemList;

Компонент Item
import React from "react";
import styles from "./Item.module.css";

const Item = ({ item, remove }) => {

    console.log(`${item.label}`);
    
    return (
        <div className={styles.Item}>
            <h2>{item.label}</h2>
            <button onClick={remove.bind(null, item.id)} />
        </div>
    );
};

export default Item;

Что произойдет при запуске? Посмотрим в консоль.

Ай-ай-ай, самое важное, добавить key.

items.map((item) => <Item key={item.id} item={item} remove={remove} />)

А теперь удалим элемент и посмотрим сколько раз вызовется функция/компонент Item.

Окей, а как это исправить? Многие используют для этих целей useCallback и useMemo, ожидая, что это поможет, ну или код станет лучше работать. Давайте попробуем.

const remove = useCallback((id) =>
    setItems((prev) => prev.filter((item) => item.id !== id)), []);

Как думаете, что случится? Да ничего, вызовы будут такими же, вы просто мемоизировали функцию. А зачем? Вот и я думаю, что смысла в этом было мало. Причины:

  • на рендерах это никак не отразилось

  • вы увеличили стек вызова (useCallback каждый раз проверяет надо ли пересоздать функцию)

  • в целом с таким же успехом можно было и пересоздать ее, перфоманс не уменьшится, может даже наоборот

А как все-таки правильно? Тут в дело вступает React.memo, тот самый HOC, который мемоизирует компоненты.

export default React.memo(Item); // вызываем его при экспорте
Неописуемый восторг
Неописуемый восторг

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

Кстати, а что насчет свойства key? Можете проверить сами, если его убрать, то useCallback и React.memo вам не помогут.

А useMemo когда использовать? Точно также как и useCallback, ну или если вычисления сильно сложнее pages = Math.ceil(total / perPage).

Заключение

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

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

Спасибо за внимание!

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


  1. p07a1330
    21.12.2022 02:00
    +2

    сложные вычисления всегда можно просто переложить на серверную часть

    А ресурсы сервера бесплатные?
    И запрос ходит мгновенно, без сетевых задержек?


    1. Aleks_ja
      21.12.2022 02:18
      +2

      Но это будет уже проблема бэкендеров ;)


  1. haldagan
    21.12.2022 09:49
    +2

    const key = JSON.stringify(args);

    Правильно, ну их эти Set'ы и Map'ы.
    Перед тем, как использовать JSON.stringify в качестве хеширующей функции неплохо бы убедиться, что она корректно работает со всеми типами аргументов используемых вами.


    1. Alexandroppolus
      21.12.2022 10:47
      +1

      Там коммент есть, что можно лучше.

      Хотя сам по себе этот пример слегка не в тему. В Реакте запоминается только последнее значение, потому нет надобности в ключах, мэпах и т.д.


      1. haldagan
        21.12.2022 11:11

        Там коммент есть

        Не заметил. Похоже баннерная слепота: специально по статье искал место с предупреждением.

        "можно лучше"

        Тут скорее "так не стоит делать". Как, например, не стоит использовать массивы в ключах у Map: вроде и работает, а почему не так как надо, не всегда очевидно.


        1. p07a1330
          21.12.2022 16:19

          Как, например, не стоит использовать массивы в ключах у Map

          https://github.com/anko/array-keyed-map

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