首页 / 文章 / 使用 Context 和 Hooks 在 React Native 中实现持续的主题切换

使用 Context 和 Hooks 在 React Native 中实现持续的主题切换

使用 Context、useState 和 useEffect 构建一个 React Native 主题切换器:包含标签页导航、主题选择器、AsyncStorage 数据持久化功能,以及启动时的加载提示。

1618 词

主题切换看似是个样式问题,但实际上是状态管理问题:当前主题必须能在任何屏幕上清晰显示、可通过任意屏幕进行切换,并且在应用重启后依然存在。这使其成为在比计数器更实际的场景中学习 Hooks 的绝佳练习。本教程将使用 useContextuseStateuseEffect 来构建一个具有两个标签页、主题选择器以及持久化存储功能的简单 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.jsSettings.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 读取上下文,并将主题作为属性传递给被包裹的组件。修改 MainSettingsTabBar,使其通过 withTheme 导出,这样它们就能在无需知晓主题来源的情况下获取当前主题。高阶组件指南 对此模式有更详细的介绍。

当组件已经预期会接收属性时,HOC 效果很好。在以 Hooks 为主的代码库中,一个返回 useContext(ThemeContext) 的小型 useTheme Hook 通常更为简单,无需额外的包装层,还能让依赖关系在组件内部清晰可见。我们的自定义 Hook 与逻辑复用指南解释了为何 Hooks 会共享逻辑而非状态,这也正是此处仍需要上下文的原因。

使用 useState 更改主题

现在让选择器能够正常工作。在 ThemeContextProvider 中,用 useState 存储当前主题,并以默认主题作为初始值。将主题以及 setTheme 函数一同放入上下文值中。

在设置界面中,当点击FlatList中的某一行时调用setTheme函数。由于提供者的状态发生了变化,所有读取该上下文的组件都会使用新的颜色重新渲染:界面和标签栏会立即切换。

有一个值得采用的细节:如果提供者每次渲染时生成的上下文值都是一个新对象,那么所有使用该上下文的组件都会随之重新渲染。通过使用useMemo并根据主题对值进行缓存,就可以避免这种情况。在如此小的应用中这几乎无关紧要,但一旦有许多组件使用该上下文,这就变得很重要了。

利用AsyncStorage保存选择

现在点击主题可以正常生效,但重新加载页面后选择的内容会丢失。需扩展setTheme函数,使其在更新状态的同时将用户选择的内容写入AsyncStorage。存储主题的key而非整个对象更为稳妥:如果日后在themes.json中调整了主题颜色,用户将获得更新后的版本而非过时的副本。

使用useEffect在应用启动时恢复主题

最后一步是在应用启动时读取已保存的主题。由于useEffect会在组件渲染之后执行,因此它是处理诸如读取存储数据之类的副作用的合适位置。从类组件的角度来看,它相当于componentDidMountcomponentDidUpdate所承担的功能;虽然有时会将其与componentWillReceiveProps相提并论,但这种比较具有误导性,因为副作用是在渲染之后才执行的,而非在新属性到来之前。

ThemeContextProvider内部,需要添加一个效应来从AsyncStorage中读取存储的键值,找到对应主题并调用状态设置函数。有两个细节确保了这一过程的正确性:

  • 传入空的依赖数组。该效果应在提供者挂载时运行一次,而非每次渲染后都运行。Hooks API 参考文档说明了依赖数组如何控制效果的触发时机。
  • 处理加载状态。AsyncStorage 是异步的,因此在保存的主题确定之前就会进行首次渲染。需要跟踪加载是否已完成,在完成之前不显示任何内容(或仅显示加载界面)。否则用户在看到自己选择的主题之前会先看到默认主题。

此外,还需处理尚未存储任何内容或存储的键不再对应任何主题的情况,此时应回退到默认主题。这样,用户的选择就能在应用重启后依然保留。

总结

最终的提供者是一个函数组件,它负责管理主题状态、持久化更改、在启动时恢复这些更改,并通过上下文暴露所有相关内容。与基于类的版本相比,其逻辑更为简洁,且从上到下依次可读。

有几点原则也适用于其他功能:

  • 将共享的 UI 预设值保存在靠近根层的上下文提供者中,并同时提供设置函数。
  • 仅持久化标识符而非整个对象,这样数据更改就不会在设备上留下过时的副本。
  • 将异步启动时的读取操作视为加载状态,而非直接渲染默认值后再进行替换。
  • 在熟练掌握了这三种 Hook 之后,可以进一步学习 useReducer 以处理更复杂的状态,使用 useRef 管理不会触发重新渲染的可变值,而 useLayoutEffect 则用于在绘制之前必须完成的任务。将现有的类组件转换为函数组件是很好的练习方式。
  • 相关阅读