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

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

Кстати, у меня есть Telegram-канал, где я собираю ссылки на свои статьи про Full-Stack разработку, развитие SaaS-продуктов и управление IT-проектами.

Содержание

Какой у меня был опыт с useMemo и useCallback?

До этого с данными хуками я сталкивался только в документации, туториалах на YouTube и статьях для начинающих на хабре. Несколько раз у меня даже спрашивали на собеседованиях! Однако на практике я не видел случаев, чтобы за счёт этих методов что-либо оптимизировали.

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

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

Дисклеймер: мой опыт субъективный и не всеобъемлющий. Но совпадает с моими знакомыми разработчиками, которых я спросил перед написанием статьи. Мы все дружно пишем в основном приложения для бизнеса, без rocket sciece'a.

Практически во всех примерах эти хуки используют в довольно надуманных обстоятельствах. Например, вот пример использования useMemoиз документации React'a:

import { useMemo } from 'react';
import { filterTodos } from './utils.js'

export default function TodoList({ todos, theme, filters }) {
  const visibleTodos = useMemo(
    () => filterTodos(todos, filters),
    [todos, filters]
  );
  
  return (
    <div className={theme}>
      <p><b>Note: <code>filterTodos</code> is artificially slowed down!</b></p>

      <ul>
        {visibleTodos.map(todo => (
          <li key={todo.id}>
            {todo.completed ?
              <s>{todo.text}</s> :
              todo.text
            }
          </li>
        ))}
      </ul>
    </div>
  );
}

В этом коде мы видим, что извне приходят todos и tab, в зависимости от которых меняется список. Подразумевается, что если изменится theme, которая не влияет на список задач — список задач всё равно отфильтруется заново.

Но на практике я обычно встречаю такой код:

export default function TodoList({ filters }) {
  const [todos, setTodos] = useState([]);
  
  const loadTodos = async (filter) => {
    setTodos(await todosApi.getTodos(filters));
    
    // или хотя бы так
    // const todos = await todosApi.getTodos();
    // setTodos(filterTodos(todos, tab));
  }

  useEffect(() => {
    loadTodos();
  }, [filter]);
  
  return (
    <div className={theme}>
      <p><b>Note: <code>filterTodos</code> is artificially slowed down!</b></p>

      <ul>
        {visibleTodos.map(todo => (
          <li key={todo.id}>
            {todo.completed ?
              <s>{todo.text}</s> :
              todo.text
            }
          </li>
        ))}
      </ul>
    </div>
  );
}

То есть, фильтрация происходит в момент применения загрузки данных с сервера.

Аналогично методы редко оборачиваются в useCallback, потому что изменения состояний достаточно изолированные. Соответственно, если метод переопределится внутри компонента вместе с состоянием — ререндер будет всё равно один. Или дочерних компонентов мало, от лишнего ререндера производительность не пострадает.

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

...но вот возникла ситуация, когда я понял, зачем все-таки нужны эти хуки!

Контекст проблемы

В данный момент я руковожу разработкой конструктора Telegram Mini App'ов. Это что-то по типу Webflow, Tilda или FlutterFlow, но для бизнес-приложений и маркетинговых воронок в Telegram (с монетизацией, рассылками, тарифами, CRM и т.д.). Средняя нагрузка одного приложения ~25 000 MAU (бывают и на несколько сотен пользователей, бывают и на несколько сотен тысяч).

Помните хайп на кликеры (и notcoin в частности)? Первая версия конструктора появилась в качестве конструктора именно кликеров. Со временем мы, конечно, ушли в другие ниши. Но у клиентов все-таки остались кликеры.

Вот так выглядит типичный кликер внутри конструктора (обратите внимание на поля в формате {{value}}:

Примерно две недели назад пользователи начали жаловаться, что подтормаживает интерфейс во время тапанья в старых кликерах. Кнопку с персонажем можно тапать несколькими пальцами, в этот момент всё плавно подвисает:

Во время тапа обновления отрисовывались с задержкой 5-10 секунд
Во время тапа обновления отрисовывались с задержкой 5-10 секунд

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

Почему появилась проблема?

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

У нас появилось глобальное состояние (баллы пользователей, купленные страницы, текущий тариф и т.д.). Эти значения могут подставляться в "плейсхолдеры" в текстовых полях в формате {{current_score}}, {{current_energy}}, {{rate_name}}.

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

В итоге, тапанье несколькими пальцами начало приводить к очень быстрому перерендеру всех блоков. Если в период кликеров это был перерендер 5-10 простых блоков, сейчас это 20+ сложных блоков на экране. Появились подлагивания, virtual DOM начал тормозить.

Как мы решили проблему?

Проблему начали решать двумя способами:

1) Добавили debouncer для тапанья.

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

Сам по себе этот фикс немного улучшил ситуацию, но глобально не помог.

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

Раньше компонент самого простого блока мог выглядеть вот так:

...

export function BlockComponent({ block }: Props): JSX.Element {
  const isBuilder = useSelector(selectBuilder.isBuilder);

  const handleClick = () => {
    if (!isBuilder) {
      const url = block.uiProperties.find((property) => property.name === 'url')?.value as string;
        if (url) {
          if (url.includes('#')) {
            window.location.hash = url;
          } else {
            TelegramLinkHelper.openLink(url);
          }
        }
      }
    }
  };

  return (
    <BlockWrapperComponent
      block={block}
      isBuilder={isBuilder}
      boxShadow={
        block.getUiProperty('isShowShadow')
          ? `${block.getUiProperty('shadowHorizontalOffset')}px ${block.getUiProperty('shadowVerticalOffset')}px ${block.getUiProperty('shadowBlur')}px ${block.getUiProperty('shadowSpread')}px ${block.getUiProperty('shadowColor')}`
          : 'none'
      }
      borderRadius={block.getUiProperty('borderRadius') as number}
      onClick={() => {
        handleClick();
      }}
    >
      <div
        style={{
          width: BlockSizeHelper.getBlockWidth(),
          height: BlockSizeHelper.getBlockHeight(),
          background: block.getUiProperty('backgroundColor') as string,
          borderRadius: `${block.getUiProperty('borderRadius')}px`,
          borderWidth: `${block.getUiProperty('borderWidth')}px`,
          borderColor: block.getUiProperty('borderColor') as string,
          cursor: 'pointer',
          padding: `${block.paddingTop}px ${block.paddingRight}px ${block.paddingBottom}px ${block.paddingLeft}px`,
        }}
      />
    </BlockWrapperComponent>
  );
}

Обратите внимание, что каждый раз стили вытягиваются из модели блока block.getUiProperty(...) (причём это не просто структура данных, а делается поиск по массиву). Метод handleClick переопределяется на каждое изменение глобального состояния.

*компонент блока — это .tsx код;
*модель блока — это SomeBlock.ts с бизнес-логикой и настройками.

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

И ведёт на habr.ru
И ведёт на habr.ru

После оптимизаций, компонент с useMemo и useCallback выглядит вот так:

...

export function BlockComponent({ block }: Props): JSX.Element {
  const isBuilder = useSelector(selectBuilder.isBuilder);

  const handleClick = useCallback(() => {
    if (!isBuilder) {
      const url = block.uiProperties.find((property) => property.name === 'url')?.value as string;

      if (url) {
        if (url.includes('#')) {
          window.location.hash = url;
        } else {
          TelegramLinkHelper.openLink(url);
        }
      }
    }
  }, [isBuilder, block.uiProperties]);


  const boxShadow = useMemo(() => {
    return block.getUiProperty('isShowShadow')
      ? `${block.getUiProperty('shadowHorizontalOffset')}px ${block.getUiProperty('shadowVerticalOffset')}px ${block.getUiProperty('shadowBlur')}px ${block.getUiProperty('shadowSpread')}px ${block.getUiProperty('shadowColor')}`
      : 'none';
  }, [block.uiProperties]);

  const divStyle = useMemo(
    () => ({
      width: BlockSizeHelper.getBlockWidth(),
      height: BlockSizeHelper.getBlockHeight(),
      background: block.getUiProperty('backgroundColor') as string,
      borderRadius: `${block.getUiProperty('borderRadius')}px`,
      borderWidth: `${block.getUiProperty('borderWidth')}px`,
      borderColor: block.getUiProperty('borderColor') as string,
      cursor: 'pointer',
      padding: `${block.paddingTop}px ${block.paddingRight}px ${block.paddingBottom}px ${block.paddingLeft}px`,
    }),
    [
      block.uiProperties,
      block.paddingTop,
      block.paddingRight,
      block.paddingBottom,
      block.paddingLeft,
    ],
  );

  return (
    <BlockWrapperComponent
      block={block}
      isBuilder={isBuilder}
      boxShadow={boxShadow}
      borderRadius={block.getUiProperty('borderRadius') as number}
      onClick={handleClick}
    >
      <div style={divStyle} />
    </BlockWrapperComponent>
  );
}

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

В одном .tsx компоненте блока может быть 5-10+ функций и 5+ составных стилей (для разных частей блока), которые зависят от настроек. Например, в блоке "дневной бонус" более 60 визуальных настроек и 7 функций с бизнес-логикой:

Каждое текстовое поле, обводка, размер шрифта настраиваются через настройки справа
Каждое текстовое поле, обводка, размер шрифта настраиваются через настройки справа

Итог

Оптимизация 50 блоков и debouncer помогли. Приложения перестали притормаживать, если активно тапать. Наши клиенты довольны, их пользователи могут и дальше приносить им прибыль.

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

Ну и я наконец-то поработал реальным сценарием, где пригодились useCallback и useMemo.

P.S. Напомню, что у меня есть Telegram-канал, где я собираю ссылки на свои статьи про Full-Stack разработку, развитие SaaS-продуктов и управление IT-проектами.

Если у вас есть вопросы или рекомендации, что можно было сделать лучше — буду рад комментариям. Ну а если статья понравилась, поставьте стрелочку вверх, пожалуйста ?.

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


  1. gsaw
    23.01.2025 09:04

    Борьба с симптомами, а не болезнью.


    1. artptr86
      23.01.2025 09:04

      Просто в Реакте эволюционно получился аналог подхода с использованием сигналов как в Mobx, Vue или SolidJS, только вывернутый наизнанку. То есть в упомянутых фреймворках определение зависимостей, мемоизация и цикл пересчёта данных находятся под капотом, а в React hooks их пишет сам программист. Да и в целях сохранения обратной совместимости получилось всё равно не так эффективно, как могло быть.


  1. winkyBrain
    23.01.2025 09:04

    Новый день - новый пост про useCallback и useMemo) вы правда только спустя 6 лет работы наконец-то разобрались, зачем они нужны?

    Кстати, у меня есть Telegram-канал


  1. adminNiochen
    23.01.2025 09:04

    То есть чтобы проапдейтить какой-то сильно вложенный плейсхолдер, ты ререндеришь всё приложение, а потом пытаешься пофиксить посадку перформанса мемоизацией логики в компонентах которые посередине вложены?

    Вот после таких решений и рождаются анекдоты про программистов и их костыли.


  1. devlev
    23.01.2025 09:04

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

    В вашей ситуации так и просится глобальный стор, который будет хранить все переменные, а эти переменные будут читаться там где надо через специальные компоненты с использованием useSelector. Когда надо обработать событие будет использоваться компонент с вызовом useDispatch, Тем самым получается то, о чем говорят выше - аля использование сигналов только на React.

    Выше правильно сказали, в React нет сигналов из коробки. Для этого нужно отдельная зависимость. Redux частично может взять на себя роль сигналов.


    1. RostislavDugin Автор
      23.01.2025 09:04

      У нас есть Redux, глобальные переменные и так там хранятся.

      Но проблема в том, что плейсхолдеры определяются пользователем в текстовых полях. Следовательно, заранее неизвестно в каком именно компоненте какой плейсхолдер будет нужен и какой (или какие) selector'ы будут нужны в каждом из 50 возможных типов блоков.


  1. LyuMih
    23.01.2025 09:04

    Спасибо за статью, было интересно почитать про внутреннее устройство конструктора кликеров.

    Волосы:

    1. Обновлялись до React 19? Вроде как там обещают оптимизацию useMemo/useCallback

    2. Использовать в каждом компоненте useCallback/ useMemo - означает быть подверженным человеческому фактору - лень, забыл, сроки горят. Искали ли вы системное решение данной библиотеки в виде другой библиотеки/своего хука. Мб замена redux на Zustand/Mobx сможет излечить одну из этих родовых травм?

    3. Ещё возможно вариант, что вам нужно дополнительно оптимизировать Инпуты - чтобы они не делали лишние действия


    1. RostislavDugin Автор
      23.01.2025 09:04

      1. Нет, мы ещё на 18-й (но нам и не нужна доп. оптимизация хуков, нам и текущей реализации хватает).

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

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

      3. А мы так и сделали. У нас кнопка и есть инпут, который вызывает события. Мы её задебоунсили