Почему тормозит FlatList и как это чинить
Любимая практическая задача интервьюеров: «лента тормозит при быстром скролле — что будете делать?»
Виртуализация — приём, при котором на экране существуют только видимые элементы списка и небольшой запас вокруг них. Остальные не создаются или уничтожаются.
Вопрос
«Список из тысячи карточек с картинками дёргается при прокрутке и иногда показывает пустые места. Назовите причины и способы починки.»
Что на самом деле проверяет интервьюер
Умеете ли вы рассуждать системно, а не перечислять пропсы наизусть. Хороший ответ строится по слоям: данные, ячейка, сам список, изображения. И начинается с измерения, а не с гаданий.
Развёрнутый ответ
Слой 1. Правильный список
Первое, что нужно проверить, — не рендерится ли список через ScrollView и map. Такой список создаёт все элементы сразу: тысяча карточек — тысяча наборов нативных view и мгновенная просадка памяти. Для длинных данных нужен FlatList или SectionList.
Слой 2. Стабильные ссылки
// ПЛОХО: новые функции и новый объект стиля на каждом рендере
<FlatList
data={items}
renderItem={({ item }) => <Row item={item} onPress={() => open(item.id)} />}
keyExtractor={(item, index) => String(index)}
/>
// ХОРОШО
const Row = React.memo(function Row({ item, onPress }) {
return (
<Pressable onPress={() => onPress(item.id)} style={styles.row}>
<Text numberOfLines={1}>{item.title}</Text>
</Pressable>
);
});
const keyExtractor = (item) => item.id;
const renderItem = useCallback(
({ item }) => <Row item={item} onPress={handleOpen} />,
[handleOpen]
);
<FlatList data={items} renderItem={renderItem} keyExtractor={keyExtractor} />
Слой 3. Подсказки самому списку
Если высота ячейки известна и постоянна, getItemLayout избавляет список от измерений при прокрутке и делает scrollToIndex точным:
const ITEM_HEIGHT = 72;
const getItemLayout = (data, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
});
console.log('смещение 10-го элемента:', getItemLayout(null, 10).offset);
console.log('смещение 100-го элемента:', getItemLayout(null, 100).offset);
Вывод:
смещение 10-го элемента: 720
смещение 100-го элемента: 7200
Остальные полезные пропсы:
| Проп | Что делает |
initialNumToRender | сколько элементов отрисовать до первого кадра — держите ровно на экран |
windowSize | сколько «экранов» держать вокруг видимой области (по умолчанию 21) |
maxToRenderPerBatch и updateCellsBatchingPeriod | компромисс между пустыми местами и нагрузкой на JS-поток |
removeClippedSubviews | открепляет невидимые view; помогает на Android, но иногда даёт артефакты |
onEndReachedThreshold | когда подгружать следующую страницу |
Слой 4. Содержимое ячейки
- Плоская вёрстка: чем меньше вложенных
View, тем дешевле layout. - Никаких тяжёлых вычислений в
renderItem— форматирование дат и сумм готовьте заранее, при получении данных. - Картинки нужного размера с сервера, а не оригиналы на 4000 пикселей; кэширование через
expo-imageилиfast-image. numberOfLinesу текста, чтобы высота ячейки была предсказуемой.- Тени на Android дороги — на длинных списках их часто заменяют границей или фоном.
Когда FlatList уже не спасает
Для лент с тысячами разнородных ячеек берут @shopify/flash-list: он переиспользует уже созданные view вместо создания новых, поэтому даёт заметно меньше пропущенных кадров. Взамен требует оценку размера ячейки и аккуратности с состоянием внутри ячейки (view переиспользуются). Упоминание FlashList с пониманием компромисса — сильный ход на собеседовании.
Отдельно про «пустые места»
Белые прямоугольники при быстром скролле означают, что JS-поток не успевает отрисовать ячейки. Лечится не увеличением windowSize «на всякий случай», а удешевлением ячейки и разгрузкой JS-потока. Заглушка-скелет вместо пустоты — не решение проблемы, но заметно улучшает восприятие.
Типичные ошибки кандидатов
- Сразу называют
removeClippedSubviewsкак «главную оптимизацию», не понимая её побочных эффектов. - Не упоминают мемоизацию ячейки и стабильные
renderItemиkeyExtractor. - Ставят индекс в
keyExtractor— это ломает и производительность, и состояние ячеек. - Не говорят про измерение. Правильный первый шаг — профайлер и счётчик кадров, а не подбор пропсов вслепую.
Как ответить кратко
«Сначала проверю, что это действительно виртуализированный список, а не ScrollView с map. Дальше по слоям: стабильные keyExtractor и renderItem, ячейка в React.memo, плоская вёрстка, никакого форматирования в renderItem. Списку даю getItemLayout, если высота фиксированная, и настраиваю initialNumToRender, windowSize и maxToRenderPerBatch. Картинки — правильного размера и с кэшированием. Пустые места при быстром скролле означают, что JS-поток не успевает: удешевляю ячейку, а если ячейки тяжёлые и разнородные — беру FlashList с переиспользованием view. И всё это только после профилирования.»