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/err… 并填写表格。
盗版: 如果您在互联网上发现我们作品的任何非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 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 引擎的重要部分以及它们如何协同工作以实现异步:
![图 2.2 – JavaScript 引擎异步代码执行
![图 2.2 – JavaScript 引擎异步代码执行
图 2.2 – JavaScript 引擎异步代码执行
你的命令将被推送到调用栈,并按照后进先出的顺序进行处理。为了实现异步,JavaScript 引擎提供了一些 API,这些 API 可以从你的 JavaScript 代码中调用。这些 API 在另一个线程上执行代码。大多数这些 API 期望一个作为参数传递的回调。
当第二个线程上的代码执行完成后,此回调将被推送到消息队列。消息队列由事件循环监控。一旦调用栈为空且消息队列不为空,事件循环就会从消息队列中取出第一个项目并将其推入调用栈。现在我们回到了 JavaScript 上下文,JavaScript 代码执行继续进行,并使用给定的回调。
比回调更好的是什么?Promises!
在 ES 2015 中,引入了 promises。在底层,它们的工作方式与回调非常相似,只是还有一个名为作业或微任务队列的另一个队列。这个队列的工作方式类似于消息队列,但在事件循环处理时具有更高的优先级。
与回调相比,promises 具有更简洁的语法。虽然您可以将任意数量的回调传递给一个函数,但 promise 返回一个具有确切一个或两个参数的函数——resolve和(可选的)reject。当 promise 成功处理时,调用resolve,如果在处理 promise 时发生错误,则调用reject。
以下代码显示了 promise 的通用示例及其使用方法:
const myPromise = () => new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 500);
});
console.log('start promise');
myPromise()
.then(() => {
console.log('promise resolved');
});
// start promise
// -- 500ms delay
// promise resolved
promise 是通过new Promise创建的,然后被调用。在 promise 内部,在 promise 解决之前有一个 500 毫秒的延迟。当 promise 解决时,.then内部的函数被调用。
使用 promise 的这种异步行为的一个简单示例是从服务器获取数据。您可以使用 JavaScript 中的 Fetch API。此 API 会联系服务器并等待响应。
一旦收到响应,resolve或reject就会被推送到队列中,并由事件循环处理。以下示例显示了简单 fetch 的代码:
fetch("https://fakerapi.it/api/v1/texts?_quantity=1")
.then(response => response.json())
.then(data => {
console.log(data);
})
.catch(error => {
console.log(error); // handle or report the error
})
此代码示例甚至包含两个 promises:
-
fetch操作,它返回服务器响应 -
包含在响应中的 JSON 数据的解包
如果其中一个 promise 被拒绝,catch块会调用并带有一些错误信息。
小贴士
您应该始终捕获错误和 promise 拒绝,并处理它们——或者至少报告它们。虽然未处理的 promise 拒绝在大多数情况下不会使您的应用程序崩溃,但它表明出了问题。如果没有适当的错误报告,这个错误可能很难发现和调试。始终使用报告工具,如 Sentry 或 Bugsnag 是一个好主意。您可以在第十四章,技巧、窍门和最佳实践中了解更多相关信息。
Promises 还提供了一些有趣的功能,例如Promises.all和Promises.first,这使得处理多个 promises 成为可能。如果您想了解更多关于这方面的信息,可以查看bit.ly/prn-promise…。
使用 async/await 改进的语法
在 ES 2017 中,引入了async和await关键字来处理 Promises。这是我在你的项目中推荐使用的语法,因为它使得代码易于阅读和理解。你不需要将.then与回调函数链接到 promise 调用上,你只需简单地awaitpromise。
唯一的要求是,你编写的代码函数必须声明为异步函数。你也可以用try/catch块来包装调用。这类似于常规的 promise 语法中的.catch。以下示例展示了如何使用async/await属性:
const fetchData = async () => {
try {
const response = await fetch(
"https://fakerapi.it/api/v1/texts?_quantity=1");
const data = await response.json();
console.log(data);
} catch (error) {
console.log(error);
}
}
fetchData();
我们使用async关键字将fetchData指定为异步函数。在异步函数内部,我们使用try/catch进行适当的错误处理。在try块内部,我们使用await关键字等待fetch调用和 JSON 主体的解包。
基本上,每个 promise 都可以与 async/await 语法一起使用。此外,异步函数可以用.then和.catch作为 Promise 来处理。再次强调,这是我在大型项目中推荐使用的语法。由于它与 Promises 兼容,你可以直接使用很多库。但是,当你必须与依赖于其 API 中的 promises 的库一起工作时,你必须修补它。
补丁回调库
当使用 React Native 时,你会发现一些库在它们的 JavaScript 中使用回调函数。这是因为 JavaScript 和 React Native 上下文之间的传输在大多数情况下依赖于回调函数。我建议修补这些库并重新工作,以提供 promise API,然后你可以在你的项目中使用 async/await。这很简单,并且大大提高了代码质量。以下代码块展示了一个非常简单的示例:
// libraryFunction(successCallback, errorCallback);
const libraryFunctionPromise = new Promise((resolve, reject) => {
libraryFunction(resolve, reject);
}
在这个代码示例中,我们有一个提供函数的库,该函数期望一个successCallback和一个errorCallback。我们创建了一个 promise,它只是调用这个函数,并将resolve作为successCallback,将reject作为errorCallback。就是这样,现在我们可以使用 async/await 来调用我们的 promise,然后它为我们调用库函数。
小贴士
尽可能地使用 async/await 语法而不是 promise。这使得你的代码更容易阅读和理解。
在本节中,你学习了 JavaScript 中异步的实现方式,回调和 promise 是如何工作的,以及为什么你应该依赖 async/await,尤其是在大型项目中。
这导致了本章的最后一节,这在大型项目中工作也非常重要——JavaScript 中的静态类型检查。
使用类型化 JavaScript
JavaScript 是一种动态类型语言。这意味着你可以在变量初始化后更改其类型。虽然这对于小型脚本来说可能非常有用,但在大型项目中工作可能会导致难以解决的问题。在拥有大量用户的 app 中调试此类错误可能会变得非常混乱。
这就是 JavaScript 扩展发挥作用的地方。有多个解决方案可以将 JavaScript 扩展为强类型语言。这不仅防止了错误,还使代码重构和代码补全以及直接在编写代码时指出问题成为可能。
这大大加快了开发过程。我肯定会推荐使用强类型 JavaScript,并且我想在这里介绍两种最流行的解决方案。
Flow
由 Facebook 创建并开源的 Flow 是一个与普通 JavaScript 一起工作的静态类型检查器。它最初是一个命令行工具,用于扫描您的文件以检查类型安全性并向控制台报告错误。如今,所有常见的 JavaScript IDE 都内置了 Flow 支持或通过优秀的插件提供支持。
要启用 Flow 的静态类型检查,您只需在文件顶部添加// @flow注释即可。这告诉 Flow 类型检查器将文件包含在检查中。然后您可以直接在变量和参数的声明后面添加类型(内联),或者您可以声明更复杂的类型,并使用这些类型在声明变量时指定变量的类型。
这在下面的代码块中显示:
type Person = {
name: string,
height: number,
age: number
}
let john: Person = {
name: "John",
height: 180,
age: 35
}
我们创建了一个Person类型,然后使用它来创建一个人物,john。如果我们遗漏了某个属性或者分配了错误类型的值,Flow IDE 集成会给我们一个错误。
由于 Flow 不是一个独立的语言,而只是在 JavaScript 之上的一个工具,我们必须将我们的文件从 Flow 注释文件转换回普通JavaScript 文件。这基本上意味着,我们必须使用一个转换器来从我们的文件中移除所有的 Flow 注释。Flow 提供了一个 Babel 插件来完成这项工作,这个插件必须安装到您的项目中才能工作。
Flow 可以通过.flowconfig文件进行配置。在这里,您可以定义哪些文件和文件夹应该被检查,哪些不应该被检查,以及指定一些选项,例如如何处理导入,以及 Flow 可以并行启动多少个工作线程来检查您的代码或 Flow 允许使用多少内存。
如果您想深入了解 Flow,请访问flow.org/网站。
TypeScript
对于强类型 JavaScript 的另一种选择是 TypeScript。它是由微软开发和维护的一个开源语言,它建立在 JavaScript 之上。它也为所有常见的 JavaScript IDE 提供了出色的集成,并且与 Flow 非常相似。
在您能够在生产环境中执行之前,TypeScript 代码将通过 TypeScript 编译器或 Babel 转换为纯 JavaScript。甚至注释的语法也几乎相同。在Flow部分的示例代码在 TypeScript 中也能完美运行。
如果您想深入了解 TypeScript,请访问www.typescriptlang.org网站。
通常情况下,我更倾向于使用 TypeScript 而不是 Flow,因为 TypeScript 的使用范围更广,社区支持也更大。文档质量更好,IDE 集成和代码补全也更好。如果你开始一个新的项目,我建议选择 TypeScript。但 Flow 也是一个不错的解决方案。如果你在你的项目中已经有了工作的 Flow 集成,目前没有必要迁移到 TypeScript。
重要提示
如果你参与一个大规模项目,我肯定会推荐使用 Flow 或 TypeScript。即使你一开始有一些开销,最终它能为你节省更多的时间和金钱。
摘要
在本章中,我们学习了现代 JavaScript 的工作原理,以及在与 React Native 一起工作时的一些特别重要的基础知识,还有 JavaScript 中的异步是如何工作的。你已经对底层技术有了基本的了解,以及误用可能导致昂贵的错误以及如何避免它们。
在下一章中,我们将学习关于 React 的内容,它内部是如何工作的,以及在与 React Native 一起工作时,哪些 React 的部分是重要的,需要深入了解。
第三章:欢迎来到 React Native
在学习完 React 和 React Native 的基础知识后,包括第一章“什么是 React Native?”以及 JavaScript 和 TypeScript 的基础知识第二章“理解 JavaScript 和 TypeScript 的基础”之后,现在是时候深入 React Native 的世界了。
React Native 最优秀的地方之一是它在使用方式上非常灵活。你可以选择Expo,它会为你处理所有原生部分,让你能在几小时内完成你的第一个应用。它还使得在没有 Mac 的情况下构建 iOS 应用成为可能。但你也可以选择裸 React Native 工作流程,这为你提供了将 React Native 应用集成到整个开发环境中的许多选项。
你还可以集成或甚至编写自己的(原生)库。虽然这种灵活性是 React Native 最大的优势之一,但它需要你真正理解在不同场景下发生的事情,以便为你的项目和公司做出正确的选择。
本章将帮助你做到这一点。你将真正理解不同的方法,如何利用它们,以及在何时使用每种方法。
你将在本章的各个部分学习以下内容:
-
通过示例应用了解 React Native 的工作原理
-
传递属性
-
理解类组件、函数组件和 Hooks
-
将不同平台连接到 JavaScript
-
介绍新的 React Native 架构
技术要求
为了能够运行本章中的代码,你必须设置以下内容:
-
一个有效的 React Native 开发环境(
reactnative.dev/docs/environment-setup—React Native 命令行界面 (CLI) 快速入门指南) -
虽然本章的大部分内容也应该在 Windows 上工作,但我建议你在 Mac 上工作
通过示例应用了解 React Native 的工作原理
通过实际操作来理解一项技术没有比这更好的方式了。本节包含一个简单的示例应用,该应用将根据静态JavaScript 对象表示法 (JSON)文件显示电影信息。应用将在下一章中进一步开发。目前,它应该包含以下视图:
-
一个主视图,用于显示电影类别列表
-
一个包含类别信息以及该类别最受欢迎的电影(包括标题和海报)的类别详情页面
-
一个包含电影信息(包括标题、海报、评分、上映日期和描述)的电影详情页面
虽然这是一个非常简单的例子,但我们将用它来重点理解底层发生了什么。但让我们先从创建应用开始。我们将使用 React Native 裸工作流程来完全控制,同时不增加任何开销。这意味着我们正在使用官方的 React Native CLI 来初始化我们的项目。这可以通过以下命令完成:
npx react-native init videoexample
--template react-native-template-typescript
我们使用 TypeScript 模板直接将项目设置为 TypeScript 项目。这包括TypeScript 编译器(tsc)以及正确的文件扩展名。你将在第九章《提高 React Native 开发的基本工具》中了解更多关于模板和其他启动 React Native 项目选项的内容。
前面的命令创建了一个包含新 React Native 项目的videoexample文件夹。如果你已经正确设置了所有内容,你可以使用cd videoexample && npx react-native run-ios在你的 iOS 模拟器上启动示例应用(iOS 模拟器仅适用于 iOS;在 Windows 上,你可以使用cd videoexample && npx react-native run-android来启动 Android 模拟器)。
当你成功启动你的模拟器后,你应该能看到 React Native 默认应用正在运行。它应该看起来像这样:
![图 3.1 – React Native 默认应用
![图 3.1 – React Native 默认应用
![图 3.1 – React Native 默认应用
当你在你的集成开发环境(IDE)中打开videoexample文件夹时,你会看到 React Native CLI 为你创建了很多文件。在接下来的小节中,你将了解它们是什么以及它们的作用。
理解 React Native 示例项目
示例项目只有一个屏幕,但从技术上讲,它是一个完整的 Android 和 iOS 应用。这意味着它包含以下内容:
-
android:这个文件夹包含原生的 Android 项目。你可以用 Android Studio 打开这个文件夹,就像处理原生 Android 应用一样。它使用 Gradle 作为构建系统,并且很好地集成到 Android Studio 中。你可能在某个时候需要修改以下文件:-
android/app/src/main/AndroidManifest.xml:Android 清单包含关于应用的基本信息。你可能需要在添加需要用户权限的功能或从推送通知启动应用时编辑此文件。 -
android/app/src/main/java/com/<youridentifier>/MainApplication.java & android/app/src/main/java/com/<youridentifier>/MainApplication.java:这些是应用的主要文件。通常你不需要修改这些文件,但某些库需要在这里进行一些额外的配置才能正确工作。 -
android/app/build.gradle: 此文件定义了你的应用的 Android 构建过程。在大多数情况下,即使你安装了具有原生部分的第三方库,React Native 也会自动处理此过程。但在某些情况下,你可能在这些库之间遇到冲突,或者你必须进行一些额外的配置。在这些情况下,这是你要查看的文件。在android/build.gradle中还有一个构建文件,你可以为所有子项目/模块添加配置。
-
-
iOS: 此文件夹包含原生 iOS 项目。它由你的应用项目和名为<youridentifier>.xcodeproj的东西组成:这是你的应用的项目文件。它只包含你的项目。不要在 Xcode 中使用它,因为它不会工作! -
<youridentifier>.xcworkspace: 这是你要工作的文件。它包含你的项目以及 pods 的项目。这是在 Xcode 中要工作的文件。 -
Podfile: 在此文件中,你可以定义其他项目的依赖项。这些依赖项通过cocoapods获取。你可以将cocoapods视为原生依赖项的npm或yarn包。在大多数情况下,所有依赖项都由 React Native 自动处理,但有时你必须调整依赖项(例如,在撰写本文时——例如——在 M1 Mac 上)。如果你必须这样做,Podfile就是你要查看的文件。
关于 cocoapods 的说明
cocoapods 是 iOS 开发中一个非常流行的依赖管理工具。尽管如此,它不是苹果官方提供的工具,而是一个开源解决方案。cocoapods 团队没有关于 Xcode 或 macOS 即将发布的任何信息,因此 cocoapods 有时需要一些时间才能与最新版本良好地协同工作。
node_modules: 此文件夹在npm install或yarn的依赖安装过程中完全自动生成。除非你想修补第三方库,否则你不需要在此处做任何更改。
关于修补库的提示
有时,修补现有的库以修复错误或添加某些功能可能很有用。在这些情况下,你可以维护这个库的自己的分支(这非常耗时)或者你可以使用 patch-package。patch-package 是一个创建特定 npm 依赖项补丁的小工具。你可以在 第十章*,大规模、多平台项目的结构 中了解更多信息。
-
.eslintrc.js/.prettierrc.js: 一个新的 React Native 项目自带内置的 ESLint 和 Prettier 支持。这些文件包含 ESLint 和 Prettier 的配置。有关这些工具的更多信息,请参阅 第九章,提高 React Native 开发的必备工具。 -
.watchmanconfig: React Native 使用一个名为watchman的工具来监视项目文件,并在它们发生变化时触发操作。这对于开发过程中的热重载非常重要。在大多数情况下,此文件只是一个空对象。 -
app.json:这个文件包含有关你的应用的信息,例如应用名称。 -
babel.config.js/tsconfig.json:这些文件包含有关 Babel 和 TypeScript 编译器的信息、标准和规则。在大多数情况下,你不需要编辑这些文件。 -
metro.config.js:React Native 在开发期间使用名为 Metro 的打包器来创建你的 JavaScript 包。这个打包器运行在你的 Mac 或 PC 上,在你做出更改后重新创建你的应用 JavaScript 包,并将其推送到你的设备或模拟器。这个文件包含metro打包器的配置。在大多数情况下,你不需要编辑它。如果你想了解更多关于 Metro 的信息,请访问官方页面:facebook.github.io/metro/。 -
Index.js:这是你的 JavaScript 包的入口点。如果你查看代码,你会发现它只是通过 React Native 的AppRegistry.registerComponent将./App绑定到本地应用。 -
App.tsx:这是 React Native 的默认应用。你可以在这里进行更改,并直接在你的模拟器中看到它们。稍后,这个文件将被我们的示例应用替换。
通过了解所有这些文件,你已经学到了很多关于 React Native 的知识。你看到它包含真实本地的项目,具有真实本地的依赖项,使用了大量有用的工具,并且有一个单一的入口点。
我们示例应用的下一步是设置一个工作文件夹结构。
结构化示例应用
首先,我总是建议为所有的 JavaScript/TypeScript 代码创建一个src文件夹。将所有属于一起的代码放在一个地方总是一个好主意。
对于我们的示例应用,我们在src文件夹中创建了以下三个子文件夹:
-
@types:在这个文件夹中,你放置你的 TypeScript 类型声明。 -
components:这个文件夹包含所有可重用的组件。 -
containers:在这里,你可以定义带有自定义动画的ScrollView容器。这些容器用于存放视图的内容。 -
services:在这个文件夹中,我们将创建我们的服务以连接到电影。在这个例子中,它将使用静态 JSON 文件作为源;稍后,我们将连接到外部应用程序编程接口(API)。 -
views:这个文件夹包含整个页面的视图。在我们的例子中,它包含之前定义的三个视图。
注意
有其他方法来结构化 React Native 项目。特别是对于大型项目,有多个仓库的项目,在某些情况下可能会有更好的解决方案。你将在第十章,“结构化大型、多平台项目”中了解到一些。对于我们的示例项目,这种结构绝对是可以的。
为了更深入地了解正在发生的事情,我们尝试在不使用任何第三方库的情况下完成我们示例项目的第一个版本。这只是为了学习目的,并不建议在实际项目中使用。
我们必须决定的第一件事是应用程序的一般架构。在图表中可视化应用程序的不同部分可能非常有帮助,就像你在这里可以看到的那样:
图 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-r… – React Native CLI 快速入门)。
-
虽然本章的大部分内容也应该在 Windows 上运行,但我建议你在 Mac 上工作。
-
要查看简单示例,你可以使用
codesandbox.io/并将react-native-web作为依赖项导入。这提供了所有 React Native 组件并将它们转换为 HTML 标签。
管理全局应用程序状态
由于 React Native 基于 React,管理应用程序状态与 React 应用程序没有太大区别。有数十个维护良好且可用的状态管理库,你都可以在 React Native 中使用。然而,在应用程序中,有一个良好的计划和知道如何管理应用程序状态比在 Web 应用程序中更为重要。
虽然等待几秒钟数据出现或新页面加载可能是可以接受的,但在移动应用程序中并非如此。用户习惯于立即看到信息或变化。因此,你必须确保在你的应用程序中也是如此。
在本节中,我们将探讨最流行的状态管理解决方案,但首先,你将了解不同的状态管理模式以及你应该为你的项目使用哪一个。
传递属性
虽然在小应用程序和示例项目中仅使用本地组件状态可能运行良好,但这种方法非常有限。有许多用例需要在不同组件之间共享数据。你的应用程序越大,你将拥有的组件就越多,你需要传递数据的层级就越多。
以下图表显示了主要问题:
![图 5.1 – 无全局状态管理解决方案的状态管理
图 5.1 – 无全局状态管理解决方案的状态管理
上述图表显示了一个非常简单的示例,与我们的示例应用程序非常接近,但你已经可以看到主要问题:应用程序包含两个标签页,一个用于显示内容,另一个提供个人用户区域。第二个标签页包含一个登录功能,该功能被提取到一个登录组件中。
内容标签页包含一个仪表板组件,主要用于显示内容。但我们也希望能够适应用户的内容。因此,我们需要在仪表板组件中获取有关用户的信息。
没有全局应用程序状态管理库,如果用户登录,我们将不得不做以下操作:
-
从登录组件传递信息到用户标签页。
-
从
App.js传递信息。 -
在
App.js的状态中设置用户信息。 -
将用户信息作为属性传递给内容标签页。
-
从内容标签页将用户信息传递到仪表板组件。
即使在这个简单的例子中,我们也必须包含五个组件来向仪表板组件提供用户信息。当我们谈论复杂的现实世界应用时,可能会有 10 个或更多的层级,你需要通过这些层级传递你的数据。这将是一个难以维护和理解的噩梦。
这种方法还存在另一个问题:当我们把用户信息作为属性传递给App.js时,App.js会发生变化。这意味着我们会重新渲染内容标签页以及可能的大量未因属性更改而改变的子组件。
这尤其重要,因为大型应用程序的全局状态可能会变得相当复杂和庞大。如果你将其与后端应用程序进行比较,你可以将全局应用程序状态视为系统的数据库。
因此,全局状态管理库应该解决两个问题。一方面,它们应该给我们一个在组件之间共享信息并保持我们的应用程序状态管理可维护的选项。另一方面,它们还应该帮助减少不必要的重新渲染,从而优化我们的应用程序性能。
使用全局状态提供者/容器
以下图表显示了使用全局状态管理解决方案的数据流预期工作方式:
图 5.2 – 使用全局状态管理解决方案的状态管理
如您所见,全局应用程序状态管理解决方案提供了一个将数据设置到全局位置并连接组件以消费这些数据的选项。虽然这确保了当这些数据发生变化时,连接的组件会自动重新渲染,但它也必须保证只有这些组件会重新渲染,而不是整个组件树。
虽然这是一个好的模式,但也伴随着一些风险。当每个组件都可以连接到你的全局状态时,你必须非常小心地编辑这种状态的方式。
重要提示
绝不允许任何组件直接写入你的状态。无论你使用什么库,你的全局状态提供者都应该始终控制状态如何被更改。
如前所述的信息框中提到,您的全局状态提供者应始终控制状态。这意味着您不应允许任何组件直接设置状态。相反,您的应用状态提供者应提供一些可以改变状态的函数。这确保您始终知道状态可以如何改变。只能以这些方式改变的状态也称为可预测状态。
使用可预测状态模式
在处理大型项目时,特别是在有多个开发者参与的项目中,拥有可预测状态尤为重要。想象一下,在一个项目中,任何人都可以简单地从任何组件直接设置状态。当您遇到错误,因为您的状态包含一个无法由您的应用程序处理的无效值时,几乎不可能找出这个值是从哪里来的。此外,当您允许从全局状态提供者外部直接编辑状态时,您无法提供任何中央验证。
当您使用可预测状态模式时,您有三个优点。首先,您可以提供验证并防止无效值写入您的状态。其次,如果您遇到由于无效状态值而导致的错误,您有一个中心点可以开始调试。第三,它更容易为其编写测试。
创建可预测状态的模式如下图中所示:
![图 5.3 – 简单的可预测状态管理
图 5.3 – 简单的可预测状态管理
如您所见,组件触发任何事件。在这个例子中,用户点击了一个按钮。这个事件触发了一个动作。这可能是一个自定义钩子或由某些状态管理库提供的函数。这个钩子或函数可以执行多项操作,从验证事件到从本地存储解决方案或外部后端获取数据。最后,状态将被设置。
为了让您有一个更好的概念,让我们看看一个具体的例子。该组件是一个重新加载按钮。点击它后,动作从后端获取最新的数据。它处理请求,如果请求成功并提供有效数据,动作将此数据设置在状态中。否则,它设置错误消息并提供代码到状态。
如您所见,这种模式也可以在业务逻辑和 UI 之间提供一层良好的抽象。如果您想要一个更好的抽象,您可以使用我们接下来要讨论的下一个模式。
使用状态/动作/还原模式
这个简单的可预测状态管理模式可以扩展。以下图显示了扩展版本,其中添加了还原器和选择器:
![图 5.4 – 状态/动作/还原模式
图 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 下载量
图 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 – 安全努力和泄露的可能性(灵感来源于 reactnative.dev/docs/securi…)
![图片 B16694_05_07.jpg]
图 5.7 – 安全努力和泄露的可能性(灵感来源于 reactnative.dev/docs/securi…
如你所见,即使是在保护你的应用程序方面的一些努力,也能显著降低泄露的可能性。你应该至少做到以下这些:
-
不要在你的代码中存储你的私有 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。
现在你已经完成了这本书的前五章,你可以创建一个具有强大技术基础的工作应用。在下一章中,你将学习如何通过美丽的动画让你的应用看起来更美观。