Постоянная смена тем в React Native с использованием Context и Hooks
Создайте переключатель тем для React Native с использованием Context, useState и useEffect: навигация по вкладкам, выбор темы, сохранение данных с помощью AsyncStorage и элемент защиты во время запуска.
Переключение тем кажется проблемой стилизации, но на самом деле это проблема состояния: текущая тема должна быть читаемой с любого экрана, возможной для изменения с любого из них и сохраняться после перезагрузки приложения. Это отличная практика для изучения Hooks на чем-то более реалистичном, чем простой счётчик. В этом руководстве создаётся небольшое приложение React Native с двумя вкладками, выборщиком тем и постоянным хранилищем данных, при этом для замены классовых компонентов и большого количества шаблонного кода используются useContext, useState и useEffect.
Почему Hooks подходят для решения этой проблемы
Хуки появились в React 16.8, а React Native получил стабильную поддержку с версией 0.59. До их появления для обмена темой требовался провайдер на основе классов, хранящий состояние, методы жизненного цикла для загрузки сохранённых настроек, а также пропсы рендеринга или компоненты-потребители, разбросанные по всей структуре приложения. Благодаря хукам провайдер превращается в обычный функциональный компонент, состояние хранится с помощью useState, загрузка данных происходит в useEffect, а компоненты-потребители читают значение с помощью useContext. Вся логика теперь находится в одном небольшом файле, а не распределена по методам жизненного цикла.
Указанные здесь версии отражают момент, когда такой подход впервые стал возможен; современные версии React Native поддерживают хуки из коробки, поэтому любой недавний проект будет работать с ними.
Настройка проекта
Вам нужен проект React Native версии 0.59 или новее. Новый проект можно сгенерировать с помощью команды react-native init RNThemeProvider (в более новых версиях используется community CLI или Expo; проверьте актуальную документацию по началу работы).
Дополнительно добавляются две библиотеки:
- react-navigation обеспечивает навигацию с вкладками.
- AsyncStorage хранит выбранный тема на устройстве. Она была удалена из ядра React Native и теперь поставляется как отдельный пакет сообщества (сегодня опубликован как
@react-native-async-storage/async-storage), поэтому её необходимо устанавливать отдельно.
В более старых версиях React Native после установки также требовалось вручную связывать нативные модули; с автоматическим связыванием в современных версиях этот шаг обычно не нужен.
Организация файлов
Небольшая, предсказуемая структура позволяет разделить логику тем от экранов и навигации:
- src
— components
— TabBar.js # custom bottom tabbar component
— core
— themeProvider.js # custom hook for theming
— themes.json # JSON array containing our themes
— screens
— Main.js # first tab
— Settings.js # second tab
— App.js # navigation part
— index.js # entry point for react-native
— package.json # dependencies
Папка core содержит всё, что связано с темами: themes.json с определениями тем и themeProvider.js с контекстом, поставщиками данных и вспомогательными функциями. Экраны находятся в папке screens, пользовательская панель вкладок — в components, а App.js обеспечивает работу навигации.
Создание шелла навигации
Начните без использования тем. В файле App.js создайте навигатор вкладок внизу с двумя вкладками: Main для содержимого приложения и Settings для настроек. Каждая вкладка переходит на простой функциональный компонент в файлах Main.js и Settings.js, который пока отображает лишь текст-заменитель.
На этом этапе запустите приложение. Если появятся два вкладки, между которыми можно переключаться, значит структура готова, а все последующие шаги лишь добавляют функционал. API навигации менялись в разных версиях react-navigation, поэтому следуйте инструкциям для установленной вами версии, а не копируйте примеры из более старых версий дословно.
Определение тем и интерфейса выбора
Темы как данные
Каждая тема представляет собой объект в файле themes.json, содержащий три поля: уникальный key для идентификации, цвет фона и цвет текста. Хранение тем в формате JSON вместо кода облегчает их расширение; генератор палитр, такой как Coolors, — это быстрый способ найти подходящие друг к другу цветовые комбинации.
themeProvider.js импортирует этот файл и пока экспортирует два элемента: полный массив тем, который отображается на экране настроек, и стандартную тему (второй элемент массива). Логика обеспечения тем будет реализована позже.
Экраны и панель вкладок
На экране Настройки используется компонент FlatList, который отображает по одной строке для каждой темы, причем заголовок форматируется с учетом текущей темы. На экране Главный также применяется текущая тема к фону и тексту.
Наконец, панель вкладок также должна отражать выбранную тему. Специальный компонент TabBar в файле components/TabBar.js отображает вкладки и использует цвет текущей темы для активной вкладки. Он регистрируется в навигаторе вкладок в файле App.js через параметры навигатора для использования пользовательского компонента панели вкладок.
На данный момент приложение выглядит тематизированным, но только благодаря заранее заданному шаблону. Пока ничего не реагирует на нажатия. Именно здесь и пригодятся Hooks.
Обмен темой с использованием useContext
React Context позволяет передавать значения по всей структуре приложения без необходимости передачи пропсов на каждом уровне. Если вы только знакомитесь с Context, документация React Context объясняет эту концепцию.
В файле themeProvider.js создайте контекст темы и компонент-провайдер ThemeContextProvider. Оберните компонент навигации в App.js с помощью этого провайдера, чтобы все экраны и панель вкладок находились под его управлением.
Чтобы упростить работу с контекстом, добавьте в тот же файл компонент высшего порядка withTheme. Он считывает контекст с помощью useContext и передаёт тему внутреннему компоненту в качестве свойства. Обновите Main, Settings и TabBar так, чтобы они экспортировались через withTheme; тогда они будут получать текущую тему, не зная её источника. Руководство по компонентам высшего порядка подробнее рассматривает эту практику.
HOC эффективно работает, когда компоненты уже ожидают пропсы. В кодбейсе, где в первую очередь используются хуки, небольшой хук useTheme, возвращающий useContext(ThemeContext), часто бывает проще: он избавляет от дополнительного уровня обертки и делает зависимости видимыми внутри компонента. Наша инструкция по использованию пользовательских хуков и повторному использованию логики объясняет, почему хуки делятся логикой, а не состоянием, и именно поэтому здесь всё ещё необходим контекст.
Изменение темы с помощью useState
Теперь сделаем выбрасывающий элемент рабочим. Внутри ThemeContextProvider храним текущую тему с помощью useState, инициализированного значением по умолчанию. В значение контекста помещаем как саму тему, так и функцию setTheme.
На экране настроек вызывайте функцию setTheme при нажатии на строку в компоненте FlatList. Поскольку состояние провайдера меняется, каждый компонент, читающий контекст, перерисовывается с новыми цветами: экраны и панель вкладок мгновенно меняются.
Есть один важный момент, который стоит учесть: если значение контекста при каждой перерисовке провайдера является новым объектом, все компоненты-потребители перерисовываются каждый раз вместе с провайдером. Чтобы избежать этого, можно сохранить значение с помощью useMemo, указав ключом тему. В приложении такого небольшого размера это почти не имеет значения, но становится важным, когда множество компонентов используют контекст.
Сохранение выбора с помощью AsyncStorage
Теперь нажатие на тему работает, но выбор теряется при перезагрузке. Расширьте функцию setTheme, чтобы она, помимо обновления состояния, записывала выбранную тему в AsyncStorage. Более надежным решением будет хранение key темы вместо всего объекта: если позже изменить цвета темы в файле themes.json, пользователи получат обновленную версию, а не устаревшую копию.
Восстановление темы при запуске с использованием useEffect
Последний шаг — загрузка сохраненной темы при запуске приложения. Функция useEffect выполняется после отрисовки компонента, поэтому именно здесь реализуются побочные эффекты, такие как чтение данных из хранилища. С точки зрения классового подхода это соответствует тому, что раньше обрабатывали функции componentDidMount и componentDidUpdate; иногда приводимое сравнение с componentWillReceiveProps вводит в заблуждение, поскольку эффекты выполняются после отрисовки, а не до поступления новых параметров.
Внутри ThemeContextProvider необходимо добавить эффект, который считывает сохраненный ключ из AsyncStorage, находит соответствующую тему и вызывает функцию для изменения состояния. Для корректной работы этого подхода важны два момента:
- Передайте пустой массив зависимостей. Эффект должен выполняться один раз при монтировании провайдера, а не после каждой отрисовки. Справочник API хуков объясняет, как массив зависимостей контролирует момент выполнения эффекта.
- Обрабатывайте состояние загрузки. AsyncStorage является асинхронным, поэтому первая отрисовка происходит до того, как становится известна сохранённая тема. Отслеживайте, завершилась ли загрузка, и не отображайте ничего (или временный интерфейс), пока это не произойдёт. В противном случае пользователи увидят мгновенное отображение стандартной темы перед появлением своего выбора.
Также обрабатывайте ситуацию, когда пока ничего не сохранено или сохранённый ключ больше не соответствует теме, переходя на стандартную. Благодаря этому выбор сохраняется при перезапуске приложения.
Заключение
Готовый провайдер представляет собой единственный функциональный компонент, который управляет состоянием темы, сохраняет изменения, восстанавливает их при запуске и предоставляет всё через контекст. По сравнению с версией на основе классов логика более краткая и читается сверху вниз.
Несколько принципов применимы и к другим функциям:
- Храните общие настройки интерфейса в провайдере контекста рядом с корнем и предоставляйте функцию для установки значений наряду с самими значениями.
- Сохраняйте идентификаторы, а не целые объекты, чтобы изменения данных не оставляли устаревших копий на устройствах.
- Рассматривайте асинхронные операции загрузки как состояние загрузки, а не как момент отображения стандартных значений с последующей заменой.
useReducer для более сложного управления состоянием, useRef для изменяемых значений, которые не должны вызывать перерисовку, и useLayoutEffect для операций, которые должны выполняться до отрисовки. Практиковаться можно, конвертируя существующие компоненты классового стиля.Связанные материалы
- Почему Codegen делает подход «спецификация прежде всего» обязательным для React Native TurboModules — Узнайте, как Codegen преобразует спецификацию TurboModule на TypeScript в контракт времени сборки, что ломается при её пропуске и где действие этого правила прекращается.
- Планирование обновления Expo SDK 58: iOS 27, React Native 0.88 и новые инструменты — Практический обзор бета-версии Expo SDK 58: какие изменения принесены для iOS 27 и React Native 0.88, какие функции являются экспериментальными и как безопасно протестировать обновление.
- Обработка нативных папок как результатов сборки с помощью Expo Prebuild и CNG — Как функция непрерывной генерации нативного кода позволяет приложению Expo использовать пользовательские нативные модули, плагины конфигурации и секреты EAS без необходимости сохранения или ручного редактирования папок ios и android.
- Самостоятельно хостингуемые обновления OTA для чистого React Native с hot-updater и Supabase — Настройка обновлений JavaScript через кабель в приложении чистого React Native с использованием hot-updater и Supabase: инициализация, скрытый хук обновления, каналы, скрипты развертывания и возврат к предыдущей версии.