Первый экран: тот же React, другие элементы
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
React вы знаете по части IV, поэтому первый пример этой части — не «привет, мир», а кусок настоящего приложения: экран тренировки из «Подхода», дневника, который мы будем собирать всю часть. Хуки, JSX, ключи и обработчики в нём ровно те же, что в части IV, и объяснять их заново мы не будем. Поменялись элементы: вместо div, p и button — View, Text и Pressable, вместо onClick — onPress, а под ref лежит уже не узел DOM. Разберём, что именно поменялось и почему.
Проект этой части — «Подход», дневник тренировок: справочник упражнений, лента тренировок, экран тренировки с подходами, таймер отдыха. Начнём с сердца приложения — карточки упражнения, куда записывают подходы. Экран сразу настоящий: состояние, список, условие и кнопка.
Прогноз перед запуском: кнопку нажимают дважды, с паузой. Сколько строк появится в консоли и с какими числами?
Три строки: первая отрисовка и по одной на каждое нажатие. Ничего неожиданного — и в этом главное. 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 и CSS | style — объект | ни классов, ни каскада — глава 2 |
id, data-testid | id (старое имя — nativeID), testID | метка для платформы остаётся id, для тестов — testID |
ref — узел DOM | ref — нативный элемент | размер спрашивают у measure(), ответ приходит колбэком |
createRoot().render() | registerRootComponent | регистрирует корень приложения; в песочнице вместо него mount |
Первой строке таблицы посвящена следующая статья: там знакомая вёрстка из части II переезжает в примитивы, и консоль выдаёт настоящую ошибку про текст внутри View. Здесь посмотрим, как отличия видит компилятор. Типы в примерах этой части настоящие, из пакета react-native, и описывают они телефон, а не браузер. Прогноз: сколько ошибок покажет компилятор, выполнится ли код и каким цветом окажется вторая строка на экране?
Ошибок две, и обе по делу. 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 и сообщает скринридеру, что недоступна.
Прогноз: первой нажимают «Завершить», пока подходов ноль, потом «Записать подход», потом снова «Завершить». Сколько строк будет в консоли?
Три. Первое нажатие до onPress не дошло: disabled остановил его внутри Pressable, состояние не менялось, и рендера не было. Функции стиля вызывались на каждом рендере Workout и вызовутся ещё раз, когда сменится pressed. Но тогда перерисуется только сама кнопка: pressed — её состояние, а не ваше, и Workout об этом даже не узнает.
Здесь предпросмотр честно расходится с телефоном. fireEvent.press — это click, а react-native-web зовёт onPress по клику, минуя касание: onPressIn и onPressOut от него не срабатывают, и pressed ни на миг не становится true — проверено запуском. Зажмите кнопку мышью на экране примера — тогда касание будет и затемнение вы увидите. Почему нажатие на самом деле автомат из нескольких состояний и как собрать его самим, разбирает статья «Нажатие — это автомат, а не onClick» в четвёртой главе.
В первом примере нажатия шли с паузой, и каждое дало свою отрисовку. Что будет без паузы? Вопрос не праздный: в примерах этой части нажимает не палец, а fireEvent, и прогноз почти всегда упирается в то, сколько отрисовок случилось.
Прогноз: три нажатия подряд, в одной задаче. Обработчик самый обычный — setSets(sets + 1).
Две строки, и во второй — единица. Три клика выполнились друг за другом прежде, чем React получил управление: все три обработчика — из первого рендера, у всех sets равен нулю, и все три попросили записать единицу. Это снимок из статьи «Состояние — снимок, а не переменная», а склейка трёх просьб в одну отрисовку — пачка из «Своей очереди обновлений и своей пачки». С обновителем setSets((n) => n + 1) отрисовка тоже была бы одна, но уже с тройкой.
На телефоне так не выйдет: между двумя касаниями пальца проходят десятки миллисекунд, и React успевает отрисовать каждое. Поэтому для примеров этой части запомните правило чтения — оно сбережёт не один прогноз:
| Как нажали | Отрисовок | Что видят обработчики |
|---|---|---|
три fireEvent в одном такте | одна | один и тот же снимок |
каждый fireEvent в своём setTimeout | по одной на нажатие | свежий снимок, как от пальца |
три fireEvent в одном такте, обновитель | одна | обновитель читает очередь — итог три |
useRef и ref как пропс работают как в части IV — в статье «Ссылки на узлы: useRef и ref как пропс». Другое — то, что окажется в ref.current. Узла DOM на телефоне нет, и под ref у View лежит нативный элемент React Native. Самое нужное у него — measure(): где элемент и какого он размера после раскладки.
Прогноз: какая строка напечатается первой?
В предпросмотре сначала «эффект закончился», потом высота. 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 вы не вызываете и его: экран кладётся в файл, и маршрутизатор находит его сам.
Тело компонента не меняется ни на символ. Цепочка до него — уже этаж 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, useRef | React | настоящий React 19 |
registerRootComponent, expo-router, файл app/index.tsx | Expo | не собирается — только листинг |
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 в обработчике.
Хватает одного пропса у второй кнопки — disabled={sets === 0}:
Вывод становится 0, 1, 2, 1: первое нажатие остановилось внутри Pressable и до onPress не дошло. Проверка в обработчике — if (sets > 0) — дала бы тот же счёт, но disabled делает больше: Pressable сообщает о недоступности скринридеру, а по тому же условию кнопку легко приглушить в функции стиля, как в разделе про нажатие, — style={({ pressed }) => [styles.button, sets === 0 && styles.muted, pressed && styles.pressed]}.
Сколько отрисовок
Скажите до запуска, сколько строк «рендер» напечатает пример и с какими числами. Потом перепишите только нижние строки — вызовы fireEvent, — чтобы каждое из трёх нажатий дало свою отрисовку.
Три строки: 0, 2 и 3. Первые два нажатия выполнены в одной задаче — React склеил их в одну отрисовку, а обновитель прочитал очередь, поэтому вышла двойка, а не единица. Третье нажатие — в своей задаче и со своей отрисовкой.
Чтобы каждое нажатие отрисовалось отдельно, их разводят по времени:
Вывод — 0, 1, 2, 3. А будь обработчик записан как setSets(sets + 1), исходный вариант напечатал бы 0, 1, 2: двум склеенным нажатиям достался бы один снимок.