使用 Context 和 Hooks 在 React Native 中实现持续的主题切换
使用 Context、useState 和 useEffect 构建一个 React Native 主题切换器:包含标签页导航、主题选择器、AsyncStorage 数据持久化功能,以及启动时的加载提示。
主题切换看似是个样式问题,但实际上是状态管理问题:当前主题必须能在任何屏幕上清晰显示、可通过任意屏幕进行切换,并且在应用重启后依然存在。这使其成为在比计数器更实际的场景中学习 Hooks 的绝佳练习。本教程将使用 useContext、useState 和 useEffect 来构建一个具有两个标签页、主题选择器以及持久化存储功能的简单 React Native 应用,从而替代以往需要使用的类组件及大量样板代码。
为何 Hooks 适合解决这个问题
Hooks在React 16.8中引入,而React Native则在0.59版本获得了稳定支持。在此之前,要实现主题共享,就需要使用基于类的provider来管理状态、通过生命周期方法加载保存的偏好设置,再将渲染属性或消费者分散在各个组件中。借助Hooks后,provider变成了普通的函数组件,状态存储在useState中,数据加载操作在useEffect中完成,消费者则通过useContext读取相应值。这样一来,所有逻辑都集中在一个小型文件中,而不再分散在各个生命周期方法里。
此处提到的版本指的是这种实现方式首次出现的时刻;目前的React Native版本都已原生支持Hooks,因此任何新项目都可以直接使用。
项目搭建
你需要一个版本为 0.59 或更高版本的 React Native 项目。可以使用 react-native init RNThemeProvider 创建一个新项目(较新的工具链会使用社区 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 中的一个简单函数组件,目前这些组件仅显示一些占位文本。
在此阶段运行应用程序。如果出现了两个标签页且可以在这两者之间切换,说明框架结构已经完成,后续步骤仅用于添加功能。不同版本的 react-navigation 中导航 API 会有所变化,因此请按照所安装版本的说明进行操作,而非直接复制旧示例。
定义主题与选择器界面
作为数据的主题
每个主题都是 themes.json 中的一个对象,包含三个字段:用于标识它的唯一 key、背景颜色以及文本颜色。将主题以 JSON 格式而非代码形式存储,便于扩展;像 Coolors 这样的配色工具能快速帮您找到搭配协调的颜色组合。
themeProvider.js会导入该文件,目前主要导出两样内容:一是设置界面所显示的所有主题的完整数组,二是默认主题(即数组中的第二个元素)。具体的提供者逻辑会在后续实现。
界面与标签栏
设置界面使用FlatList来为每个主题渲染一行,标题样式则依据当前主题而定。主界面也会将其背景和文字应用为当前主题的样式。
最后,标签栏也应体现当前主题的风格。components/TabBar.js中定义的自定义TabBar组件负责渲染标签,并使用主题颜色来标识当前激活的标签。该组件通过App.js中导航器的选项被注册为自定义标签栏组件。
目前这个应用看起来有主题风格,但仅限于硬编码的默认主题。点击屏幕时还没有任何反应。这就是 Hooks 发挥作用的地方。
使用 useContext 共享主题
React Context 能让值在组件树中传递,而无需在每一层都传递属性。如果您还不熟悉 Context,React Context 文档会对相关概念进行说明。
在 themeProvider.js 中,创建一个主题上下文以及对应的提供者组件 ThemeContextProvider。将 App.js 中的导航组件用该提供者包裹起来,这样所有的屏幕和标签栏都会处于其管理之下。
为便于使用上下文,可在同一文件中添加一个 withTheme 高阶组件。该组件通过 useContext 读取上下文,并将主题作为属性传递给被包裹的组件。修改 Main、Settings 和 TabBar,使其通过 withTheme 导出,这样它们就能在无需知晓主题来源的情况下获取当前主题。高阶组件指南 对此模式有更详细的介绍。
当组件已经预期会接收属性时,HOC 效果很好。在以 Hooks 为主的代码库中,一个返回 useContext(ThemeContext) 的小型 useTheme Hook 通常更为简单,无需额外的包装层,还能让依赖关系在组件内部清晰可见。我们的自定义 Hook 与逻辑复用指南解释了为何 Hooks 会共享逻辑而非状态,这也正是此处仍需要上下文的原因。
使用 useState 更改主题
现在让选择器能够正常工作。在 ThemeContextProvider 中,用 useState 存储当前主题,并以默认主题作为初始值。将主题以及 setTheme 函数一同放入上下文值中。
在设置界面中,当点击FlatList中的某一行时调用setTheme函数。由于提供者的状态发生了变化,所有读取该上下文的组件都会使用新的颜色重新渲染:界面和标签栏会立即切换。
有一个值得采用的细节:如果提供者每次渲染时生成的上下文值都是一个新对象,那么所有使用该上下文的组件都会随之重新渲染。通过使用useMemo并根据主题对值进行缓存,就可以避免这种情况。在如此小的应用中这几乎无关紧要,但一旦有许多组件使用该上下文,这就变得很重要了。
利用AsyncStorage保存选择
现在点击主题可以正常生效,但重新加载页面后选择的内容会丢失。需扩展setTheme函数,使其在更新状态的同时将用户选择的内容写入AsyncStorage。存储主题的key而非整个对象更为稳妥:如果日后在themes.json中调整了主题颜色,用户将获得更新后的版本而非过时的副本。
使用useEffect在应用启动时恢复主题
最后一步是在应用启动时读取已保存的主题。由于useEffect会在组件渲染之后执行,因此它是处理诸如读取存储数据之类的副作用的合适位置。从类组件的角度来看,它相当于componentDidMount和componentDidUpdate所承担的功能;虽然有时会将其与componentWillReceiveProps相提并论,但这种比较具有误导性,因为副作用是在渲染之后才执行的,而非在新属性到来之前。
在ThemeContextProvider内部,需要添加一个效应来从AsyncStorage中读取存储的键值,找到对应主题并调用状态设置函数。有两个细节确保了这一过程的正确性:
- 传入空的依赖数组。该效果应在提供者挂载时运行一次,而非每次渲染后都运行。Hooks API 参考文档说明了依赖数组如何控制效果的触发时机。
- 处理加载状态。AsyncStorage 是异步的,因此在保存的主题确定之前就会进行首次渲染。需要跟踪加载是否已完成,在完成之前不显示任何内容(或仅显示加载界面)。否则用户在看到自己选择的主题之前会先看到默认主题。
此外,还需处理尚未存储任何内容或存储的键不再对应任何主题的情况,此时应回退到默认主题。这样,用户的选择就能在应用重启后依然保留。
总结
最终的提供者是一个函数组件,它负责管理主题状态、持久化更改、在启动时恢复这些更改,并通过上下文暴露所有相关内容。与基于类的版本相比,其逻辑更为简洁,且从上到下依次可读。
有几点原则也适用于其他功能:
- 将共享的 UI 预设值保存在靠近根层的上下文提供者中,并同时提供设置函数。
- 仅持久化标识符而非整个对象,这样数据更改就不会在设备上留下过时的副本。
- 将异步启动时的读取操作视为加载状态,而非直接渲染默认值后再进行替换。
useReducer 以处理更复杂的状态,使用 useRef 管理不会触发重新渲染的可变值,而 useLayoutEffect 则用于在绘制之前必须完成的任务。将现有的类组件转换为函数组件是很好的练习方式。相关阅读
- 为何 Codegen 使 React Native TurboModules 必须遵循先写规范的原则 — 了解 React Native 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 文件夹。
- 使用 hot-updater 和 Supabase 为纯 React Native 应用实现自托管 OTA 更新 —— 利用 hot-updater 和 Supabase 在纯 React Native 应用中搭建空中 JavaScript 更新功能:包括初始化、静默更新钩子、频道管理、部署脚本以及回滚机制。