ReactNative-渐进式指南-全-
ReactNative 渐进式指南(全)
原文:
zh.annas-archive.org/md5/7b97db5d1b53e3a28b301bff1811634d译者:飞龙
前言
React Native 框架提供了一系列强大的功能,使得在多个平台(如 iOS、Android、Linux、macOS X、Windows 和 Web)上高效构建高质量、易于维护的前端应用程序成为可能,这有助于你节省时间和金钱。
在 专业 React Native 一书中,你将找到对基本概念、最佳实践、高级流程以及日常开发者问题的易于使用技巧的全面覆盖。通过逐步解释、实际示例和专家指导,你将了解 React Native 在底层是如何工作的,然后利用这些知识来开发高性能的应用程序。随着你的学习,你将了解 React 和 React Native 之间的区别,导航 React Native 生态系统,并回顾创建 React Native 应用程序所需的 JavaScript 和 TypeScript 的基础知识。你还将处理动画,并通过手势控制你的应用程序。最后,你将能够通过自动化流程、测试和持续集成来构建更大的应用程序并提高开发效率。
在完成这本 React native 应用程序开发书籍之后,你将获得信心来构建适用于多个平台的高性能应用程序,甚至是在更大规模上。
本书面向的对象
这本书是为使用 React Native 进行开发的开发者编写的,他们有兴趣构建专业的跨平台应用程序。需要熟悉 JavaScript(包括其语法)的基础知识以及一般的软件工程概念,包括数据类型、控制流程和服务器/客户端结构。
本书涵盖的内容
第一章, 什么是 React Native?,将包含对 React Native 的简要介绍,它如何与 React 和 Expo 相关联,以及它是如何由社区驱动的。
第二章, 理解 JavaScript 和 TypeScript 的基本要素,展示了避免最常见错误和不良模式的重要基础概念。你将获得有用的提示,学习最佳实践,并重复使用 JavaScript 在应用程序中的最重要的基础知识。
第三章, Hello React Native,将帮助你更深入地了解 React Native。它包含了一个示例应用程序中的核心概念解释,以及关于 React Native 架构的理论信息以及如何将不同平台连接到 React Native JavaScript 包。
第四章, React Native 中的样式、存储和导航,涵盖了不同的领域,这些领域对于使用 React Native 创建高质量产品都至关重要。你必须关注良好的用户体验,这包括良好的设计和清晰的导航。此外,你的用户应该能够在没有网络连接的情况下尽可能多地使用你的应用程序,这意味着需要处理本地存储的数据。
第五章,管理状态和连接后端,大量关注数据。首先,您将学习如何在您的应用中处理更复杂的数据。然后,我们将探讨如何通过连接远程后端使您的应用与世界其他部分进行通信的不同选项。
第六章,与动画一起工作,专注于屏幕动画。在 React Native 中实现平滑动画有多种方法。根据您要构建的项目类型和动画,您可以从多种解决方案中选择,每种解决方案都有其自身的优缺点。我们将在本章中讨论最佳和最广泛使用的解决方案。
第七章,在 React Native 中处理手势,教您如何处理手势,如何结合手势和动画,以及提供用户反馈的最佳实践。
第八章,JavaScript 引擎和 Hermes,主要是一个理论章节,您将学习 React Native 中不同的 JavaScript 引擎是如何工作的,以及为什么 Hermes 是生产应用中首选的解决方案(当可以使用时)。它包括一些理论背景以及在不同环境中的关键指标测试。
第九章,提高 React Native 开发效率的必备工具,教您关于使开发更轻松的有用工具,尤其是在处理大型项目时。您将了解 Storybook 是如何工作的,以及为什么这是一个 React Native 开发的绝佳工具。您还将学习关于 React Native 的样式组件,不同 UI 库的建议,ESLint/TSLint,以及如 Ignite 之类的样板 CLI。
第十章,构建大规模、多平台项目结构,教您如何构建大规模项目。这包括应用架构、多个开发者成功协作的流程,以及确保良好代码质量的流程。
第十一章,创建和自动化工作流程,专注于工作流程自动化。您将学习如何设置多个 CI 管道进行代码质量检查、自动化的 PR 检查、通过邮件、Slack 或板问题进行自动化的通信,以及将应用部署到应用商店。我们将探讨 GitHub Actions、fastlane、Bitrise 和其他 CI/CD 解决方案。
第十二章,React Native 应用的自动化测试,教您如何使用 Jest 和 react-native-testing-library 进行单元和快照测试,如何确保一定的测试覆盖率,如何使用 Detox 进行端到端测试,甚至如何使用 AWS Device Farm 和 Appium 在真实设备上进行测试。
第十三章,小贴士与展望分为两部分。在第一部分,您可以阅读我关于如何使您的 React Native 项目成功的最有用的技巧。第二部分侧重于框架的展望以及我认为 React Native、其社区及其生态系统将如何在未来发展。这基于技术发展以及社区中不同大玩家的承诺。
为了充分利用这本书
您应该有一个可工作的 React Native 环境,以便能够运行本书中的示例。所有示例都使用 React Native 0.68 进行测试,但它们也应该与未来的版本兼容。
| 本书涵盖的软件 | 操作系统要求 |
|---|---|
| React Native 0.68 | Windows、macOS 或 Linux,最好是 macOS |
| TypeScript 4.4 | |
| ECMAScript 12 |
如果您正在使用这本书的数字版,我们建议您亲自输入代码或从书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助您避免与代码复制粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件github.com/alexkuttig/prn-videoexample。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他来自我们丰富的图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们!
下载彩色图像
我们还提供了一份包含本书中使用的截图和图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/xPgoW。
使用的约定
本书使用了多种文本约定。
文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“这是上一章示例项目中来自<Header />组件,但使用内联样式来设置Text组件的样式。”
代码块设置如下:
import React from 'react';
import {ScrollView, Text, View} from 'react-native';
const App = () => {
return (
<ScrollView contentInsetAdjustmentBehavior="automatic">
<View>
<Text>Hello World!</Text>
</View>
</ScrollView>
);
};
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
<Pressable
key={genre.name}
onPress={() => props.onGenrePress(genre)}
testID={'test' + genre.name}>
<Text style={styles.genreTitle}>{genre.name}</Text>
</Pressable>
任何命令行输入或输出应按以下方式编写:
npx react-native init videoexample
--template react-native-template-typescript
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“转到设置,滚动到页面底部,并选择开发者。”
小贴士或重要注意事项
看起来像这样。
联系我们
我们欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。
勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问 www.packtpub.com/support/errata 并填写表格。
盗版: 如果您在互联网上发现我们作品的任何非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packt.com 与我们联系,并提供材料的链接。
如果您有兴趣成为作者: 如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com。
分享您的想法
读完 Professional React Native 后,我们很乐意听听您的想法!请选择 www.amazon.in/review/create-review/error?asin=180056368X 为这本书提供反馈。
您的评论对我们和科技社区都至关重要,并将帮助我们确保我们提供高质量的内容。
第一部分:React Native 入门
本模块主要是帮助您达到理解更高级模块(2 和 3)所需的基本知识水平,即 React 和 React Native。阅读后,您将了解基于 React 的现代客户端开发是如何工作的,以及 React、React Native 和 Expo 之间的区别。
以下章节属于本节:
-
第一章, 什么是 React Native?
-
第二章, 理解 JavaScript 和 TypeScript 的基本要素
-
第三章, Hello React Native
第一章:什么是 React Native?
为多个平台构建高质量的应用程序是应用程序开发的圣杯。自从 React Native 发布以来,它一直在非常竞争激烈的环境中受到挑战,因为它似乎一直是这个圣杯。它在 2015 年由 Facebook 发布时的性能比任何竞争对手(Ionic、Cordova)都要好得多,其开发速度也比创建独立的 Android 和 iOS 应用程序要快得多。
自 2015 年以来,关于 React Native 发生了许多事情。Facebook 开源了这个框架,许多贡献者甚至像微软、Discord 和 Shopify 这样的大公司也大力投资 React Native,同时新的竞争对手如 Flutter 和 Kotlin Multiplatform Mobile 也发展起来。
在 7 年的时间里,许多公司成功地将他们的应用程序迁移到了 React Native,而其他一些公司则失败了,转而回到原生开发,或者最终选择了其他多平台技术。
到 2022 年,React Native 被用于比以往更多的产品中,并且它比早期变得更加开发者友好。它不仅适用于 iOS 和 Android,还适用于 macOS、Windows、Web、VR 和其他平台。最重要的是,尽管有许多谣言称并非如此,Facebook 仍然在大力押注 React Native。
Facebook 的 React Native 核心团队刚刚完成了对其主要应用程序中超过 1,000 个 React Native 屏幕的重写,包括约会、工作和市场,这些应用程序每月有超过 10 亿用户访问。这意味着 React Native 为世界上最大和最常用的应用程序的重要和业务关键部分提供了动力,这是它作为一个稳定且受支持的框架的最终证明。
如你所见,React Native 已经变得非常强大并且被广泛使用。但你必须知道如何利用其优势以及如何处理其劣势,以创建高质量的应用程序和良好的软件产品。本书包含了你需要了解的学习成果、最佳实践以及基本架构和流程概念,以便能够决定以下事情:
-
何时在你的项目中使用 React Native
-
如何设置你的 React Native 项目以支持更大规模的工作
-
如何使用 React Native 创建世界级的产品
-
如何在 React Native 项目中组织团队
-
如何通过有用的工具和流程支持你的开发团队
本章简要介绍了 React 的主要概念,这是 React Native 构建的基础,以及 React Native 本身和 Expo 框架,这是一个建立在 React Native 之上的工具和库集合。我们将关注与理解本书后面将要涵盖的内容相关的关键概念。
如果你已经对 React、React Native 和 Expo 的工作原理有非常好的理解,你可以自由地跳过本章。
在本章中,我们将涵盖以下主题:
-
探索 React
-
理解 React 基础
-
介绍 React Native
-
介绍 Expo
技术要求
要尝试本章中的代码示例,你需要为 探索 React 和 理解 React 基础 部分设置一个小型 React 应用,并为 介绍 React Native 部分设置一个 React Native 应用。这需要你根据你使用的操作系统安装各种库。reactjs.org/ 和 reactnative.dev/ 都提供了设置正确开发环境的逐步指南。
你可以在书的 GitHub 仓库中找到代码:
探索 React
在 reactjs.org/ 上,React 被定义为 用于构建用户界面的 JavaScript 库。主页上使用的口号是声明式、组件化、一次学习,到处编写。
当 React 首次在 2013 年 5 月的 JSConf US 大会上由 Facebook 的 Jordan Walke 介绍时,观众非常怀疑,Facebook 决定开始一次 React 巡回 来说服人们这个新库的好处。如今,React 是最受欢迎的用于创建网络应用的框架之一,它不仅被 Facebook 本身使用,还被 Instagram、Netflix、Microsoft 和 Dropbox 等许多其他大公司使用。
在下一节中,我将向你展示 React 的工作原理,它与其他类似框架和方法的独特之处,以及它与 React Native 的关系。
小贴士
如果你已经安装了 Node 和 Node 包管理器,你可以在终端中使用以下命令设置一个新的应用:
npx create-react-app name-of-your-app
理解 React 基础
要开始,请在你的 IDE 中打开一个项目,这样我们就可以探索一个简单的例子。这是一个返回简单 Hello World 消息的 React 应用看起来像:
function App() {
return (
<div>
<p>Hello World!</p>
</div>
)
}
当看到这些代码行时,你首先想到的可能就是这看起来就像 XML/HTML!确实如此,但这些标签会被一个预处理器转换成 JavaScript,所以这是看起来像 XML/HTML 标签的 JavaScript 代码。因此得名 JSX,它是 JavaScript XML 的缩写。
JSX 标签可以像 XML/HTML 标签一样使用;你可以使用不同类型的标签来结构化你的代码,并且可以使用 CSS 文件和 className 属性来样式化它们,这是 React 对 HTML 的 class 属性的等效。
另一方面,你可以在 JSX 中的任何地方插入 JavaScript 代码,无论是作为属性的值还是标签内部。你只需要将它放在大括号中。请看以下代码,它使用了 JSX 中的 JavaScript 变量:
function App() {
const userName = 'Some Name';
return (
<div>
<p>Hello {userName}!</p>
</div>
)
}
在这个例子中,我们通过将 userName 变量插入到我们的示例代码的 JSX 中,向一个我们之前存储在 userName 变量中的用户打招呼。
这些 JSX 标签非常实用,但如果我想在整个代码中重用代码的一部分,比如一种特殊的按钮或侧边栏元素呢?这就是 ReactJS 主页上“基于组件”的口号发挥作用的地方。
理解 React 组件
我们的例子包括一个名为App的组件。在这种情况下,它是一个函数式组件。在 React 中也可以使用类组件,但接下来的大多数示例将使用更常见的函数式组件。React 允许你编写自定义组件,并在代码的其他部分像正常 JSX 标签一样使用它们。
假设我们想要一个按钮,当点击时可以打开指向 ReactJS 主页的外部链接。我们可以定义一个自定义的ReactButton组件,如下所示:
function ReactButton() {
const link = 'https://reactjs.org';
return (
<div>
<a href={link} target="_blank" rel="noopener noreferrer">
Go To React
</a>
</div>
)
}
然后,我们可以在主组件中使用该按钮,使用空标签表示法,因为它没有子组件:
function App() {
const userName = 'Some Name';
return (
<div>
<p>Hello {userName}!</p>
<ReactButton/>
</div>
)
}
如您所见,React 中的每个组件都必须实现return函数以在应用中渲染视图。JSX 代码只能在由return函数调用时执行,并且必须有一个 JSX 标签包裹所有其他标签和组件。没有必要明确实现当内容变化时视图应该如何行为——React 会自动处理这一点。这就是我们描述 React 为声明式时所意味着的。
到目前为止,我们已经看到了为什么 React 被定义为用于构建用户界面的声明式、基于组件的 JavaScript 库。但我们还没有谈到 React 的主要优势之一:它如何高效地重新渲染视图。为了理解这一点,我们需要看看 props 和 state。
理解 React 的 props 和 state
一个WelcomeMessage组件,用于显示欢迎文本,包括来自App组件的用户名。
这个组件可能看起来是这样的:
function WelcomeMessage(props) {
return (
<div>
<p>Welcome {props.userName}!</p>
<p>It's nice to see you here!</p>
</div>
)
}
然后,我们可以将其包含在App组件中:
function App() {
const userName = "Some Name";
return (
<div>
<WelcomeMessage userName={userName}/>
<ReactButton/>
</div>
)
}
prop 的名称被用作 JSX 标签的属性。通过将props作为子组件的参数,所有这些属性都会自动在子组件中可用,例如我们例子中的username。
React 之所以高效,是因为每当 prop 的值发生变化时,只有那些受该变化影响的组件才会重新渲染。这大大减少了重新渲染的成本,尤其是在具有多层的大型应用中。
对于状态变化也是如此。React 提供了将任何组件转换为有状态组件的可能性,通过在类组件中实现state变量或在函数组件中使用useState钩子(更多关于 Hooks 的内容请见第三章,Hello React Native)。有状态组件的经典例子是一个Counter:
function Counter () {
const [numClicks, setNumClicks] = useState(0);
return (
<div>
<p>You have clicked {numClicks} times!</>
<button onClick={() => setNumClicks(numClicks+1)>
Click Me
</button>
</div>
)
}
numClicks状态变量初始化为0。每当用户点击按钮并且Counter组件的内部状态发生变化时,只有<p>标签的内容会重新渲染。
ReactDOM 负责比较 UI 树中的所有元素与之前的元素,并仅更新内容已更改的节点。此包还使得将 React 代码轻松集成到现有 Web 应用程序中成为可能,无论它们是用什么语言编写的。
当 Facebook 在 2012 年决定成为一家以移动优先的公司时,React 的这种一次学习,到处编写的方法被应用于移动应用程序的开发,这导致了 2013 年 React Native 的出现,其中可以使用 JavaScript 或 TypeScript 仅编写 iOS 或 Android 应用程序。
既然我们已经了解了 React 是什么以及它的一般工作原理,让我们进一步了解 React Native。
介绍 React Native
React Native 是一个框架,它使得将 React 代码编写并部署到多个平台成为可能。最著名的是 iOS 和 Android,但您可以使用 React Native 创建 Windows、macOS、Oculus、Linux、tvOS 以及更多应用程序。使用 React Native for Web,您甚至可以使用相同的代码将移动应用程序作为 Web 应用程序部署。
小贴士
如果您不想花一个小时设置创建新 React Native 应用程序的开发环境并尝试代码示例,您可以使用npm或yarn安装 Expo CLI:
npm install -g expo-cli 或 yarn global add expo-cli
之后,只需在终端中运行一个命令即可设置新的 React Native 应用程序:
expo init YourAppName
expo init是 yarn。如果您想使用npm,请将--npm添加到expo init命令中。
在下一节中,您将学习如何在 React Native 框架中实现跨平台开发。
React Native 基础知识
由于 React Native 在 React 的基础上构建,代码看起来非常相似;您使用组件来结构化代码,使用 props 将参数从一个组件传递到另一个组件,并在返回语句中使用 JSX 来渲染视图。主要区别之一是您可以使用的基本 JSX 组件类型。
在 React 中,它们看起来与我们在上一节中看到的 XML/HTML 标签非常相似。在 React Native 中,所谓的核心组件是从react-native库导入的,并且看起来不同:
import React from 'react';
import {ScrollView, Text, View} from 'react-native';
const App = () => {
return (
<ScrollView contentInsetAdjustmentBehavior="automatic">
<View>
<Text>Hello World!</Text>
</View>
</ScrollView>
);
};
export default App;
React Native 不像某些其他跨平台解决方案那样使用 Web 视图在设备上渲染 JavaScript 代码;相反,它将用 JavaScript 编写的 UI 转换为本地 UI 元素。例如,React Native 的View组件被转换为 Android 的ViewGroup组件,以及 iOS 的UIView组件。这种转换是通过 Yoga 引擎(yogalayout.com)完成的。
React Native 由两个线程驱动 - JavaScript 线程,其中执行 JavaScript 代码,以及本地线程(或 UI 线程),其中发生所有设备交互,如用户输入和屏幕绘制。
这两个线程之间的通信是通过所谓的Bridge进行的,它是 JavaScript 代码和应用程序原生部分之间的一种接口。例如,原生事件或指令等信息以序列化批量的形式从原生 UI 线程通过 Bridge 发送到 JavaScript 线程,然后再返回。这个过程在下面的图中展示:
![图 1.1 – React Native Bridge
![图片 B16694_01_01.jpg]
图 1.1 – React Native Bridge
如您所见,事件在原生线程中收集。然后信息被序列化并通过 Bridge 传递到 JavaScript 线程。在 JavaScript 线程中,信息被反序列化并处理。这也同样适用于相反的方向,如前图中步骤 5到8所示。您可以调用由原生组件提供的方法,或者 React Native 在必要时可以更新 UI。这也是通过序列化信息并通过 Bridge 将其传递到原生线程来完成的。这个 Bridge 使得原生和 JavaScript 之间的异步通信成为可能,这对于使用 JavaScript 创建真正的原生应用程序来说是非常好的。
但它也有一些缺点。信息的序列化和反序列化,以及作为原生和 JS 之间唯一的中心通信点,使得 Bridge 成为了一个瓶颈,在某些情况下可能导致性能问题。这就是为什么 React Native 在 2018 年至 2022 年之间被完全重写。
新的 React Native(2022)
由于之前提到的架构问题,React Native 的核心被完全重构并重写。主要目标是消除 Bridge 及其相关的性能问题。这是通过引入 JSI(JavaScript 接口)来实现的,它允许原生代码和 JavaScript 代码之间直接通信,无需进行序列化和反序列化。
JS 部分真正了解原生对象,这意味着您可以直接同步调用方法。此外,在重构过程中引入了一个新的渲染器,称为 Fabric。关于 React Native 重构的更多细节将在第三章,Hello React Native中提供。
重构使得原本出色的 React Native 框架更加出色,显著提高了其即插即用的性能。在撰写本文时,越来越多的包正在适应新的 React Native 架构。
更多 React Native 优势
自从 2015 年开源以来,已经形成了一个庞大且不断增长的社区,该社区为各种不同的问题和用例开发并提供了大量的附加包。这是 React Native 相对于其他类似跨平台方法的主要优势之一。
这些包大多数都得到了良好的维护,并提供了目前存在的几乎所有原生功能,因此你只需使用 JavaScript 来编写你的应用程序。
这意味着使用 React Native 进行移动应用开发可以大大减少开发团队的大小,因为你不再需要 Android 和 iOS 专家,或者至少可以显著减少原生专家的团队规模。
与这些维护良好的包一起工作的最好之处在于,当包更新时,React Native 核心重写等事物会自动应用到你的应用中。
此外,热重载功能通过使代码更改的效果在几秒钟内可见,从而加快了开发过程。还有其他几个工具使 React Native 开发者的生活更加舒适,我们将在第九章《提高 React Native 开发的基本工具》中更详细地探讨。
现在我们已经了解了 React 和 React Native 是什么,以及它们是如何相互关联的,让我们看看一个使整个开发过程变得更加容易的工具——Expo。
介绍 Expo
设置新的 React Native 应用有几种方法。对于本书中的示例项目,我们将使用 Expo。它是一个基于 React Native 构建的强大框架,包括许多不同的工具和库。Expo 使用纯 React Native,并增强了大量功能。
当涉及到核心组件和原生功能时,React Native 是一个非常精简的框架,而 Expo 提供了几乎你能在应用中使用到的所有功能。它为几乎所有原生设备功能提供了组件和 API,例如视频播放、传感器、位置、安全、设备信息以及更多。
将 Expo 视为一个全服务包,它可以让你的 React Native 开发者生活变得更加轻松。由于任何事物都有其缺点,Expo 会增加你最终应用包的大小,因为无论你是否使用,你都会将所有库添加到你的应用中。
它还使用了一种某种修改过的 React Native 版本,这通常比最新的 React Native 版本落后一到两个版本。因此,当使用 Expo 时,你必须在它们发布后等待几个月才能使用最新的 React Native 功能。
如果你想要以最大速度达到结果且不需要优化你的包大小,我会推荐使用 Expo。
当使用 Expo 设置新项目时,你可以选择两种不同的工作流程——裸工作流程和管理工作流程。在这两种工作流程中,框架为你提供了易于使用的库,用于包含相机、文件系统等原生元素。此外,还有推送通知处理、空中功能更新以及针对 iOS 和 Android 构建的特殊 Expo 构建服务等服务。
如果你选择裸工作流,你将拥有一个普通的 React Native 应用程序,并且可以添加你需要的 Expo 库。你还可以添加其他第三方库,这在托管工作流中是不可能的。在那里,你只需在你选择的 IDE 中编写 JavaScript 或 TypeScript 代码;其他所有事情都由框架处理。
在他们的主页 (docs.expo.dev/) 上,Expo 建议你从一个新的应用开始使用托管工作流,因为如果需要,你总是可以通过在 CLI 中使用 expo eject 命令切换到裸工作流。这种必要性可能出现在你需要集成一个由 Expo 不支持的第三方包或库,或者你想添加或更改原生代码的情况下。
初始化应用程序后,你可以使用 expo start 命令来运行它。这将启动 Metro 打包器,使用 Babel 编译应用程序的 JavaScript 代码。此外,它打开 Expo 开发者 CLI 界面,在那里你可以选择你想要在哪个模拟器中打开应用程序,如下面的截图所示:
![图 1.2 – Expo CLI 界面
![img/B16694_01_02.jpg]
图 1.2 – Expo CLI 界面
Expo 开发者工具提供了访问 Metro 打包器日志的功能。它还创建了关于如何运行应用程序的多个选项的关键绑定,例如 iOS 或 Android 模拟器。最后,它创建了一个可以用 Expo Go 应用扫描的二维码。对于大多数用例,Expo 还支持从 React Native 代码创建 Web 应用程序。
使用 Expo,在硬设备上运行你的应用程序非常简单——只需在你的智能手机或平板电脑上安装 Expo 应用,并扫描之前描述的二维码。同时运行在多个设备或模拟器上也是可能的。
所有这些特性使 Expo 成为使用 React Native 进行移动应用开发的非常方便且易于使用的框架。
摘要
在本章中,我们介绍了 JavaScript 库 React 的主要概念。我们已经展示了 React 是声明式的、基于组件的,并遵循“一次学习,到处编写”的方法。这些概念是跨平台移动开发框架 React Native 的基础。
你已经看到了这个框架的主要优势,即庞大的社区提供了额外的包和库,许多操作系统(除了 iOS 和 Android)都可用,以及通过 Bridge 或 JSI 使用原生元素。最后但同样重要的是,你发现了 Expo 作为设置 React Native 应用的一种方式,并且你知道何时使用哪种 Expo 工作流。
在下一章中,我们将简要介绍 JavaScript 和 TypeScript 的最重要的事实和特性。
第二章:理解 JavaScript 和 TypeScript 的基本知识
由于 React Native 应用程序是用 JavaScript 编写的,因此对这种语言有非常深入的理解对于构建高质量的应用程序至关重要。JavaScript 非常容易学习,但很难掌握,因为它允许你几乎可以做任何事情而不会给你带来太多麻烦。然而,仅仅因为你能够做任何事情并不意味着你应该这样做。
本章的整体目标是展示避免最常见的错误、不良模式和非常昂贵的“不要”的重要基础概念。你将获得有用的提示,学习最佳实践,并重复使用 JavaScript 在应用程序中最重要的一些基本知识。
在本章中,我们将涵盖以下主题:
-
探索现代 JavaScript
-
React Native 开发的 JavaScript 知识
-
与异步 JavaScript 一起工作
-
使用类型化 JavaScript
技术要求
除了需要一个浏览器来运行本章的示例之外,没有其他技术要求。只需访问jsfiddle.com/或codesandbox.io/,输入并运行你的代码即可。
要访问本章的代码,请通过以下链接访问本书的 GitHub 仓库:
本章不是一个完整的教程。如果你不熟悉 JavaScript 基础知识,请查看javascript.info,这是我推荐开始学习的 JavaScript 教程。
探索现代 JavaScript
当我们谈论现代JavaScript时,这指的是 ECMAScript 2015(也称为 ES6)或更新的版本。它包含了许多有用的功能,这些功能不包括在较旧的 JavaScript 版本中。自 2015 年以来,每年都会发布一次更新规范。
你可以在 TC39 GitHub 仓库(bit.ly/prn-js-proposals)中查看之前版本中实现的功能。你还可以在那里找到有关即将推出的功能和发布计划的大量信息。
让我们通过查看内部结构来开始我们的旅程,以理解 JavaScript 最重要的部分。为了真正理解现代 JavaScript 及其周围的工具,我们必须稍微了解一下语言的基础和历史。JavaScript 是一种脚本语言,几乎可以在任何地方运行。
最常见的用例显然是构建用于网页浏览器的动态前端,但它也可以作为其他软件的一部分在服务器(Node.js)上运行,在微控制器上运行,或者(对我们来说最重要的是)在应用程序中运行。
JavaScript 运行的地方都必须有一个 JavaScript 引擎,它负责执行 JavaScript 代码。在旧浏览器中,引擎只是简单的解释器,在运行时将代码转换为可执行的字节码,而不进行任何优化。
今天,不同的 JS 引擎内部正在进行大量的优化,这取决于对引擎用例重要的哪些指标。例如,Chromium V8 引擎引入了即时编译,这在执行 JavaScript 时带来了巨大的性能提升。
为了能够在所有这些平台和所有这些引擎之间对 JavaScript 有一个共同的理解,JavaScript 有一个称为 ES 的标准化规范。随着越来越多的功能(如改进的异步或更简洁的语法)被引入 JavaScript,这个规范不断演变。
这个不断发展的功能集对于开发者来说很棒,但也引入了一个大问题。为了能够使用 ES 语言规范的新功能,相关的 JavaScript 引擎必须实现这些新功能,然后必须将引擎的新版本推出给所有用户。
尤其是当涉及到浏览器时,这是一个大问题,因为许多公司依赖于非常旧的浏览器作为其基础设施。这将使得开发者多年内无法使用新功能。
这就是像 Babel (babeljs.io) 这样的转译编译器发挥作用的地方。这些转译编译器将现代 JavaScript 转换为向后兼容的版本,这样较旧的 JavaScript 引擎就可以执行。这种转编译是现代网络应用程序以及 React Native 应用程序构建过程中的一个重要步骤。
当编写现代 JavaScript 应用程序时,它的工作方式是这样的:
-
你使用现代 JavaScript 编写代码。
-
转译编译器将你的代码转换为预 ES6 的 JavaScript。
-
JavaScript 引擎解释你的代码并将其转换为字节码,然后在该机器上执行。
-
现代 JavaScript 引擎通过诸如即时编译等特性优化执行。
当涉及到 React Native 时,你可以选择具有不同优势和劣势的不同 JavaScript 引擎。你可以在第八章中了解更多信息,JavaScript 引擎和 Hermes。
在本节中,你学习了现代 JavaScript 是什么以及它在底层是如何工作的。让我们继续学习在开发 React Native 时所需的 JavaScript 的具体部分。
探索 JavaScript 以进行 React Native 开发
在本节中,你将学习一些基本的 JavaScript 概念,所有这些概念对于真正理解如何使用 React Native 都非常重要。再次强调,这并不是一个完整的教程;它只包括如果你不想遇到难以调试的错误,你必须牢记的最重要的事情。
小贴士
当你不确定 JavaScript 在特定场景中的行为时,只需创建一个隔离的示例并在 jsfiddle.com/ 或 codesandbox.io/ 上尝试它。
理解对象的分配和传递
在任何编程语言中,分配或传递数据是最基本的操作之一。你在每个项目中都会做很多次。当使用 JavaScript 时,处理原始类型(布尔值、数字、字符串等)和处理对象(或数组,它们基本上是对象)之间存在差异。
原始类型是通过值分配和传递的,而对象是通过引用分配和传递的。这意味着对于原始类型,会创建并存储值的真正副本,而对于对象,只会创建并存储对同一对象的引用。
这一点非常重要,因为当您编辑分配或传递的对象时,您也在编辑初始对象。
以下代码示例将使这一点更加清晰:
function paintRed(vehicle){
vehicle.color = 'red›;
}
const bus = {
color: 'blue'
}
paintRed(bus);
console.log(bus.color); // red
paintRed 函数不返回任何内容,我们在初始化为蓝色公交车后不在 bus 中写入任何内容。那么会发生什么?bus 对象是通过引用传递的。这意味着 paintRed 函数中的 vehicle 变量和函数外部的 bus 变量引用存储中的相同对象。
当改变 vehicle 的颜色时,我们也改变了 bus 引用的对象的颜色。
这是预期的行为,但您应该尽量避免在大多数情况下使用它。在较大的项目中,当对象在许多函数中传递并更改时,代码可能会变得非常难以阅读(和调试)。正如罗伯特·C·马丁在《Clean Code》一书中已经写到的,函数应该没有副作用,这意味着它们不应该改变函数作用域之外的价值。
如果您想在函数中更改对象,我建议在大多数情况下使用返回值。这更容易理解和阅读。以下示例显示了上一个示例中的代码,但没有副作用:
function paintRed(vehicle){
const _vehicle = { ...vehicle }
_vehicle.color = 'red'
return _vehicle;
}
let bus = {
color: 'blue'
}
bus = paintRed(bus);
console.log(bus.color); // red
在这个代码示例中,bus 是一个新对象,这是由 paintRed 函数创建的这一点非常清楚。
在您的工作项目中请记住这一点。当您必须调试对象中的更改,但不知道它从何而来时,这真的可能花费您很多时间。
创建对象的真正副本
由前一点导致的一个非常常见的问题是您必须克隆一个对象。有多种方法可以做到这一点,每种方法都有不同的限制。以下代码示例中展示了三种选项:
const car = {
color: 'red',
extras: {
radio: "premium",
ac: false
},
sellingDate: new Date(),
writeColor: function() {
console.log('This car is ' + this.color);
}
};
const _car = {...car};
const _car2 = Object.assign({}, car);
const _car3 = JSON.parse(JSON.stringify(car));
car.extras.ac = true;
console.log(_car);
console.log(_car2);
console.log(_car3);
我们创建了一个具有不同类型属性的对象。这很重要,因为克隆对象的不同方法并不适用于所有属性。我们使用字符串作为 color,对象作为 extras,日期作为 sellingDate,并在 writeColor 中使用函数来返回带有汽车颜色的字符串。
在接下来的几行中,我们使用三种不同的方法来克隆对象。在创建 _car、_car2 和 _car3 克隆对象后,我们更改初始 car 对象中的 extras。然后我们记录所有三个对象。
现在,我们将详细探讨有关如何在 JavaScript 中克隆对象的多种选项。这些选项包括以下内容:
-
扩展运算符和
Object.assign -
JSON.stringify和JSON.parse -
真正的深克隆
我们将从扩展运算符和Object.assign开始,它们基本上以相同的方式工作。
扩展运算符和Object.assign
我们用来创建_car的三个点称为car。在第 14 行,我们做了非常类似的事情;我们使用Object.assign将car的所有属性赋值给一个新的空对象。
事实上,第 13 行和第 14 行的工作方式相同。它们创建了一个浅克隆,这意味着它们克隆了对象的所有属性值。
这对于值来说效果很好,但对于复杂的数据类型则不行,因为,再次强调,对象是通过引用分配的。因此,这些创建复杂对象副本的方法只克隆了对象属性数据的引用,而没有创建每个属性的真正副本。
在我们的例子中,我们不会创建extras、sellingDate和writeColor的实际副本,因为car对象中属性的值只是对对象的引用。这意味着当我们修改第 17 行的_car.extras时,也会修改_car2.extras,因为它们引用的是同一个对象。
因此,这些克隆对象的方法对于只有一层的对象来说效果很好。一旦有一个多层的对象,使用扩展运算符或Object.assign克隆可能会在你的应用程序中引起严重问题。
再次进行序列化和解析
一种非常常见的克隆对象模式是使用 JavaScript 内置的JSON.stringify和JSON.parse功能。这会将对象转换为原始类型(JSON 字符串),并通过再次解析字符串来创建一个新的对象。
这将强制进行深克隆,这意味着甚至子对象也会按值复制。这种方法的缺点是它只适用于在 JSON 中有等效值的值。
因此,你将丢失所有函数、未定义的属性以及 JSON 中不存在的值,如无穷大。其他事物,如日期对象,将被简化为字符串,导致时区丢失。因此,这个解决方案非常适合具有原始值的深对象。
真正的深克隆
当你想创建一个真正的深克隆对象时,你必须发挥创意并编写自己的函数。在网上搜索时,有很多不同的方法。我建议使用经过良好测试和维护的库,例如 Lodash (lodash.com/)。它提供了一个简单的cloneDeep函数,它会为你完成工作。
你可以使用所有解决方案,但你要记住每种方法的局限性。当使用它们时,你也应该查看不同解决方案的性能。在大多数情况下,所有克隆方法都足够快,可以用来使用,但当你应用程序中遇到性能问题时,你应该更仔细地查看你使用的方法。
请在以下表格中查找摘要:


图 2.1 – JavaScript 克隆解决方案的比较
在某些情况下知道如何克隆对象非常重要,因为使用错误的克隆技术可能会导致难以调试的错误。
在理解了如何克隆对象之后,让我们看看如何解构对象。
在 JavaScript 中使用解构
当你使用 React Native 时,你还需要经常做的一件事是解构对象和数组。解构基本上意味着展开对象属性或数组元素。尤其是在使用 Hooks 时,这是你必须非常清楚的事情。让我们从数组开始。
解构数组
看一下以下代码示例,它展示了数组是如何被解构的:
let name = ["John", "Doe"];
let [firstName, lastName] = name;
console.log(firstName); // John
console.log(lastName); // Doe
你可以看到一个包含两个元素的数组。在第二行,我们通过将name数组赋值给包含两个变量的数组来解构name数组。第一个变量被分配数组的第一个值,第二个变量被分配第二个值。这也可以用于超过两个值的情况。
数组解构在每次你使用useStateHook 时都会用到(更多内容请参阅第三章**,Hello React Native)。
现在你已经知道了如何解构数组,让我们继续学习如何解构对象。
解构对象
以下代码示例展示了如何解构一个对象:
let person = {
firstName: "John",
lastName: "Doe",
age: 33
}
let {firstName, age} = person;
console.log(firstName); // John
console.log(age); // 33
对象解构与解构数组的工作方式相同。但请注意代码示例的第 6 行中的花括号。在解构对象而不是数组时,这一点非常重要。你可以仅通过在解构中使用键来获取对象的所有属性,但你不必使用所有属性。在我们的例子中,我们只使用了firstName和age,而没有使用lastName。
在使用解构时,你还可以收集在解构期间未指定的所有元素。这通过以下章节中描述的扩展运算符来完成。
在解构时使用扩展运算符
以下代码示例展示了如何使用扩展运算符:
const person = {
firstName: 'n',
lastName: 'Doe',
age: 33,
height: 176
}
const {firstName, age, ...rest} = person;
console.log(firstName); // John
console.log(age); // 33
console.log(Object.keys(rest).length); // 2
当解构数组或对象时,你可以使用扩展运算符来收集在解构中未指定的所有元素。在代码示例中,我们在解构时使用了firstName和age。
在这个例子中,所有其他属性,例如lastName和height,都被收集到一个新的对象rest变量中。这在 React 和 React Native 中用得很多,例如在将属性(或 props)传递到组件并解构这些 props 时。
当你使用 React 或 React Native,尤其是与函数组件和 Hooks 一起工作时,解构是你在每个组件中都会用到的东西。基本上,它不过是展开对象属性或数组元素。
现在我们已经理解了解构,让我们继续学习另一个重要的话题——JavaScript 中的this关键字及其作用域。
理解 JavaScript 中的this
当涉及到this关键字时,JavaScript 有着相当独特的行为。它并不总是指向使用它的函数或作用域。默认情况下,this绑定到全局作用域。这可以通过隐式或显式绑定来改变。
隐式和显式绑定
this始终指向对象。this指向另一个上下文。这是 React 和 React Native 中经常使用的一种方法,用于在类组件的处理程序中绑定this。
请查看以下代码示例:
class MyClass extends Component{
constructor( props ){
this.handlePress =
this.handlePress.bind(this);
}
handlePress(event){
console.log(this);
}
render(){
return (
<Pressable type="button"
onPress={this.handlePress}>
<Text>Button</Text>
</Pressable >
);
}
}
在前面的代码中,我们明确地将类的this值绑定到handlePress函数上。这是必要的,因为我们如果不这样做,this将隐式地绑定到调用它的对象上,在这种情况下,它将是Pressable组件中的任何地方。由于在大多数情况下,我们希望在handlePress函数中访问MyClass组件的数据,因此这种显式绑定是必要的。
你可以在很多应用中看到这种代码,因为长期以来,这是从函数内部访问类属性的唯一方法。这导致了构造函数中,特别是在较大的类组件中,有很多显式绑定语句。幸运的是,今天有一个更好的解决方案——箭头函数!
箭头函数拯救
在现代 JavaScript 中,还有一个解决方案使得隐式/显式绑定变得多余:this关键字被绑定。你不需要写function myFunction(param1){},只需简单地写const myFunction = (param1) => {}。
这里重要的是箭头函数始终使用this的词法作用域,这意味着它们不会隐式地重新绑定this。
以下示例展示了如何使用箭头函数来使显式绑定语句变得多余:
class MyClass extends Component{
handlePress = (event) => {
console.log(this);
}
render(){
return (
<Pressable type="button"
onPress={this.handlePress}>
<Text>Button</Text>
</Pressable >
);
}
}
正如你所见,我们使用箭头函数来定义handlePress。正因为如此,我们不需要像之前的代码示例那样进行显式绑定。我们只需在handlePress函数内部使用this来访问MyClass组件的其他属性的状态和 props。这使得代码更容易编写、阅读和维护。
重要提示
请记住,普通函数和箭头函数不仅在语法上不同,它们还改变了this的绑定方式。
理解this的作用域对于避免昂贵的错误,如未定义的对象引用至关重要。当涉及到应用开发时,这些未定义的对象引用可能会导致应用崩溃。因此,在使用this关键字时,请记住你引用的作用域。
这些是在使用 JavaScript 开发大型应用时你必须真正理解的最重要的事情。如果你不这样做,你将犯下昂贵的错误。
在使用 React Native 开发应用时,下一件非常重要的事情是异步编程。
使用异步 JavaScript
由于 React Native 的架构(更多内容请参阅第三章,Hello React Native)以及应用的典型用例,理解异步 JavaScript 至关重要。异步调用的典型例子是对 API 的调用。
在同步世界中,在发出调用后,应用程序将被阻塞,直到收到 API 的响应。这显然是不期望的行为。应用程序应该在等待响应的同时响应用户交互。这意味着 API 调用必须是异步的。
在 JavaScript 中处理异步调用有多种方式。第一种是回调。
探索回调
回调是处理 JavaScript 中异步操作的最基本方式。我建议尽可能少地使用它们,因为还有更好的替代方案。但是,由于许多库依赖于回调,你必须对它们有一个很好的理解。
回调是一个 JavaScript 函数 A,它作为参数传递给另一个函数 B。在函数 B 的某个点,函数 A 被调用。这种行为被称为回调。以下代码展示了简单的回调示例:
const A = (callback) => {
console.log("function A called");
callback();
}
const B = () => {
console.log("function B called");
}
A(B);
// function A called
// function B called
当你查看代码时,函数 A 被调用。它记录了一些文本,然后调用回调。这个回调是在函数 A 被调用时作为属性传递给函数 A 的函数 – 在这个例子中,函数 B。
因此,函数 B 在函数 A 的末尾被调用。函数 B 随后记录了一些更多的文本。由于这段代码,你将看到两行文本:首先,函数 A 记录的文本,其次,函数 B 记录的文本。
虽然回调可能有点难以理解,但让我们看看底层发生了什么。
理解实现
要真正理解回调,我们不得不稍微深入到 JavaScript 引擎的实现中。JavaScript 是单线程的,所以在 JavaScript 代码执行过程中,异步是不可能的。以下图显示了 JavaScript 引擎的重要部分以及它们如何协同工作以实现异步:

图 3.2 – 示例应用程序架构
如图 3.2所示,我们将创建三个视图(Home.tsx、Genre.tsx和Movie.tsx)。由于我们没有使用任何导航库,我们必须使用App.tsx的状态在这些视图之间切换。所有三个视图都使用ScrollContainer容器来正确放置视图的内容。它们还共享一些可重用组件。
结果是一个非常简单的应用程序,它让我们能够导航我们的电影内容。在下面的屏幕截图中,你可以看到它的样子:

图 3.3 – 示例应用程序截图
你可以在第一页看到一个电影类型的列表,在第二页看到一个单一类型的电影列表,在第三页是电影详情。
现在你已经了解了架构并看到了高级概述,现在是时候深入代码了。我们将关注最有趣的部分,但如果你想看到整个代码,请参阅技术要求部分中提到的 GitHub 仓库。让我们从App.tsx文件开始。
创建根视图
App.tsx文件作为我们项目的根组件。它决定哪个视图应该被挂载,并持有全局应用程序状态。请查看以下代码:
const App = () => {
const [page, setPage] = useState<number>(PAGES.HOME);
const [genre, setGenre] = useState<IGenre |
undefined>(undefined);
const [movie, setMovie] = useState<IMovie |
undefined>(undefined);
const chooseGenre = (lGenre: IGenre) => {
setGenre(lGenre);
setPage(PAGES.GENRE);
};
const chooseMovie = (lMovie: IMovie) => {
setMovie(lMovie);
setPage(PAGES.MOVIE);
};
const backToGenres = () => {
setMovie(undefined);
setPage(PAGES.GENRE);
};
const backToHome = () => {
setMovie(undefined);
setGenre(undefined);
setPage(PAGES.HOME);
};
switch (page) {
case PAGES.HOME:
return <Home chooseGenre={chooseGenre} />;
case PAGES.GENRE:
return (
<Genre
backToHome={backToHome}
genre={genre}
chooseMovie={chooseMovie}
/>
);
case PAGES.MOVIE:
return <Movie backToGenres={backToGenres}
movie={movie} />;
}
};
如你所见,App.tsx文件有三个状态变量。这个状态可以被视为全局状态,因为App.tsx文件是应用程序的根组件,并且可以传递给其他组件。它必须包含一个页面来定义哪个视图应该可见,并且它可以包含一个类型和一个电影。
在文件的末尾,你可以找到一个switch/case语句。根据页面状态,这个switch/case决定哪个视图应该被挂载。此外,App.tsx文件提供了一些在应用程序中导航的函数(chooseGenre、chooseMovie、backToGenres、backToHome),并将它们传递给视图。
重要提示
如你所见,状态变量的直接设置函数(setPage、setGenre、setMovie)并没有传递给任何视图。相反,我们创建了调用这些设置函数的函数。这是最佳实践,因为它保证了我们的状态以可预测的方式被修改。你永远不应该允许你的状态直接从组件外部被修改。你将在第五章中了解更多关于管理状态和连接后端的内容。
接下来,让我们看看视图。这些是显示内容的页面。
根据状态显示内容
Home视图是用户打开应用时看到的第一个页面。请查看以下代码:
import {getGenres} from '../../services/movieService';
interface HomeProps {
chooseGenre: (genre: IGenre) => void;
}
const Home = (props: HomeProps) => {
const [genres, setGenres] = useState<IGenre[]>([]);
useEffect(() => {
setGenres(getGenres());
}, []);
return (
<ScrollContainer>
<Header text="Movie Genres" />
{genres.map(genre => {
return (
<Pressable onPress={() =>
props.chooseGenre(genre)}>
<Text style={styles.genreTitle}>{genre.name}
</Text>
</Pressable>
);
})}
</ScrollContainer>
);
};
在这里,你可以看到多个东西。在代码块顶部,你可以看到我们为props组件定义了一个interface。这是 TypeScript 声明,说明了应该从父组件(在这种情况下,是App.tsx文件)传递给此组件的内容。接下来,我们有一个作为状态变量的类型列表。
这是一个局部状态或组件状态,因为它只在这个组件内部使用。在下一行,我们使用useEffect钩子调用我们的movieService的getGenres方法来获取类型并将它们设置到局部状态。
你将在本章的理解类组件、函数组件和 Hooks部分中了解更多关于useState和useEffect钩子的内容,但到目前为止,重要的是当组件挂载时,带有空数组作为第二个参数的useEffect只会被调用一次。
注意
当使用 React 时,经常使用挂载和卸载这两个术语。挂载意味着向渲染树添加之前不存在的组件。一个新挂载的组件可以触发其生命周期函数(类组件)或 hooks(函数组件)。卸载意味着从渲染树中移除组件。这也可以触发生命周期函数(类组件)或 Hook 清理(函数组件)。
在useEffect钩子之后,你可以看到return语句,其中包含ScrollContainer容器,该容器包含Header组件和一系列Pressable实例,每个类型一个。这个列表是用.map命令创建的。
重要提示
这种声明性 UI 和 JavaScript 数据处理混合是 React 和 React Native 最大的优势之一,你将经常看到它。但无论何时这样做,都要记住,这将在组件每次重新渲染时进行处理和重新计算。这意味着不应在此处执行昂贵的数据处理操作。
在查看Home视图之后,我们也应该看看Genre视图。它基本上以相同的方式工作,但有一个很大的不同。Genre视图根据从App.tsx文件传递的属性获取其数据。在这里看看Genre.tsx文件的useEffect钩子:
useEffect(() => {
if (typeof props.genre !== 'undefined') {
setMovies(getMoviesByGenreId(props.genre.id));
}
}, [props.genre]);
你可以看到movieService的getMoviesByGenreId方法需要从App.tsx文件中获取Genre.tsx文件中的类型。
整个过程如下:
-
App.tsx文件将chooseGenre函数传递给Home.tsx文件。 -
用户点击一个类型并触发
chooseGenre函数,该函数将类型设置为App.tsx状态,并在App.tsx文件中将页面设置为GENRE,这会导致Home.tsx卸载并挂载Genre.tsx。 -
App.tsx文件将类型传递给Genre.tsx文件。 -
Genre.tsx文件根据 genre ID 获取该类别的电影。
使用相同的模式设置电影并导航到Movie.tsx视图。
在这个例子中,Movie.tsx页面本身不获取任何数据。它从App.tsx文件中传递下来显示的电影数据,并且不需要其他信息。
在理解视图之后,我们现在将查看组件。
使用可重用组件
将在不同地方使用的 UI 代码移动到组件中非常重要,至少当项目增长时——这是防止代码重复和 UI 不一致的关键。但即使在较小的项目中,使用可重用组件也是一个好主意,并且可以大大加快开发速度。在这个简单的例子中,我们创建了一个Header组件:
interface HeaderProps {
text: string;
}
const Header = (props: HeaderProps) => {
return <Text style={styles.title}>{props.text}</Text>;
};
const styles = StyleSheet.create({
title: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 16,
},
});
如您所见,这是一个非常简单的组件。它接受一个字符串,并以预定义的方式渲染该字符串,但即使这个简单的组件也能为我们节省很多时间,并防止代码重复。我们不必在Home.tsx、Genre.tsx和Movie.tsx中分别样式化标题文本,我们只需使用Header组件,就可以以一致的方式获取我们的标题文本。
重要提示
在可能的情况下使用可重用组件。它们确保 UI 的一致性,并使整个应用程序中的更改易于适应。
在查看组件之后,我们将注意力转向服务。
使用服务获取数据
您应该始终将数据获取从应用程序的其他部分抽象出来。这不仅出于逻辑原因,而且如果您必须在此处更改任何内容(因为 API 更改),您不想触及您的视图或组件。
在这个例子中,我们使用两个 JSON 文件作为数据源。您可以在存储库的assets/data下找到它们。服务使用这些文件来过滤或列出数据,并将其提供给视图。请查看以下代码:
const genres: IGenre[] = require('../../assets/data/genres.json');
const movies: IMovie[] = require('../../assets/data/movies.json');
const getGenres = (): Array<IGenre> => {
return genres;
};
const getMovies = (): Array<IMovie> => {
return movies;
};
const getMovieByGenreId = (genreId: number):
Array<IMovie> => {
return movies.filter(movie =>
movie.genre_ids.indexOf(genreId) > -1);
};
export {getGenres, getMovies, getMovieByGenreId };
如您所见,我们要求在前两行提供两个 JSON 文件。getGenres和getMovies函数仅返回文件的内容,没有任何过滤。getMovieByGenreId函数接受一个数字类型的 genre ID,并在电影的genre_ids中过滤出此 ID 的电影。然后它返回过滤后的movies数组。
在最后一行,我们导出要导入到我们的视图中的函数。
重要提示
在较大的项目中,使用类似我们这里的 JSON 文件这样的虚拟数据开始工作是非常常见的。这是因为前端部分通常与 API 并行开发,并且有了虚拟数据,前端团队可以确切地知道数据将是什么样子。当 API 准备就绪并且数据服务很好地抽象化后,用真实世界的 API 数据获取替换虚拟数据就不再成问题。我们也会在第五章中这样做,管理状态和连接后端。
最后,我们将查看容器。
使用容器进行页面样式
在我们的示例中,我们只有一个容器,ScrollContainer。它具有与组件非常相似的目的,但组件主要是作为视图的一部分使用的部分,而容器用于定义视图的(外部)布局。请查看我们的ScrollContainer容器代码:
interface ScrollContainerProps {
children: React.ReactNode;
}
const ScrollContainer = (props: ScrollContainerProps) => {
return (
<SafeAreaView style={styles.backgroundStyle}>
<ScrollView
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={styles.contentContainer}
style={styles.backgroundStyle}>
{props.children}
</ScrollView>
</SafeAreaView>
);
};
正如你在界面定义中看到的,我们的ScrollContainer容器只接受一个名为children的属性,它被定义为React.ReactNode。这意味着你可以将组件传递给ScrollContainer。此外,React 组件的children属性使得在传递所有 JSX 标签之间的内容时,可以使用开闭标签使用此组件,并将这些内容作为children属性传递给组件。这正是我们在所有视图中所做的那样。
我们的ScrollContainer容器也使用了一个名为SafeAreaView的组件。这是由 React Native 提供的,可以处理所有带有刘海(iPhone、三星)的设备、虚拟返回按钮(Android)等不同设备。
现在你已经看过我们第一个示例应用的所有不同部分,是时候进行简短的总结。到目前为止,你已经学会了如何构建一个应用,为什么抽象不同的层很重要,以及如何创建可重用的 UI。
你也已经了解到,React 和 React Native 组件始终由两部分组成:在状态/属性中准备数据,以及使用 JSX 显示数据。也许你也意识到,我们所有的组件都是按照这样的顺序排序的,即数据准备位于组件的顶部,而数据的显示位于底部。我更喜欢这种组件结构方式,因为它使组件的阅读性大大提高。
你也已经知道了一种在组件之间传递属性的方法。因为这是一个非常重要的主题,我们将在下一节中更详细地讨论。
传递属性
正如你在示例应用中已经看到的,在应用中传递数据有多种方式。已经建立了一些最佳实践,你绝对应该坚持;否则,你的应用可能会变得非常难以调试和维护。我们在这里列出这些:
- 永远不要以不可预测的方式从组件外部修改组件的状态:我知道——我再重复一遍;我们在上一节中提到过,但这非常重要。以不可预测的方式从组件外部修改状态可能会导致错误,尤其是在你与一个开发团队一起在大型项目中工作时。但让我们详细看看。
在这种情况下,“不可预测”意味着你直接将你的状态设置函数传递给其他组件。
为什么这样很糟糕?因为其他组件和可能的其他开发者可以决定将什么放入你的组件状态。很可能 sooner or later,其中之一决定放入一些你的组件在某些边缘情况下无法处理的东西。
解决方案是什么?在多个场景中,你可能需要从组件外部修改组件状态,但如果你必须这样做,请通过传递预定义的函数以可预测的方式进行。然后,这些函数应该验证数据并处理状态修改。
-
你可以使用
PropTypes。更多信息,请参阅此链接:www.npmjs.com/package/prop-types。 -
限制传递的 props 数量:你传递的属性越多,你的代码就越难阅读和维护,所以如果你认为有必要传递一个属性,请三思。此外,传递对象而不是多个原始数据类型会更好。
在本节介绍传递属性的最好实践之后,我们将在下一节更深入地探讨不同的组件类型和 hooks。
理解类组件、函数组件和 Hooks
React 和 React Native 提供了两种不同的编写组件的方式:类组件和函数组件。如今,你可以互换使用这两种变体。两种方式都受到支持,目前没有迹象表明其中任何一种在未来不会被支持。那么,为什么存在两种不同的方式呢?这要归因于历史原因。在 2019 年(React 16.8)引入 hooks 之前,函数组件不能拥有状态或使用任何生命周期方法,这意味着任何需要获取和存储数据的组件都必须是类组件。但是,由于函数组件需要编写的代码更少,它们通常用于显示作为 props 传递的数据。
随着 Hooks 的引入,函数组件的限制发生了变化。Hooks是 React 提供的函数,使得在函数组件中也能使用原本仅限于类组件的功能。
今天,是否使用函数组件和 hooks 或类组件和生命周期方法很大程度上取决于你的个人喜好。再次强调,函数组件需要编写的代码更少,但具有面向对象编程(OOP)语言经验的开发者可能更喜欢使用类组件。两种方式都是完全可行的,并且在性能方面没有差异。只是在使用类组件时,应用程序的大小会稍微大一些。
在接下来的小节中,我们将探讨不同的语法以及如何处理不同组件类型。我们将从类组件开始。
使用类组件和生命周期方法
如前所述,类组件始终能够以可变状态持有动态数据。这种状态可以通过用户交互或生命周期方法中触发的事件来改变。生命周期方法是 React 提供的方法,在组件执行的具体时间点被调用。
最重要的生命周期方法之一是 componentDidMount。这个方法在组件被挂载后直接调用,通常用于数据获取。以下代码示例展示了类组件的一个非常基础的例子:
class App extends React.Component {
constructor() {
super();
this.state = {
num: Math.random() * 100
};
}
render() {
return <Text>This is a random number:
{this.state.num}</Text>;
}
}
类组件有一个 state 属性,它在类的构造函数中初始化。这个 state 变量可以持有多个对象。在这种情况下,它只包含一个 num 属性,该属性使用介于 0 和 100 之间的随机数进行初始化。组件必须始终有一个 render 函数。这个函数包含组件的 JSX。在这个例子中,它只是一个显示随机数给用户的 Text 组件。
为了让这个例子更有活力,我们可以启动一个间隔,每秒重新生成一个随机数。这就是生命周期函数发挥作用的地方。我们会使用 componentDidMount 生命周期函数来启动间隔,并使用 componentWillUnmount 来清理它。请查看以下代码片段:
componentDidMount = () => {
this.interval = setInterval(() => {
this.setState({ num: Math.random() * 100 });
}, 1000);
};
componentWillUnmount = () => {
clearInterval(this.interval);
};
在 componentDidMount 中,我们创建一个间隔,每秒更新 num 状态。正如你所看到的,我们并没有直接设置状态,而是使用了 setState 方法。记住——直接设置状态只允许在构造函数的初始化中使用。
我们还将间隔的句柄存储在 this.interval 中。在 componentWillUnmount 中,我们清除 this.interval,这样当我们从组件导航离开时,就不会有代码无限运行。
注意
componentDidMount 是获取组件中使用的数据的正确位置。
如果你想看到这个例子的运行版本,请查看以下 CodeSandbox 实例:codesandbox.io/s/class-component-basic-nz9cy?file=/src/index.js。
在这个简单的例子之后,是时候更仔细地看看生命周期方法了。你现在将了解这里列出的最常用的方法:
-
componentDidMount(): 这个方法在组件被挂载后直接调用。在整个组件的生命周期中,它只会被调用一次。它可以用于数据获取、添加处理程序或以任何其他方式填充状态。 -
componentWillUnmount(): 这个方法在组件即将卸载之前被调用。在整个组件的生命周期中,它只会被调用一次。它应该用于清理处理程序、间隔、超时或任何其他正在执行的代码。 -
componentDidUpdate(prevProps): 每当组件更新并重新渲染时,都会调用这个方法。在整个组件的生命周期中,它可能被多次调用(很多次)。componentDidUpdate方法接收作为参数传递的先前 props,以便你可以将它们与当前 props 进行比较,以检查发生了什么变化。它可以用于根据组件参数的变化重新获取数据。请注意,在componentDidUpdate方法中的任何setState方法都必须被条件包裹。这是为了防止无限循环。 -
shouldComponentUpdate(nextProps, nextState): 这个方法在组件即将进行重新渲染之前被调用。在整个组件的生命周期中,它可能被多次调用(很多次)。它仅为了性能考虑而存在,因为在某些场景下,你可能只想在特定的 props 或 state 部分发生变化时重新渲染组件。这在处理大型应用程序或大量数据列表时特别有用。
还有一些生命周期方法使用得不太频繁。如果你想了解更多,请查看官方文档:reactjs.org/docs/react-component.html。
在本节中,你学习了类组件的语法以及如何使用生命周期方法。为了进行直接比较,我们将在下一个子节中为带有 Hooks 的函数组件编写相同的示例。
使用函数组件和 Hooks
由于我们在本章第一部分的示例应用中使用了函数组件语法,你应该已经熟悉它了。尽管如此,我们仍将查看一个代码示例,就像我们在之前关于类组件的子节中做的那样,如下所示:
const App = () => {
const [num, setNum] = useState(Math.random() * 100);
return <Text>This is a random number: {num}</Text>;
};
如你所见,即使在这么小的示例中,代码也要短得多。函数组件基本上就是一个在每次重新渲染时运行的函数。但是,有了 Hooks,特别是 useState 钩子,函数组件提供了一种在重新渲染之间存储数据的方法。
我们使用 useState 钩子将 num 变量存储在组件状态中。函数组件必须返回应该渲染的内容。你可以将组件视为一个直接的 render 函数。然后我们可以使用 num 变量来打印随机数。
重要提示
在函数组件中,不使用 Hooks 或类似机制放入的所有代码都会在每次重新渲染时运行。这基本上和在类组件的 render 函数中放入代码一样。这意味着你应该只在那里放置你的声明性 UI 和便宜的数据处理操作。所有其他操作都应该用 Hooks 包裹,以防止性能问题。
接下来,我们将启动一个间隔,每秒更改一次随机数。我们在类组件的示例中也做了同样的事情。以下代码在函数组件中实现了这一点:
useEffect(() => {
const interval = setInterval(() => {
setNum(Math.random() * 100);
}, 1000);
return () => clearInterval(interval);
}, []);
我们使用useEffect Hook 来启动间隔。useEffect间隔接受两个参数。第一个是一个定义应该运行的效果的函数。第二个参数是一个数组,它定义了效果应该运行的时间。它是可选的,如果你不提供它,你的效果将在每次重新渲染时运行。
你可以在其中放置状态变量、其他函数等等。如果你这样做,效果将在数组中的任何一个变量更改时运行。在我们的情况下,我们希望效果在组件挂载时只运行一次。为了实现这一点,我们将使用空数组作为第二个参数。
我们还返回一个清除效果的匿名函数。这是一个清理函数。这个清理函数在组件卸载时运行,并在下次运行效果之前。由于我们只在挂载时运行效果,因此清理函数只在卸载时运行。
如果你想运行这个示例,请查看以下 CodeSandbox 实例:codesandbox.io/s/function-component-basic-yhsrlo。
在这个简单的示例之后,是时候深入探讨最重要的 Hooks 了。我们已经使用了其中两个,它们无疑是其中最重要的。
使用无状态函数组件和 useState
useState Hook 使得在重新渲染之间存储信息并创建有状态函数组件成为可能。它返回一个包含两个条目的数组。第一个是状态变量,而第二个是状态变量的设置函数。在大多数情况下,你将使用数组解构在一行中访问这两个条目,如下面的代码示例所示:
const [example, setExample] = useState(exampleDefaultValue)
useState函数还接受一个参数,你可以使用它来定义状态变量的默认值。这是它初始化时得到的值。
要更改状态值,你总是必须使用设置函数。永远不要直接设置值,因为这不会触发任何重新渲染或其他 React 内部操作。
要更改值并触发重新渲染,你可以简单地使用固定值调用设置函数。这就是它的样子:
setExample(newValue)
这是你大部分时间会做的事情,但你也可以传递一个更新函数。当你需要根据旧状态进行状态更新时,这非常有用,例如:
setExample(prevValue => prevValue + 1)
在这个示例中,我们将传递一个函数,该函数接受前一个值作为单个参数。现在我们可以使用这个值来返回新值,然后这个值将被用于设置函数。这在递增或递减值时特别有用。
现在我们能够在重新渲染之间存储数据,我们将在某些事件之后运行一些函数。
使用 useEffect 与效果一起使用
useEffect 钩子用于在特定事件之后运行代码。这些事件可以是组件的挂载或组件的更新。useEffect 钩子的第一个参数必须是一个函数,当效果被触发时将运行此函数。
第二个参数是一个数组,可以用来限制效果应该触发的事件。这是可选的,当你不提供它时,效果在挂载时运行,并在每次触发重新渲染的更新时运行。如果你提供一个空数组,效果仅在挂载时运行。如果你在数组中提供值,效果仅限于在提供的值之一发生变化时运行。
这里有一件非常重要的事情需要提及。如果你在 useEffect 钩子内部使用可以改变重新渲染之间变量的引用和函数,你必须将它们包含在依赖项中。这是因为否则,你可能在 useEffect 钩子中有一个指向陈旧数据的引用。请查看以下图表以了解这一点的说明:

图 3.4 – useEffect 中的引用
在图的左侧,你可以看到当你没有在依赖项中包含一个状态变量——你是在你的 useEffect 钩子内部访问这个状态变量——会发生什么。在这种情况下,状态变量发生变化并触发了重新渲染,但由于你的 useEffect 钩子没有与状态变量建立连接,它不知道发生了变化。
当效果下次运行时——例如,由另一个依赖项的变化触发——你会访问你状态变量的陈旧(旧)版本。这一点非常重要,因为它可能导致非常严重且难以发现的错误。
在图的右侧,你可以看到当你将状态变量包含在 useEffect 钩子的依赖项中时会发生什么。现在 useEffect 钩子知道状态变量何时发生变化,并更新引用。
这同样适用于你在组件中编写的函数。请始终记住,你编写在函数组件内部且未被钩子包裹的每个函数都会在每次重新渲染时被重新创建。
这意味着如果你想在 useEffect 钩子内部访问函数,你也必须将它们添加到依赖项中。否则,你可能会引用这些函数的陈旧版本。但这也导致另一个问题。由于函数在每次重新渲染时都会被重新创建,它会在每次重新渲染时触发你的效果,而这通常是我们不希望看到的。
这就是两个其他钩子发挥作用的地方。在重新渲染之间,你可以缓存值和函数,这不仅解决了我们的 useEffect 触发问题,而且显著提高了性能。
使用 useCallback 和 useMemo 提高性能
useCallback 和 useMemo 都是用于在重新渲染之间记忆事物的 Hooks。虽然 useCallback 提供了记忆函数的功能,而 useMemo 提供了记忆值的功能。这两个 Hooks 的 API 非常相似。你提供一个函数和一个依赖项数组。useCallback Hooks 在不执行函数的情况下记忆函数,而 useMemo Hooks 执行函数并记忆函数的返回值。
总是要记住,这些 Hooks 是用于性能优化的。特别是关于 useMemo,React 文档明确指出,没有语义保证记忆化在所有情况下都有效。这意味着你必须以即使没有记忆化也能正常工作的方式编写你的代码。
你现在已经了解了最常见的 Hooks。你将在 第五章 中了解更多,管理状态和连接后端。如果你想获得更深入的理解,我可以推荐 React 文档中的官方 Hooks 教程:reactjs.org/docs/hooks-reference.html。
注意
除了 React 提供的 Hooks,你还可以编写自己的 Hooks 来在函数组件之间共享逻辑。你可以在自定义 Hook 中调用所有 React Hooks。请遵循命名约定,并始终以 use 开头你的自定义 Hooks。
在对组件、Hooks 以及 React Native 的 React 部分进行了广泛的探讨之后,现在是我们深入探讨原生部分的时候了。正如你在 第一章 中学到的,什么是 React Native?,React Native 有一个 JavaScript 部分和一个原生部分。
如你在本章的第一节中学到的,React Native 随带了一个完整的 Android 项目和一个完整的 iOS 项目。现在是时候看看所有这些是如何联系在一起的。
将不同平台连接到 JavaScript
在本节的第一个小节中,我们将重点关注 Android 和 iOS,因为这些是最常见的平台。在本节的最后,我们还将探讨如何部署到 Web、Mac、Windows 以及其他平台。
首先,重要的是要理解 React Native 提供了 JavaScript 和原生之间的通信方式。大多数时候,你不需要在原生端做任何改变,因为框架本身或一些社区库已经覆盖了大部分原生功能,但无论如何,理解它是如何工作的仍然很重要。
让我们从 UI 开始。当你用 JavaScript 编写 UI 时,React Native 会将你的 JSX 组件,如 View 和 Text,映射到 iOS 上的 UIView 和 NSAttributedString 或 Android 上的 android.view 和 SpannableString 等原生组件。这些原生组件的样式是通过一个名为 Yoga 的布局引擎来实现的。
虽然 React Native 为 Android 和 iOS 提供了许多组件,但有些场景并不直接支持。一个很好的例子是可缩放矢量图形(SVG)。React Native 本身并不提供 SVG 支持,但它提供了连接 JavaScript 和原生组件的逻辑,这样每个人都可以创建自己的映射和组件。
接下来,大型 React Native 社区开始发挥作用。几乎每个功能都有开源库提供这些映射,至少对于 Android 和 iOS 是如此。SVG 支持也是如此。有一个维护良好的库叫做 react-native-svg,您可以在以下位置找到它:github.com/react-native-svg/react-native-svg。
这个库提供了一个 <SVG /> JavaScript 组件,底层映射到 Android 和 iOS 上的原生 SVG 实现。
在理解了 UI 映射的工作原理之后,是时候看看 JavaScript 和原生之间的其他通信了。第二个非常常见的用例是数据的传输,例如关于用户手势、传感器信息或其他可以在一方创建并需要传输到另一方的数据。
这是通过连接方法完成的。React Native 提供了一种从 JavaScript 调用原生方法、传递回调函数到原生,并从原生调用这些回调的方法。这就是数据如何双向传输的方式。
虽然 Android 和 iOS 的支持是开箱即用的,但 React Native 并不仅限于这些平台。微软创建了名为 react-native-windows 和 react-native-macos 的开源项目。这些项目支持许多功能,可以将您的应用程序带到 Windows 和 macOS 平台。
还有一个非常有用的项目叫做 react-native-web,它为 React Native 添加了网络支持。一个需要理解的重要事情是,即使您可以使用相同的代码库为所有平台编写代码,您可能仍然希望将其适应特定平台的最佳实践。
例如,如果您针对的是网络,您可能希望优化您的项目以适应搜索引擎,这对于 Android 和 iOS 应用程序来说并不是必要的。处理这些特定平台调整的方法有很多种。最常见的方法将在第十章**,结构化大规模、多平台项目中解释。
虽然您可以使用 Android、iOS、Windows、macOS 和网络,但您并不局限于它们。基本上,您可以使用 React Native 为任何平台创建应用程序,您只需自己编写原生部分即可。
很长一段时间以来,JavaScript 和原生之间的所有通信都是通过所谓的桥异步地通过 JSON 完成的。虽然这在大多数情况下都很好用,但在某些情况下可能会导致性能问题。
因此,Facebook 的 React Native 核心团队决定完全重写 React Native 架构。这花费了几年的时间,但在撰写本书时,新的架构已经在主要的 Facebook 应用中推出,并且它也进入了 React Native 开源仓库,可供公众使用。你将在下一节中了解更多关于新架构的内容。
介绍新的 React Native 架构
在最后一节中,你学习了 JavaScript 和原生之间的连接是如何工作的。虽然这个基本概念没有改变,但底层实现发生了完全的改变。请查看以下图表:
![图 3.5 – 新的 React Native 架构
![图片/B16694_03_05.jpg]
图 3.5 – 新的 React Native 架构
新的 React Native 架构的核心是称为 JavaScript 接口(JSI)的东西。它取代了通过桥进行通信的旧方式。虽然通过桥的通信是以序列化的 JSON 的异步方式进行,但 JSI 使得 JavaScript 能够持有 C++ 主机对象的引用并调用它们的方法。
这意味着通过 JSI 连接的 JavaScript 对象和 C++ 主机对象将真正地相互了解,这使得同步通信成为可能,并使得 JSON 序列化的需求变得过时。这为所有 React Native 应用带来了巨大的性能提升。
重构的一部分是一个名为 Fabric 的新渲染器,它减少了创建原生 UI 所需的步骤数量。此外,使用 JSI,一个决定将要渲染内容的阴影树直接在 C++ 中创建,同时 JavaScript 也有对其的引用。这意味着 JavaScript 和原生代码都可以与阴影树交互,这极大地提高了 UI 的响应速度。
从 JSI 中受益的重构的第二部分被称为 Turbo Modules。它取代了 Native Modules,这是连接原生模块和 JavaScript 模块的方式。虽然旧的 Native Modules 都必须在启动时初始化,因为 JavaScript 没有关于原生模块状态的信息,但 JSI 使得在需要时延迟模块初始化成为可能。
由于 JavaScript 现在可以持有直接的引用,因此也就没有必要与序列化的 JSON 进行交互。这导致 React Native 应用的启动时间显著提升。
此外,还有一个名为 CodeGen 的新开发者工具与新的架构一起推出。它使用类型化 JavaScript 生成相应的原生接口文件,以确保 JavaScript 和原生侧之间的兼容性。这在编写包含原生代码的库时非常有用。你将在*第十章**,在“创建自己的库”部分中的“结构化大规模、多平台项目”中了解更多关于此内容。
总的来说,新的架构将为每个 React Native 应用在所有级别上带来巨大的性能提升。将现有应用切换到新架构需要一些时间,而且直到所有常见的开源库都完成切换也需要一些时间。但这是迟早的事,而且这绝对值得付出努力。
概述
为了结束本章,让我们简要总结一下本章的内容。你学习了简单 React Native 应用的项目结构是什么样的,以及不同的文件分别用于什么。你还了解了类组件和函数组件,以及最重要的生命周期方法和 Hooks。基于这些,你可以在类组件和函数组件中使用组件状态并触发代码执行。
你还学习了 JavaScript 和原生在 React Native 应用中的连接方式,当前(旧)React Native 架构的问题,以及新的架构是什么。
现在你已经对 React Native 的整体工作原理有了很好的了解,让我们在下一章深入探讨组件、样式、存储和导航。
第二部分:使用 React Native 构建世界级应用
在这部分,我们将不仅关注创建应用,还要使用 React Native 创建一流的应用。你将学习在创建具有原生性能和世界级用户体验的应用时,必须注意哪些事项。
以下章节包含在本节中:
-
第四章,React Native 中的样式、存储和导航
-
第五章,管理状态和连接后端
-
第六章,与动画一起工作
-
第七章,在 React Native 中处理手势
-
第八章,JavaScript 引擎和 Hermes
-
第九章,提高 React Native 开发的基本工具
第四章:React Native 中的设计、存储和导航
现在您已经了解了 React Native 背后的基本概念,是时候深入探讨 React Native 最常见的一些领域了。
本章涵盖了不同的领域,所有这些在处理 React Native 时都很重要。当使用 React Native 创建大型应用时,您始终需要深入了解您应用的设计风格,以创建一个美观的产品。除了设计风格外,还有另一个决定用户是否从美学角度喜欢您的应用的因素——动画。然而,这将在第六章中介绍,与动画一起工作。
本章我们将关注的另一件事是如何在用户的设备上本地存储数据。每个平台的工作方式都不同。虽然 Android 和 iOS 相当相似,并且您可以访问具有巨大容量的设备存储,但在与网络工作时就完全不同了,那里的容量非常有限。
我们将要讨论的最后一件事是如何在您的 React Native 应用中在不同屏幕间导航。再次强调,这可能会因平台而异,但您将获得不同导航概念的全面概述。
在本章中,我们将涵盖以下主题:
-
理解如何设计 React Native 应用
-
在 React Native 中使用本地存储解决方案
-
理解 React Native 中的导航
技术要求
要运行本章中的代码,您必须设置以下内容:
-
一个有效的 React Native 开发环境(
reactnative.dev/docs/environment-setup– React Native CLI 快速入门) -
虽然本章的大部分内容也应该在 Windows 上工作,但我建议在 Mac 上工作
理解如何设计 React Native 应用
您可以从不同的解决方案中选择来处理 React Native 应用中的设计。但在我们查看最常见的一些之前,您必须理解其背后的概念。在本章中,我们将首先介绍所有这些解决方案试图实现的目标。
使设计可维护
在项目开始时,设计通常处理得非常糟糕,因为它不会干扰业务逻辑,因此不太可能引入错误。所以,大多数时候,当考虑应用架构时,大多数开发者会想到状态管理、数据流、组件结构等,但不会想到设计。当项目增长时,这总是要付出代价。保持一致的设计需要越来越多的时间,而更改 UI 变得真正痛苦。
因此,您应该在应用一开始时就考虑如何处理设计。无论您使用什么解决方案或库,您都应该始终遵循以下概念:
-
使用中央文件存储颜色、字体和大小:这应该是一个单独的文件,或者一个用于颜色,一个用于字体,一个用于大小,如边距、填充和边框半径。我更喜欢使用一个单独的文件。
-
永远不要在您的组件/CSS 文件中硬编码值:您永远不应该在组件中使用固定值。始终使用您在中央文件中定义的值。这保证了您的 UI 保持一致,并且如果您需要适应,您可以轻松地更改值。
-
永远不要重复代码:当您发现自己因为更容易、更快或更方便而复制组件部分样式时,请始终记住这并不是长期之计。重复的代码总是会导致 UI 不一致,并在您稍后想要更改某些内容时让您不得不触摸多个文件。因此,与其复制粘贴代码,不如将其提取到组件或样式文件中。您稍后将会了解更多关于这些选项的信息。
当我们带着这些概念回到我们的示例项目时,我们必须重构它,因为目前我们违反了所有这些概念。我们没有中央文件;我们在每个地方都硬编码了值,并且我们在多个文件中定义了backButton样式。
首先,让我们创建一个中央文件来存储我们的值。它可能看起来像这样:
import {Appearance} from 'react-native';
const isDarkMode = Appearance.getColorScheme() === 'dark';
const FontConstants = {
familyRegular: 'sans-serif',
sizeTitle: 18,
sizeRegular: 14,
weightBold: 'bold',
};
const ColorConstants = {
background: isDarkMode ? '#333333' : '#efefef',
backgroundMedium: isDarkMode ? '#666666' : '#dddddd',
font: isDarkMode ? '#eeeeee' : '#222222',
};
const SizeConstants = {
paddingSmall: 2,
paddingRegular: 8,
paddingLarge: 16,
borderRadius: 8,
};
export {FontConstants, ColorConstants, SizeConstants};
如您所见,我们所有的值都集中在一个地方。如果您再深入一点看,我们还为我们应用引入了暗黑模式,这只是一个使用我们的中央颜色存储的 3 分钟任务。我们只需要获取设备外观设置的信息,并相应地提供颜色。
注意
您可以在 iOS 模拟器上非常容易地测试您的暗黑模式应用。前往设置,滚动到最底部,选择开发者。开发者屏幕将打开;第一个开关激活暗黑外观。如果您使用我们的应用支持暗黑模式,您应该始终在两个模拟器上测试——一个在暗黑模式,一个在亮模式。
现在我们有了中央存储,让我们创建一个<BackButton />组件来消除重复的样式定义。它可能看起来像这样:
interface BackButtonProps{
text: string;
onPress: () => void;
}
const BackButton = (props: BackButtonProps) => {
return (
<Pressable onPress={props.onPress}
style={styles.backButton}>
<Text>{props.text}</Text>
</Pressable>
);
};
const styles = StyleSheet.create({
backButton: {
padding: SizeConstants.paddingLarge,
marginBottom: SizeConstants.paddingLarge,
backgroundColor: ColorConstants.backgroundMedium,
},
});
在我们新创建的组件中,我们不再使用固定值,而是引用我们的中央存储中的值。
最后,我们必须遍历我们的应用,用我们的新组件替换backButton可点击部分,并用对中央存储的引用替换固定值。这样,我们就遵守了这些概念。
这些概念是不同库或解决方案的核心。为了为您的项目选择正确的解决方案,最重要的决定之一是部署到哪个平台。以下小节将涵盖最常见解决方案,包括有关解决方案在哪个平台上运行最佳的信息。
选择正确的样式解决方案
在本小节中,我们将探讨内联样式、React Native 样式表、CSS 模块和 styled-components。所有四种解决方案都运行良好,各有优缺点。我们将从内联样式开始。
使用 React Native 内联样式
要理解内联样式,让我们看看一个代码示例。以下代码展示了上一章示例项目中的<Header />组件,但它使用了内联样式来设置Text组件的样式:
const Header = (props: HeaderProps) => {
return <Text style={{
fontSize: 18,
fontWeight: 'bold',
marginBottom: 16}
}>
{props.text}
</Text>;
};
如你所见,我们可以创建一个包含样式规则的对象。这可行,并且具有很大的优势。你不仅可以使用固定值,还可以使用你可以在组件中访问的任何静态或动态值。这非常有用,尤其是在你处理用户定义的主题时。但这种方法也有多个缺点。
首先,当项目规模扩大时,代码会变得相当混乱——至少,我认为当样式、组件和数据以这种方式混合时,代码难以阅读。因此,我会尽可能地将其分离。
接下来,你不能重用任何样式。每次你需要它们时,都必须复制你的样式。现在,你可以争辩说,你不需要复制样式,因为你可以简单地提取包含样式的组件到一个自定义组件中。尽管这是正确的,但有些情况下你不想这样做。我们将在下一小节中更深入地探讨这些场景。
接下来,我们必须考虑性能。内联样式对象将在每次渲染时被重新创建,这可能会对你的应用性能和内存使用产生负面影响。
最后,我们将探讨不同的平台。这种内联样式方法在构建不同平台时几乎没有优化空间。虽然这在 Android、iOS、Windows 和 macOS 上可能不是真正的问题,但对于 Web 来说,它可能会造成真正的痛苦,因为它会使你的包大小大大增加。
在 Web 上,你必须非常关注加载时间,因为用户没有安装你的应用程序的版本。此外,像 Google 这样的搜索引擎也非常关注加载时间,这会影响你的排名,产生正面或负面的影响。因此,你的样式代码必须在构建过程中进行优化,而内联样式无法做到这一点。
要利用优化优势,你必须使用样式表。我们将在下一节中探讨它们。
使用 React Native 样式表
在上一章的示例应用中,我们使用了样式表(StyleSheets),但在这里我们再次探讨它们,以便真正理解它们的优点。样式表不仅使代码更易读,支持样式和业务逻辑的良好分离,而且还能在应用的构建时间和运行时实现许多性能优化。
以下代码是我们示例应用中的<Header />组件。它使用 React Native 样式表进行样式设置:
const Header = (props: HeaderProps) => {
return <Text style={styles.title}>{props.text}</Text>;
};
const styles = StyleSheet.create({
title: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 16,
},
});
在查看此代码时,你应该意识到以下几点:
-
首先,它更加清晰且易于分离。
-
第二,
StyleSheet是在组件外部定义的,这使得它在重新渲染之间保持持久。这在性能和内存使用方面更好。 -
第三,当你使用无法解释的样式时,
StyleSheet.create会在你的模拟器中创建错误。这可以在非常早期的阶段帮助你捕捉到错误。
但 StyleSheet 最大的好处是能够优化你的样式代码以适应网页。开源的网页库 react-native-web 能够很好地将你的应用程序中的所有 StyleSheet 分割成类,并将所需的类名添加到你的组件中。这使得你的代码更小、更优化,并且大大提高了加载时间。
除了所有这些优势之外,StyleSheet 还有一个问题。由于它们是在组件外部声明的,因此你无法访问组件变量,如状态和属性。这意味着,如果你想在你的样式中使用用户生成的值,你必须将 StyleSheet 的值与内联样式结合,如下所示:
<Text style={[styles.title, {color:props.color}]}>{props.text}</Text>
这段代码会使用 StyleSheet 中的 title 风格,并为 <Text /> 组件添加一个用户定义的颜色。这种结合方法也可以在处理动画时使用。你可以在第六章 处理动画 中了解更多相关信息。
最后,我们将探讨 StyleSheet 的另一个好处。你可以在组件中多次使用一个样式。再次强调,如果你坚持我的建议,你永远不会需要这样做,因为在这种情况下你会创建一个自定义组件。但在日常工作中,有些情况下不创建组件更快,而且也不会造成伤害。
例如,如果你有一个包含两行文本的简单组件,你可以创建一个 <TextLine /> 组件并使用两次,或者简单地使用两个具有相同样式引用的 <Text /> 组件,并在 StyleSheet 中引用。
使用 <TextLine /> 组件的第一种方法更为简洁,但第二种方法可以节省你的时间,并且从长远来看不会产生问题。因此,在这种情况下,StyleSheet 相比内联样式有另一个优势。
注意
当你多次使用相同的样式时,一定要小心。虽然这可能有帮助,但在许多情况下,你会重复代码,这些代码应该被提取到自定义组件中。
现在我们已经了解了这个内置解决方案,让我们看看需要外部库的两个解决方案。
使用 CSS 模块进行样式设置
CSS 模块在网页上非常流行。你使用 CSS、Sass 或 Less 来设置组件样式。在大多数情况下,你将为每个组件创建一个额外的样式文件。专家们经常争论这是好是坏。
你有一个额外的文件,但你将样式和组件之间的分离做得非常清晰。我确实喜欢这种分离,但如果你能将应用程序拆分成小的组件,直接将样式添加到组件中也是可以的,从我的角度来看。
在 React Native 中使用 CSS 模块需要一些额外的配置。由于 React Native 没有内置的 CSS 处理器,你必须将你的 CSS 代码转换为 JavaScript 样式,然后才能显示。这可以通过 babel 转换器来完成。
如果你需要在 React(网页)和 React Native 项目之间共享样式,而不使用 react-native-web 生成网页部分,CSS 模块可以是一个很好的选择。这尤其适用于你正在为现有的网页应用程序构建应用程序时。
这种方法的一个非常重要的问题是,你无法在 CSS 模块中使用你的 JavaScript 变量。尽管你可以创建和使用 CSS 变量,但这并不允许你在样式中使用用户生成的值。
如果你开始一个针对 Android、iOS、Windows 或 Mac 的新项目,我不建议使用 CSS 模块,因为这些平台,CSS 模块方法与 StyleSheets 没有优势。再次强调,我唯一推荐使用 CSS 模块的情况是当你为基于 CSS 模块的老旧网页应用程序构建应用程序时。
对于 React 网页项目,还有一个非常流行的解决方案,也可以用于 React Native。它被称为 styled-components,你将在下一个子节中了解它。
理解 styled-components
View 和 Text,你可以通过标签模板字面量来增强它们,创建新的组件,称为 styled-components。
以下代码展示了我们示例项目中用 styled-components 样式化的 <Header /> 组件:
import styled from 'styled-components/native';
const Header = (props: HeaderProps) => {
return <StyledText>{props.text}</StyledText>;
};
const StyledText = styled.Text`
font-size: ${FontConstants.sizeTitle};
font-weight: ${FontConstants.weightBold};
margin-bottom: ${SizeConstants.paddingLarge};
color: ${ColorConstants.font};
`;
正如你所见,我们通过使用 styled-components 中的 styled 创建 StyledText 组件,并将模板字面量添加到 React Native 的 Text 组件中。在这个字面量内部,我们可以编写纯 CSS。这里酷的地方在于我们还可以使用 JavaScript 变量,甚至可以将属性传递给我们的 styled-component。这看起来会是这样:
<StyledText primary>{props.text}</StyledText>;
这是我们向 StyledText 组件传递属性的方式。现在,我们可以在模板字面量中使用这个属性:
const StyledText = styled.Text`
font-size: ${props => props.primary ?
FontConstants.sizeTitle :
FontConstants.sizeRegular};
`;
这个函数被称为 插值,它使得在 styled-components 的 CSS 中使用用户生成的内容成为可能。
这太棒了,因为它解决了许多问题,支持结构和样式之间的清晰分离,并允许我们使用常规 CSS,这对于大多数开发者来说比 StyleSheets 中的驼峰式 CSS 更熟悉。
虽然我喜欢这种网络方法,但我对仅适用于应用的项目的这种方法持批评态度。styled-components 库为网络提供了许多有用的优化功能,但在纯 React Native 项目中,它也会将 CSS 编译成 JavaScript 风格。此外,它不提供对动画的支持,这是现代应用非常重要的一个部分。你可以在第六章 与动画一起工作中了解更多关于这一点。
虽然我不会推荐在纯 React Native 项目中使用 styled-components,但当你尝试在 React Native 和 React 项目之间共享样式代码而不使用 react-native-web 时,它们可以非常有用。在这种情况下,你可以从 styled-components 中获得很多好处。
如果你想要深入了解 styled-components,我建议阅读官方文档styled-components.com/docs。
在本节中,我们学习了为 React Native 应用进行样式化的最重要的概念,并查看了一些最常用的实现样式的解决方案。大多数时候,你不会自己编写所有的样式,而是使用 UI 库。这将在第九章 提高 React Native 开发的必备工具中处理。
如果你想要查看示例项目的所有更改,请查看此示例项目的存储库,并选择chapter-4-styling标签。
现在我们已经知道了如何为我们的应用进行样式化,是时候在用户的设备上存储一些数据了。
在 React Native 中使用本地存储解决方案
在移动应用中,存储数据是一个非常重要的任务。即使现在,你也无法保证移动设备始终连接到互联网。正因为如此,最佳实践是创建你的应用,使其尽可能多地具有功能,即使在没有互联网连接的情况下也是如此。话虽如此,你可以看到为什么在 React Native 应用中本地存储数据很重要。
对于本地存储解决方案来说,最重要的区分标准是它是否是一个安全的或非安全的存储解决方案。由于大多数应用至少存储了一些关于用户的信息,你应该始终考虑你想要将哪些信息放入哪个存储中。
重要
总是使用安全的存储解决方案来存储敏感信息。
虽然在安全存储中存储敏感数据很重要,但大多数数据,如用户进度、应用内容等,都可以存储在普通存储解决方案中。由于加密/解密和/或访问特殊设备功能,安全存储操作总是伴随着一些开销,因此你应该只为敏感信息使用它们,以防止对应用性能产生负面影响。
在下面的子节中,你将了解最常见的用于常规数据的存储解决方案。
存储非敏感数据
很长一段时间里,React Native 都自带了一个名为 AsyncStorage 的内置存储解决方案。但自从 Facebook 的 React Native 核心团队试图将 React Native 核心降至最低(轻量级核心)以来,AsyncStorage 被转交给社区进行进一步开发。
尽管如此,它得到了非常好的维护,并且很可能是最常用的存储解决方案。除了AsyncStorage之外,其他常见解决方案还包括react-native-mmkv/react-native-mmkv-storage、react-native-sqlite-storage/react-native-quick-sqlite和react-native-fs。所有这些解决方案都有其优缺点,工作方式完全不同,并且可以用于不同的任务。让我们从最受欢迎的一个开始。
与 AsyncStorage 一起工作
AsyncStorage是一个简单的键/值存储,可以用来存储数据。虽然它只能存储原始数据,但在存储之前必须将复杂对象序列化为 JSON。尽管如此,它非常易于使用。API 看起来是这样的:
import AsyncStorage from '@react-native-async-storage/async-storage';
// set item
const jsonValue = JSON.stringify(value)
await AsyncStorage.setItem('@key', jsonValue)
// get item
const strValue = await AsyncStorage.getItem('@key')
const jsonValue = strValue != null ? JSON.parse(strValue) : null
如您所见,设置和获取数据的 API 非常简单。
AsyncStorage未加密,不能用于运行复杂查询。它是一个简单的键/值存储;没有数据库。它也不支持事务或锁定。这意味着在您向应用程序的不同部分写入/读取时必须非常小心。
我建议用它来存储用户进度、关于应用内容的信息以及任何不需要可搜索的数据。有关安装和使用AsyncStorage的更多信息,请参阅官方文档react-native-async-storage.github.io/async-storage/docs/install/。
相较于AsyncStorage,MMKV 是 React Native 的一个较新的替代方案。它的速度可以快到 30 倍,并且拥有更多功能。
在 React Native 中使用 MMKV
MMKV是由微信开发并用于其生产应用的本地存储解决方案。有多个 React Native 包装器用于这个本地解决方案;其中大多数已经基于 JSI,因此支持同步和超快访问。
与AsyncStorage一样,MMKV 是一个简单的键/值存储。这意味着在存储之前,复杂对象必须序列化为 JSON 字符串。API 几乎与AsyncStorage一样简单:
import { MMKV } from 'react-native-mmkv'
export const storage = new MMKV()
// set data
const jsonValue = JSON.stringify(value)
storage.set('@key', jsonValue)
// get data
const strValue = storage.getString('@key')
const jsonValue = strValue!= null ? JSON.parse(strValue) : null
如您所见,得益于 JSI,API 是同步的,因此我们不需要处理 async/await 语法。在第二行,您可以看到存储的初始化。这是相较于AsyncStorage的一个优势,因为您可以使用多个 MMKV 存储实例。
虽然 MMKV 可以加密数据,但在撰写本文时,关于如何处理密钥尚无安全解决方案。因此,我仅建议用于存储非敏感数据。这在未来可能会有所改变。
MMKV 可以用作 AsyncStorage 的更快替代品。与 AsyncStorage 相比,MMKV 的唯一缺点是,在撰写本文时,React Native 包装器使用得并不多。有两个维护良好的 React Native MMKV 映射器,所以当你考虑在你的项目中使用 MMKV 时,你应该看看它们。你可以在那里找到有关安装、使用和 API 的更多信息。第一个是 react-native-mmkv。这是一个更精简的项目,并附带一个更简单的 API。它也更容易安装。你可以在这里查看它:github.com/mrousavy/react-native-mmkv。第二个是 react-native-mmkv-storage。它提供了更多功能,例如索引和数据生命周期方法,这在锁定和事务处理时可能非常有用。你可以在这里查看它:github.com/ammarahm-ed/react-native-mmkv-storage。
现在我们已经了解了处理非常类似用例的 AsyncStorage 和 MMKV,让我们看看一个带有更多功能的解决方案:SQLite。
与 SQLite 一起工作
与 AsyncStorage 和 MMKV 相比,SQLite 不仅仅是一个简单的键值存储 – 它是一个完整的数据库引擎,包括锁定、事务和高级查询等功能。
然而,这意味着你不能简单地以序列化数据的形式存储你的对象。SQLite 使用 SQL 查询和表来存储你的数据,这意味着你必须处理你的对象。要插入数据,你必须创建一个包含每个属性的列的表,然后使用 SQL 语句插入每个对象。让我们看看下面的代码:
import { QuickSQLite } from 'react-native-quick-sqlite';
const dbOpenResult = QuickSQLite.open('myDB', 'databases');
// set data
let { status, rowsAffected } = QuickSQLite.executeSql(
'myDB',
'UPDATE users SET name = ? where userId = ?',
['John', 1]
);
if (!status) {
console.log(`Update affected ${rowsAffected} rows`);
}
// get data
let { status, rows } = QuickSQLite.executeSql(
'myDB',
'SELECT name FROM users'
);
if (!status) {
rows.forEach((row) => {
console.log(row);
});
}
如你所见,插入和查询数据需要更多的代码。你需要创建和执行 SQL,并处理你得到的数据,以便你可以以可以工作的格式使用它。这意味着 SQLite 并不像 AsyncStorage 和 MMKV 那样容易和快速使用,但它提供了高级查询功能。这意味着你可以过滤和搜索你的数据,甚至连接不同的表。
如果你拥有非常复杂的数据结构,需要大量地连接和查询不同的对象或表,我会推荐使用 SQLite。我更喜欢用于本地数据存储的简单解决方案,但也有一些情况,SQLite 是更好的选择。
除了使用它的更高复杂性之外,SQLite 还会增加一些 MB 到你的应用大小,因为它将它的 SQLite 数据库引擎实现添加到你的应用中。
最常用的 React Native SQLite 包装器是 react-native-sqlite-storage。API 简单,并被许多项目使用。你可以在 github.com/andpor/react-native-sqlite-storage 上了解更多信息。
另一个解决方案是 react-native-quick-sqlite。这是一个相对较新的库,但它基于 JSI,因此比其他解决方案快五倍。您可以在github.com/ospfranco/react-native-quick-sqlite了解更多信息。
现在您已经了解了 SQLite 数据库引擎,让我们看看另一个用例。有时,您需要存储大量数据,这意味着您需要直接访问文件系统。这就是我们接下来要探讨的。
在 React Native 中使用文件系统
要存储大量数据,创建并存储文件始终是一个好主意。在 iOS 和 Android 上,每个应用程序都在一个沙盒中运行,其他应用程序无法访问。虽然这并不意味着您的所有文件都是安全的——用户可以非常容易地检索它们——但它至少为您提供了关于您数据的一些隐私保护。然而,这种沙盒模式意味着您无法访问其他应用程序的数据。
在 React Native 中,要读取和写入应用程序沙盒中的数据,您可以使用如 react-native-fs 这样的库。这个库提供了您可访问的路径常量,并允许您从文件系统中读取和写入文件。
我建议在您从服务器同步文件或写入大量数据时使用这种方法。大多数情况下,您可以结合之前提到的一种方法来本地存储文件,然后将文件的路径存储在其他存储解决方案之一中。
如果您想了解更多关于 React Native 中文件系统访问的信息,请查看 react-native-fs 的文档,网址为github.com/itinance/react-native-fs。
通过这样,我们已经涵盖了存储和访问非敏感数据的最常见解决方案。这是您应该存储大部分数据的地方。然而,某些数据包含敏感信息,如密码或其他用户信息。这些数据需要另一级别的保护。因此,让我们看看 React Native 中敏感信息的存储解决方案。
存储敏感数据
当您在用户的设备上存储敏感信息时,您应该始终考虑如何保护它。大多数情况下,这将是无关紧要的,但当一个用户丢失设备时,您应该确保他们的敏感信息尽可能安全。
当您无法控制设备时,您永远无法确保 100%的数据安全。然而,我们需要尽我们所能,使敏感信息尽可能难以被检索。
你首先应该考虑的是是否需要持久化信息。不存在的信息无法被盗取。如果你需要持久化信息,请使用安全存储。Android 和 iOS 提供了内置的安全存储数据解决方案。React Native 为这些原生内置解决方案提供了包装器。以下是一些维护良好且易于使用的解决方案:
-
expo-secure-store:使用 iOS Keychain 和 AndroidSharedPreferences结合 Keystore System。它提供了一个简单的 API,可以存储高达 2,048 字节的值。更多信息可以在docs.expo.dev/versions/latest/sdk/securestore/找到。 -
react-native-sensitive-info:这个库维护得很好,提供了很多功能。它还增加了一层安全保护,即使在 rooted 设备上也能保护你的数据。它支持 Android、iOS 和 Windows。更多信息可以在mcodex.dev/react-native-sensitive-info/找到。 -
react-native-keychain:这是一个维护得很好的库,具有简单的 API。它支持 Android 和 iOS,并在所有设备上加密数据。更多信息可以在github.com/oblador/react-native-keychain找到。
再次强调,尽管这些解决方案非常好且安全,基于原生实现,但数据永远无法达到 100%的安全。因此,请只保留必要的。
现在你已经了解了数据存储解决方案以及敏感数据和非敏感数据之间的区别,是时候看看 React Native 应用中的导航了。
理解 React Native 中的导航
React Native 没有内置的导航解决方案。这就是为什么我们在示例应用中与全局状态一起工作,并在导航时简单地切换组件。虽然这在技术上可行,但它并不提供良好的用户体验。
现代导航解决方案包括性能优化、动画、集成到全局状态管理解决方案中等等。在我们深入探讨这些解决方案之前,让我们看看不同平台上的导航是什么样的。
在不同平台上的导航
如果你打开任何 iOS 或 Android 应用,你很快就会意识到应用中的导航与在浏览器中导航网页完全不同。浏览器通过用新页面替换旧页面来从一个页面导航到另一个页面。除此之外,每个页面都有一个 URL,如果它在浏览器的地址栏中输入,可以直接访问。
在 iOS 或 Android 应用中,导航以不同导航器的组合形式出现。你导航离开的页面不一定会被新的页面替换。可以同时激活多个页面。
让我们来看看最常见的导航场景和用于处理这些场景的导航器:
-
堆栈导航器:当在堆栈导航器中导航到新页面时,新页面会推送到旧页面上方。然而,旧页面并不会被卸载。它将继续存在,如果你通过返回按钮离开新页面,你将自动导航回旧页面。新页面将从所谓的层堆栈中弹出,你将发现你的旧页面处于你离开时的相同状态。这也包括滚动位置。
-
标签导航器:一个非常受欢迎的导航器是标签导航器。这个导航器提供了最多五个可以通过标签栏选择的标签。这个标签栏包含文本和/或图标,可以位于屏幕顶部或底部。每个标签都有一个层堆栈。这意味着你可以单独导航每个标签。当你选择另一个标签时,标签的状态不会重置。在大多数情况下,你只是在你的标签导航器中有多重堆栈导航器。
-
切换导航器:这个导航器提供了与网络导航相同的行为。当使用这个导航器时,你会用新页面或层堆栈替换旧的一个。这意味着旧页面或层堆栈会被卸载并从内存中移除。如果你返回导航,旧页面或层堆栈将有一个完整的干净重启,就像你之前从未去过那里一样。
大多数应用都会结合这些导航器来为用户提供出色的导航体验。因为这种在移动应用中的常见导航体验与网络不同,所以在为移动和网页项目规划时,你应该始终牢记这一点。你将在第十章 结构化大规模、多平台项目中了解更多关于这一点。
尽管多个社区项目为 React Native 应用中的导航提供了很好的支持,例如由 Wix 支持的 react-native-navigation(更多信息可以在wix.github.io/react-native-navigation/docs/before-you-start/)和 react-router/native(更多信息可以在v5.reactrouter.com/native/guides/quick-start)中,但本节我们将重点关注 react-navigation。它是迄今为止最常用的、最活跃维护的、最先进的 React Native 导航解决方案。
使用 React 导航
要了解 React 导航是如何工作的,最好的方法是将它简单地集成到我们的示例项目中。在这里,我们将做两件事。首先,我们将用 React 导航堆栈导航器替换我们的全局状态导航解决方案。然后,我们将添加一个标签导航器来创建第二个标签,我们将在下一章中使用它。
但在您开始使用 React Navigation 之前,您必须安装它。这个过程很简单——您只需通过 npm 安装包及其依赖项。这可以通过 npm install @react-navigation/native react-native-screens react-native-safe-area-context 命令完成。由于 react-native-screens 和 react-native-safe-area-context 有原生部分,您将需要使用 npx pod-install 命令安装 iOS Podfiles。之后,您将需要创建新的构建才能使用 React Navigation。对于 iOS,可以使用 npx react-native run-ios 来完成。
在撰写本文时,为了在 Android 上使 React Navigation 工作正常,需要一些额外的步骤。由于这可能会在未来发生变化,请查看官方文档中的安装部分,网址为 reactnavigation.org/docs/getting-started/#installation。
现在我们已经安装了 React Navigation,是时候在我们的示例项目中使用它了。首先,我们将用 Stack Navigator 替换 App.tsx 中的全局状态导航。要使用 Stack Navigator,我们需要使用 npm install @react-navigation/native-stack 命令来安装它。然后,我们就可以在我们的应用中开始使用了:
const MainStack = createNativeStackNavigator<MainStackParamList>();
const App = () => {
return (
<NavigationContainer>
<MainStack.Navigator>
<MainStack.Screen
name="Home"
component={Home}
options={{title: 'Movie Genres'}}
/>
<MainStack.Screen
name="Genre"
component={Genre}
options={{title: 'Movies'}}
/>
<MainStack.Screen
name="Movie"
component={Movie}
options={({route}) =>
({title: route.params.movie.title})}
/>
</MainStack.Navigator>
</NavigationContainer>
);
};
如您所见,我们的 App.tsx 变得简单多了。我们可以移除所有的 useState 钩子和所有的设置函数,因为 React Navigation 会处理所有这些。我们只需要创建一个 Stack Navigator,使用 React Navigation 的 createNativeStackNavigator 命令,然后在返回语句中返回我们的 Layer Stack。请注意 <NavigationContainer />,它包裹着整个应用。这是管理导航状态所必需的,通常应该包裹根组件。
在这里,每个屏幕都有一个名称、一个组件和一些选项。名称也是屏幕可以通过它导航到的键。component 是当屏幕被导航到时应挂载的组件。options 允许我们配置诸如标题和返回按钮等事项。
现在我们已经定义了 Layer Stack,是时候查看视图并看看那里有什么变化了。让我们看看 <GenreView />。这是我们可以看到所有变化最好的地方:
type GenreProps = NativeStackScreenProps<MainStackParamList, 'Genre'>;
const Genre = (props: GenreProps) => {
const [movies, setMovies] = useState<IMovie[]>([]);
useEffect(() => {
if (typeof props.route.params.genre !== 'undefined') {
setMovies(getMovieByGenreId(props.route.params.genre.
id));
}
}, [props.route.params.genre]);
return (
<ScrollContainer>
{movies.map(movie => {
return (
<Pressable
onPress={() =>
props.navigation.navigate('Movie',
{movie: movie})}>
<Text
style={styles.movieTitle}>{movie.title}</Text>
</Pressable>
);
})}
</ScrollContainer>
);
};
您首先可以看到,有另一种方式可以访问通过 React Navigation 传递的属性。每个作为 React Navigation 屏幕的组件都会传递两个额外的属性——navigation 和 route。
route 包含关于当前路由的信息。route 中最重要的属性是 params。当我们导航到一个屏幕时,我们可以传递 params,然后可以通过 route.params 来检索它们。在这个例子中,这就是我们将类型传递给视图(props.route.params.genre)的方式,然后我们使用它来获取电影列表。
当你查看返回语句中<Pressable />组件的onPress函数时,你可以看到如何在 React Navigation 中导航到另一个页面。navigation属性提供了在不同屏幕之间导航的不同函数。在我们的例子中,我们使用带有Movie键的navigate函数来导航到<Movie />视图。我们还传递了当前电影作为参数。
当你将代码与上一节中的示例进行比较时,你会意识到<Header />和<BackButton />组件缺失。这是因为 React Navigation 自带内置的头部和后退按钮支持。虽然你可以禁用它,但它的默认行为是每个屏幕都有一个头部,包括返回到上一个屏幕的后退按钮。
如果你想查看所有这些更改,请查看此示例项目的仓库,并选择chapter-4-navigation标签。
如果你在这个标签上运行示例项目,你还会看到 React Native 为导航操作添加了动画。这些动画可以以任何可能的方式自定义。甚至有一个社区库来支持不同页面之间的共享动画元素。你可以在这里查看:github.com/IjzerenHein/react-navigation-shared-element。
现在你已经学会了如何使用 Stack Navigator,我们将添加另一个导航器。我们想要创建一个第二个标签,因为我们想要创建一个用户可以保存他喜欢的电影的地方。这将通过 Tab Navigator 来完成。
与 Stack Navigator 一样,在使用之前我们必须安装 Tab Navigator。这可以通过npm install @react-navigation/bottom-tabs来完成。在我们安装了 Tab Navigator 之后,我们可以将其添加到我们的App.tsx中。请查看以下代码片段:
const MainStackScreen = () => {
return (
<MainStack.Navigator>
<MainStack.Screen component={Home}/>
<MainStack.Screen component={Genre}/>
<MainStack.Screen component={Movie}/>
</MainStack.Navigator>
);
};
const App = () => {
return (
<NavigationContainer>
<TabNavigator.Navigator>
<TabNavigator.Screen
name="Main"
component={MainStackScreen}
options={{
headerShown: false,
}}
/>
<TabNavigator.Screen
name="User"
component={User}
/>
</TabNavigator.Navigator>
</NavigationContainer>
);
这是一个非常有限的示例。要查看工作代码,请查看示例仓库并选择chapter-4-navigation-tabs标签。正如你所看到的,我们将主 Stack 移动到其自己的函数组件中。我们的App组件现在包含<TabNavigator />和两个屏幕。
第一个屏幕使用<MainStackScreen />作为其组件。这意味着当我们处于第一个标签时,我们使用我们的 Stack Navigator。第二个屏幕使用一个新创建的<User />组件。你可以通过标签栏在这些标签之间切换,标签栏是由 React Navigation 自动创建的。
注意
当你与标签一起工作时,你应该始终安装一个图标库,例如react-native-vector-icons(github.com/oblador/react-native-vector-icons)。这样的库使得在标签栏中找到和使用表达性图标变得容易。
这个例子包含两个不同的导航器,展示了 React Navigation 的灵活性。我们可以在 <Navigator.Screen /> 组件中使用我们的视图,或者使用其他导航器。这种导航嵌套给我们带来了几乎无限的可能性。请注意,在这种情况下,我们必须隐藏第一个标签页的标题,因为它已经被我们的 Stack Navigator 创建了。我们可以通过 headerShown: false 选项来实现这一点。
如您所见,使用 React Navigation 进行导航既简单又强大。它还提供了出色的 TypeScript 支持,正如您在仓库中可以看到的那样。您可以为每一层栈创建类型,并精确地定义可以传递给不同屏幕的内容。这包括不仅限于类型检查,还包括在大多数现代 IDE 中的自动完成功能。您可以在reactnavigation.org/docs/typescript/了解更多关于 React Navigation 对 TypeScript 的支持。
React Navigation 支持许多其他功能,包括深度链接、测试、持久化导航状态以及集成不同的状态管理解决方案。如果您想了解更多信息,请访问官方文档:reactnavigation.org/docs/getting-started/。
摘要
现在我们已经将一个现代导航库添加到我们的示例项目中,是时候总结本章内容了。首先,您学习了在您希望美化应用程序时需要考虑的因素。您还了解了美化 React Native 应用程序最常见的方法,并学习了哪些方法适合与网络项目共享代码。
然后,您学习了如何在 React Native 应用程序中本地存储数据。最后,您学习了在网页和移动端之间导航的不同之处,以及如何使用现代导航库在 React Native 应用程序中实现最先进的导航解决方案。
在下一章中,我们将探讨创建和维护全局应用状态的方法,以及如何从外部资源获取数据。在学习这些内容的同时,我们将用一些酷炫的功能填充本章中创建的占位符屏幕。
第五章:管理状态和连接后端
在上一章中,你学习了如何构建一个运行良好且外观出色的应用程序。在本章中,我们将关注数据。首先,你将学习如何在你的应用程序中处理更复杂的数据。然后,你将了解有关如何通过连接远程后端使你的应用程序与世界其他部分通信的不同选项。
在本章中,我们将涵盖以下主题:
-
管理全局应用程序状态
-
使用全局状态管理解决方案
-
连接到远程后端
技术要求
要运行本章中的代码,你必须设置以下内容:
-
一个有效的 React Native 环境 (bit.ly/prn-setup-rn – React Native CLI 快速入门)。
-
虽然本章的大部分内容也应该在 Windows 上运行,但我建议你在 Mac 上工作。
-
要查看简单示例,你可以使用
codesandbox.io/并将react-native-web作为依赖项导入。这提供了所有 React Native 组件并将它们转换为 HTML 标签。
管理全局应用程序状态
由于 React Native 基于 React,管理应用程序状态与 React 应用程序没有太大区别。有数十个维护良好且可用的状态管理库,你都可以在 React Native 中使用。然而,在应用程序中,有一个良好的计划和知道如何管理应用程序状态比在 Web 应用程序中更为重要。
虽然等待几秒钟数据出现或新页面加载可能是可以接受的,但在移动应用程序中并非如此。用户习惯于立即看到信息或变化。因此,你必须确保在你的应用程序中也是如此。
在本节中,我们将探讨最流行的状态管理解决方案,但首先,你将了解不同的状态管理模式以及你应该为你的项目使用哪一个。
传递属性
虽然在小应用程序和示例项目中仅使用本地组件状态可能运行良好,但这种方法非常有限。有许多用例需要在不同组件之间共享数据。你的应用程序越大,你将拥有的组件就越多,你需要传递数据的层级就越多。
以下图表显示了主要问题:

图 5.1 – 无全局状态管理解决方案的状态管理
上述图表显示了一个非常简单的示例,与我们的示例应用程序非常接近,但你已经可以看到主要问题:应用程序包含两个标签页,一个用于显示内容,另一个提供个人用户区域。第二个标签页包含一个登录功能,该功能被提取到一个登录组件中。
内容标签页包含一个仪表板组件,主要用于显示内容。但我们也希望能够适应用户的内容。因此,我们需要在仪表板组件中获取有关用户的信息。
没有全局应用程序状态管理库,如果用户登录,我们将不得不做以下操作:
-
从登录组件传递信息到用户标签页。
-
从
App.js传递信息。 -
在
App.js的状态中设置用户信息。 -
将用户信息作为属性传递给内容标签页。
-
从内容标签页将用户信息传递到仪表板组件。
即使在这个简单的例子中,我们也必须包含五个组件来向仪表板组件提供用户信息。当我们谈论复杂的现实世界应用时,可能会有 10 个或更多的层级,你需要通过这些层级传递你的数据。这将是一个难以维护和理解的噩梦。
这种方法还存在另一个问题:当我们把用户信息作为属性传递给App.js时,App.js会发生变化。这意味着我们会重新渲染内容标签页以及可能的大量未因属性更改而改变的子组件。
这尤其重要,因为大型应用程序的全局状态可能会变得相当复杂和庞大。如果你将其与后端应用程序进行比较,你可以将全局应用程序状态视为系统的数据库。
因此,全局状态管理库应该解决两个问题。一方面,它们应该给我们一个在组件之间共享信息并保持我们的应用程序状态管理可维护的选项。另一方面,它们还应该帮助减少不必要的重新渲染,从而优化我们的应用程序性能。
使用全局状态提供者/容器
以下图表显示了使用全局状态管理解决方案的数据流预期工作方式:

图 5.2 – 使用全局状态管理解决方案的状态管理
如您所见,全局应用程序状态管理解决方案提供了一个将数据设置到全局位置并连接组件以消费这些数据的选项。虽然这确保了当这些数据发生变化时,连接的组件会自动重新渲染,但它也必须保证只有这些组件会重新渲染,而不是整个组件树。
虽然这是一个好的模式,但也伴随着一些风险。当每个组件都可以连接到你的全局状态时,你必须非常小心地编辑这种状态的方式。
重要提示
绝不允许任何组件直接写入你的状态。无论你使用什么库,你的全局状态提供者都应该始终控制状态如何被更改。
如前所述的信息框中提到,您的全局状态提供者应始终控制状态。这意味着您不应允许任何组件直接设置状态。相反,您的应用状态提供者应提供一些可以改变状态的函数。这确保您始终知道状态可以如何改变。只能以这些方式改变的状态也称为可预测状态。
使用可预测状态模式
在处理大型项目时,特别是在有多个开发者参与的项目中,拥有可预测状态尤为重要。想象一下,在一个项目中,任何人都可以简单地从任何组件直接设置状态。当您遇到错误,因为您的状态包含一个无法由您的应用程序处理的无效值时,几乎不可能找出这个值是从哪里来的。此外,当您允许从全局状态提供者外部直接编辑状态时,您无法提供任何中央验证。
当您使用可预测状态模式时,您有三个优点。首先,您可以提供验证并防止无效值写入您的状态。其次,如果您遇到由于无效状态值而导致的错误,您有一个中心点可以开始调试。第三,它更容易为其编写测试。
创建可预测状态的模式如下图中所示:

图 5.3 – 简单的可预测状态管理
如您所见,组件触发任何事件。在这个例子中,用户点击了一个按钮。这个事件触发了一个动作。这可能是一个自定义钩子或由某些状态管理库提供的函数。这个钩子或函数可以执行多项操作,从验证事件到从本地存储解决方案或外部后端获取数据。最后,状态将被设置。
为了让您有一个更好的概念,让我们看看一个具体的例子。该组件是一个重新加载按钮。点击它后,动作从后端获取最新的数据。它处理请求,如果请求成功并提供有效数据,动作将此数据设置在状态中。否则,它设置错误消息并提供代码到状态。
如您所见,这种模式也可以在业务逻辑和 UI 之间提供一层良好的抽象。如果您想要一个更好的抽象,您可以使用我们接下来要讨论的下一个模式。
使用状态/动作/还原模式
这个简单的可预测状态管理模式可以扩展。以下图显示了扩展版本,其中添加了还原器和选择器:

图 5.4 – 状态/动作/还原模式
上述图表显示了所谓的 状态/动作/Reducer 模式。在这个模式中,动作不是一个函数或 Hook,而是一个被派发的 JavaScript 对象。在大多数情况下,这个动作由 reducer 处理。reducer 接收动作,它可以携带一些数据作为有效负载,并对其进行处理。它可以验证数据,将数据与当前状态合并,并设置状态。
通常,在这个模式中,reducer 不会访问任何其他数据源。它只知道动作和状态。如果你想要在这个模式中获取数据,你可以使用中间件。这个中间件拦截派发的动作,处理其任务,并派发其他动作,然后这些动作被 reducers 处理。
再次,让我们看看一个具体的例子。用户点击了 FETCH_DATA 动作。这个 FETCH_DATA 动作由中间件处理。中间件获取数据并验证请求。如果一切顺利,它将派发一个带有新数据作为有效负载的 SET_DATA 动作。
Reducer 处理这个 SET_DATA 动作,可能进行一些数据验证,将数据与当前状态合并,并设置新状态。如果中间件中的数据获取失败,中间件将派发一个带有错误代码和错误消息的有效负载的 DATA_FETCH_ERROR 动作。这个动作也被一个 reducer 处理,它为状态设置错误代码和消息。
图 5.3 和 图 5.4 之间的另一个区别是选择器的存在。这是不同状态管理解决方案中存在的东西,因为它使得只订阅状态的一部分而不是整个状态成为可能。这非常有用,因为它使得在不需要总是重新渲染整个应用的情况下创建复杂的状态对象成为可能。
当我们看一个例子时,这会更清晰。假设你有一个应用程序,其全局状态由一个用户、一个文章数组和一个收藏文章 ID 数组组成。你的应用程序在一个标签页中显示文章,每个文章都有一个按钮可以将其添加到收藏列表中。在第二个标签页中,你显示用户信息。
当你把所有这些都放在同一个全局状态中,而不使用选择器时,如果你的用户标签页偏好一篇文章,那么默认情况下,你的用户标签页会重新渲染,即使用户页面上没有任何变化。这是因为用户标签页也消耗了整个状态,并且这个状态发生了变化。当在用户上使用选择器时,它不会重新渲染,因为用户标签页连接到的状态的用户部分没有变化。
如果你使用一个没有选择器的复杂状态,你将不得不创建不同的状态提供者,它们之间完全独立。
现在你已经了解了不同的选项,是时候看看何时需要使用全局状态,或者何时可以使用局部组件状态并简单地传递 props 了。
比较局部组件状态和全局应用状态
如果你想在 UI 中显示一些数据,在大多数情况下你必须将其存储在你的状态中。但有趣的问题是:在哪个状态中?本地组件状态还是全局应用程序状态?
这是一个没有简单答案或适用于每种情况的规则的话题。然而,我想给你一些指导原则,以便你可以为所有用例做出良好的决策:
-
尽量保持全局状态尽可能精简:全局变量在大多数编程语言中是非常不常见的。这是有原因的。如果可以在应用程序的任何地方设置一切,那么调试和维护它将变得非常困难。此外,全局应用程序状态越大,遇到性能问题的可能性就越大。
-
表单数据不应成为全局状态的一部分:当你提供输入字段,如文本字段、开关、日期选择器或其他任何内容时,这些组件的状态不应成为全局应用程序状态的一部分。这些信息属于视图,它提供了这些字段,因此应成为视图组件状态的一部分。
-
尽量减少向下传递超过三层数据:在向子组件传递 props 时,你应该尽量避免通过多层传递这些数据。最佳实践是永远不要将组件 props 传递给子组件,而只传递组件的状态。然而,在实践中这可能相当困难,所以我建议坚持不要向下传递超过三层数据。
-
尽量减少向上传递多层数据:正如你已经学到的,你可以通过从父组件传递一个函数给子组件,该函数设置父组件的状态,然后从子组件中调用这个函数,从而从子组件传递数据到父组件。由于这可能导致组件之间非常混乱的依赖关系,因此在向上传递数据时应比向下传递数据更加小心。我建议只向上传递一层数据。
-
对于在应用程序多个区域使用的数据,使用全局应用程序状态:当数据需要在应用程序的多个区域可用,而这些区域位于完全不同的导航堆栈中时,你应该始终使用全局应用程序状态。
决定哪些数据属于哪个状态可能具有挑战性。这始终是具体情况具体分析,有时,你可能因为需求变化或在使用过程中意识到这不是正确的决定而不得不撤销你的决定。这是可以的。然而,你可以在一开始就考虑为你的数据选择正确的状态解决方案来减少这些努力。
现在我们已经涵盖了理论,是时候看看最流行的解决方案以及如何维护全局应用程序状态了。
与全局状态管理解决方案一起工作
从历史上看,我们可能需要从 Redux 开始,因为它是第一个流行的全局状态管理解决方案。在 2015 年推出时,它迅速成为 React 应用程序中全局状态管理的既定标准。它仍然被非常广泛地使用,但特别是在过去 3 年中,一些其他第三方解决方案已经出现。
React 还引入了一个内置的全局状态管理解决方案,它可以用于类组件,也可以用于函数组件。它被称为 React Context,由于它随 React 一起提供,我们将首先看看它。
使用 React Context
React Context 的概念非常简单:它就像是一个通向组件的隧道,任何其他组件都可以连接到它。一个上下文总是由一个提供者和一个消费者组成。提供者可以被添加到任何现有的组件中,并期望传递一个值属性。所有是提供者组件后代的组件都可以实现一个消费者并消费这个值。
使用普通的 React Context 提供者和消费者
以下代码展示了普通的 React Context 示例:
export function App() {
return (
<ColorsProvider>
<ColoredButton />
</ColorsProvider>
);
}
在您的 App.js 文件中,您添加了一个 ColorsProvider,它包裹了一个 ColoredButton 组件。这意味着在 ColoredButton 中,我们将能够实现一个用于 ColorsProvider 值的消费者。但让我们先看看 ColorsProvider 的实现:
import defaultColors from "defaultColors";
export const ColorContext = React.createContext();
export function ColorsProvider(props) {
const [colors, setColors] =
useState(defaultColors.light);
const toggleColors = () => {
setColors((curColors) =>
curColors === defaultColors.dark ?
defaultColors.light : defaultColors.dark
);
};
const value = {
colors: colors,
toggleColors: toggleColors
};
return <ColorContext.Provider value={value} {...props} />;
}
在这个例子中,ColorsProvider 是一个函数组件,它提供了一个具有颜色属性的状态。这个状态使用从 defaultColors 导入的默认颜色方案初始化。它还提供了一个 toggleColors 函数,该函数可以改变颜色方案。
颜色状态变量和 toggleColors 函数随后被打包成一个值对象,并将其传递给 ColorContext.Provider 的值属性。ColorContext 在第 2 行初始化。
如您所见,该文件有两个导出:ColorContext 本身和 ColorsProvider 函数组件。您已经学习了如何使用提供者,所以接下来,我们将看看如何消费上下文的值。
注意
ColorsProvider 函数组件对于 React Context 的工作并不是必需的。我们也可以将 React Context 的初始化、颜色状态、toggleColors 函数以及 ColorContext.Provider 直接添加到 App.js 文件中。但这是一个最佳实践,我建议将您的上下文提取到单独的文件中。
以下代码展示了 ColoredButton,它在我们的 App.js 文件中被 ColorsProvider 包裹:
function ColoredButton(props) {
return (
<ColorContext.Consumer>
{({ colors, toggleColors }) => {
return (
<Pressable
onPress={toggleColors}
style={{
backgroundColor: colors ?
colors.background :
defaultColors.background
}}
>
<Text
style={{
color: colors ? colors.foreground :
defaultColors.foreground
}}
>
Toggle Colors
</Text>
</Pressable>
);
}}
</ColorContext.Consumer>
);
}
如您所见,我们使用了一个 ColorContext.Consumer 组件,它提供了 ColorsProvider 的值。这些值可以被使用。在这种情况下,我们使用 colors 对象来样式化 Pressable 和 Text 组件,并将 toggleColors 函数传递给 Pressable 组件的 onPress 属性。
这种实现消费者组件的方法在函数组件和类组件中都可以工作。当与函数组件一起工作时,你可以使用更简单的语法来获取上下文的值。
使用 Context 和 React Hooks
以下代码示例展示了之前查看的代码示例的一个小部分:
function ColoredButton(props) {
const {colors, toggleColors} = React.useContext(ColorContext);
return (
<Pressable
onPress={toggleColors}
正如你所见,你不需要实现上下文消费者组件,你可以简单地使用 useContext Hook 来获取值。这使得代码更短,可读性更强。
虽然这个例子非常简单,但它仍然遵循了最佳实践。正如你所见,setColors 函数,即我们状态的设置器,不是公开可用的。相反,我们提供了一个 toggleColors 函数,它允许我们以预定义的方式更改状态。此外,我们很好地将状态从 UI 中抽象出来。
Hooks 允许你更进一步。当项目增长并且你想要添加一个额外的抽象层,例如用于外部请求,你可以创建一个自定义 Hook 作为你的中间件。
这是我们将在示例项目中添加的内容。我们将创建一些功能,使用户能够创建一个收藏电影列表,然后在该 用户 选项卡中显示。在这个过程中,我们将讨论 React Context 在全局状态管理中的优点和局限性。
以下图展示了我们将要创建的内容:
![图 5.5 – 示例应用 – 收藏电影
![img/B16694_05_05.jpg]
图 5.5 – 示例应用 – 收藏电影
这就是应用应该能够做到的事情。在每部电影详情页,我们将添加一个按钮来将电影添加到 收藏电影。如果电影已经是 收藏电影 的一部分,按钮将变为 移除 按钮,从列表中移除电影。
在 电影 列表中,我们想在所有属于 收藏电影 列表的电影上添加点赞图标。最后,我们想在 用户 选项卡中显示所有电影。
首先,我们必须创建上下文和自定义 Hook,以便能够存储数据。以下代码展示了 UserProvider:
export function UserProvider(props: any) {
const [name, setName] = useState<string>('John');
const [favs, setFavs] = useState<{[favId: number]:
IMovie}>({});
const addFav = (fav: IMovie): void => {
if (!favs[fav.id]) {
const _favs = {...favs};
_favs[fav.id] = fav;
setFavs(_favs);
}
};
const removeFav = (favId: number): void => {
if (favs[favId]) {
const _favs = {...favs};
delete _favs[favId];
setFavs(_favs);
}
};
const value = {
name, favs, addFav, removeFav,
};
return <UserContext.Provider value={value} {...props} />;
}
正如你所见,我们有两个状态变量:一个对象,以类似映射的结构存储收藏电影(favs)和用户的名字(name)。现在你可以忽略 name;我们稍后会用到它。
提供者还包含 addFav 和 removeFav 函数,这是从提供者外部编辑存储的唯一方式。这两个函数以及 name 和 favs 状态变量被打包到 value 变量中,然后传递到提供者的 value 属性。
接下来,我们将查看自定义 Hook。这个 Hook 作为中间件和数据选择器,用于在存储之前获取数据,并将数据转换为所需的形式:
export function useUser() {
const context = React.useContext(UserContext);
const {name, favs, addFav, removeFav} = context;
const addFavById = (favId: number): void => {
const movie = getMovieById(favId);
if (!movie) {
return;
}
addFav(movie);
};
const getFavsAsArray = (): IMovie[] => {
return Object.values(favs);
};
const isFav = (favId: number): boolean => {
return !!favs[favId];
};
return {
name, favs, getFavsAsArray, removeFav, addFavById,
isFav,
};
}
正如我们在之前的 Hooks 示例中所做的那样,我们将使用 useContext Hook 来使提供者的数据在我们的自定义 Hook 中可访问。自定义 Hook 包含三个函数。addFavById 函数接受一个 movieId 并从我们的 movieService 中获取电影。这是一个典型的中间件任务。
getFavsAsArray 函数提供了一个用户喜欢的电影数组。isFav 函数回答了给定 ID 是否属于用户喜欢的电影列表中的问题。这两个函数是典型的选择器。
Hook 返回这三个函数以及来自提供者的 name、favs 和 removeFav。有了这些,我们就可以非常容易地实现我们的需求。
让我们从电影详情页面开始。我们将查看添加的代码的不同部分;如果您想查看整个文件,请访问这本书的 GitHub 仓库:
const Movie = (props: MovieProps) => {
const {isFav, addFavById, removeFav} = useUser();
const _isFav = isFav(props.route.params.movie.id);
...
在这个组件中,我们需要 isFav 函数来检查电影是否已经是用户收藏的一部分。根据这一点,我们希望能够将电影添加到或从用户的收藏中。因此,我们导入 useUser Hook,然后使用对象解构来使这些函数可用。我们还存储 isFav 信息以供以后使用。
现在我们可以使用这些函数了,我们必须实现按钮本身:
<Pressable
style={styles.pressableContainer}
onPress={
_isFav
? () => removeFav(props.route.params.movie.id)
: () => addFavById(props.route.params.movie.id)
}>
<Text style={styles.pressableText}>
{_isFav ? '👎 Remove from favs' : '👍 Add to favs'}
</Text>
</Pressable>
如您所见,按钮的实现部分相当简单。我们使用我们的 _isFav 变量来检查按钮应该显示哪种文本,并决定应该调用哪个函数。addFavById 和 removeFav 函数可以像组件提供的任何其他函数一样调用。
现在我们已经构建了编辑收藏的功能,下一步是在电影列表中显示这些信息。在电影详情视图中,Hook 的导入工作如下:
const Genre = (props: GenreProps) => {
const [movies, setMovies] = useState<IMovie[]>([]);
const {isMovieFav} = useUser();
...
由于我们不想向状态写入任何内容,因此我们不需要使这些函数可用。而且与电影详情页面相反,我们必须检查多部电影是否有收藏状态,因此在这里创建一个变量来缓存 isMovieFav 的结果是没有意义的。
接下来,让我们看看电影列表的 JSX 实现:
return (
<ScrollContainer>
{movies.map(movie => (
<Pressable
{isMovieFav(movie.id) ? (
<Text style={styles.movieTitleFav}>👍</Text>
) : undefined}
<Text style={styles.movieTitle}>{movie.title}
</Text>
</Pressable>
))}
</ScrollContainer>
);
在遍历电影时,我们将使用 isMovieFav 函数检查每部电影。如果它返回 true,我们将添加一个点赞图标。这里只需要进行这个更改。
最后一步是在 用户 选项卡中显示 收藏电影 的列表。这同样只需要几行代码:
const User = (props: UserProps) => {
const {getMovieFavsAsArray} = useUser();
const _movieFavsArray = getMovieFavsAsArray();
return (
<ScrollContainer>
{_movieFavsArray.map(movie => {
return (
<Pressable>
<Text style={styles.movieTitle}>{movie.title}
</Text>
</Pressable>
);
})}
</ScrollContainer>
);
};
前面的代码展示了整个组件(除了导入和样式)。我们使用 Hook 的 getMovieFavsAsArray 函数获取我们喜欢的电影,并将它们存储在一个变量中。然后,我们遍历数组并渲染电影。就这样!我们的示例就完成了。
正如你在本例中看到的,组件的实现部分非常简单,在大多数情况下只需要几行代码。即使在大项目中,只要你的上下文结构良好,这一点也会保持不变。我非常喜欢这种方法,因为它不需要任何外部库,并且有清晰的 UI 组件、中间件和状态提供者之间的分离。它还带来了另一个好处。
在使用 React Context 时,持久化存储的部分并当用户重新打开应用时重新加载数据可以非常有用。这也非常简单。下面的代码片段是 UserProvider 的一部分,展示了如何存储和重新加载用户的收藏列表。
在这种情况下,我们使用 AsyncStorage 作为本地存储解决方案:
useEffect(() => {
AsyncStorage.getItem('HYDRATE::FAVORITE_MOVIES').then
(value => {
if (value) {
setFavs(JSON.parse(value));
}
});
}, []);
useEffect(() => {
if (favs !== {}) {
AsyncStorage.setItem('HYDRATE::FAVORITE_MOVIES',
JSON.stringify(favs));
}
}, [favs]);
由于提供者就像任何其他组件一样工作,它也可以使用 useEffect Hook。在这个例子中,我们使用一个效果在提供者挂载时从 AsyncStorage 获取 favs。我们使用另一个效果在 favs 变量每次变化时存储收藏。虽然有很多好处,但不幸的是,这种基于 React Context 的方法有一个很大的限制。
理解 React Context 的限制
在这个示例的开始,我告诉你要忽略状态提供者中的 name 变量,因为我们需要它在以后使用。现在就是“以后”。如果你已经看过这本书的 GitHub 仓库,你可能已经意识到 Home 视图的代码已经发生了变化。
以下代码片段显示了变化:
const Home = (props: HomeProps) => {
const {name} = useUser();
...
console.log('re-render home');
return (
<ScrollContainer>
<Text style={styles.welcome}>Hello {name}</Text>
...
这个视图现在导入了 useUser Hook,并读取用户的名字,为用户提供一个温馨的欢迎信息。它还包含一个 console.log,记录页面的每次重新渲染。当你运行代码示例并添加/删除电影到/从用户的收藏中时,你会意识到 Home 组件在 UserProvider 中 favs 的每次变化时都会重新渲染。
即使在这个组件中我们没有使用 favs,这种情况也会发生。这是因为 UserProvider 中的状态变化会触发每个子组件的重新渲染,这也包括所有导入自定义 Hook 的组件。
这种限制并不意味着你不能使用 React Context。它在大型项目中也很普遍。但你始终要记住这个限制。我推荐的解决方案是将你的全局状态分割成不同的上下文,每个上下文有不同的提供者。
在这个例子中,我们可以创建一个只包含用户名的 UserContext,以及一个只包含收藏列表的 FavContext。
你也可以使用 useMemo、React.memo 或 componentDidUpdate 来优化这种方法的表现。但如果你需要这样做,我建议使用另一个提供这些优化功能的解决方案。其中之一是 Zustand,我们将在下一节中探讨。
使用 Zustand
Zustand.js 是一种非常简洁的状态管理方法。它基于 Hooks,并内置了性能优化的选择器。它还可以以不同的方式扩展,以便你可以用它来实现你喜欢的确切的全局状态管理模式。
注意
如果你想在类组件中使用 Zustand,你不能直接这样做,因为类组件不支持 Hooks。然而,你可以使用 高阶组件(HOC)模式将类组件包裹在一个函数组件中。然后,你可以在函数组件中使用 Hook,并将 Zustand 状态作为 prop 传递给类组件。
你可以在 React 文档中了解更多关于 HOC 的信息:bit.ly/prn-hoc。
要创建一个 Zustand 存储,你必须使用 Zustand 提供的 create Hook。这创建了一个存储,它持有状态并提供访问状态的功能。为了获得更具体的概念,让我们看看我们的示例项目在由 Zustand 处理全局状态时的样子。
这里显示的代码片段只是摘录。如果你想查看运行示例,请访问这本书的 GitHub 仓库,并选择 chapter-5-zustand 标签:
export const useUserStore = create<IUser & UserStoreFunctions>((set, get) => ({
name: 'John',
favs: {},
addFavById: (favId: number) => {
const _favs = {...get().favs};
if (!_favs[favId]) {
const movie = getMovieById(favId);
if (movie) {
_favs[favId] = movie;
set({favs: _favs});
}
}
},
removeFav: (favId: number) => {
const _favs = {...get().favs};
if (_favs[favId]) {
delete _favs[favId];
set({favs: _favs});
}
},
}));
我们使用 Zustand 提供的 create 函数来创建存储。我们向 create 传递一个函数,该函数可以访问 get 和 set 参数,并返回存储。这个存储本身是一个对象,可以持有数据对象(状态)和函数(设置器或选择器)作为属性。在这些函数内部,我们可以使用 get 来访问状态对象或 set 来写入存储的部分。
再次强调,当你将对象作为状态的一部分工作时,你必须创建一个新的对象并将其写入存储以触发重新渲染。如果你只是修改现有的状态对象并将其写回,状态将不会被识别为已更改,因为对象引用没有改变。
提示
当你在状态中使用对象时,总是需要在设置状态之前创建这些对象的副本可能会很烦人。这个问题通过一个名为 produce 函数的开源库得到解决,它接受旧状态,允许你进行更改,并自动从它创建一个新的对象。它还作为中间件集成到 Zustand 中。
你可以在 immer.js 的网站上了解更多信息:bit.ly/prn-immer。
在我们的例子中,我们仍然有 name 和 favs 作为状态属性。为了修改这个状态,我们的 Zustand 存储提供了 addFavById 函数和 removeFav 函数。addFavById 函数不仅将数据写入存储,还会从我们的 movieService 中获取给定 ID 的电影。
接下来,我们将看看如何在组件内部连接到存储。我们甚至不需要更改太多代码,就可以在我们的组件中将 React Context 切换到 Zustand。
让我们看看电影视图:
const Movie = (props: MovieProps) => {
const [addFavById, favs, removeFav] = useUserStore(state
=> [
state.addFavById,
state.favs,
state.removeFav,
], shallow);
const _isFav = favs[props.route.params.movie.id];
...
在这里,我们使用我们刚刚使用 Zustand 的 create 函数创建的 useUserStore 钩子来连接到 Zustand 状态。我们通过数组解构连接到状态的多个部分。由于我们已经在 React Context 示例中实现了函数在 JSX 代码中的使用,所以我们不需要在那里做任何改变。这些函数做的是同样的事情,但来自另一个状态管理解决方案。
然而,当查看 Home 视图时,最重要的事情发生了:
const Home = (props: HomeProps) => {
const name = useUserStore(state => state.name);
console.log('rerender home');
...
在这里,我们正在做与 React Context 示例中相同的事情:我们将我们的主页视图连接到全局状态并获取名称。当你运行这个示例时,你会意识到当你添加或删除收藏夹时,console.log 将不再被触发。
这是因为 Zustand 只在组件连接到的状态部分发生变化时触发重新渲染,而不是状态中的任何内容发生变化时。这非常有用,因为你不必过多地考虑性能优化。Zustand 提供了这项功能作为默认设置。
由于 Zustand 的简单性和灵活性,它变得越来越受欢迎。如前所述,你不必选择这种简单的 Zustand 方法。你甚至可以用它创建类似 Redux 的工作流程。
谈到 Redux,这是你接下来将要学习的下一个解决方案。
与 Redux 一起工作
当涉及到全局状态管理时,Redux 是迄今为止最常用的解决方案。以下图表比较了 react-redux 和 Zustand 的使用情况:

图 5.6 – react-redux 和 Zustand 的每日 npm 下载量
如你所见,react-redux 的每日下载量相当稳定,大约在 500 万左右。Zustand 的受欢迎程度正在迅速增长。它从 2021 年第三季度的每日约 10 万次下载增长到 2022 年第二季度的每日约 50 万次下载。这是一个迹象,表明许多新项目更倾向于使用 Zustand 而不是 Redux。
尽管如此,Redux 是一个非常好的解决方案。它遵循一个非常清晰的架构,并围绕它建立了一个庞大的生态系统。Redux 使用状态/动作/还原器模式,并强迫开发者坚持使用它。它可以通过不同的中间件如 redux-thunk 或 redux-saga 来增强以处理效果。它还提供了出色的开发者工具用于调试。
由于 Redux 是一个非常成熟的技术,市场上有很多关于 Redux 的优秀教程和书籍。因此,本书不会涵盖 Redux 的基本用法。如果你还不了解 Redux 的基础知识,我建议从这里开始学习官方教程:bit.ly/prn-redux。
虽然 Redux 是一个出色的状态管理解决方案,但它有两个巨大的缺点。首先,它为创建和维护流程的所有部分创建了一些额外开销。要在全局状态中提供一个简单的字符串值,你至少需要一个存储、一个 reducer 和一个 action。其次,深度集成 Redux 的应用程序代码可能变得相当难以阅读。
我会推荐 Redux 用于由许多开发者共同工作的庞大应用。在这种情况下,清晰的结构和逻辑层之间的分离是值得额外开销的。应该使用中间件来处理副作用,而redux-toolkit可以用来简化代码。这种设置在大规模场景中可以非常有效地工作。
现在你已经学会了如何使用 Redux、Zustand 和 React Context 来处理全局应用程序状态,你已经看到有多个不同的方法可以处理全局状态管理。虽然这些解决方案目前是我的最爱,但还有更多选项可供选择。如果你想寻找不同的选项,我也推荐 MobX、MobX-state-tree、Recoil 和 Rematch。
现在你已经学会了如何在 React Native 应用中处理数据,我们将探讨如何从外部 API 检索数据。
连接到远程后端
React Native 允许你使用不同的解决方案连接到在线资源,如 API。首先,你将了解关于纯 HTTP API 连接的内容。在本节的后面部分,我们还将探讨更高级的解决方案,如 GraphQL 客户端和 Firebase 或 Amplify 等 SDK。但让我们先从一些基本的事情开始。
理解 React Native 中连接的一般原则
无论你在你的 React Native 应用中使用什么连接解决方案,始终使用JavaScript 对象表示法(JSON)作为数据传输的格式都是一个好主意。由于 React Native 应用是用 JavaScript 编写的,而 JavaScript 与 JSON 配合得非常好,这是唯一合理的选择。
接下来,无论你使用哪种连接解决方案,都要始终将你的 API 调用封装在服务中。即使你确信你选择的连接解决方案,你可能在几年后想要或必须替换它。
当你将所有代码封装在服务中时,这要简单得多,而不是在应用中的每个地方寻找它。我想在这里提到的最后一件事是,你必须考虑如何保护你的 API。
理解安全风险
你始终要记住,React Native 应用完全在客户端运行。这意味着你应用中发送的任何内容都可以被认为是公开可用的。这还包括 API 密钥、凭证或任何其他认证信息。虽然永远不可能有 100%无法攻破的软件,但你至少应该提供一定级别的安全性:
![图 5.7 – 安全努力和泄露的可能性(灵感来源于 https://reactnative.dev/docs/security)
![图片 B16694_05_07.jpg]
图 5.7 – 安全努力和泄露的可能性(灵感来源于 https://reactnative.dev/docs/security)
如你所见,即使是在保护你的应用程序方面的一些努力,也能显著降低泄露的可能性。你应该至少做到以下这些:
-
不要在你的代码中存储你的私有 API 密钥或凭证。
-
不要使用
react-native-dotenv或react-native-config等工具来存储敏感数据。这些数据也会以纯文本形式发送到客户端。 -
在可能的情况下使用基于用户的密钥或凭证。
-
在生产构建中移除所有控制台输出,以防止暴露密钥。
-
在安全的本地存储解决方案中存储敏感信息(参见 第四章,React Native 中的样式、存储和导航)的 存储 部分)。
当你需要与只提供你一个密钥的第三方 API 一起工作时,你应该创建自己的服务器层,你可以在你的应用程序内部调用它。然后,你可以在服务器上存储你的 API 密钥,将其添加到请求中,从你的服务器调用第三方 API,并将响应提供给你的应用程序。
这样做可以防止你的 API 密钥公开。再次提醒,始终记住你与应用程序一起发布的所有内容都可能被暴露。
提醒到此,让我们开始我们的第一个简单调用,我们将使用 JavaScript Fetch API。
使用内置的 Fetch API
React Native 内置了 Fetch API,这对于大多数用例来说已经足够了。它易于使用,易于阅读,并且可以用于所有大小的应用程序。我们将再次使用我们的示例应用程序来查看它是如何工作的。我们将用 The Movie DB 的真实 API 调用替换 genres.json 和 movies.json 静态文件(www.themoviedb.org)。请注意,此 API 仅适用于非商业用途且免费,并且在使用时必须遵守使用条款。
你可以在 GitHub 上找到完整的示例代码(chapter-5-fetch 标签)。要运行它,你必须注册 www.themoviedb.org/ 并获取一个 API 密钥。你可以在这里了解更多信息:bit.ly/prn-tmd-api。
现在,让我们看看代码。首先,我们必须为所有 API 信息创建一个常量文件:
export const APIConstants: {
API_URL: string;
API_KEY: string;
} = {
API_URL: 'https://api.themoviedb.org/3/',
API_KEY: '<put your api key here - never do that
in production>',
};
在我们的示例中,我们将基本 URL 和 API 密钥放在这里。这是你可以粘贴从 The Movie DB 获取的 API 密钥的地方。
安全提示
在生产环境中,永远不要像这样将你的 API 密钥放在你的应用程序中。
由于我们已经在 movieService 中提取了数据连接,因此这是我们将会进行大部分更改的文件。我们不会读取和过滤本地文件,而是连接到真实的 API。为了使连接更容易,我们首先编写两个辅助函数:
const createFullAPIPath: (path: string) => string = path => {
return (
APIConstants.API_URL + path +
(path.includes('?') ? '&' : '?') +
'api_key=' + APIConstants.API_KEY
);
};
async function makeAPICall<T>(path: string): Promise<T> {
console.log(createFullAPIPath(path));
const response = await fetch(createFullAPIPath(path));
return response.json() as Promise<T>;
}
createFullAPIPath 函数接受请求的路径,并将基本 URL 和用于身份验证的 API 密钥添加到调用中。makeAPICall 函数执行获取操作,并从响应 JSON 返回类型化数据。
这些辅助函数用于创建不同的函数,这些函数被导出,以便在应用程序中使用。让我们看看其中之一——getGenres 函数:
const getGenres = async (): Promise<Array<IGenre>> => {
let data: Array<IGenre> = [];
try {
const apiResponse = await makeAPICall<{genres: Array
<IGenre>}>('genre/movie/list',
);
data = apiResponse.genres;
} catch (e) {
console.log(e);
}
return data;
};
正如你所见,我们使用 makeAPICall 辅助函数来获取数据。我们添加我们期望的数据类型。作为路径,我们只需要传递 API 的相对路径。然后,我们处理响应并返回数据。在生产中,我们不会将错误记录到控制台,而是记录到外部错误报告系统。你将在 第十三章**,提示和展望 中了解更多信息。
在我们的应用程序中,还有一件简单的事情需要更改,以便使其再次工作。你可能已经注意到,我们的服务中的函数已更改为 async 函数,它们返回承诺而不是直接数据。虽然我们能够同步处理本地数据,但 API 调用始终是异步执行的。
这是一件好事。你不想让你的应用程序在 API 请求的响应到来之前冻结。但是,由于服务函数现在返回承诺,我们必须修改这些函数被调用的地方。
那么,让我们再次看看主页视图——更确切地说,是 useEffect 钩子部分:
useEffect(() => {
const fetchData = async () => {
setGenres(await getGenres());
};
fetchData();
}, []);
由于我们无法在 useEffect 钩子中直接创建异步函数,所以我们创建了一个异步的 fetchData 函数,然后在 useEffect 中调用它。在这个函数中,我们等待由 getGenres 返回的承诺,并将数据设置在状态中。
类似的变化必须在 genre 视图、movie 视图以及我们的 Zustand 存储的 addFavById 函数中进行。
虽然 Fetch 非常强大,你甚至可以在大型和企业的项目中使用它,但其他一些解决方案也可能很有用。
与其他数据获取解决方案一起工作
在本小节中,你将了解其他流行的数据获取解决方案。它们都有各自的优点和缺点,最终你必须决定哪种最适合你的项目。以下解决方案运行良好,维护良好,并且被广泛使用:
-
Axios:Axios 是一个用于获取数据的第三方 HTTP 客户端。它的工作方式与 Fetch API 非常相似,但带来了许多附加功能。一旦创建,你可以使用头信息、拦截器等配置你的 Axios 实例。它还提供了出色的错误处理,并允许你取消请求。
-
Apollo/URQL GraphQL 客户端:GraphQL 是一种 API 查询语言,在过去的几年中变得非常流行。它相对于 REST API 的优势在于,你可以在客户端控制你想要获取的内容。你还可以在一次调用中获取多个资源。这以最有效的方式获取你所需的确切数据。你可以在这里了解更多关于 GraphQL 的信息。
GraphQL 有多种客户端实现。最受欢迎的包括 Apollo 和 URQL。这两个客户端不仅提供数据获取功能,还处理缓存、刷新和 UI 中的数据实际化。虽然这非常有用,但你始终应该确保在用户离线时也能提供出色的用户体验。
-
React Native Firebase:Firebase 是一个非常流行的应用开发后端平台。它提供了一系列维护良好的 SDK 服务。React Native Firebase 是针对原生 Android 和 iOS SDK 的包装器。它提供数据获取功能,但仅限于连接到 Firebase 服务的连接。如果你想了解更多关于 Firebase 的信息,可以访问 React Native Firebase 文档:
bit.ly/prn-firebase。 -
AWS Amplify:Amplify 是一组可以通过 Amplify SDK 访问的 AWS 服务。与 Firebase 类似,它提供了数据获取功能,但仅限于在 Amplify 中配置的 AWS 服务。如果你想了解更多关于 Amplify 的信息,可以访问 Amplify JavaScript 文档:
bit.ly/prn-amplify。
除了这些解决方案之外,许多服务提供商还提供了他们自己的 SDK,可以用来访问他们的服务。使用这些 SDK 是完全可行的。但再次提醒,始终不要在应用中存储任何 API 密钥或认证信息。
摘要
为了总结本章内容,让我们简要回顾一下。在本章中,你学习了如何处理本地和全局状态。你了解了全局状态处理中最流行的概念以及如何决定哪些数据应该存储在你的全局状态或组件或视图的本地状态中。你还了解了如何使用 React Context、Zustand 和 Redux 进行全局状态处理。
在掌握 React Native 中的状态管理后,你学习了如何将你的应用连接到远程后端。你了解了如何使用内置的 Fetch API,如何在服务中提取 API 调用,如何创建和使用辅助函数,以及如何处理异步调用。最后,你学习了数据获取的不同解决方案,如 Axios、GraphQL 客户端和其他 SDK。
现在你已经完成了这本书的前五章,你可以创建一个具有强大技术基础的工作应用。在下一章中,你将学习如何通过美丽的动画让你的应用看起来更美观。
第六章:与动画一起工作
动画是每个移动应用的一部分。平滑的动画可以决定用户是否感到舒适地使用应用。实质上,动画只是屏幕反复渲染,从一个状态过渡到另一个状态。
这种渲染应该非常快,以至于用户不会意识到动画的单个状态,而是感知到它是一个平滑的动画。更进一步,动画不仅随时间从状态 A 变换到状态 B,而且还会对用户的交互做出反应,如滚动、按下或滑动。
大多数设备的屏幕帧率为 60 帧/秒(fps),而现代设备已经达到 120 fps(截至编写本文时,React Native 仅支持 60 fps,你可以在 GitHub 上了解相关信息:bit.ly/prn-rn-fps)。这意味着当运行动画时,屏幕必须以 60 fps 的速度重新渲染。
这相当具有挑战性,因为计算复杂的动画和重新渲染屏幕是一些计算密集型操作。特别是在低端设备上,动画的计算可能会变得太慢,屏幕刷新率低于 60/120 fps。这会使动画和应用程序感觉迟钝和缓慢。
实质上,你可以将动画分为两种不同类型:
-
屏幕动画:这类动画仅适用于屏幕的一部分。这种类型的动画有很多不同的用途,例如吸引用户注意、提供触摸反馈、显示进度或加载指示,或者改善滚动体验。
-
全屏动画:这类动画过渡整个屏幕。大多数情况下,这种类型的动画用于导航到另一个屏幕。
由于全屏动画由所有流行的导航库内部处理,因此本章将重点介绍屏幕动画。全屏动画已在 第四章**,样式、存储和导航,导航部分 中介绍。
在 React Native 中实现平滑动画有多种方法。根据项目类型和想要构建的动画类型,你可以从众多解决方案中选择,每种方案都有其自身的优缺点。在本章中,我们将讨论最佳和最广泛使用的解决方案。
在本章中,我们将涵盖以下主题:
-
理解 React Native 中动画的架构挑战
-
使用 React Native 内置的 Animated API
-
使用
react-native-animatable创建简单动画 -
探索 Reanimated 2 – React Native 最完整的动画框架
-
在 React Native 中使用 Lottie 动画
信息
关于使用 Skia 渲染引擎(它为 Chrome、Firefox、Android 和 Flutter 提供动力)在 React Native 中渲染动画的一些有趣的发展,但在撰写本文时,这种方法尚未准备好投入生产。
技术要求
要运行本章中的代码,您必须设置以下内容:
-
一个可工作的 React Native 环境 (bit.ly/prn-setup-rn – React Native CLI 快速入门)
-
一个 iOS/Android 模拟器或真实设备(真实设备更受欢迎)
理解 React Native 中动画的架构挑战
当涉及到动画时,React Native 的当前架构并不理想。想象一下,一个基于 ScrollView 垂直滚动值来缩放或移动标题图片的动画;这个动画必须基于 ScrollView 的滚动值进行计算,并立即重新渲染图片。以下图表显示了使用纯 React Native 架构时会发生什么:

图 6.1 – 基于滚动值进行动画时的 React Native 架构
在这里,您可以看到一般的 React Native 架构。JavaScript 线程是您编写代码的地方。每个命令都将序列化并通过桥接发送到原生线程。在这个线程中,命令被反序列化并执行。同样,用户输入也是如此,但方向相反。
对于我们的动画来说,这意味着滚动值必须序列化,通过桥接发送,反序列化,通过复杂的计算转换为动画值,序列化,通过桥接返回,反序列化,然后渲染。整个过程必须在每 16 毫秒(或每秒 60 次)内完成。
这种往返会导致多个问题:
-
序列化/反序列化过程消耗了不必要的计算能力
-
在大多数情况下,JavaScript 中的计算速度比原生代码慢
-
计算可能会阻塞 JavaScript 线程,使应用无响应
-
这种往返过程可能导致帧率下降,使动画看起来迟缓且缓慢
由于这些问题,不建议在您自己的纯 React Native 代码中编写动画(例如,通过在循环中设置状态)。幸运的是,有多个现成的解决方案可以避免这些问题,并实现高质量的动画。
在接下来的几节中,我们将探讨四种不同的解决方案。每个解决方案都有其优缺点,而应该选择哪种解决方案则取决于项目和用例。让我们从内置的 Animated API 开始。
使用 React Native 的内部动画 API
React Native 自带内置的 Animated API。这个 API 非常强大,你可以用它实现许多不同的动画目标。在本节中,我们将简要了解它是如何工作的,以及内部 Animated API 的优势和局限性。
要获取完整的教程,请查看官方文档,链接为bit.ly/prn-animated-api。
要了解 Animated API 的工作原理,让我们从一个简单的例子开始。
从一个简单的例子开始
以下代码实现了一个简单的淡入动画,使视图在 2 秒内出现:
import React, { useRef } from "react";
import { Animated, View, Button } from "react-native";
const App = () => {
const opacityValue = useRef(new Animated.Value(0)).
current;
const showView = () => {
Animated.timing(opacityValue, {
toValue: 1,
duration: 2000
}).start();
};
return (
<>
<Animated.View
style={{
backgroundColor: 'red',
opacity: opacityValue
}}
/>
<Button title="Show View" onPress={showView} />
</>
);
}
export default App;
动画 API 基于动画值。这些值随时间变化,并作为应用程序样式的组成部分使用。在这个例子中,我们将opacityValue初始化为一个Animated.Value组件,其初始值为0。
如你所见,JSX 代码包含一个Animated.View组件,其样式使用opacityValue作为透明度属性。当运行此代码时,Animated.View组件最初是完全隐藏的;这是因为透明度被设置为0。当调用showView时,它启动一个Animated.timing函数。
这个Animated.timing函数期望一个Animated.Value组件作为第一个属性,一个配置对象作为第二个参数。Animated.Value组件是动画过程中应该改变的价值。通过配置对象,你可以定义动画的一般条件。
在这个例子中,我们想在 2 秒(2,000 毫秒)内将Animated.Value组件的值从 0 变为 1。然后,Animated.timing函数计算动画的不同状态,并负责渲染Animated.View组件。
值得了解
实际上,你可以对 UI 的任何部分进行动画处理。Animated API 直接导出了一些组件,例如Animated.View、Animated.Image、Animated.ScrollView、Animated.Text和Animated.FlatList。但你可以通过使用Animated.createAnimatedComponent()来对任何组件进行动画处理。
虽然 Animated API 并没有完全解决 React Native 架构的问题,但它比反复设置状态要好,因为它大大减少了从 JavaScript 线程传输到原生线程的负载,但这种传输必须每帧进行。为了防止每帧都进行这种传输,你必须使用原生驱动程序,如下面的子节所示。
使用原生驱动程序
当使用配置对象配置动画时,你可以设置一个名为useNativeDriver的属性。这非常重要,并且尽可能应该这样做。
当使用useNativeDriver: true的原生驱动程序时,React Native 在开始动画之前将所有内容发送到原生线程。这意味着动画完全在原生线程上运行,这保证了动画的流畅运行和没有帧丢失。
不幸的是,原生驱动程序目前仅限于非布局属性。因此,如变换和透明度等属性可以使用原生驱动程序进行动画处理,而所有 Flexbox 和位置属性,如 height、width、top 或 left,则不能使用。
插值动画值
在某些情况下,你不想直接使用 Animated.Value 组件。这就是插值发挥作用的地方。插值是输入和输出范围的简单映射。在下面的代码示例中,你可以看到一个插值,它向之前的简单示例添加了一个位置变化:
style={{
opacity: opacityValue,
transform: [{
translateY: opacityValue.interpolate({
inputRange: [0, 1],
outputRange: [50, 0]
}),
}],
}}
在这个代码示例中,我们向 style 对象中添加了一个 translateY 变换属性。这个属性改变了一个对象的垂直位置。我们既没有设置一个固定值,也没有直接绑定 opacityValue。
我们使用一个具有定义的 inputRange 值 [0,1] 和定义的 outputRange 值 [50,0] 的插值函数。本质上,这意味着当 opacityValue(我们的 AnimatedValue)为 0 时,translateY 值将是 50,而当 opacityValue 为 1 时,translateY 值将是 0。这导致我们的 AnimatedView 在淡入的同时向上移动 50px 到其原始位置。
小贴士
尽量使用插值来减少你需要在应用程序中使用的动画值数量。大多数情况下,你可以使用一个动画值,并在其上进行插值,即使在复杂的动画中也是如此。
Animated API 的插值函数非常强大。你可以有多个值来定义范围,超出范围外推或夹紧,或指定动画的缓动函数。
了解 Animated API 的高级选项
Animated API 带来了很多不同的选项,这让你几乎可以创建你所能想象的任何动画:
-
你可以对动画值执行数学运算,如
add()、subtract()、divide()、multiply()、modulo()等。 -
你可以使用
Animated.sequence()顺序组合动画,或者使用Animated.parallel()同时组合它们(你甚至可以将这些选项结合起来)。 -
你还可以使用
Animated.delay()进行延迟动画或使用Animated.loop()进行循环动画。 -
除了
Animated.timing()之外,还有其他选项可以改变Animated.Value组件。其中之一是使用Animated.event()将ScrollView的滚动值绑定到AnimatedValue。
以下示例与本章 理解 React Native 中动画的架构挑战 部分的示例非常相似。代码展示了如何使用滚动值作为动画的驱动程序:
const App = () => {
const scrolling = useRef(new Animated.Value(0)).current;
const interpolatedScale = scrolling.interpolate({
inputRange: [-300, 0],
outputRange: [3, 1],
extrapolate: 'clamp',
});
const interpolatedTranslate = scrolling.interpolate({
inputRange: [0, 300],
outputRange: [0, -300],
extrapolate: 'clamp',
});
return (
<>
<Animated.Image
source={require('sometitleimage.jpg')}
style={{
...styles.header,
transform: [
{translateY: interpolatedTranslate},
{scaleY: interpolatedScale},
{scaleX: interpolatedScale}
]
}}
/>
<Animated.ScrollView
onScroll={
Animated.event([{nativeEvent: {contentOffset: {y:
scrolling,},},}],
{ useNativeDriver: true },
)
}
>
<View style={styles.headerPlaceholder} />
<View style={styles.content}>
</View>
</Animated.ScrollView>
</>
);
}
在这个例子中,ScrollView 的原生滚动事件直接连接到 Animated.Value 组件。使用 useNativeDriver: true 属性,使用了原生驱动程序;这意味着动画,由滚动值驱动,完全在原生线程上运行。
前面的例子包含了两个滚动值的插值:第一个在ScrollView被过度滚动时(这意味着ScrollView返回负滚动值)缩放图像,而第二个在滚动时将图像向上移动。
再次强调,由于使用了原生驱动程序,所有这些插值都是在原生线程上完成的。这使得 Animated API 在这个用例中非常高效。你可以阅读更多关于基于用户手势运行动画的信息,请参阅第七章**,React Native 中的手势处理。
Animated API 还提供了不同的缓动方法和复杂的弹簧模型。更多详细信息,请参阅官方文档bit.ly/prn-animated-api。
如你所见,Animated API 确实非常强大,你可以用它实现几乎每一个动画目标。那么,为什么市场上还有其他解决方案,当这个非常好的动画库已经内置时?嗯,对于每一个用例,Animated API 都远非完美。
理解 Animated API 的优缺点
内部 React Native Animated API 是一个非常好的简单到中等复杂度动画的解决方案。以下是 Animated API 最重要的优点:
-
强大的 API: 你可以构建几乎所有的动画。
-
无需外部库: 使用 Animated API 时,你不需要向你的项目添加任何依赖。这意味着无需额外的维护工作或更大的包大小。
-
使用原生驱动实现平滑动画: 当使用原生驱动程序时,你可以确信你的动画以 60 fps 运行。
同时,Animated API 也有一些缺点,你在选择最适合你项目的动画解决方案时必须牢记:
-
复杂的动画变得相当混乱: 由于 Animated API 的结构,包含大量元素或非常复杂的动画可能会变得非常混乱,代码也可能变得难以阅读和理解。
-
原生驱动程序不支持所有样式属性: 当使用 Animated API 时,你绝对应该使用原生驱动程序。由于这个驱动程序不支持位置或 Flexbox 属性,因此本质上,Animated API 仅限于非布局属性。
-
必须使用
Animated.Value组件。
总的来说,我会推荐 Animated API 用于小型到中型复杂度的动画,当你项目中还没有其他动画库时。然而,让我们看看另一个选项:react-native-animatable。
使用 react-native-animatable 创建简单的动画
有很多动画在几乎每个应用中都会被重复使用。这就是react-native-animatable的宗旨。这个库建立在内部 React Native Animated API 之上,并提供了一个非常简单且声明性和命令性的 API 来使用简单、预定义的动画。
从一个简单的例子开始
以下代码示例描述了使用声明式方法通过react-native-animatable实现的简单淡入动画,以及使用命令式方法通过react-native-animatable实现的简单淡出动画:
import React from "react";
import { View, Text, Pressable } from "react-native";
import * as Animatable from 'react-native-animatable';
const App = () => {
const handleRef = ref => this.view = ref;
const hideView = () => {
this.view.fadeOutDown(2000);
}
return (
<>
<Animatable.View
style={{
backgroundColor: 'red'
}}
ref={handleRef}
animation="fadeInUp"
duration=2000
/>
<Pressable onPress={hideView}>
<Text>Hide View</Text>
</Pressable>
</>
);
}
export default App;
在这个示例中,Animatable.View被赋予了一个预定义的Animatable动画作为动画属性,以及一个定义动画运行多长时间的持续时间。这就是实现入场动画所需的所有操作。
如前所述,Animatable 还支持命令式使用,这意味着您可以在 Animatable 组件上调用 Animatable 函数。在这个示例中,this.view包含对Animatable.View的引用,这使得可以在其上调用 Animatable 函数。
这是在按下Pressable时完成的。在这里,调用hideView,然后调用预定义的fadeOutDown Animatable 函数,使视图在 2 秒(2,000 毫秒)内消失。
使用原生驱动
如我们在使用 React Native 的内部动画 API部分所学,使用原生驱动对于实现流畅的动画至关重要。由于react-native-animatable基于动画 API,因此您也应该配置动画以使用原生驱动。
使用react-native-animatable,这是通过在运行动画的组件上添加useNativeDriver={true}属性来完成的。
重要提示
在使用原生驱动之前,请检查您想要使用的预定义动画是否支持原生驱动。
react-native-animatable库不仅限于预定义的动画。它还支持使用非常简单的 API 定义自定义动画。让我们看看这是如何实现的。
使用自定义动画
以下示例展示了如何创建一个简单的淡入和上升动画,就像我们在上一节中所做的那样:
const fadeInUpCustom = {
0: {
opacity: 0,
translateY: 50,
},
1: {
opacity: 1,
translateY: 0,
},
};
react-native-animatable的自定义动画将样式映射到关键帧。在这个示例中,我们从第一个关键帧(0)开始,将opacity值设置为0,将translateY值设置为50。在最后一个关键帧(1)中,opacity值应该是1,translateY值应该是0。现在这个动画可以作为任何 Animatable 组件的动画属性值使用,而不是预定义的字符串值。
理解 react-native-animatable 的优缺点
基于 React Native 动画 API 构建,动画 API 的所有优缺点也适用于react-native-animatable。除此之外,以下优点也值得提及:
-
到目前为止,
react-native-animatable是创建和使用高质量动画最容易的库。 -
声明式方法:声明式方法创建的代码易于阅读和理解。
由于react-native-animatable是一个基于动画 API 构建的库,这个额外的层也带来了一些缺点:
-
将
react-native-animatable作为项目的一个额外依赖项。这尤其重要,因为在编写本文时,该项目并没有得到非常积极的维护。这意味着,如果底层 Animated API 发生任何变化,它可能会阻止你升级你的 React Native 项目。 -
受限的 API:预定义的动画和创建自定义动画的可能性有限。如果你想创建复杂的动画,你应该使用其他选项。
实际上,react-native-animatable 是建立在 React Native Animated API 之上的一个简单库。它简化了动画的工作,并且与简单、预定义的动画配合得最好。如果你需要这些简单或标准的动画,而你又非常有限的时间来创建动画,react-native-animatable 是你的最佳选择。
如果你想要创建更复杂的动画,请参阅以下部分。
探索 Reanimated 2 – React Native 最完整的动画解决方案
Reanimated 是迄今为止 React Native 最完整、最成熟的动画解决方案。它最初是对 React Native Animated API 的改进重实现,但随着版本 2 的发布,API 发生了变化,库的功能得到了极大的增强。
本节涵盖了以下主题:
-
通过一个简单的示例了解 Reanimated API
-
理解 Reanimated 2 的架构
-
理解 Reanimated 的优缺点
让我们开始吧。
通过一个简单的示例了解 Reanimated API
实际上,Reanimated 2 的核心概念与 Animated API 一样简单。有可以更改的动画值,这些动画值驱动着动画。
以下代码展示了一个在 View 组件中缩放的动画:
import React from "react";
import { Text, Pressable } from "react-native";
import Animated, { useSharedValue, useAnimatedStyle,
Easing, withTiming } from 'react-native-reanimated';
const App = () => {
const size = useSharedValue(0);
const showView = () => {
size.value = withTiming(100, {
duration: 2000,
easing: Easing.out(Easing.exp),
});
}
const animatedStyles = useAnimatedStyle(() => {
return {
width: size.value,
height: size.value,
backgroundColor: 'red'
};
});
return (
<>
<Animated.View style={animatedStyles} />
<Pressable onPress={showView}>
<Text>Show View</Text>
</Pressable>
</>
);
}
当查看这段代码时,我们意识到以下几点:
-
结构与 Animated API 非常相似。有一个
sharedValue,它是Animated中的Animated.Value,还有一个withTiming函数,它是Animated.timing在Animated中的等效函数。Animated.View组件的样式对象是通过useAnimatedStyle函数创建的,然后用作样式属性。 -
没有使用
useNativeDriver属性。 -
我们在动画中更改宽度和高度值,因此布局属性会发生变化,这是使用 React Native 内部 Animated API 所不可能的。
Reanimated 的一个酷特点是,你不必关心原生驱动程序。使用 Reanimated 的每个动画都在 UI 线程上处理。另一个酷特点是,每个样式属性都可以使用。
如果你将此与 Animated API 的限制进行比较,你会立即看到 Reanimated 有多么强大。
要了解这是如何实现的,让我们看看 Reanimated 的架构。
理解 Reanimated 2 的架构
Reanimated 2 基于 animation worklet。在这种情况下,worklet 是在 UI 线程上运行的 JavaScript 函数。Reanimated 2 在 UI 线程上创建了一个第二、非常简约的 JavaScript 环境,用于处理这些动画 worklet。
这意味着它完全独立于 React Native JavaScript 线程和 React Native 桥接运行,这保证了即使是复杂的动画也能获得出色的性能。此 worklet 线程使用新的 React Native 架构。
让我们从了解如何使用 worklet 开始。
开始使用 worklet
让我们看看本章“理解 React Native 中动画的架构挑战”部分的示例。我们有一个根据 ScrollView 的 Y 滚动值调整标题图像大小或移动的动画。以下图显示了使用 Reanimated 2 实现此示例时发生的情况:

图 6.2 – 基于 Reanimated 2 中滚动值的动画
在 Reanimated 2 中,动画作为 JavaScript 线程上的 worklet 创建。但整个动画 worklet 都在 UI 线程上的 worklet 线程中执行。因此,每次接收到新的滚动事件时,它不必跨越桥梁;相反,它直接在工作线程中处理,并将新的动画状态传递回 UI 线程进行渲染。
为了实现这种架构,Reanimated 2 提供了自己的 Babel 插件。此 Babel 插件从 react-native 代码中提取所有标记为 worklet 的函数,并使其在 UI 线程上的单独 worklet 线程中可运行。以下代码示例显示了如何将函数标记为 worklet:
function myWorklet() {
'worklet';
console.log("Hey I'm running on the UI thread");
}
这是一个简单的 JavaScript 函数,在第 2 行包含 worklet 注解。基于这个注解,Reanimated 2 Babel 插件知道它必须处理这个函数。
现在,这可以作为 JavaScript 线程上的标准函数运行,也可以根据调用方式作为 UI 线程上的 worklet 运行。如果函数像 JavaScript 代码中的正常函数一样被调用,它就在 JavaScript 线程上运行;如果使用 Reanimated 2 的 runOnUI 函数调用,它就在 UI 线程上异步运行。
当然,无论在哪里运行,都可以向这些 worklet 函数传递参数。
理解 JavaScript 线程和工作线程之间的联系
理解这种联系对于防止发生许多错误至关重要。本质上,JavaScript 线程和工作线程在完全不同的环境中运行。这意味着在 worklet 中,无法简单地从 JavaScript 线程访问所有内容。当涉及到 worklet 时,以下是一些可能的连接:
-
worklet并使用runOnUI调用。这将在 UI 线程上的 Worklet 上下文中运行函数。传递的每个参数都会复制到 UI 线程上的 Worklet 上下文中。 -
Worklets 可以访问 JavaScript 线程上的常量:Reanimated 2 处理 Worklet 代码,并将使用的常量和它们的值复制到 Worklet 上下文中。这意味着常量也可以在 Worklets 中使用,而无需担心性能下降。
-
Worklets 可以同步调用其他 Worklet 函数:Worklets 可以同步调用其他 Worklet,因为它们在相同的环境中运行。
-
Worklets 可以异步调用非 Worklet 函数:当从 Worklet 内部调用 JavaScript 线程上的函数时,这个调用必须是异步的,因为被调用的函数在另一个环境中运行。
想了解更多关于 Worklet 的信息,可以查看官方文档中的 Worklet 部分,链接为bit.ly/prn-reanimated-worklets。
使用共享值
就像在 React Native 的内部 Animated API 中一样,Reanimated 2 使用动画值来驱动动画。在 Reanimated 2 中,这些动画值被称为共享值。它们被称为共享值,因为可以从 JavaScript 环境(JavaScript 线程和 UI 线程上的 Worklet 上下文)中访问。
由于这些共享值用于驱动动画,而这些动画在 UI 线程上的 Worklet 上下文中运行,因此它们被优化为从 Worklet 上下文中更新和读取。这意味着从 Worklet 中读取和写入共享值是同步的,而从 JavaScript 线程中读取和写入是异步的。
你可以在官方文档中更深入地了解共享值,链接为bit.ly/prn-reanimated-shared-values。
使用 Reanimated 2 钩子和函数
当使用 Reanimated 2 时,大多数情况下不需要创建 Worklet。Reanimated 2 提供了一套优秀的钩子和函数,可以用来创建、运行、更改、中断和取消动画。这些钩子会自动处理将动画执行转移到 Worklet 上下文。
这就是本节开头示例中使用的方法。在那个场景中,我们使用useSharedValue钩子创建了一个共享值,将视图的样式与useAnimatedStyle钩子连接起来,并使用withTiming函数开始动画。
当然,你也可以使用 Reanimated 2 处理滚动值。以下代码示例展示了如何将ScrollView连接到共享值,通过用户滚动来缩放和移动图像的动画:
function App() {
const scrolling = useSharedValue(0);
const scrollHandler = useAnimatedScrollHandler((event) =>
{
scrolling.value = event.contentOffset.y;
});
const imgStyle = useAnimatedStyle(() => {
const interpolatedScale = interpolate(
scrolling.value,[-300, 0],[3, 1],Extrapolate.CLAMP
);
const interpolatedTranslate = interpolate(
scrolling.value,[0, 300],[0, -300],Extrapolate.CLAMP
);
return {
transform: [
{translateY: interpolatedTranslate},
{scaleY: interpolatedScale},
{scaleX: interpolatedScale}
]
};
});
return (
<>
<Animated.Image
source={require('sometitleimage.jpg')}
style={[styles.header,imgStyle]}
/>
<Animated.ScrollView
onScroll={scrollHandler} >
<View style={styles.headerPlaceholder} />
<View style={styles.content} />
</Animated.ScrollView>
</>
);
}
在这个例子中,ScrollView 使用 Reanimated 的 useAnimatedScrollHandler 钩子将 Y 滚动值(内容偏移量)绑定到动画值。然后,这个动画值通过 Reanimated 2 的插值函数进行插值。这是在 useAnimatedStyle 钩子内部完成的。
这种设置使得动画工作,无需将滚动值通过桥接发送到 JavaScript 线程。整个动画在 UI 线程的工作线程中运行。这使得动画性能极高。
当然,Reanimated 2 提供了广泛的其它选项。可以使用基于弹簧的动画、基于速度的动画、延迟或重复动画,以及按顺序运行动画,仅举几例。
由于完整的 Reanimated 2 指南超出了本书的范围,请参阅官方文档(bit.ly/prn-reanimated-docs)和 API 参考(bit.ly/prn-reanimated-api-reference)。
为了完成这一部分,我们将探讨 Reanimated 2 的优缺点。
理解 Reanimated 的优缺点
到目前为止,Reanimated 2 是 React Native 中动画最先进和最完整的解决方案。有很多理由使用 Reanimated 2。以下是最重要的几个原因:
-
易于使用的 API:带有 Hooks 和函数的 Reanimated 2 API 容易学习、阅读和理解。
-
出色的性能:Reanimated 2 的动画在所有设备上运行流畅且性能出色。
-
布局属性的动画:所有样式值都可以用于动画。没有像 Animated API 中的限制。
-
中断、更改和取消动画:在 Reanimated 2 中,动画在运行时可以被中断、更改或取消,而不会导致帧率下降或操作缓慢。
Reanimated 2 是一个非常好的库,但在使用它之前,您应该查看以下缺点:
-
复杂的安装:由于 Reanimated 2 深度干预 React Native 的架构,安装过程相当复杂。您需要对原生代码进行一些修改,并添加 Reanimated 2 Babel 插件。这并不是一个大问题,因为它只需要做一次,但会花费一些时间。当新的架构,包括新的 Fabric 渲染器推出时,这将会改变。
-
Reanimated 2 使您的包更大:虽然内部 Animated API 是 React Native 的一部分,但 Reanimated 2 是一个外部依赖项。这意味着您的包将会增大。
如果您的应用程序有很多动画、更复杂的动画以及/或动画布局属性,我肯定会推荐使用 Reanimated 2。如果您只使用基本的动画,这些动画可以通过内部 Animated API 实现,那么您不需要 Reanimated,可以继续使用 Animated API。
虽然 Reanimated 2、Animated API 以及 react-native-animatable 都有非常相似的方法,但接下来我们将了解的下一个库工作方式完全不同。让我们来看看 Lottie。
在 React Native 中使用 Lottie 动画
Lottie 是在应用和网页开发中处理动画的完全不同的方法。它允许你渲染和控制预构建的矢量动画。以下图示展示了 Lottie 动画创建和播放的过程:


图 6.3 – 使用 Lottie 动画时的流程
本质上,Lottie 包含一个播放器,在 React Native 的情况下是 lottie-react-native 库。这个库期望一个 Lottie 动画的 JSON 文件。这个文件是用 Adobe After Effects(一款专业的动画软件)创建的,并通过 Bodymovin 插件导出为 JSON 格式。
这个过程完全改变了我们在应用中处理动画的方式。开发者不再负责创建动画;他们只需要包含 JSON 文件。当处理非常复杂的动画时,这可以节省大量的时间。
所有这些内容在查看一个简单的 Lottie 动画时都会变得更加清晰。
从一个简单的例子开始
以下代码示例展示了如何使用 Lottie 实现一个加载动画:
import React from 'react';
import { View, StyleSheet } from 'react-native';
import LottieView from 'lottie-react-native';
const App = () => {
return (
<View style={styles.center}>
<LottieView
source={require('loading-animation.json')}
style={styles.animation}
autoPlay/>
</View>
);
};
const styles = StyleSheet.create({
center: {
flex: 1,
alignItems: 'center',
justifyContent: 'center'
},
animation: {
width: 150,
height: 150
}
});
export default App;
无论动画多么复杂,以下代码就是包含加载动画所需的所有代码。LottieView 从 lottie-react-native 库中导入,并放置在动画应该发生的位置。Lottie JSON 文件作为源传递给 LottieView,可以通过样式属性像常规 React Native 视图一样进行样式化。
然而,lottie-react-native 不仅仅是一个简单的播放器。它为你提供了对动画的编程控制。你可以开始和停止动画,在加载时自动播放,并在完成后循环播放。最后一个特性对于加载动画特别有用。
将 Lottie 动画与 React Native Animated API 结合使用
lottie-react-native 的最佳特性是它可以将动画的进度绑定到 React Native Animated API 的 Animated.Value 组件。这为许多不同的用例打开了大门,例如基于 Lottie 的动画运行时间或弹簧动画。你还可以使用缓动或根据用户交互创建 Lottie 动画。
以下代码示例展示了如何创建一个由 Animated.Value 组件驱动的 Lottie 动画,该组件绑定到了 React Native ScrollView 的 Y 滚动值:
const App = () => {
const scrolling = useRef(new Animated.Value(0)).current;
let interpolatedProgress = scrolling.interpolate({
inputRange: [-1000, 0, 1000],
outputRange: [1, 0, 1],
extrapolate: 'clamp',
});
return (
<View style={styles.container}>
<Animated.ScrollView
onScroll={Animated.event(
[{
nativeEvent: {
contentOffset: {
y: scrolling,
},
},
}],
{ useNativeDriver: true },
)}
scrollEventThrottle={16}>
<LottieView
source={require('looper.json')}
style={styles.animation}
progress={interpolatedProgress}/>
</Animated.ScrollView>
</View>
)
}
在这个例子中,ScrollView 的 Y 滚动值绑定到了 onScroll 函数中的 Animated.Value 组件。然后,Animated.Value 组件被插值以获取 interpolatedProgress,其值在 0 和 1 之间。这个 interpolatedProgress 作为进度属性传递给了 LottieView。
Lottie 还支持使用原生驱动程序的 React Native Animated API 动画。这对于性能来说非常重要。关于这方面的更多信息,请参阅本章的 使用 React Native 内部 Animated API 部分。
寻找或创建 Lottie 动画
虽然 Lottie 动画对开发者来说很容易包含,但有人必须创建包含动画的 Lottie JSON 文件。获取 Lottie 动画文件有三种方法:
-
在互联网上寻找 Lottie 文件:有很多有才华的动画艺术家在互联网上分享他们的作品。许多文件是免费的,但也可以购买高级动画内容。开始搜索 Lottie 动画的最佳地方是
lottiefiles.com/。 -
学习使用 After Effects 创建动画:有很多优秀的入门教程,即使一开始看起来令人望而却步,After Effects 仍然是一款出色的软件,用它来创建第一个动画相当简单。如果你对学习 After Effects 感兴趣,可以从 bit.ly/prn-lottie-tutorial 上的教程开始。
-
雇佣一位动画艺术家:在我看来,这是最好的解决方案。一位经验丰富的动画艺术家只需几个小时就能为你的项目创建一系列单独的动画。与动画艺术家合作可以节省时间和金钱,并且当拥有与你的 UI 概念完全匹配的单独动画时,将大大提高你应用的质量。你可以在
lottiefiles.com/上找到并联系动画艺术家。
现在我们已经很好地理解了 React Native 中的 Lottie 动画是如何工作的,让我们来看看其优缺点。
理解 Reanimated 的优缺点
由于 Lottie 方法完全不同,在考虑将 Lottie 作为项目动画解决方案时,你应该牢记其巨大的优缺点。
使用 Lottie 时,以下优点尤为突出:
-
lottie-react-native,无论动画多么复杂,只需几行代码即可集成动画。 -
动画文件比 GIF 或 Sprites 小得多:在处理动画文件时,另一种方法是 GIF 或 Sprites。Lottie 文件比这些解决方案小得多,消耗的内存也少得多。
-
对动画进度的程序控制:与处理 GIF 不同,你可以对动画进行程序控制。你甚至可以将动画进度绑定到 React Native Animated 的动画值。
然而,Lottie 也存在以下缺点:
-
无法完全控制动画:当使用 Lottie 动画时,你可以控制动画的进度,但仅限于进度。你不能像完全脚本化动画那样根据用户交互更改动画路径。
-
lottie-react-native必须包含在应用程序中,同时也需要包含针对原生平台的 Lottie 模块。 -
lottie-react-native将立即与每个新的 React Native 版本兼容。
Lottie 是在 React Native 项目中包含高质量动画的绝佳选择。特别是对于复杂的加载动画、微动画或任何不需要完整程序控制的动画,Lottie 是一个很好的解决方案。
摘要
在本章中,你了解了在 React Native 中进行动画时的总体架构挑战。你了解到有不同解决方案可以克服这一挑战,并创建高质量和性能良好的动画。我们探讨了 Animated、react-native-animatable、Reanimated 和 Lottie,这些都是 React Native 屏幕动画的最佳和最广泛使用的动画解决方案。
这很重要,因为你在应用程序中需要使用动画来创建高质量的产品,而这些动画库是唯一在 React Native 中创建高质量和性能良好的动画的方法。
在下一章中,你将学习如何处理用户手势,以及如何与更复杂的手势一起工作来完成不同的事情——例如,驱动动画。
第七章:在 React Native 中处理手势
使好的应用在众多不良应用或移动网站中脱颖而出的最重要的事情之一就是良好的手势处理。虽然大多数情况下移动网站只监听简单的点击,但应用可以通过不同的手势来控制,例如短按、长按、滑动、捏合缩放或多指触摸。以非常直观的方式使用这些手势是开发应用时需要考虑的最重要的事情之一。
但不仅仅只是监听这些手势 – 你必须立即对用户做出响应,以便他们可以看到(并且可能取消)他们正在做的事情。一些手势需要触发或控制动画,因此必须与我们在第六章中学习到的动画解决方案配合得非常好,即与动画一起工作。
在 React Native 中,有多种处理手势的方法。从简单的内置组件到非常复杂的第三方手势处理解决方案,你有很多不同的选项可以选择。
在本章中,你将学习以下内容:
-
使用内置组件来响应用户手势
-
与 React Native 手势响应系统以及 React Native
PanResponder一起工作 -
理解 React Native 手势处理器
技术要求
要运行本章中的代码,你必须设置以下事项:
-
一个可工作的 React Native 环境 (bit.ly/prn-setup-rn – React Native CLI 快速入门)
-
用于测试手势和多点触控的真实的 iOS 或 Android 设备
要访问本章的代码,请点击以下链接进入本书的 GitHub 仓库:
使用内置组件来响应用户手势
React Native 附带多个具有内置手势响应支持的组件。基本上,这些组件是对手势响应系统的抽象使用,你将在下一节中学习。手势响应系统为处理 React Native 中的手势提供了支持,同时也支持协商哪个组件应该处理用户手势。
最简单的用户交互是用一个手指点击。通过不同的Touchable组件、一个Pressable组件和一个Button组件,React Native 提供了不同的选项来识别点击并响应用户交互。
使用组件来响应简单的点击
记录用户点击操作最简单的组件是 React Native 的Touchable组件。
与Touchable组件一起工作
React Native 在 iOS 上提供了三个不同的Touchable组件,以及一个仅适用于 Android 的额外第四个Touchable组件:
-
TouchableOpacity:通过减少被点击元素(及其所有子元素)的不透明度来自动提供用户反馈,让底层视图透过来。你可以通过设置activeOpacity来配置不透明度的减少。 -
TouchableHighlight: 通过减少不透明度和显示底层颜色来自动提供用户反馈,这会使被点击的元素变暗或变亮。你可以通过设置underlayColor来定义底层颜色,通过设置activeOpacity来定义不透明度的减少。 -
TouchableWithoutFeedback: 不提供用户反馈。只有在你有充分的理由时才应使用此功能,因为每个响应触摸的元素都应该显示视觉反馈。一个可能的原因是你已经在其他地方处理了视觉反馈。 -
TouchableNativeFeedback: 仅适用于 Android。通过触发原生 Android 触摸效果来自动提供用户反馈。在大多数设备上,这是众所周知的 Android 波纹效果,其中组件通过从触摸点扩展一个圆圈来改变颜色。你可以通过设置background属性来定义波纹效果。
所有四个 Touchable 组件都提供了四种方法来监听用户交互。这些方法的调用顺序如下图的顺序:
![Figure 7.1 – The onPress call order
![img/B16694_07_01.jpg]
图 7.1 – onPress 调用顺序
重要的是始终记住,onPress 在 onPressOut 之后被调用,而 onLongPress 在 onPressOut 之前被调用。让我们更详细地看看这些方法:
-
onPressIn: 当用户开始点击按钮时,立即调用此方法。 -
onPressOut: 当用户释放点击或当用户将手指移出组件外部时调用此方法。 -
onPress: 当用户在达到长按延迟(在delayLongPress中定义)之前完成点击时调用此方法。 -
onLongPress: 当达到长按延迟(在delayLongPress中定义)并且在此期间没有释放点击时调用此方法。
使用这些方法,你已可以处理许多不同的用例,并且——永远不要忘记——对用户的触摸提供即时视觉反馈。
虽然 Touchable 组件需要一些自定义样式,但 React Native 还提供了一个 Button 组件,它带有预定义的样式。
使用 Button 组件
在底层,Button 在 iOS 上使用 TouchableOpacity,在 Android 上使用 TouchableNativeFeedback。Button 带有一些预定义的样式,这样你就可以在不自己设置样式的情况下使用它。以下代码示例显示了使用 Button 的简单性:
<Button
onPress={() => Alert.alert("Button pressed!")}
title="Press me!"
color="#f7941e"
/>
你只需定义一个 onPress 方法、一个按钮 标题 和按钮的 颜色。Button 将处理其余部分,例如样式和视觉用户反馈。当然,你也可以使用 Touchable 组件的所有其他方法。
Button 和 Touchable 是 React Native 中相当老旧的组件。由于它们工作良好,你可以在大多数情况下使用它们。但还有一个新的实现来处理用户点击。
使用 Pressable 组件
除了Touchable和Button组件外,React Native 还提供了一个Pressable组件。这是最新的组件,由于其针对特定平台视觉反馈的高级支持,建议使用。
看一下以下代码示例,了解Pressable的优点:
<Pressable
onPress={() => Alert.alert("Button pressed!")}
style={({ pressed }) => [
{
backgroundColor: pressed
? '#f7941e'
: '#ffffff'
},
styles.button
}>
>
{
({ pressed }) => (
<Text style={styles.buttonText}>
{pressed ? 'Button pressed!' : 'Press me!'}
</Text>
)
}
</Pressable>
它提供了与Touchable组件相同的方法,但在 Android 上还有涟漪支持,并在 iOS 上与自定义样式一起工作。你可以提供style属性作为一个函数,并监听pressed状态。
你还可以将一个功能组件作为子组件传递给Pressable组件,并使用那里的pressed状态。这意味着你可以根据它是否被按下改变Pressable组件的样式和内容。
另一个优点是你可以为Pressable组件定义点击和偏移区域:
![图 7.2 – Pressable点击和按下区域
![img/B16694_07_02.jpg]
图 7.2 – Pressable点击和按下区域
在图 7.2中,你可以看到中心可见的Pressable组件。如果你想触摸区域大于可见元素,你可以通过设置hitSlop来实现。这对于重要的按钮或屏幕上重要的可触摸区域来说是一个非常常见的事情。
虽然hitSlop定义了点击开始的位置,但pressRetentionOffset定义了在Pressable组件外部,点击不会停止的额外距离。这意味着当你开始在点击区域内部开始点击并移动你的手指到点击区域外部时,通常onPressOut会被触发,点击手势完成。
但如果你已经定义了一个额外的按下区域,并且你的手势停留在该按下区域内,只要你的手指移动到该按下区域外,则点击手势被视为持续手势。hitSlop和pressRetention可以设置为number值或Rect值,这意味着作为一个具有bottom、left、right和top属性的Object。
点击区域和按下区域都是提高你应用用户体验的绝佳方法,例如,它们可以使用户更容易按下重要的按钮。
在查看简单的点击处理之后,让我们继续看滚动手势。
与ScrollView一起工作
处理滚动手势最简单的方法是 React Native 的ScrollView组件。如果内容比ScrollView本身大,这个组件可以使内容可滚动。ScrollView会自动检测和处理滚动手势。它有很多可配置的选项,所以让我们看看最重要的几个:
-
horizontal: 定义ScrollView应该是水平还是垂直。默认是垂直。 -
decelerationRate:定义用户在滚动时释放触摸时滚动减速的速度。 -
snapToInterval或snapToOffsets:使用这两个方法,你可以定义ScrollView应该停止的间隔或偏移量。这可以极大地改善用户体验,因为滚动视图可以始终停止,以便用户可以看到一个完整的列表元素。 -
scrollEventThrottle仅适用于 iOS:定义在滚动时滚动事件将被触发的频率。这对于性能和用户体验非常重要。对于用户体验来说,最佳值是 16,这意味着滚动事件每 16 毫秒触发一次(直到 RN 支持 120 Hz - 然后,它将变为 8 毫秒)。
根据你对滚动事件的操作,这可能会导致性能问题,因为每次滚动事件都会通过桥接发送(除非你直接通过动画 API 处理,如第六章中所述,与动画一起工作)。因此,考虑你需要在这里设置什么值,并可能将其增加以防止性能问题。
小贴士
还有更多配置选项,例如定义过度滚动效果、粘性头部或弹跳。如果你想有一个完整的概述,请查看文档(bit.ly/prn-scrollview)。由于这不是初学者指南,我们专注于优化应用程序的重要部分。
说到这一点,当然,你可以通过使用ScrollView组件来自行处理滚动事件。这为你提供了优化 UX 的多种选择。ScrollView提供了以下方法:
-
onScroll: 在滚动过程中持续触发。这是一个很好的工具,可以通过将自定义动画与滚动事件结合来添加令人惊叹的用户反馈,就像我们在第六章中做的那样,与动画一起工作。但是,在这样做的时候,你应该要么使用带有本地驱动程序的 Animated API 来防止滚动事件每 16 毫秒传输一次,要么使用scrollEventThrottle来限制事件数量。 -
onScrollBeginDrag: 当用户开始滚动手势时触发。 -
onScrollEndDrag: 当用户停止滚动手势时触发。 -
onMomentumScrollBegin: 当ScrollView开始移动时触发。 -
onMomentumScrollEnd: 当ScrollView停止移动时触发。
使用这五个方法,你可以为用户的滚动手势提供很多不同的反馈。从简单地通知用户他们正在滚动到使用onScroll构建高级动画,一切皆有可能。
注意
当ScrollView有非常长的子元素列表时,它可能会变得相当慢和占用大量内存。这是由于ScrollView一次性渲染所有子元素造成的。如果你需要一个具有元素懒加载的更高效版本,请查看 React Native 的FlatList或SectionList。
在使用内置的 React Native 组件之后,是时候看看如何完全自己处理触摸了。完成这一点的第一个选项是直接与 React Native 手势响应者系统一起工作。
与手势响应者系统和 PanResponder 一起工作
触摸响应者系统是处理 React Native 中手势的基础。所有Touchable组件都基于触摸响应者系统。使用此系统,您不仅可以监听手势,还可以指定哪个组件应该是触摸响应者。
这非常重要,因为在您的屏幕上有多个触摸响应者的情况下(例如,ScrollView中的Slider),存在几种场景。虽然大多数内置组件协商哪个组件应该成为触摸响应者并自行处理用户输入,但在直接与手势响应者系统一起工作时,您必须自己考虑这一点。
触摸响应者系统提供了一个简单的 API,并且可以在任何组件上使用。当与触摸响应者系统一起工作时,您必须做的第一件事是协商哪个组件应该成为处理手势的响应者。
成为响应者
要成为响应者,组件必须实现以下协商方法之一:
-
onStartShouldSetResponder: 如果此方法返回true,组件想要在触摸事件的开始时成为响应者。 -
onMoveShouldSetResponder: 如果此方法返回true,组件想要成为触摸事件的响应者。只要组件不是响应者,就会为每个触摸移动事件调用此方法。
重要提示
这两种方法首先在最深层的节点上调用。这意味着当多个组件实现这些方法并返回true时,最深层的组件将成为触摸事件的响应者。请在手动协商响应者时记住这一点。
您可以通过实现onStartShouldSetResponderCapture或onMoveShouldSetResponderCapture来防止子组件成为响应者。
对于这些响应者协商,如果另一个组件请求,组件释放控制权是很重要的。触摸响应者系统还为此提供了处理程序:
-
onResponderTerminationRequest: 如果此处理程序返回true,当另一个组件想要成为响应者时,组件会释放响应者。 -
onResponseTerminate: 当响应者被释放时,此处理程序会被调用。这可能是由于onResponderTerminationRequest返回true,或者由于操作系统行为。
当组件尝试成为响应者时,协商有两种可能的结果,都可以通过处理程序方法来处理:
-
onResponderGrant: 当它成功成为响应者并随后监听触摸事件时,此处理程序会被调用。最佳实践是使用此方法来突出显示组件,以便用户可以看到响应他们触摸的元素。 -
onResponderReject: 当另一个组件当前是响应者且不会释放控制权时,此处理程序会被调用。
当你的组件成功成为响应者时,你可以使用处理程序来监听触摸事件。
处理触摸
成为响应者后,你可以使用两个处理程序来捕获触摸事件:
-
onResponderMove: 当用户在屏幕上移动手指时,此处理程序会被调用。 -
onResponderRelease: 当用户从设备的屏幕上释放触摸时,此处理程序会被调用。
在处理手势时,你通常使用 onResponderMove 并处理它返回的事件的位置值。当连接位置值时,你可以重新创建用户在屏幕上绘制的路径。然后你可以按你想要的方式对此路径做出响应。
实际上是如何工作的,以下示例展示了:
const CIRCLE_SIZE = 50;
export default (props) => {
const dimensions = useWindowDimensions();
const touch = useRef(
new Animated.ValueXY({
x: dimensions.width / 2 - CIRCLE_SIZE / 2,
y: dimensions.height / 2 - CIRCLE_SIZE / 2
})).current;
return (
<View style={{ flex: 1 }}
onStartShouldSetResponder={() => true}
onResponderMove={(event) => {
touch.setValue({
x: event.nativeEvent.pageX, y: event.nativeEvent.pageY
});
}}
onResponderRelease={() => {
Animated.spring(touch, {
toValue: {
x: dimensions.width / 2 - CIRCLE_SIZE / 2,
y: dimensions.height / 2 - CIRCLE_SIZE / 2
},
useNativeDriver: false
}).start();
}}
>
<Animated.View
style={{
position: 'absolute', backgroundColor: 'blue',
left: touch.x, top: touch.y,
height: CIRCLE_SIZE, width: CIRCLE_SIZE,
borderRadius: CIRCLE_SIZE / 2,
}}
onStartShouldSetResponder={() => false}
/>
</View>
);
};
此示例包含两个 View。外部的 View 作为触摸响应者,而内部的 View 是一个小圆圈,其位置根据用户移动手指的位置而改变。外部 View 实现了手势响应系统处理程序,而内部 View 只是对于 onStartShouldSetResponder 返回 false,以避免成为响应者。
你还可以看到手势响应系统与 React Native Animated 一起是如何工作的。当 onResponerMove 被调用时,我们处理触摸事件并将事件的 pageX 和 pageY 值设置为 Animated.ValueXY。
这是我们用来计算内部 View 位置的值。当用户从设备上移除手指时,onResponderRelease 会被调用,我们使用 Animated.spring 函数将 Animated.ValueXY 值恢复到其起始值。这使内部 View 回到屏幕中间的位置。
以下图像显示了示例中的代码在屏幕上的样子:

图 7.3 – 在 iPhone 上运行的手势响应系统示例
在这里,你可以看到初始状态(左侧屏幕)。然后,用户触摸屏幕的右下角,蓝色圆圈跟随触摸移动(中间屏幕)。当用户释放触摸后,蓝色圆圈会在给定的时间段内从用户最后触摸屏幕的位置返回到屏幕中心(右侧屏幕显示了返回动画中的圆圈)。
即使在这个简单的例子中,你也可以看到手势响应器系统是一个非常强大的工具。你可以完全控制触摸事件,并且可以非常容易地将它们与动画结合。尽管如此,大多数时候你不会直接使用手势响应器系统。这是因为PanResponder,它是在手势响应器系统之上的一层轻量级层。
使用 PanResponder
PanResponder基本上与手势响应器系统的工作方式完全相同。它提供了一个相似的 API;然而,你只需要将Responder替换为PanResponder。例如,onResponderMove变为onPanResponderMove。区别在于你不仅得到原始的触摸事件。PanResponder还提供了一个状态对象,它代表了整个手势的状态。这包括以下属性:
-
stateID: 手势的唯一标识符 -
dx: 自触摸手势开始以来的水平距离 -
dy: 自触摸手势开始以来的垂直距离 -
vx: 触摸手势的当前水平速度 -
vy: 触摸手势的当前垂直速度
当涉及到解释和处理更复杂的手势时,这个状态对象非常有用。因此,大多数库和项目使用PanResponder而不是直接与手势响应器系统交互。
虽然手势响应器系统和PanResponder是响应用户触摸的非常好的选项,但它们也带来了一些缺点。首先,它们与没有原生驱动程序的 Animated API 具有相同的限制。由于触摸事件必须通过桥接传输到 JavaScript 线程,我们总是落后一帧。
这可能随着 JSI 的改进而变得更好,但这一点目前必须得到证明。另一个限制是没有任何 API 允许我们定义任何原生手势处理器的交互。这意味着总会有一些情况,无法通过手势响应器系统 API 解决。
由于这些限制,Software Mansion 团队在 Shopify 和 Expo 的支持下构建了一个新的解决方案——React Native 手势处理器。
理解 React Native 手势处理器
React Native 手势处理器是一个第三方库,它完全取代了内置的手势响应器系统,同时提供了更多的控制和更高的性能。
React Native 手势处理器与 Reanimated 2 结合使用效果最佳,因为它是由同一团队编写的,并依赖于 Reanimated 2 提供的工作 lets。
信息
本书参考的是 React Native 手势处理器 2.0 版本。版本 1 也被许多项目使用。
React Native 手势处理器 2 API 基于GestureDetectors和Gestures。虽然它也支持版本 1 的 API,但我建议使用新的 API,因为它更容易阅读和理解。
让我们创建上一节中的可拖动圆形示例,但这次我们使用 React Native 手势处理器和 Reanimated 2:
const CIRCLE_SIZE = 50;
export default props => {
const dimensions = useWindowDimensions();
const touchX = useSharedValue(dimensions.width/
2-CIRCLE_SIZE/2);
const touchY = useSharedValue(dimensions.height/
2-CIRCLE_SIZE/2);
const animatedStyles = useAnimatedStyle(() => {
return {
left: touchX.value, top: touchY.value,
};
});
const gesture = Gesture.Pan()
.onUpdate(e => {
touchX.value = e.translationX+dimensions.width/
2-CIRCLE_SIZE/2;
touchY.value = e.translationY+dimensions.height/
2-CIRCLE_SIZE/2;
})
.onEnd(() => {
touchX.value = withSpring(dimensions.width/
2-CIRCLE_SIZE/2);
touchY.value = withSpring(dimensions.height/
2-CIRCLE_SIZE/2);
});
return (
<GestureDetector gesture={gesture}>
<Animated.View
style={[
{
position: 'absolute', backgroundColor: 'blue',
width: CIRCLE_SIZE, height: CIRCLE_SIZE,
borderRadius: CIRCLE_SIZE / 2
},
animatedStyles,
]}
/>
</GestureDetector>
);
};
在这个例子中,你可以看到 React Native Gesture Handler 的工作原理。我们创建 GestureDetector 并将其包裹在代表触摸手势目标的元素周围。然后,我们创建一个 Gesture 并将其分配给 GestureDetector。在这个例子中,这是一个 Pan 手势,意味着它识别屏幕上的拖动。Gesture.Pan 提供了许多不同的处理程序。在这个例子中,我们使用了两个:
-
onUpdate:每次任何手势位置更新时,此处理程序都会被调用 -
onEnd:当手势释放时,此处理程序被调用
我们使用 onUpdate 来改变 Reanimated 的 sharedValue 值,并使用 onEnd 来将 sharedValue 重置到初始状态。
然后,我们使用 sharedValue 来创建 animatedStyle,并将其分配给我们的 Animated.View,即我们的圆形。
屏幕上的结果与上一节相同,但这里有两个重要的优势:
-
更好的性能:由于我们使用了 Reanimated 2 worklets,我们的值和计算不需要通过桥接。手势输入和动画完全在 UI 线程上计算。
-
Race)或者是否可以在同一时间激活多个手势(Simultaneous)。
除了这些,React Native Gesture Handler 包含了许多不同的手势,例如 Tap(点击)、Rotation(旋转)、Pinch(捏合)、Fling(抛掷)或 ForceTouch(强触),以及内置组件如 Button(按钮)、Swipeable(可滑动)、Touchable(可触摸)或 DrawerLayout(抽屉布局),这使得它成为内置手势响应系统的优秀替代品。
如果你想深入了解 React Native Gesture Handler 所提供的所有可能选项,请查看文档:bit.ly/prn-gesture-handler。
摘要
在本章中,我们学习了 React Native 的内置组件以及处理用户手势的解决方案。从简单的点击手势到更复杂的手势,React Native 提供了稳定的解决方案来处理手势。我们还了解了 React Native Gesture Handler,这是一个针对这些内置解决方案的优秀第三方替代品。
我建议在所有可以坚持使用标准组件的使用场景中,使用 React Native 的内置组件和解决方案。一旦你开始编写自己的手势处理,我建议使用 React Native Gesture Handler。
在动画和手势处理之后,我们将继续探讨另一个在性能方面非常重要的主题。
在下一章中,你将了解不同的 JavaScript 引擎是什么,React Native 中有哪些选项,以及不同的引擎对性能和其他重要关键指标的影响。
第八章:JavaScript 引擎和 Hermes
React Native 运行在 JavaScript 上,如第二章中所述,理解 JavaScript 和 TypeScript 的基本知识,JavaScript 需要一个 JavaScript 引擎来解释和/或将其转换为可执行的机器代码。对于 React Native 来说,这没有例外。
尽管市面上有相当多的不同 JavaScript 引擎,但在 React Native 项目中只有少数被使用。这是因为改变 JavaScript 引擎的过程相当复杂,以及新的 Hermes 引擎,这是一个为 React Native 开发的引擎,很快将成为默认引擎。尽管如此,了解不同可能的引擎及其优缺点仍然很重要和有帮助。
在本章的理论部分,我们将涵盖以下主题:
-
理解 JavaScript 引擎
-
了解 Hermes 引擎
-
比较关键指标
技术要求
由于这是一个理论章节,你不需要设置任何东西。
理解 JavaScript 引擎
如本章引言所述,JavaScript 引擎负责解释 JavaScript 并将其/转换为机器代码,以便设备可以执行它。
最初的 JavaScript 引擎是简单的解释器,它们只是处理语句并确保执行。代码就像它被编写的那样执行。这已经发生了很大变化。
现代 JS 引擎提供了许多优化功能。最被讨论的是即时编译(JIT),这是所有现代 JS 引擎都实现的。
编译型语言,如 C 语言,在代码执行前进行编译。在这个编译步骤中,不仅将代码转换为机器语言,还包括许多优化步骤。这产生了一个性能极优的输出。
即时编译意味着代码在运行时进行编译。这意味着即时编译器在编译时并不知道所有代码。这使得代码优化变得更加困难。即时编译器包含两个组件——分析器和编译器。当 JS 代码由解释器执行时,分析器会关注不同语句执行的频率。
一个语句执行得越频繁,它从分析器那里得到的优先级就越高。当达到某个阈值时,分析器将这些代码语句发送给编译器,编译器然后将这些语句编译为字节码。当该语句下次要执行时,它将通过一个高度优化的字节码解释器执行。这使得这些部分运行得更快。
在编译过程中还可以进行一些优化。这很大程度上取决于实现,每个现代 JS 引擎都有自己的即时编译器实现。
通常,即时编译对运行时间较长的代码效果更好,因为编译器有更多时间来学习如何优化。由于在运行 React Native 应用时执行了大量的 JS 代码,即时编译效果极佳。
目前最知名的 JS 引擎是 JavaScriptCore 和 V8。由于两者都可以用于 React Native,我们将更深入地探讨它们。
使用 JavaScriptCore
JavaScriptCore 是为 Safari 浏览器提供动力的 JS 引擎。它是随 React Native 一起提供的默认引擎。如果你创建一个新的空白项目,JavaScriptCore 将解释并执行你的 JS 代码。
使用 V8
V8 是一个开源的 JS 引擎,得到了谷歌的大力支持。当你使用 React Native 的远程调试功能时,默认使用 V8。在这种情况下,你的 JS 代码将在由 V8 驱动的 Chrome 浏览器中执行。
重要提示
请始终记住,当你在远程调试开启/关闭时,你正在使用不同的 JS 引擎。在没有远程调试的情况下,你的 JS 代码在设备或模拟器上运行;当远程调试激活时,你的 JS 代码在计算机上的 Chrome 中运行,并通过 WebSocket 与本地进行通信。即使这两个引擎的行为应该相当相似,也有一些不一致之处。因此,在发布你的应用之前,始终在没有远程调试的情况下进行测试。
还有一个项目为 React Native 提供了将 V8 作为主要 JS 引擎的支持。对于 Android 来说这并不是什么大问题,因为它只是用 V8 引擎替换了 Android JS 引擎的 JavaScriptCore。在 iOS 上则更为复杂,因为 JavaScriptCore 在 iOS 上可用,无需将其包含在应用包中。因此,你不仅需要使用可用的 JS 引擎,还必须在你的应用中捆绑 V8 引擎。这会使你的应用包大小增加高达 7 MB,具体取决于你使用的版本。你可以在 react-native-v8 项目中找到更多关于此的信息:bit.ly/prn-rn-v8。
虽然这两个引擎都能正常工作,但 Facebook 开始了一个名为 Hermes 的项目,以开发他们自己的 React Native JS 引擎。由于 React Native 的用例与浏览器引擎有很大不同,因为代码在构建时是可用的,并且在发布后无法更改;因此,有更多的优化空间。
了解 Hermes 引擎
Hermes 是在 2019 年的 React Native EU 大会上引入 React Native 社区的。当时,它已经在 Facebook 的应用中投入生产超过一年。它是完全以移动为中心构建的,这完全改变了架构方法。以下图显示了现代 JS 引擎的工作方式。

在创建和构建 JavaScript 代码时,通常会有一些向后兼容的 JS 代码的转换编译和一些 JS 代码的压缩。然后,这个压缩后的 JS 包被发送到设备并执行。JavaScript 引擎如 JavaScriptCore 或 V8 会尝试使用即时编译来优化执行,正如之前所描述的,这是一个相当复杂的过程,可能会存储和优化错误的代码语句。Hermes 完全改变了这种方式。
以下图显示了 Hermes 中优化和编译的执行方式:

]
图 8.2 – Hermes 管道(灵感来源于 Tzvetan Mikov)
因为我们知道所有代码,我们希望在 React Native 应用中打包,所以可以在构建过程中进行编译和优化。这意味着所有优化都是在您的计算机(或您的 CI 环境中)进行的,而不是在用户的设备上。Hermes 使用一种所谓的内部代码表示,这种表示对代码优化进行了高度优化。
优化代码后,它被编译成优化的字节码。因此,当使用 Hermes 时,您不再发送 JavaScript,而是发送优化的字节码。这种字节码只需要在用户的设备上由 Hermes 引擎加载和执行。
这种方法带来了许多好处。其中最重要的如下:
-
无需预热:我们不需要花费时间在即时编译器预热上。
-
即时编译器输出的内存使用量为零:我们不需要为即时编译器的输出占用任何内存。这大大减少了内存占用。
-
启动优化:一些在启动时由 JS 引擎执行的运算可以预先计算。这使得应用程序的启动速度大大提高。
-
更小的包大小:优化后的包比压缩后的 JavaScript 代码更小。
由于这种方法的好处,Hermes 被推动尽快成为 React Native 的默认 JS 引擎。在撰写本文时,您仍然需要激活它,但操作非常简单:
-
在
android/app/build.gradle文件中将enableHermes从false改为true。之后,您必须清理并重新构建您的应用程序。 -
在
ios/Podfile文件中将:hermes_enabled => false改为:hermes_enabled => true。使用cd ios && pod install重新安装您的 pods。
请注意,当使用 Hermes 时,远程调试功能的工作方式与之前不同。由于方法完全不同,没有可以直接在您的 Chrome 浏览器中运行的包。尽管如此,Hermes 支持使用 Chrome 检查器协议和 Chrome 开发者工具进行调试。
要使用远程调试,您必须通过 Metro 将您的 Chrome 浏览器连接到正在运行的设备。这可以通过以下方式完成:
-
在您的 Chrome 浏览器中转到
chrome://inspect/#devices。 -
点击
配置…按钮,并添加 Metro 服务器地址(通常是localhost:8081)。 -
现在,有一个
Hermes React Native目标,您可以进行检查。
更多信息,请访问 React Native 的 Hermes 文档(bit.ly/prn-hermes)或 Hermes 引擎本身的文档(bit.ly/prn-hermes-engine)。
如前所述,Hermes 方法给 React Native 带来了很多好处。这也在关键指标中得到了反映,我们将在下一节中查看这些指标。
比较关键指标
当涉及到移动应用时,在优化您的应用时,您应该查看以下几个指标。
理解重要指标
移动设备上最重要的关键指标如下:
-
交互时间(TTI):这是用户点击您的应用图标到用户可以使用您的应用之间的时间。尽可能减少 TTI 非常重要,因为移动应用用户非常没有耐心。交互时间越长,用户就越有可能在不使用您应用的情况下离开。
-
应用大小:这是用户必须从商店下载以安装您的应用的大小。应用大小越大,用户就越不愿意下载您的应用。这可能有多种原因,例如某些国家的高传输成本或用户设备上剩余的磁盘空间。事实是,您的应用越小,用户就越有可能下载它。
-
内存利用率:这个指标描述了您的应用在执行过程中消耗了多少内存。如果您的应用非常耗内存,可能会导致问题,尤其是在旧设备或多任务处理期间。此外,它可能导致操作系统关闭您的应用。您的应用消耗的内存越少,越好。
在查看这些指标时,有一些基准结果公开可用。由于 JavaScriptCore 和 V8 提供的结果大多相似(V8 在大多数测试中略好),我们将重点关注 React Native 应用中使用的 JavaScriptCore 和 Hermes 的比较。
在 Android 上比较 JavaScriptCore 和 Hermes
以下测试比较了 Android 上 JSC 和 Hermes 的关键指标。这次测试是由 Facebook 的 Hermes 团队使用 Hermes 的一个非常早期版本进行的:
| JSC | Hermes | |||
|---|---|---|---|---|
| 交互时间 | 4.30s | 2.01s | -2.29s | -53% |
| 应用大小 | 41MB | 22MB | -19MB | -46% |
| 内存利用率 | 185MB | 136MB | -49MB | -26% |
图 8.3 – Facebook JSC/Hermes 在 Android 上的测试(https://bit.ly/prn-hermes-test-fb)
另一次由备受尊敬的 React Native 社区成员 Kudo Chien 进行的测试运行也包含了 TTI。这次测试使用了不同的套件大小:
| JSC | Hermes | *毫秒 | ||
|---|---|---|---|---|
| TTI 3MB 套件 | 400 | 240 | 160 | -40% |
| TTI 10MB 套件 | 584 | 305 | 279 | -48% |
| TTI 15MB 套件 | 694 | 342 | 352 | -51% |
图 8.4 – Kudo Chien 在 Android 上的 TTI 测试(https://bit.ly/prn-hermes-test-kudo)
如果你查看测试结果,它们在 Android 上非常显著。所有测试中的交互时间都减少了大约 50%。这是一个真正的变革。与真正的原生或 Flutter 应用程序相比,React Native 应用程序过去打开速度较慢。这是由于在渲染第一个屏幕之前需要初始化 JS 引擎。Hermes 在 React Native 这个领域是一个巨大的进步。
当查看 Facebook 的测试时,应用程序大小也减少了近 50%。这部分原因是因为我们不再需要将 JavaScriptCore 引擎打包到我们的应用程序中,因此这种效果将在大型应用程序中减少。但即使在大型应用程序中,你也可以期待大约 30%的包大小节省。
现在让我们看看内存使用情况。在 Facebook 的测试中,Hermes 实现了大约 25%的内存节省。这主要是因为不需要即时编译,这也是一个巨大的成就。
再次强调,这些测试是在 Hermes 的非常早期版本上运行的,因此你可以期待未来有更大的提升。
虽然在 Android 上的结果非常清晰,但让我们继续在 iOS 上进行测试。
在 iOS 上比较 JSC 和 Hermes
在 iOS 上,我们必须记住 JavaScriptCore 是由操作系统提供的。这意味着当我们使用 JSC 时,我们不需要将任何 JavaScript 引擎打包到我们的应用程序中。此外,JavaScriptCore 针对 iOS 和苹果产品进行了优化。iOS 上 Hermes 的实现是由Callstack公司完成的,这是一家为 React Native 做出了大量贡献的公司。完成实现后,Callstack 团队还进行了一些测试,以比较 JSC 和 Hermes。以下是结果:
| JSC | Hermes | *以毫秒为单位 | ||
|---|---|---|---|---|
| 交互时间 | 920ms | 570ms | -350ms | -38% |
| 应用程序大小 | 10.6MB | 13MB | 2.4MB | 18% |
| 内存使用量 | 216MB | 178MB | -38MB | -18% |
图 8.5 – iOS 上 JSC/Hermes 调用栈测试(https://bit.ly/prn-hermes-test-ios)
与 Android 一样,交互时间和内存使用量都有很大提升。这些值略低于 Android,但这可以归因于 iOS 上 JSC 的更好优化。iOS 上的应用程序大小增加了,这似乎是合乎逻辑的,因此我们现在必须将 Hermes 添加到我们的包中,而 JSC 则由操作系统提供。
但是,当你的应用程序的 JavaScript 包增长时,由于 Hermes 的字节码比基于 JSC 的包中分发的压缩 JS 代码更小,这种效果将会减少。
摘要
在本章中,我们了解了 JavaScript 引擎的一般情况,学习了 React Native 对 JavaScript 引擎的特殊要求,我们可以在 React Native 中使用的不同引擎,以及如何更改我们的 React Native 项目的 JS 引擎。然后我们了解了 Hermes,这是一个考虑到移动设备和 React Native(尤其是 React Native)而开发的 JavaScript 引擎。
在理解了 Hermes 的方法和其优势之后,我们比较了在 JavaScriptCore、V8 和 Hermes 上运行的应用程序的关键指标。虽然使用 JSC 或 V8 没有太大差异,但 Hermes 在 TTI(触摸到文本显示时间)和内存利用率方面给 React Native 带来了巨大的提升。
在掌握 JavaScript 引擎之后,我们将在下一章中查看在处理 React Native 时有用的工具。
第九章:提高 React Native 开发的基本工具
React Native是一个拥有非常强大的开发者社区的框架。在过去的一年里,大量工具和库经历了进化式增长,使得 React Native 应用的开发变得更加容易和舒适。
除了专门为 React Native 开发的工具和库之外,你还可以在纯 React 生态系统中使用很多东西。这是因为这些大多数东西都与任何 React Native 应用的 JavaScript/React 部分兼容。
了解最佳工具和库以及如何使用它们非常有用,因为它可以节省你大量时间,并大大提高你的代码和产品的质量。
尤其是在你从事更大项目时,一些工具是绝对必需的,以确保在大团队中的良好协作。
在本章中,你将了解以下主题:
-
如何使用类型安全、代码检查器和代码格式化工具提高代码质量
-
为什么以及何时应该使用样板解决方案,以及如何利用它们
-
如何寻找和使用高质量的 UI 库
-
为什么以及何时应该使用 Storybook,以及如何使用它
技术要求
要运行本章中的代码,你必须设置以下内容:
- 一个有效的 React Native 环境(
reactnative.dev/docs/environment-setup) – React Native CLI 快速入门
使用类型安全、代码检查器和代码格式化工具提高代码质量
如同在第二章,理解 JavaScript 和 TypeScript 的基本知识中已提到的,在大项目中使用类型化的 JavaScript 并配合一些工具确保一定程度的代码质量是必要的。
在下一节中,你将学习如何做到这一点。让我们从使用 TypeScript 或 Flow 进行类型安全开始。
使用 TypeScript 或 Flow 确保类型安全
类型安全在大多数编程语言中是标准,例如 Java 或 C#,这有很好的理由。相比之下,JavaScript 是动态类型的。这是因为 JavaScript 的历史。记住,JavaScript 最初被创建为一种脚本语言,用于快速编写小块代码。在这种情况下,动态类型是可行的,但当项目增长时,具有所有优点的静态类型是必不可少的。
使用类型化的 JavaScript 在开始创建类型时会产生一些开销,但它最终会给你带来很多优势。此外,如今,大多数库都附带定义好的类型,你可以直接使用。
在第二章,理解 JavaScript 和 TypeScript 的基本知识,你已经学习了如何使用和编写 TypeScript。本小节重点介绍 TypeScript 的优势以及在使用它时可以预防的错误。
动态类型可能导致严重且难以发现的错误
让我们从现实世界的一个例子开始这个部分,这是我在一个项目中的经历。在处理一个 React Native 项目时,我们没有使用静态类型化的 JavaScript。我们从远程数据库(Google Firebase)中通过唯一 ID 获取问题,并将它们本地存储在设备上(AsyncStorage)。
根据问题的 ID,我们还存储了用户答案,并在应用中将问题标记为已回答。更新后,所有答案似乎都从用户的设备上消失了,没有人知道为什么。结果是更新将唯一的 ID 从number改为string,这使得存储的用户答案与问题之间的比较失败。
调试这个错误非常困难,因为它在用应用更新的版本创建答案时不会发生。只有在用应用的老版本回答问题时才会发生;随后,应用被更新,问题被同步。
此外,错误从未抛出错误消息。它只是默默地发生了。因此,找到并修复这个错误花了一些时间。这只是一个例子,说明了由于动态类型而发生的错误,以及为什么处理这些错误很困难。它们可能导致直接注意到的严重错误,但在很多情况下,它们不会。
这在应用开发中尤其严重,因为你需要在用户的设备上存储大量数据。当你没有意识到你的数据类型有问题时,这可能导致数百万个不同设备上的数据损坏,这很难识别、调试和修复。
大多数这些错误可以通过使用 TypeScript 或 Flow 进行静态类型检查来预防。
重要提示
当使用 TypeScript 或 Flow 时,不要使用any或Object来使你的类型编写更容易。类型检查及其所有优势只有在整个项目中使用时才能真正发挥作用。因此,你应该明确地为所有属性添加类型。
带有类型检查的 JavaScript 不仅可以防止错误,还可以提高你的生产力。
通过代码补全增强你的 IDE
当你有静态定义的类型时,你的 IDE 很容易帮助你进行代码补全。大多数现代 IDE,如 Visual Studio Code 或 JetBrains WebStorm,对 TypeScript 和 Flow 都有出色的支持。
虽然 WebStorm 为 TypeScript 和 Flow 提供了大部分内置支持,但 VS Code 有很多有用的插件。特别是当使用 Flow 时,你必须安装一个扩展来确保代码补全和代码导航能正确工作。为此,请转到Flow Language Support。
此外,我建议在每次提交时通过你的 CI 管道运行类型检查。你可以在第十一章 创建和自动化工作流程 中了解更多关于这个内容。
虽然类型化 JavaScript 阻止了许多错误并提高了生产力,但还有许多其他领域可以防止错误发生。其中大部分都由代码检查工具覆盖。在下一节中,你将了解它们是什么以及它们是如何工作的。
使用代码检查工具消除最常见的错误
Linters 是一种监控你的代码并强制执行某些规则的工具。当涉及到 JavaScript/TypeScript 时,ESLint 无疑是市场上最流行和最成熟的代码检查工具,因此本小节将重点关注 ESLint。它通过将你的代码与预定义的规则集进行对比来分析你的代码并找出问题。
这些问题可能是错误、非高效代码,甚至是代码风格错误。我建议使用 ESLint,因为它免费且可以确保一定程度的代码质量。
如果你使用 React Native CLI 来设置你的项目,你会发现 ESLint 已经预安装并带有工作规则集。如果你想将其添加到现有项目中,你可以使用以下命令进行安装:要么使用 npm install --save-dev eslint,要么使用 yarn add --dev eslint。在下一步中,你必须设置一个配置。这可以通过 npm init @eslint/config 或 yarn create @eslint/config 命令自动完成。
现在,你可以使用 npx eslint file.js 或 yarn run eslint file.js 来使用 ESLint 检查你的代码与你的规则集。ESLint 还提供了一个 --fix 选项,它自动尝试修复尽可能多的错误。
你还可以将 ESLint 集成到大多数现代 IDE 中,以突出显示并自动修复 ESLint 发现的问题。我建议这样做。
此外,我建议在 CI 流程中每次提交时都运行 ESLint 检查。你可以在第十一章 创建和自动化工作流程中了解更多相关信息。
ESLint 是一个寻找常见错误的优秀工具,尽管它也支持代码风格规则,但在这个领域还有另一个工具做得更好。
使用 prettier 强制执行常见的代码风格
Prettier 是一个在 2016 年创建的代码格式化工具。本质上,它根据一组规则自动重写你的代码。这确保了它遵循标准,并为整个项目开发团队强制执行统一的代码风格。
要使用 prettier,你可以简单地使用以下命令将其作为开发依赖项安装。要么使用 npm install --save-dev prettier,要么使用 yarn add --dev prettier。
将 prettier 与 ESLint 等代码检查工具集成可能会有些挑战。这是因为——正如你在上一个子节中学到的——这些代码检查工具也有格式化代码的规则。当你同时使用它们并指定了冲突的规则时,这不会起作用。幸运的是,prettier 随附有 ESLint 的预配置,可以防止这种情况发生。你可以从 prettier 主页下载它们。
安装完成后,您可以从命令行运行 prettier。要检查您的代码格式是否符合 prettier 规则,您可以使用 prettier 命令,后跟您想要检查的文件或文件夹的路径。在实践中,您通常希望 prettier 自动格式化您的文件。这可以通过 prettier --write 后跟文件或文件夹的路径来实现。
重要提示
您可以使用 .prettierignore 文件来排除文件不被 prettier 重新编写。您应该使用此文件来防止非您编写的文件、配置文件或其他文件的重新编写。
Prettier 为您的项目带来了很多价值,您不会希望在没有它的前提下进行开发,尤其是在您不是单独工作的时侯。使用 prettier 的最重要的优势如下列所示:
-
更易于代码审查:在进行代码审查时,大多数编辑器会突出显示已做的更改。到目前为止,代码审查中最令人烦恼的事情是当开发者有另一个自动格式化设置时,导致所有代码都被标记为已更改以供审查。虽然这完全合理,因为所有代码都因自动格式化而更改,但它使得审查过程变得更加困难。这需要更多时间,并使审查更容易出错。Prettier 通过强制执行统一的代码风格来防止这种情况。
-
更易于代码可读性:当您向团队添加开发者时,代码可读性是一个重要因素。代码的可读性越容易,新开发者成为您团队的有生产力成员所需的时间就越少。Prettier 保证统一的代码风格,这使得代码更容易阅读和理解。
Prettier 作为命令行工具和所有常见 IDE 的 IDE 扩展/插件提供。为了确保它被使用,您应该在项目的以下部分包含它:
-
IDE:所有开发者都应该将 prettier 添加到他们的 IDE 中,并配置他们的自动格式化快捷键以使用 prettier。
-
提交前:提交前钩子应确保 prettier 不会抛出任何错误。
-
CI/CD:在创建拉取请求/合并请求时,应该运行 prettier 以确保手动审查可以高效进行。您可以在第十一章中了解更多信息,创建和自动化工作流程。
如果您使用 prettier 实施此过程,从长远来看,您将节省大量时间。
因此,您在处理 React Native 项目时了解了最重要的工具。现在您将了解一些工具来成功启动新的 React Native 项目。市场上有不同的开源 样板解决方案,它们都有各自的优势。样板解决方案意味着您可以使用它作为模板开始,或者是一个 CLI 工具来生成您的起始项目。
使用样板解决方案
模板化解决方案使得设置具有稳固架构的项目变得容易。这非常有帮助,但你应该意识到这些模板化解决方案带来的权衡。此外,你应该确切地知道你想要什么,因为外面有完全不同的解决方案。
首先,在这个上下文中,模板化解决方案是指所有为你生成代码以开始项目而无需自己配置一切的东西。这可以是任何东西,从具有内置 TypeScript 支持但无其他功能的简单模板到提供导航、状态管理、字体、动画、连接等解决方案的完整 CLI 解决方案,例如 Infinite Red 的 Ignite CLI。
因为模板化解决方案包含的内容范围很广,很难对它们做出一般性的假设。尽管如此,可以说的是,模板化解决方案包含的内容越多,其中任何东西出现问题的风险就越大。因此,在本节中,你将了解最常见的模板,每个解决方案的优点、权衡以及如何使用它们。
使用 React Native TypeScript 模板
React Native 集成了模板引擎。当你使用 React Native CLI 来设置你的项目时,你可以使用一个 template 标志。这就是你可以使用 React Native TypeScript 模板的方式:
npx react-native init App
--template react-native-template-typescript
这个模板没有提供导航、状态管理或其他任何解决方案。它是普通的 React Native Starter 模板,但支持 TypeScript。我非常喜欢它,因为它非常简单,几乎没有依赖,并允许你决定你需要什么,同时为你完成所有 TypeScript 编译器配置。
优点包括以下内容:
-
TypeScript 支持
-
无不必要的依赖
-
易于维护
权衡包括以下内容:
- 无
虽然 React Native TypeScript 模板在开始新项目时使用起来不费吹灰之力,但以下模板化解决方案并不容易决定。这是因为它们附带更多的库。
使用 by thecodingmachine 的 React Native 模板
这个模板也使用了内置的 React Native 模板引擎来工作。但与 React Native TypeScript 模板相比,它已经为你做了很多决定。它包含了 Redux、Redux Persist 和用于状态管理的 redux toolkit、用于 API 调用的 Axios、React Navigation 和 Flipper 集成。此外,它为你的项目创建了一个良好的目录结构。你可以使用以下调用创建基于此模板的项目:
npx react-native init MyApp
--template @thecodingmachine/react-native-boilerplate
因为这个模板包含了很多预定义的库,你应该查看它是否是积极维护的并且最近有更新。否则,你可能会开始使用所有库的非常旧版本,这可能会很快需要耗时的更新。
优点包括以下内容:
-
TypeScript 支持
-
良好的库
-
良好的项目结构
权衡包括以下内容:
-
它使用 Redux 进行状态管理,因此你可能不得不坚持使用它
-
在撰写本文时,它已经落后于最新的 React Native 发布版三个版本,因此你将错过最新的功能和错误修复。
关于此样板更详细的信息,请访问官方文档 thecodingmachine.github.io/react-native-boilerplate/。
虽然这些都是好的解决方案,但你应该查看它们是否真的适合你的项目。下一个模板带有略微不同的配置。
使用 mcnamee 的 React Native Starter Kit
这个样板不使用任何模板引擎或 CLI。它只是一个你可以下载或克隆并开始的 GitHub 仓库。此外,它还附带了一个有用的结构,并引入了许多库。
它使用 Redux 和 Rematch 进行状态管理,React Native Router Flux 进行导航,并且还附带 Native Base 作为 UI 库和 Fastlane 进行部署。基本上,它为你提供了在数小时内将第一个结果交付所需的一切。
但再次提醒,请查看模板的维护情况。在撰写本文时,React Native Router Flux 的最后一个发布版本已经超过一年,这意味着模板的一个核心库基本上是不可用的。
优点包括以下内容:
-
良好的项目结构
-
添加了所有启动所需的内容
权衡包括以下内容:
-
它使用 Redux 进行状态管理,因此你可能不得不坚持使用它
-
它使用 Native Base 作为 UI 工具包,因此你可能不得不坚持使用它
-
它有一个过时的导航库,因此你将遇到与 React Native 最新版本的问题
你可以从官方 GitHub 页面 github.com/mcnamee/react-native-starter-kit 获取更多关于此模板的信息。
在查看两个样板模板后,我们将探讨两个真正广泛的 CLI 工具来设置你的项目。
使用 Ignite CLI 进行工作
Ignite 是由 Infinite Red 开发和维护的一个样板解决方案,Infinite Red 是一家出色的 React Native 公司,致力于出色的开源工作。它远不止是一个简单的模板。它是一个完整的 CLI,可以替换内置的 React Native init 命令。
使用以下命令,你可以创建一个新的应用程序:
npx ignite-cli new YourAppName
这创建了一个具有良好文件夹结构的应用程序,使用 React Navigation 进行导航,使用 MobX-State-Tree 进行状态管理,使用 apisauce 进行 API 调用,当然,还有 TypeScript 支持。除此之外,你的项目还自动支持 Flipper 和 Reactotron 进行调试,Detox 进行端到端测试,以及 Expo,包括 Expo web。
在所有这些之上,Ignite CLI 还带有一个名为生成器的功能。使用这些生成器,你可以通过 Ignite CLI 生成你的模型、组件、屏幕和导航器。这意味着你可以根据需要自定义项目,而无需从头开始编写这些文件。如果你想创建一个新的组件,可以使用以下命令:
npx ignite-cli generate component MyNewComponent
此命令基于存储在ignite/templates文件夹中的模板创建组件,该模板是用你的项目创建的。
小贴士
当使用 Ignite 生成器工作时,你可以编辑用于生成文件的模板。只需编辑ignite/templates中的模板,生成的文件将包含你的更改。这意味着你可以根据需要和标准调整模板,然后使用生成器确保每个人都遵守这些标准。
虽然这种设置非常适合专业项目,但它内置了许多库决策。特别是,对于状态管理,你可能想看看 MobX-State-Tree。这是一个很好的状态管理解决方案,但不像 Redux 或 React Context 那样受欢迎,这意味着社区支持相当有限。
优点包括以下内容:
-
良好的项目结构
-
良好的调试集成
-
Detox 端到端测试集成
-
本地化集成
-
生成器
权衡包括以下内容:
-
它使用 MobX-State-Tree 进行状态管理,这不像 Redux 或 React Context 那样受欢迎。
-
它自带 Expo 集成。这将增加你的应用包大小,并添加另一个依赖项。
-
它为小型项目增加了很多开销
想要了解更多关于 Ignite 的信息,请访问 GitHub 页面github.com/infinitered/ignite。
现在,你了解了不同的样板解决方案及其优缺点。即使你不使用样板解决方案来创建你的项目,我也建议你看看它们创建的结构。这种结构是你可以在此基础上构建的。
在查看这些样板解决方案之后,接下来我们将关注 UI 部分。同时,也有很多有用的开源解决方案,这将使你的生活变得更加轻松。
寻找和使用高质量的 UI 库
UI 库为最常见的用例提供预定义的 UI。你可以为你的项目使用很多不同的 UI 库。但有些比其他的好。本节不仅列出了最受欢迎的库,还为你提供了在进行自己的研究时必须考虑的一些想法。
一个好的 UI 库应该满足以下标准:
-
维护良好:与所有库一样,它必须得到良好的维护。这意味着有多个贡献者,代码质量良好,并且有定期的发布。这对于确保 React Native 的未来版本升级得到支持非常重要。
-
基于组件:一个 React Native UI 库应该提供一组组件,你可以直接使用。
-
主题:库应该包含主题选项,并且易于适应你的颜色、字体、填充和边距。
-
类型声明:一个好的 UI 库应该为组件和主题提供类型声明。
市面上有很多不同的 UI 库。在接下来的子节中,我将向你介绍其中两个,但鉴于它们可能并不完全适合你的项目,请在使用它们之前,根据这里提到的标准进行自己的研究。
使用 React Native Paper
React Native Paper 是一个基于 Material Design 的 UI 库。它由 Callstack 创建和维护,Callstack 是一家专注于 React Native 核心的公司,因此这些人员非常了解他们的工作。这意味着该库在代码质量方面设定了非常高的标准。
React Native Paper 符合之前子节中定义的所有标准。以下是在 React Native Paper 中包含的功能:
-
出色的主题支持:Paper 内置了主题支持。你可以轻松地更改和扩展默认主题,并在你的应用中到处使用它们。
-
类型声明:所有组件和主题都附带类型声明。
-
react-native-vector-icons和MaterialCommunityIcons为你提供图标。 -
超过 30 个预构建组件:所有组件都高度可定制且易于使用。
-
Appbar作为 React Navigation 中的自定义导航栏。
虽然从技术角度来看,React Native Paper 可能是市面上最好的 UI 库,但你必须记住,它完全基于 Google 的 Material Design。这意味着你可能不希望在 iOS 上使用它,因为它会使你的应用看起来与 iOS 标准不同。
如需了解更多关于 React Native Paper、其安装和使用的详细信息,请访问官方文档:callstack.github.io/react-native-paper/。
另一个高质量的 UI 库是 NativeBase。在下一个子节中,你将了解这个库。
使用 NativeBase
NativeBase 是一个适用于 React Native 以及纯 React 的 UI 库。这意味着它不仅适用于你的 iOS 和 Android 应用,如果你有,它也适用于你的 Web 应用。对于需要 Android、iOS 和 Web 支持的产品,这非常有用,因为你可以主要使用相同的代码库为所有平台编写代码。
此外,NativeBase 符合本节第一子节中定义的所有标准。以下是在 NativeBase 中包含的功能:
-
出色的主题支持:NativeBase 也提供了非常好的主题支持。本质上,它的工作方式与 React Native Paper 非常相似。你可以轻松地更改和扩展默认主题,并在你的应用中到处使用它们。它还支持开箱即用的亮色和暗色模式。
-
类型声明:所有组件和主题都带有类型声明。你还有关于如何扩展这些类型以自定义主题或组件的优秀文档。
-
react-native-vector-icons用于纯 React Native 项目或@expo/vector-icon用于 Expo 项目。 -
超过 30 个预构建组件:所有组件都高度可定制且易于使用。
-
响应式支持:NativeBase 具有出色的响应式设计支持。这意味着你只需在组件上添加几个额外的属性,就可以适应不同的屏幕尺寸。
-
无障碍性:基于 React Native ARIA,NativeBase 为所有组件提供了无障碍支持。这意味着你可以轻松地为屏幕阅读器提供支持,确保良好的对比度,并启用应用程序的键盘交互。
此外,NativeBase 还附带一个 Figma 文件,这使得它成为与设计专家一起创建自己的设计系统的理想起点。总的来说,它是一个在创纪录的时间内创建美观界面的非常好的解决方案。
如需了解更多关于 NativeBase 的信息,请访问官方文档docs.nativebase.io/。
如前所述,还有许多开源 UI 库。请查看此列表以获取最受欢迎的库:
-
React Native UI Kitten
-
React Native Elements
-
Material Kit Pro React Native by creative-tim
-
Nachos UI Kit for React Native
这些 UI 库可以为你节省大量时间。但因为你希望为你的用户提供独特的应用程序体验,所以你应该只将它们作为起点。幸运的是,大多数库都足够灵活,你可以使用它们来创建自己的设计,同时使用经过实战考验的库结构。
随着你的项目增长,我建议扩展你选择的库,添加你自己的组件。如果你喜欢,并认为你有其他人对之感兴趣的东西,你甚至可以通过创建拉取请求和扩展官方库来为社区做出贡献。
在查看 UI 库之后,在下一个子节中,你将了解另一个有用的工具。这对于开发大型应用程序特别有用,在这些应用程序中,UI 组件在不同的存储库之间共享,并且一些开发者只负责 UI 组件。它被称为 Storybook。
使用 Storybook 进行 React Native 开发
Storybook 在纯 React 世界中非常受欢迎。这是一个渲染你所有组件在预定义状态下的工具,这样你就可以查看它们,而无需启动你的真实应用程序并导航到它们被使用的位置。
使用 Storybook,你可以编写故事,这些故事随后会被打包成故事书。每个故事都包含一个组件。它还定义了故事书中的位置。以下代码示例展示了故事可能的样子:
import {PrimaryButton} from '@components/PrimaryButton;
export default {
title: 'components/PrimaryButton,
component: PrimaryButton,
};
export const Standard = args => (
<PrimaryButton {...args} />
);
Standard.args = {
text: 'Primary Button',
size: 'large',
color: 'orange',
};
export const Alert = args => (
<PrimaryButton {...args} />
);
Alert.args = {
text: 'Alert Button',
size: 'large',
color: 'red',
};
在第一行中,导入了 PrimaryButton 组件。接下来的默认导出定义了在 Storybook 中的位置以及与哪个组件相关联。Standard 常量和 Alert 常量是不同的状态,PrimaryButton 组件将在 Storybook 中渲染并显示。相应的 args 定义了这个状态:

图 9.1 – Storybook 在浏览器中运行
在 React Native 上的 Storybook 既可以运行在 iOS 或 Android 模拟器上,也可以在真实设备上运行,或者您可以使用 React Native Web 创建组件的网页版本并将它们渲染到任何浏览器中。这如图图 9.1所示,当与设计师合作时特别有用。
Storybook 使得您可以在与应用程序的其他部分隔离的情况下开发组件。它不仅展示了组件,还允许您在 Storybook 中更改属性。因此,您可以看到组件在不同情况下在您的实际应用程序中的表现。
我不会在小型项目中使用 Storybook,但当您的项目和团队成长时,Storybook 可以成为提高您 UI 开发速度的有用工具。这尤其适用于您在不同存储库之间共享 UI 组件的情况。如果您公司有多个应用程序,都应该具有相同的视觉和感觉,我建议您使用它。
在这种情况下,为您的组件创建一个中心存储库可能是一个不错的解决方案。使用 Storybook,这个存储库可以由开发者和设计师维护,而无需访问所有应用程序。您可以在第十章,“结构化大规模、多平台项目”部分的编写自己的库中了解更多信息。
有关 Storybook 的更多信息,请访问官方文档storybook.js.org/。
摘要
在本章中,您学习了用于提高代码质量、自动捕获最常见错误以及加快项目设置和开发过程的有用工具。您了解了类型定义的重要性以及如何使用 ESLint 和 prettier 确保您的代码符合某些标准。
此外,您还了解了最流行的 React Native 模板解决方案以启动项目,并学习了每个解决方案的优势和权衡。在本章末尾,您学习了 React Native 的 Storybook,如何使用它,以及在哪些场景下它是一个有用的工具。
在了解了所有这些有用的工具之后,是时候深入探讨大规模项目了。在下一章中,您将学习如何设置和维护项目结构,这将适用于大规模项目。此外,您还将了解您有哪些选项可以在不同平台之间共享代码,以及这些解决方案在哪些场景下效果最佳。
第三部分:在大型项目和组织中使用 React Native
你将学习如何在大型组织或大型项目中使用 React Native。这包括结构化大型应用程序、建立良好的流程、尽可能使用自动化,以及开始编写自己的库。
本节包含以下章节:
-
第十章,结构化大型、多平台项目
-
第十一章,创建和自动化工作流程
-
第十二章,React Native 应用的自动化测试
-
第十三章,技巧与展望
第十章:结构化大型、多平台项目
我坚信,软件项目的结构是决定成功或失败的关键因素之一。这包括应用程序架构、开发过程以及整个项目组织。
项目越大,参与项目的开发者越多,项目运行时间越长,良好的项目结构就越重要。但小型项目也可能因为结构不良而失败。因此,本章的大部分内容也适用于小型项目。
当使用 React Native 开发适用于多个平台的应用程序时,项目结构尤为重要,不仅限于 iOS 和 Android。不同的平台有不同的需求,并带来不同的用户期望。展示这一点的最佳例子是 iOS、Android 和网页之间的差异。
如已在第四章中提到的,React Native 中的样式、存储和导航,移动应用程序和网页中的导航概念完全不同。在规划项目结构时,您必须考虑这一点。
在投资良好的架构和良好的项目结构时的问题在于,它总是在一开始就产生一些额外开销。以下图显示了这种困境:

图 10.1 – 随着项目随时间增长,编码生产力降低
如果您在开始时不投资于架构,您将拥有更高的生产力。如果您投资于架构,您必须考虑应该实现什么,项目可能向哪个方向发展,以及当项目达到最大成功时,您将有什么用途、团队规模和要求。
这些考虑需要一些时间,实施和执行标准化流程可能需要更多时间。直接下载随机模板并开始编码总是更快。但如前所述,最终会得到回报,因为有了良好的应用程序架构和良好的项目结构,您最终会得到易于维护、测试和开发的软件。
因此,您将在本章中学习以下内容:
-
设置适用于大型企业项目的应用程序架构
-
使用 React Native 部署到不同的平台
-
使用您自己的库重用代码
技术要求
要运行本章中的代码,您必须设置以下内容:
-
一个有效的 React Native 环境 (bit.ly/prn-setup-rn – React Native CLI 快速入门)。
-
您可以在本存储库中找到示例项目:
bit.ly/prn-videoexample。 -
虽然本章的大部分内容也应该适用于 Windows,但我建议在 Mac 上进行操作。
-
本章包含一些原生代码。您应该对 Java 或 Kotlin 以及 Objective-C 或 Swift 有基本了解。
为大型企业项目设置一个适用的应用架构
当我们谈论大型项目以及如何设置一个合适的应用架构时,看看这些大型项目与小型团队或单个开发者项目相比有什么不同是有意义的。
以下是最重要的几点:
-
项目团队非常大:在大型项目中,你通常有一个由许多开发者组成的庞大团队。通常,这些开发者遍布世界各地,这意味着他们处于不同的时区,使用不同的第一语言,并且有着完全不同的文化背景。因此,拥有一个清晰的结构和明确的责任是非常重要的。否则,你的项目将会失败。
-
多个开发者将在应用程序的同一部分工作:在最后期限即将到来,一个特性必须完成的时候,多个开发者将共同工作在同一个特性和应用程序的同一部分。这意味着你应该考虑如何组织你的代码,以便在没有冲突的情况下实现这一点。
-
每个错误都会被用户发现:在只有少数用户的较小项目中,很多错误可能永远不会被发现。在拥有大量用户的规模较大的应用程序中,错误几乎不可能保持未被发现。这意味着在将应用程序发布给公众之前,你必须投入更多的努力来自己发现错误。
-
代码必须通过程序进行测试:项目越大,运行时间越长,程序化测试你的代码就变得越重要。在某个时候,手动处理所有测试是不可能的。这意味着你必须有一个非常支持这种自动化测试的应用架构。
-
代码库将变得非常大:正如“大型项目”这个术语所暗示的,项目和它的代码库将变得非常大。这意味着你必须提供一个结构,使得新开发者尽可能容易地理解项目中的情况。
在这些要点的基础上,我们将尝试找到一些支持所有这些的架构方法。
适应我们的示例项目结构
当项目增长时,最重要的就是分解。这意味着你应该尽可能地将你的组件拆分成小而意义明确的片段。
当我们查看示例项目的项目结构时,我们已经很好地分解了我们的应用程序,并且我们选择的架构对于我们的用例来说效果良好。它已经包含了一些即使在大型项目中我也推荐保留的东西。以下就是这些内容:
-
使用服务:每个 API、SDK 或第三方连接器都应该被封装在你的服务中。这样,你可以以最小的努力更改 SDK 或服务以及合作伙伴。
-
组件和视图的分离:可重用的组件和可导航的视图应保存在不同的文件夹中。这使得新开发者更容易找到他们正在工作的视图。
然而,这种方法也带来了一些问题,尤其是在代码库增长且多个开发者共同工作时:
-
组件和视图难以通过程序进行测试
-
组件目录将迅速增长并变得非常大
-
单个功能将难以找到,并且散布在整个代码库中
-
整个代码库将变得相当混乱
-
许多开发者将不得不同时触摸相同的文件
因此,我们将对我们的方法进行一些调整。首先,我们将关注组件级别。到目前为止,我们将在一个文件中编写组件的业务逻辑、UI、样式和类型。现在将发生变化。我们将把我们的组件拆分为以下:
-
index.tsx:index文件包含组件的业务逻辑,如数据获取,以及与全局应用程序状态的连接。它只渲染以下.view组件。 -
<component>.view.tsx:view文件包含 UI。它不保留自己的状态,也不直接连接到全局应用程序状态。它只渲染从index文件获得的属性。 -
<component>.styles.tsx:styles文件包含 React Native StyleSheet 或 styled-components,具体取决于你选择的方法。 -
<component>.types.tsx:types文件提供了index和view文件的属性和状态的数据类型。
通过这种分离,我们实现了两个目标。首先,一个开发者更容易处理业务逻辑,另一个处理 UI,而不会产生合并冲突或其他问题。其次,我们的组件现在对自动化测试的支持更好。
我们可以使用任何组件测试框架来渲染和测试视图,而无需模拟我们的全局状态或组件状态。此外,使用这种方法集成工具(如 Storybook)也容易得多。
要查看该方法的实际应用,你可以查看 GitHub 仓库,选择 chapter-10-split-home-view 标签,并查看 views/home 文件夹。
提示
为了确保每个人都遵循这种模式,并使创建新的视图和组件更简单,你可以使用文件生成器。这些是小脚本,使用模板和通常是一个组件名称来创建你想要的结构。你可以在 GitHub 仓库中看到一个示例。选择 chapter-10-generator 标签,查看 util 文件夹。你可以使用生成器通过 npm run generate <name> 来生成新的视图。
在组件级别进行此更改后,我们将退后一步,再次审视整个项目。当你项目增长时,我推荐的第二个更改是按功能对视图和组件进行分组。这使得理解整个项目结构并导航代码变得容易得多。
我必须承认,这取决于个人偏好,有些人甚至喜欢在大型项目中组件和视图之间清晰的分离,但我更喜欢功能方法。这种方法如图所示:
![图 10.2 – React Native 特征分组架构]
![图片/B16694_10_02.jpg]
图 10.2 – React Native 特征分组架构
这种方法按功能对应用程序进行分组。它还有一个组件文件夹,其中包含非常基本的通用组件,如按钮、列表和头像——基本上,这些是在你的应用程序的每个功能中使用的,以提供一致的用户体验。但每个功能也有自己的组件文件夹,你可以将只为这个功能创建的组件放入其中。还有对这个方法的修改,即将多个视图放在一个功能中。
从我的个人经验来看,我可以这样说,这种功能方法使代码库结构非常清晰,并且使查找你正在寻找的内容变得更容易。另一方面,你总会遇到一些组件,你不确定是否应该将它们放入通用组件中。
最后,你必须找到自己想如何构建应用程序结构的方法。但在本节中,你学习了为了创建即使在项目扩展时也能正常工作的结构,你必须注意的最重要的事情。
现在你已经学会了如何一般性地构建 React Native 项目,我们将更进一步,专注于多平台开发。
使用 React Native 部署到不同平台
在本节中,你将学习如何设置你的 React Native 项目以支持多个平台。由于这是最常见的情况,我们将在这里大量关注 Web,但本节中的提示和方法也适用于其他平台,如桌面和电视。
当为多个平台创建应用程序时,始终有两个目标。首先,你希望尽可能支持更多平台特定的功能,并希望为用户提供他们在该平台上习惯的外观和感觉。其次,你试图尽可能多地共享代码,因为这使维护和开发你的应用程序更容易。
初看,这些目标似乎相互矛盾,但有一些智能的方法可以同时获得两者的最佳效果。让我们从最简单的方法开始。
使用 react-native-web 创建 Web 克隆
当你使用 React Native 创建应用程序时,你可以使用一个名为react-native-web的库来在 Web 上运行你的 React Native 应用程序。
在我们开始之前,你必须理解 react-native-web 是做什么的。基本上,它将所有 React Native 组件映射到 HTML 组件。例如,一个 <View/> 组件将得到 <div/>。它还将 React Native 的原生 API 调用映射到浏览器 API,只要可用。这意味着你将得到一个普通的 React 网页应用程序。
虽然 react-native-web 是一个很棒的库,但要开始使用它并不容易,因为您必须设置一个单独的构建过程来使用它。这个构建过程将创建一个独立的 React 网页应用程序。像每个 React 网页应用程序一样,它需要一个打包器来创建优化的浏览器可读 JavaScript 代码。一个非常流行的解决方案是 Webpack,我们也将使用它来构建我们的网页应用程序。此外,每个网页应用程序都需要一个入口点。在大多数情况下,这是一个 index.html 文件,然后加载包含 React 应用的 JavaScript 包。因此,我们必须将其添加到我们的项目中。
在 react-native-web 文档中(您可以通过以下链接查看:https://bit.ly/prn-rn-web)以非常详细的方式描述了设置网页支持的全过程,但撰写本文时,该文档缺少 TypeScript 支持。
因此,当我们在示例应用程序中设置基本的网页支持时,我将描述最重要的内容。您可以在选择 chapter-10-web 标签时在 GitHub 仓库中找到完整的完整设置。
安装 react-native-web
我们将从添加 react-native-web 和 react-dom 到我们的项目开始。请使用正确的 react-dom 版本。由于我们在 React Native 应用中使用 React 17,因此我们必须使用 react-dom@17。这些库是创建 React 应用所必需的。安装可以通过 npm 完成:
npm install react-dom@17 react-native-web
否则,可以通过 yarn 来完成:
yarn add react-dom@17 react-native-web
现在我们已经安装了 react-native-web,我们需要处理网页的构建过程和开发环境。
安装 webpack
为了做到这一点,我们将添加 Webpack、相应的 CLI 以及一个名为 webpack-dev-server 的 Webpack 扩展。这个扩展提供了一个内置的开发服务器,在您开发应用程序时支持实时重新加载。
这些 npm 库的安装可以通过以下 npm 命令来完成:
npm install –saveDev webpack webpack-cli webpack-dev-server
否则,你可以使用一个 yarn 命令:
yarn add --dev webpack webpack-cli webpack-dev-server
除了这个基本的 Webpack 设置之外,我们还将安装两个加载器。加载器是 Webpack 的一个核心概念。它们使您能够预处理文件并决定它们应该如何在您的包中使用。我们将使用以下加载器:
-
ts-loader:这是一个预处理我们的 TypeScript 文件并将其转换为浏览器可读 JavaScript 的加载器 -
file-loader:这个加载器将我们的资产二进制文件(如图像)复制到我们的最终包中
我们需要为我们的网页构建过程工作的最后一件事是 html-webpack-plugin。这个插件创建我们的入口点。它通过加载 HTML 模板并添加创建的 JavaScript 包来写入 index.html。
这些添加可以通过以下npm命令安装:
npm install –saveDev file-loader ts-loader html-webpack-plugin
否则,使用以下yarn命令安装:
yarn add --dev file-loader ts-loader html-webpack-plugin
现在我们已经安装了所有工具,我们必须配置我们的项目。
配置 React Native 项目以支持 Web
首先,让我们为我们的应用程序创建一个 JavaScript 入口点。为此,我们将在应用程序的根目录中创建index.web.js。这包含以下代码。
AppRegistry.registerComponent(appName, () => App);
AppRegistry.runApplication(appName, {
initialProps: {},
rootTag: document.getElementById('movie-root'),
});
我们使用 React Native 的AppRegistry通过registerComponent函数加载我们的<App />组件,然后通过runApplication运行我们的应用程序。
runApplication需要一个 HTML 节点作为rootTag来支持 Web。这个 HTML 节点将在runApplication期间被 React 应用程序替换。在我们的例子中,我们将从 HTML 文档中获取带有movie-root ID 的元素。
接下来,我们将在项目的root文件夹中创建一个web/文件夹。在这个文件夹中,我们将放置一个包含以下内容的index.html模板(请参考 GitHub 仓库以获取完整文件):
<head>
<title>
Movie Application
</title>
<style>
html, body { height: 100%; }
body { overflow: hidden; }
#movie-root { display:flex; height:100%; }
</style>
</head>
<body>
<div id="movie-root"></div>
</body>
在文档的头部,我们定义了一个标题和一些样式。这些样式对于react-native-web应用程序的显示非常重要。主体部分只包含一个空的<div />元素,并带有#movie-root ID。这是我们用于 JavaScript 入口点的容器。
接下来,我们必须配置我们的 Webpack 构建器。为此,请在web/文件夹中创建webpack.config.js。以下代码片段显示了最重要的配置。对于完整文件,请查看 GitHub 仓库:
const rootDir = path.join(__dirname, '..');
module.exports = {
entry: {
app: path.join(rootDir, './index.web.ts'),
},
output: {
path: path.resolve(rootDir, 'dist'),
filename: 'app-[hash].bundle.js',
},
module: {
rules: [{
test: /\.(tsx|ts|jsx|js)$/,
exclude: /node_modules/,
loader: 'ts-loader'
}]
},
plugins: [
new HtmlWebpackPlugin({
template: path.join(__dirname, './index.html'),
})
],
resolve: {
extensions: [
'.web.tsx','.web.ts','.tsx','.ts','.js'
],
alias: Object.assign({
'react-native$': 'react-native-web',
}),
},
};
让我们从头到尾处理这个配置。首先,我们定义了我们的 JavaScript 入口点。在这里,我们放置了我们刚刚创建的index.web.js文件。然后,我们定义了我们的输出。在这种情况下,它是dist/目录和一个带有哈希值的 JS 包,以确保每次构建都有新的文件名,以防止浏览器缓存问题。
在module部分,我们可以定义规则来指定哪些加载器应该用于预处理哪些文件。我们使用正则表达式来测试文件名,并为所有匹配的文件定义加载器。在这个例子中,我们为包含.tsx、.ts、.jsx或.js的所有文件使用ts-loader,除了node_modules文件夹中的所有内容。
在文件的下一部分,我们定义了我们将使用哪些插件。在我们的例子中,只有HTMLWebpackPlugin用于从我们的模板 HTML 文件创建入口点index.html。config文件的最后一部分是resolve部分。在这里,React Native 到普通 React Web 应用程序的转换魔法正在发生。
通过为react-native创建react-native-web别名,我们替换了所有react-native的实例,现在它们都来自react-native-web。这意味着所有从react-native获取的导入现在都来自react-native-web。
现在我们 Web 应用程序的构建过程已经工作,我们将在我们的 TypeScript 设置中进行一些小的调整:
"lib": ["es2017", "dom"],
"jsx": "react",
"noEmit": false,
我们在lib部分添加了dom,将jsx模式改为react,并将noEmit从true改为false。这是为了以 Webpack 可以处理的方式创建文件。这一步完成后,设置就完成了。
在浏览器中以 React 应用运行 React Native 代码
现在,我们可以从命令行以dev模式启动我们的 React Native 应用作为 React Web 应用。你可以使用以下命令来完成:
cd web && webpack-dev-server
以下截图显示了我们的示例电影应用在浏览器中的运行情况:

图 10.3 – 在浏览器中运行的我们的示例电影应用
图 10.3 展示了在浏览器中运行的示例电影应用的 UI。它运行得非常完美,与原生应用使用相同的代码库。当你使用浏览器的检查工具检查 HTML 时,你会看到所有的 React Native 组件都被转换成了 HTML 组件。
作为本节的最后一步,为了使开发和创建生产构建更容易,我们在package.json的scripts部分添加了两个命令:
"start:web": "cd web && webpack-dev-server",
"build:web": "cd web && webpack",
第一行是我们刚才用来以dev模式启动应用程序的命令。第二行是用来在生产模式下构建应用程序以进行部署的命令。这会将完整的包写入我们在webpack.config.js中定义的dist文件夹。
在本小节中,你学习了如何创建你的 React Native 应用在 Web 上的克隆。虽然这可能在某些情况下有效,但大多数时候这还不够。Web 和移动用户在大多数领域的期望是不同的,你也可能希望使用 Web 和移动不同的库,这些库不支持其他平台。区分不同平台的一个非常简单的解决方案是利用文件扩展名。
使用.native 和.web 文件扩展名
如前一小节所述,我们对 Web 和原生应用有两个完全不同的构建过程。虽然我们配置了 Webpack 打包器以支持.web.ts或.web.tsx文件,但原生 Metro 打包器默认支持.native.ts或.native.tsx文件。这意味着我们可以通过简单地创建文件的两个版本来编写特定平台的代码:
-
App.tsx和App.native.tsx会导致我们的 Web 应用使用App.tsx,而我们的原生应用使用App.native.tsx -
App.tsx和App.web.tsx会导致我们的 Web 应用使用App.web.tsx,而我们的原生应用使用App.tsx
这种方法可以用来共享大部分代码,但为组件创建特定平台的版本。它也可以用来为不同的平台定义不同的导航堆栈,或者通过创建特定平台的App.tsx文件来使用不同的导航库。
总的来说,这种方法非常强大,但也存在一些限制。例如,你将不得不使用你在不同平台之间共享的库的相同版本,因为这两个平台共享一个package.json文件。如果你想更进一步,你可以要么在monorepo中处理多个包,要么从你想要共享的代码中创建自己的库,然后将这些库导入到不同的平台特定项目中。
让我们先看看monorepo方法。
在单一代码库中处理多个包
对于将你的多平台 React Native 应用程序作为monorepo进行结构化,我建议使用yarn工作空间。这是在单个存储库中设置多个 JavaScript 包的方法。yarn在版本和存储方面优化库。它还允许包之间相互链接,这也是我们在这里使用它的主要原因。
想了解更多关于yarn工作空间的信息,你可以查看官方文档(bit.ly/prn-yarn-workspaces)。以下图显示了具有yarn工作空间的多平台monorepo结构:

图 10.4 – 基于工作空间的多平台 React Native 单一代码库
你有一个共享代码包(通常也称为App),它可以包含应用程序的大部分内容,如视图、存储、服务和组件。这个包不是直接启动的,也没有本地或 Web 入口点。然后,你为每个平台有一个包。
这些包中的每一个都有自己的package.json文件,可以定义自己的库和版本。这种设置甚至允许你在不同的平台上使用同一库的不同版本,只要你的共享代码支持所有这些版本。
平台特定包包含入口点,我也建议在这里放置平台特定的东西,如导航和一般的应用程序结构(层堆栈或导航树)。这使得不仅能够为每个平台创建相同应用程序的副本,而且还可以使用非常不同的方法。
例如,你可以在 Web 和移动端有完全不同的层堆栈。这完全合理,因为大多数时候,不同平台的需求是完全不同的。有些你在 Web 上需要的东西甚至不想在移动应用中拥有,反之亦然。
这种包式方法还有另一个优点。基于 React 的框架有很多,它们执行了许多针对 Web 的特定优化,例如支持服务器端渲染、将浏览器历史支持添加到路由中,或者进行广泛的 Web 包优化。这类最受欢迎的框架是Next.js和Gatsby。使用这种设置,你可以为 Web 使用它们。
如果你想要从这种monorepo设置开始,我可以推荐一个优秀的模板,你可以在以下链接找到:bit.ly/prn-rn-universal-monorepo。这个模板不仅支持移动和网页,还支持一些其他框架和平台,如 Next.js、Electron、桌面应用程序,甚至浏览器扩展。还有一个很好的描述,可以指导你完成设置过程,你可以在以下链接找到:bit.ly/prn-rn-anywhere。
采用这种方法,我们首次为不同的平台创建了不同的包。在这种情况下,我们只使用了一个仓库,因为这使开发变得相当简单。我们只需要克隆仓库,安装依赖项,就可以开始了。
我真的很喜欢这种方法,但当代码库和团队规模大幅增长时,进一步深入确实是有意义的。为了更清晰地分离应用程序的不同部分并明确其责任,你可以将应用程序拆分为不同的项目。这意味着你将创建自己的库。
使用自己的库重用代码
有很多理由要创建自己的库。在不同平台之间共享代码无疑是其中之一。但通过自己的库,你还可以实现以下事情:
-
确保所有应用程序中的一致设计:当你在提供多个应用程序的公司工作,并且需要确保这些应用程序的设计一致时,创建一个提供所有这些应用程序的 UI 组件的 UI 库是一个好主意。这确保了一个一致的设计系统。
-
简化后端连接:你可以将你的服务提取到一个库中,然后可以在所有项目中使用这个库。这确保了统一的后端连接层。
-
定义责任:每个库都可以由其维护者或团队维护。通过这种库方法,你可以明确定义责任。
-
提供额外功能:你也可以编写自己的库来提供原生功能,这些功能在社区模块中无法以你所需的方式获得。在这种情况下,我总是建议以自己的库的形式提供这种功能(如果可能的话,使其对社区可用)。
注意
大多数社区模块都是因为有人遇到了尚未解决的问题而开始的。如果你能够通过新的库或模块解决问题,我强烈建议与社区分享。即使你不是出于利他的原因,这也可以是一件非常好的事情。通常,你可以找到其他人面临相同的挑战,你们可以一起创造更好的解决方案。
创建我们自己的库可能相当具有挑战性。您可以在网上找到大量教程和博客文章,介绍如何为您的库创建完美的设置。其中一些不错,一些则不好。但与其使用其中之一,我推荐使用名为react-native-builder-bob的工具包。
使用 react-native-builder-bob 编写、维护和发布我们自己的库
此工具使编写、维护和发布您自己的库的过程变得非常简单。它由一家名为Callstack的公司创建和维护,该公司在 React Native 社区中非常活跃,甚至为 React Native 的核心做出了贡献。
他们使用react-native-builder-bob为自己的库编写代码,许多最受欢迎的库也是如此。
您可以使用以下简单命令开始使用预配置的react-native-builder-bob创建自己的库:
npx create-react-native-library <your-library-name>
此命令将启动设置过程,并通过几个问题引导您完成。以下截图显示了此过程:

图 10.5 – 使用 create-react-native-library 创建自己的库
在回答有关作者和包的问题后,这些问题是创建package.json所必需的,create-react-native-library将询问您想要开发哪种类型的库。
您可以选择以下选项:
-
本地模块/本地视图:如果您模块包含本地代码,应选择此选项。这些选项使用当前的桥接架构在 JavaScript 和本地代码之间进行通信。
-
JavaScript 库:如果您模块不包含任何本地代码,应选择此选项。大多数用例,如简单的 UI 库、服务 SDK 和状态提供者,都属于此类。此外,当您使用包含本地代码的其他库,但您的库是仅包含 JavaScript 的库时,这也是正确的类型。
-
Turbo 模块:在撰写本文时,此类型处于实验阶段。它基于新的 React Native 架构创建本地模块(参见第三章,介绍新的 React Native 架构部分)。
我们将首先创建一个仅包含 JavaScript 的库。想象一下,我们创建的示例应用是某个大型公司众多应用中的一个。因为管理层喜欢我们的设计,所以他们希望所有未来的应用都能遵循我们的设计系统。因此,我们希望将我们的StyleConstants文件作为设置企业设计系统的第一步放入我们的库中。
创建仅包含 JavaScript 的库
要开始我们自己的仅 JavaScript 库,我们将选择 create-react-native-library 下拉菜单。create-react-native-library 使用一组预配置的工具、预定义的脚本、一个简单的乘法函数作为源代码,甚至还有一个示例应用程序来展示库。如果您想查看一个工作示例,可以查看以下 GitHub 仓库:bit.ly/prn-repo-styles-library。
当我们检查我们新创建的库的 root 文件夹时,我们会发现许多我们已知的文件。这里有一个 babel.config.js 文件来定义 Babel 应如何转换我们的代码,一个包含有关包信息以及所有依赖和脚本的 package.json 文件,还有一个包含 TypeScript 编译器所有信息的 tsconfig.json 文件。
接下来,我们将更深入地查看 package.json。除了所有预定义的信息和配置之外,我想指出两个重要的事情。第一个是关于如何找到我们库各个部分的信息。以下代码片段显示了这些信息:
"main": "lib/commonjs/index",
"types": "lib/typescript/index.d.ts",
"source": "src/index",
当我们使用 TypeScript 创建我们的库时,它将由 react-native-builder-bob 编译为预 ES6 JavaScript,这样它就可以在所有 React Native 项目中使用,无论它使用的是哪种堆栈(TypeScript、Flow、纯 JS 或 Expo)。这意味着我们的库代码以不同的方式分发。以下属性中定义了这一点:
-
main:这是您库的主要入口点。当您从库中导入任何内容时,这是您的项目将查找导出路径的地方。 -
types:由于我们使用 TypeScript,react-native-builder-bob为我们的代码创建类型,以便所有使用类型化 JavaScript 的人都可以使用我们创建的类型。 -
source:这是可以找到未编译源代码的地方。
当我们在 source 目录中工作时,使用我们库的项目将只与 main 和 types 一起工作。
我希望您首先查看的是 scripts 部分,尤其是以下脚本。
"scripts": {
"prepare": "bob build",
"release": "release-it",
},
这些脚本是这个库设置中最基本的部分。使用 prepare 脚本,您可以运行 react-native-builder-bob 的 build 命令。它将编译您的库并提供您刚刚学到的入口点。
release 脚本将使用 release-it 库创建您库的新版本。这将启动一个引导过程,执行以下操作:
-
更新库版本
-
创建一个变更日志
-
将您的库发布到
npm -
将库版本更新提交到
git -
添加一个
git标签 -
将更改推送到远程仓库
-
在 GitHub 上创建一个发布
这个脚本非常有用,因为它强制您在发布和标记库方面遵循最佳实践。
现在您已经了解了库项目的结构,让我们使用这个库来发布我们的样式。由于我们已经在 StyleConstants 文件中收集了所有的样式信息,所以这很简单。
前往库项目的 src/index.tsx 文件,并将 StyleConstants.ts 文件的内容粘贴进去。接下来,提交更改,并使用以下命令构建和发布库:
npm run prepare && npm run release
注意
您需要在 www.npmjs.com/ 上创建一个免费账户,并通过命令行使用 npm login 登录,以便能够发布您的库。
在您发布库包之后,您可以在项目中安装它。您可以使用常规的 npm 命令:
npm install <your-library-name>
或者,您可以使用 yarn 命令:
yarn add <your-library-name>
现在您能够通过库访问您的样式,您可以删除 StyleConstants.ts 文件,并将所有导入替换为您的库。以下图显示了 Home.styles.tsx 的更改:

图 10.6 – 从本地文件导入更改到库
如您所见,导入保持不变,只是 from 路径变更为库。您必须在所有使用 StyleConstants 的文件中这样做。
正如您在本小节中学到的,创建自己的库的过程相当复杂,但使用正确的工具工作时会容易得多。但鉴于我们的示例是一个仅使用 JavaScript 的库,这是 React Native 库中最简单的一种。当向库中添加本地代码时,它会变得更加复杂。
理解本地库之间的区别
如您所知,React Native 有一个 JavaScript 部分和一个本地部分。这意味着当我们需要时,我们可以利用本地平台特定的代码。这不仅适用于应用程序项目,也适用于库。本地代码是用平台特定的语言编写的,例如 Android 的 Kotlin 或 Java,iOS 的 Swift 或 Objective-C。
但并不仅仅是语言在不同平台之间有所不同。应用程序管理第三方包的过程以及如何构建和部署的过程也完全不同。
Android 使用 Gradle 来获取包并构建您的应用程序。对于 iOS,有多个包管理器,但 React Native 严重依赖于 CocoaPods。构建是通过 Xcode 完成的。
这意味着当您向库中添加本地代码时,您不仅要交付和导入您的 JavaScript 代码,还要提供本地代码并将其添加到包含在本地包中的本地构建过程中。
在这种设置下,您的本地代码也包含在库包中。要能够编写本地代码,您在用 create-react-native-library 创建库时必须选择 Native Module。这将创建两个额外的文件夹(android 和 ios),其中包含本地代码,以及本地构建过程的配置文件。
对于 Android,这是一个build.gradle文件,可以在android文件夹中找到。对于 iOS,这是一个.podspec文件,可以在库的root文件夹中找到。
所有这些文件都是为您创建的,因此您不需要修改它们。当使用原生代码安装您的库时,React Native 的自动链接功能会在 Android 上为您处理所有事情。在 iOS 上,您需要运行npx pod-install来将库的原生部分包含到原生项目中。
现在您能够创建纯 JavaScript 库和包含原生代码的库,我们将再次审视如何提供它们。我们使用公共的npm注册表来托管我们的库作为公共包。
虽然我真的很喜欢与社区共享一切的方法,但您可能需要将您的库保持为私有,尤其是在它们是公司应用程序的重要部分时。下一小节将向您展示如何仅向选定的人提供对您的库的访问权限。
对库设置访问限制
有一些方法可以将您的库仅与选定的人共享。以下两种是最常见的:
-
使用付费 npmjs.com 计划:当使用付费的npmjs.com计划时,您可以在您的包上定义权限。这意味着只有您明确允许的人才能访问您的包。
-
package.json:"prn-video-example-styles": "git+https://github.com/alexkuttig/video-example-styles" -
您甚至可以通过添加一个
#符号后跟标签名、分支名或提交哈希来指定您的包应该从哪里获取标签、分支或提交。
再次强调,我强烈建议尽可能地将您的模块发布出来,而不是将其保持为私有。这个拥有数千个维护良好的公共包的社区是 React Native 之所以成功的主要原因之一。因此,向社区回馈总是一个好主意。
摘要
在本章中,您学习了如何构建大规模或多平台产品。现在您能够创建适用于大规模和长期运行项目的项目结构。
您还在网络上创建了一个示例 React Native 移动应用的克隆版本,并理解了为什么这并不总是最佳选择。然后您学习了如何创建满足用户期望的多平台应用,同时保持高比例的共享代码。
在本章的最后部分,您学习了如何创建、发布和维护自己的库,了解了仅使用 JavaScript 的库和包含原生代码的库之间的区别,以及如何仅将这些库发布给选定的人。
在专注于为代码库本身创建良好的结构之后,在下一章中,我们将关注如何实施良好的工作流程以及如何使用持续集成(CI)工具来支持这些流程。
第十一章:创建和自动化工作流程
使用现代工作流程自动化自动化工作流程在大规模项目中是绝对必要的。这将为您节省大量时间,但更重要的是,它将确保您不会错过任何东西,并且您的重复性流程,如检查代码样式和质量、构建应用程序或发布应用程序都能正常工作。
接下来,它让您有信心,您刚刚编写的代码不仅能在您的机器上运行,因为它是在一个干净的机器上克隆并启动的。最后,它确保项目不依赖于个人。
在特定情况下,例如构建和发布应用程序这样的步骤,在更大规模的项目中可能会变得相当复杂,因此并非项目中的每个成员都能完成这些任务。但有了正确的自动化设置,只需按一下按钮即可。
当谈到工作流程自动化时,您也会经常听到持续集成(CI)和持续交付(CD)这两个术语。这两个术语都描述了自动化工作流程。CI 指的是项目的开发阶段。这意味着每个开发者都会频繁地将他们创建的代码集成到一个共享的仓库中,通常每天多次。在每次集成中,代码都会自动检查(TypeScript/Flow、ESLint、Prettier 和测试),并且开发者会立即得到反馈。DS 指的是部署或交付步骤。它描述了构建和交付应用程序的自动化。
由于在构建应用程序时可以进行 CI,因此您应该使用它。CD 适用于测试构建,但对于公共生产构建,如移动应用程序,它效果不佳。每天多次向公众发布是不可能的,因为每个发布都必须由苹果和谷歌手动审查才能在相应的应用商店中可用。
即使可能(您可以通过在第十三章中学习的 CodePush 实现,技巧与展望),我也不建议过于频繁地推送更新,因为这会导致每个用户在每次启动时都必须更新应用程序版本。
正因如此,我们将专注于开发过程中的持续集成(CI)以及为构建和发布步骤构建自动化工作流程,这些工作流程可以手动触发以进行公开生产构建,或者自动触发以进行内部测试构建(CD)。
这使您能够自动将应用程序更新推送给测试用户,并通过一键推送将应用程序发布给公众,同时不会因为过于频繁的更新而打扰真实用户。
由于当自动化的工作流程不好时,最好的自动化工具也毫无价值,因此在本章中,我们也将关注创建一个有效的开发工作流程。
在本章中,我们将涵盖以下主题:
-
理解集成/交付工作流程自动化
-
创建协作开发工作流程
-
为开发过程创建有用的 CI 管道
-
理解工作流程自动化和 CD 的构建和发布
技术要求
要能够运行本章中的代码,您必须设置以下内容:
-
一个可工作的 React Native 环境 (bit.ly/prn-setup-rn – React Native CLI 快速入门)
-
虽然本章的大部分内容也应该在 Windows 上工作,但我建议在 Mac 上进行操作
-
拥有 GitHub 账户以运行 CI 管道
-
拥有 Bitrise 账户以运行 Bitrise 交付工作流程
理解集成/交付工作流程自动化
集成和交付工作流程自动化的过程相当简单:您需要一个仓库和一个可以连接到您的仓库的自动化工具或构建服务器。然后,您必须定义规则,关于哪些 Git 事件应向服务器发送信息以触发某些脚本。以下图表说明了这个过程:

图 11.1 – 基本 CI 设置
Git 事件,如提交、拉取请求或合并,会触发自动化工具。自动化工具会启动一个配置在自动化工具设置中的干净服务器。然后,它从您的仓库克隆代码并开始在其上运行脚本。对于 React Native 应用程序,这些脚本通常从安装所有项目依赖项和运行静态类型检查器(Flow/TypeScript)开始。
接下来,您应该运行代码质量工具,如 ESLint 和 Prettier,并检查代码是否符合所有要求。大多数时候,您也会在这里运行一些测试(更多关于这一点在 *第十二章**,React Native 应用程序的自动化测试)。
您可以在这里运行其他任何脚本,以及集成其他云工具,如 SonarQube (bit.ly/prn-sonarcube,一个高级代码质量工具) 或 Snyk (bit.ly/prn-snyk,一个基于云的安全智能工具)。
在脚本执行完毕后,您的自动化工具会创建一个响应并将其发送回您的仓库。然后,这个响应会在您的仓库中显示,并可用于允许或拒绝进一步的操作。
现在,基本的自动化工具已集成到所有流行的基于 Git 的源代码仓库服务中,包括 GitHub(GitHub Actions)、Bitbucket(Bitbucket Pipelines)和 GitLab(GitLab CI/CD)。虽然这些工具对于 React Native CI 要求来说工作得很好,但构建和部署移动应用程序是一个非常复杂的过程,具有特殊的要求。
例如,iOS 应用程序仍然只能在 macOS 机器上构建。虽然从技术上讲,这些基本自动化工具中的大多数也可以完成这一步,但我不会推荐使用它们进行构建和部署。
对于这一步,有一个专门的工具包称为 fastlane,它可以集成到特殊的自动化工作流程工具中,如 Bitrise、CircleCI 和 Travis CI。我建议使用这个工具包,因为它可以为您节省很多时间。
现在您已经了解了流程自动化的理论,是时候考虑我们的开发过程应该是什么样子了。在我们可以自动化任何事情之前,我们需要建立一个良好的流程。
创建协作开发工作流
在大规模项目中,最重要的是信息更新。通常,在这些项目中,很多人需要协调,多个项目部分需要协同工作以构建复杂的产品。虽然信息很重要,但它不应限制开发速度。
因此,我们必须创建一个可以支持自动化的工作流程,以满足这两个要求。以下图表显示了此工作流程的重要部分:

图 11.2 – 工作流自动化设置
如您所见,工作流程需要四个技术部分。具体如下:
-
信息单一来源:所有信息都集中在这里。通常,这是一个问题跟踪器,其中每个任务、错误或功能请求都作为一个问题创建。例如,包括 Jira、ClickUp、GitLab 问题跟踪器和 GitHub 问题跟踪器。
-
代码管理:这是您的源代码存储的地方。它应能够与您的 信息单一来源 集成,以传输有关哪些问题已经完成或正在处理的信息。例如,包括 Bitbucket、GitHub 代码和 GitLab 仓库。
-
工作流自动化:这是您的应用程序进行测试和构建的地方。此工具还应能够与您的 信息单一来源 通信,以传输有关问题状态的信息。例如,包括 Bitbucket Pipelines、GitHub Actions、GitLab CI/CD、CircleCI 和 Bitrise。
-
稳定性监控:在您的应用程序部署给用户之后,您应该跟踪有关其稳定性的信息。崩溃或其他问题应自动报告给您的 信息单一来源。例如,包括 Bugsnag、Sentry、Rollbar 和 Crashlytics。您将在 第十三章 “技巧与展望” 中了解更多关于这些工具的信息。
现在,我们可以开始创建我们的开发工作流程。以下图表显示了推荐的标准化功能分支工作流程:


图 11.3 – 功能分支工作流程
正如工作流名称所暗示的,对于每个功能(这也可以是一个错误或改进——在这里,每个单独的问题都被视为一个功能),都会创建一个新的分支。然后,以下工作流程开始:
-
当创建分支时,信息单一来源必须更新,以便包含有关问题是否已经处理以及谁正在处理的信息。
-
接下来,开发者会对问题进行一次或多次提交以解决问题。
-
每个提交都会由工作流程自动化工具进行检查。
-
如果有错误,开发者会立即收到通知。当开发者认为他们已经解决了问题并完成了他们的工作后,他们会创建一个拉取请求(有时也称为合并请求)。
-
这个拉取请求也经过了工作流程自动化的检查,但这次,不仅进行了简单的检查,还进行了更广泛的检查(例如,端到端测试)。
-
如果一切顺利,必须更新单一的信息点。问题被分配给另一位开发者进行审阅,状态也会相应地更改以反映审阅状态。
-
如果需要更改,则过程将回退到步骤 1。如果审阅者对结果满意,他们可以将代码合并到 master 或 main 分支。
-
再次,必须更新单一的信息点,以反映问题的正确状态。
我非常喜欢这个过程,因为它为你提供了你需要的大量东西。以下是一些例子:
-
你总是能知道项目的确切状态。
-
工作流程的大部分可以自动化以节省时间。通常,开发者和审阅者只需要在代码管理工具中工作;其他一切都是自动化的。
-
这确保了每段代码都由另一位开发者进行双重检查,从而提高了代码质量。
-
审阅者不需要对基本代码质量进行检查,因为这是自动完成的。
现在我们已经了解了我们的流程,让我们开始编写自动化管道。
为开发过程创建有用的持续集成(CI)管道
再次,我们将使用我们的示例项目。首先,我们将设置一个管道,它可以在开发过程中通过非常简单的检查来支持我们,针对的是图 11.3 的步骤 3。我们将使用 GitHub Actions 来执行这个 CI 管道,但它与 Bitbucket (bit.ly/prn-bitbucket-pipelines) 和 GitLab CI/CD (bit.ly/prn-gitlab-cicd) 非常相似。
首先,我们必须创建我们希望在管道中使用的脚本。在我们的例子中,我们想要使用 TypeScript 编译器进行类型检查,并使用 ESLint 和 Prettier 进行静态代码分析,以确保代码风格正确。
为了做到这一点,我们将在package.json文件的scripts部分提供以下脚本:
"typecheck": "tsc --noEmit",
"lint": "eslint ./src",
"prettier": "prettier ./src --check",
接下来,我们必须创建一个可以被 GitHub Actions 解释的工作流程文件。由于这是一个完全集成的自动化工作流程,一旦我们将这个文件推送到我们的 GitHub 仓库,GitHub Actions 就会开始工作。
这就是我们的第一个工作流程自动化管道(或 CI 管道)的样子。你必须将它创建在 .github/workflows/<the github actions workflow name>.yml 下:
name: Check files on push
on: push
jobs:
run-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: install modules
run: npm install
- name: run typecheck
run: npm run typecheck
- name: run prettier check for code styling
run: npm run prettier
- name: run eslint check for code errors
run: npm run lint
让我们逐行查看代码。第一行定义了工作流程的名称。第二行定义了工作流程应该在何时运行。在这种情况下,我们希望在每次向仓库推送时运行它,无论推送来自哪个分支或作者。
提示
您可以在不同的触发事件上运行工作流。您可以在文档中找到完整的列表(bit.ly/prn-github-actions-events为 GitHub Actions 事件列表)。
在上一节描述的开发过程中,一些特别有用的触发事件是推送和拉取请求。您还可以将这些触发事件限制在特定的分支上。
接下来,您可以看到jobs部分。在这里,您定义实际的流程,它包含一个或多个可以顺序或并行运行的作业。在这种情况下,我们定义了一个包含多个步骤的作业。
对于我们的工作,我们首先要做的是定义它应该在哪个机器上运行。每个工作流自动化工具都有许多预定义的机器映像供您选择,但您始终可以提供自己的机器来运行自动化管道。在我们的例子中,我们将使用 GitHub Actions 提供的最新 Ubuntu 映像。
接下来,我们定义作业的步骤。这可以是使用uses命令与预定义操作一起使用的预定义操作,或者是我们自己创建的操作。在我们的例子中,我们使用了这两种选项。首先,我们使用预定义操作来检出我们的代码,然后我们使用四个自定义操作来安装模块和运行我们的检查。
提示
当使用工作流自动化工具时,您的工作流运行时间将是您需要支付的指标。因此,您应该始终考虑如何构建您的工作流,以便在自动化工具机器上花费尽可能少的时间。
一旦我们将此文件推送到我们的 GitHub 仓库,自动工作流的第一次运行就被触发了。在这种情况下,机器启动,克隆了仓库,安装了依赖模块,并运行了我们的检查。您可以在GitHub Actions标签页中查看自动化运行情况。
在前面的提示中,您了解到优化工作流以尽可能快地运行是很重要的。所以,这就是我们接下来要做的。以下图表显示了两种优化我们工作流的方法,以便我们可以更快地完成它:
![Figure 11.4 – Parallelize workflows]
![img/B16694_11_04.jpg]
图 11.4 – 并行化工作流
完成事情最快的方法是通过并行运行它们。GitHub Actions 不允许您并行运行步骤,但您可以并行运行多个作业。您必须详细调查您的工作流,以找出哪些部分可以并行化,哪些步骤更适合顺序运行。
在我们的例子中,仅仅为三个任务创建三个作业并没有太多意义。这是因为花费时间最长的步骤是安装依赖项,这对于所有三个作业都是必要的。幸运的是,我们可以使用缓存来工作,这样我们就不必在每次测试运行中重复缓存的任务。
在前面的图示左侧,您可以看到我们示例的管道设置,它首先安装依赖项,然后并行运行我们的三个作业。所有三个作业都从缓存中获取依赖项,这些依赖项是在安装步骤中填充的。在右侧,您可以看到另一种设置。在这个设置中,我们有三个并行作业,它们完全独立于彼此运行。
所有三个作业都试图从缓存中获取依赖项,并且只有在找不到它们时才安装它们。在某些场景下,这两种选项都更快。如果您必须安装依赖项,第二种设置会稍微长一点,因为安装步骤将被触发三次(因为步骤是并行开始的,而在它们开始的时候,依赖项要么被缓存,要么不是所有三个作业都有)。
第一种设置只触发一次依赖项安装,并确保它为其他作业缓存。在大多数场景中,这种第一种设置会花费更多时间,因为它需要您按顺序运行两个作业(安装 + 类型检查/Prettier/ESLint)。
正因如此,我建议采用以下代码中所示的第二种设置:
name: Check files on push alternative
on: push
jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '14'
- uses: actions/cache@v2
id: npm-cache
with:
path: '**/node_modules'
key: ${{ runner.os }}-node-${{
hashFiles('**/package-lock.json') }}
- name: Install dependencies if not cached
if: steps.npm-cache.outputs.cache-hit != 'true'
run: npm install
- name: run typecheck
run: npm run typecheck
prettier:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '14'
- uses: actions/cache@v2
id: npm-cache
with:
path: '**/node_modules'
key: ${{ runner.os }}-node-${{
hashFiles('**/package-lock.json') }}
- name: Install dependencies if not cached
if: steps.npm-cache.outputs.cache-hit != 'true'
run: npm install
- name: run prettier check for code styling
run: npm run prettier
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '14'
- uses: actions/cache@v2
id: npm-cache
with:
path: '**/node_modules'
key: ${{ runner.os }}-node-${{
hashFiles('**/package-lock.json') }}
- name: Install dependencies if not cached
if: steps.npm-cache.outputs.cache-hit != 'true'
run: npm install
- name: run eslint check for code errors
run: npm run lint
如您所见,三个作业非常相似。我们检出项目,使用指定的节点版本设置节点环境,并检查缓存。缓存的键包含运行时的操作系统版本和package-lock.json文件的哈希值,当依赖项(版本更新、新库等)发生变化时,这个哈希值会改变。
接下来,我们有一个条件安装步骤,它只在未命中缓存时安装依赖项。这发生在我们的缓存名称更改时,如前所述,或者如果缓存过期(这发生在至少一周未使用后)。
最后,我们执行我们的类型检查/Prettier/ESLint 步骤。虽然这种并行化看起来相当复杂,但在大规模使用时可以节省您大量时间。因此,您应该花些时间设置您的工作流程自动化,以确保它符合您的需求。
所有现代代码管理解决方案,如 GitHub、Bitbucket 和 GitLab,都深度集成了工作流程自动化工具。这意味着一旦您配置了工作流程自动化,您不仅会在工作流程自动化工具或部分中看到结果,还会在您的仓库中看到结果。例如,它将直接在提交列表中显示每个已测试提交的结果。
对于更多详细信息,您必须访问工作流程自动化工具或部分 – 在我们的案例中,GitHub Actions – 以查看 CI 管道的结果。如果一切按预期进行,您将看到一个绿色的勾选标记。如果工作流程检测到我们的任何检查中抛出了错误,我们将看到一个红色的点,这会通知我们我们的工作流程执行失败。
以下截图显示了一个包含多个工作流程运行的列表:

图 11.5 – GitHub Actions 中的工作流程运行
在这个例子中,我们的工作流程运行了两次成功,而其中一次失败了。失败的流程运行总是最有趣的,因为它提供了大量关于出错原因的信息。
点击它,你会看到有关日志和执行时间的详细信息,这样你就可以找到并修复错误。这是它在GitHub Actions中的样子:
![Figure 11.6 – GitHub Actions 中的失败工作流程运行
![img/B16694_11_06.jpg]
图 11.6 – GitHub Actions 中的失败工作流程运行
如你所见,我们不仅可以看到哪些检查失败,还可以看到详细的日志。在这种情况下,我们在Genre.tsx文件中使用了错误类型,这导致了一系列错误。通过这个工作流程,我们不仅找到了错误,还知道了我们必须修复错误的精确文件和行号。
注意
与 CI 管道一起工作,关键在于尽快提供反馈。你应该使用 Husky (bit.ly/prn-husky)等工具在将它们提交到本地机器之前运行你的管道。这不仅取代了你的工作流程自动化工具,还可以进一步缩短反馈周期。
现在你已经知道了如何创建 CI 管道来支持和改进开发过程,让我们来看看构建和发布应用。
理解工作流程自动化和 CD 对于构建和发布的重要性
在我们开始创建我们的管道之前,让我们先看看构建和发布应用的一般情况。Android 使用 Gradle 作为其构建工具,并使用 KeyStore 文件来验证应用的所有权。如果你不熟悉发布 Android 应用,请先阅读此指南:bit.ly/prn-android-release。
在 iOS 上,你必须使用 Xcode 来构建、签名和发布你的应用。如果你不熟悉这个过程,请先阅读此指南:bit.ly/prn-ios-release。
幸运的是,对于两个平台(Android 和 iOS),构建和部署过程可以通过命令行工具执行。Gradle 本身就是一个命令行工具,Xcode 提供了 Xcode 命令行工具。这意味着我们可以为整个过程编写脚本,然后我们可以使用我们的工作流程自动化工具调用这些脚本。
不幸的是,这些过程相当复杂,所以我们不想自己编写脚本。这就是一个名为Fastlane的工具集发挥作用的地方。Fastlane 是 iOS 和 Android 应用的专用自动化工具。它提供了用于签名、构建和将代码部署到 Apple App Store 和 Google Play 的脚本。你可以在这里找到有关 Fastlane 的更多信息:bit.ly/prn-fastlane。
我不推荐直接使用 Fastlane 的原因是它与 Bitrise 和 CircleCI 等高级工作流程自动化工具具有出色的集成。我们将以 Bitrise 为例进行深入了解,但其他工具如 CircleCI 和 Travis CI 的工作方式非常相似。
Bitrise 以与 GitHub Actions 相同的方式集成到您的代码管理解决方案中。您可以使用某些事件来触发工作流程。它提供了一个出色的 UI 来创建这些工作流程。我喜欢使用它,因为它相当简单,并且节省了大量时间。
您可以从大量预定义的操作中选择,这些操作主要关注 iOS 和 Android 应用程序。Bitrise 甚至为 React Native 应用程序提供自己的自动设置。以下图表显示了典型的 iOS 构建和部署工作流程:


图 11.7 – Bitrise iOS 构建和部署工作流程
步骤是按列执行的。因此,我们首先激活一个 SSH 密钥,以便能够连接到仓库。接下来,仓库被克隆。之后,安装npm依赖模块,以及通过 CocoaPods 安装的原生模块。
例如,对于可以在此集成的每个其他脚本,我们将获取我们应用 UI 的最近翻译文件,以便在下一步与应用程序包集成。然后,我们将更新Info.plist文件中的版本号。接下来,工作流程处理代码签名,构建应用程序,并将其部署到 App Store Connect。
Android 构建的工作流程看起来非常相似:



再次,操作是按列执行的。第一列与 iOS 工作流程中的相同。激活 SSH 密钥,克隆仓库,并安装npm依赖模块。接下来,我们必须安装所有缺失的 Android SDK 工具。
然后,我们必须更改 Android 版本代码,就像我们在 iOS 中做的那样,获取与应用程序捆绑的翻译。然后,我们必须构建应用程序并将其部署到 Google Play。
在幕后,Bitrise 和其他具有图形工作流程编辑器的 CI 工具使用您在设置开发 CI 管道时了解到的相同逻辑。以下代码是为 iOS 工作流程的.yml文件:
ios-release-build:
steps:
- activate-ssh-key@4:
run_if: '{{getenv "SSH_RSA_PRIVATE_KEY" | ne ""}}'
- git-clone@4: {}
- npm@1:
inputs:
- command: install
- cocoapods-install@2: {}
- script@1:
inputs:
- content: |-
cd scripts
bash getTranslationsCrowdin.sh
- set-ios-info-plist-unified@1:
inputs:
- bundle_version: „$VERSION_NUMBER_IOS"
- info_plist_file: "$BITRISE_SOURCE_DIR_PLIST"
- manage-ios-code-signing@1:
- xcode-archive@4.3:
inputs:
- project_path: "$BITRISE_PROJECT_PATH"
- distribution_method: app-store
- export_method: app-store
- deploy-to-itunesconnect-deliver@2:
如您所见,它具有相同的结构。它包含多个步骤,这些步骤可以作为配置获取额外的输入。像任何其他工作流程自动化工具一样,Bitrise 使用环境变量。这些变量存储在平台上,并在工作流程执行期间替换占位符(在这里,它们以$开头)。
注意
你永远不应该将私钥或签名信息添加到你的仓库中。如果发生了这种情况,任何可以访问仓库的人都可以获取这些私有数据,并能够为你的应用程序签名发布。将此信息存储在你的自动化工作流程工具中会更好,因为在那里,没有人可以获取密钥和签名证书,但所有有访问权限的开发者仍然可以创建新的发布。
此工作流程可以是手动触发的,我建议用于公共生产构建,或者自动触发的,我建议用于内部或公共测试构建。
摘要
现在,是时候总结本章内容了。首先,你学习了工作流程自动化、持续集成和持续交付这些术语的含义,以及它们在应用开发中的应用。然后,你考虑了一个适用于大规模项目的开发流程。
接着,你学习了如何通过简单的自动化工作流程工具,如 GitHub Actions,来支持这个流程。最后,你了解了专门的自动化工作流程工具,如 Bitrise,这样你就可以构建、签名和部署你的 iOS 和 Android 应用。
在本章中,有一个特别重要的主题——测试——在关于工作流程自动化的讨论中被遗漏了。自动化测试在开发阶段以及发布前都很重要。因此,我们将在下一章详细探讨自动化测试。
第十二章:React Native 应用程序的自动化测试
自动化测试是当您的项目增长时必须做的最重要的事情之一。它可以帮助确保您应用程序的某个质量水平,并允许您在不引入每个版本中的错误的情况下运行更快的发布周期。我建议尽快为您的应用程序编写自动化测试。
从一开始就编写测试要容易得多,因为这样您被迫以适合自动化测试的方式组织代码。如果一开始没有关注这一点,那么重构应用程序以使用自动化测试可能会很困难。
在本章中,您将了解自动化测试的一般知识以及如何在 React Native 应用程序中使用自动化测试。您将了解不同类型的自动化测试的不同工具和框架。这些工具和框架被世界上一些最广泛使用的应用程序在生产中采用,因此我建议使用它们。
为了给您提供一个关于所有这些主题的良好概述,本章将涵盖以下主题。如果您已经熟悉自动化测试的一般知识,您可以跳过第一部分:
-
理解自动化测试
-
在 React Native 中使用单元和集成测试
-
使用组件测试
-
理解端到端测试
技术要求
要运行本章中的代码,您必须设置以下内容:
-
一个有效的 React Native 环境(bit.ly/prn-setup-rn – React Native CLI 快速入门)。
-
尽管本章的大部分内容也应该适用于 Windows,但我建议在 Mac 上操作。您需要在 Mac 上操作才能在 iOS 模拟器上运行 Detox 端到端测试。
-
一个 AWS 账户,用于访问 AWS Device Farm。
理解自动化测试
自动化测试有不同的形式。以下是一些最常见的自动化测试形式,并将在本章中进行介绍:
-
单元测试:单元测试覆盖业务逻辑的最小部分,例如单个函数。
-
集成测试:这种测试形式在 React Native 中与单元测试非常相似,但它覆盖了多个业务逻辑部分,并测试这些部分的集成是否按预期工作。
-
组件测试:这些测试覆盖您的 React Native UI 组件,并检查它们是否按预期执行。您还可以使用这种测试形式检查组件中的(意外)变化。
-
端到端测试:这种测试形式模拟最终用户的行为,并检查您的整个应用程序是否按预期运行。
要充分利用自动化测试,您应该实现所有四种类型的测试。所有这些测试都覆盖了应用程序的不同领域,并可以帮助您找到其他测试类型无法发现的错误。
当使用自动化测试时,你应该尽量提高 代码覆盖率。代码覆盖率描述了你的代码中由自动化测试覆盖的部分百分比。虽然这是一个很好的指标,可以了解自动化测试是否在项目中使用,以及你没有忘记应用程序的任何部分,但它本身意义不大。
这是因为为你的每一行代码编写一个测试并没有帮助。当使用自动化测试,尤其是单元测试、集成测试和组件测试时,你应该总是为你要测试的部分编写多个测试,覆盖最常见的用例以及重要的边缘情况。这意味着在编写测试之前,你必须深思熟虑。
使用单元测试、集成测试和组件测试时,你通常测试应用程序的小部分。这也意味着你必须创建一个环境,使这些小部分可以独立工作。这可以通过模拟在测试部分中使用的依赖项来实现。
Mocking 指的是为测试环境编写依赖项的自己的实现,以确保它按预期行为,并排除依赖项中的错误导致测试错误的可能性。
注意
并非总是清楚应用程序的哪些部分应该在测试中进行模拟。我建议在单元测试中模拟更多而不是更少,因为你想要测试代码的非常小部分是否按预期行为。在集成和组件测试中,我建议模拟更少而不是更多,因为你想要测试应用程序的更大部分,并查看整个组合是否工作。
因为单元测试、集成测试和组件测试在测试环境中运行,并且只使用你的应用程序的部分,所以它们非常可靠。没有多少事情可以干扰这些测试,从而扭曲测试结果。这与处理端到端测试不同。
这些测试在模拟器或真实设备上的真实应用程序上运行,并依赖于诸如网络连接或其他设备行为等因素。这可能导致 测试不可靠。不可靠的测试是指在没有任何代码更改的情况下,在不同测试运行中通过和失败测试。
这是一个真正的问题,因为它导致你必须手动检查测试是否失败仅仅是因为它是不可靠的,还是因为它发现了你应用程序中的错误。我们将在 理解端到端测试 部分更详细地介绍测试不可靠性。
但首先,我们将通过使用单元测试和集成测试自动测试我们应用程序的业务逻辑部分。
在 React Native 中使用单元测试和集成测试
当你开始一个新的 React Native 项目时,它自带一个名为 Jest 的预配置测试框架。这是单元测试、集成测试和组件测试的推荐框架。我们将在以下部分中使用它。
让我们从单元测试开始。我们将再次使用我们的示例项目,但我们将回滚几个提交,使用本地电影服务实现。你可以在示例仓库中选择 chapter-12-unit-testing 分支来查看完整的代码。
这个本地服务实现非常适合作为单元测试的例子,因为它没有依赖项。我们知道它正在处理的数据,并且可以很容易地编写测试。在这个例子中,我们将测试两个 API 调用:getMovies 和 getMovieById。
以下代码展示了我们的第一个单元测试:
import {getMovies,getMovieById} from '../src/services/movieService';
describe('testing getMovies API', () => {
test('getMovies returns values', () => {
expect(getMovies()).toBeTruthy();
});
test('getMovies returns an array', () => {
expect(getMovies()).toBeInstanceOf(Array);
});
test('getMovies returns three results', () => {
expect(getMovies()).toHaveLength(46);
});
});
describe('testing getMovieById API', () => {
test('getMovies returns movie if id exists', () => {
expect(getMovieById(892153)).toBeTruthy();
});
test('getMovies returns movie with correct information,
() => {
const movie = getMovieById(892153);
expect(movie?.title).toBe('Tom and Jerry Cowboy Up!');
expect(movie?.release_date).toBe('2022-01-24');
});
test('getMovies returns nothing if id does not exist', ()
=> {
expect(getMovieById(0)).toBeFalsy();
});
});
前面的代码包含六个测试,分为两个部分。第一部分包含所有关于 getMovies API 调用的测试。第一个测试确保 getMovies 调用返回了一个值。第二个测试检查 getMovies 是否返回一个数组,而最后一个测试验证返回的数组长度是否符合我们预期的长度。
注意
你可能想知道为什么在这里需要三个测试,因为最后一个测试在第一个或第二个测试失败时就会失败。这是因为它为我们提供了有用的信息,使我们能够看到哪些测试失败了。这使得调试和搜索更改或错误变得容易得多。
在代码示例的第二部分,我们测试了 getMoviesById 块。同样,我们有三个测试。第一个测试验证 API 调用返回了一个已知存在的电影 ID 的值。第二个测试检查返回的是否是正确的电影。第三个测试确保 getMovieById API 调用对于我们知道不存在的 ID 不返回任何内容。
如你所见,在测试一个函数时,你不应该只写一个单元测试;你应该尝试至少覆盖以下区域:
-
检查现有和非现有返回值
-
检查预期的数据类型
-
检查返回的值是否与你的预期数据匹配
-
如果你处理范围,请为边缘情况编写测试
-
如果你遇到了一个错误,请使用单元测试来重现它,以确保它永远不会再次遇到
使用 Jest 编写集成测试与单元测试非常相似。区别在于你测试的是应用程序的更大部分。虽然术语并不总是统一的,你可以在 React Native 文档中找到一个好的定义(bit.ly/prn-integration-tests)。以下四个点中至少有一个为真时,它被视为集成测试:
-
结合你的应用程序的几个模块
-
使用外部系统
-
向其他应用程序(如天气服务 API)发起网络调用
-
进行任何类型的文件或数据库 I/O 操作
在进行集成测试时,有一件事非常重要,那就是模拟。当使用 Jest 作为测试运行器运行测试时,你没有任何应用程序的本地部分可用;你的测试在仅 JavaScript 环境中运行你的 JavaScript 代码。
这意味着您至少需要模拟应用程序的所有原生部分。Jest 提供了对模拟代码不同部分的高级支持。您可以在此处查看详细文档:bit.ly/prn-jest-mocking。
单元测试和集成测试的工作方式与服务器应用程序或用其他语言编写的应用程序的测试非常相似,但组件测试是一种仅针对前端测试的类型。这就是我们接下来要探讨的。
与组件测试一起工作
当在 React Native 中使用组件测试时,推荐的解决方案是使用react-native-testing-library。这个库与 Jest 兼容,为您的 JavaScript 应用程序添加了一个渲染环境,并提供多个有用的选择器和其它功能。
最简单的组件测试类型是检查(意外的)变化。这被称为快照测试。组件将被渲染并转换为 XML 或 JSON 表示,称为快照。这个快照与测试一起存储。下次测试运行时,它将用于检查变化。
以下代码示例展示了我们示例应用程序中HomeView组件的快照测试:
import React from 'react';
import HomeView from '../src/views/home/Home.view';
import {render} from '@testing-library/react-native';
const genres = require('../assets/data/genres.json');
describe('testing HomeView', () => {
test('HomeView has not changed', () => {
const view = render(
<HomeView genres={genres}
name={'John'}
onGenrePress={()=>{}}/>,
);
expect(view).toMatchSnapshot();
});
});
这个代码示例展示了在结构化代码时考虑测试的重要性。我们可以简单地从Home.view导入HomeView组件,并在渲染时传递属性。
我们不需要模拟任何存储或外部依赖。这使得创建第一个快照测试变得非常容易。我们使用react-native-testing-library中的render函数来创建组件的快照表示。然后,我们期望它与我们的存储快照相匹配。
虽然快照测试可以非常有用,以发现意外的变化,但它只在我们有变化时提供信息。为了获取更多关于变化的信息并检查一切是否按预期工作,我们必须创建更高级的组件测试。
以下代码示例展示了我们如何检查组件是否渲染了有效的内容:
test('all list items exist', () => {
render(<HomeView genres={genres}
name={'John'}
onGenrePress={() => {}} />);
expect(screen.getByText('Action')).toBeTruthy();
expect(screen.getByText('Adventure')).toBeTruthy();
expect(screen.getByText('Animation')).toBeTruthy();
});
在这个测试中,我们将我们的genres.json文件中的所有三个流派传递给HomeView组件。再次,我们使用react-native-testing-library中的render函数来渲染它。渲染后,我们使用测试库中的另一个函数screen。
使用这个函数,我们可以查询渲染到模拟屏幕上的值。这就是我们尝试找到我们期望存在的三个流派标题的方式,我们通过使用toBeTruthy来检查它们。
接下来,我们将更进一步,检查我们是否可以点击列表项:
test('all list items are clickable', () => {
const mockFn = jest.fn();
render(<HomeView genres={genres}
name={'John'}
onGenrePress={mockFn} />);
fireEvent.press(screen.getByText('Action'));
fireEvent.press(screen.getByText('Adventure'));
fireEvent.press(screen.getByText('Animation'));
expect(mockFn).toBeCalledTimes(3);
});
在这个测试中,我们使用react-native-testing-library中的fireEvent函数在每一个列表项上创建一个点击事件。为了检查点击事件是否触发了我们的onGenrePress函数,我们向组件传递了一个使用jest.fn()创建的 Jest 模拟函数。
这个模拟函数在测试期间收集了大量信息,包括它在测试期间被调用的频率。这是我们在这个测试中要检查的内容。然而,我们可以更进一步。
我们不仅能够检查模拟函数是否被调用,还能检查它是否以正确的参数被调用:
test('click returns valid value', () => {
const mockFn = jest.fn();
render(<HomeView genres={genres}
name={'John'}
onGenrePress={mockFn} />);
fireEvent.press(screen.getByText('Action'));
expect(mockFn).toBeCalledWith(genres[0]);
});
这个例子只在按下事件发生时触发,然后检查传递给函数的参数是否正确。由于Action类型是genres数组中的第一个,我们期望onGenrePress函数与它一起被调用。
再次强调,这些类型的测试之所以如此简单,仅仅是因为我们有一个良好的代码结构。如果我们没有将主页拆分为业务逻辑和视图,我们就必须处理我们的导航库,以及我们的全局状态管理解决方案。虽然这在大多数情况下是可行的,但它使得你的组件测试变得更加复杂。
注意
将单元测试、集成测试和组件测试集成到你的持续集成(CI)开发流程中是个好主意。你至少应该在打开拉取请求时运行这些测试。如果你的设置允许,你还可以在每次提交时运行它们,以实现更快的反馈循环。
我还建议要求达到一定程度的代码覆盖率,以确保所有开发者都为他们的代码编写测试。
你迄今为止所了解的所有测试类型都只在你模拟的环境中测试和使用了应用程序的某些部分。然而,当涉及到端到端测试时,情况就改变了。
理解端到端测试
端到端测试的想法非常简单:这些测试试图模拟现实世界的用户行为,并验证应用程序是否按预期运行。通常,端到端测试作为黑盒测试运行。
这意味着测试框架并不知道正在被测试的应用程序的内部功能。它运行在应用程序的发布构建版本上,这个版本将被发布。
理解端到端测试的作用
初看之下,端到端测试似乎是对自动化测试的万能药。难道仅仅通过端到端测试测试我们应用程序的所有场景就足够了吗?我们甚至还需要其他类型的测试,比如单元测试、集成测试或组件测试吗?
这些问题的答案非常简单。端到端测试功能强大,但它们也有一些特性,使得它们只能很好地覆盖某些场景。首先,端到端测试运行时间较长,因此使用端到端测试测试更复杂应用程序的所有功能可能需要多达数小时。
这意味着它们不能在每次提交时运行,这使得反馈循环变得很长。因此,这种场景不能集成到 CI 开发流程中,例如在第十一章中描述的创建和自动化工作流程。其次,端到端测试本身具有不稳定性。
这意味着这些测试可以在没有代码更改的情况下在不同测试运行中通过或失败。一个原因是应用程序可以在不同的测试运行中表现出不同的内部行为。例如,多个网络请求可以在不同的测试运行中以不同的顺序解决。
这对最终用户来说没有问题,但对于尝试尽可能快地运行交互的自动化端到端测试来说,可能会出现问题。测试不稳定性的另一个原因是测试运行的实际情况。
当测试设备在测试运行期间遇到网络连接问题时,测试将失败,即使它应该通过。现代测试框架试图尽可能减少这些问题,但它们还没有完全解决。
我建议您为应用程序中最常用的路径使用端到端测试。这可以包括账户创建和登录,以及您产品的核心功能。
注意
作为一名开发者,您应该始终确保在确保产品质量和保持开发速度之间保持平衡。过多的端到端测试可以提高质量,但会显著降低您的开发或发布流程的速度。
现在我们已经了解了端到端测试的一般情况,让我们开始编写我们的第一个测试。
使用 Detox 编写端到端测试
Detox 是一个最初为 React Native 应用程序开发的端到端测试框架。它不是一个真正的黑盒测试框架,因为它将自身的客户端注入到被测试的应用程序中。这样做是为了减少测试的不稳定性,这效果相当好,但也不能完全防止测试的不稳定性。
这也意味着您不会发送与测试相同的二进制文件。通常,这应该不会成问题,因为您只需使用相同的代码和配置构建另一个二进制文件,只是您会将 Detox 客户端捆绑到您的二进制文件中,但我想在这里提一下。
正常的 Detox 测试流程如图所示:

图 12.1 – Detox 测试流程
如您所见,在运行测试之前,您必须创建应用程序的生产版本。根据您创建构建的机器以及应用程序的大小,这可能需要一些时间。接下来,您运行测试。完成之后,测试环境将被拆解,以便您可以处理测试结果。
虽然这个过程在运行测试时效果很好,但在编写测试时可能会相当烦人。Detox 在使用测试 ID 来识别您想要与之交互的元素时效果最佳。这意味着您需要触摸您的代码,并给这些元素添加测试 ID。
这也意味着每次你需要更改代码中关于测试 ID 的任何内容时,你都必须创建一个新的构建。幸运的是,在编写测试时,你可以使用另一个过程。你还可以在开发构建中使用 Detox,这导致以下过程:

图 12.2 – 编写测试的 Detox 流程
当与开发构建一起工作时,你只需要创建一次本地的开发构建。正如你所知,JavaScript 包将在开发过程中从运行在你电脑上的 Metro 服务器获取。
这意味着你可以运行你的测试。如果你意识到你需要更改测试 ID,你可以简单地应用它们并重新启动测试。然后,开发构建将从 Metro 服务器获取新的 JavaScript 包并运行测试。这可以节省很多时间。
既然你已经了解了 Detox 的基本知识,让我们开始使用它。这本书没有包括安装它的详细步骤指南,因为过去的安装步骤变化相当频繁。所以,请查看 Detox 文档中的官方安装指南:bit.ly/prn-detox-start。
如果你在使你的 Detox 测试工作时有困难,你可以查看 GitHub 上的示例项目,项目地址为 chapter-12-detox-testing。
编写 Detox 测试与编写组件测试非常相似,因为 Detox 使用 Jest 作为其推荐的测试运行器。然而,与 Detox 一起,我们在真实世界的场景中运行测试,针对真实的应用。这意味着我们不需要进行模拟,因为我们需要的所有东西都是可用的。在我们开始编写测试之前,我们必须向我们要与之交互的组件添加测试 ID。
以下示例展示了 Home.view.tsx 的一个片段:
<Pressable
key={genre.name}
onPress={() => props.onGenrePress(genre)}
testID={'test' + genre.name}>
<Text style={styles.genreTitle}>{genre.name}</Text>
</Pressable>
在这里,你可以看到用于显示类别的 Pressable 组件。我们向这个组件添加了一个 testID 属性,这使得它在我们的测试中可以被识别。
以下代码示例展示了我们应用的简单 Detox 测试。你还可以在示例项目仓库中的 e2e/movie.e2e.js 找到它:
describe('Movie selection flow', () => {
it('should navigate to movie and show movie details',
async () => {
await device.launchApp();
awaitexpect(element(by.id('testAction'))).
toBeVisible();
await element(by.id('testAction')).tap();
await expect(element(by.id('testmovie0'))).
toBeVisible();
await element(by.id('testmovie0')).tap();
await expect(element(by.id('movieoverview'))).
toBeVisible();
});
});
首先,我们告诉 Detox 启动我们的应用。然后,我们等待具有 testAction ID 的类别可见。接下来,我们点击 Pressable 组件。对于电影,我们做的是相同的,但我们不使用电影名称作为 ID,而是使用列表索引。最后,我们验证电影概述文本是否显示。
这个例子很好地展示了端到端测试的优点和缺点。一方面,我们只需要几行代码就能导航到三个不同的屏幕并验证内容。这意味着我们可以相当自信地认为应用在这些屏幕上不会崩溃。另一方面,构建应用、将其加载到模拟器中、启动它并运行测试需要花费很多时间。
虽然 Detox 可以在真实设备上运行,但它主要与模拟器一起使用。这些模拟器可以在 CI 环境中运行,因此可以轻松集成到自动化工作流程中。
但你甚至可以将你的自动化工作流程中的端到端测试集成更进一步。虽然在这些模拟器上运行这些测试是有用的,但将它们运行在实际设备上会更好。特别是在 Android 上,你拥有数千种不同的设备,你应该至少测试最常见的那些。
在某些特定设备或操作系统版本上出现一些错误并不罕见。由于你不想购买数百台设备进行测试,你可以使用 AWS Device Farm 等设备农场。不幸的是,Detox 在这些环境中无法工作,所以你必须使用 Appium 作为测试框架。这就是我们将要探讨的内容。
理解 Appium 和 AWS Device Farm
与 Detox 不同,Appium 是一个真正的黑盒测试框架。它作用于你的发布二进制文件,因此测试了你想要发布的代码。它最初并不是为 React Native 设计的,而是为原生 Android 和 iOS 测试设计的。尽管如此,你仍然可以很好地使用它来测试 React Native 应用程序。
Appium 是一个非常成熟的框架。在撰写本文时,Appium 的第 2 版仍在开发中,尚未准备好使用,因此这里提供的示例是关于 Appium 的第 1 版。
该框架由多个部分组成,当你与 Appium 一起工作时,你必须理解这些不同的部分。以下图表显示了这些不同的部分:

图 12.3 – Appium 框架组件
Appium 的核心是一个 Node.js 服务器,它从 Appium 客户端接收测试命令。这个客户端就是你将编写测试的地方。它可以编写为不同的语言,如 JavaScript、Java、C#或 Python。
由于你不想仅为了编写测试而引入另一种语言,我建议在这里使用 JavaScript 实现。然后服务器使用 Appium 驱动程序与原生测试框架通信,这些框架用于在真实的 Android 和 iOS 设备上运行测试。
Appium 还提供了一个桌面应用程序,它有一个非常有用的检查器模式。当你不使用测试 ID 时,你可以使用此模式来查找标识符以编写你的测试。
由于 Appium 的安装过程将随着 Appium 版本 2 的发布而显著变化,这本书没有包含安装的详细步骤指南。你可以在官方 Appium 文档中找到这些说明:bit.ly/prn-appium-installation。
在我看来,当与设备农场结合使用以在多个真实设备上运行测试时,使用 Appium 与 React Native 结合才真正有趣。否则,我建议坚持使用 Detox,因为它更容易安装、配置和维护。但遗憾的是,Detox 不支持在设备农场上运行。所以,再次,你不得不在那里使用 Appium。
其中一个设备农场是 AWS 设备农场。这是一个 Amazon 服务,它为您提供了访问数百种不同真实移动设备模型的机会。您可以通过网络浏览器手动上传和安装您的应用程序,或者在这些设备上运行自动化测试。
这种自动化测试正是我们将要做的。以下图表显示了在 AWS 设备农场上运行 Appium 测试的过程如何与您的自动化工作流程集成:

图 12.4 – 在 AWS 设备农场上运行自动化测试
您可以通过工作流程自动化或 CI 工具(如 Bitrise)以编程方式访问 AWS 设备农场,或者通过您的网络浏览器手动访问。在两种情况下,您都必须上传一个待测试的 Android APK 或 iOS IPA 文件和一个测试包。
此包是一个.zip文件,其中包含测试以及一些 AWS 设备农场的配置。您还可以选择用于测试的设备池。设备池是在 AWS 设备农场控制台中可以创建的设备集合。
AWS 将在您的设备池中的每个设备上运行您的测试,并收集测试结果。这些结果将在 AWS 设备农场控制台中显示,也可以传递回您的工作流程自动化或 CI 工具。
以下截图显示了 AWS 设备农场中测试运行的概览:

图 12.5 – AWS 设备农场结果屏幕
此概览显示了一个测试运行,在每个选择的设备池的每个设备上执行了三个测试。所有测试都通过了,除了两个。这意味着可能存在一个错误,导致两种测试在一个设备类型上失败,或者有两个测试是易变的。
这需要您进行调查。幸运的是,AWS 设备农场提供了每个测试运行的日志、截图和视频记录,以便您可以轻松地找出发生了什么。
由于在本地和 AWS 设备农场使用 Appium 的安装和配置过程并不简单,我创建了一个演示仓库,您可以从这里开始。它还包含详细的设置和安装指南,以及用于在本地运行 Appium 测试和为 AWS 设备农场创建测试包的有用脚本。您可以在以下链接找到它:bit.ly/prn-appium-aws-repo。
现在,让我们总结本章内容。
概述
首先,您学习了为什么自动化测试很重要,以及 React Native 应用程序存在哪些类型的测试。然后,您学习了如何使用 Jest 和react-native-testing编写单元测试、集成测试以及组件测试。
最后,你在涵盖两个不同框架:Detox 和 Appium 的过程中学习了端到端测试。完成这一章后,你应该明白自动化测试是大规模项目的一个关键部分,并且每种测试类型都很重要,因为它覆盖了不同的领域。
现在你已经学习了使用 React Native 编写大规模应用程序的基础知识,在这本书的最后一章,我将分享我的经验之谈,并对未来几年 React Native 的发展趋势进行展望。
第十三章:技巧与展望
本章分为两部分。在第一部分,我收集了关于如何使你的 React Native 项目成功的最有用的技巧。这些技巧来自于我在作为开发者、顾问、软件架构师或产品所有者参与的大量不同的 React Native 项目中所学到的知识。我还将 React Native 作为我自己的公司中的技术栈,我负责业务方面,因此我也了解这一方面的需求和痛点。
第二部分是对我认为 React Native、其社区及其生态系统未来如何发展的展望。这是基于其技术发展以及社区中不同大玩家的承诺。
这意味着你将在本章学习以下内容:
-
理解使你的 React Native 项目成功的重要因素
-
光明的未来
技术要求
由于这是一个完全理论性的章节,因此没有技术要求。
理解使你的 React Native 项目成功的重要因素
在这本书中,你学到了很多关于如何确保 React Native 项目成功的专业技术基础。但如果你已经参与过生产项目,你知道软件项目永远不会像书中描述的那样工作。总会有突如其来的障碍和问题,以及看似不可能实现的截止日期。
这些技巧将确保你能够克服这些障碍,解决问题,并在现实世界的软件项目中最终取得成功。所以,让我们立即开始介绍这些技巧。
技巧 1 – 找到一个你永远不需要绕过的流程
我参与过的许多项目一开始都有明确的过程定义,但通常会出现有人绕过流程的情况。一个非常常见的例子如下:
业务方面需要将功能或错误修复包含在今天的版本中,导致测试减少,审查不够详细,甚至直接提交到发布分支。
这是我在很多项目中都经历过的事情。问题是,在几乎所有我经历的情况中,这最终导致了更多的工作。测试必须延后进行,发现的错误必须修复,直接的提交必须稍后合并或选择,大多数时候代码必须重构,在最坏的情况下,一个错误可能导致需要稍后修复的数据损坏。因此,这种行为的额外工作可能会使你按照流程进行的工作量增加 10 倍或更多。
所以,简单的回答可能是对业务方面说“不”,但这种情况并不总是可能的,因为业务方面可能有一个合理的担忧。想象一下,首席执行官(CEO)向一个重要客户承诺了一个功能,并担心由于下一个版本只在一周后发布,他们可能无法按时交付。
这是一个你必须调整你的流程以实现更快的发布周期或允许紧急发布的例子,以消除 CEO 的担忧。
这只是一个例子,也是针对这个具体例子的一个具体解决方案。还有许多其他场景,团队成员可能会对流程失去信心,并试图绕过它。
有时,解释过程背后的原因就足够了;在其他时候,则需要适应。但本小节的要点是:找到你信任的过程,永远不要绕过它。
提示 2 – 计划尽可能灵活,使用无存储发布更新的策略
项目团队越大,出现影响发布计划的问题的可能性就越大。再次强调,我想用一个例子来开始这个提示。我正在开发的一个应用程序被翻译成了 36 种语言。这意味着在每次发布之前,所有引入到用户界面(UI)的文本都会被传递给翻译人员。
他们有 36 小时的时间翻译和验证这些文本,并将它们上传到我们的翻译服务。在这 36 小时之后,我们运行了发布管道,并将包含翻译的二进制文件发布的应用程序发布出去。
这导致了两个问题。首先,我们必须等待 36 小时才能将发布版本提交给苹果/谷歌进行审查。其次,大多数时候,至少有一位翻译人员迟到,导致新文本在该语言中直到下一次发布才可用。
我们通过为我们的应用程序添加所有翻译的更新功能来解决此问题。此功能在下图中展示:
![图 13.1 – 无存储发布更新
![图片/B16694_13_01.jpg]
图 13.1 – 无存储发布更新
我们仍然将翻译文件打包到二进制文件中,并带有这些翻译的发布版本发送出去。但在应用程序启动时,我们会在我们的服务器上搜索更新的翻译。如果我们找到任何,我们会获取并持久化它们,以确保用户设备上始终有最新版本可用。更详细的解释可以在这里找到:bit.ly/prn-update-translations。
这个流程不仅适用于翻译,也适用于任何应本地可用且频繁更改的数据类型。
你甚至可以更进一步,使用如 Microsoft CodePush 或 Expo Updates 之类的空中更新工具。这些工具利用了 React Native 应用程序是一个包含 JavaScript 包的原生应用程序的事实,通过提供一种在空中更新整个 JavaScript 包的解决方案。
基本上,你的应用程序连接到工具的服务器并搜索更新的 JavaScript 包。如果找到更新包,它将被下载,你的应用程序将启动/重启。
虽然这些工具对于修复 bug 甚至改进功能非常有帮助,但根据 App Store 和 Google Play 的指南,不允许使用它们来引入新功能。此外,你必须记住,它们仅限于 JavaScript 包。
一旦你引入了新的原生功能、资产或其他内容,这些工具就无法提供此类更新。更糟糕的是,如果你尝试使用这些工具提供此类更新,你可能会在用户的设备上破坏你的应用程序,因为你试图访问不存在的原生功能。
因此,如果你使用这些工具,请务必小心。在这里,你可以阅读更多关于 CodePush 的信息(bit.ly/prn-ms-code-push),而在这里,你可以找到关于 Expo 更新的更多详细信息(bit.ly/prn-expo-updates)。
所有这些想法只有一个目标:尽可能灵活,以便能够应对可能出现的任何要求。尽管苹果和谷歌通常在不到 1 天内完成审查,使得原生发布到 App Store 或 Google Play 不再是一个大问题,但了解即使苹果或谷歌延迟审查流程,也能提供更新是很好的。
所以,本小节的要点是:制定一个策略,以便尽可能快和灵活地更新你的应用程序。
小贴士 3 – 总是使用稳定性监控工具关注你的应用程序中发生的事情
在软件开发中,唯一确定的事情是,没有没有 bug 的软件。所以,你的应用程序将包含 bug,用户可能会遇到问题。唯一的问题是:你何时会注意到它?
在过去几年中,我学到的最重要的事情之一是,你对应用程序中发生的事情了解得越多,你就能越快地做出反应。最糟糕的情况是,你只是在用户对你的应用程序写差评后才发现了一个 bug。
有很多不同的稳定性监控工具作为软件即服务(SaaS)产品可用。在 React Native 应用程序中,最广泛使用的是 Bugsnag 和 Sentry。两者都提供了出色的 React Native 支持,通过提供 React Native 软件开发工具包(SDKs)。
这些 SDK 收集原生崩溃以及 JavaScript 错误,添加有关设备类型和状态的有用信息,并将它们发送到服务器。服务器整合数据,这些工具提供了一个网络仪表板,你可以从中获取有关应用程序稳定性的信息。
你可以查看每一个崩溃和错误,甚至通过提供源映射来追踪错误回溯到代码中的特定行。
当出现以前未见过的 bug 时,你还可以将这些工具连接到你的项目管理工具,以自动创建问题。
还有其他解决方案可供选择,你甚至可以自己编写,但你应该确保实施任何解决方案来跟踪你应用的稳定性。所以,这个技巧的要点是:使用稳定性监控工具。
提示 4 – 让用户通过 A/B 测试进行测试
A/B 测试是现在在移动应用开发的许多领域都使用的一种方法,你绝对应该使用它。这意味着你将你的用户分成两组,并向他们提供应用的不同部分。然后,你等待一段时间,查看指标,以查看哪个用户组在你关心的指标中表现更好。
在移动开发中,A/B 测试最常见的使用案例是测试新功能。如果你不确定新功能是否有助于提高你的目标指标(例如提高留存率),你将只向一半的用户提供新功能。
你将这些用户标记为组 A。其他没有访问新功能的用户将被标记为组 B。然后,你会等待并收集数据。一段时间后,你可以比较哪个用户组在目标指标方面表现更好。
这可以通过功能、设计、措辞、图片等等来实现。但 A/B 测试也可以用于完全不同的案例。
使用 A/B 测试的另一个例子是将应用更新仅发布给一组人。这可能是一个测试组,或者在 Google Play 上,你甚至可以决定只将更新推广到你用户的一定百分比。然后,你可以比较旧版本和新版本的老化稳定性指标,以将更新推广给所有用户。
因此,A/B 测试可以帮助你在真实世界环境中获得所需的答案,这是唯一重要的环境。所以,这个技巧的关键要点是:使用 A/B 测试收集信息,以便能够做出更好的决策。
提示 5 – 使用 TypeScript 作为单一语言栈
TypeScript 是一种在移动、网页和后端上工作的类型化语言。当你使用这种单一语言栈设置项目时,这是一个巨大的优势。你的整个团队至少能够阅读和理解整个项目的代码。
如果需要,有才华的软件开发人员也可以从后端转移到前端或相反方向,你甚至可以在客户端和服务器之间共享代码。这在你有共享的数据类型或业务逻辑时尤其有趣,这些逻辑在移动客户端上运行,在网页服务器上运行。
为此代码有一个共享的基础可以保证数据类型和业务逻辑行为在移动、网页和服务器之间不会有所不同。
我在单一语言栈项目中经历了最佳的结果,最快的上市时间(TTM),以及最好的团队合作。所以,这个技巧的要点是:使用 TypeScript 作为单一语言栈。
提示 6 – 保持你的代码简单和清晰
这个技巧看似明显,但让我解释一下我的意思。有一些简单的想法可以使你的代码保持简单和清晰。
当使用 React 和 React Native 时,你通常有很多不同的选项来解决特定的问题。你必须做出的第一个选择是在功能组件和类组件之间。
但还有许多其他的选择要做。你将使用哪种状态管理解决方案?你将如何连接你的后端?你会编写自己的原生解决方案吗?如果是这样,你将使用哪种语言?
如果你做出了这些选择,你应该坚持你的选择。如果你为状态组件同时使用功能组件和类组件,这会使应用程序更加复杂。同样适用于所有其他选项。做出选择并坚持下去。
接下来,你应该始终将可重用的代码提取到组件中。大多数情况下,创建一个带有一些配置选项的组件,而不是多次编写几乎相同的代码,要容易得多。
最重要的是,永远不要重复代码。这不仅会增加引入错误或不一致的风险,而且在长期来看也会花费更多的时间。维护一个精简的代码库,其中所有内容都提取到组件中,要容易得多。
最后,尽量编写可读的代码。在完成一个功能后,总是看看你的代码,问问自己是否有其他开发者可以在不阅读任何文档或运行应用的情况下理解你所写的内容。如果不能,尝试重命名和重构你的代码,直到它变得可理解。如果代码没有这些注释就无法工作,那么请添加注释。
目标是让新加入的开发团队能够尽可能快地开始产生效益。所以,这里的关键要点是:尽可能使用简单、清晰、易懂的代码,并尽量少使用不同的库。
使用这些技巧,你应该不仅能够生存,而且能够在你的下一个 React Native 项目中取得成功。
作为这本书的最后一部分,我想简要地展望一下 React Native 的未来。
理解 React Native 的光明未来
在决定使用哪种技术时,它总是对未来技术的长期适用性起着重要作用。这在长期运行的大型企业项目中尤为重要。因此,我决定以一些论据结束这本书,以证明你可以绝对确信 React Native 是一个好的选择。
这尤其有趣,因为过去几年对 React Native 开发者来说并不总是容易。基于非常高效的二维(2D)图形引擎 Skia 的 Flutter,为跨平台开发提供了一种新的解决方案,并引发了巨大的炒作。
随着 Kotlin 和 Swift 的兴起,原生开发变得越来越舒适。与此同时,React Native 并没有太多的发展。长期承诺的重构(新架构),最初宣布于 2020 年,比预期的花费了更多的时间。一些开发者开始对 React Native 失去信心。
但这一切在 2022 年发生了变化。现在,React Native 的未来前所未有的光明。这有多个原因,我将在本书的最后一节中解释。
原因 1 – 新架构终于落地
如第三章中所述,Hello React Native,新架构为 React Native 应用程序和 React Native 社区带来了巨大的推动。以下是新架构带来的最重要的改进:
-
通用性能:用JavaScript 接口(JSI)替换旧的 React Native 桥接消除了 React Native 最大的性能瓶颈。在 JavaScript 和应用程序的本地部分之间传递数据时不再需要序列化/反序列化。这以及其他许多优化使得缩小与 Flutter 应用或原生应用的性能差距成为可能。
-
启动时间:新架构允许应用程序的原生模块进行懒加载,这大大提高了启动时间。
-
同步通信:随着新架构的引入,现在可以从 JavaScript 线程内部调用原生函数,这可以使得代码更加简洁和易于编写。
-
编写原生模块:CodeGen 和新的架构总体上使得编写原生模块变得更加容易。内置的类型安全还支持开发中最重要的功能之一。
我预计新架构的全面推广将在 2023 年初完成。这意味着大多数社区库都将进行适配,你可以在一个稳定的环境中从所有改进中受益。
再次强调,这个新架构真的是一个很大的事情,因为它反驳了大多数反对使用 React Native 的论点。
下一个原因是 React Native 有一个新的架构方法。一个社区项目使得在 React Native 中使用 Skia 成为可能,这同样是一个巨大的进步。
原因 2 – React Native Skia
正如我之前所解释的,Skia 是不仅为 Flutter 提供动力,也为 Google Chrome、Android、Firefox 以及更多产品提供动力的图形引擎。Skia 是这些产品之所以如此受欢迎的原因之一,因为它是一个极其强大且性能卓越的图形引擎。
过去曾有一些尝试利用 Skia 在 React Native 中的力量,但只有在新架构下才可能创建一个可工作的 React Native Skia 库。这对于 React Native 来说又是一个巨大的推动,因为它在屏幕绘制方面开辟了一个全新的世界。
要了解 React Native Skia 带来的新机会的广度,你必须看看 React Native 中渲染元素是如何工作的。正如在第三章“Hello React Native”中解释的那样,每个在 JavaScript 代码中使用的组件都将映射到一个原生组件。这也意味着你只能使用在原生端可用并且已映射到 React Native 组件的元素和属性。
React Native Skia 使用相同的概念,但创建了一个可以使用 Skia 图形引擎绘制的原生画布。然后它不在 React Native 中提供原生组件,而是在 Skia应用程序编程接口(API)中提供。
这意味着在未来,如果你更喜欢使用自己的图形引擎将 UI 绘制到屏幕上而不是使用原生组件,你不必再选择 Flutter。这也可以使用 React Native 实现。你甚至可以在同一个应用程序中使用这两个概念。
你绝对应该看看这个项目。它在 GitHub 上托管;你可以在这里找到所有信息:bit.ly/prn-rn-skia。
React Native 拥有光明未来的下一个原因既简单又重要。该框架背后的社区仍在增长,包括许多对 React Native 下大注的大公司。
原因 3 – 社区
即使 React Native 最初是由 Facebook 创建的,但 React Native 已不再仅仅是 Meta(前身为 Facebook)。它还包括微软、Shopify、特斯拉、Salesforce、彭博社、Discord、Coinbase、Pinterest 以及许多其他公司。
这些公司也在大力押注 React Native。Meta 在 Facebook 应用中使用了超过 1,000 个屏幕,这仍然是世界上使用最广泛的应用之一。微软在他们的许多知名产品中使用 React Native,如 Microsoft Office 或 Microsoft Teams。Shopify 团队将他们的所有应用都重写为 React Native。
更好的是,这些公司中的大多数不仅使用 React Native,而且还在积极贡献。例如,微软为 Windows 和 macOS 创建了并维护 React Native。Shopify 赞助 React Native Skia,并支持多个其他社区项目,如 React Native FlashList。
而不仅仅是这些公司。这是全球成千上万的贡献者。这导致世界上最具活力的开发者社区之一,每天都在创建有用、高质量的开放源代码库和解决方案。
原因 4 – TypeScript 和 React
虽然它可能不是高性能计算任务的正确选择,但使用 TypeScript 作为移动应用程序的语言绝对是正确的选择。你可以在移动、Web 和服务器上运行它,以在这些平台之间共享代码。它易于学习和开始,新开发者可以非常快地变得高效。
如果你使用 TypeScript 作为你的单一语言栈,你将能够接触到最大的人才市场之一,这比 Dart(Flutter)、Kotlin(原生 Android)或 Swift(原生 iOS)开发者的市场要大得多。在信息技术人才非常稀缺的这些日子里,这一点尤为重要。
对于 React 来说,也是如此。它到目前为止是最受欢迎的 Web 框架。每一位 React 开发者都能够参与 React Native 项目,并且经过短短几天的培训后,也能非常高效地工作。这意味着你有一个非常大的人才库,你可以从中雇佣你的开发团队。
因此,当你决定在你的项目中使用 React Native 时,你不必感到害怕。对进一步开发这个框架有着巨大的承诺,你可以绝对确信它将会被长期积极地维护。许多大型公司都依赖于 React Native,他们这样做有很好的理由。所以,在我看来,它是编写移动应用的最佳选择。
概述
在展望了这些之后,现在是时候对这个章节做一个简短的总结了。在本章的第一部分,你学习了良好的开发过程是多么重要,如何尽可能灵活地发布,如何监控你应用的稳定性,如何利用 A/B 测试,如何在你的移动应用中使用 TypeScript,以及如何保持你的代码简单和整洁。在第二部分,你了解了 React Native 拥有光明未来的最重要的原因,并发现你可以绝对确信它是你移动应用的一个很好的选择。
现在,是时候给予最后的祝贺了。你已经完成了这本书的阅读,我希望你学到了许多新的、有用的东西。你现在应该已经理解了 React Native 是如何工作的,以及如何在大规模项目中使用它来构建适用于多个平台的高性能应用,这可以帮助你节省时间和金钱。


浙公网安备 33010602011771号