FlatList: та же идея, доведённая до конца
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Прошлая статья собрала окно своими руками — деление вместо перебора, распорки, запас — и свела его детали в таблицу, где каждой нашлась ручка FlatList. Теперь переведём на FlatList сам справочник «Подхода» и проверим таблицу запуском. Почти всё сойдётся: то же окно, то же размонтирование строк за краем. Не сбудется одно — обещание, которое прошлая статья вычитала в исходнике: про отсутствующие ключи FlatList молчит, а настоящее предупреждение про ключи придёт от самого React и по другому поводу. И найдётся то, чего у нашего окна не было: FlatList — PureComponent и может не заметить перемены, которую окно заметило бы, причём песочница такую ошибку иногда прощает, а телефон — нет. FlatList — нижний этаж, сам React Native, поэтому всё в статье выполняется в песочнице.
У нашего окна были data, renderRow, две высоты и запас. У FlatList набор почти тот же. data — массив упражнений. renderItem — функция, которая рисует одну строку. keyExtractor — функция, которая по элементу отдаёт ключ строки, строкой. key внутри renderItem писать не нужно: ключ FlatList ставит сам, на обёртку строки — ячейку. А высоту строки по умолчанию не спрашивает: меряет сам, когда строка уже нарисована.
Одно отличие от нашего renderRow: в renderItem приходит не сам элемент, а объект. Прогноз: сколько у него полей — и сколько строк нарисует первая отрисовка, если в справочнике тысяча упражнений, а в рамку влезает четыре?
Полей три. item — сам элемент data, index — его номер, separators — ручки для подсветки разделителей между строками, если они заданы через ItemSeparatorComponent. Отсюда привычная запись ({ item }) => …: из трёх полей обычно нужно одно.
Строк в первой отрисовке десять — не четыре и не тысяча. Наше окно знало высоту рамки из пропа и сразу рисовало ровно то, что в неё попадает. FlatList высоту рамки узнаёт только после раскладки, из onLayout, а до неё рисует фиксированную порцию: initialNumToRender, по умолчанию 10 строк. Потом он дорисовывает пачками. Сколько строк окажется у него в итоге и от чего это зависит, — предмет следующей статьи.
Тип аргумента renderItem в примерах записан руками, а высоту списку даёт обёртка. В проекте тип выводится из data сам (ListRenderItemInfo), а style и testID ставят прямо на FlatList. Эти пропы и тип приходят из пакета @react-native/virtualized-lists, которого в наборе типов песочницы нет. Без записи руками компилятор песочницы скажет «Элемент привязки "item" имеет неявный тип "any"», а на style и testID у FlatList — «Ни одна перегрузка не соответствует этому вызову». Код при этом выполняется.
keyExtractor в примере выше можно было не писать. Без него FlatList берёт ключ сам — функцией keyExtractor из VirtualizeUtils.js, одинаковой в React Native 0.86 и react-native-web: поле key, если оно есть, иначе поле id, иначе номер строки, String(index). У упражнения «Подхода» есть id, и справочник получил бы те же ключи. Явный keyExtractor всё равно полезен: он говорит, какое поле — ключ, а не надеется на то, что поле называется id.
Прошлая статья вычитала в исходнике обещание: если нет ни key, ни id, FlatList возьмёт номер строки и напечатает предупреждение. Проверим. С сервера пришли одни названия, строками. Прогноз: появится ли в консоли что-нибудь раньше строки «список отрисован»?
Ничего. Список молча взял номера, и обещание прошлой статьи оказалось неверным. Текст предупреждения в исходниках есть — «VirtualizedList: missing keys for items…», — но печатает его VirtualizedList, и только когда номер вместо ключа выбрал его собственный _keyExtractor, то есть когда своего keyExtractor у списка нет. А FlatList всегда передаёт вниз свой: в FlatList.js стоит keyExtractor={this._keyExtractor}, и уже этот метод сам откатывается на key, id и номер. До флажка, который включает предупреждение, дело не доходит. Так и в React Native 0.86, и в копии внутри react-native-web: про номера FlatList молчит.
Тот же список прямо на VirtualizedList, без FlatList сверху. Ему нужны ещё два пропа, getItemCount и getItem: данные у него не обязаны быть массивом. Компилятор песочницы здесь скажет, что VirtualizedList в типах нет, — этот экспорт тоже приходит из @react-native/virtualized-lists; в проекте импорт на месте, а пример выполнится и здесь. Прогноз: что напечатается теперь?
Вот оно — предупреждение из исходника. Пробел в конце строки — второй аргумент console.warn, имя компонента строки: оно заполняется, только когда элементы data — сами React-элементы, а здесь это пустая строка. Голый VirtualizedList берут для данных, которые не массив; со справочником «Подхода» его не встретить.
Настоящее предупреждение про ключи, которое достаётся пользователю FlatList, приходит от самого React — и оно не про то, что ключа нет, а про то, что он повторился. К справочнику добавили своё упражнение, а номер ему выдали отдельно, и он совпал с номером из справочника. Прогноз: что будет в консоли?
Это console.error отладочной сборки React — та же проверка, что у любого массива детей (часть IV, «Своя сверка: что на самом деле делает key»). FlatList отдал React две ячейки с ключом ex-1, и React предупреждает: при обновлениях одна из этих строк может задвоиться или пропасть. .$ex-1 — тот же ex-1, переписанный React.Children: ScrollView пропускает через него детей списка. В React Native 0.86 он делает это всегда, через toArray; в react-native-web — через map, потому что VirtualizedList всегда передаёт ему массив stickyHeaderIndices, пусть и пустой. Поэтому строка одна и та же в песочнице и на телефоне.
Отсутствия ключа FlatList не заметит, а повтор заметит React — и ловит он ровно ту беду, которая бывает на практике: ключи есть, но не уникальны. В «Подходе» номера будут рождаться в разных местах — справочник, свои упражнения, второе устройство, — и в главе 6 id станет «устройство + счётчик» как раз затем, чтобы номера не совпадали. А пока повтор лечится keyExtractor, — это вторая задача.
И последнее про ключ: keyExtractor обязан вернуть строку. Если сервер присылает id числом, запись keyExtractor={(w) => w.id} компилятор не пропустит. Сообщение длинное — «Ни одна перегрузка не соответствует этому вызову» с разбором обеих перегрузок, — а суть в последней его строке: «Тип "number" не может быть назначен для типа "string"». Этот тип компилятор песочницы знает: keyExtractor объявлен в самом FlatList.d.ts пакета react-native, а не в пакете списков. Лечится String(w.id).
Наше окно было обычным компонентом-функцией: перерисовался экран — перерисовалось и оно, и renderRow заново вызвалась для каждой строки. FlatList устроен иначе. Это классовый PureComponent, так он и объявлен в FlatList.js: он перерисовывается, только если какой-то его проп изменился при поверхностном сравнении через Object.is, — то же правило, что у memo в части IV («memo, useMemo и useCallback: что они делают»).
Пока renderItem — стрелка прямо в теле экрана, она новая на каждой перерисовке, и FlatList перерисовывается всегда. Но renderItem часто делают постоянной — выносят из компонента или оборачивают в useCallback: обычный шаг, когда список начинают ускорять. Что тогда станет со строками, которые читают не только data?
Проверим на отметках. Отметки «сделано» лежат не в data, а в отдельном множестве — так они будут жить в хранилище главы 6. Кнопка отмечает жим лёжа и перерисовывает экран. Под ней два списка с постоянными функциями строки: наше окно из прошлой статьи — с этой статьи оно в подложке, как WindowedList, — и FlatList. Прогноз: что покажет каждый после нажатия?
Своё окно отметку показало, FlatList — нет. Экран перерисовался, но пропсы FlatList — тот же массив data, та же функция renderFlat — не изменились, и PureComponent пропустил перерисовку целиком: renderFlat даже не вызывалась. Отметка в множестве есть, а на экране её не будет, пока список не перерисуется по другой причине.
Для этого у FlatList есть проп extraData: в описании пропа в FlatList.js он назван меткой, которая говорит списку перерисоваться, раз он PureComponent. Передайте туда номер версии — extraData={version} — и после нажатия FlatList покажет отметку. Сменившийся проп заставит перерисоваться сам список, а тот соберёт для ячеек новую функцию строки, и ячейки перерисуются вслед за ним. Отсюда и правило из того же описания: extraData должен меняться по ===. Номер версии годится. Множество, в которое добавили элемент, — нет, это тот же объект; об этом третья задача.
Те же отметки, но в состоянии экрана, а renderItem — стрелка прямо в разметке, и extraData нет. Прогноз: увидит ли FlatList отметку?
Увидит. Стрелка на каждой перерисовке экрана новая, проп renderItem изменился, и PureComponent пропускает список дальше. Поэтому extraData нужен не всегда, а там, где renderItem постоянна, а строка читает что-то кроме item. Цена стрелки — перерисовка списка на каждую перерисовку экрана; сколько это стоит, считает следующая статья.
Вернёмся к первому опыту раздела: постоянная функция строки, отметки во внешнем множестве, extraData нет. Только справочник теперь на тысячу строк, и после нажатия его листают дальше. Прогноз: что покажет строка жима лёжа после нажатия — и после прокрутки?
После прокрутки отметка появилась, хотя extraData так и нет. Прокрутка сдвинула окно, и VirtualizedList перерисовался по своей причине — поменялся его диапазон строк. Ячейка CellRenderer в react-native-web — обычный Component (таблица прошлой статьи): она перерисовывается вместе со списком и заново зовёт renderItem, а та читает уже новое множество. Строка жима лёжа всё ещё смонтирована, хотя до неё сотня строк, — почему, покажет следующий раздел.
На телефоне этого не случится. В React Native 0.86 ячейка — PureComponent, а _pushCells из VirtualizedList.js после прокрутки отдаёт ей тот же item, тот же index, ту же renderItem и те же методы списка. Пропсы не изменились, ячейка пропускает перерисовку, и отметки не будет, пока не перерисуется сам FlatList. Это вывод по исходнику, а не запуск на устройстве. Практический итог: строка, которая в песочнице «догоняет» после прокрутки, на телефоне сломана, и забытый extraData ищут чтением кода, а не прокруткой предпросмотра.
Таблица прошлой статьи обещала: у FlatList, как у нашего окна, строка, ушедшая за окно, размонтируется вместе со своим состоянием. Проверим тем же опытом. Отметка — локальное состояние строки. Отмечаем первое упражнение и шестнадцатое, уезжаем далеко вниз и возвращаемся.
Прокручиваем методом самого списка. В ref у FlatList лежит не ScrollView, а список, и у него свои методы прокрутки: scrollToOffset({ offset }) — к смещению, scrollToIndex({ index }) — к строке. Прогноз: какие из двух строк уйдут из дерева и что останется от отметок?
Шестнадцатое упражнение, «Подтягивания широким хватом», ушло из дерева и вернулось без отметки — ровно как в нашем окне. А первое не уходило вовсе и отметку сохранило. Это не случайность. Строки первой порции, те самые initialNumToRender, VirtualizedList не размонтирует никогда: так записано в описании пропа в VirtualizedListProps.js, ради мгновенного возврата к началу списка. У нашего окна такого исключения не было.
Полагаться на него не стоит: оно касается только первых десяти строк, а проп initialScrollIndex, с которым список открывается сразу на нужной строке, выключает его совсем. Состояние, которое должно пережить прокрутку, держат выше строки, как в первой задаче прошлой статьи.
Остальные строки таблицы прошлой статьи — распорки $spacer-…, запас windowSize, ячейка CellRenderer — в этом опыте тоже работают, но разглядеть их можно только счётом: сколько строк держит список и во что обходится его перерисовка, считает следующая статья. Сверх окна у FlatList есть то, что к окну не относится: колонки numColumns (ключ строки тогда — ключи её элементов через двоеточие), разделители ItemSeparatorComponent, шапка и подвал ListHeaderComponent и ListFooterComponent.
Сведём в одно место, чем FlatList в песочнице отличается от FlatList на телефоне. Каждая строка сверена с исходниками, левая колонка — ещё и запуском выше.
| Что | В песочнице (react-native-web 0.21) | На телефоне (React Native 0.86) |
|---|---|---|
| Код списка | копия FlatList и VirtualizedList внутри react-native-web, более старая: размеры строк хранятся в _frames | FlatList.js из react-native и VirtualizedList из @react-native/virtualized-lists 0.86, размеры строк — в ListMetricsAggregator |
| Ключ по умолчанию | key → id → номер; FlatList про номера молчит | то же самое, код совпадает |
Ячейка CellRenderer | React.Component: перерисовывается с каждой перерисовкой списка, и renderItem зовётся заново | React.PureComponent: с теми же пропсами не перерисовывается |
Забытый extraData | строка «догоняет» сама, как только список перерисуется по своей причине: прокрутка сдвинула окно, дорисовалась пачка | строка остаётся старой: ячейка с теми же пропсами пропускает перерисовку (вывод по исходнику _pushCells) |
| Повтор ключа | console.error React в консоли примера, ключ записан как .$ex-1 | та же строка React с тем же .$ex-1; в LogBox — плашкой ошибки внизу экрана |
Предупреждение VirtualizedList о ключах | строка в консоли примера | console.warn; если приложение поддерживает консоль React Native DevTools (флаг __FUSEBOX_HAS_FULL_CONSOLE_SUPPORT__), LogBox предупреждений не показывает, а один раз выводит «Open debugger to view warnings.» |
| Типы | нет пакета @react-native/virtualized-lists: style, testID, windowSize у FlatList — ошибка компилятора, тип аргумента renderItem не выводится | всё это обычные пропы, аргумент renderItem — ListRenderItemInfo<T>, выводится из data |
Правило то же, что в прошлых статьях: код пишется так, чтобы быть верным в обеих колонках. Ключ — явный и уникальный. Строка, которая читает не только item, — с extraData, даже если песочница прощает его отсутствие. Локальное состояние строки — только такое, которое не жалко потерять.
FlatList— окно из прошлой статьи с ручками:data,renderItemполучает{ item, index, separators },keyExtractorотдаёт ключ строкой. Первая отрисовка —initialNumToRender, 10 строк: высоту рамки список узнаёт только после раскладки.- Ключ по умолчанию — поле
key, иначеid, иначе номер строки. Про номераFlatListмолчит: предупреждение «missing keys for items» печатает только голыйVirtualizedList. - Настоящее предупреждение про ключи — от React, «Encountered two children with the same key». Оно ловит ключи, которые есть, но совпали, — например, когда номера раздают разные источники.
FlatList—PureComponent. ЕслиrenderItemпостоянна, а строка читает что-то кромеitem, это передают вextraData, и меняться он должен по===.- Строка за окном размонтируется вместе с состоянием — кроме первых
initialNumToRenderстрок, которые список держит всегда. Прокрутка из кода — методами списка,scrollToOffsetиscrollToIndex. - В песочнице ячейка
CellRenderer— обычныйComponent, и забытыйextraData«лечится» первой же прокруткой. На телефоне ячейка —PureComponent, и строка остаётся старой. ВерностьextraDataпроверяют по коду, а не по тому, что пример в песочнице показал отметку.
1/4У элементов data нет ни key, ни id, keyExtractor не задан. Что сделает FlatList?
2/4Какое предупреждение про ключи на самом деле можно получить от списка на FlatList?
3/4renderItem вынесен из компонента и читает отметки из внешнего множества. Экран перерисовался с новой версией отметок. Что покажет FlatList на телефоне?
4/4Отметили строку 1 и строку 16 справочника — отметка в локальном состоянии строки. Уехали на тысячи точек вниз и вернулись. Где отметка сохранится?
Фильтр переносит отметку
Справочник без id, строки — одни названия. Отметьте «Жим гантелей лёжа» и включите фильтр «только гантели». Предупреждений нет, а отметка оказывается у упражнения, которое никто не отмечал. Почините одной правкой.
Та же беда, что в задаче «Отметка переезжает» прошлой статьи, только ключи-места теперь раздал сам FlatList: без key и id он взял номера строк. Фильтр сдвинул номера, на месте 1 теперь «Жим гантелей на наклонной скамье», и отметка, приклеенная к месту 1, досталась ему. Ключом должно быть упражнение. Кроме названия, у строки ничего нет, — оно и станет ключом:
Остаётся одна строка вывода: отметка стоит у того упражнения, которое нажали, а после фильтра его строка просто переехала на место 0. Название годится в ключи, пока названия не повторяются; когда сервер начнёт присылать id, ключом станет он. И заметьте: ни до правки, ни после предупреждения не было. Про номера FlatList молчит, так что ловить это приходится самим.
Своё упражнение с чужим номером
К справочнику добавлены свои упражнения. Их нумеровали отдельно, и номера совпали со справочником. Сделайте ключи уникальными, не трогая сами id: они могут быть уже записаны на сервере.
Уникальным должен быть ключ, а не каждый id по отдельности. В ключ добавляется источник:
Ключи становятся mine:ex-1 и catalog:ex-1, React больше не находит повторов, и в консоли одна строка. Тот же приём — пространство имён в ключе — в главе 6 перейдёт в сам id: номер, рождённый на телефоне, получит приставку устройства, и совпасть с номером другого устройства уже не сможет.
extraData, который не помог
Здесь extraData на месте, а отметка после нажатия всё равно не появляется. Предскажите почему, прежде чем запускать, и почините.
extraData сравнивается по ===, а done — всё то же множество: add меняет его содержимое, а не ссылку. Для PureComponent пропсы не изменились, и список пропускает перерисовку. Передавать нужно то, что при изменении отметок становится другим значением, — номер версии:
Теперь «✓». Годится и само множество, если объявить его через let и не править, а заменять новым: done = new Set(done).add("ex-1"). Ссылка каждый раз новая, и extraData={done} тоже даст «✓». А проверять такую правку прокруткой в песочнице нельзя: раздел про extraData показал, что прокрутка там «лечит» список и без него.