Первый экран: тот же React, другие элементы

первый экран≈ 12 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

React вы знаете по части IV, поэтому первый пример этой части — не «привет, мир», а кусок настоящего приложения: экран тренировки из «Подхода», дневника, который мы будем собирать всю часть. Хуки, JSX, ключи и обработчики в нём ровно те же, что в части IV, и объяснять их заново мы не будем. Поменялись элементы: вместо div, p и button — View, Text и Pressable, вместо onClick — onPress, а под ref лежит уже не узел DOM. Разберём, что именно поменялось и почему.

Разделы темы:·

Проект этой части — «Подход», дневник тренировок: справочник упражнений, лента тренировок, экран тренировки с подходами, таймер отдыха. Начнём с сердца приложения — карточки упражнения, куда записывают подходы. Экран сразу настоящий: состояние, список, условие и кнопка.

Прогноз перед запуском: кнопку нажимают дважды, с паузой. Сколько строк появится в консоли и с какими числами?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Три строки: первая отрисовка и по одной на каждое нажатие. Ничего неожиданного — и в этом главное. useState, обработчик, замкнувший sets, новый массив вместо правки старого, map с ключом, тернарник вместо if в разметке — всё работает как в части IV и здесь не объясняется. Если что-то из этого списка кажется незнакомым, вернитесь к главам «От ручной перерисовки к React» и «Состояние и перерисовка».

Поменялось другое, и отличий в примере ровно шесть: элементы (View и Text вместо div и p), кнопка (Pressable с onPress вместо button с onClick), стиль (объект из StyleSheet.create вместо класса), метка testID, источник компонентов (глобальная ReactNative вместо импорта) и точка входа (mount вместо createRoot). Первые два — предмет этой статьи, стилю отдана вся вторая глава, а два последних — особенности песочницы, о них в конце.

И ещё одна строка, которой в части IV не было, — fireEvent.press("log-set"). DOM примеру на React Native недоступен, и так задумано: на телефоне его нет. Поэтому нажимает помощник песочницы — по testID, теми же глаголами, что fireEvent в React Native Testing Library. Пауза перед нажатием тоже не для красоты: mount рисует не сразу, а в одной из следующих задач. Нажмите сразу после него — и fireEvent ответит ошибкой «нет элемента с testID»; в нашей проверке даже нулевой таймер срабатывал раньше первой отрисовки.

Стили в примерах уже есть, но разбирать их рано. Почему стиль здесь объект, а не класс, и что делает StyleSheet.create — первая статья второй главы.

В браузере JSX превращается в узлы DOM, и React DOM их правит. На телефоне DOM нет вовсе. View — заказ на нативный контейнер: на iOS его исполняет наследник UIView, на Android — наследник ViewGroup. Text — заказ на текстовый вид платформы, и больше строку нарисовать некому: контейнер ничего не знает ни о шрифте, ни о переносах. Pressable — тот же View, который вдобавок распознаёт нажатие. React в этой схеме прежний, меняется рендерер под ним: вместо узлов он создаёт нативные виды.

В части IVВ React NativeЧто изменилось
<div><View>контейнер без текста: строка прямо в нём — ошибка
<p>, <span>, <h1><Text>единственное место для строк; заголовок — тот же Text со своим стилем
<button onClick><Pressable onPress>нажатие вместо клика, нажатость — аргумент функции стиля
className и CSSstyle — объектни классов, ни каскада — глава 2
id, data-testidid (старое имя — nativeID), testIDметка для платформы остаётся id, для тестов — testID
ref — узел DOMref — нативный элементразмер спрашивают у measure(), ответ приходит колбэком
createRoot().render()registerRootComponentрегистрирует корень приложения; в песочнице вместо него mount

Первой строке таблицы посвящена следующая статья: там знакомая вёрстка из части II переезжает в примитивы, и консоль выдаёт настоящую ошибку про текст внутри View. Здесь посмотрим, как отличия видит компилятор. Типы в примерах этой части настоящие, из пакета react-native, и описывают они телефон, а не браузер. Прогноз: сколько ошибок покажет компилятор, выполнится ли код и каким цветом окажется вторая строка на экране?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Ошибок две, и обе по делу. className у View нет вовсе — классов в React Native не бывает. А color нет в ViewStyle: цвет текста — свойство текста, и на телефоне контейнер его не принимает и детям не передаёт. Вторая строка на экране примера такая же чёрная, как первая, хотя предпросмотр пришёл к этому своим путём: react-native-web отдал color самому div контейнера, но Text ставит себе чёрный цвет сам и чужого не наследует. Почему наследования здесь нет почти нигде и где единственное исключение — во второй главе.

А код при этом выполнился: строка в консоли есть, экран нарисован. Это знакомое по части III стирание типов — компилятор сообщает, но не останавливает. Поэтому диагностики под примерами этой части стоит читать так же внимательно, как консоль: предпросмотр многое молча прощает — чужой className он просто выбросил, — а телефон прощает меньше.

А вот тег div в примере на React Native компилятор пропустит без единого слова: HTML-теги объявляют сами типы React, без библиотеки DOM. Что с ним будет на телефоне — в следующей статье.

Событие Pressable — onPress: «палец коснулся и отпустил в пределах кнопки». Вокруг него ещё три: onPressIn — коснулся, onPressOut — отпустил или увёл, onLongPress — держит. Нажатие умеет и Text: у него тоже есть onPress — для ссылки посреди абзаца, где отдельная кнопка была бы лишней.

Второе отличие — как кнопка показывает, что её держат. В CSS для этого есть :active, а CSS здесь нет. Нажатость хранит сам Pressable — в собственном useState — и отдаёт её вам аргументом: style и children можно передать функцией ({ pressed }) => …. Третье — disabled: выключенная кнопка не пропускает нажатие к onPress и сообщает скринридеру, что недоступна.

Прогноз: первой нажимают «Завершить», пока подходов ноль, потом «Записать подход», потом снова «Завершить». Сколько строк будет в консоли?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Три. Первое нажатие до onPress не дошло: disabled остановил его внутри Pressable, состояние не менялось, и рендера не было. Функции стиля вызывались на каждом рендере Workout и вызовутся ещё раз, когда сменится pressed. Но тогда перерисуется только сама кнопка: pressed — её состояние, а не ваше, и Workout об этом даже не узнает.

Здесь предпросмотр честно расходится с телефоном. fireEvent.press — это click, а react-native-web зовёт onPress по клику, минуя касание: onPressIn и onPressOut от него не срабатывают, и pressed ни на миг не становится true — проверено запуском. Зажмите кнопку мышью на экране примера — тогда касание будет и затемнение вы увидите. Почему нажатие на самом деле автомат из нескольких состояний и как собрать его самим, разбирает статья «Нажатие — это автомат, а не onClick» в четвёртой главе.

В первом примере нажатия шли с паузой, и каждое дало свою отрисовку. Что будет без паузы? Вопрос не праздный: в примерах этой части нажимает не палец, а fireEvent, и прогноз почти всегда упирается в то, сколько отрисовок случилось.

Прогноз: три нажатия подряд, в одной задаче. Обработчик самый обычный — setSets(sets + 1).

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Две строки, и во второй — единица. Три клика выполнились друг за другом прежде, чем React получил управление: все три обработчика — из первого рендера, у всех sets равен нулю, и все три попросили записать единицу. Это снимок из статьи «Состояние — снимок, а не переменная», а склейка трёх просьб в одну отрисовку — пачка из «Своей очереди обновлений и своей пачки». С обновителем setSets((n) => n + 1) отрисовка тоже была бы одна, но уже с тройкой.

На телефоне так не выйдет: между двумя касаниями пальца проходят десятки миллисекунд, и React успевает отрисовать каждое. Поэтому для примеров этой части запомните правило чтения — оно сбережёт не один прогноз:

Как нажалиОтрисовокЧто видят обработчики
три fireEvent в одном тактеоднаодин и тот же снимок
каждый fireEvent в своём setTimeoutпо одной на нажатиесвежий снимок, как от пальца
три fireEvent в одном такте, обновительоднаобновитель читает очередь — итог три

useRef и ref как пропс работают как в части IV — в статье «Ссылки на узлы: useRef и ref как пропс». Другое — то, что окажется в ref.current. Узла DOM на телефоне нет, и под ref у View лежит нативный элемент React Native. Самое нужное у него — measure(): где элемент и какого он размера после раскладки.

Прогноз: какая строка напечатается первой?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

В предпросмотре сначала «эффект закончился», потом высота. measure не возвращает размер, а зовёт колбэк, и react-native-web зовёт его позже: под ref здесь лежит обычный div, которому пришили measure(), а тот ставит таймер и уже в нём спрашивает размеры у браузера.

На телефоне порядок строк был бы обратным. Там под ref лежит объект класса ReactNativeElement, и в React Native 0.86 его measure() зовёт колбэк сразу, прямо внутри вызова: раскладку нативная часть уже посчитала, ждать нечего. Колбэк в сигнатуре — наследство старой архитектуры с асинхронным мостом, о ней — в главе про нативные возможности. Вывод практический: не пишите код, которому важно, раньше или позже придёт ответ, — размер нужен внутри колбэка, там им и пользуйтесь.

Типы описывают телефон. У ReactNativeElement есть measure(), measureInWindow() и focus(), но нет ни classList, ни querySelector, поэтому bar.current?.classList компилятор не пропустит, хотя у div в предпросмотре он под рукой. А громоздкий тип в примере — издержка песочницы. Строка const { View } = ReactNative достаёт из объекта только значение: тип с тем же именем деструктуризация не приносит, и короткое useRef<View> компилятор отвергает словами «относится к значению, но здесь используется как тип». В проекте View приходит через import, в типах React Native 0.86 он объявлен классом, и useRef<View>(null) там компилируется. В примерах части пишем React.ComponentRef<typeof View> — «то, что лежит под ref у View»: так работает и здесь, и в проекте.

Осталось две строки, которых в настоящем проекте не будет. const { View, Text, Pressable } = ReactNative — в песочнице нет ни модулей, ни npm, поэтому библиотека лежит в глобальной переменной; в проекте это import из пакета react-native, а useState импортируется из react. И mount(<SetLogger />) — помощник песочницы: показать дерево на экране примера. На телефоне корень регистрирует registerRootComponent, а в проекте на expo-router вы не вызываете и его: экран кладётся в файл, и маршрутизатор находит его сам.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Тело компонента не меняется ни на символ. Цепочка до него — уже этаж Expo. В package.json приложения стоит "main": "expo-router/entry"; этот файл передаёт корень маршрутизатора в renderRootComponent, тот зовёт registerRootComponent из пакета expo, а он — AppRegistry.registerComponent("main", …) из React Native. Так устроен запуск в приложении автора учебника на Expo SDK 57; свой проект мы заведём в последней статье этой главы.

Что в примереЭтажВ песочнице
View, Text, Pressable, StyleSheet, measure()React Nativeвыполняет react-native-web
JSX, useState, useEffect, useRefReactнастоящий React 19
registerRootComponent, expo-router, файл app/index.tsxExpoне собирается — только листинг
mount, fireEventпесочница учебникана телефоне их работу делают registerRootComponent и палец

Экран примера, кстати, тоже не телефон: те же компоненты react-native-web рисует в HTML, и Platform.OS здесь равен "web". Где предпросмотр настоящий, а где только похож, разберут две следующие статьи: «Ни div, ни класса, ни каскада» покажет ошибку, которую не ловит ни один тип, а «Что в этой песочнице настоящее» проведёт границу запуском.

  • Компонент React Native — тот же компонент из части IV: JSX, useState, useEffect, useRef, ключи в списках работают без изменений. Меняются элементы.
  • View — контейнер без текста, Text — единственное место для строк, Pressable — вид, который распознаёт нажатие. Это заказы на нативные виды, а не узлы DOM, и настоящие типы react-native это знают: className и color у View компиляцию не пройдут.
  • Нажатие — onPress у Pressable и у Text. Нажатость pressed хранит сам Pressable и отдаёт в функции style и children; disabled не пускает нажатие к onPress.
  • В примерах нажимает fireEvent: несколько вызовов в одном такте React склеивает в одну отрисовку, и обработчики видят один снимок. Чтобы увидеть каждую отрисовку, нажатия разносят по времени.
  • Под ref у View — не узел DOM: на телефоне это ReactNativeElement с measure(). Ответ приходит колбэком — в предпросмотре после эффекта, на телефоне с React Native 0.86 сразу, поэтому на порядок строк не полагаются.
  • Код экрана в песочнице и в проекте один и тот же; меняются импорты и точка входа. Здесь — mount, в Expo — файл маршрута, который найдёт expo-router и зарегистрирует registerRootComponent.

1/4Где в React Native может стоять строка «Отдых 90 секунд»?

2/4Обработчик кнопки — onPress={() => setSets(sets + 1)}, в начале sets равен нулю. В одном setTimeout подряд вызвано fireEvent.press три раза. Что напечатает рендер после нажатий?

3/4Зачем Pressable принимает style функцией ({ pressed }) => …?

4/4Экран из статьи переезжает в настоящий проект на Expo с expo-router. Что придётся поменять?

Отмена, которая не уводит в минус

Добавьте к счётчику кнопку «Отменить подход» так, чтобы, пока подходов нет, её нажатие ничего не делало. Сейчас первое же нажатие уводит счётчик в минус. Обойдитесь без if в обработчике.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Хватает одного пропса у второй кнопки — disabled={sets === 0}:

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод становится 0, 1, 2, 1: первое нажатие остановилось внутри Pressable и до onPress не дошло. Проверка в обработчике — if (sets > 0) — дала бы тот же счёт, но disabled делает больше: Pressable сообщает о недоступности скринридеру, а по тому же условию кнопку легко приглушить в функции стиля, как в разделе про нажатие, — style={({ pressed }) => [styles.button, sets === 0 && styles.muted, pressed && styles.pressed]}.

Сколько отрисовок

Скажите до запуска, сколько строк «рендер» напечатает пример и с какими числами. Потом перепишите только нижние строки — вызовы fireEvent, — чтобы каждое из трёх нажатий дало свою отрисовку.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Три строки: 0, 2 и 3. Первые два нажатия выполнены в одной задаче — React склеил их в одну отрисовку, а обновитель прочитал очередь, поэтому вышла двойка, а не единица. Третье нажатие — в своей задаче и со своей отрисовкой.

Чтобы каждое нажатие отрисовалось отдельно, их разводят по времени:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод — 0, 1, 2, 3. А будь обработчик записан как setSets(sets + 1), исходный вариант напечатал бы 0, 1, 2: двум склеенным нажатиям достался бы один снимок.