Я наконец-то разобрался, зачем нужны useMemo и useCallback на практике
Я занимаюсь фронтенд разработкой на 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 (
Note: filterTodos
is artificially slowed down!
{visibleTodos.map(todo => (
-
{todo.completed ?
{todo.text} :
todo.text
}
))}
);
}
В этом коде мы видим, что извне приходят 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 (
Note: filterTodos
is artificially slowed down!
{visibleTodos.map(todo => (
-
{todo.completed ?
{todo.text} :
todo.text
}
))}
);
}
То есть, фильтрация происходит в момент применения загрузки данных с сервера.
Аналогично методы редко оборачиваются в useCallback
, потому что изменения состояний достаточно изолированные. Соответственно, если метод переопределится внутри компонента вместе с состоянием — ререндер будет всё равно один. Или дочерних компонентов мало, от лишнего ререндера производительность не пострадает.
В общем, все всё упрощают и это нормально. На оптимизацию рентабельно забивать до тех пор, пока это не стало требованием от бизнеса или проблемой для пользователей. Разумеется, за исключением ситуации, когда заранее очевидно, что будет проблема и нужно предпринять превентивные действия.
…но вот возникла ситуация, когда я понял, зачем все-таки нужны эти хуки!
Контекст проблемы
В данный момент я руковожу разработкой конструктора Telegram Mini App’ов. Это что-то по типу Webflow, Tilda или FlutterFlow, но для бизнес-приложений и маркетинговых воронок в Telegram (с монетизацией, рассылками, тарифами, CRM и т.д.). Средняя нагрузка одного приложения ~25 000 MAU (бывают и на несколько сотен пользователей, бывают и на несколько сотен тысяч).
Помните хайп на кликеры (и notcoin в частности)? Первая версия конструктора появилась в качестве конструктора именно кликеров. Со временем мы, конечно, ушли в другие ниши. Но у клиентов все-таки остались кликеры.
Вот так выглядит типичный кликер внутри конструктора (обратите внимание на поля в формате {{value}}
:
Примерно две недели назад пользователи начали жаловаться, что подтормаживает интерфейс во время тапанья в старых кликерах. Кнопку с персонажем можно тапать несколькими пальцами, в этот момент всё плавно подвисает:
Во время тапа обновления отрисовывались с задержкой 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 (
{
handleClick();
}}
>
);
}
Обратите внимание, что каждый раз стили вытягиваются из модели блока block.getUiProperty(...)
(причём это не просто структура данных, а делается поиск по массиву). Метод handleClick
переопределяется на каждое изменение глобального состояния.
*компонент блока — это .tsx
код;
*модель блока — это SomeBlock.ts
с бизнес-логикой и настройками.
Вот так самый простой блок выглядит для пользователя в конструкторе:
И ведёт на 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 (
);
}
Следовательно, стили кэшируются и не пересчитываются при каждой попытке перерендера. И не пересоздаётся функция в случае, если не изменилось состояние, важное для её работы.
В одном .tsx
компоненте блока может быть 5–10+ функций и 5+ составных стилей (для разных частей блока), которые зависят от настроек. Например, в блоке «дневной бонус» более 60 визуальных настроек и 7 функций с бизнес-логикой:
Каждое текстовое поле, обводка, размер шрифта настраиваются через настройки справа
Итог
Оптимизация 50 блоков и debouncer помогли. Приложения перестали притормаживать, если активно тапать. Наши клиенты довольны, их пользователи могут и дальше приносить им прибыль.
На какое-то время (может полгода, может год) мы оттянули необходимость уходить от концепции глобального состояния для плейсхолдера. А это означает, что не придется усложнять архитектуру ради оптимизации и выйдет бежать в разработке с той же скоростью.
Ну и я наконец-то поработал реальным сценарием, где пригодились useCallback
и useMemo
.
P.S. Напомню, что у меня есть Telegram-канал, где я собираю ссылки на свои статьи про Full-Stack разработку, развитие SaaS-продуктов и управление IT-проектами.
Если у вас есть вопросы или рекомендации, что можно было сделать лучше — буду рад комментариям.