React 项目第二版(三)
原文:
zh.annas-archive.org/md5/54872467feccdd4d547352263ad84982译者:飞龙
第七章:使用 Next.js 和 GraphQL 构建全栈电商应用
如果你正在阅读此内容,这意味着你已经到达了本书的最后一章,该章节专注于使用 React 构建网络应用。在前面的章节中,你已经使用了 React 的核心功能,例如渲染组件、使用 Context 和 Hooks 进行状态管理。你已经学习了如何为你的 React 应用添加路由或使用 Next.js 进行 SSR。此外,你还知道如何使用 Jest 和 Enzyme 为 React 应用添加测试。让我们通过添加 GraphQL 到你迄今为止所学的内容列表中,使这次体验成为全栈的。
在本章中,你不仅将构建应用的前端,还将构建后端。为此,我们将使用 GraphQL,它最好被定义为一个针对 API 的查询语言。使用模拟数据,你将在 Next.js 中创建一个 GraphQL 服务器,该服务器为你的 React 应用提供了一个端点。在前端方面,这个端点将通过 Apollo Client 进行消费,它帮助你处理向服务器发送请求以及对此数据的状态管理。
在本章中,将涵盖以下主题:
-
使用 Next.js 创建 GraphQL 服务器
-
使用 Apollo Client 消费 GraphQL
-
在 GraphQL 中处理身份验证
项目概述
在本章中,我们将使用 Next.js 创建一个全栈电商应用,该应用的后端是一个 GraphQL 服务器,并通过 Apollo Client 在 React 中消费这个服务器。对于前端,有一个初始应用可供快速入门。
构建时间为 3 小时。
入门
本章中我们将创建的项目基于你可以在 GitHub 上找到的初始版本:github.com/PacktPublishing/React-Projects-Second-Edition/tree/main/Chapter07-initial。完整的源代码也可以在 GitHub 上找到:github.com/PacktPublishing/React-Projects-Second-Edition/tree/main/Chapter07。
初始项目基于 Next.js 的样板应用,旨在快速入门。此应用需要安装几个依赖项,你可以通过运行以下命令来完成:
npm install && npm run dev
此命令将安装运行 Next.js 上的 React 应用所需的全部依赖,例如 react、next 和 styled-components。一旦安装过程完成,GraphQL 服务器和 React 应用都将启动。
使用初始 React 应用入门
由于该 React 应用程序是用 Next.js 创建的,因此可以使用 npm run dev 启动,并在 http://localhost:3000/ 上可用。这个初始应用程序不显示任何数据,因为它还需要连接到 GraphQL 服务器,您将在本章的后面完成这项工作。因此,此时应用程序将仅渲染一个标题为 E-Commerce Store 的页眉以及一个副标题,看起来大致如下:
:
图 7.1 – 初始应用程序
使用 Next.js 构建的此初始 React 应用程序的结构如下:
chapter-7-initial
|- /node_modules
|- /public
|- /pages
|- /api
|- /hello.js
|- /products
|- /index.js
|- /cart
|- /index.js
|- /login
|- /index.js
|- _app.js
|- index.js
|- /utils
|- hooks.js
|- authentication.js
package.json
在 pages 目录中,您可以找到此应用程序的所有路由。路由 / 由 pages/index.js 渲染,而路由 /cart、/login 和 /products 由相应目录中的 .js 文件渲染。所有路由都将包含在 pages/_app.js 中。在这个文件中,构建了所有页面的页眉。所有路由也将包含一个 SubHeader 组件,以及一个 Button 用于返回上一页或一个 Button 用于 Cart 组件。utils 目录包含两个文件,其中包含您在本章后面需要使用的方法。此外,此应用程序将在 http://localhost:3000/api/hello 下提供一个 REST 端点,该端点来自 pages/api/hello.js 文件。
使用 React、Apollo 和 GraphQL 构建全栈电子商务应用程序
在本节中,您将连接 React 网络应用程序到 GraphQL 服务器。Next.js API 路由上的 GraphQL 服务器用于创建一个使用动态模拟数据作为源的单一 GraphQL 端点。React 使用 Apollo Client 消费此端点并处理应用程序的状态管理。
使用 Next.js 创建 GraphQL 服务器
在 第三章 中,构建动态项目管理板,我们已经使用 Next.js 创建了一个 React 应用程序,其中已经提到您也可以用它来创建 API 端点。通过查看本章目录中的文件,您可以看到 pages 目录中有一个名为 api 的目录,其中包含一个名为 hello.js 的文件。您在 pages 目录中创建的所有目录和文件都将作为浏览器中的路由可用,但如果您在 pages 目录下的 api 目录中创建它们,它们被称为 API 路由。hello.js 文件就是这样一条 API 路由,它位于 http://localhost:3000/api/hello 下。此端点返回一个包含以下内容的 JSON 块:
{"name":"John Doe"}
这是一个 REST 端点,我们也在本书的前几章中进行了探索。在本章中,我们将使用 GraphQL 端点,因为 GraphQL 是 Web 和移动应用程序使用的 API 的流行格式。
GraphQL 最好描述为 API 的查询语言,它被定义为从 API 检索数据的一种约定。通常,GraphQL API 与 RESTful API 相比较,后者是发送依赖于多个端点的 HTTP 请求的一种知名约定,这些端点将返回各自的数据集合。与知名的 RESTful API 相反,GraphQL API 将提供一个单一端点,允许您查询和/或突变数据源,如数据库。您可以通过向 GraphQL 服务器发送包含查询或突变操作的文档来查询或突变数据。无论什么数据可用,都可以在 GraphQL 服务器的模式中找到,该模式由定义可以查询或突变的数据的类型组成。
在创建 GraphQL 端点之前,我们需要在 Next.js 中设置服务器。因此,我们需要安装以下依赖项,这些依赖项是设置所必需的:
npm install graphql @graphql-tools/schema @graphql-tools/mock express-graphql
在我们的应用程序中使用 GraphQL 需要graphql库,而express-graphql是 Node.js 的 GraphQL 服务器的一个小型实现。@graphql-tools/schema和@graphql-tools/mock都是开源库,可以帮助您创建 GraphQL 服务器。我们还可以删除pages/api/hello.js文件,因为我们不会使用这个 API 路由。
要设置 GraphQL 服务器,我们必须创建一个新的文件,pages/api/graphql/index.js,它将包含我们应用程序的单个 GraphQL 端点。我们需要导入graphqlHTTP来创建服务器。GraphQL 服务器的模式是在名为typeDefs的变量下编写的:
import { graphqlHTTP } from 'express-graphql';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { addMocksToSchema } from '@graphql-tools/mock';
const typeDefs = /* GraphQL */ `
type Product {
id: Int!
title: String!
thumbnail: String!
price: Float
}
type Query {
product: Product
products(limit: Int): [Product]
}
`;
在模式下方,我们可以使用graphqlHTTP实例启动 GraphQL 服务器,并将模式传递给它。我们还配置服务器为我们的模式中的所有值创建模拟。在文件底部,我们返回handler,这是 Next.js 用来在路由http://localhost:3000/api/graphql上使 GraphQL 服务器可用的:
// ...
const executableSchema = addMocksToSchema({
schema: makeExecutableSchema({ typeDefs, }),
});
function runMiddleware(req, res, fn) {
return new Promise((resolve, reject) => {
fn(req, res, (result) => {
if (result instanceof Error) {
return reject(result);
}
return resolve(result);
});
});
}
async function handler(req, res) {
const result = await runMiddleware(
req,
res,
graphqlHTTP({
schema: executableSchema,
graphiql: true,
}),
);
res.json(result);
}
export default handler;
确保再次运行应用程序后,GraphQL API 在http://localhost:3000/api/graphql上可用。在浏览器页面上,GraphiQL 游乐场将显示,这里您可以使用和探索 GraphQL 服务器。
使用这个游乐场,您可以向 GraphQL 服务器发送查询和突变,这些可以在页面的左侧键入。您可以发送的查询和突变可以在该 GraphQL 服务器的文档中找到,您可以通过点击标记为文档的绿色按钮来查找。此按钮将打开一个概述,其中包含 GraphQL 服务器所有可能的返回值。
图 7.2 – 使用 GraphiQL 游乐场
当您在此页面的左侧描述查询或突变时,服务器返回的输出将在演示场的右侧显示。GraphQL 查询的构建方式将决定返回数据的结构,因为 GraphQL 遵循 需要什么,就得到什么 的原则。由于 GraphQL 查询总是返回可预测的结果,我们可以有一个如下所示的查询:
query {
products {
id
title
price
}
}
这将返回一个输出,其结构将与您发送到 GraphQL 服务器的文档中定义的查询结构相同。将此文档与查询一起发送到 GraphQL 服务器将返回一个对象数组,其中包含产品信息,默认情况下限制为 10 个产品。结果将以 JSON 格式返回,并且每次发送请求时都会包含不同的产品,因为数据是由 GraphQL 服务器模拟的。响应格式如下:
{
"data": {
"products": [
{
"id": 85,
"title": "Hello World",
"price": 35.610056991945214
},
{
"id": 24,
"title": "Hello World",
"price": 89.47561381959673
}
]
}
}
使用 GraphQL 的应用程序通常快速且稳定,因为它们控制着获取的数据,而不是服务器。使用 GraphQL,我们还可以在我们的数据中创建某些字段之间的关系,例如,通过在我们的产品中添加一个类别字段。这是通过在 pages/api/graphql/index.js 中的 GraphQL 模式中添加以下内容来完成的:
// ...
const typeDefs = `
type Product {
id: Int!
title: String!
thumbnail: String!
price: Float
+ category: Category
}
+ type Category {
+ id: Int!
+ title: String!
+ }
type Query {
product: Product
products(limit: Int): [Product]
}
`;
// ...
我们还可以通过将其添加到模式中来添加对 type Category 的查询:
// ...
const typeDefs = `
// ...
type Category {
id: Int!
title: String!
}
type Query {
product: Product
products(limit: Int): [Product]
+ categories: [Category]
}
`;
// ...
产品现在将有一个名为 category 的新字段,但您也可以单独查询类别列表。由于 GraphQL 服务器的所有数据目前都是模拟的,您不需要连接一个提供类别信息的数据库。但我们可以指定某些字段应该如何模拟,例如,通过为我们的产品添加缩略图。因此,我们需要创建一个名为 mocks 的变量,将 Product 类型的字段缩略图设置为指向 picsum.photos 的 URL。这是一个用于实时生成模拟图像的免费服务器:
// ...
+ const mocks = {
+ Product: () => ({
+ thumbnail: () => 'https://picsum.photos/400/400'
+ }),
+ };
const executableSchema = addMocksToSchema({
schema: makeExecutableSchema({ typeDefs, }),
+ mocks,
});
// ...
除了在 Product 类型上模拟 thumbnail 字段外,我们还想模拟所有具有 Int 或 Float 类型的字段值。这两个字段现在通常是负值,这对其用作标识符或价格是不正确的。Int 类型用于定义标识符,而 Float 类型用于价格。我们也可以通过添加以下内容来模拟这些字段:
// ...
const mocks = {
+ Int: () => Math.floor(Math.random() * 99) + 1,
+ Float: () => (Math.random() * 99.0 + 1.0).toFixed(2),
Product: () => ({
thumbnail: () => 'https://picsum.photos/400/400'
}),
};
// ...
您可以通过尝试以下查询来检查此操作,该查询还请求产品的类别和缩略图:
query {
products {
id
title
price
thumbnail
category {
id
title
}
}
}
您可以将前面的查询插入到 GraphQL 演示场中,以获取响应,其外观将类似于以下截图:
图 7.3 – 向 GraphQL 服务器发送查询
由于 GraphQL 服务器模拟了数据,因此每次您使用此查询发送新的请求时,值都会发生变化。但您可以通过在 HTTP 请求的正文发送查询来获得相同的响应,无论是从命令行还是从使用fetch的 React 应用程序。
您还可以使用像 Apollo Client 这样的库来使这个过程更加直观。这将在本章的下一节中解释,您将使用 Apollo 将 GraphQL 服务器连接到 React Web 应用程序,并从您的应用程序向服务器发送文档。
使用 Apollo Client 消费 GraphQL
在设置好 GraphQL 服务器后,让我们继续到从 React 应用程序向该服务器发送请求的部分。为此,您将使用 Apollo 包,这些包可以帮助您在应用程序和服务器之间添加一个抽象层。这样,您就不必担心自己使用例如fetch这样的方法将文档发送到 GraphQL 端点,可以直接从组件中发送文档。
设置 Apollo Client
如我们之前提到的,您可以使用 Apollo 连接到 GraphQL 服务器;为此,将使用 Apollo Client。使用 Apollo Client,您可以设置与服务器的连接,处理查询和突变,并为从 GraphQL 服务器检索的数据启用缓存,以及其他功能。您可以通过以下步骤将 Apollo Client 添加到应用程序中:
-
要安装 Apollo Client 及其相关包,您需要从 React 应用程序初始化的
client目录中运行以下命令:npm install @apollo/client
这将安装 Apollo Client 以及您在 React 应用程序中使用 Apollo Client 和 GraphQL 所需的其他依赖项。
注意
通常,在安装 Apollo Client 时,我们还需要安装graphql,但这个库已经存在于我们的应用程序中。
-
这些包应该导入到您想要创建包含与 GraphQL 服务器连接的 Apollo Provider 的
pages/_app.js文件中:import { createGlobalStyle } from 'styled-components'; + import { + ApolloClient, + InMemoryCache, + ApolloProvider, + } from "@apollo/client"; import Header from '../components/Header'; const GlobalStyle = createGlobalStyle` // ... -
现在,您可以使用
ApolloClient类来定义client常量,并将本地 GraphQL 服务器的位置传递给它:// ... + const client = new ApolloClient({ + uri: 'http://localhost:3000/api/graphql/', + cache: new InMemoryCache() + }); function MyApp({ Component, pageProps }) { return ( // ... -
在
MyApp组件的return函数中,您需要添加ApolloProvider并将您刚刚创建的client作为属性传递:// ... function MyApp({ Component, pageProps }) { return ( - <> + <ApolloProvider client={client}> <GlobalStyle /> <Header /> <Component {...pageProps} /> + </ApolloProvider> - </> ); } export default MyApp;
在这些步骤之后,所有嵌套在ApolloProvider内的组件都可以访问此client,并通过查询和/或突变将文档发送到 GraphQL 服务器。在 Next.js 中,所有页面组件都是基于路由在Component下渲染的。从ApolloProvider获取数据的方法与我们之前使用的上下文 API 类似。
使用 React 发送 GraphQL 查询
Apollo Client 不仅导出了一个 Provider,还导出了从该 Provider 中消耗值的方法。这样,你可以轻松地使用添加到 Provider 中的客户端获取任何值。其中一种方法是Query,它可以帮助你发送一个包含查询的文档到 GraphQL 服务器,而无需使用fetch函数,例如。
由于Query组件应该始终嵌套在ApolloProvider组件内部,它们可以放置在App中渲染的任何组件中。其中之一是pages/product/index.js中的Products组件。该组件正在为/路由渲染,应显示电子商务店中可用的产品。
要从Products组件发送文档,请按照以下步骤操作,这将指导你使用react-apollo发送文档的过程:
-
在
Products页面组件中,你可以从@apollo/client导入useQuery钩子,并为命名查询getProducts定义一个常量。此外,你需要导入gql,以便在你的 React 文件中使用 GraphQL 查询语言,如下所示:import styled from 'styled-components'; + import { useQuery, gql } from '@apollo/client'; import SubHeader from '../../components/SubHeader'; import ProductItem from '../../components/ProductItem'; // ... + const GET_PRODUCTS = gql` + query getProducts { + products { + id + title + price + thumbnail + } + } + `; function Products() { // ... -
从
Products组件中导入的useQuery钩子可以调用并处理基于传递给它的查询的数据获取过程。与上下文 API 类似,useQuery可以通过返回一个data变量来从 Provider 中消耗数据。你可以遍历此对象中的products字段,并返回已导入此文件的ProductItem组件列表。此外,还会返回一个loading变量,当 GraphQL 服务器尚未返回数据时,该变量将为true:// ... function Products() { + const { loading, data } = useQuery(GET_PRODUCTS); return ( <> <SubHeader title='Available products' goToCart /> + {loading ? ( + <span>Loading...</span> + ) : ( <ProductItemsWrapper> + {data && data.products && data.products.map((product) => ( + <ProductItem key={product.id} data={product} /> + ))} </ProductItemsWrapper> + )} </> ); }; export default Products;
这将在你的应用程序挂载时发送一个包含GET_PRODUCTS查询的文档到 GraphQL 服务器,并随后在ProductItem组件列表中显示产品信息。在添加从 GraphQL 服务器检索产品信息的逻辑后,你的应用程序将类似于以下所示:
![图 7.4 – 从 GraphQL 渲染产品
图 7.4 – 从 GraphQL 渲染产品
通过点击此页面的右上角按钮,你将导航到/cart路由,该路由也需要从 GraphQL 服务器查询数据。由于我们还没有检索购物车的查询,我们需要将其添加到pages/api/graphql/index.js中的 GraphQL 服务器。
-
由于 GraphQL 服务器没有连接的数据源,我们可以使用
let创建一个可变变量。这是一个我们希望稍后更新的对象,例如,当我们向购物车添加产品时:import { graphqlHTTP } from 'express-graphql'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { addMocksToSchema } from '@graphql-tools/mock'; + let cart = { + count: 0, + products: [], + complete: false, + }; const typeDefs = ` // ... -
在模式中,我们需要为
Cart定义一个类型,并将此类型添加到我们 GraphQL 服务器的查询列表中:// ... const typeDefs = ` // ... + type Cart { + count: Int + products: [Product] + complete: Boolean + } type Query { product: Product products(limit: Int): [Product] categories: [Category] + cart: Cart } `; const mocks = { // ... -
在
pages/cart/index.js文件中,已经导入了用于在购物车中渲染产品的组件。我们确实需要从@apollo/client导入useQuery钩子和gql,并创建查询常量:import styled from 'styled-components'; + import { useQuery, gql } from '@apollo/client'; import { usePrice } from '../../utils/hooks'; import SubHeader from '../../components/SubHeader'; import ProductItem from '../../components/ProductItem'; import Button from '../../components/Button'; // ... + const GET_CART = gql` + query getCart { + cart { + products { + id + title + price + thumbnail + } + } + } + `; function Cart() { // ... -
在
Cart组件中,我们需要使用useQuery钩子获取我们想要显示的数据。在获取数据后,我们可以返回一个列表,其中包含添加到购物车中的产品以及结账按钮:// ... function Cart() { + const { loading, data } = useQuery(GET_CART); return ( <> <SubHeader title='Cart' /> + {loading ? ( + <span>Loading...</span> + ) : ( <CartWrapper> <CartItemsWrapper> + {data && data.cart.products && data.cart.products.map((product) => ( + <ProductItem key={product.id} data={product} /> + ))} </CartItemsWrapper> + {data && data.cart.products.length > 0 && ( + <Button backgroundColor='royalBlue'> Checkout </Button> + )} </CartWrapper> + )} </> ); }; export default Cart; -
由于购物车为空,这不会显示任何产品;在下一节中,购物车将被产品填充。然而,让我们通过将
useQuery钩子添加到导航到SubHeader中按钮中,继续操作,SubHeader在除/cart本身以外的路由上渲染。可以在components目录中创建一个名为CartButton.js的新文件。在这个文件中,useQuery钩子将返回一个查询的数据,该查询请求购物车中产品的总数。此外,我们还可以通过向此文件添加以下代码来向Button组件添加一个值:import { useQuery, gql } from '@apollo/client'; import Button from './Button'; export const GET_CART_TOTAL = gql` query getCart { cart { count } } `; function CartButton({ ...props }) { const { loading, data } = useQuery(GET_CART_TOTAL); return ( <Button {...props}> {loading ? 'Cart' : `Cart (${data.cart.count})`} </Button> ); } export default CartButton; -
这个
CartButton组件替换了Button,现在在components/SubHeader.js文件中以购物车中产品数量的占位符形式显示:import styled from 'styled-components'; import { useRouter } from 'next/router'; - import Button from './Button'; + import CartButton from './CartButton'; // ... function SubHeader({ title, goToCart = false }) { const router = useRouter(); return ( <SubHeaderWrapper> // ... {goToCart && ( - <Button onClick={() => router.push('/cart')}> - Cart (0) - </Button> + <CartButton onClick={() => router.push('/cart')} /> )} </SubHeaderWrapper> ); } export default SubHeader;
在将显示产品或购物车信息的组件连接到 GraphQL 服务器后,你可以通过添加将产品添加到购物车的变更来继续操作。如何在应用程序中添加变更以及如何将文档容器变更发送到 GraphQL 服务器将在本节下一部分中展示。
处理 GraphQL 中的变更
变更数据使得使用 GraphQL 更有趣,因为当数据被变更时,应该执行一些副作用。例如,当用户将产品添加到他们的购物车时,购物车的数据应该在整个组件中更新。当你使用 Apollo Client 时,这相当简单,因为 Provider 以与上下文 API 相同的方式处理这一点。
现在的 GraphQL 服务器只有查询,还没有操作。添加变更与之前我们添加查询到模式的方式类似,但对于变更,我们还需要添加解析器。解析器是 GraphQL 中的魔法所在,也是模式与获取数据逻辑(可能来自数据源)链接的地方。变更的添加是在 pages/api/graphql/index.js 文件中完成的。
-
第一步是将添加产品到购物车的变更添加到模式中。此变更以
productId作为参数。此外,我们还需要在稍后模拟一个类型列表:// ... const typeDefs =` // ... const typeDefs = gql` // ... type Cart { total: Float count: Int products: [Product] complete: Boolean } type Query { product: Product products(limit: Int): [Product] categories: [Category] cart: Cart } + type Mutation { + addToCart(productId: Int!): Cart + } `; const mocks = { // ... -
到目前为止,我们模式中的所有值都是由 GraphQLServer 模拟的,但通常你会在模式中的每个类型上添加解析器。这些解析器将包含从数据源获取数据的逻辑。由于我们希望将
Cart类型的值存储在此文件顶部创建的cart对象中,因此我们需要为addToCart变更添加一个解析器:// ... + const resolvers = { + Mutation: { + addToCart: (_, { productId }) => { + cart = { + ...cart, + count: cart.count + 1, + products: [ + ...cart.products, + { + productId, + title: 'My product', + thumbnail: 'https://picsum.photos/400/400', + price: (Math.random() * 99.0 + 1.0). toFixed(2), + category: null, + }, + ], + }; + return cart; + }, + }, + }; const executableSchema = addMocksToSchema({ // ... -
在创建
graphqlHTTP实例时,我们需要传递我们为其创建的解析器,以便我们的更改生效:// ... const executableSchema = addMocksToSchema({ schema: makeExecutableSchema({ typeDefs, }), mocks, + resolvers, }); // ... export default handler;
您可以通过在http://localhost:3000/api/graphql可用的 GraphQL playground 中尝试此突变来测试它。在这里,您需要在页面左上角的框中添加突变。您想要包含在此突变中的productId变量必须放置在页面左下角的框中,称为查询变量。这将产生以下输出:
图 7.5 – 在 GraphiQL playground 中使用突变
每次您使用此突变将文档发送到 GraphQL 服务器时,列表中都会添加一个新的产品。此外,count字段将增加1。但是,当您想要使用Cart类型的查询检索此信息时,值仍然将由 GraphQL 服务器模拟。为了返回cart对象,我们还需要为获取购物车信息的查询添加一个解析器:
// ...
const resolvers = {
+ Query: {
+ cart: () => cart,
+ },
Mutation: {
// ...
},
};
const executableSchema = addMocksToSchema({
// ...
使用addToCart突变返回的响应将反映您可以使用购物车查询检索的内容。
为了能够从我们的 React 应用程序中使用此突变,我们需要进行以下更改:
-
目前,还没有按钮可以将产品添加到购物车中,因此您可以在
components目录中创建一个新文件,并命名为AddToCartButton.js。在这个文件中,您可以添加以下代码:import { useMutation, gql } from '@apollo/client'; import Button from './Button'; const ADD_TO_CART = gql` mutation addToCart($productId: Int!) { addToCart(productId: $productId) { count products { id title price } } } `; function AddToCartButton({ productId }) { const [addToCart, { data }] = useMutation(ADD_TO_CART); return ( <Button onClick={() => !data && addToCart({ variables: { productId } }) } > {data ? 'Added to cart!' : 'Add to cart'} </Button> ); } export default AddToCartButton;
这个新的AddToCartButton将productId作为属性,并使用来自@apollo/client的useMutation Hook,该 Hook 使用我们之前创建的突变。Mutation的输出是调用此突变的实际函数,该函数接受一个包含输入参数的对象作为参数。点击Button组件将执行突变并将productId传递给它。
-
此按钮应显示在
/或/products路由上的产品列表旁边,其中每个产品都通过ProductItem组件显示。这意味着您需要在components/ProductItem.js中导入AddCartButton,并通过以下代码向其传递一个productId属性:import styled from 'styled-components'; import { usePrice } from '../utils/hooks'; + import AddToCartButton from './AddToCartButton'; // ... function ProductItem({ data }) { const price = usePrice(data.price); return ( <ProductItemWrapper> {data.thumbnail && <Thumbnail src={data.thumbnail} width={200} />} <Title>{data.title}</Title> <Price>{price}</Price> + <AddToCartButton productId={data.id} /> </ProductItemWrapper> ); } export default ProductItem;
现在,当您在浏览器中打开 React 应用程序时,将在产品标题旁边显示一个按钮。如果您点击此按钮,突变将被发送到 GraphQL 服务器,产品将被添加到购物车中。然而,您不会看到显示SubHeader组件的按钮有任何变化。
-
在发送突变后执行此查询可以通过在
components/AddToCartButton.js中的useMutationHook 的refetchQueries选项中设置值来完成。此选项接受一个包含应请求的查询信息的对象数组。在这种情况下,它仅是GET_CART_TOTAL查询,由CartButton执行。为此,进行以下更改:import { useMutation, gql } from '@apollo/client'; import Button from './Button'; + import { GET_CART_TOTAL } from './CartButton'; // ... function AddToCartButton({ productId }) { const [addToCart, { data }] = useMutation(ADD_TO_CART); return ( <Button onClick={() => !data && addToCart({ variables: { productId }, + refetchQueries: [{ query: GET_CART_TOTAL }], }) } > {data ? 'Added to cart!' : 'Add to cart'} </Button> ); } export default AddToCartButton; -
当你点击
CartButton时,我们将导航到/cart路由,在这里显示我们购物车中的产品。在这里,AddToCartButton也会被渲染,因为这是在ProductItem组件中定义的。让我们通过访问components/ProductItem.js文件并添加以下代码行来更改这一点,这将条件性地渲染此按钮:// ... - function ProductItem({ data }) { + function ProductItem({ data, addToCart = false }) { const price = usePrice(data.price); return ( <ProductItemWrapper> {data.thumbnail && <Thumbnail src={data.thumbnail} width={200} />} <Title>{data.title}</Title> <Price>{price}</Price> - <AddToCartButton productId={data.id} /> + {addToCart && <AddToCartButton productId={data.id} />} </ProductItemWrapper> ); } export default ProductItem; -
从
Products页面组件中,我们需要传递addToCart属性来渲染此页面上的按钮:// ... return ( <> <SubHeader title='Available products' goToCart /> {loading ? ( <span>Loading...</span> ) : ( <ProductItemsWrapper> {data && data.products && data.products.map((product) => ( <ProductItem key={product.id} data={product} + addToCart /> ))} </ProductItemsWrapper> )} </> ); }; export default Products;
现在,每次你从这个组件向 GraphQL 服务器发送文档突变时,都会发送 GET_CART_TOTAL 查询。如果结果已更改,CartButton 和 Cart 组件将使用这个新的输出进行渲染。因此,CartButton 组件将被更新以显示 AddToCartButton 组件:
![图 7.6 – 更新购物车中的产品
图 7.6 – 更新购物车中的产品
在本节中,我们学习了如何设置 Apollo 客户端并使用它向 GraphQL 服务器发送文档。在本书的下一节中,我们将通过处理身份验证来扩展这一点。
在 GraphQL 中处理身份验证
到目前为止,我们已经创建了一个可以被使用 Next.js 和 React 构建的应用程序消费的 GraphQL 服务器。通过查询和突变,我们可以查看产品列表并将它们添加到购物车中。但我们还未添加检查购物车的逻辑,这将在本节中完成。
当用户将产品添加到购物车后,你希望他们能够进行结账;但在那之前,用户应该进行身份验证,因为你想要知道谁在购买该产品。
对于前端应用程序中的身份验证,大多数情况下使用 JSON Web Tokens(JWTs),这些是加密的令牌,可以轻松地用于与后端共享用户信息。JWT 将在用户成功认证后由后端返回,并且通常,此令牌将有一个过期日期。对于用户应该进行身份验证的每个请求,都应该发送此令牌,以便后端服务器可以确定用户是否已认证并且允许执行此操作。尽管 JWT 可以用于身份验证,因为它们是加密的,但不应将任何私人信息添加到其中,因为令牌仅应用于认证用户。只有当发送了包含正确 JWT 的文档时,服务器才能发送私人信息。
在我们能够将结账过程添加到 React 应用程序之前,我们需要使客户能够进行身份验证。这包括多个步骤:
-
我们需要在模式中创建一个新的类型,该类型定义了用户和用于登录用户的突变,我们可以在
pages/api/graphql/index.js中完成:// ... const typeDefs = ` // ... + type User { + username: String! + token: String! + } type Query { product: Product products(limit: Int): [Product] categories: [Category] cart: Cart } type Mutation { addToCart(productId: Int!): Cart + loginUser(username: String!, password: String!): User } `; // ... -
在模式中定义突变后,可以将其添加到解析器中。在
utils/authentication.js文件中,已经存在一个用于检查username和password组合的方法。如果这个组合是正确的,该方法将返回一个有效的令牌以及用户名。从这个文件中,我们还导入了一个用于检查令牌是否有效的方法:import { graphqlHTTP } from 'express-graphql'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { addMocksToSchema } from '@graphql-tools/mock'; + import { loginUser, isTokenValid } from '../../../utils/authentication'; // ... const resolvers = { Query: { cart: () => cart, }, Mutation: { + loginUser: async (_, { username, password }) => { + const user = loginUser(username, password); + if (user) { + return user; + } + }, // ...
从 GraphiQL 游乐场,我们现在可以通过输入用户名 test 和密码 test 来检查这个突变是否工作:
图 7.7 – 使用 GraphQL 创建 JWT
-
在
pages/login/index.js文件中,我们可以添加逻辑来使用表单的输入来发送包含loginUser变化的文档到 GraphQL 服务器。Login页面组件已经使用useState钩子来控制username和password输入字段的值。可以从@apollo/client导入useMutation钩子:import { useState } from 'react'; + import { useMutation, gql } from '@apollo/client'; // ... + const LOGIN_USER = gql` + mutation loginUser($username: String!, $password: String!) { + loginUser(username: $username, password: $password) { + username + token + } + } + `; function Login() { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); + const [loginUser, { data }] = useMutation(LOGIN_USER); return ( // ... -
在创建
loginUser函数后,我们可以将其添加到form元素的onSubmit事件中,并将username和password的值作为变量传递给此函数:// ... function Login() { // ... return ( <> <SubHeader title='Login' /> <FormWrapper> <form + onSubmit={(e) => { + e.preventDefault(); + loginUser({ variables: { username, password } }); + }} > // ... -
点击
Button将会发送包含username和password值的文档到 GraphQL 服务器,如果成功,它将返回该用户的 JWT。此令牌也应存储在会话存储中,以便以后使用。此外,我们希望在用户登录后将其重定向回主页。为此,我们需要从 React 导入一个useEffect钩子来监视数据的变化。当令牌存在时,我们可以使用从useRouter钩子获得的router对象,我们需要从 Next.js 导入这个钩子:- import { useState } from 'react'; + import { useState, useEffect } from 'react'; import { useMutation, gql } from '@apollo/client'; + import { useRouter } from 'next/router'; // ... function Login() { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [loginUser, { data }] = useMutation(LOGIN_USER); + const router = useRouter(); + useEffect(() => { + if (data && data.loginUser && data.loginUser.token) { + sessionStorage.setItem('token', data.loginUser.token); + router.push('/'); + } + }, [data]); return ( // ... -
每次客户通过
/login路由登录时,令牌都会存储在浏览器中的会话存储中。您可以通过访问Bearer来从会话存储中删除令牌,因为这就是 JWT 被识别的方式。这需要我们对pages/_app.js进行多次修改:import { createGlobalStyle } from 'styled-components'; import { ApolloClient, InMemoryCache, ApolloProvider, + createHttpLink, } from '@apollo/client'; + import { setContext } from '@apollo/client/link/context'; import Header from '../components/Header'; // ... + const httpLink = createHttpLink({ + uri: 'http://localhost:3000/api/graphql/', + }); + const authLink = setContext((_, { headers }) => { + const token = sessionStorage.getItem('token'); + return { + headers: { + ...headers, + authorization: token ? `Bearer ${token}` : '', + }, + }; + }); const client = new ApolloClient({ - uri: 'http://localhost:3000/api/graphql/', + link: authLink.concat(httpLink), cache: new InMemoryCache(), }); function MyApp({ Component, pageProps }) { // ...
在对 GraphQL 服务器的每个请求中,现在都会将令牌添加到 HTTP 请求的头部。
-
GraphQL 服务器现在可以从 HTTP 请求头部获取令牌并将它们存储在上下文中。上下文是一个对象,您可以使用它来存储您想在解析器中使用的数据,例如 JWT。这可以在
pages/api/graphql/index.js中完成:// ... const executableSchema = addMocksToSchema({ schema: makeExecutableSchema({ typeDefs, }), mocks, resolvers, + context: ({ req }) => { + const token = req.headers.authorization || ''; + return { token } + }, }); // ...
最后,我们还可以创建一个用于检查项目的突变。这个突变应该清空卡片,在生产环境中,将客户重定向到支付服务提供商。在这种情况下,我们只需清空卡片并显示订单已成功创建的消息。为了帮助检查过程,我们需要进行以下修改:
-
我们需要在我们的 GraphQL 服务器
pages/api/graphql/index.js的模式中添加一个新的突变:// ... type Mutation { addToCart(productId: Int!): Cart loginUser(username: String!, password: String!): User + completeCart: Cart } `; const mocks = { // ... -
在模式中定义的突变可以被添加到解析器中。这个突变需要清除购物车中的产品,将
count字段设置为0,并将complete字段设置为true。此外,它应该检查用户是否在上下文中存储了一个令牌,以及这个令牌是否有效。为了检查令牌,我们可以使用之前导入的isTokenValid方法:// ... const resolvers = { Query: { cart: () => cart, }, Mutation: { // ... + completeCart: (_, {}, { token }) => { + if (token && isTokenValid(token)) { + cart = { + count: 0, + products: [], + complete: true, + }; + return cart; + } + }, }, }; // ... -
在
pages/cart/index.js文件中,我们需要从@apollo/client导入这个 Hook,并从 Next.js 导入useRouter以将未认证的用户重定向到/login页面。此外,可以在这里添加完成购物车的突变:import styled from 'styled-components'; import { useQuery, + useMutation, gql } from '@apollo/client'; + import { useRouter } from 'next/router'; // ... + const COMPLETE_CART = gql` + mutation completeCart { + completeCart { + complete + } + } + `; function Cart() { // ...
在 Cart 组件的返回语句中,有一个用于结账的按钮。这个按钮需要调用由 useMutation Hook 创建的函数,该函数接受这个新的突变。这个突变完成购物车并清除其内容。如果用户未认证,它应该将用户重定向到 /login 页面:
// ...
function Cart() {
const { loading, data } = useQuery(GET_CART);
+ const [completeCard] = useMutation(COMPLETE_CART);
return (
<>
<SubHeader title='Cart' />
{loading ? (
<span>Loading...</span>
) : (
<CartWrapper>
// ...
{data &&
data.cart.products.length > 0 &&
+ sessionStorage.getItem('token') && (
<Button
backgroundColor='royalBlue'
+ onClick={() => {
+ const isAuthenticated =
sessionStorage.getItem(
'token');
+ if (isAuthenticated) {
+ completeCard();
+ }
+ }}
>
Checkout
</Button>
)}
</CartWrapper>
)}
</>
);
}
export default Cart;
这完成了应用程序的结账过程,从而结束了这一章,在这一章中,你使用了 React 和 GraphQL 来创建一个电子商务应用程序。
摘要
在这一章中,你创建了一个全栈 React 应用程序,该应用程序使用 GraphQL 作为其后端。使用 GraphQL 服务器和模拟数据,在 Next.js 中使用 API 路由创建了 GraphQL 服务器。这个 GraphQL 服务器接收查询和突变,为你提供数据,并允许你突变这些数据。这个 GraphQL 服务器被一个使用 Apollo 客户端的 React 应用程序使用,以从服务器发送和接收数据。
就这样!你已经完成了这本书的第七章,并且已经用 React 创建了七个网络应用程序。到现在为止,你应该对 React 和其特性感到很舒适,并且准备好学习更多。在下一章中,你将介绍 React Native,并学习如何通过使用 React Native 和 Expo 创建一个动画游戏来利用你的 React 技能构建一个移动应用程序。
进一步阅读
-
Next.js API 路由:
nextjs.org/docs/api-routes/introduction -
GraphQL:
graphql.org/learn/ -
Apollo 客户端:
www.apollographql.com/docs/react/
第八章:使用 React Native 和 Expo 构建动画游戏
React 开发的口号之一是*"一次学习,到处编写","这得益于 React Native 的存在。使用 React Native,您可以使用 JavaScript 和 React 编写原生移动应用程序,并使用名为Expo的工具链轻松运行和部署这些应用程序。本书中创建的先前应用程序都是 Web 应用程序,这意味着它们将在浏览器中运行。在浏览器中运行应用程序的缺点是在您点击按钮或导航到不同页面时缺乏交互。当构建直接在手机上运行的移动应用程序时,您的用户期望有使应用程序使用起来容易且熟悉的动画和手势。这就是您在本章中要关注的内容。
在本章中,您将创建一个 React Native 应用程序,使用 React Native 的 Animated API 和一个名为GestureHandler的包添加动画和手势。它们一起使我们能够创建充分利用移动设备交互方法的应用程序,这对于像高/低这样的游戏来说非常完美。
要创建这个游戏,以下主题将被涵盖:
-
使用 Expo 设置 React Native
-
向 React Native 添加手势和动画
-
使用 Lottie 的高级动画
项目概述
在本章中,我们将使用 React Native 和 Expo 创建一个动画高/低游戏,它使用 Animated API 添加基本动画,Lottie 进行高级动画,以及来自 Expo 的GestureHandler来处理原生手势。
构建时间为 1.5 小时。
注意
本章使用 React Native 版本 0.64.3 和 Expo SDK 版本 44。由于 React Native 和 Expo 更新频繁,请确保您使用的是这个版本,以确保本章中描述的模式按预期运行。
入门
本章中我们构建的项目完整源代码可以在 GitHub 上找到:github.com/PacktPublishing/React-Projects-Second-Edition/tree/main/Chapter08。此外,本章最后部分所需的winner.json文件可以在github.com/PacktPublishing/React-Projects-Second-Edition/tree/main/Chapter08-assets找到。
您需要在 iOS 或 Android 移动设备上安装 Expo Go 应用程序,以便在物理设备上运行项目。一旦您下载了应用程序,您需要创建一个 Expo 账户以使开发过程更加顺畅。请确保将您的账户详细信息保存在安全的地方,因为您在本章的后续部分需要使用这些信息。
或者,您可以在计算机上安装 Xcode 或 Android Studio 来在虚拟设备上运行应用程序:
-
对于 iOS:有关如何设置本地机器以运行 iOS 模拟器的信息,请在此处查看:
docs.expo.io/workflow/ios-simulator/. -
对于 Android:有关如何设置本地机器以从 Android Studio 运行模拟器的信息,请在此处查看:
docs.expo.io/workflow/android-studio-emulator/.注意
强烈推荐使用 Expo 客户端应用程序从本章开始在一个物理设备上运行项目。目前,仅支持在物理设备上接收通知,在 iOS 模拟器或 Android Studio 模拟器上运行项目将导致错误信息。
使用 React Native 和 Expo 创建一个动画游戏应用程序
在本节中,您将使用 React Native 和 Expo 构建一个在移动设备上直接运行的动画游戏。React Native 允许您使用您已经从 React 熟悉的相同语法和模式,因为它使用核心 React 库。此外,Expo 使得您无需安装和配置 Xcode(对于 iOS)或 Android Studio 即可开始在您的机器上创建原生应用程序。因此,您可以从任何机器上为 iOS 和 Android 平台编写应用程序。
Expo 将 React API 和 JavaScript API 结合到 React Native 开发过程中,例如 JSX 组件、Hooks 以及如相机访问等原生功能。简而言之,Expo 工具链由多个工具组成,这些工具可以帮助您使用 React Native,例如 Expo CLI,它允许您从终端创建 React Native 项目,并包含运行 React Native 所需的所有依赖项。使用 Expo 客户端,您可以从连接到本地网络的 iOS 和 Android 移动设备上打开这些项目,而 Expo SDK 是一个包含所有库的包,使得您的应用程序能够在多个设备和平台上运行。
使用 Expo 设置 React Native
我们在这本书中之前创建的应用程序使用了 Create React App 或 Next.js 来设置起始应用程序。对于 React Native,有一个类似的样板代码可用,它是 Expo CLI 的一部分,可以同样轻松地设置。
您需要使用以下命令全局安装 Expo CLI,使用 Yarn:
yarn global add expo-cli
或者,您可以使用 npm:
npm install -g expo-cli
注意
Expo 使用 Yarn 作为其默认的包管理器,但您仍然可以使用 npm,就像我们在之前的 React 章节中所做的那样。
这将启动安装过程,这可能需要一些时间,因为它将安装带有所有依赖项的 Expo CLI,以帮助您开发移动应用程序。之后,您将能够使用 Expo CLI 的 init 命令创建一个新项目:
expo init chapter-8
现在,Expo 将为您创建项目,但在那之前,它会询问您是想创建一个空白模板、带有 TypeScript 配置的空白模板,还是带有一些示例屏幕的样本模板。对于本章,您需要选择第一个选项。Expo 会自动检测您的机器上是否已安装 Yarn;如果是,它将使用 Yarn 安装设置计算机所需的其它依赖项。
您的应用程序现在将根据您之前选择的设置创建。您现在可以通过进入 Expo 刚刚创建的目录,使用以下命令启动此应用程序:
cd chapter-8
yarn start
这将启动 Expo,并允许您从终端和浏览器启动您的项目。在终端中,您现在将看到一个 QR 码,您可以使用移动设备上的 Expo 应用程序扫描,或者如果您已安装 Xcode 或 Android Studio,您还可以启动 iOS 或 Android 模拟器。此外,在运行start命令后,Expo DevTools将在您的浏览器中打开:
图 8.1 – 运行 Expo 时的 Expo DevTools
在此页面上,您将在左侧看到一个侧边栏,以及您的 React Native 应用程序的日志在右侧。如果您使用的是 Android 设备,您可以直接从 Expo Go 应用程序扫描 QR 码。在 iOS 上,您需要使用相机扫描代码,这将要求您打开 Expo 客户端。或者,Expo DevTools 中的侧边栏有按钮可以启动 iOS 或 Android 模拟器,您需要安装 Xcode 或 Android Studio。否则,您还可以找到按钮通过电子邮件发送应用程序链接。
无论您是使用 iOS 或 Android 模拟器打开的应用程序,还是从 iOS 或 Android 设备打开,此时应用程序应该是一个显示打开 App.js 以开始您的应用程序开发的白色屏幕。
注意
如果您没有看到应用程序,而是显示错误信息的红色屏幕,您应该确保您在本地机器和移动设备上运行的是正确的 React Native 和 Expo 版本。这些版本应该是 React Native 版本 0.64.3 和 Expo 版本 44。使用任何其他版本都可能导致错误,因为 React Native 和 Expo 的版本应该保持同步。
使用 Expo 创建的此 React Native 应用程序的项目结构与您在前几章中创建的 React 项目非常相似:
chapter-8
|- node_modules
|- assets
|- package.json
|- App.js
|- app.json
|- babel.config.js
在assets目录中,一旦你在移动设备上安装了此应用程序,你就可以找到用作主屏幕应用程序图标的图像,以及将作为启动屏幕使用的图像,该屏幕在启动应用程序时显示。App.js文件是应用程序的实际入口点,你将在这里放置在应用程序挂载时将被渲染的代码。应用程序的配置(例如,应用商店)放置在app.json中,而babel.config.js包含特定的 Babel 配置。
添加基本路由
对于使用 React 创建的 Web 应用程序,我们使用了 React Router 进行导航,而对于 Next.js,路由已经通过文件系统内置。对于 React Native,我们需要一个支持 iOS 和 Android 的不同路由库。这个最受欢迎的库是react-navigation,我们可以从Yarn安装它:
yarn add @react-navigation/native
这将安装核心库,但我们需要通过运行以下命令来扩展我们当前的 Expo 安装,以包含react-navigation所需的依赖项:
expo install react-native-screens react-native-safe-area-context
要将路由添加到你的 React Native 应用程序中,你需要了解浏览器和移动应用程序中路由的区别。React Native 中的历史记录在行为上与浏览器不同,在浏览器中,用户可以通过更改浏览器中的 URL 来导航到不同的页面,并且之前访问过的 URL 会被添加到浏览器历史记录中。相反,你需要自己跟踪页面之间的转换并存储应用程序中的本地历史记录。
使用 React Navigation,你可以使用多个不同的导航器来帮助你完成这项工作,包括栈导航器和标签导航器。栈导航器的行为非常类似于浏览器,因为它在过渡后将页面堆叠在一起,并允许你使用 iOS 和 Android 的原生手势和动画进行导航。让我们开始吧:
-
首先,我们需要安装这个库来使用栈导航以及来自
react-navigation的带有导航元素的附加库:yarn add @react-navigation/native-stack@react-navigation/elements -
从这个库和
react-navigation的核心库中,我们需要在App.js中导入以下内容来创建栈导航器:import { StatusBar } from 'expo-status-bar'; import React from 'react'; import { StyleSheet, Text, View } from 'react-native'; + import { NavigationContainer } from '@react-navigation/native'; + import { createNativeStackNavigator } from '@react-navigation/native-stack'; + const Stack = createNativeStackNavigator(); export default function App() { // ... -
从
App组件中,我们需要返回这个栈导航器,它还需要一个组件来返回主屏幕。因此,我们需要在名为screens的新目录中创建一个Home组件。这个组件可以在名为Home.js的文件中创建,内容如下:import React from 'react'; import { StyleSheet, Text, View } from 'react-native'; export default function Home() { return ( <View style={styles.container}> <Text>Home screen</Text> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#fff', alignItems: 'center', justifyContent: 'center', }, }); -
在
App.js中,我们需要导入这个Home组件,并通过从App组件返回一个NavigationContainer组件来设置栈导航器。在这个组件内部,栈导航器是通过Stack组件中的Navigator组件创建的,主屏幕在Stack.Screen组件中描述。此外,移动设备的状态栏也在这里定义:import { StatusBar } from 'expo-status-bar'; import React from 'react'; - import { StyleSheet, Text, View } from 'react-native'; + import { StyleSheet } from 'react-native'; import { NavigationContainer } from '@react-navigation/native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; + import Home from './screens/Home'; const Stack = createNativeStackNavigator(); export default function App() { export default function App() { return ( - <View style={styles.container}> - <Text>Open up App.js to start working on your app!</Text> + <NavigationContainer> <StatusBar style='auto' /> + <Stack.Navigator> + <Stack.Screen name='Home' component={Home} /> + </Stack.Navigator> + </NavigationContainer> - </View> ); } // ...
确保您仍在终端中运行 Expo;否则,请使用yarn start命令重新启动。现在,您的移动设备或模拟器上的应用程序应该看起来像这样:
图 8.2 – 带有堆栈导航器的应用程序
注意
在 Expo Go 中重新加载应用程序时,您可以使用 iOS 或 Android 手机摇晃设备。通过摇晃设备,将出现一个菜单,其中包含重新加载应用程序的选项。在此菜单中,您还必须选择启用快速刷新,以便在您对代码进行更改时自动刷新应用程序。
我们已经设置了堆栈导航器的第一个页面,所以让我们在下一部分添加更多页面,并创建按钮在它们之间导航。
在屏幕之间导航
在 React Native 中在屏幕之间导航的工作方式与在浏览器中略有不同,因为再次没有 URL。相反,您需要使用堆栈导航器渲染的组件可用的导航对象,或者通过从react-navigation调用useNavigation钩子。
在学习如何在屏幕之间导航之前,我们需要添加另一个屏幕进行导航:
-
您可以通过在
screens目录下名为Game.js的文件中创建一个新的组件来添加此屏幕,代码如下:import React from 'react'; import { StyleSheet, Text, View } from 'react-native'; export default function Game() { return ( <View style={styles.container}> <Text>Game screen</Text> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#fff', alignItems: 'center', justifyContent: 'center', }, }); -
此组件必须在
App.js中导入,并作为新屏幕添加到堆栈导航器中。此外,在导航器上,我们需要设置initialRouteName属性来设置必须显示的默认屏幕:import { StatusBar } from 'expo-status-bar'; import React from 'react'; import { StyleSheet } from 'react-native'; import { NavigationContainer } from '@react-navigation/native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import Home from './screens/Home'; + import Game from './screens/Game'; const Stack = createNativeStackNavigator(); export default function App() { return ( <NavigationContainer> <StatusBar style='auto' /> - <Stack.Navigator> + <Stack.Navigator initialRouteName='Home'> <Stack.Screen name='Home' component={Home} /> + <Stack.Screen name='Game' component={Game} /> </Stack.Navigator> </NavigationContainer> ); } // ... -
从
screens/Home.js中的Home组件,我们可以从useNavigation钩子中获取导航对象,并创建一个按钮,当按下时将导航到Game屏幕。这是通过使用navigation对象的navigate方法并将其传递给 React Native 的Button组件的onPress属性来完成的:import React from 'react'; - import { StyleSheet, Text, View } from 'react-native'; + import { StyleSheet, View, Button } from 'react-native'; + import { useNavigation } from '@react-navigation/native'; export default function Home() { + const navigation = useNavigation(); return ( <View style={styles.container}> - <Text>Home screen</Text> + <Button onPress={() => navigation.navigate( 'Game')} title='Start game!' /> </View> ); } // ...
现在,您可以通过使用我们刚刚创建的按钮或使用页眉中的按钮在主页和游戏屏幕之间进行切换。此页眉是由 react-navigation 自动生成的,但您也可以自定义它,我们将在第九章中这样做,使用 React Native 和 Expo 构建全栈社交媒体应用程序:
图 8.3 – 带有基本路由的我们的应用程序
到目前为止,我们已经向应用程序添加了基本路由,但我们还没有游戏。在screens/Game.js文件中,可以通过使用本地状态管理(使用useState和useEffect钩子)来添加高/低游戏的逻辑。这些钩子在 React Native 中的工作方式与在 React 网络应用程序中相同。让我们添加游戏逻辑:
-
在
Game组件中,从 React 导入这些钩子,在Button和Alert组件旁边。导入它们之后,我们需要创建一个本地状态变量来存储用户的选择,并为游戏创建随机数和分数。还要从react-navigation中导入useNavigation钩子:- import React from 'react'; - import { StyleSheet, Text, View } from 'react-native'; + import React, { useEffect, useState } from 'react'; + import { Button, StyleSheet, Text, View, Alert } from 'react-native'; + import { useNavigation } from '@react-navigation/native'; export default function Game() { + const baseNumber = Math.floor(Math.random() * 100); + const score = Math.floor(Math.random() * 100); + const [choice, setChoice] = useState(''); return ( <View style={styles.container}> // ...
baseNumber 的值是游戏开始时带有 1 到 100 之间的初始随机值的数字,使用 JavaScript 中的 Math 方法创建。分数值也有一个随机数作为值,这个值用于与 baseNumber 进行比较。choice 本地状态变量用于存储用户的选项,如果分数高于或低于 baseNumber。
-
要能够做出选择,我们需要添加两个
Button组件,根据你按下的哪个按钮设置选择值是更高或更低:// ... return ( <View style={styles.container}> - <Text>Game screen</Text> + <Text>Starting: {baseNumber}</Text> + <Button onPress={() => setChoice('higher')} title='Higher' /> + <Button onPress={() => setChoice('lower')} title='Lower' /> </View> ); } const styles = StyleSheet.create({ // ... -
从
useEffect钩子中,我们可以比较baseNumber和score的值,并根据值选择显示一个警告。根据选择,用户会看到一个显示消息说明他们是否获胜以及得分的Alert组件。在显示警告的同时,将使用baseNumber、score和choice的值来导航回上一页。这将重置Game组件:// ... + const navigation = useNavigation(); + useEffect(() => { + if (choice) { + const winner = + (choice === 'higher' && score > baseNumber) || + (choice === 'lower' && baseNumber > score); + Alert.alert(`You've ${winner ? 'won' : 'lost'}`, `You scored: ${score}`); + navigation.goBack(); + } + }, [baseNumber, score, choice]); return ( <View style={styles.container}> // ...
现在,你能够玩游戏并选择你认为分数是否会高于或低于显示的 baseNumber。但我们还没有添加任何样式,这将在本节下一部分完成。
React Native 中的样式
你可能在前面的组件中看到,我们更改或添加到项目中使用了名为 StyleSheet 的变量。使用这个变量从 React Native 中,我们可以创建一个样式对象,我们可以通过传递一个名为 style 的属性将其附加到 React Native 组件上。我们已经使用它来使用名为 container 的样式来设置组件样式,但让我们做一些更改,也为其他组件添加样式:
-
在
screens/Home.js中,我们需要将Button组件替换为TouchableHighlight组件,因为 React Native 中的Button组件难以设置样式。这个TouchableHighlight组件是一个可按的元素,当按下时会高亮显示,为用户提供反馈。在这个组件内部,必须添加一个Text组件来显示按钮的标签:import React from 'react'; - import { StyleSheet, View, Button } from 'react-native'; + import { StyleSheet, Text, View, TouchableHighlight } from 'react-native'; import { useNavigation } from '@react-navigation/native'; export default function Home() { const navigation = useNavigation(); return ( <View style={styles.container}> - <Button onPress={() => navigation.navigate( 'Game')} title='Start game!' /> + <TouchableHighlight + onPress={() => navigation.navigate('Game')} + style={styles.button} + > + <Text style={styles.buttonText}> Start game!</Text> + </TouchableHighlight> </View> ); } // ... -
TouchableHighlight和Text组件使用styles对象中的button和buttonText样式,我们需要将其添加到文件底部的StyleSheet的create方法中:// ... const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#fff', alignItems: 'center', justifyContent: 'center', }, + button: { + width: 300, + height: 300, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-around', + borderRadius: 150, + backgroundColor: 'purple', + }, + buttonText: { + color: 'white', + fontSize: 48, + }, });
使用 React Native 创建样式意味着你需要使用 camelCase 表示法,而不是我们习惯的 CSS 中的 kebab-case – 例如,background-color 变为 backgroundColor。
-
我们还需要通过打开
screens/Game.js文件来为Game屏幕上的按钮添加样式修改。在这个文件中,我们再次需要用带有内部Text的TouchableHighlight组件替换 React Native 的Button组件:import React, { useEffect, useState } from 'react'; import { - Button, StyleSheet, Text, View, Alert, + TouchableHighlight, } from 'react-native'; import { useNavigation } from '@react-navigation/native'; export default function Game() { // ... return ( <View style={styles.container}> - <Text>Starting: {baseNumber}</Text> - <Button onPress={() => setChoice('higher')} title='Higher' /> - <Button onPress={() => setChoice('lower')} title='Lower' /> + <Text style={styles.baseNumber}> Starting: {baseNumber}</Text> + <TouchableHighlight onPress={() => setChoice('higher')} style={styles.button}> + <Text style={styles.buttonText}>Higher </Text> + </TouchableHighlight> + <TouchableHighlight onPress={() => setChoice('lower')} style={styles.button}> + <Text style={styles.buttonText}>Lower</Text> + </TouchableHighlight> </View> ); } // ... -
styles对象必须包含新的baseNumber、button和buttonText样式,我们可以在文件底部添加这些样式:// ... const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#fff', alignItems: 'center', justifyContent: 'center', }, + baseNumber: { + fontSize: 48, + marginBottom: 30, + }, + button: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-around', + borderRadius: 15, + padding: 30, + marginVertical: 15, + }, + buttonText: { + color: 'white', + fontSize: 24, + }, }); -
然而,现在两个按钮都将拥有相同的白色背景。我们可以通过为它们添加额外的样式来改变这一点。React Native 组件上的
style属性也可以接受一个样式对象的数组,而不仅仅是单个对象:// ... return ( <View style={styles.container}> <Text style={styles.baseNumber}> Starting: {baseNumber}</Text> <TouchableHighlight onPress={() => setChoice('higher')} - style={styles.button} + style={[styles.button, styles.buttonGreen]} > <Text style={styles.buttonText}>Higher</Text> </TouchableHighlight> <TouchableHighlight onPress={() => setChoice('lower')} - style={styles.button} + style={[styles.button, styles.buttonRed]} > <Text style={styles.buttonText}>Lower</Text> </TouchableHighlight> </View> ); // ... -
这些
buttonGreen和buttonRed对象也必须添加到样式对象中:// ... const styles = StyleSheet.create({ // ... + buttonRed: { + backgroundColor: 'red', + }, + buttonGreen: { + backgroundColor: 'green', + }, buttonText: { color: 'white', fontSize: 24, }, });
通过这些添加,应用程序现在已经被样式化,这使得它更具吸引力。我们使用了 React Native 的 StyleSheet 对象来应用这种样式,使你的应用程序看起来像这样:
图 8.4 – 样式化的 React Native 应用程序
移动游戏通常有令人眼花缭乱的动画,这些动画会让用户想要继续玩游戏,并使游戏更具互动性。目前功能正常的 Higher/Lower 游戏还没有使用动画,只是内置了一些 React Navigation 创建的过渡效果。在下一节中,你将为应用程序添加动画和手势,这将改善游戏界面,并让用户在玩游戏时感到更加舒适。
在 React Native 中添加手势和动画
在 React Native 中使用动画有多种方式,其中之一是使用 Animated API,这是 React Native 的核心。使用 Animated API,你可以为 React Native 的 View、Text、Image 和 ScrollView 组件创建默认的动画。或者,你也可以使用 createAnimatedComponent 方法来创建自己的组件。
创建基本动画
你可以添加的最简单的动画之一是通过改变元素的透明度值来使元素淡入或淡出。在之前创建的 Higher/Lower 游戏中,按钮已经被样式化了。这些颜色已经显示出微小的过渡,因为你在创建按钮时使用了 TouchableHighlight 元素。然而,你可以通过使用 Animated API 来添加一个自定义的过渡效果。要添加动画,必须更改以下代码块:
-
首先,创建一个名为
components的新目录,它将包含我们所有的可重用components。在这个目录中,创建一个名为AnimatedButton.js的文件,它将包含以下代码来构建新的组件:import React from 'react'; import { StyleSheet, Text, TouchableHighlight } from 'react-native'; export default function AnimatedButton({ action, onPress }) { return ( <TouchableHighlight onPress={onPress} style={[ styles.button, action === 'higher' ? styles.buttonGreen : styles.buttonRed, ]} > <Text style={styles.buttonText}>{action}</Text> </TouchableHighlight> ); } -
将以下样式添加到文件底部:
// ... const styles = StyleSheet.create({ button: { display: 'flex', alignItems: 'center', justifyContent: 'space-around', borderRadius: 15, padding: 30, marginVertical: 15, }, buttonRed: { backgroundColor: 'red', }, buttonGreen: { backgroundColor: 'green', }, buttonText: { color: 'white', fontSize: 24, textTransform: 'capitalize', }, }); -
如你所见,这个组件与我们在
screens/Game.js中拥有的按钮相似。因此,我们可以从该文件中删除TouchableHighlight按钮,并用AnimatedButton组件替换它们。确保将正确的action和onPress值作为属性传递给此组件:import React, { useEffect, useState } from 'react'; import { StyleSheet, Text, View, Alert, - TouchableHighlight, } from 'react-native'; import { useNavigation } from '@react-navigation/native'; + import AnimatedButton from '../components/AnimatedButton'; export default function Game() { // ... return ( <View style={styles.container}> <Text style={styles.baseNumber}> Starting: {baseNumber}</Text> - <TouchableHighlight onPress={() => setChoice('higher')} style={[styles.button, styles.buttonGreen]}> - <Text style={styles.buttonText}>Higher </Text> - </TouchableHighlight> - <TouchableHighlight onPress={() => setChoice('lower')} style={[styles.button, styles.buttonRed]}> - <Text style={styles.buttonText}>Lower</Text> - </TouchableHighlight> + <AnimatedButton action='higher' onPress={() => setChoice('higher')} /> + <AnimatedButton action='lower' onPress={() => setChoice('lower')} /> </View> ); } // ... -
如果你在移动设备上的应用程序或电脑上的模拟器中查看,则不会看到任何可见的变化,因为我们首先需要将可点击元素从
TouchableHighlight元素更改为TouchableWithoutFeedback元素。这样,带有高亮的默认过渡就会消失,我们可以用我们自己的效果来替换它。TouchableWithoutFeedback元素可以从 React Native 的components/AnimatedButton.js中导入,并且应该放在一个View元素周围,该元素将保留按钮的默认样式:import React from 'react'; import { StyleSheet, Text, - TouchableHighlight, + TouchableWithoutFeedback, + View } from 'react-native'; export default function AnimatedButton({ action, onPress }) { return ( - <TouchableHighlight onPress={onPress} style={[ styles.button, action === 'higher' ? styles.buttonGreen : styles.buttonRed ]}> + <TouchableWithoutFeedback onPress={onPress}> + <View style={[ styles.button, action === 'higher' ? styles.buttonGreen : styles.buttonRed ]}> <Text style={styles.buttonText}>{action}</Text> - </TouchableHighlight> + </View> + </TouchableWithoutFeedback> ); } // ... -
要在点击按钮时创建过渡效果,我们可以使用 Animated API。我们将使用它来改变
AnimatedButton组件在被按下时的不透明度。Animated API 的新实例通过指定在动画过程中应该改变值的值来开始。这个值应该在组件的整个范围内可改变,因此你可以将这个值添加到组件的顶部。这个值应该使用useRef钩子创建,因为你希望这个值以后可以改变。此外,我们还需要从 React Native 中导入Animated:- import React from 'react'; + import React, { useRef } from 'react'; import { StyleSheet, Text, TouchableWithoutFeedback, - View, + Animated, } from 'react-native'; export default function AnimatedButton({ action, onPress }) { + const opacity = useRef(new Animated.Value(1)); return ( // ... -
现在可以使用内置的任何三种动画类型来改变这个值。这些是
decay、spring和timing,其中你将使用 Animated API 的timing方法在指定的时间范围内改变动画值。Animated API 可以从TouchableWithoutFeedback上的onPress事件触发,并在动画完成后调用onPress属性:// ... export default function AnimatedButton({ action, onPress }) { const opacity = useRef(new Animated.Value(1)); return ( <TouchableWithoutFeedback - onPress={onPress} + onPress={() => { + Animated.timing(opacity.current, { + toValue: 0.2, + duration: 800, + useNativeDriver: true, + }).start(() => onPress()); + }} > // ...
timing方法接受你在组件顶部指定的opacity以及一个包含 Animated API 配置的对象。我们需要获取当前的不透明度值,因为这个是一个ref值。其中一个字段是toValue,当动画结束时,它将成为opacity的值。另一个字段是用于指定动画持续时间的字段。
注意
与timing并列的内置动画类型还有decay和spring。而timing方法在一段时间内逐渐改变,decay类型具有在开始时快速改变并在动画结束时逐渐减慢的动画。使用spring,你可以在动画结束时创建稍微超出其边缘的动画。
-
可以用
Animated.View组件替换View组件。该组件使用由useRef钩子创建的opacity变量来设置其不透明度:// ... - <View + <Animated.View style={[ styles.button, action === 'higher' ? styles.buttonGreen : styles.buttonRed, + { opacity: opacity.current }, ]} > <Text style={styles.buttonText}>{action} </Text> - </View> + </Animated.View> </TouchableWithoutFeedback> ); } // ...
现在,当你按下Game屏幕上的任何按钮时,它们将会淡出,因为不透明度从1过渡到0.2需要 400 毫秒。
为了使动画看起来更平滑,你可以向Animated对象添加一个easing字段。这个字段的值来自Easing模块,可以从 React Native 导入。Easing模块有三个标准函数:linear、quad和cubic。在这里,linear函数可以用于更平滑的时间动画:
import React, { useRef } from 'react';
import {
StyleSheet,
Text,
TouchableWithoutFeedback,
Animated,
+ Easing,
} from 'react-native';
export default function AnimatedButton({ action, onPress }) {
const opacity = useRef(new Animated.Value(1));
return (
<TouchableWithoutFeedback
onPress={() => {
Animated.timing(opacity.current, {
toValue: 0.2,
duration: 400,
useNativeDriver: true,
+ easing: Easing.linear(),
}).start(() => onPress());
}}
>
// ...
经过这个最后的修改,动画就完成了,游戏界面已经感觉更平滑了,因为按钮是通过我们自己的自定义动画来高亮的。在本节的下一部分,我们将结合一些这些动画,使游戏的用户体验更加先进。
注意
你还可以结合动画——例如,使用parallel方法——从 Animated API。这个方法将启动在同一时刻指定的动画,并将一个动画数组作为其值。在parallel函数旁边,还有三个其他函数可以帮助你进行动画组合。这些函数是delay、sequence和stagger,它们也可以组合使用。delay函数在预定延迟后开始任何动画,sequence函数按照你指定的顺序开始动画,并在一个动画解决之前等待,然后开始另一个动画,而stagger函数可以在指定延迟之间按顺序和并行地开始动画。
使用 Expo 处理手势
手势是移动应用程序的一个重要特性,因为它们区分了平庸和优秀的移动应用程序。在你创建的高低游戏中,可以添加几个手势来使游戏更具吸引力。
之前,你使用了TouchableHighlight元素,它在用户按下后会通过改变它来提供用户反馈。另一种你可以用来实现这个功能的元素是TouchableOpacity元素。这些手势给用户一种印象,即当他们在你的应用程序中做出决策时会发生什么,从而改善了用户体验。这些手势可以自定义并添加到其他元素中,使得可以拥有自定义的可触摸元素。
为了实现这一点,你可以使用一个名为react-native-gesture-handler的包,它可以帮助你在每个平台上访问原生手势。所有这些手势都将运行在原生线程上,这意味着你可以在不处理 React Native 手势响应系统性能限制的情况下添加复杂的手势逻辑。它支持的一些手势包括点击、旋转、拖动、平移和长按。在前一节中,我们已经安装了这个包,因为它是react-navigation的要求。
注意
你也可以直接从 React Native 使用手势,而无需使用额外的包。然而,React Native 当前使用的响应者系统不在原生线程中运行。这不仅限制了创建和自定义手势的可能性,还可能导致跨平台或性能问题。因此,建议使用react-native-gesture-handler包,但这对于在 React Native 中使用手势并不是必需的。
我们将实现的动作是长按手势,它将被添加到我们的主页屏幕上的开始按钮中,位于screens/Home.js。在这里,我们将使用来自react-native-gesture-handler的TapGestureHandler元素,它在原生线程中运行,而不是使用 React Native 的TouchableWithoutFeedback元素,后者使用手势响应系统。为了实现这一点,我们需要做以下操作,请确保其余的数字都相应更新:
-
使用 Expo 进行安装:
expo install react-native-gesture-handler -
从
react-native-gesture-handler导入TapGestureHandler和State,紧挨着从 React Native 导入的View和Alert。可以移除TouchableHighlight的导入,因为这将被替换:import React from 'react'; import { StyleSheet, Text, View, + Alert, - TouchableHighlight, } from 'react-native'; import { useNavigation } from '@react-navigation/native'; + import { TapGestureHandler, State } from 'react-native-gesture-handler'; export default function Home() { // ... -
我们可以将
TouchableHighlight组件替换为TapGestureHandler,并在其中放置一个View组件,然后对其应用样式。TapGestureHandler不接收onPress属性,而是接收onHandlerStateChange属性,我们将新的onTap函数传递给它。在这个函数中,我们需要检查触摸事件的状态是否为活动状态。为此,你需要知道触摸事件会经过不同的状态:UNDETERMINED、FAILED、BEGAN、CANCELLED、ACTIVE和END。这些状态的名字相当直观,通常处理器的流程如下:UNDETERMINED>BEGAN>ACTIVE>END>UNDETERMINED:// ... export default function Home() { const navigation = useNavigation(); + function onTap(e) { + if (e.nativeEvent.state === State.ACTIVE) { + Alert.alert('Long press to start the game'); + } + } return ( <View style={styles.container}> - <TouchableHighlight - onPress={() => navigation.navigate('Game')} - style={styles.button} - > + <TapGestureHandler onHandlerStateChange={onTap}> + <View style={styles.button}> <Text style={styles.buttonText}>Start game!</Text> + </View> - </TouchableHighlight> + </TapGestureHandler> </View> ); } // ... -
如果你现在在“主页”屏幕上按下开始按钮,你会收到需要长按按钮以开始游戏的消息。为了添加这个长按手势,我们需要在
TapGestureHandler组件内部添加一个LongPressGestureHandler组件。此外,我们还需要创建一个可以被LongPressGestureHandler组件调用的函数,该函数将带我们进入游戏屏幕:import React from 'react'; import { StyleSheet, Text, View, Alert } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { + LongPressGestureHandler, TapGestureHandler, State, } from 'react-native-gesture-handler'; export default function Home() { const navigation = useNavigation(); + function onLongPress(e) { + if (e.nativeEvent.state === State.ACTIVE) { + navigation.navigate('Game'); + } + } // ... -
在
TapGestureHandler内部应放置新导入的LongPressGestureHandler组件。该组件接收导航到游戏的函数,以及一个设置长按最小持续时间的属性。如果你不设置此属性,默认的最小持续时间将是 500ms:// ... export default function Home() { // ... return ( <View style={styles.container}> <TapGestureHandler onHandlerStateChange={onSingleTap} > + <LongPressGestureHandler+ onHandlerStateChange={onLongPress} + minDurationMs={600} + > <View style={styles.button}> <Text style={styles.buttonText}> Start game!</Text> </View> + </LongPressGestureHandler> </TapGestureHandler> </View> ); } // ...
通过这个最新的更改,您只能通过在 主页 屏幕上长按 开始 按钮来启动游戏。这些手势可以进一步自定义,因为您可以使用组合来拥有多个相互响应的点击事件。通过创建所谓的 交叉处理程序交互,您可以创建一个支持 双击 和 长按 手势的可触摸元素。
下一节将向您展示如何处理更高级的动画,例如在任意两位玩家获胜时显示动画图形。为此,我们将使用 Lottie 包,因为它比内置的 Animated API 支持更多的功能。
使用 Lottie 的高级动画
React Native Animated API 对于构建简单的动画来说很棒,但构建更高级的动画可能更困难。幸运的是,Lottie 通过使我们能够在 iOS、Android 和 React Native 中实时渲染 After Effects 动画,为 React Native 提供了创建高级动画的解决方案。
注意
当使用 Lottie 时,您不必自己创建这些 After Effects 动画;有一个完整的资源库,您可以在项目中自定义并使用。这个库叫做 LottieFiles,可在 lottiefiles.com/ 获取。
由于我们已经为游戏按钮添加了动画,添加更多高级动画的好地方是在显示您赢或输游戏的消息上。这个消息可以显示在屏幕上而不是弹窗中,如果用户赢了,可以显示奖杯。让我们现在就做这个:
-
要开始使用 Lottie,运行以下命令,它将安装 Lottie 到我们的项目中:
yarn add lottie-react-native -
安装完成后,我们可以创建一个新的屏幕组件,名为
screens/Result.js,其内容如下:import React from 'react'; import { StyleSheet, Text, View } from 'react-native'; export default function Result() { return ( <View style={styles.container}> <Text></Text> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#fff', alignItems: 'center', justifyContent: 'center', }, }); -
将此屏幕添加到堆栈导航器中,以便可以通过在 App.js 中导入它来在移动应用的导航中使用。此外,还应该导入导航元素
HeaderBackButton:import { StatusBar } from 'expo-status-bar'; import React from 'react'; import { StyleSheet } from 'react-native'; import { NavigationContainer } from '@react-navigation/native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; + import { HeaderBackButton } from '@react-navigation/elements'; import Home from './screens/Home'; import Game from './screens/Game'; + import Result from './screens/Result'; // ... -
当添加
Result屏幕时,我们也从 React Navigation 导入了HeaderBackButton组件,因为我们还想要更改主页屏幕而不是游戏屏幕,以便用户在完成游戏后可以开始新游戏:// ... export default function App() { return ( <NavigationContainer> <StatusBar style='auto' /> <Stack.Navigator initialRouteName='Home'> <Stack.Screen name='Home' component={Home} /> <Stack.Screen name='Game' component={Game} /> + <Stack.Screen + name='Result' + component={Result} + options={({ navigation }) => ({ + headerLeft: (props) => ( + <HeaderBackButton + {...props} + label='Home' + onPress={() => navigation.navigate('Home')} + /> + ), + })} + /> </Stack.Navigator> </NavigationContainer> ); // ... -
从
screens/Game.js中的Game屏幕中,我们可以在游戏后引导用户到Result屏幕并传递一个参数给这个屏幕。使用此参数,可以显示游戏的结果消息:// ... export default function Game() { // ... useEffect(() => { if (choice.length) { const winner = (choice === 'higher' && score > baseNumber) || (choice === 'lower' && baseNumber > score); - Alert.alert(`You've ${winner ? 'won' : 'lost'}`, `You scored: ${score}`); - navigation.goBack(); + navigation.navigate('Result', { winner }) } }, [baseNumber, score, choice]); return ( // ... -
从
screens/Result.js文件中的Result屏幕中,我们可以从lottie-react-native导入LottieView,并使用 React Navigation 的useRoute钩子从route对象中获取参数。使用此参数,如果用户赢了或输了,我们可以返回一条消息:import React from 'react'; import { StyleSheet, Text, View } from 'react-native'; + import LottieView from 'lottie-react-native'; + import { useRoute } from '@react-navigation/native'; export default function Result() { + const route = useRoute(); + const { winner } = route.params; return ( <View style={styles.container}> + <Text>You've {winner ? 'won' : 'lost'}</Text> // ... -
导入的
Lottie组件可以渲染你自行创建或从LottieFiles库下载的任何 Lottie 文件。在本章的 GitHub 仓库中,你可以找到一个名为winner.json的 Lottie 文件,该文件可用于本项目。此文件必须放置在assets目录中,并且可以通过将组件添加到源代码中由LottieView组件渲染。动画的width和height值可以通过传递一个style对象来设置。此外,你应该添加autoPlay属性以在组件渲染后开始动画:// ... export default function Result() { const route = useRoute(); const { winner } = route.params; return ( <View style={styles.container}> <Text>You've {winner ? 'won' : 'lost'}</Text> + {winner && ( + <LottieView + autoPlay + style={{ + width: 300, + height: 300, + }} + source={require('../assets/winner.json')} + /> + )} </View> ); } // ... -
作为最后的润色,我们可以在屏幕上显示的消息中添加一些样式,并使其更大:
// ... return ( <View style={styles.container}> - <Text>You've {winner ? 'won' : 'lost'}</Text> + <Text style={styles.message}> You've {winner ? 'won' : 'lost'}</Text> // ... const styles = StyleSheet.create({ // ... + message: { + fontSize: 48, + }, });
当Result屏幕组件接收到带有true值的winner参数时,用户将看到渲染的奖杯动画,而不是游戏板。当你使用 iOS 模拟器或 iOS 设备运行应用程序时,这个效果的样子可以在这里看到:
![图 8.5 – 游戏获胜后的 Lottie 动画
![img/Figure_8.05_B17390.jpg]
图 8.5 – 游戏获胜后的 Lottie 动画
注意
如果你觉得这个动画的速度太快,你可以通过结合 Animated API 和 Lottie 来降低速度。LottieView组件可以接受一个progress属性,该属性决定了动画的速度。当你传递由 Animated API 创建的值时,你可以根据你的偏好调整动画的速度。
通过使用 Lottie 添加此动画,我们创建了一个可以玩数小时的动画游戏移动应用程序。
摘要
在本章中,我们使用 Expo 创建了一个 React Native 应用程序。React Native 使用与 React 相同的原理,可以用来创建移动应用程序。我们基于堆栈导航添加了基本的路由,即 React Navigation。我们还向游戏中添加了基本和更复杂的手势,这些手势通过react-native-gesture-handler包在本地线程中运行。最后,我们使用 React Native Animated API 和 Lottie 创建了动画,这些 API 可通过 Expo CLI 获取。
在下一章中,我们将创建一个探索在 React Native 中处理数据的工程项目。我们还将了解 iOS 和 Android 之间在样式上的差异。
进一步阅读
-
Expo:
docs.expo.io/ -
各种 Lottie 文件:
lottiefiles.com/ -
更多关于 Animated API 的信息:
facebook.github.io/react-native/docs/animated
第九章:使用 React Native 和 Expo 构建全栈社交媒体应用程序
在本书中您创建的大多数项目都集中在显示数据和在页面之间导航。当我们使用 React Native 创建第一个移动应用程序时,动画是其中一个重点,这在创建移动应用程序时是必不可少的。在本章中,我们将探讨移动应用程序的一个大优势,即能够使用手机上的相机(或相册)。
本章中我们将创建的应用程序将遵循与之前章节相同的数据密集型应用程序模式。使用 React 技术,如 Context 和 Hooks,从支持身份验证的本地 API 获取数据,同时再次使用 React Navigation 创建更高级的路由设置。此外,使用运行应用程序的移动设备的相机通过 Expo 将图片发布到社交动态。
本章将涵盖以下主题:
-
具有身份验证的高级路由
-
在 React Native 和 Expo 中使用相机
-
iOS 和 Android 的样式差异
项目概述
在本章中,我们将构建一个使用本地 API 请求并添加帖子到社交动态的应用程序,包括使用移动设备上的相机。通过本地 API 和 React Navigation 添加了具有身份验证的高级路由,同时使用 Expo 来访问相机(滚动)。
构建时间为 2 小时。
注意
本章使用 React Native 版本 0.64.3 和 Expo SDK 版本 44。由于 React Native 和 Expo 经常更新,请确保您使用这些版本以确保本章中描述的模式按预期运行。
开始
本章中我们将创建的项目基于您可以在 GitHub 上找到的初始版本:github.com/PacktPublishing/React-Projects-Second-Edition/tree/main/Chapter09-initial。完整的源代码也可以在 GitHub 上找到:github.com/PacktPublishing/React-Projects-Second-Edition/tree/main/Chapter09。
您需要在 iOS 或 Android 移动设备上安装 Expo Go 应用程序,以便在物理设备上运行项目。一旦您下载了应用程序,您需要创建一个 Expo 账户以使开发过程更加顺畅。请确保将您的账户详细信息保存在安全的地方,因为您在本章的后续部分需要这些信息。
或者,您可以在计算机上安装 Xcode 或 Android Studio 来在虚拟设备上运行应用程序:
-
对于 iOS:有关如何设置本地机器以运行 iOS 模拟器的信息,请参阅此处:
docs.expo.io/workflow/ios-simulator/。 -
对于 Android:有关如何设置你的本地机器以从 Android Studio 运行模拟器的信息,可以在此处找到:
docs.expo.io/workflow/android-studio-emulator/。注意
强烈建议使用 Expo 客户端应用程序在物理设备上运行本章中的项目。目前仅支持在物理设备上接收通知,在 iOS 模拟器或 Android Studio 模拟器上运行项目将导致错误信息。
检查初始项目
对于本章,已经使用 Expo 的 CLI 创建了一个初始应用,正如你在上一章所学。要开始,你需要在本章目录下运行以下命令来安装所有依赖项并启动服务器和应用程序:
yarn && yarn start
在安装依赖项后,此命令将启动 Expo,并给你从终端或浏览器启动项目的权限。在终端中,你现在可以使用二维码在你的移动设备上打开应用程序,或者在模拟器中打开应用程序。在浏览器中,将打开 Expo DevTools,这也允许你使用手机摄像头或 Expo Go 应用程序扫描二维码。
为我们的应用程序获取数据的本地 API 是使用 JSON Server 创建的。我们之前已经使用过这个库,因为我们在这个存储库中使用了 db.json 文件。对于这个项目,我们在本章的目录中有一个单独的 db.json 文件,它由 server.js 文件加载以创建本地 API。可以通过在单独的终端标签或窗口中运行以下命令来启动本地 API:
yarn start-server
这将在 http://localhost:3000/api/ 上启动一个服务器,例如,http://localhost:3000/api/posts 端点,它返回一系列帖子。然而,在构建移动应用程序时,出于安全原因,你不能使用 localhost 地址(或任何没有 HTTPS 的其他地址)。为了能够在 React Native 应用程序中使用此端点,你需要找到你机器的本地 IP 地址。
要找到你的本地 IP 地址,你需要根据你的操作系统执行以下操作:
-
对于 Windows:打开终端(或命令提示符)并运行以下命令:
Ipconfig
这将返回一个类似于以下截图中的列表,其中包含你本地机器的数据。在这个列表中,你需要查找 IPv4 地址 字段:
图 9.1 – 在 Windows 中查找本地 IP 地址
-
对于 macOS:打开终端并运行以下命令:
ipconfig getifaddr en0
运行此命令后,你的机器的本地 IPv4 地址将被返回,看起来像这样:
192.168.1.107
本地 IP 地址可以用作 localhost 的替代品,您可以通过访问以下页面来尝试:http://192.168.1.107/api/posts。请确保将 IP 地址替换为您自己的。
本章的应用程序已经设置好,需要知道用于本地 API 的 URL。在 Expo 中的配置可以存储在 app.json 中,但如果您想存储特定的配置环境变量,也可以存储在 app.config.js 中。在此文件中,您可以添加以下配置:
export default {
extra: {
apiUrl: 'http://LOCAL_IP_ADDRESS:3000',
},
};
在前面的 app.config.js 文件中,您需要将 LOCAL_IP_ADDRESS 替换为您从您的机器上获取的自己的 IP 地址。
要在我们的代码中使用此环境变量,我们使用 expo-constants 库。这已经在本章的初始应用程序中安装,如何从 app.config.js 获取 apiUrl 的示例可以在 context/PostsContext.js 文件中看到:
import React from 'react';
import { createContext, useReducer } from 'react';
import Constants from 'expo-constants';
const { apiUrl } = Constants.manifest.extra;
export const PostsContext = createContext();
// ...
apiUrl 常量现在用于获取以下本地 API。无论您是从虚拟设备还是物理设备打开的应用程序,此时初始应用程序应该看起来像这样:
图 9.2 – 初始应用程序
初始应用程序的 screens 目录包含五个屏幕,分别是 Posts、PostDetail、PostForm、Profile 和 Login。Posts 屏幕将是加载的初始屏幕,显示您可以点击以继续到 PostDetail 屏幕的帖子列表。目前,PostForm、Profile 和 Login 屏幕尚未可见,因为我们将在本章后面添加高级路由和身份验证。
从这个 React Native 应用程序的项目结构如下,其中结构与您在这本书中之前创建的项目类似:
chapter-9-initial
|- /.expo
|- /.expo-shared
|- /node_modules
|- /assets
|- /components
|- Button.js
|- FormItem.js
|- PostItem.js
|- /context
|- AppContext.js
|- PostsContext.js
|- UserContext.js
|- /screens
|- Login.js
|- PostDetail.js
|- PostForm.js
|- Posts.js
|- Profile.js
app.config.js
app.json
App.js
babel.config.js
db.json
server.js
在 assets 目录中,您可以找到在您将此应用程序安装到移动设备上后用作主屏幕应用程序图标的图像,以及将作为启动屏幕显示的图像。App.js 文件是您应用程序的实际入口点,所有此应用程序的组件都位于 screens 和 components 目录中。您还可以找到一个名为 context 的目录。此目录包含此应用程序的所有状态管理组件。
注意
如果在您的本地设备或模拟器上加载应用程序时出现错误,显示 app.config.js。此外,服务器必须在单独的终端标签页中运行。
您的应用程序的配置,例如 App Store,放置在 app.json 中,而 babel.config.js 包含特定的 Babel 配置。如前所述,app.config.js 文件包含本地 API 的 URL 配置。还需要两个文件来创建本地 API。这些是前面在本节中描述的 db.json 和 server.js 文件。
使用 React Native 和 Expo 构建全栈社交媒体应用程序
本章中将要构建的应用程序将使用本地 API 检索和修改应用程序中可用的数据。此应用程序将显示社交媒体源的数据,允许您添加包含图片的新帖子,并允许您对这些社交媒体帖子做出回应。
带有身份验证的高级路由
我们已经学习了如何使用 React Navigation 向 React Native 应用程序添加路由。我们添加的路由是使用堆栈导航器,它没有显示所有路由的某种菜单或导航栏的方式。在本节中,我们将使用 React Navigation 添加标签导航器以在应用程序底部显示标签栏。稍后,我们还将添加身份验证流程。
添加底部标签
底部标签在 iOS 应用程序中很常见,但在 Android 应用程序中则不太受欢迎。在本章的最后部分,我们将了解 iOS 和 Android 之间在样式上的差异。但首先,我们将专注于向我们的应用程序添加底部标签。
要添加标签导航器,我们需要完成以下操作:
-
React Navigation 有一个用于创建标签导航器的单独库,我们需要从 npm 安装它:
yarn add @react-navigation/bottom-tabs
当@react-navigation/bottom-tabs的安装完成时,请确保使用npm start命令重新启动 Expo。
-
在
App.js文件中,列出此应用程序的所有路由,我们需要导入创建标签的方法:import { StatusBar } from 'expo-status-bar'; import React from 'react'; import { NavigationContainer } from '@react-navigation/native'; import { createStackNavigator } from '@react-navigation/stack'; + import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; // ... -
可以使用
createBottomTabNavigator方法创建标签导航器。这些导航器屏幕必须在App.js文件中的单独组件内创建,其中Posts、PostForm和Profile屏幕将被添加到其中。这些屏幕将后来在底部标签中可用。重要的是要传递不显示标题的选项,因为屏幕标题将由父导航器渲染:// ... + const Tab = createBottomTabNavigator(); + function Home() { + return ( + <Tab.Navigator> + <Stack.Screen + name='Posts' + component={Posts} + options={{ headerShown: false }} + /> + <Stack.Screen + name='Profile' + component={Profile} + options={{ headerShown: false }} + /> + <Stack.Screen + name='PostForm' + component={PostForm} + options={{ headerShown: false }} + /> + </Tab.Navigator> + ); + } export default function App() { // ... -
要在应用程序中渲染导航器,我们需要将其添加到
App组件内的return语句中:export default function App() { return ( <AppContext> <NavigationContainer> <StatusBar style='auto' /> - <Stack.Navigator initialRouteName='Posts'> - <Stack.Screen name='Posts' component={Posts} /> - <Stack.Screen name='Profile' component={Profile} /> - <Stack.Screen name='PostForm' component={PostForm} /> + <Stack.Navigator initialRouteName='Home'> + <Stack.Screen name='Home' component={Home} /> <Stack.Screen name='PostDetail' component={PostDetail} /> <Stack.Screen name='Login' component={Login} /> </Stack.Navigator> </NavigationContainer> </AppContext> ); } -
当你现在使用标签导航器导航到任何屏幕时,你会看到标题栏中的标题始终是
Home组件,它本身渲染不同的屏幕。我们可以通过使用 React Navigation 中的getFocusedRouteNameFromRoute在主页面的options属性中强制标题为活动标签的标题:import { StatusBar } from 'expo-status-bar'; import React from 'react'; - import { NavigationContainer } from '@react-navigation/native'; + import { NavigationContainer, getFocusedRouteNameFromRoute } from '@react-navigation/native'; // ... export default function App() { return ( <AppContext> <NavigationContainer> <StatusBar style='auto' /> <Stack.Navigator> <Stack.Screen name='Home' component={Home} + options={({ route }) => ({ + headerTitle: getFocusedRouteNameFromRoute(route), + })} /> <Stack.Screen name='PostDetail' component={PostDetail} /> <Stack.Screen name='Login' component={Login} /> </Stack.Navigator> </NavigationContainer> </AppContext> ); } -
底部标签也可以在激活时拥有一个图标和自定义颜色。为此,我们可以修改标签导航器的
screenOptions。标签的图标可以从@expo/vector-icons导入,该图标已经包含在 Expo 中:import { StatusBar } from 'expo-status-bar'; + import { FontAwesome } from '@expo/vector-icons'; import React from 'react'; // ... function Home() { return ( <Tab.Navigator + screenOptions={({ route }) => ({ + tabBarActiveTintColor: 'blue', + tabBarInactiveTintColor: 'gray', + tabBarIcon: ({ color, size }) => { + const iconName = + (route.name === 'Posts' && 'feed') || + (route.name === 'PostForm' && 'plus-square') || + (route.name === 'Profile' && 'user'); + return <FontAwesome name={iconName} size={size} color={color} />; }, + })} > // ... </Tab.Navigator> ); } // ... -
最后,我们还可以更改标签的标签,例如,对于显示添加新帖子表单的
PostForm屏幕:// ... function Home() { return ( <Tab.Navigator // ... > <Stack.Screen name='PostForm' component={PostForm} options={{ headerShown: false, + tabBarLabel: 'Add post', }} /> <Stack.Screen name='Profile' component={Profile} /> </Tab.Navigator> ); } // ...
通过这些更改,应用程序现在具有具有堆栈导航器和标签导航器的路由,看起来应该像这样:
图 9.3 – 带有底部标签的应用程序
现在,我们几乎可以到达所有屏幕,只有Login屏幕仍然隐藏。这个屏幕被添加到堆栈导航器中,并且当用户未认证时应显示。在本节的下一部分,我们将添加认证流程来处理这个问题。
认证流程
在前端应用程序中进行身份验证时,大多数情况下使用的是JSON Web Tokens(JWTs),这是一种加密的令牌,可以轻松地与后端共享用户信息。当用户成功认证后,后端会返回 JWT,通常这个令牌会有一个过期日期。对于用户需要认证的每个请求,都应该发送这个令牌,以便后端服务器可以确定用户是否已认证并且允许执行此操作。尽管 JWT 可以用于认证,因为它们是加密的,但不应向其中添加任何私人信息,因为令牌仅应用于认证用户。只有当发送了包含正确 JWT 的文档时,服务器才能发送私人信息。
本章中我们正在构建的移动应用程序仅使用GET请求检索帖子,但本地 API 也支持POST请求。但为了发送POST请求,我们需要进行认证,这意味着我们需要检索一个可以与我们的 API 请求一起发送的令牌。为此,我们可以使用 API 的api/login端点:
-
Login组件可以用于登录,但目前没有显示。要显示此组件,我们需要更改App.js中堆栈导航器的逻辑。我们需要在这个文件中创建一个新的组件,称为Navigator,而不是让App组件返回堆栈导航器:// ... + function Navigator() { + return ( + <NavigationContainer> + <StatusBar style='auto' /> + <Stack.Navigator> + <Stack.Screen name='Login' component={Login} /> + <Stack.Screen + name='Home' + component={Home} + options={({ route }) => ({ + headerTitle: getFocusedRouteNameFromRoute(route), + })} + /> + <Stack.Screen name='PostDetail' component={PostDetail} /> + </Stack.Navigator> + </NavigationContainer> + ); + } export default function App() { // ... -
上述代码块可以从
App中删除,并用这个新的Navigator组件替换:// ... export default function App() { return ( <AppContext> - // ... + <Navigator /> </AppContext> ); } -
我们还需要检查
Navigator组件中令牌的值,因为我们不希望在未提供令牌时包含主页。登录的逻辑已经存在于context/UserContext.js文件中的UserContext中,并且可以从Navigator组件中获取此上下文中的user对象:import { StatusBar } from 'expo-status-bar'; import { FontAwesome } from '@expo/vector-icons'; - import React from 'react'; + import React, { useContext } from 'react'; // ... import AppContext from './context/AppContext'; + import UserContext from './context/UserContext'; const Stack = createStackNavigator(); const Tab = createBottomTabNavigator(); function Home() { // ... -
现在,我们可以从上下文中获取
user对象,并添加逻辑以在不存在令牌时仅返回Login屏幕:// ... function Navigator() { + const { user } = useContext(UserContext); return ( <NavigationContainer> <StatusBar style='auto' /> - <Stack.Navigator> + <Stack.Navigator initialRouteName= {user.token.length ? 'Home' : 'Login'}> <Stack.Screen name='Home' // ... /> <Stack.Screen name='PostDetail' component={PostDetail} /> <Stack.Screen name='Login' component={Login} /> )} </Stack.Navigator> </NavigationContainer> ); } export default function App() { // ... -
如果你现在刷新应用程序,你可以看到正在显示的
Login组件。你可以使用用户名和密码组合登录,这两个值都是test。登录后,我们希望导航到主页,为此我们需要在screens/Login.js中做出更改:+ import { useNavigation } from '@react-navigation/core'; + import React, { useContext, useState } from 'react'; - import React, { useContext, useEffect, useState } from 'react'; // ... export default function Login() { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); - const { error, loginUser } = useContext(UserContext); + const { user, error, loginUser } = useContext(UserContext); + const navigation = useNavigation(); + useEffect(() => { + if (user.token) { + navigation.navigate('Home'); + } + }, [user.token]); return ( // ...
当上下文中user对象的token值发生变化时,用户现在将被导航到主页。这可以通过使用用户名和密码组合登录来显示,这两个值都是test。如果你输入了错误值,你会看到错误信息,就像这里所显示的:
图 9.4 – 处理认证
然而,由于在重新加载应用程序时上下文会被恢复,令牌并没有被持久化。对于 Web 应用程序,我们可以使用localStorage或sessionStorage。但对于移动应用程序,你需要使用 React Native 的AsyncStorage库来在 iOS 和 Android 上实现持久化存储。在 iOS 上,它将使用原生代码块为你提供AsyncStorage提供的全局持久化存储,而在运行 Android 的设备上,将使用基于 RocksDB 或 SQLite 的存储。
注意
对于更复杂的用法,建议在AsyncStorage之上使用抽象层,因为默认情况下不支持加密。此外,如果你想要使用AsyncStorage存储大量信息,键值系统可能会给你带来性能问题。iOS 和 Android 都会对每个应用程序可以使用的存储量设置限制。
要添加用户令牌的持久性,我们需要从 Expo 安装正确的库并对上下文进行修改:
-
我们可以通过运行以下命令从 Expo 安装
AsyncStorage:expo install @react-native-async-storage/async-storage -
为了持久化,可以在
context/UserContext.js文件中的UserContext中导入AsyncStorage令牌:import React, { createContext, useReducer } from 'react'; + import AsyncStorage from '@react-native-community/async-storage'; import Constants from 'expo-constants'; // ... -
在同一文件中,在将其添加到上下文后,可以使用
AsyncStorage来存储令牌:// ... export const UserContextProvider = ({ children }) => { const [state, dispatch] = useReducer(reducer, initialState); async function loginUser(username, password) { try { // ... if (result) { dispatch({ type: 'SET_USER_TOKEN', payload: result.token }); + AsyncStorage.setItem('token', result.token); } } catch (e) { dispatch({ type: 'SET_USER_ERROR', payload: e.message }); } } // ... -
现在令牌从本地 API 检索后已持久化,也可以从
AsyncStorage中检索。因此,我们需要创建一个新的函数来检索令牌并将其添加到上下文中:// ... + async function getToken() { + try { + const token = await AsyncStorage.getItem('token'); + if (token !== null) { + dispatch({ type: 'SET_USER_TOKEN', payload: token }); + } + } catch (e) {} + } return ( - <UserContext.Provider value={{ ...state, loginUser, logoutUser }}> + <UserContext.Provider value={{ ...state, loginUser, logoutUser, getToken }}> {children} </UserContext.Provider> ); }; export default UserContext; -
最后,当应用程序首次渲染时,需要从
App.js调用此函数。这样,当应用程序启动或刷新时,你将获得令牌,并且认证会被持久化:import { StatusBar } from 'expo-status-bar'; import { FontAwesome } from '@expo/vector-icons'; - import React, { useContext } from 'react'; + import React, { useContext, useEffect } from 'react'; // ... function Navigator() { - const { user } = useContext(UserContext); + const { user, getToken } = useContext(UserContext); + useEffect(() => { + getToken(); + }, []); return ( // ... -
登录一次后,令牌现在已持久化,当应用程序加载时将跳过
Login屏幕,并且AsyncStorage中存在令牌。然而,由于令牌已持久化,我们还需要一种注销并删除令牌的方法。在context/UserContext.js文件中,必须修改logoutUser函数:// ... async function logoutUser() { + try { + await AsyncStorage.removeItem('token'); dispatch({ type: 'REMOVE_USER_TOKEN' }); + } catch (e) { } } async function getToken() { // ...
当你现在转到Profile屏幕并点击AsyncStorage和应用程序状态时,我们需要将用户导航回Login屏幕。在不同嵌套导航器之间导航的演示将在本节的下一部分进行。
注意
在使用 iOS 或 Android 手机时,要重新加载 Expo Go 中的应用程序,你可以摇晃设备。通过摇晃设备,会出现一个菜单,其中包含重新加载应用程序的选项。在这个菜单中,你还必须选择启用 快速刷新,以便在修改代码时自动刷新应用程序。
在嵌套路由之间导航
在 React Navigation 中,我们可以嵌套不同的导航器,例如在应用程序启动时渲染的堆栈导航器,显示 Login 屏幕或标签导航器。从嵌套导航器中,无法直接导航到父导航器,因为无法访问父导航器的 navigation 对象。但幸运的是,我们可以使用一个 ref 来创建对“最高”导航器的引用。从这个引用,我们可以访问 navigation 对象,否则我们会使用 useNavigation 钩子来访问。为了在我们的应用程序中实现这一点,我们需要更改以下内容:
-
创建一个名为
routing.js的新文件,并包含以下内容:import React, { createRef } from 'react'; export const navigationRef = createRef(); -
这个
navigationRef可以在App.js中导入,并将其附加到App组件中的NavigationContainer:// ... import AppContext from './context/AppContext'; import UserContext from './context/UserContext'; + import { navigationRef } from './routing'; // ... function Navigator() { const { user, getToken } = useContext(UserContext); // ... return ( - <NavigationContainer> + <NavigationContainer ref={navigationRef}> // ... -
包含
Login屏幕的堆栈导航器的navigation对象现在可以通过screens/Profile.js中的Profile屏幕的此ref访问。使用reset方法,我们可以重置整个navigation对象并导航到Login屏幕:// ... + import { navigationRef } from '../routing'; export default function Profile() { const { logoutUser } = useContext(UserContext); return ( <View style={styles.container}> <Button onPress={() => { logoutUser(); + navigationRef.current.reset({ + index: 0, + routes: [{ name: 'Login' }], + }); }} label='Logout' /> </View> ); } // ...
用户认证处理完毕后,我们可以在下一节继续添加创建带有图片的新帖子的功能。
使用 React Native 和 Expo 的相机
除了显示已添加到本地 API 的帖子外,您还可以使用 POST 请求添加帖子,并发送文本和图片作为变量。将图片上传到您的 React Native 应用程序可以通过使用相机拍照或从相册中选择图片来实现。对于这两种用例,React Native 和 Expo 都提供了 API,或者可以从 npm 安装大量可安装的包。对于这个项目,您将使用来自 Expo 的 ImagePicker API,它将这些功能合并到一个组件中。
要将创建新帖子功能添加到您的社交媒体应用程序中,需要对创建添加帖子的新屏幕进行以下更改:
-
我们需要从 Expo 安装一个库,以便我们可以在任何设备上访问相册:
expo install expo-image-picker -
要使用相册,我们需要使用在
screens/PostForm.js文件中导入的ImagePicker库请求CAMERA_ROLL权限:import React, { useContext, useState } from 'react'; import { StyleSheet, TouchableOpacity, View, Text, KeyboardAvoidingView, Platform, Alert, Image } from 'react-native'; + import * as ImagePicker from 'expo-image-picker'; // ... export default function PostForm() { // ... + async function uploadImage() { + const { status } = await ImagePicker .requestMediaLibraryPermissionsAsync(); + if (status !== 'granted') { + Alert.alert('Sorry', 'We need camera roll permissions to make this work!'); + } + } return ( // ... -
然后,需要将这个
uploadImage函数添加到同一文件中的TouchableOpacity组件:// ... return ( <KeyboardAvoidingView behavior={Platform.OS == 'ios' ? 'padding' : 'height'} style={styles.container} > <View style={styles.form}> <TouchableOpacity + onPress={() => uploadImage()} style={styles.imageButton} > <Text style={styles.imageButtonText}>+ </Text> </TouchableOpacity> // ... -
当您现在按下此屏幕上添加帖子的按钮时,将显示一个弹出窗口,要求给予 Expo Go 访问相册的权限。此外,请注意,在此页面上,我们不是使用
View组件来包装屏幕,而是使用KeyboardAvoidingView组件。这确保了当您在输入时,此屏幕上的组件不会被键盘隐藏。注意
您不能再次请求用户权限;相反,您需要手动授予相册权限。要再次设置此权限,您应该在 iOS 的设置屏幕上选择 Expo 应用程序。在下一屏幕上,您能够添加访问相册的权限。
-
当用户已授予访问相册的权限时,您可以使用来自 Expo 的
ImagePickerAPI 打开相册。这同样是一个异步函数,它接受一些配置字段,例如宽高比。如果用户已选择了一张图片,ImagePickerAPI 将返回一个包含字段 URI 的对象,这是用户设备上图片的 URL:// ... async function uploadImage() { const { status } = await ImagePicker .requestMediaLibraryPermissionsAsync(); if (status !== 'granted') { Alert.alert( 'Sorry', 'We need camera roll permissions to make this work!', ); + } else { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.All, + allowsEditing: true, + aspect: [4, 3], + quality: 1, + }); + if (!result.cancelled) { + setImageUrl(result.uri); + } } } return ( // ... -
由于图像的 URL 现在存储在本地状态中的
imageUrl常量中,您可以在Image组件中显示此 URL。此Image组件将imageUrl作为源值,并已设置为使用 100% 的width和height:// ... return ( <KeyboardAvoidingView behavior={Platform.OS == 'ios' ? 'padding' : 'height'} style={styles.container} > <View style={styles.form}> <TouchableOpacity onPress={() => uploadImage()} style={styles.imageButton}> + {imageUrl.length ? ( + <Image + source={{ uri: imageUrl }} + style={{ width: '100%', height: '100%' }} + /> + ) : ( <Text style={styles.imageButtonText}>+</Text> + )} </TouchableOpacity> // ...
通过这些更改,AddPost 屏幕应该看起来像以下截图,这些截图是从运行 iOS 的设备上拍摄的。如果您使用的是 Android Studio 模拟器或运行 Android 的设备,此屏幕的外观可能会有细微差异:
![Figure 9.5 – 使用相册
![img/Figure_9.05_B17390.jpg]
图 9.5 – 使用相册
这些更改将使您能够从相册中选择照片,但您的用户也应该能够通过使用他们的相机上传全新的照片。使用来自 Expo 的 ImagePicker API,您可以处理这两种情况,因为该组件还有一个 launchCameraAsync 方法。这个异步函数将启动相机,并以相同的方式返回相机相册中的图片的 URL。
要添加直接使用用户设备上的相机上传图片的功能,您可以进行以下更改:
-
当用户点击图像占位符时,默认情况下将打开图片库。但您也希望给用户选择使用相机的选项。因此,必须在使用相机或相册上传图片之间做出选择,这是一个实现
ActionSheet组件的完美用例。React Native 和 Expo 都有一个ActionSheet组件;建议使用来自 Expo 的组件,因为它将在 iOS 上使用本地的UIActionSheet组件,在 Android 上使用 JavaScript 实现:yarn add @expo/react-native-action-sheet -
在此之后,我们需要在
App.js文件中导入ActionSheetProvider从@expo/react-native-action-sheet:import { StatusBar } from 'expo-status-bar'; import { FontAwesome } from '@expo/vector-icons'; import React, { useContext, useEffect } from 'react'; import { NavigationContainer, getFocusedRouteNameFromRoute } from '@react-navigation/native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; + import { ActionSheetProvider } from '@expo/react-native-action-sheet'; // ... -
我们将包含
PostForm屏幕的导航器包裹在这个相同的文件中,这样我们就可以在该屏幕组件中使用 Hook 创建操作表:function Home() { return ( + <ActionSheetProvider> // ... + </ActionSheetProvider> ); } function Navigator() { // ... -
在
screens/PostForm.js文件中,我们现在可以导入 Hook 来从@expo/react-native-action-sheet创建操作表:// ... import * as ImagePicker from 'expo-image-picker'; + import { useActionSheet } from '@expo/react-native-action-sheet'; import { useNavigation } from '@react-navigation/core'; import Button from '../components/Button'; import FormInput from '../components/FormInput'; import PostsContext from '../context/PostsContext'; export default function PostForm() { // ... -
要添加操作表,必须添加一个打开此
ActionSheet的函数,并使用showActionSheetWithOptions属性和选项来构建ActionSheet。选项是相机、相册和取消,根据按下的按钮的索引,应调用不同的函数:// ... export default function PostForm() { // ... const { addPost } = useContext(PostsContext); const navigation = useNavigation(); + const { showActionSheetWithOptions } = useActionSheet(); // ... + function openActionSheet() { + const options = ['Camera roll', 'Camera', 'Cancel']; + const cancelButtonIndex = 2; + showActionSheetWithOptions( + { options, cancelButtonIndex }, + (buttonIndex) => { + if (buttonIndex === 0) { + uploadImage() + } + }, + ); + } return ( // ... -
当
buttonIndex为 0 时,将调用请求访问相册并从中选择图片的函数,但我们还需要一个请求相机权限并使用相机的函数:// ... + async function takePicture() { + const { status } = await ImagePicker.requestCameraPermissionsAsync(); + if (status !== 'granted') { + Alert.alert('Sorry', 'We need camera permissions to make this work!'); + } else { + const result = await ImagePicker. launchCameraAsync ({ + mediaTypes: ImagePicker.MediaTypeOptions.All, + aspect: [4, 3], + quality: 1, + }); + if (!result.cancelled) { + setImageUrl(result.uri); + } + } + } function openActionSheet() { // ... -
最后,必须将打开操作表的
openActionSheet函数附加到TouchableOpacity组件:// ... return ( <KeyboardAvoidingView behavior={Platform.OS == 'ios' ? 'padding' : 'height'} style={styles.container} > <View style={styles.form}> <TouchableOpacity - onPress={() => uploadImage()} + onPress={() => openActionSheet()} style={styles.imageButton} > // ...
按压图像占位符现在将打开操作表以选择您是想使用相册还是相机来选择图像:
图 9.6 – iOS 上的操作表
您的帖子及图片现在将显示在帖子屏幕的顶部,这意味着您已成功添加了帖子。在本章的最后部分,我们将探讨该应用在 iOS 和 Android 之间在样式上的差异。
iOS 和 Android 的样式差异
在设计您的应用时,您可能希望为 iOS 和 Android 设置不同的样式规则,例如,以更好地匹配 Android 操作系统的样式。有多种方法可以将不同的样式规则应用于不同的平台;其中之一是通过使用Platform模块,该模块可以从 React Native 导入。
此模块已经在本应用的某些部分中使用,但让我们通过在导航器的标签中根据设备的操作系统添加不同的图标来更详细地了解其工作原理:
-
在
App.js中,我们已经从 Expo 导入了FontAwesome图标,但对于 Android,我们希望导入MaterialIcons以便它们可以显示。此外,我们还需要从 React Native 导入Platform:import { StatusBar } from 'expo-status-bar'; import { FontAwesome, + MaterialIcons, } from '@expo/vector-icons'; import React, { useContext, useEffect } from 'react'; + import { Platform } from 'react-native'; // ... -
使用
Platform模块,您可以通过检查Platform.OS的值是否为ios或android来检查您的移动设备是否正在运行 iOS 或 Android。该模块必须在标签导航器中使用,这样我们就可以在这两个平台之间做出区分:// ... function Home() { return ( <ActionSheetProvider> <Tab.Navigator // ... screenOptions={({ route }) => ({ tabBarIcon: ({ color, size }) => { // ... - return <FontAwesome name={iconName} size={size} color={color} />; + return Platform.OS === 'ios' ? ( + <FontAwesome name={iconName} size={size} color={color} /> + ) : ( + <MaterialIcons name={iconName} size={size} color={color} /> + ); }, })} > // ... -
这将用
MaterialIcons替换 Android 上的FontAwesome图标。此图标库为图标使用不同的名称,因此我们还需要进行以下更改:// ... function Home() { return ( <ActionSheetProvider> <Tab.Navigator // ... screenOptions={({ route }) => ({ tabBarIcon: ({ color, size }) => { const iconName = - (route.name === 'Posts' && 'feed') || - (route.name === 'PostForm' && 'plus-square') || - (route.name === 'Profile' && 'user'); + (route.name === 'Posts' && + (Platform.OS === 'ios' ? 'feed' : 'rss-feed')) || + (route.name === 'PostForm' && + (Platform.OS === 'ios' ? 'plus-square' : 'add-box')) || + (route.name === 'Profile' && (Platform.OS === 'ios' ? 'user' : 'person')); return Platform.OS === 'ios' ? ( // ...
当您在 Android 移动设备上运行应用程序时,导航标签将显示基于 Material Design 的图标。如果您使用的是苹果设备,它将显示不同的图标;您可以将 Platform.OS === 'ios' 条件更改为 Platform.OS === 'android',以将 Material Design 图标添加到 iOS。如果您还没有看到任何变化,请尝试在您的设备上重新加载应用程序。
-
我们还可以直接在
StyleSheet中使用Platform模块,例如,更改我们应用程序中Button组件的颜色。默认情况下,我们的Button组件具有蓝色背景,但让我们将其在 Android 上更改为紫色。在components/Button.js中,我们需要导入Platform模块:import React from 'react'; import { StyleSheet, TouchableOpacity, View, Text, + Platform, } from 'react-native'; export default function Button({ onPress, label }) { // ... -
我们在创建
StyleSheet的过程中使用select方法:// ... const styles = StyleSheet.create({ button: { width: '100%', padding: 20, borderRadius: 5, - backgroundColor: 'blue', + ...Platform.select({ + ios: { + backgroundColor: 'blue', + }, + android: { + backgroundColor: 'purple', + }, }), }, // ...
另一个可以在 iOS 和 Android 之间以不同方式样式的组件是 PostItem 组件。如前所述,有多种方法可以做到这一点;除了使用 Platform 模块外,您还可以使用平台特定的文件扩展名。任何具有 *.ios.js 或 *.android.js 扩展名的文件都只会在扩展名指定的平台上渲染。您不仅可以应用不同的样式规则,还可以在不同的平台上进行功能上的更改:
-
将当前的
components/PostItem.js文件重命名为components/PostItem.android.js,并创建一个名为components/PostItem.ios.js的新文件,其内容如下:import React from 'react'; import { StyleSheet, Text, Dimensions, Image, View } from 'react-native'; const PostItem = ({ data }) => ( <View style={styles.container}> <View style={styles.details}> <Text>{data.description}</Text> </View> <Image source={{ uri: data.imageUrl }} style={styles.thumbnail} /> </View> ); -
这将改变 iOS 上帖子标题和图片的顺序,显示标题在图片上方。此外,我们还需要在文件末尾添加以下样式:
// ... const styles = StyleSheet.create({ container: { display: 'flex', alignItems: 'center', justifyContent: 'flex-start', backgroundColor: 'white', borderWidth: 1, borderColor: '#ccc', marginBottom: '2%', }, thumbnail: { width: Dimensions.get('window').width * 0.98, height: Dimensions.get('window').width * 0.98, margin: Dimensions.get('window').width * 0.01, }, details: { width: '95%', margin: '2%', }, }); export default PostItem; -
在 iOS 上,我们希望显示一个阴影而不是围绕此组件的边框。为了添加此阴影,我们需要更改组件的样式:
// ... const styles = StyleSheet.create({ container: { display: 'flex', alignItems: 'center', justifyContent: 'flex-start', backgroundColor: 'white', shadowRadius rule, while Android uses the elevation rule. -
最后,我们还需要更改图像的尺寸,因为我们已经向
container样式添加了边距:// ... const styles = StyleSheet.create({ // ... thumbnail: { - width: Dimensions.get('window').width * 0.98, - height: Dimensions.get('window').width * 0.98, + width: Dimensions.get('window').width * 0.94, + height: Dimensions.get('window').width * 0.94, margin: Dimensions.get('window').width * 0.01, },
这将在 iOS 和 Android 上产生以下结果,其中边框已被阴影取代:
图 9.7 – iOS 和 Android 上的样式差异
根据您的手机类型,您也可以将此文件从 components/PostItem.ios.js 重命名为 components/PostItem.android.js,以在 Android 上看到相同的更改。
就这样。通过这些最终更改,您已经创建了一个将在 Android 和 iOS 设备上运行的 React Native 应用程序,并且这两个平台之间存在样式差异。
摘要
在本章中,您已经使用 React Native 和 Expo 创建了一个移动社交媒体应用程序,该应用程序使用本地 API 发送和接收数据,同时也用于身份验证。为了处理身份验证,结合了多种类型的导航器。在获得使用权限后,我们学习了如何使用移动设备的相机和相册。同时,还解释了 iOS 和 Android 之间在样式上的差异。
在完成这个社交媒体应用后,你已经完成了本书的最后一章 React Native 章节,现在可以开始阅读最后一章了。在最后一章中,你将探索 React 的另一个用例,即 VR。通过将 React 与 Three.js 结合,你可以通过编写 React 组件来创建 360 度的 2D 和 3D 体验。