Почему тормозит 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. И всё это только после профилирования.»

Проверьте себя
1. Чем FlatList принципиально отличается от ScrollView со списком элементов внутри?
AНичем, FlatList — просто удобная обёртка над map
BFlatList виртуализирует список: рендерит только видимые элементы и небольшой запас вокруг них
CFlatList рендерится на нативном потоке, минуя React
DFlatList кэширует элементы на диске
2. Что даёт getItemLayout?
AПозволяет задать разную высоту каждому элементу автоматически
BСообщает списку размеры элементов заранее, избавляя от измерения при прокрутке и делая scrollToIndex мгновенным
CВключает нативную анимацию появления элементов
DУменьшает объём данных, передаваемых в data
3. Почему renderItem, объявленный стрелочной функцией прямо в JSX, мешает оптимизации?
AОн вызывает ошибку в StrictMode
BНа каждом рендере это новая ссылка, поэтому список считает, что всё изменилось, и мемоизация ячеек не работает
CСтрелочные функции не поддерживаются FlatList
DОн выполняется на UI-потоке и блокирует прокрутку