Почему производительность списков — это всегда больная тема
Списки — сердце практически любого мобильного приложения. Лента новостей, каталог товаров, чат, контакты, история транзакций — всё это, по сути, списки. И в React Native эта задача сложнее, чем в чисто нативной разработке: между JS-потоком и нативным UI стоит слой абстракции, и любая неэффективность мгновенно превращается в дропы FPS и раздражающие «белые пробелы» при прокрутке.
Честно говоря, ещё пару лет назад ситуация со списками в RN была так себе. Но в 2026-м экосистема наконец-то предлагает зрелые решения.
Встроенный FlatList никуда не делся — он по-прежнему отправная точка. Но FlashList v2 от Shopify (полностью переписанный под New Architecture) и Legend List 1.0 с его уникальным подходом — это уже принципиально другой уровень. В этом руководстве разберём каждый компонент, сравним архитектуру, покажем рабочие примеры и поможем выбрать подходящий инструмент для вашего проекта.
Как работает FlatList и почему он тормозит
Архитектура виртуализации
FlatList построен поверх VirtualizedList и использует стратегию виртуализации: он рендерит только те элементы, что видны на экране, плюс небольшой буфер. Элементы, уходящие за пределы видимости, полностью размонтируются — так экономится память.
Проблема в том, что при каждом скролле новые элементы создаются с нуля. Даже если элемент уже был смонтирован раньше — FlatList пересоздаст его заново: полный цикл рендеринга, useEffect-хуки, вся логика. При быстрой прокрутке это приводит к просадкам FPS и белым пробелам (так называемые blank areas). Знакомо, правда?
Ключевые пропсы для оптимизации FlatList
Прежде чем бежать за альтернативами, стоит попробовать выжать максимум из того, что есть. Вот главные рычаги оптимизации.
getItemLayout — самая важная оптимизация
Если элементы вашего списка имеют одинаковую высоту, getItemLayout — это, пожалуй, самое значительное улучшение, которое можно сделать. Он избавляет FlatList от необходимости измерять каждый элемент асинхронно:
const ITEM_HEIGHT = 72;
const getItemLayout = (data, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
});
<FlatList
data={items}
renderItem={renderItem}
getItemLayout={getItemLayout}
keyExtractor={(item) => item.id}
/>
Без getItemLayout FlatList вынужден измерять высоту каждого элемента по мере рендеринга. При быстрой прокрутке система просто не успевает — и появляются пустые промежутки.
windowSize, initialNumToRender и maxToRenderPerBatch
Эти три пропса работают в связке — они определяют, сколько элементов живёт в памяти и как быстро рендерятся новые:
<FlatList
data={items}
renderItem={renderItem}
keyExtractor={(item) => item.id}
// Количество «экранов» элементов в памяти (дефолт: 21)
// Уменьшите до 5-7 для экономии памяти
windowSize={5}
// Количество элементов для первого рендера (дефолт: 10)
// Если на экране видно 5 элементов — поставьте 5
initialNumToRender={5}
// Элементов за один «батч» при прокрутке (дефолт: 10)
maxToRenderPerBatch={10}
// Задержка между батчами в мс
updateCellsBatchingPeriod={100}
// Особенно полезен на Android — скрывает элементы за пределами viewport
removeClippedSubviews={true}
/>
windowSize контролирует, сколько «экранов» контента FlatList хранит в памяти. Значение по умолчанию — 21 (это 10 экранов выше и 10 ниже текущей позиции). Для большинства приложений хватит 5–7, и это ощутимо снизит расход памяти. Правда, при очень быстрой прокрутке могут проскакивать кратковременные белые пробелы — тут уж компромисс.
Мемоизация: React.memo, useCallback и keyExtractor
Одна из самых распространённых ошибок — определять renderItem прямо внутри JSX. Это создаёт новую функцию при каждом рендере родительского компонента, и FlatList вынужден перерисовывать все видимые элементы. Казалось бы, мелочь, а на деле — серьёзный удар по производительности.
import React, { useCallback, memo } from 'react';
import { FlatList, View, Text, Image, StyleSheet } from 'react-native';
// Выносим элемент списка в отдельный мемоизированный компонент
const ProductItem = memo(({ item }) => (
<View style={styles.item}>
<Image
source={{ uri: item.thumbnail }}
style={styles.thumbnail}
/>
<View style={styles.info}>
<Text style={styles.title}>{item.name}</Text>
<Text style={styles.price}>{item.price} ₽</Text>
</View>
</View>
));
export default function ProductList({ products }) {
// Стабильная ссылка на renderItem через useCallback
const renderItem = useCallback(
({ item }) => <ProductItem item={item} />,
[]
);
// Стабильная ссылка на keyExtractor
const keyExtractor = useCallback((item) => item.id, []);
return (
<FlatList
data={products}
renderItem={renderItem}
keyExtractor={keyExtractor}
getItemLayout={(data, index) => ({
length: 80,
offset: 80 * index,
index,
})}
/>
);
}
const styles = StyleSheet.create({
item: { flexDirection: 'row', padding: 12, height: 80 },
thumbnail: { width: 56, height: 56, borderRadius: 8 },
info: { marginLeft: 12, justifyContent: 'center' },
title: { fontSize: 16, fontWeight: '600' },
price: { fontSize: 14, color: '#666', marginTop: 4 },
});
Важный момент: keyExtractor должен возвращать уникальный, стабильный идентификатор, а не индекс массива. Использование index.toString() — классическая ошибка, которая приводит к некорректным ре-рендерам при изменении данных.
FlashList v2: революция списков для New Architecture
Что изменилось в v2
FlashList v2 — это не просто обновление, а полная переработка с нуля, специально для New Architecture. Ключевое отличие от v1: теперь это 100% JavaScript-решение без нативных модулей. Это упрощает поддержку, а производительность при этом стала ещё лучше.
Главная фишка FlashList (и в v1, и в v2) — стратегия переработки ячеек (cell recycling). Вместо того чтобы уничтожать компоненты при скролле, FlashList поддерживает пул и переиспользует их, обновляя только данные. По сути, тот же подход, что в нативных UITableView (iOS) и RecyclerView (Android). И это работает потрясающе.
Что конкретно изменилось в v2:
- Не нужны оценки размеров: Проп
estimatedItemSizeиз v1 удалён — v2 автоматически измеряет элементы при первом рендере и кеширует результаты - Masonry-макет как проп:
MasonryFlashListупразднён — теперь достаточно передатьmasonry={true} - Адаптивный алгоритм: Вместо фиксированного окна рендеринга v2 учитывает скорость и направление прокрутки, а также производительность устройства. На практике это означает до 50% меньше пустых областей при скролле
- maintainVisibleContentPosition по умолчанию: Автоматически корректирует позицию прокрутки при добавлении элементов сверху — для чатов это просто спасение
Важный нюанс: FlashList v2 работает только с New Architecture. Если ваш проект ещё на старой — оставайтесь на FlashList v1.x.
Установка и базовый пример
# Установка
npm install @shopify/flash-list@^2.0.0
# или
yarn add @shopify/flash-list@^2.0.0
Вот базовый пример — заметьте, насколько API похож на FlatList:
import React, { useCallback } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { FlashList } from '@shopify/flash-list';
const data = Array.from({ length: 10000 }, (_, i) => ({
id: String(i),
title: `Элемент ${i}`,
subtitle: `Описание элемента ${i}`,
}));
export default function PerformantList() {
const renderItem = useCallback(({ item }) => (
<View style={styles.item}>
<Text style={styles.title}>{item.title}</Text>
<Text style={styles.subtitle}>{item.subtitle}</Text>
</View>
), []);
return (
<FlashList
data={data}
renderItem={renderItem}
keyExtractor={(item) => item.id}
// estimatedItemSize больше НЕ нужен в v2!
// FlashList сам измерит элементы
/>
);
}
const styles = StyleSheet.create({
item: { padding: 16, borderBottomWidth: 1, borderBottomColor: '#eee' },
title: { fontSize: 16, fontWeight: '600' },
subtitle: { fontSize: 14, color: '#888', marginTop: 4 },
});
Миграция с FlashList v1 на v2
Переход с v1 на v2 не такой уж болезненный, но есть несколько вещей, которые стоит знать:
// БЫЛО (FlashList v1)
import { FlashList, MasonryFlashList } from '@shopify/flash-list';
const listRef = useRef<FlashList<ItemType>>(null);
<FlashList
estimatedItemSize={80} // удалить
estimatedListSize={{ ... }} // удалить
inverted={true} // заменить
onBlankArea={callback} // удалить
renderItem={renderItem}
data={data}
/>
// СТАЛО (FlashList v2)
import { FlashList, FlashListRef } from '@shopify/flash-list';
const listRef = useRef<FlashListRef<ItemType>>(null);
<FlashList
// estimatedItemSize — удалён, v2 измеряет сам
// inverted — удалён, используйте:
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
renderItem={renderItem}
data={[...data].reverse()} // переверните данные вручную
/>
Полный список удалённых пропсов: estimatedItemSize, estimatedListSize, estimatedFirstItemOffset, inverted, onBlankArea, disableHorizontalListHeightMeasurement, disableAutoLayout. Много всего, но по факту вы просто удаляете код — а не добавляете.
Продвинутые возможности FlashList v2
Для списков с разными типами элементов используйте getItemType — он позволяет FlashList создавать отдельные пулы для переработки разных компонентов. Это важная оптимизация, которую многие почему-то пропускают:
import React, { useCallback } from 'react';
import { View, Text, Image, StyleSheet } from 'react-native';
import { FlashList } from '@shopify/flash-list';
// Разные типы элементов: заголовок секции и обычный элемент
const SectionHeader = ({ item }) => (
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{item.title}</Text>
</View>
);
const ProductCard = ({ item }) => (
<View style={styles.productCard}>
<Image source={{ uri: item.image }} style={styles.productImage} />
<Text style={styles.productName}>{item.name}</Text>
<Text style={styles.productPrice}>{item.price} ₽</Text>
</View>
);
export default function CatalogList({ items }) {
const renderItem = useCallback(({ item }) => {
if (item.type === 'header') return <SectionHeader item={item} />;
return <ProductCard item={item} />;
}, []);
// getItemType позволяет FlashList создавать отдельные пулы
// для переработки разных типов компонентов
const getItemType = useCallback((item) => item.type, []);
return (
<FlashList
data={items}
renderItem={renderItem}
getItemType={getItemType}
keyExtractor={(item) => item.id}
/>
);
}
А для Pinterest-подобных сеток в v2 всё стало ещё проще — один проп:
<FlashList
data={photos}
renderItem={renderItem}
masonry={true}
numColumns={2}
keyExtractor={(item) => item.id}
/>
Legend List 1.0: новый подход к спискам
Что такое Legend List
Legend List — высокопроизводительный компонент от команды LegendApp, целиком написанный на TypeScript без нативных зависимостей. Позиционируется как drop-in замена и FlatList, и FlashList.
Главная особенность — опциональная переработка. По умолчанию Legend List использует виртуализацию (как FlatList), но если включить recycleItems={true}, переключается на recycling (как FlashList). Это даёт гибкость: для простых элементов — recycling ради скорости, для сложных со своим стейтом — виртуализация ради безопасности. По-моему, очень элегантное решение.
Что ещё выделяет Legend List 1.0:
- Двунаправленный бесконечный скролл: Прокрутка в обоих направлениях без рывков и «прыжков» контента
- Чат-интерфейсы без инверсии: Пропсы
alignItemsAtEndиmaintainScrollAtEndпозволяют строить чат без костыля сinverted, который ломает анимации - 100% JS: Работает и на старой, и на новой архитектуре
- Динамические размеры: Элементы разной высоты — не проблема, и без потери производительности
Установка и примеры
# npm
npm install @legendapp/list
# yarn
yarn add @legendapp/list
# expo
npx expo install @legendapp/list
Вот базовый пример чата с Legend List:
import React, { useCallback } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { LegendList } from '@legendapp/list';
const messages = Array.from({ length: 5000 }, (_, i) => ({
id: String(i),
text: `Сообщение ${i}`,
sender: i % 2 === 0 ? 'me' : 'other',
}));
export default function ChatScreen() {
const renderItem = useCallback(({ item }) => (
<View style={[
styles.bubble,
item.sender === 'me' ? styles.myBubble : styles.otherBubble,
]}>
<Text style={styles.messageText}>{item.text}</Text>
</View>
), []);
return (
<LegendList
data={messages}
renderItem={renderItem}
keyExtractor={(item) => item.id}
// Включаем recycling для производительности
recycleItems={true}
// Контент прижат к низу — идеально для чата
alignItemsAtEnd={true}
// Автоскролл при новых сообщениях
maintainScrollAtEnd={true}
// Корректировка позиции при добавлении элементов сверху
maintainVisibleContentPosition={true}
/>
);
}
const styles = StyleSheet.create({
bubble: { padding: 12, marginVertical: 4, marginHorizontal: 16,
borderRadius: 16, maxWidth: '75%' },
myBubble: { backgroundColor: '#6C63FF', alignSelf: 'flex-end' },
otherBubble: { backgroundColor: '#E8E8E8', alignSelf: 'flex-start' },
messageText: { fontSize: 15, color: '#333' },
});
Оптимизация Legend List для тяжёлых списков
Для максимальной производительности Legend List предлагает тонкую настройку:
<LegendList
data={items}
renderItem={renderItem}
keyExtractor={(item) => item.id}
// Включить переработку компонентов
recycleItems={true}
// Категоризация типов для более умной переработки
getItemType={(item) => item.type}
// Фиксированный размер — отключает измерение, максимум производительности
getFixedItemSize={() => 80}
// Или оценочный размер для элементов разной высоты
getEstimatedItemSize={() => 100}
/>
getFixedItemSize — штука мощная. Если знаете точную высоту элементов, он полностью отключает измерение, и вы получаете максимальную производительность. Для элементов с динамической высотой используйте getEstimatedItemSize — Legend List подскажет оптимальное значение в логах (удобно).
Сравнение: FlatList vs FlashList v2 vs Legend List
Итак, давайте посмотрим на всё это в одной таблице:
| Параметр | FlatList | FlashList v2 | Legend List 1.0 |
|---|---|---|---|
| Стратегия | Виртуализация | Cell Recycling | Виртуализация + опциональный Recycling |
| Встроенный | Да | Нет | Нет |
| Нативный код | Нет | Нет (v2) | Нет |
| New Architecture | Обе | Только новая | Обе |
| Оценки размеров | getItemLayout | Автоматически | Опционально |
| Masonry-макет | Нет | Да (проп) | Нет |
| Чат без инверсии | Нет | Частично | Да (нативно) |
| Двунаправленный скролл | Нет | Нет | Да |
| Производительность | Базовая | В 5–10× быстрее | Сопоставимо с FlashList |
| Размер пакета | 0 КБ (встроен) | Средний | Минимальный |
Когда какой выбирать
FlatList — ваш выбор, если:
- Список небольшой (до 100 элементов) и элементы простые
- Нужны встроенные фичи: SectionList, header/footer, pull-to-refresh
- Не хотите добавлять зависимости
- Производительность и так устраивает
FlashList v2 — если:
- Проект на New Architecture (React Native 0.76+)
- Списки от 100 элементов и больше
- Нужен masonry-макет (Pinterest-стиль)
- Критична производительность на Android
- Хотите проверенное решение — FlashList используется в Shopify и тысячах других приложений
Legend List — если:
- Нужна поддержка обеих архитектур
- Строите чат — пропсы
alignItemsAtEndиmaintainScrollAtEndтут незаменимы - Нужен двунаправленный бесконечный скролл
- Хотите минимальный размер зависимости
- Элементы со сложным локальным стейтом (можно отключить recycling)
Практические сценарии оптимизации
Сценарий 1: каталог товаров с изображениями
Каталог с карточками товаров — один из самых типичных сценариев. Главная засада здесь — тяжёлые элементы с картинками:
import React, { useCallback, memo } from 'react';
import { View, Text, Image, Pressable, StyleSheet } from 'react-native';
import { FlashList } from '@shopify/flash-list';
// Мемоизированный компонент карточки товара
const ProductCard = memo(({ item, onPress }) => (
<Pressable onPress={() => onPress(item.id)} style={styles.card}>
<Image
source={{ uri: item.imageUrl }}
style={styles.image}
// Используйте resizeMode для оптимизации отрисовки
resizeMode="cover"
/>
<View style={styles.cardContent}>
<Text style={styles.name} numberOfLines={2}>
{item.name}
</Text>
<Text style={styles.price}>{item.price} ₽</Text>
{item.discount > 0 && (
<Text style={styles.discount}>-{item.discount}%</Text>
)}
</View>
</Pressable>
));
export default function ProductCatalog({ products, onProductPress }) {
const renderItem = useCallback(
({ item }) => <ProductCard item={item} onPress={onProductPress} />,
[onProductPress]
);
return (
<FlashList
data={products}
renderItem={renderItem}
keyExtractor={(item) => item.id}
numColumns={2}
// getItemType помогает FlashList эффективнее
// переиспользовать компоненты одного типа
getItemType={(item) =>
item.hasDiscount ? 'discounted' : 'regular'
}
/>
);
}
Сценарий 2: бесконечная лента с пагинацией
Классический паттерн бесконечной прокрутки с подгрузкой данных. Этот подход работает одинаково хорошо со всеми тремя компонентами:
import React, { useState, useCallback } from 'react';
import { View, Text, ActivityIndicator, StyleSheet } from 'react-native';
import { FlashList } from '@shopify/flash-list';
export default function InfiniteFeed() {
const [items, setItems] = useState(initialItems);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const loadMore = useCallback(async () => {
if (loading) return;
setLoading(true);
try {
const nextPage = page + 1;
const newItems = await fetchItems(nextPage);
setItems((prev) => [...prev, ...newItems]);
setPage(nextPage);
} finally {
setLoading(false);
}
}, [loading, page]);
const renderItem = useCallback(({ item }) => (
<View style={styles.feedItem}>
<Text style={styles.feedTitle}>{item.title}</Text>
<Text style={styles.feedBody}>{item.body}</Text>
</View>
), []);
const renderFooter = useCallback(() => {
if (!loading) return null;
return (
<View style={styles.footer}>
<ActivityIndicator size="small" color="#6C63FF" />
</View>
);
}, [loading]);
return (
<FlashList
data={items}
renderItem={renderItem}
keyExtractor={(item) => item.id}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
ListFooterComponent={renderFooter}
/>
);
}
Сценарий 3: чат-интерфейс с Legend List
Чат — пожалуй, самый сложный сценарий для списков. Традиционный подход с inverted={true} в FlatList тянет за собой кучу проблем: инвертированные жесты, сломанные анимации, неправильный порядок для accessibility. Legend List решает всё это элегантно:
import React, { useState, useCallback } from 'react';
import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native';
import { LegendList } from '@legendapp/list';
export default function ChatScreen() {
const [messages, setMessages] = useState(initialMessages);
const [inputText, setInputText] = useState('');
const sendMessage = useCallback(() => {
if (!inputText.trim()) return;
const newMsg = {
id: Date.now().toString(),
text: inputText,
sender: 'me',
timestamp: new Date(),
};
setMessages((prev) => [...prev, newMsg]);
setInputText('');
}, [inputText]);
const renderItem = useCallback(({ item }) => (
<View style={[
styles.messageBubble,
item.sender === 'me' ? styles.myMessage : styles.theirMessage,
]}>
<Text style={styles.messageText}>{item.text}</Text>
<Text style={styles.timestamp}>
{new Date(item.timestamp).toLocaleTimeString('ru-RU', {
hour: '2-digit', minute: '2-digit',
})}
</Text>
</View>
), []);
return (
<View style={styles.container}>
<LegendList
data={messages}
renderItem={renderItem}
keyExtractor={(item) => item.id}
recycleItems={true}
// Контент прижат к низу — как в настоящем чате
alignItemsAtEnd={true}
// Автоматический скролл при новом сообщении
maintainScrollAtEnd={true}
// Коррекция позиции при загрузке старых сообщений сверху
maintainVisibleContentPosition={true}
/>
<View style={styles.inputContainer}>
<TextInput
style={styles.input}
value={inputText}
onChangeText={setInputText}
placeholder="Сообщение..."
/>
<Pressable onPress={sendMessage} style={styles.sendButton}>
<Text style={styles.sendText}>→</Text>
</Pressable>
</View>
</View>
);
}
Профилирование и измерение производительности
Оптимизация без измерений — это гадание на кофейной гуще. Давайте разберём, чем конкретно измерять.
Performance Monitor
Встроенный Performance Monitor показывает FPS UI-потока и JS-потока в реальном времени. Откройте Dev Menu → «Perf Monitor». Цель — стабильные 60 FPS на обоих потоках при скролле. Если JS FPS падает ниже 45 — пора оптимизировать.
React DevTools Profiler
React DevTools Profiler — ваш друг для анализа ре-рендеров. Запускаете запись, скроллите список, останавливаете и смотрите: какие компоненты перерисовались и почему. Самая частая находка — элементы перерисовываются при каждом скролле из-за нестабильных ссылок на функции или объекты.
Советы по профилированию
- Тестируйте на реальных устройствах: На симуляторе и мощном iPhone всё выглядит отлично — возьмите средний Android и прослезитесь
- Тестируйте с реальным объёмом данных: 20 элементов никогда не покажут проблему — загрузите 1000+
- Release-сборки: Debug-режим добавляет заметный оверхед, поэтому для точных замеров профилируйте релизную сборку
- Следите за blank areas: Белые пробелы при быстрой прокрутке — главный симптом проблем с производительностью списка
Миграция с FlatList: пошаговый чеклист
Если FlatList тормозит и вы решили переезжать — вот чеклист, чтобы ничего не забыть:
- Определите архитектуру: New Architecture → FlashList v2 или Legend List. Старая → Legend List или FlashList v1
- Определите сценарий: Чат → Legend List. Каталог/лента → FlashList v2. Masonry → FlashList v2
- Установите пакет и замените импорт
- Удалите ненужные пропсы: FlashList v2 не использует
getItemLayout,windowSize,initialNumToRenderи другие пропсы виртуализации - Добавьте
keyExtractor— обязательно для обоих компонентов - Для FlashList: добавьте
getItemTypeпри наличии разных типов элементов - Для Legend List: включите
recycleItems={true}, если элементы не содержат сложного стейта - Мемоизируйте: Оберните
renderItemвuseCallback, компоненты элементов — вReact.memo - Протестируйте на реальном устройстве с большим объёмом данных
FAQ: частые вопросы
Почему FlatList тормозит при быстрой прокрутке?
Потому что он размонтирует элементы при выходе из viewport и создаёт заново при возвращении. При быстром скролле JS-поток не успевает — появляются белые пробелы и падает FPS. Первым делом оптимизируйте пропсы (getItemLayout, windowSize, maxToRenderPerBatch) и мемоизируйте компоненты. Если не помогло — переходите на FlashList или Legend List.
Можно ли использовать FlashList v2 со старой архитектурой?
Нет. FlashList v2 требует New Architecture и на старой просто не запустится. Варианты: FlashList v1.x (@shopify/flash-list@^1.0.0) или Legend List, который работает на обеих.
В чём разница между виртуализацией и cell recycling?
Виртуализация (FlatList) — это создание и уничтожение компонентов при скролле. Cell recycling (FlashList) — это переиспользование уже существующих компонентов с новыми данными. Recycling быстрее, потому что не нужно тратить ресурсы на mount/unmount. Грубо говоря, вместо «сломать и построить заново» — «поменять табличку на двери».
Как выбрать между FlashList v2 и Legend List?
FlashList v2 — более зрелый и проверенный вариант, особенно хорош для каталогов, лент и masonry. Legend List выигрывает в чат-сценариях (благодаря alignItemsAtEnd и maintainScrollAtEnd) и в проектах на старой архитектуре. Оба дают существенный прирост по сравнению с FlatList.
Нужно ли указывать estimatedItemSize в FlashList v2?
Нет, этот проп удалён. FlashList v2 измеряет элементы автоматически и кеширует результаты. Это одно из главных улучшений — больше никакого подбора «волшебных чисел». Просто не забудьте про keyExtractor.