首页 / 文章

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

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

发布者:REACTAPP.TOP阅读约 7 分钟1618 词

来源: Build a React Native theme provider using Hooks

主题切换看似是个样式问题,但实际上是状态管理问题:当前主题必须能在任何屏幕上清晰显示、可通过任意屏幕进行切换,并且在应用重启后依然存在。这使其成为在比计数器更实际的场景中学习 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 预设值保存在靠近根层的上下文提供者中,并同时提供设置函数。
  • 仅持久化标识符而非整个对象,这样数据更改就不会在设备上留下过时的副本。
  • 将异步启动时的读取操作视为加载状态,而非直接渲染默认值后再进行替换。
  • 在熟练掌握了这三种 Hook 之后,可以进一步学习 useReducer 以处理更复杂的状态,使用 useRef 管理不会触发重新渲染的可变值,而 useLayoutEffect 则用于在绘制之前必须完成的任务。将现有的类组件转换为函数组件是很好的练习方式。