
Иммутабельные паттерны разработки UI-приложений в последние годы стали одними из самых популярных благодаря сочетанию простой архитектуры и хорошей производительности. Они строятся на функциональном программировании без классов, используя стандартные структуры данных (без лишней магии и Proxy), быстрое (ссылочное / поверхностное) сравнение и мемоизацию на всех уровнях. В общем, принципы практически те же, на которых строится и сам React. Минусом же данных паттернов является ухудшение производительности по мере роста проекта, и то, насколько легко эту производительность поломать всего лишь несколькими привычными и безобидными, на первый взгляд, вещами. Опять-таки, как и в React.
И, пожалуй, самое печальное, что эти проблемы я встречал абсолютно во всех проектах, в которых данные паттерны использовались, и практически независимо от грейда разработчиков. Здесь явно необходимо улучшать анализаторы кода, как это делается для хуков React в ESLint, и улучшать библиотеки, отслеживая самые явные ошибки еще в debug-сборках. Но пока это еще не развито в полной мере, предлагаю пройтись по основным проблемам и вручную проверить ваши проекты на их наличие.
1. Основные проблемы
Они в общем-то связаны с ранее перечисленными особенностями - ссылочное / поверхностное сравнение на всех уровнях.
Достаточно на любом из уровней начать возвращать каждый раз новый объект - и все последующие уровни станут работать настолько плохо, насколько это возможно:
мемоизированный селектор (Reselect) будет пересчитываться.
подписки компонентов будут приводить к перерендеру (
useSelector,mapStateToProps).будет приводить к вызову мемоизирующих хуков и эффектов (
useMemo,useCallback,useEffect), где он есть в зависимостях.при передаче детям компонента они перерендерятся независимо от их мемоизации (
memo).
В итоге создание нового объекта в источнике данных может приводить к лишним вычислениям и перерендеру VDOM вплоть до всего экрана на каждое изменение стора, даже если данные по факту не изменились. Иммутабельные сторы в пиковые нагрузки могут меняться десятки раз в секунду, и к каким тормозам это может привести объяснять не нужно. При этом отследить из-за чего тормозит может быть проблематично, особенно если создание объектов возникает в определенных, редких условиях - отсутствие кэша, пустые массивы, feature-флаги, а разработчики и тестировщики работают на топовом железе.
Итак, примеры:
// 1. Да, вот так просто. К сожалению, почти каждый читатель этой статьи так делал/делает.
// Уверен многие даже не поймут - [] это создание нового массива на каждый вызов селектора, если данных нет.
const selectItems = (state) => state.items ?? []
// 2. Ну тут же, казалось бы, очевидно, что надо мемоизировать.
const selectFilteredItems = (state) => state.items?.filter(x => x)
const mapStateToProps = (state, ownProps) => {
return {
// 3. Одна из многих вариаций п.1 используя ??.
// И мемоизация не спасает, если реселектор возвращает `Falsy`.
options: reselectOptions(state, ownProps.id) || {},
// 4. Даже даты нас подставили. Date - объект.
date: parseDate(state.map[ownProps.id].dateString)
// 5. С выбором пользователя и аватара проблем нет, но вот обертка всего
// в новый объект приведет к постоянным перерендерам.
userInfo: {
user: selectUser(ownProps.id),
avatar: selectAvatar(ownProps.id)
},
}
}
// 6. Сами найдете проблему?
type Props = {
items?: Item[]
loading?: boolean | null
}
const ListScreen = memo(({items}: Props) => {
// ...
useEffect(() => {
if (!items?.length) {
fetchData()
}
}, [items?.length])
})Исправляем:
// 1.1 Если есть потребность не работать с undefined, используем константы.
const EMPTY_ARRAY = Object.freeze([])
const EMPTY_OBJECT = Object.freeze({})
const selectItems = (state) => state.items ?? EMPTY_ARRAY
// 1.2 Чуть хуже - можно вернуть опциональное значение, но тогда будет перерендер при
// переходе с [] на undefined и обратно, хотя поведение в таких случаях часто одинаковое.
const selectItems = (state) => state.items
// 2. Мемоизация
const reselectFilteredItems = createSelector(
selectItems,
(items) => items.filter(x => x)
)
const mapStateToProps = (state, ownProps) => {
return {
// 3. Либо живем с undefined, либо перемещаем ?? {} в мемоизированный селектор,
// либо ?? EMPTY_OBJECT.
options: reselectOptions(state, ownProps.id),
// 4. Здесь мы трансформировали все строковые даты в объекты еще в слое доступа
// к данным (например, API клиенте). Хотя можно это делать и во время рендера,
// и в мемоизированном селекторе - в зависимости от задачи.
date: state.map[ownProps.id].date,
// 5. Просто не оборачиваем в новый объект - делаем пропы максимально "плоскими".
user: selectUser(ownProps.id),
avatar: selectAvatar(ownProps.id),
}
}
// 6. Переключения с false на null, undefined и number и дальше по кругу будут приводить
// к лишним вычислениям useEffect. Делаем зависимости стабильнее.
// И да, проблема была не только с useEffect, но и с memo.
type Props = {
// Убираем ? и | null - меньше возможных перерендеров,
// но и менее удобно пользоваться - на усмотрение.
items: Item[]
loading: boolean
}
const ListScreen = memo(({items}: Props) => {
// Теперь useEffect не будет вызываться на каждое изменение длины массива.
const hasData = Boolean(items.length)
useEffect(() => {
// ...
}, [hasData])
})Что ж, основные проблемы, приводящие к перерендерам всего экрана по 100 раз в секунду, мы исправили. Давайте пройдемся по чуть более сложным моментам.
2. Списки
Многие считают невозможным в иммутабельных сторах сделать так, чтобы при изменении элемента списка перерендерить только одну ячейку. На самом деле это довольно просто - нужна нормализация. Точнее - хранить порядок данных (id) и сами данные (сущности) отдельно.
type State = {
order: string[] // Список id элементов.
items: Record<string, Item> // Сами элементы с константным поиском по id.
}
// Список подписывается только на order, а каждая отдельная ячейка на свою сущность:
const ListScreen = () => {
const order = useSelector(selectOrder)
// ...
}
const ListScreenItem = memo(({id}: {id: string}) => {
const item = useSelector((state) => selectItem(state, id))
// ...
})3. Мемоизированные селекторы с параметрами
С мемоизированными селекторами важно понимать, где эта мемоизация хранится. Библиотека Reselect по умолчанию хранит данные в самом селекторе, и только последнее значение для последних входных параметров. Если несколько замонтированных компонентов используют его с разными параметрами или разными сторами - он будет постоянно пересчитываться:
const reselectOptions = createSelector(
(state: State) => state.items,
(_, id: string) => id,
(items, id) => items.find(x => x.id === id)?.options,
)
const Item = ({id}: {id: string}) => {
const options = useSelector((x) => reselectOptions(x, id))
// ...
}
const Screen = () => {
// Два одновременно замонтированных компонента полностью нивелируют мемоизацию,
// приводя к постоянному пересчету селекторов.
return (
<>
<Item id={id1}/>
<Item id={id2}/>
</>
)
}Самое банальное решение - кэшировать селектор в компоненте.
const Button = ({id}: {id: string}) => {
const reselectOptions = useMemo(() => createReselectOptions(id), [id])
const options = useSelector(reselectOptions)
}Либо почитать документацию с другими вариантами настройки LRU или WeakMap кэширования.
4. Immer
Один из лучших способов ухудшить производительность иммутабельных сторов в 100 раз - использовать Immer. И да, он по умолчанию и без возможности отключения используется в слайсах RTK и в RTK Query. Значит и они нам не подходят - для RTK используем ванильные reducer.
5. Не пишем бойлерплейт
Многих ошибок можно банально избежать, если из раза в раз не реализовывать самим загрузку и кэширование данных. Уже написано много библиотек, начиная с Tanstack Query и заканчивая ApolloClient. Проблема разве что в том, что все они используют свои отдельные закрытые сторы без прямого доступа. Исключение - RTK Query, но мы ранее уже определились, что его использовать не стоит (и не только по причине плохой производительности).
Отличное решение для Zustand / Redux - библиотека RRC, генерирующая все необходимые функции, включая хуки, для кэширования предоставленных асинхронных операций. Имеет максимально простой, но гибкий интерфейс, полную типизацию, более 100 юнит тестов, иммутабельный стор на выбор (Redux/Zustand), поддерживает нормализацию, дедупликацию, бесконечную пагинацию, мутабельные коллекции (см. далее) и многое другое. И конечно не использует Immer - производительность в приоритете.
6. Разбиваем сторы
В большинстве проектов нет проблем с хранением данных в одном сторе. Но если планируется много одновременно замонтированных экранов под большой нагрузкой - имеет смысл разбить иммутабельные сторы так, чтобы уменьшить количество подписчиков (время записи в стор включает как изменение состояния, так и уведомление подписчиков и вычисления селекторов), а также чуть меньше сравнивать строки (action.type в Redux) и поверхностно копировать корневые объекты. Как вариант - разбить модель на независимые друг от друга домены и создать стор на каждый из них.
Причем не обязательно использовать только один тип сторов, а наоборот, выбрать подходящий под определенный тип данных.
Варианты разбиения на сторы:
Тема приложения (светлая, темная) - можно хранить в
React.Context. Изменяется редко, может быть подписан чуть ли не каждый компонент, поэтому хранить такие вещи в едином сторе - не лучшая идея.Метаданные, ненужные для рендера, можно хранить в мутабельном виде - не подписан никто, меняются часто.
Отдельные иммутабельные сторы на:
Настройки, feature-флаги для залогиненного пользователя - подписаны много компонентов, изменяется редко, восстанавливается/очищается на логин/логаут.
Локальные глобальные настройки для всех пользователей (Дебаг меню) - редкие подписки, восстанавливается на старте приложения, не очищается на логаут.
Доменная модель и состояния загрузки для отдельных групп экранов (Календарь, Звонки и т.д.) - подписана только соответствующая группа экранов, может восстанавливаться/очищаться на их открытие/закрытие и/или на логаут.
Доменная модель и состояния загрузки для всех экранов (сущности Пользователь, Канал и т.п.) - подписано много компонентов, изменяется часто, восстанавливается/очищается на логин/логаут. Особо требователен к производительности, см. секцию "Копирование данных".
И да, Redux тоже поддерживает несколько сторов, хоть и не очень удобно и не очень документированно.
Возражения
1. В документации Redux не советуют так делать, значит не надо.
За свой код отвечает разработчик, а не команда Redux. Команда Mattermost Mobile V1 поплатилась за это - пришлось все переписывать, но опять вышло плохо - все таки нужно было не "кровати переставлять".
2. Лучше тогда выбрать другой вид сторов.
Если хронически не понимать как работают технологии и как их эффективно использовать, то переписывание на что-то другое не гарантированно решает все проблемы. А для Zustand разбиение на сторы и есть самый верный путь.
3. Большинству проектов это не нужно.
Да.
7. Ненужные подписки
Код станет немного эффективнее, если не подписываться на данные, которые не нужны для отрисовки. Например, находятся в обработчиках нажатия.
const Button = () => {
const store = useStore()
// Так делать не нужно:
// const worldName = useSelector(selectWorldName)
// const currentId = useSelector(selectCurrentId)
const onPress = () => {
// Подписка никак не обновит алерт.
Alert.alert(`Hello, ${selectWorldName(store.getState())}!`)
// И здесь не нужна.
const currentId = selectCurrentId(store.getState())
fetchData(currentId)
}
}Возражения
1. Это не особо поможет, но придется еще и об этом думать.
Когда пишешь код, то лучше думать всегда - стоит развивать эту привычку. Также, мелкие оптимизации могут быть незаметны по одиночке, но отлично работают, если делаются повсеместно.
8. Копирование данных
Асимптотическая сложность добавления элемента в стандартную коллекцию (массив, объект) иммутабельного стора - O(N), так как оно требует поверхностного копирования (обычно через оператор ...). Плюсом требуется еще и собрать мусор - всю предыдущую коллекцию. Для большинства проектов это не должно быть проблемой, так как размер коллекций редко превышает 1000, а расход батарейки не особо важен. Но для огромных проектов это может стать проблемой, и есть способ превратить O(N) в O(log N), и даже в O(1).
8.1. O (log N) - Immutable.js
Тут все просто - заменяем массивы на List, а объекты на Map из библиотеки immutable. Кратко о реализации - теперь мы работаем с древовидной структурой, где размер массивов-"листьев" не превышает определенного значения (обычно 32). В случае с List - каждый лист дерева это массив до 32 элементов, а при превышении порога увеличивается вложенность - создается новая ветка, где один из листьев - старый массив. Например, чтобы хранить миллиард значений требуется всего лишь 6 уровней вложенности.
Минусы тоже есть - медленнее для небольших коллекций, сериализуемость, отладка, новое API. Лучше почитать здесь.
Возражения
1. Опять учить новую библиотеку, переписывать пол проекта, а по итогу .toJS() будут писать везде и станет только хуже.
Нет уверенности в проблемах с производительностью, процессах разработки и компетенции команды выучить небольшую библиотеку - не стоит туда лезть. Стоит начать с профайлинга и базовых оптимизаций из других пунктов.
8.2. O(1) - Мутабельные коллекции
Нет копирования - нет проблем (почти). Когда понимаешь как работает технология - делать местечковые оптимизации не проблема, но многие не до конца понимают и, разумеется, боятся. Использование иммутабельного стора обычно не запрещает мутабельные данные, а лишь не дает возможность на них напрямую подписаться, а также пользоваться такими инструментами, как отладка с путешествием во времени (кто-то этим пользуется?). Но подписаться всегда можно не напрямую, а на специальный ключ изменения:
type State = {
// Мутабельный список id элементов, именуем с _ - нельзя подписаться напрямую.
_order: string[]
// Меняем orderChangeKey вместе с массивом.
orderChangeKey: number
}
const mergeLastPageOrder = (itemIds: string[]) => {
const {_order, orderChangeKey} = getState()
_order.push(...itemIds)
setState({orderChangeKey: orderChangeKey + 1})
}
const useOrder = () => {
const changeKey = useSelector(selectOrderChangeKey) // Подписка только на changeKey.
return [useStore().getState()._order, changeKey]
}Есть еще пара особенностей:
Относится к такой коллекции как к аналогу
useRef(), и передавать вместо нееchangeKey, где нужна зависимость от изменения.Учитывать, что значение обновляется сразу, а
propsкомпонентов только после рендеринга. Лучше искать не по индексу, переданному черезprops, а по id, и предполагать что элемента может не быть (так и без мутабельности стоит делать).
Из плюсов, кроме производительности - отсутствуют специфические минусы коллекций из Immutable.js.
Недавно как раз вышло обновление 0.22.2 для библиотеки RRC, которое дает возможность одной опцией сделать все ее внутренние коллекции мутабельными. Остальной код при этом менять чаще всего не потребуется, так как подписок на ее внутренние коллекции по-хорошему быть не должно. Имеется также бенчмарк, демонстрирующий когда это имеет смысл (кратко и упрощенно - для коллекций > 1000 элементов).
Результаты замеров времени добавления элемента в коллекцию в зависимости от размера коллекции, в микросекундах (чем меньше, тем лучше):
Размер коллекции | 0 | 1 000 | 10 000 | 100 000 | 1 000 000 |
|---|---|---|---|---|---|
Иммутабельный reducer | 1.57 | 1.81 | 7.62 | 103.82 | 1457.89 |
Мутабельный reducer | 1.4 | 1.15 | 0.65 | 1.03 | 0.76 |
Возражения
1. Это не по документации, так делать нельзя!
Да, я не советую так делать постоянно - маловероятно, что проблемы с производительностью в этом. Но знать про такую возможность стоит, как и в целом про возможность хранить мутабельные данные в таких сторах - например метаданные, которые должны чиститься вместе со стором. Либо использовать как способ оптимизации больших коллекций, если нет желания тащить библиотеку Immutable.js.
9. Персистентность
Ох уж этот redux-persist, который во многих проектах настроен так криво, что сохраняет не только отдельные reducer, но и дополнительно целиком все состояние...
Кратко:
Персистентность далеко не всегда нужна.
Если состояние компактное, прекрасно целиком помещается в оперативную память и быстро загружается с диска, то и стоит загружать его целиком на старте.
Код простой и синхронный - не работаем с персистентным хранилищем напрямую, только через изменение стора.
Используем любое надежное key/value хранилище, не кэширующее состояние в оперативной памяти - мы это и так делаем. Сериализуем в JSON.
Разбиваем хранение состояния на отдельные ключи для оптимизации большого стора - не храним все под одним. Учитываем размер и частоту изменения.
Используем debounce.
Если состояние слишком большое для загрузки всего на старте, то можно использовать, как вариант, SQL базу данных.
И главное - логируем и замеряем операции с диском, чтобы не было ситуации из первого абзаца.
10. Требовательные к отзывчивости интерфейса операции
Все-таки нужно понимать, что иммутабельные сторы не предназначены для двусторонней связи пользовательского ввода в текстовых полях или анимации координат перетаскиваемого по экрану объекта. Для максимально частых и критичных обновлений интерфейса с минимальной задержкой нужно использовать более императивный подход, в идеале не требующий даже перерендера VDOM. Например, в React Native для этого часто используется react-native-reanimated.
Итого
В этой статье мы рассмотрели неочевидные и довольно частые - критичные и не очень, проблемы с производительностью иммутабельного кода, встречающиеся в большинстве проектов. На каждый пункт можно было бы написать отдельную статью, но я постарался ужать как можно сильнее. Если что-то упустил - давайте обсуждать в комментариях.
Стоит иметь в виду, что если ваш проект небольшой, то не нужно слепо следовать всем советам - часть из них требуется лишь в высоконагруженных приложениях, либо в React Native - запаса производительности на старых андроидах не так много, и о батарейке забывать не стоит.
И конечно нужно понимать что такое преждевременная оптимизация:
Больше сложного, нетипичного кода, требующего использования нестандартных подходов и библиотек в небольшом проекте? Да, похоже на то.
Код, написанный оптимально для основных библиотек? Нет, это обязанность разработчика.
