React-和-ReactNative-第五版-三-

56 阅读1小时+

React 和 ReactNative 第五版(三)

原文:zh.annas-archive.org/md5/47e218557a614bce0d999181bbb2b76b

译者:飞龙

协议:CC BY-NC-SA 4.0

第十三章:服务器端渲染

正如我们在第一章为什么选择 React中讨论的那样,React 库在将我们的组件转换为各种目标格式方面非常灵活。你可能已经猜到了,其中一个目标格式是标准的 HTML 标记,以字符串形式呈现并在服务器上生成。在本章中,我们将深入探讨 React 中服务器端渲染SSR)的工作原理以及它为用户和开发者提供的优势。你将了解为什么这种方法对你的应用程序来说很有价值,以及它是如何增强整体用户体验和性能的。

本章涵盖了以下主题:

  • 在服务器上工作

  • 使用 Next.js

  • React 服务器组件

技术要求

你可以在 GitHub 上找到本章的代码文件,链接为github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter13

在服务器上工作

网络技术已经走了很长的路,或者更准确地说,已经回到了起点。一切始于由服务器准备的静态网页。服务器是所有网站和应用逻辑的基础,因为它们完全负责其功能。然后,我们试图摆脱SSR,转向在浏览器中渲染页面,这导致了网页作为完整应用程序的开发取得了重大飞跃,现在可以与桌面应用程序相媲美。因此,浏览器成为了应用逻辑的核心,而服务器只是为应用程序提供数据。

目前,开发周期已经让我们回到了 SSR 和服务器组件,但现在我们有了服务器和客户端的统一逻辑和代码。为什么会出现这种情况,以及我们在技术演变过程中获得了哪些结论和经验,我们将在本节中尝试理解,同时我们还将了解我们的应用程序在服务器上执行的工作类型。

服务器端渲染

在传统的单页应用程序SPA)方法中,我们完全依赖本地浏览器渲染。我们编写所有代码、样式和标记,专门针对浏览器,在应用程序构建过程中,我们得到静态的 HTML、CSS 和 JavaScript 文件,然后这些文件被加载到浏览器中。

在大多数情况下,初始的 HTML 文件是空的,没有任何内容。在这个文件中唯一重要的事情是连接的 JavaScript 文件,它将渲染我们所需的一切。

下面是一个示意图,说明了单页应用程序(SPA)应用是如何加载和渲染的:

图 13.1:单页应用程序(SPA)应用

这种方法引入了交互性,使得应用程序感觉和功能就像真正的桌面应用程序一样。不再需要每次更新内容、接收通知、新电子邮件或消息时都重新加载页面,因为整个应用程序逻辑直接在浏览器中。随着时间的推移,浏览器应用程序几乎完全取代了桌面应用程序。现在,我们可以在单个浏览器中写电子邮件、处理文档、观看电影以及做更多的事情。许多公司,而不是开发桌面应用程序,开始将他们的项目作为 Web 应用程序来创建。浏览器能够在任何架构和操作系统上运行的能力显著降低了开发成本。

同时,服务器也经历了变化,远离了页面模板、缓存等。后端开发者不再需要关注页面布局,可以更多地投入到更复杂的逻辑和架构中。

然而,单页应用程序(SPA)确实存在一些缺点,包括由于需要下载和处理脚本而导致的长时间初始加载时间。在这个过程中,用户会看到一个空白屏幕或加载指示器。此外,空白的初始 HTML 文件不适合搜索引擎优化,因为搜索引擎将其视为一个空白页面。

在创建在线商店等场景的背景下,普通的 React SPA 可能不适合,因为用户和搜索引擎需要立即看到页面内容。在 SPAs 出现之前,这类任务是由仅在服务器端工作的工具解决的,这些工具始终准备内容。在 React 中,解决这个问题更复杂,因为我们知道 React 在浏览器端工作。

解决方案的第一步显然是使用 React 在服务器上渲染页面内容。这不会是问题。自从其发布以来,React 就提供了用于此目的的renderToString函数,该函数可以在Node.js 服务器环境中调用。此函数返回一个 HTML 字符串,当发送到浏览器时,允许内容在用户的屏幕上渲染。

让我们看看使用renderToString函数的 SSR 会如何工作:

图片

图 13.2:使用 renderToString 进行服务器渲染

在这个例子中,当在浏览器中请求页面时,服务器通过调用renderToString函数并将它传递给 React 组件树,输出 HTML。通过将这个 HTML 字符串作为对浏览器请求的响应发送,浏览器渲染结果。

然而,在这样的例子中,服务器上生成的并在浏览器中渲染的 HTML 缺乏交互性和客户端应用程序的功能。对于像按钮、导航以及我们在单页应用程序(SPAs)中习惯的所有功能,都需要 JavaScript。因此,在实现服务器渲染的交互式网站或应用程序的下一步中,不仅要传输 HTML,还要传输 JavaScript,这将提供我们需要的所有交互性。

为了解决这个问题,引入了同构 JavaScript的方法。以这种风格编写的代码可以先在服务器上执行,然后再在客户端执行。这允许你在服务器上准备初始渲染,并将准备好的 HTML 以及 JavaScript 包发送到客户端,然后允许浏览器提供交互性。这种方法加快了应用的初始加载速度,同时保持其功能,并允许搜索引擎在搜索结果中索引页面。

当用户打开一个页面时,他们立即看到服务器上执行的渲染结果,甚至在 JavaScript 加载之前。这种快速的初始响应显著提高了用户体验。页面和 JS 包加载后,浏览器对页面进行激活至关重要,正如我们从renderToString示例中所知,我们所有的元素都缺乏交互性。为此,脚本需要将所有必要的事件监听器附加到元素上。这个过程被称为激活,与从头开始的全页渲染相比,这是一个更轻更快的过程。

交互性的另一个重要特性是能够瞬间或平滑地导航到应用中的下一个页面,而无需重新加载浏览器页面。通过同构 JavaScript,这成为了可能,因为只需要加载下一个页面的 JavaScript 代码,然后应用就可以在本地渲染下一个页面。

图 13.3:SSR

上图以示意图的形式展示了 SSR 方法,其中应用是完全交互式的。最初,当请求一个页面时,服务器渲染内容并返回带有附加 JavaScript 包的 HTML。然后,浏览器加载 JS 文件并使页面上先前显示的所有内容生效。这种方法就是现在所知的 SSR。它已经在 React 开发者中得到了广泛应用,并在现代网络技术中找到了其位置。SSR 结合了页面内容的快速加载和服务器渲染的高性能,以及客户端应用的灵活性和交互性。

静态站点和增量静态生成

虽然 SSR 代表了一个重大的改进,但它并不是万能的解决方案,有其缺点,包括需要为每个请求从头生成一个页面。例如,没有动态内容的页面每次都必须在服务器上生成,这可能会使用户的显示延迟。此外,即使是简单的应用或网站,SSR 也需要一个 Node.js 服务器进行渲染,与 SPAs 不同,在 SPAs 中,只需要使用内容分发网络CDN)将应用文件放置得更靠近用户,从而加快加载速度。

解决这些问题的方案在于静态站点生成SSG)方法。SSG 的逻辑是在项目构建过程中在服务器上渲染所有静态页面。因此,我们得到许多准备就绪的 HTML 页面,可以在请求时立即交付。与 SSR 类似,在 SSG 中,JavaScript 包在页面加载后进行激活,使其具有交互性。最终,我们获得与单页应用(SPAs)相同但不是空 HTML 文件的经验:而是充满内容以便快速渲染。SSG 项目可以托管在快速 Web 服务器或 CDNs 上,这也允许进行额外的缓存,并加快此类应用程序的加载时间。

SSG 成为网站、博客和简单在线商店的理想解决方案,确保快速页面加载时间,不阻塞请求,支持 SEO,并且与 SPAs 具有相同的交互性。此外,现在可以将 SSR 用于动态数据和 SSG 用于静态页面结合起来。这种混合方法为实施更复杂的项目开辟了新的可能性,结合了两种方法的优势。它允许开发者通过选择最佳的渲染方法来优化性能和用户体验,具体取决于网站或应用的每一页的具体要求。

开发者和公司面临的一个问题是更新静态生成的页面。例如,传统上,添加新的博客文章或更新在线商店的库存需要完全重建项目,这可能既耗时又麻烦,尤其是在大型项目中。想象一下,一个有 1,000 篇文章的博客因为添加了一篇新文章而不得不完全重建和重新渲染。

这个问题通过一种称为增量静态生成ISR)的方法得到解决。ISR 结合了 SSG 和 SSR 的原则以及缓存功能。为了理解这种方法,想象我们在构建阶段生成的所有 HTML 和 JS 文件只是一个缓存,代表项目构建的当前结果。与任何缓存一样,我们现在需要引入其重新验证的逻辑。只要我们的缓存有效,所有页面请求都像以前一样使用 SSG 方法工作。但是,当重新验证时间到期时,下一个页面请求将启动在 SSR 模式下在服务器上的重新渲染。生成的输出被发送到客户端,并同时用新的 HTML 文件替换旧的 HTML 文件,即更新缓存。然后应用程序继续在 SSG 模式下运行。

多亏了增量静态生成ISR),现在可以实施包含数百万页面的大规模项目,这些页面不需要为小更新而不断重建。还可能完全跳过构建阶段的页面生成,因为所需的页面将在请求时进行渲染和保存。对于大型项目,这提供了项目构建速度的显著提升。

目前,结合 ISR 的 SSG 和传统的 SSR 是实施简单网站、博客以及复杂应用程序中最受欢迎的方法之一。然而,传统的 SPA 仍然是一个非常受欢迎的解决方案。但如果我们知道如何创建和组装 SPA,那么我们刚才讨论的所有其他内容又该如何呢?针对这个问题,重要的是要注意,你不需要手动开发所有这些方法。有几个基于 React 的框架提供了上述所有功能:

  • Next.js:这个框架以其灵活性和强大的功能而闻名。Next.js 最初是 SSR,但现在支持 SSR 和 SSG,包括 ISR 支持。最近,Next.js 一直在深入研究一个新的概念,即使用服务器组件实现应用程序,我们将在本章末尾讨论这一点。

  • Gatsby:Gatsby 的主要区别在于它对使用来自各种来源(如CMSMarkdown)的数据生成静态站点的强烈关注。尽管与 Next.js 的差异没有以前那么大,但它仍然是一个相当受欢迎的解决方案。

  • Remix:这是一个相对较新的框架,专注于与 Web 标准的更紧密集成和提升用户体验。Remix 提供了独特的数据处理和路由方法,我们可以按页面部分而不是按页面工作,通过仅更改和缓存需要动态内容的页面部分来实现嵌套导航。

所有这些框架共同提供了我们讨论过的方法的相似体验和实现。接下来,我们将探讨如何使用 Next.js 实现 SSR 和静态生成。

使用 Next.js

在熟悉了 SSR 的理论之后,让我们看看如何使用Next.js框架在实践上实现所有这些。

Next.js 是一个流行的基于 React 的框架,专门设计用来简化 SSR 和静态站点生成的过程。它提供了创建高性能 Web 应用程序的强大和灵活的功能。

Next.js 的功能:

  • 一个易于使用的 API,自动实现 SSR 和静态生成:你只需要使用提供的方法和函数编写代码,框架将自动确定哪些页面应该在服务器端渲染,哪些可以在项目构建过程中渲染。

  • 基于文件的路由:Next.js 使用基于项目文件夹和文件结构的简单直观的路由系统。这大大简化了应用程序中路由的创建和管理。

  • 通过 API 路由实现创建全面的全栈应用程序的能力,这些 API 路由允许你实现服务器端 REST API 端点。

  • 图像、字体和脚本的优化,提高项目的性能。

框架的另一个重要特性是与 React Core 团队紧密合作以实现新的 React 特性。因此,Next.js 目前支持两种应用程序实现类型,称为Pages RouterApp Router。前者实现了我们之前讨论的主要功能,而后者是一种为与 React Server Components 一起工作而设计的新方法。我们将在本章的后面部分检查这种方法,但现在,让我们从 Pages Router 开始。

要开始使用 Next.js,你只需要执行一个命令,这个命令会为你设置一切:

npx create-next-app@latest 

这个 CLI 命令会问你几个问题:

What is your project named? … using-nextjs
✔ Would you like to use TypeScript? … No / YesWould you like to use ESLint? … No / YesWould you like to use Tailwind CSS? … No / YesWould you like to use `src/` directory? … No / YesWould you like to use App Router? (recommended) … No / YesWould you like to customize the default import alias (@/*)? … No / Yes
✔ What import alias would you like configured? … @/* No / Yes 

对于我们当前的示例,你应该对所有问题回答“是”,除了关于使用 App Router 的问题。此外,你可以访问提供的链接中我们将进一步讨论的现成示例:github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter13/using-nextjs

在示例中,我们将创建一个包含多个页面的小型网站,每个页面使用不同的服务器渲染方法。在 Next.js 中,网站的每一页都应该放置在具有与 URL 路径对应的名称的单独文件中。在我们的项目示例中:

  • 网站的主页,可通过根路径domain.com/访问,将位于pages文件夹中的index.tsx文件。为了理解以下示例,主页面文件的路径将是pages/index.tsx

  • /about页面将位于pages/about.tsx文件中。

  • 接下来,我们将在路径pages/posts/index.tsx创建一个/posts页面。

  • 每个单独的帖子页面将位于一个使用路径pages/posts/[post].tsx的文件中。带有方括号名称的文件指示 Next.js 这将是一个动态页面,其中帖子变量作为参数。这意味着像/posts/1/posts/2这样的页面将使用此文件作为页面组件。

  • 这就是文件路由的工作方式。项目的主要目录是pages文件夹,我们可以嵌套文件,这些文件将用于根据文件和文件夹的结构和名称生成网站页面。

pages文件夹中,还有两个服务文件,它们不是实际的页面,但被框架用于准备页面:

  • _document.tsx文件对于准备 HTML 标记是必要的。在这个文件中,我们可以访问<html><body>标签。这个文件始终在服务器上渲染。

  • _app.tsx文件用于初始化页面。你可以使用这个组件来连接脚本或用于在路由之间重复使用的页面的根布局。

让我们在App组件中给我们的网站添加一个标题。下面是_app.tsx文件的样子:

const inter = Inter({ subsets: ["latin"] });
export default function App({ Component, pageProps }: AppProps) {
  return (
    <div className={inter.className}>
      <header className="p-4 flex items-center gap-4">
        <Link href="/">Home</Link>
        <Link href="/posts">Posts</Link>
        <Link href="/about">About</Link>
      </header>
      <div className="p-4">
        <Component {...pageProps} />
      </div>
    </div>
  );
} 

App组件返回的标记将被用于我们项目的每个页面,这意味着我们将在任何页面上看到这个标题。此外,我们还可以使用组件控制,其中将放置项目的其余动态部分。

现在,让我们看看我们项目的首页将是什么样子:

图 13.4:主页

图 13.4:主页

在这个页面上,我们可以看到带有链接和标题的网站标题,这些内容是从pages/index.tsx文件中取出的:

export default function Home() {
  return (
    <main>
      <h1>Home Page</h1>
    </main>
  );
} 

pages/index.tsx文件只导出一个包含标题的组件。重要的是要注意,这个页面没有其他函数或参数,将在项目构建过程中自动渲染。这意味着当我们访问这个页面时,我们会得到浏览器可以立即渲染的预制的 HTML。

通过访问localhost:3000/,我们可以确认我们收到了准备好的标记。为此,我们只需要打开浏览器开发者工具,检查这个请求返回的内容。

图 13.5:About 组件

图 13.5:Chrome DevTools 中的主页响应

我们可以看到 Next.js 如何从AppHome组件中提取内容,并从它组装 HTML。所有这些都是在服务器端完成的,而不是在浏览器中。

接下来,让我们看看/about页面。在这个页面上,我们将实现 SSR,这意味着页面不是在构建过程中生成 HTML,而是在每次请求时渲染。为此,Next.js 提供了getServerSideProps函数,它在页面请求时运行,并返回组件用于渲染的 props。

对于我们的示例,我从第十一章从服务器获取数据中取了一些逻辑,其中我们从 GitHub 获取了用户数据。让我们看看about.tsx文件将是什么样子:

export const getServerSideProps = (async () => {
  const res = await fetch("https://api.github.com/users/sakhnyuk");
  const user: GitHubUser = await res.json();
  return { props: { user } };
}) satisfies GetServerSideProps<{ user: GitHubUser }>; 

getServerSideProps函数中,我们使用Fetch API请求用户数据。我们接收到的数据存储在user变量中,然后作为props对象返回。

重要的是要理解这个函数是 Node.js 环境的一部分,在那里我们可以使用服务器端 API。这意味着我们可以读取文件、访问数据库等。这为实施复杂的全栈项目提供了显著的能力。

接下来,在同一个about.tsx文件中,我们有About组件:

export default function About({
  user,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
  return (
    <main>
      <Image src={user.avatar_url} alt={user.login} width="100" height="100" />
      <h2>{user.name || user.login}</h2>
      <p>{user.bio}</p>
      <p>Location: {user.location || "Not specified"}</p>
      <p>Company: {user.company || "Not specified"}</p>
      <p>Followers: {user.followers}</p>
      <p>Following: {user.following}</p>
      <p>Public Repos: {user.public_repos}</p>
    </main>
  );
} 

About组件中,我们使用从getServerSideProps函数返回的user变量来创建页面的标记。仅通过这个一个函数,我们就实现了 SSR(服务器端渲染)。

接下来,让我们创建/posts/posts/[post]页面,在这些页面中我们将实现 SSG(静态生成)和 ISR(增量静态化)。为此,Next.js 提供了两个函数:getStaticPropsgetStaticPaths

  • getStaticProps:这个函数与getServerSideProps具有类似的作用,但在项目构建过程中被调用。

  • getStaticPaths:这个函数用于动态页面,其中路径包含参数(如[post].tsx)。这个函数确定在构建过程中应该预生成哪些路径。

让我们看看 Posts 页面组件是如何实现的:

export async function getStaticProps() {
  const posts = ["1", "2", "3"];
  return {
    props: {
      posts,
    },
  };
}
export default function Posts({ posts }: { posts: string[] }) {
  return (
    <main>
      <h1>Posts</h1>
      <ul>
        {posts.map((post) => (
          <li key={post}>
            <Link href={`/posts/${post}`}>Post {post}</Link>
          </li>
        ))}
      </ul>
    </main>
  );
} 

在这个例子中,getStaticProps 函数没有请求任何数据,只是简单地返回三个页面。然而,就像在 getServerSideProps 中一样,您可以使用 getStaticProps 来获取数据或与文件系统交互。然后,Posts 组件接收帖子作为 props 并使用它们来显示帖子链接列表。

下面是 Posts 页面的外观:

图 13.6:帖子页面

当打开任何帖子时,来自 [post].tsx 文件的组件将被加载。以下是它的外观:

export const getStaticPaths = (async () => {
  return {
    paths: [
      {
        params: {
          post: "1",
        },
      },
      {
        params: {
          post: "2",
        },
      },
      {
        params: {
          post: "3",
        },
      },
    ],
    fallback: true,
  };
}) satisfies GetStaticPaths; 

此函数通知构建器在构建过程中只需要渲染三个页面。在此函数中,我们还可以进行网络请求。我们返回的 "fallback" 参数表明,理论上可能存在比我们返回的更多帖子页面。例如,如果我们访问 /posts/4 页面,它将以 SSR 模式渲染并保存为构建结果:

Export const getStaticProps = (async (context) => {
  const content = `This is a dynamic route example. The value of the post parameter is ${context.params?.post}.`;
  return { props: { content }, revalidate: 3600 };
}) satisfies GetStaticProps<{
  content: string;
}>; 

getStaticProps 函数中,我们现在可以从 context 参数中读取页面参数。我们从函数中返回的 revalidate 值启用了 ISR,并告诉服务器在从上次构建后的 3600 秒后,在下一个请求中重建此页面。以下是 Post 页面的外观:

export default function Post({
  content,
}: InferGetStaticPropsType<typeof getStaticProps>) {
  const router = useRouter();
  return (
    <main>
      <h1>Post – {router.query.post}</h1>
      <p>{content}</p>
    </main>
  );
} 

当我们通过链接打开任何帖子时,我们将看到以下内容:

图 13.7:帖子页面

在这个例子中,我们创建了一个网站,其中页面使用不同的服务器端渲染方法,这对于构建大型和复杂的项目非常有用且方便。然而,Next.js 的功能远不止于此。接下来,我们将探讨使用 App Router 构建网站的新方法。

React 服务器组件

React 服务器组件代表了在 Next.js 中处理组件的新范式,它消除了同构 JavaScript。此类组件的代码仅在服务器上运行,并且可以作为结果进行缓存。在这个概念中,您可以直接从组件中读取服务器的文件系统或访问数据库。

在 Next.js 中,React 服务器组件允许您将组件分为两种类型:服务器端客户端。服务器端组件在服务器上处理,并以静态 HTML 的形式发送到客户端,从而减少浏览器的负载。客户端组件仍然具有浏览器 JavaScript 的所有功能,但有一个要求:您需要在文件开头使用 use client 指令。

要在 Next.js 中使用服务器端组件,您需要创建一个新的项目。对于路由,您仍然使用文件,但现在,项目的主要文件夹是 app 文件夹,并且路由名称仅基于文件夹名称。在每一个路由(文件夹)内部,应该有框架指定的文件。以下是一些关键文件:

  • page.tsx: 此文件及其组件将用于显示页面。

  • loading.tsx:这个文件的组件将在page.tsx文件中的组件执行和加载时作为加载状态发送到客户端。

  • layout.tsx:这相当于_app.tsx文件,但在这个情况下,我们可以有多个布局,它们可以在嵌套路由中相互嵌套。

  • route.tsx:这个文件用于实现 API 端点。

现在,让我们使用基于App Router的新架构重构我们的带有帖子的网站。让我们从主页开始。由于我们的网站没有交互元素,我建议添加一个。让我们创建一个最简单的带有计数器的按钮并将其放置在主页上。下面是这个按钮的代码:

"use client";
import React from "react";
export const Counter = () => {
  const [count, setCount] = React.useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}; 

这个组件渲染了一个带有计数器的按钮。通过点击按钮,我们更新计数器。为了让这个组件与 App Router 一起工作,我们需要添加“use client"指令,这告诉 Next.js 在请求时将这个组件的代码包含在包中并发送到浏览器。

现在,让我们把这个按钮添加到主页上,下面是这个按钮的代码示例:

export default function Home() {
  return (
    <main>
      <h1>Home Page</h1>
      <Counter />
    </main>
  );
} 

由于页面很简单,它与我们之前在 Pages Router 中看到的不同之处仅在于新按钮。尽管如此,默认情况下,App Router 将所有组件视为服务器端组件,在这种情况下,页面将在构建过程中渲染并保存为静态页面。

现在,让我们继续到“关于”页面。为了创建这个页面,我们需要创建一个名为about的文件夹,并在其中创建一个名为page.tsx的文件,我们将在这里放置组件。下面是这个文件的代码:

export const dynamic = "force-dynamic";
export default async function About() {
  const res = await fetch("https://api.github.com/users/sakhnyuk");
  const user: GitHubUser = await res.json();
  return (
    <main>
      <Image src={user.avatar_url} alt={user.login} width="100" height="100" />
      <h2>{user.name || user.login}</h2>
      <p>{user.bio}</p>
      <p>Location: {user.location || "Not specified"}</p>
      <p>Company: {user.company || "Not specified"}</p>
      <p>Followers: {user.followers}</p>
      <p>Following: {user.following}</p>
      <p>Public Repos: {user.public_repos}</p>
    </main>
  );
} 

如您所见,与使用 Pages Router 相比,这个页面的代码变得更加简单。About组件已经变为异步的,这允许我们进行网络请求并等待结果。由于在我们的例子中,我们想要使用 SSR 并在每个请求时在服务器上渲染页面,我们需要从文件中导出带有force-dynamic值的“dynamic”变量。这个参数明确告诉 Next.js 我们希望为每个请求生成一个新的页面。否则,Next.js 会在项目构建期间生成页面并将结果保存为静态页面(通过使用 SSG)。

然而,如果 App Router 只是重复之前的功能而不提供任何新功能,那就很奇怪了。如果我们创建一个位于about文件夹中的loading.tsx文件,当打开“关于”页面时,它将立即使用来自loading文件的内容作为后备来提供页面,而不是等待服务器从 GitHub 请求信息并准备页面。一旦page.tsx文件中的组件准备好,服务器就会将其发送到客户端以替换loading组件。这提供了显著的性能优势并改善了用户体验。

现在,让我们继续到“帖子”页面。在它里面创建一个posts文件夹和一个page.tsx文件。下面是更新后的/posts页面的代码示例:

export default async function Posts() {
  const posts = ["1", "2", "3"];
  return (
    <main>
      <h1>Posts</h1>
      <ul>
        {posts.map((post) => (
          <li key={post}>
            <Link href={`/posts/${post}`}>Post {post}</Link>
          </li>
        ))}
      </ul>
    </main>
  );
} 

再次强调,代码已经变得非常简洁。在渲染页面之前,我们需要获取的所有内容都可以直接在组件内部获取和创建。在我们的例子中,我们硬编码了三个将被渲染为链接的页面。

要实现一个“帖子”页面,在posts文件夹内,你需要创建一个名为 [post] 的文件夹,并在其中创建 page.tsx 文件。以下是代码,现在它更加简洁和易读:

export async function generateStaticParams() {
  return [{ post: "1" }, { post: "2" }, { post: "3" }];
} 

我们不是使用 getStaticPaths,而是通过 generateStaticParams 函数向 Next.js 提供有关在项目构建期间生成静态页面的列表信息。然后,我们使用组件内部的 props 来显示页面内容:

export const revalidate = 3600
export default async function Post({ params }: { params: { post: string } }) {
  return (
    <main>
      <h1>Post - {params.post}</h1>
      <p>
        This is a dynamic route example. The value of the post parameter is
        {params.post}.
      </p>
    </main>
  );
} 

内容基本保持不变。要激活 ISR,我们只需要从包含重新验证值的文件中导出 revalidate 变量。

在这个例子中,我们介绍了使用 React Server Components 和 Next.js 的 App Router 构建应用程序的基本方法。本章提供的 Page Router 和 App Router 示例并没有涵盖 Next.js 的所有可能性。为了更深入地了解这个框架,我建议查看其网站上的优秀文档:nextjs.org/docs

摘要

在本章中,我们探讨了在 React 应用程序上下文中使用 SSR。我们讨论了如 SSR、SSG 和 ISR 等方法,学习了每种方法的优缺点。

然后,我们学习了如何在 Next.js 和 Pages Router 的应用中应用这些方法。最后,我们介绍了一种名为 React Server Components 的新技术,以及 Next.js 的更新版架构,称为 App Router。

在下一章中,我们将学习如何测试我们的组件和应用。

第十四章:React 的单元测试

尽管测试是软件开发过程的一个组成部分,但在现实中,开发人员和公司往往对它投入的关注出奇地少,尤其是对自动化测试。在本章中,我们将试图了解为什么关注测试很重要以及它带来的优势。我们还将探讨 ReactJS 中单元测试的基础,包括一般测试理论、工具和方法,以及测试 ReactJS 组件的特定方面。

在本章中,我们将涵盖以下主题:

  • 一般测试

  • 单元测试

  • 测试 ReactJS

技术要求

您可以在 GitHub 上找到本章的代码文件,地址为 github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter14

一般测试

软件测试是一个旨在识别错误和验证产品功能的过程,以确保其质量。测试还允许开发人员和测试人员评估系统在各种条件下的行为,并确保新的更改没有导致回归,即它们没有破坏现有的功能。

测试过程包括一系列执行的动作,旨在检测和识别任何不符合要求或预期的方面。这样的动作的一个例子可能是手动测试,其中开发人员或测试人员手动检查应用。然而,这种方法耗时且几乎不能保证应用在操作上安全且没有关键错误。

为了确保在测试上节省时间的同时提高应用的可靠性,存在自动化测试。它们允许在无需人工干预的情况下验证应用的功能。

自动化测试通常由一系列预定义的测试和一个软件产品组成,通常被称为运行器,它启动这些测试并分析结果以确定每个测试的成功或失败。除此之外,自动化测试还可以用于检查性能、稳定性、安全性、可用性和兼容性,使您能够编写真正稳定、大型和成功的项目。这就是为什么避免测试从来都不是一个好主意;相反,了解它们并尝试在所有可能的项目中使用它们是值得的。

作为开发者,我们显然对自动化测试比对手动测试更感兴趣,因此本章将专注于这一点。但在那之前,让我们简要地看看测试的方法和存在的测试类型。

测试类型和方法

软件测试可以根据各种标准进行分类,包括测试的级别和它追求的目标。

通常,以下类型的测试被区分出来:

  • 单元测试:对程序的单个模块或组件进行正确操作的测试。单元测试通常由开发者编写和执行,以检查特定的函数或方法。这类测试通常编写快速,执行也快,但它们并不测试最终应用程序的临界错误,因为被测试和稳定的组件在相互交互时可能存在问题。一个单元测试的例子是检查单个函数、React 组件或 Hook 的功能。

  • 集成测试:这种测试是在各种模块或系统组件之间检查交互的测试。目标是检测集成组件之间的接口和交互中的缺陷。这类测试通常在服务器端进行,以确保所有系统协同工作,并且业务逻辑符合指定的要求。

    例如,一个集成测试可能是一个检查用户注册是否正常工作的测试,通过向 REST API 端点发出真实调用并检查返回的数据。这种测试对应用程序的实现和代码的依赖性较小,更多地是检查行为和业务逻辑。

  • 端到端(E2E)测试:测试一个完整且集成的软件系统,以确保它符合指定的要求。端到端测试评估整个程序。这种测试是最可靠的,因为它完全抽象了应用程序的实现,并通过直接与应用程序交互来检查最终行为。在测试过程中,例如,在一个网络应用程序中,在一个特殊环境中启动一个真实浏览器,其中脚本执行与应用程序的真实操作,如点击按钮、填写表单和浏览页面。

尽管集成和端到端测试等测试类型在验证应用程序质量方面提供了更大的信心,但它们也伴随着复杂性和测试开发速度、执行速度等缺点,从而增加了成本。因此,被认为是一种良好的实践,在保持平衡的同时,优先考虑单元测试,因为它们更容易维护且运行速度更快。然后,所有主要业务流程和逻辑都通过集成测试进行验证,而端到端测试仅覆盖最关键的业务案例。这种方法可以用金字塔的形式表示:

图片 1

图 14.1:测试金字塔

金字塔完美地描述了我们上面讨论的方法。其底部是单元测试,应该尽可能全面地覆盖应用程序的源代码。它具有最低的开发和维护成本,以及最高的测试执行性能。中间是集成测试,执行速度快,但开发成本较高。在最顶部,我们有端到端测试,执行时间最长,开发成本最高,但它们提供了对正在测试的产品质量的最高信心。

由于集成测试和端到端测试抽象了实现,以及应用程序中使用的编程语言或库,我们不会涉及这些类型的测试。因此,让我们更详细地关注单元测试。

单元测试

我们已经知道,单元测试是验证代码单个“单元”正确性的过程:即,函数方法。单元测试的目标是确保每个单独的单元能够正确执行其任务,这反过来又增加了对整个应用程序可靠性的信心。

export function sum(a: number, b: number): number {
  return a + b;
}
test('adds 1 + 2 to equal 3', () => {
  expect(sum(1, 2)).toBe(3);
}); 

上面的例子代表了添加两个值的最基本和最简单的函数测试。测试代码本身是一个函数,它调用一个特殊的方法,expect,该方法接受一个值,然后有一系列方法允许检查和比较结果。

看到这段代码,可能会产生的第一个问题是,真的有必要为这么简单的三行函数再写三条测试代码吗?为什么要测试这样的函数呢?我会明确地回答:是的。经常会出现这样的情况,一个函数可以被一个比它本身更大的测试覆盖,而这并没有什么问题。让我们来理解一下原因。

单元测试在测试纯函数时最有用和有效,这些函数没有副作用且不依赖于外部状态。相反,当被测试的函数由于外部因素或仅仅是因为函数的设计方式而改变其行为时,单元测试就毫无用处。例如,从服务器请求数据、从localStorage获取数据或依赖于全局变量的函数可能会对相同的输入返回不同的结果。由此我们可以得出结论,在需要通过测试实现代码覆盖率的应用程序开发方法中,你将自动努力编写可测试的代码,这意味着更加模块化、独立、清洁和可扩展的代码。这在大型项目中尤其明显。如果从一开始就编写了测试,这样的项目可以继续增长而无需进行大规模的重构或从头开始重写功能。此外,在带有测试的项目中,新来者更容易理解,因为测试可以作为模块的额外文档,通过阅读测试可以了解模块负责的内容以及它具有的行为。

对于编写单元测试来说,存在一系列的概念和方法。其中最主要和最受欢迎的是在代码开发之后的传统测试覆盖率。这种方法的优点是主要功能开发的速度快,因为测试通常是在之后处理的。因此,这种方法的问题在于延迟测试,这可能导致积累未经过测试的代码。后来,在编写测试时,通常需要修正主要代码,使其更加模块化和清洁,这需要额外的时间。

还有一种直接针对编写测试的方法,称为测试驱动开发TDD)。这是一种软件开发方法,其中测试是在代码本身之前编写的。这种方法的优点是代码将立即被测试覆盖,这意味着代码将更加清洁和可靠。然而,这种方法可能不适合原型设计或需求经常变化的项目。

在 TDD(测试驱动开发)和开发后测试之间的选择取决于许多因素,包括团队文化、项目需求和开发者的偏好。重要的是要理解,这两种方法都不是万能的解决方案,不同的情境下可能会有不同的合理选择。最重要的是,要理解测试的重要性,并且应该避免那种完全不编写测试的工作方法,因为在大多数情况下,这样的代码注定要完全重写。

现在我们已经了解了单元测试及其重要性,让我们更深入地了解一下。在编写测试之前,我们应该设置我们将要运行测试的环境。

设置测试环境

编写和运行单元测试最流行的框架是 Jest。然而,我们将探讨其性能更优的替代方案,它与 Vite 完全兼容,被称为 Vitest。要在你的项目中安装 Vitest,你需要执行以下命令:

npm install -D vitest 

对于基本操作,Vitest 不需要任何配置,因为它与 Vite 配置文件完全兼容。

接下来,为了开始,我们需要创建一个扩展名为 *.test.ts 的文件。文件的位置不是关键;最重要的是文件要在你的项目内部。通常,测试文件与被测试的函数文件相关联,并放置在同一目录下;例如,对于位于 sum.ts 文件中的 sum 函数,会创建一个名为 sum.test.ts 的测试文件,并将其放置在同一文件夹中。

要运行测试,我们需要在 package.json 文件中添加一个启动脚本:

{
  "scripts": {
    "test": "vitest"
  }
} 

然后,要调用它,只需在终端中执行命令:

npm run test 

这个命令将启动 Vitest 进程,它会扫描项目中的 .test 扩展名的文件,然后执行每个这样的文件中的所有测试。一旦所有测试完成,你将在终端窗口中看到结果,然后进程将等待测试文件的变化以重新运行它们。这特别设计为开发测试的模式,其中你不需要不断运行测试命令。对于一次性测试运行,你可以添加另一个命令,在测试完成后关闭进程:

"test:run": "vitest run" 

run 参数正是用来告诉 Vitest 你只想运行一次测试。

Vitest 特性

现在,让我们看看 Vitest 的主要特性和我们可以编写的测试类型。让我们从一个简单的函数 squared 开始:

export const squared = (n: number) => n * n 

这个函数返回一个数字的平方。以下是这个函数的测试示例:

import { expect, test } from 'vitest'
test('Squared', () => {
  expect(squared(2)).toBe(4)
  expect(squared(4)).toBe(16)
  expect(squared(25)).toBe(625)
}) 

testexpect 函数是 Vitest 包的一部分。test 函数将其名称作为第一个参数,将测试函数本身作为第二个参数。expect 方法作为检查被测试函数期望结果的基础。调用 expect 方法会创建一个包含大量方法的对象,允许以不同的方式检查执行结果。在我们的例子中,我们明确比较了执行 squared 函数的结果与期望值。

运行这个测试后,在终端窗口中,我们会看到以下信息:

✓ test/basic.test.ts (1)
   ✓ Squared
 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  17:39:33
   Duration  1.14s 

为了检查测试是否正确工作,让我们将期望值从 4 改变,看看我们会得到什么结果:

FAIL  test/basic.test.ts > Squared
AssertionError: expected 4 to be 5 // Object.is equality
- Expected
+ Receivedeval test/basic.test.ts:13:22
     11| 
     12| test('Squared', () => {
     13|   expect(squared(2)).toBe(5);
       |                      ^
     14|   expect(squared(4)).toBe(16);
     15|   expect(squared(25)).toBe(625);
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
 Test Files  1 failed (1)
      Tests  1 failed (1)
   Start at  17:41:45
   Duration  1.15s 

当测试失败时,我们可以在结果中直接看到错误发生的位置,我们得到了什么结果,以及我们期望的是什么。

toBe 方法对于直接比较结果非常有用,但对于对象和数组呢?让我们考虑这个测试示例:

test('objects', () => {
  const obj1 = { a: 1 };
  const obj2 = { a: 1 };
  expect(obj1).not.toBe(obj2);
  expect(obj1).toEqual(obj2);
}); 

在这个测试中,我们创建了两个相同的对象,但作为变量它们不会相等。为了期望相反的断言,我们使用额外的.not.键,这最终给出了两个变量不相等的陈述。如果我们仍然想检查对象具有相同的结构,有一个名为toEqual的方法,它可以递归地比较对象。这个方法也与数组类似工作。

对于数组,也有一些额外的方法可以用来检查元素是否存在,这通常非常有用:

test('Array', () => {
  expect(['1', '2', '3']).toContain('3');
}); 

toContain方法也可以与字符串和 DOM 元素一起工作,检查classList中是否存在类。

单元测试的下一个重要部分是处理函数。Vitest 允许你创建可模拟的假函数,这让你可以检查这个函数是如何以及使用什么参数被调用的。让我们看看一个示例函数:

const selector = (onSelect: (value: string) => void) => {
  onSelect('1');
  onSelect('2');
  onSelect('3');
}; 

这个函数只是为了演示,但我们很容易想象一些模块或选择器组件,它接受onSelect回调函数,该函数将在某些条件下被调用:在我们的例子中,连续调用三次。现在让我们看看我们如何使用可观察的函数进行测试:

test('selector', () => {
  const onSelect = vi.fn();
  selector(onSelect);
  expect(onSelect).toBeCalledTimes(3);
  expect(onSelect).toHaveBeenLastCalledWith('3');
}); 

在测试中,我们使用Vitest包中的vi模块创建了onSelect函数。现在这个函数允许我们检查它被调用了多少次以及使用了什么参数。为此,我们使用了toBeCalledTimestoHaveBeenLastCalledWith方法。还有一个名为toHaveBeenCalledWith的方法,它可以逐步检查在观察函数的每次调用中使用了哪些参数。在我们的例子中,有效的检查会是这三行:

 expect(onSelect).toHaveBeenCalledWith('1');
  expect(onSelect).toHaveBeenCalledWith('2');
  expect(onSelect).toHaveBeenCalledWith('3'); 

Vitest 还允许你模拟一个真实函数,你需要使用vi.spyOn方法。然而,为了做到这一点,函数必须可以从一个对象中访问。让我们看看模拟一个真实函数的示例:

test('spyOn', () => {
  const cart = {
    getProducts: () => 10,
  };
  const spy = vi.spyOn(cart, 'getProducts');
  expect(cart.getProducts()).toBe(10);
  expect(spy).toHaveBeenCalled();
  expect(spy).toHaveReturnedWith(10);
}); 

要为函数创建一个观察,我们调用vi.spyOn并传递对象作为第一个参数以及方法的名称作为第二个参数。然后,我们可以处理原始函数,稍后通过使用spy变量进行必要的检查。在上面的例子中,你还可以注意到新的方法toHaveReturnedWith,它允许你检查观察到的函数返回了什么。

模拟

接下来,我想提到单元测试中最具挑战性的部分之一:即处理具有副作用或依赖于外部数据或库的函数。之前,我提到在具有副作用的函数中进行测试是无用的,比如在底层调用某些东西。实际上,这并不完全正确。在某些情况下,编写一个纯函数可能是不可能的,但这并不意味着它不能被测试。为了测试这样的函数,我们可以使用模拟:即模拟外部行为或简单地替换某些模块或库的实现。

一个例子可能是一个依赖于计算机系统时间的函数,或者一个从服务器返回数据的函数。在这种情况下,我们可以应用一个特定的模拟指令来更改计算机的当前日期,以便为这个测试创建一个干净的结果,这样更容易进行测试。同样,也可以创建一个网络请求的模拟实现,它最终将在本地执行并返回预定的值。让我们在本节中讨论一些这些场景。

考虑到测试和使用计时器的例子。在测试环境中,我们可以避免等待计时器,并手动控制它们,以便更彻底地测试函数的行为。让我们看看一个例子:

function executeInMinute(func: () => void) {
  setTimeout(func, 1000 * 60)
}
function executeEveryMinute(func: () => void) {
  setInterval(func, 1000 * 60)
}
const mock = vi.fn(() => console.log('done')) 

我们创建了executeInMinuteexecuteEveryMinute函数,分别用于延迟函数调用一分钟和每分钟循环执行。我们还创建了一个模拟函数,我们将随后对其进行监视。以下是测试将呈现的样子:

describe('delayed execution', () => {
  beforeEach(() => {
    vi.useFakeTimers()
  })
  afterEach(() => {
    vi.restoreAllMocks()
  })
  it('should execute the function', () => {
    executeInMinute(mock)
    vi.runAllTimers()
    expect(mock).toHaveBeenCalledTimes(1)
  })
  it('should not execute the function', () => {
    executeInMinute(mock)
    vi.advanceTimersByTime(2)
    expect(mock).not.toHaveBeenCalled()
  })

  it('should execute every minute', () => {
    executeEveryMinute(mock)
    vi.advanceTimersToNextTimer()
    expect(mock).toHaveBeenCalledTimes(1)
    vi.advanceTimersToNextTimer()
    expect(mock).toHaveBeenCalledTimes(2)
  })
}) 

在这个例子中,有很多东西可以讨论,但让我们从我们没有使用test函数这个事实开始;相反,我们使用了describeitdescribe函数允许我们创建一个可以有自己的上下文和生命周期的测试套件。在测试套件中,我们可以设置初始参数或模拟某些行为,以便我们的测试用例可以在以后重用这个上下文和这些参数。在我们的例子中,我们使用了beforeEachafterEach方法,这些方法在每个测试之前设置模拟计时器,然后在每个测试之后将一切恢复到原始状态。

it方法是对test方法的别名,在功能上与它没有区别。它只是为了让测试用例在结果中更易于阅读。例如,在结果中使用describedelayed executionitshould execute the function将看起来像这样:

delayed execution > should execute the function 

然而,使用test,我们会看到的结果是:

delayed execution > if should execute the function 

现在,让我们看看测试本身。第一个测试使用executeInMinute函数,实际上,它将在一分钟后才调用我们观察的方法,但在测试中,我们可以控制时间。通过使用vi.runAllTimers(),我们强制环境启动并跳过所有计时器,并立即检查结果。在下一个测试中,我们使用vi.advanceTimersByTime(2)将时间向前推进 2 毫秒,这已经允许我们确保原始函数不会被调用。

接下来,让我们讨论executeEveryMinute方法,它应该每分钟通过调用一个参数来启动一个计时器。在这种情况下,我们可以通过使用advanceTimersToNextTimer逐步遍历这个计时器的每个迭代,这样我们就可以在不等待真实时间的情况下精确控制时间。

在编写单元测试时,我们经常会遇到被测试的函数依赖于某些库甚至是一个包。

通常情况下,你会在 React Native 中遇到这种情况,如果某个库或某些方法使用了设备的原生功能。在这种情况下,为了编写测试,我们需要创建一个模拟版本的逻辑,该逻辑将在测试期间被调用。

让我们考虑一个简单的例子,我们假设我们有一个可以与设备交互并获取当前步数的包。为了获取步数,我们将使用getSteps函数:

export function getSteps() {
  // SOME NATIVE LOGIC
  return 100;
} 

作为例子,这个函数本身将非常简单,它只会返回100的值。然而,在现实中,这样的函数将与智能手机 API 交互,这在测试范围内是无法调用的。接下来,让我们看看在编写测试时我们可以做什么:

import { beforeAll, describe, expect, it, vi } from 'vitest';
import { getSteps } from './ios-health-kit';
describe('IOS Health Kit', () => {
  beforeAll(() => {
    vi.mock('./ios-health-kit', () => ({
      getSteps: vi.fn().mockImplementation(() => 2000),
    }));
  });
  it('should return steps', () => {
    expect(getSteps()).toBe(2000);
    expect(getSteps).toHaveBeenCalled();
  });
}); 

测试和整个例子相当简单,但它们将帮助你理解模拟是如何工作的。在文件的开头,我们导入我们的原始包ios-health-kit,然后使用beforeAll方法调用vi.mock,将包的路径作为第一个参数传递,并传递一个函数,该函数将返回原始文件的实现:即创建一个具有getSteps方法作为假函数的对象,其实施将返回2000的值。然后,在测试中,我们检查它确实返回了这个值。

在这个测试中,vi.mock函数创建了一个导入包的模拟,并用它替换了原始导入,这使得我们能够成功测试这个功能。

实际上,这个例子本质上并没有测试任何东西,只是展示了模拟的可能性。在实际项目中,你可能会需要测试一些函数,这些函数内部可能使用了需要模拟的重要库。为此,在真正的测试之前不断手动编写模拟可能不太方便;为了解决这个问题,你可以在全局级别模拟库和 API。为此,你需要创建一个配置文件或使用vi.stubGlobal。我不建议在没有理解和学习基础知识的情况下立即深入下去,所以让我们继续。

更多关于通过配置进行依赖项模拟的信息可以在vitest.dev/guide/mocking找到。

最后但同样重要的是,我想讨论的例子是模拟网络请求。你将要开发的任何应用程序几乎都会与需要从服务器获取的数据交互。对于单元测试来说,这可能是一个问题,因为测试单元时,重要的是要测试与外部环境抽象的单元。因此,在单元测试中,你应该始终模拟服务器请求并提供当前测试用例所需的数据。有一个名为Mock Service Worker的库用于模拟服务器请求。它允许你非常灵活地模拟 REST 和 GraphQL 请求。让我们看一个例子:

import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest';
const server = setupServer(
  http.get('https://api.github.com/users', () => {
    return HttpResponse.json({
      firstName: 'Mikhail',
      lastName: 'Sakhniuk',
    });
  })
);
describe('Mocked fetch', () => {
  beforeAll(() => server.listen());
  afterEach(() => server.resetHandlers());
  afterAll(() => server.close());
  it('should returns test data', async () => {
    const response = await fetch('https://api.github.com/users');
    expect(response.status).toBe(200);
    expect(response.statusText).toBe('OK');
    expect(await response.json()).toEqual({
      firstName: 'Mikhail',
      lastName: 'Sakhniuk',
    });
  });
}); 

在这个测试中,我们为路径 https://api.github.com/users 创建了一个模拟网络请求,它返回我们需要的数据。为此,我们使用了来自 Mock Service Worker 包的 setupServer 函数。接下来,在生命周期方法中,我们设置了模拟服务器以监听服务器请求,然后实现了一个标准测试,其中使用常规 Fetch API 请求数据。正如您在结果中可以看到的,我们可以检查状态码和返回的数据。

使用这种模拟方法,我们确实有广泛的测试不同逻辑的可能性,这取决于从服务器返回的数据、状态码、错误等。

在本节中,我们介绍了单元测试的基础:即它们是什么以及为什么我们需要编写它们。我们学习了如何设置测试环境并为我们的未来项目编写基本测试。接下来,让我们继续本章的主要主题,即测试 ReactJS 组件。

测试 ReactJS

我们已经知道单元测试涉及检查小的单元,通常是函数,这些函数执行一些逻辑并返回一个结果。为了理解 ReactJS 中的测试是如何工作的,概念和想法是相同的。我们知道在核心上,React 组件实际上是返回节点的 createElement 函数,这些节点作为 render 函数的结果,在浏览器屏幕上以 HTML 元素的形式显示。在单元测试中,我们没有浏览器,但这对我们来说不是问题,因为我们知道 React 的渲染目标几乎可以是任何东西。正如您可能已经猜到的,在 ReactJS 组件的单元测试中,我们将渲染组件到专门创建的 JSDOM 格式,它与 DOM 完全相同,React Testing Library 将帮助我们完成这项工作。

这个库包含一套工具,允许渲染组件、模拟事件,并以各种方式检查结果。

在我们开始之前,让我们设置测试 React 组件的环境。为此,在一个新的 Vite 项目中,执行以下命令:

npm install --save-dev \
  @testing-library/react \
  @testing-library/jest-dom \
  vitest \
  jsdom 

此命令将安装我们需要的所有依赖项。接下来,我们需要创建一个 tests/setup.ts 文件,以集成 Vitest 和 React Testing Library:

import { expect, afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import * as matchers from "@testing-library/jest-dom/matchers";
expect.extend(matchers);
afterEach(() => {
  cleanup();
}); 

接下来,我们需要更新 vite.config.ts 配置文件,并在其中添加以下代码:

 test: {
    globals: true,
    environment: "jsdom",
    setupFiles: "./tests/setup.ts",
  }, 

这些参数告诉 Vitest 在开始测试之前使用一个额外的环境和执行我们的设置脚本。

最后一步是配置 TypeScript 类型,我们将指定 expect 函数现在将具有与 React 组件一起工作的额外方法。为此,我们需要将以下代码添加到 src/vite-env.d.ts 文件中:

import type { TestingLibraryMatchers } from "@testing-library/jest-dom/matchers";
declare global {
  namespace jest {
    interface Matchers<R = void>
      extends TestingLibraryMatchers<typeof expect.stringContaining, R> {}
  }
} 

这种结构为 React Testing Library 提供的所有新方法添加了类型。有了这个,环境设置就完成了,我们可以继续编写测试。

首先,让我们考虑一个最基础的检查,即组件是否已成功渲染并存在于文档中。为此,我们将创建一个返回带有Hello world文本的标题的App组件:

export function App() {
  return <h1>Hello world</h1>;
} 

这样一个组件的测试看起来会是这样:

import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { App } from "./App";
describe("App", () => {
  it("should be in document ", () => {
    render(<App />);
    expect(screen.getByText("Hello world")).toBeInTheDocument();
  });
}); 

测试本身的架构与之前相同,并且我们已经非常熟悉。需要注意的是,在测试开始时,我们使用来自testing-libraryrender函数来渲染组件,之后我们就可以执行检查。为了处理渲染结果,我们使用screen模块。它允许我们以各种方式与我们的虚拟 DOM 树进行交互,并搜索必要的元素。

我们将在稍后介绍主要的方法,但在本例中,我们使用了getByText方法,它查询包含文本“Hello World”的元素。为了检查该元素是否存在于文档中,我们使用toBeInTheDocument方法。这是运行测试时的输出:

✓ src/App.test.tsx (1)
   ✓ App (1)
     ✓ should be in document
 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  14:19:01
   Duration  198ms 

现在让我们考虑一个更复杂的例子,其中我们需要检查点击按钮会给组件添加一个新的className属性:

export function ClassCheck() {
  const [clicked, setClicked] = useState(false);
  return (
    <button
      className={clicked ? "active" : ""}
      onClick={() => setClicked(true)}
    >
      Click me
    </button>
  );
} 

通过点击按钮,我们更新了状态,这更新了组件并给它添加了一个active类。现在,让我们为这个组件编写一个测试:

describe("ClassCheck", () => {
  it("should have class active when button was clicked", () => {
    render(<ClassCheck />);
    const button = screen.getByRole("button");
    expect(button).not.toHaveClass("active");
    fireEvent.click(button);
    expect(button).toHaveClass("active");
  });
}); 

在这个测试中,你首先渲染ClassCheck组件,然后我们需要找到按钮元素,为此,我们使用带有getByRole方法的screen模块。这是下一个允许在文档中查询元素的方法,但重要的是要理解,如果文档中存在多个button元素,这个测试将产生错误。因此,在不同情况下应用合适的查询方法是必要的。现在按钮是可访问的,我们首先使用带有not前缀的toHaveClass方法确保组件不包含active类。

要点击这个按钮,React Testing Library 提供了fireEvent模块,它允许生成点击事件。点击按钮后,我们检查元素中是否存在所需的类。

使用fireEvent,可以生成所有可能的事件,如点击、拖动、播放、聚焦、失焦等。一个非常重要且需要测试的常见事件是输入元素中的change事件。让我们以Input组件为例来讨论这个问题:

export function Input() {
  return <input type="text" data-testid="userName" />;
} 

这个组件简单地返回一个input元素,但在这个例子中,我还添加了一个特殊的属性,data-testid。这个属性用于在文档中更方便地搜索元素,因为它抽象了你对组件内容或元素角色的操作。在项目开发过程中,你经常会更新你的组件,而data-testid属性将帮助你更频繁地修复由于内容更新或更改(例如从h1h2div到更语义化的元素)而导致的损坏的测试。

现在让我们为这个组件编写一个测试:

describe("Input", () => {
  it("should handle change event", () => {
    render(<Input />);
    const input = screen.getByTestId<HTMLInputElement>("userName");
    fireEvent.change(input, { target: { value: "Mikhail" } });
    expect(input.value).toBe("Mikhail");
  });
}); 

在这个测试中,像往常一样,我们渲染组件,然后使用更方便的方法 getByTestId 找到我们的元素。接下来,我们使用 fireEvent.change 方法在 input 上模拟 change 事件,该方法接受事件对象,并在测试结束时断言输入的值与预期的值相符。这样,我们现在可以测试具有各种逻辑的大型表单,例如格式化、验证等。

就像测试组件一样,React 测试库也可以测试 Hooks。这允许我们只测试自定义逻辑,并从组件中抽象出来。让我们编写一个小的 useCounter Hook,它将返回当前的 counter 值和 incrementdecrement 函数:

export function useCounter(initialValue: number = 0) {
  const [count, setCount] = useState(initialValue);
  const increment = () => setCount((c) => c + 1);
  const decrement = () => setCount((c) => c - 1);
  return { count, increment, decrement };
} 

为了测试这个 Hook,而不是使用 render 函数,React 测试库有一个 renderHook 方法。这就是这个 Hook 的测试看起来像:

test("useCounter", () => {
  const { result } = renderHook(() => useCounter());
  expect(result.current.count).toBe(0);
  act(() => {
    result.current.increment();
  });
  expect(result.current.count).toBe(1);
  act(() => {
    result.current.decrement();
  });
  expect(result.current.count).toBe(0);
}); 

首先,我们渲染 Hook 本身并检查初始值是否为零。renderHook 方法返回 result 对象,通过它我们可以读取 Hook 返回的数据。接下来,我们需要测试 incrementdecrement 方法。为此,仅仅调用它们是不够的,因为 Hooks 本质上不是纯函数,并且在其内部包含大量逻辑。因此,我们需要将这些方法包裹在 act 方法中调用,这将同步等待方法执行和 Hook 重新渲染。之后,我们可以以通常的方式断言期望。输出将看起来与我们在上一个示例中看到的一样,但现在让我们尝试更新测试以使结果失败。将第一个断言从 .toBe(0) 更新到 .toBe(10) 将看起来像:

AssertionError: expected +0 to be 10 // Object.is equality
- Expected
+ Received
- 10
+ 0
 ❯ src/useCounter.test.ts:8:32
      6|   const { result } = renderHook(() => useCounter());
      7| 
      8|   expect(result.current.count).toBe(10);
       |                                ^
      9| 
     10|   act(() => {
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
 Test Files  1 failed (1)
      Tests  1 failed (1)
   Start at  14:24:06
   Duration  200ms 

你将注意到 Vitest 如何突出显示我们得到失败断言的代码部分。

在本节中,我们学习了如何使用 React 测试库测试组件和 Hooks。

摘要

在本章中,我们探讨了广泛的测试主题。我们熟悉了测试的概念、测试类型和不同的方法。然后,我们深入研究了单元测试,学习了它是什么,以及这种测试类型提供了哪些可能性。之后,我们学习了如何设置环境并为常规函数和逻辑编写测试。在本章结束时,我们检查了测试 React 组件和 Hooks 的基本功能。

通过本章,我们结束了与惊人的 ReactJS 库的相识,并将深入探索 React 生态系统,利用创建基于 React Native 的移动应用程序的惊人机会。

加入我们的 Discord 社群!

与其他用户和作者一起阅读这本书。提出问题,为其他读者提供解决方案,与作者聊天,等等。扫描二维码或访问链接加入社区。

packt.link/ReactAndReactNative5e

二维码

第二部分

React Native

在本部分,我们将探讨使用React Native库构建移动应用。我们将探索基本 API 和一些常见方法,以帮助您开发稳定且性能良好的应用程序。

本部分包含以下章节:

  • 第十五章为什么选择 React Native?

  • 第十六章React Native 内部机制

  • 第十七章启动 React Native 项目

  • 第十八章使用 Flexbox 构建响应式布局

  • 第十九章屏幕间导航

  • 第二十章渲染项目列表

  • 第二十一章地理位置和地图

  • 第二十二章收集用户输入

  • 第二十三章响应用户手势

  • 第二十四章显示进度

  • 第二十五章显示模态屏幕

  • 第二十六章使用动画

  • 第二十七章控制图像显示

  • 第二十八章离线使用

第十五章:为什么选择 React Native?

Meta(原名 Facebook)创建了 React Native 来构建其移动应用程序。它始于 2013 年夏天的 Facebook 内部黑客马拉松项目,并于 2015 年开源。这样做的原因源于 React 在 Web 上的成功。因此,如果 React 是 UI 开发的优秀工具,而你需要一个原生应用程序,那么为什么要与之抗争呢?只需让 React 与原生移动操作系统 UI 元素协同工作!因此,同年,Facebook 将 React 分为两个独立的库,ReactReactDOM,从那时起,React 必须只与接口工作,而不关心这些元素将在哪里渲染。

在本章中,你将了解使用 React Native 构建原生移动 Web 应用程序的动机。以下是本章我们将涵盖的主题:

  • 什么是 React Native?

  • React 和 JSX 熟悉

  • 移动浏览器体验

  • Android 和 iOS:不同却相同

  • 移动 Web 应用程序的案例

技术要求

本章没有技术要求,因为它是对 React Native 的简要概念介绍。

什么是 React Native?

在本书的早期部分,我介绍了渲染目标的概念,即 React 组件渲染到的对象。对于 React 程序员来说,渲染目标是抽象的。例如,在 React 中,渲染目标可以是字符串,也可以是文档对象模型DOM)。因此,你的组件永远不会直接与渲染目标接口,因为你永远无法确定渲染发生在哪里。

移动平台有UI 小部件库,开发者可以利用这些库为该平台构建应用程序。在 Android 上,开发者使用JavaKotlin实现应用程序,而在 iOS 上,开发者实现Objective-CSwift应用程序。如果你想有一个功能性的移动应用程序,你必须选择一个。然而,你需要学习这两种语言,因为只支持两个主要平台中的一个对于成功来说并不现实。

对于 React 开发者来说,这不是问题。你构建的相同 React 组件可以在任何地方工作,甚至在移动浏览器上!需要学习两种额外的编程语言来构建和发布移动应用程序既费时又费力。解决方案是引入一个新的 React 平台,该平台支持新的渲染目标:原生移动 UI 小部件。

React Native 使用一种技术,对底层移动操作系统进行异步调用,该系统调用原生小部件 API。有一个 JavaScript 引擎,React API 与 Web 上的 React 大致相同。区别在于目标;不是 DOM,而是异步 API 调用。这个概念在这里得到了可视化:

图 15.1:React Native 工作流程

这过于简化了底层发生的一切,但基本思想如下:

  • 在 Web 上使用的相同 React 库也被React Native使用,并在JavaScriptCore上运行。

  • 发送到原生平台 API 的消息是异步的,并且为了性能目的而批量处理。

  • React Native 附带适用于移动平台的组件,而不是 HTML 元素。

  • React Native 仅仅是通过 iOS 和 Android API 渲染组件的一种方式。它可以使用相同的概念替换为 tvOS、Android TV、Windows、macOS,甚至再次用于 Web。这可以通过 React Native 的分支和附加组件来实现。在本书的这一部分,我们将学习如何为 iOS 和 Android 编写移动应用。有关其他可能平台的更多信息,请在此处查看:reactnative.dev/docs/out-of-tree-platforms

关于 React Native 的历史和机制,更多信息可以在engineering.fb.com/2015/03/26/android/react-native-bringing-modern-web-techniques-to-mobile/找到。

React 和 JSX 都很熟悉

为 React 实现一个新的渲染目标并不简单。这本质上与在 iOS 和 Android 上运行的新 DOM 的发明是一样的。那么,为什么要费这么大的劲呢?

首先,对移动应用的需求量很大。原因是移动 Web 浏览器的用户体验不如原生应用。其次,JSX 是构建 UI 的绝佳工具。你不必学习新技术,使用你已知的工具就足够了。

最后一点对你来说最为相关。如果你正在阅读这本书,你很可能对使用 React 来开发 Web 应用和原生移动应用感兴趣。我无法用言语表达 React 在开发资源方面的价值。你不需要一个专门负责 Web UI 的团队、一个专门负责 iOS 的团队、一个专门负责 Android 的团队等等,只需要一个理解 React 的 UI 团队。

在接下来的部分,你将了解在移动 Web 浏览器上提供良好用户体验的挑战。

移动浏览器的体验

移动浏览器缺乏许多移动应用的功能。这是因为浏览器无法像 HTML 元素那样复制相同的原生平台小部件。你可以尝试这样做,但通常最好是直接使用原生小部件而不是尝试复制它。这部分的理由是这需要你更少的维护工作,部分是因为使用平台原生的小部件意味着它们与平台的其他部分保持一致。例如,如果你的应用程序中的日期选择器与用户在手机上交互的所有日期选择器都不同,这并不是一个好现象。熟悉度是关键,使用原生平台小部件使得熟悉度成为可能。

移动设备上的用户交互与你在 Web 上通常设计的交互在本质上是有区别的。例如,Web 应用假设存在鼠标,并且按钮上的点击事件只是一个阶段。然而,当用户用手指与屏幕交互时,事情变得更加复杂。移动平台有一个所谓的 手势系统 来处理这种情况。React Native 在处理这些类型的事情上比 Web 上的 React 更合适,因为这些事情在 Web 应用中你不必过多考虑。

随着移动平台的更新,你希望你的应用组件也保持更新。在 React Native 中这不是问题,因为应用使用的是平台实际组件。再次强调,一致性和熟悉性对于良好的用户体验至关重要。因此,当你的应用中的按钮看起来和表现方式与设备上其他应用中的按钮相同,你的应用就会感觉像是设备的一部分。

现在你已经了解了为什么为移动浏览器开发 UI 比较困难,是时候看看 React Native 如何弥合不同原生平台之间的差距了。

Android 和 iOS:不同却相同

当我第一次听说 React Native 时,我自然而然地认为它将是一种跨平台解决方案,让你能够编写一个可以在任何设备上本地运行的单一 React 应用。然而,现实更加复杂。虽然 React Native 允许在平台之间共享大量代码,但重要的是要理解 iOS 和 Android 在许多基本层面上是不同的,它们的用户体验哲学也不同。

React Native 的目标是“一次学习,到处编写”而不是“一次编写,到处运行”。这意味着在某些情况下,你可能希望你的应用利用平台特定的控件来提供更好的用户体验。

话虽如此,React Native 生态系统已经取得了进步,使得跨平台开发更加无缝。

例如,Expo 现在支持 Web 开发,允许你使用 React Native for Web 在 Web 上运行你的应用。这意味着你可以使用单一代码库开发在 Android、iOS 和 Web 上运行的应用。此外,Tamagui UI 套件 对 Web 和移动平台都提供 100%的支持,这使得创建在多个平台上运行且不牺牲用户体验的应用变得更加容易。

鉴于这些发展,重要的是要认识到,虽然 React Native 可能不会提供一个完美的“一次编写,到处运行”的解决方案,但它已经在实现更高效的跨平台开发方面取得了长足的进步。有了像 Expo 和 Tamagui 这样的工具,开发者可以创建在不同平台上运行的应用,同时在必要时利用平台特定的功能。

在下一节中,我们将探讨移动 Web 应用在浏览器中运行可能更适合你的用户的情况。

移动 Web 应用的优势

并非你的每一位用户都愿意安装应用,尤其是如果你还没有高下载量和评分。Web 应用进入门槛要低得多:用户只需要一个浏览器。

尽管无法复制原生平台 UI 所能提供的一切,你仍然可以在移动 Web UI 中实现很棒的事情。也许拥有一个好的 Web UI 是提高你的移动应用下载量和评分的第一步。

理想情况下,你应该追求以下目标:

  • 标准 Web(笔记本电脑/台式机浏览器)

  • 移动 Web(手机/平板浏览器)

  • 移动应用(手机/平板原生平台)

在这三个空间中投入相同数量的努力可能没有太多意义,因为你的用户可能更倾向于其中一个领域而不是另一个。例如,一旦你知道你的移动应用的需求比网络版本高,那么你就可以在那里分配更多的努力。

摘要

在本章中,你了解到 React Native 是 Facebook 为了重用 React 来创建原生移动应用的努力。React 和 JSX 擅长声明 UI 组件,鉴于现在对移动应用的需求巨大,使用你所知道的 Web 知识是有意义的。

对于移动应用的需求超过移动浏览器的原因是它们感觉更好。Web 应用缺乏像应用那样处理移动手势的能力,并且从外观和感觉的角度来看,它们通常不像移动体验的一部分。

React Native 在过去的几年里发展迅速,使开发者能够创建更高效的跨平台应用。虽然 iOS 和 Android 确实存在根本性的差异,但 React Native 在提供更无缝的开发体验方面取得了进展。然而,重要的是要记住,React Native 的目标是“一次学习,到处编写”而不是“一次编写,到处运行”。这意味着开发者仍然可以利用平台特定的功能来提供更好的用户体验。

现在你已经了解了 React Native 是什么以及它的优势,你将在下一章学习如何开始新的 React Native 项目。

第十六章:React Native 内部机制

上一章简要介绍了 React Native 是什么以及用户在 React Native UI 和移动浏览器之间体验到的差异。

在本章中,我们将深入探讨 React Native,深入了解它在移动设备上的表现以及我们在开始使用此框架之前应该达到的目标。我们还将探讨我们可以执行哪些原生功能选项以及我们将面临哪些限制。

我们将涵盖以下主题:

  • 探索 React Native 架构

  • 解释 JavaScript 和本地模块

  • 探索 React Native 组件和 API

探索 React Native 架构

在理解 React Native 的工作原理之前,让我们回顾一下关于 React 架构和网页与原生移动应用之间差异的历史观点。

过去网页和移动应用的状态

Meta 在 2013 年发布了 React,这是一个用于创建应用的单一工具,采用组件方法和 虚拟 DOM。它为我们提供了开发无需考虑浏览器进程(如解析 JS 代码、创建 DOM、处理层和渲染)的网页应用的机会。我们只需使用状态和属性创建界面,用于数据和 CSS 用于样式,从后端获取数据,保存在本地存储中等。

React 与浏览器一起,使我们能够在更短的时间内创建性能应用。当时,React 的架构看起来是这样的:

图片

图 16.1:2013 年的 React 架构

由于快速开发和低门槛,新的声明式接口开发方法变得更加受欢迎。此外,如果你的后端是用 Node.js 构建的,你可以通过仅使用一种编程语言来享受整个项目的支持和开发的便利。

同时,移动应用需要更复杂的技术来创建应用。对于 Android 和 iOS 应用,公司应该管理三个不同团队,这些团队具有无与伦比的经验,以支持三个主要生态系统:

  • 网页开发者应该了解 HTML、CSS、JS 和 React。

  • JavaKotlin SDK 经验对于 Android 开发者来说是必需的。

  • iOS 开发者应该熟悉 Objective-CSwiftCocoaPods

开发应用的每一步,从原型设计到发布,都需要独特的技能。在跨平台解决方案出现之前,网页和移动应用开发看起来是这样的:

图片

图 16.2:网页和移动应用的状态

即使是一家公司执行一个基本应用,也可能面临一些重大问题:

  • 这些团队中的每一个都实现了相同的企业逻辑。

  • 在团队之间共享代码没有替代方案。

  • 在团队之间共享资源是不可能的(Android 开发者无法为 iOS 应用编写代码,反之亦然)。

由于这些重大问题,我们在测试资源方面也遇到了复杂性,因为存在更多可能产生错误的地方。开发速度也各不相同,因为移动应用程序需要更多时间来实现相同的功能。所有这些都累积成了对公司来说成本高昂的大问题。其中许多公司提出了如何编写单一代码库或重用现有代码库的想法,这些代码库可以在多个生态系统中使用。最简单的方法是使用浏览器将 Web 应用程序包装成移动应用,但正如我们在 第十五章 中探讨的,“为什么选择 React Native?” 这在处理触摸和手势方面存在局限性。

针对这些问题的回应,Meta 开始投资资源开发跨平台框架,并在 2015 年发布了 React Native 库。它还将 React 分为两个独立的库。现在,为了在浏览器中渲染我们的应用程序,我们应该使用 ReactDOM 库。

图 16.3 中,我们可以看到 React 如何与 ReactDOMReact Native 协同工作以渲染我们的应用程序:

图 16.3:ReactDOM 和 React Native 流程

现在,React 只负责管理组件树。这种方法封装了任何渲染 API,并隐藏了许多平台特定的方法。我们可以专注于开发界面,而无需猜测它们将如何被渲染。

正因如此,React 常常被宣称为一个渲染无关的库。此外,对于 Web 应用程序,我们使用 ReactDOM,它形成元素并将它们直接应用到浏览器 DOM 上。对于移动应用程序,React Native 直接在移动屏幕上渲染我们的界面。

但是,React Native 是如何替换整个浏览器 API,并允许我们编写熟悉的代码并在移动设备上运行的呢?

React Native 当前架构

React Native 库允许您通过利用原生构建块使用 React 和 JS 创建原生应用程序。例如,<Image/> 组件代表了两个其他原生组件,Android 上的 ImageView 和 iOS 上的 UIImageView。这是可行的,因为 React Native 的架构包括两个专门的层,分别由 JSNative 线程表示:

图 16.4:React Native 线程

在接下来的章节中,我们将探索每个线程,并了解它们如何进行通信,确保 JS 能够集成到原生代码中。

JS 作为 React Native 的一部分

由于浏览器通过 JS 引擎(如 V8SpiderMonkey 等)执行 JS,React Native 也包含一个 JS 虚拟机。在那里,我们的 JS 代码被执行,API 调用被处理,触摸事件被处理,以及许多其他过程发生。

最初,React Native 只支持苹果的 JavaScriptCore 虚拟机。在 iOS 设备上,这个虚拟机是内置的,并且可以直接使用。在 Android 设备的情况下,JavaScriptCore 是与 React Native 一起打包的。这增加了应用程序的大小。

因此,React Native 的Hello World应用程序在 Android 上大约消耗 3 到 4 MB。从 0.60 版本开始,React Native 开始使用新的Hermes 虚拟机,从 0.64 版本开始,也提供了对 iOS 的支持。

Hermes 虚拟机为两个平台带来了许多改进:

  • 提高了应用的启动时间

  • 减少了下载的应用大小

  • 减少了内存使用

  • 内置代理支持,使react-native-firebasemobx可用

在面试中,了解新旧架构之间的比较优势是一个相对常见的话题。有关 Hermes 的更多信息,请参阅reactnative.dev/docs/hermes

在 React Native 中,与浏览器一样,JS 是在单个线程中实现的。这个线程负责执行 JS。我们编写的业务逻辑在这个线程上执行。这意味着我们所有的常见代码,如组件、状态、Hooks 和 REST API 调用,都将由应用中的 JS 部分处理。

我们整个应用程序结构都使用Metro打包器打包成一个文件。它还负责将 JSX 代码转换为 JS。如果我们想使用 TypeScript,Babel可以支持它。它直接可用,因此无需进行任何配置。在未来的章节中,我们将学习如何启动一个现成的项目。

“原生”部分

这里是执行原生代码的地方。React Native 为每个平台实现了这部分的原生代码:Android 使用 Java,iOS 使用 Objective-C。原生层主要由与 Android 或 iOS SDK 通信的 Native 模块组成,旨在为我们提供使用统一 API 的原生功能。例如,如果我们想显示一个警告对话框,原生层为两个平台提供了一个统一的 API,我们可以通过 JS 线程使用单个 API 来调用它。

当你需要更新界面或调用原生函数时,这个线程会与 JS 线程交互。这部分有两个部分:

  • 第一个是React Native UI,负责使用原生界面塑造工具。

  • 第二个是原生模块,允许应用程序访问它们运行的平台上的特定功能。

线程间的通信

如前所述,每个 React Native 层为应用中的每个原生和 UI 功能实现了一个独特的 API。层与层之间的通信是通过桥接完成的。该模块是用 C++编写的,基于异步队列。当桥接从一方接收数据时,它会将其序列化,将其转换为JSON字符串,并通过队列传递。到达目的地后,数据会被反序列化。

如警报示例所示,本地部分接受来自 JS 的调用并显示对话框。实际上,当 JS 方法被调用时,它会向 发送消息,并在接收到这条消息后,本地部分执行指令。本地消息也可以转发到 JS 层。例如,在点击按钮时,Native 层会向 JS 层发送一个带有 onClick 事件的 Native 消息。可以想象如下:

图 16.5:桥梁

JS 和该架构的本地部分,连同桥一起,类似于网络应用的客户端和服务器端,它们通过 REST APIs 进行通信。对我们来说,本地部分是用哪种语言或如何实现的不重要,因为 JS 中的代码是隔离的。我们只需通过桥发送消息并接收响应。这既是显著的优势,也是巨大的劣势:首先,它允许我们用一个代码库实现跨平台应用,但当我们应用中有大量业务逻辑时,它可能成为瓶颈。应用中的所有事件和动作都依赖于异步的 JSON-bridged 消息。每一方发送这些消息,期望在未来某个时刻收到这些消息的响应(这并不保证)。在这种数据交换方案中,存在过载通信通道的风险。

这里有一个常用的例子,用来说明这种通信方案如何导致应用出现性能问题。假设一个应用的用户在滚动一个巨大的列表。当在本地环境中发生 onScroll 事件时,信息会异步传递到 JS 环境中。但是本地机制不会等待 JS 应用部分完成工作并向其报告。因此,在显示内容之前,列表中空白的区域会出现延迟。我们可以通过使用分页的 FlatList 等特殊方法来避免许多常见问题。我们将在未来的章节中探讨主要技巧,但记住当前架构的限制是很重要的。

设计

我们已经理解了跨平台的概念,因此可以假设每个平台都有自己的技术来创建和设计界面。为了统一这些技术,React Native 使用 CSS-in-JS 语法来设计应用的外观。使用 Flexbox,组件能够指定其子组件的布局。这确保了在不同屏幕尺寸上保持一致的布局。这通常与网页上 CSS 的工作方式相似,只是名称采用驼峰式,例如 backgroundColor 而不是 background-color

在 JS 中,它是一个具有样式属性的普通对象,在原生代码中,它是一个名为Shadow的独立线程。它使用 Meta 开发的Yoga引擎重新计算应用程序的布局,在这个线程中执行与形成应用程序界面相关的计算。这些计算的结果被发送到负责显示界面的原生 UI 线程。

当所有部分组合在一起时,React Native 的最终架构如图所示:

图片

图 16.6:当前 React Native 架构

当前 React Native 的架构解决了主要的商业问题:可以在同一个团队内开发 Web 和移动应用程序,可以重用大量的业务逻辑代码,甚至没有移动开发经验的开发者也能轻松使用 React Native。

然而,当前的架构并不理想。在过去的几年里,React Native 团队一直在努力解决桥接瓶颈问题。新的架构旨在解决这个问题。

React Native 的未来架构

React Native 引入了一系列重大改进,这将简化开发过程,使每个人都更加方便。

React Native 的重构将逐步弃用桥接,并用一个新的组件JS 接口JSI)来替代它。此外,这个元素将启用新的Fabric组件和TurboModules

使用 JSI 为改进打开了众多可能性。在图 16.7中,你可以看到 React Native 架构的主要更新:

图片

图 16.7:新的 React Native 架构

第一个变化是 JS 包不再依赖于JavaScriptCore虚拟机。实际上,它现在是当前架构的一部分,因为现在我们可以在两个平台上启用新的Hermes JS 引擎。换句话说,JavaScriptCore 引擎现在可以轻松地被其他东西取代,很可能是性能更好的东西。

第二个改进是新的 React Native 架构的核心所在。JSI 允许 JS 直接调用原生方法和函数。这是通过HostObject C++对象实现的,它存储了对原生方法和属性的引用。在 JS 中,HostObject将原生方法和属性绑定到一个全局对象上,因此直接调用 JS 函数将调用 Java 或 Objective-C API。

新的 React Native 的另一个好处是能够完全控制名为TurboModules的原生模块。而不是一次性启动它们,应用程序将只在需要时使用它们。

Fabric 是新的 UI 管理器,在 图 16.7 中被称为 Renderer,它预计将通过消除对桥接器的需求来改变渲染层。现在可以直接在 C++ 中创建 Shadow Tree,这提高了速度并减少了渲染特定元素所需的步骤数量。

为了确保 React Native 和本地部分之间的通信顺畅,Meta 目前正在开发一个名为 CodeGen 的工具。它预计将自动化强类型本地代码和动态类型 JS 的兼容性,使它们同步。通过这次升级,将不再需要为两个线程重复代码,从而实现平滑的同步。

新的架构可能为开发能够实现旧 React Native 应用程序中不可用的新设计开辟了道路。事实上,我们现在可以利用 C++ 的力量。这意味着,使用 React Native,现在将能够创建比以前更多的应用程序种类。

在这里,我们讨论了解释 React Native 如何工作的基本原理。了解我们使用的工具的架构非常重要。拥有这些知识可以让你在规划和原型设计时避免错误,并最大限度地发挥未来应用程序的潜力。在下一节中,我们将简要探讨如何通过模块扩展 React Native。

解释 JS 和本地模块

React Native 并没有提供所有内置的本地功能。它只提供了在基本应用程序中需要的最常见功能。此外,Meta 团队最近将一些功能移动到其自己的模块中,以减少整体应用程序的大小。例如,用于在设备上存储数据的 AsyncStorage 被移动到单独的包中,如果你打算使用它,就必须安装。

然而,React Native 是一个可扩展的框架。我们可以添加自己的本地模块,并使用相同的桥接器或 JSI 暴露 JS API。在这本书中,我们的重点不会放在开发本地模块上,因为我们需要先有 Objective-C 或 Java 的经验。此外,这也不是必要的,因为 React 社区已经为所有情况创建了大量现成的模块。我们将在后续章节中学习如何安装本地包。

以下是一些最受欢迎的本地模块,没有它们,大多数项目都无法繁荣发展。

React Navigation

React Navigation 是创建应用导航菜单和屏幕的最佳 React Native 导航库之一。它对于初学者来说是个好工具,因为它稳定、快速且错误较少。文档非常好,并为所有用例提供了示例。

我们将在第十九章“屏幕间导航”中了解更多关于 React Navigation 的内容。

UI 组件库

UI 组件库使您能够快速组装应用布局,而无需花费时间设计和编码原子元素。此外,此类库通常更稳定、更一致,这导致 UI 和 UX 方面都取得更好的结果。

这些是一些最受欢迎的库(我们将在未来的章节中更详细地探讨其中的一些):

  • NativeBase:这是一个组件库,使开发者能够构建通用设计系统。它建立在 React Native 之上,允许您为 Android、iOS 和网页开发应用。

  • React Native Element:这提供了一个用于在 React Native 中创建应用的综合性 UI 工具包。

  • UI Kitten:这是Eva 设计系统的 React Native 实现。该框架包含一组以类似方式设计的通用 UI 组件。

  • React-native-paper:这是一个为 React Native 提供的可定制和现成组件集合,遵循谷歌的 Material Design 指南。

  • Tamagui:这个 UI 工具包提供可以在移动设备和网页上运行的组件。

启动画面

将启动画面添加到您的移动应用中可能是一项繁琐的任务,因为这个屏幕应该在 JS 线程开始之前出现。react-native-bootsplash包允许您从命令行创建一个花哨的启动画面。如果您提供图像和背景颜色,该包将为您完成所有工作。

图标

图标是界面可视化的一个重要部分。每个平台都使用不同的方法来显示图标和其他矢量图形。React Native 为我们统一了这一点,但仅限于使用如react-native-vector-icons等额外库。使用react-native-svg,你还可以在 React Native 应用中渲染可缩放矢量图形SVG)。

处理错误

通常,当我们开发 Web 应用时,我们能够轻松处理错误,因为它们不会超出 JS 的作用域。因此,在出现关键错误的情况下,我们拥有更多的控制和稳定性,因为如果应用根本无法启动,我们可以轻松地看到原因并在DevTools中打开日志。

由于 React Native 应用中除了环境的 JS 之外还有一个本地组件,这可能会在应用执行过程中引起错误,因此存在更多复杂性。因此,当发生错误时,我们的应用将立即关闭。这将使我们很难找出原因。

react-native-exception-handler提供了一种处理本地和 JS 错误并提供反馈的简单技术。要使其工作,您需要安装并链接该模块。然后,注册您的全局处理器以处理 JS 和本地异常,如下所示:

import { setJSExceptionHandler, setNativeExceptionHandler }
  from "react-native-exception-handler";
setJSExceptionHandler((error, isFatal) => {
  // …
});
const exceptionhandler = (exceptionString) => {
  // your exception handler code here
};
setNativeExceptionHandler(
  exceptionhandler,
  forceAppQuit,
  executeDefaultHandler
); 

setJSExceptionHandlersetNativeExceptionHandler方法是一些自定义的全局错误处理器。如果发生崩溃,您可以显示错误消息,使用 Google Analytics 进行跟踪,或者使用自定义 API 通知开发团队。

推送通知

我们生活在一个通知至关重要的世界。我们每天打开数十个应用,仅仅是因为我们收到了它们的推送通知。

推送通知通常与一个网关提供商相关联,该提供商向用户的设备发送消息。以下库可以用于向你的应用添加推送通知:

  • react-native-onesignal:用于推送通知、电子邮件和短信的 OneSignal 提供商

  • react-native-firebase:Google Firebase

  • @aws-amplify/pushnotification:AWS Amplify

空中传输更新

作为正常应用更新的一个部分,当你构建新版本并将其上传到应用商店时,你可以通过空中传输(OTA)来替换 JS 包。由于包中只包含一个文件,更新它并不复杂。你可以随时更新你的应用,无需等待苹果或谷歌验证你的应用。这就是 React Native 的真正力量。

我们可以使用它是因为微软提供的 CodePush 服务。你可以在以下链接中找到更多关于 CodePush 的信息:docs.microsoft.com/en-gb/appcenter/distribution/codepush/

Expo 也支持使用 expo-updates 包进行空中传输更新。

JS 库

对于 JS(非原生)模块,我们几乎没有限制,除了使用不受支持的 API 的库,如 DOM 和 Node.js。我们可以使用任何用 JS 编写的包:MomentLodashAxiosReduxMobX 和成千上万个其他包。

在本节中,我们仅仅触及了使用各种模块扩展应用的潜力。因为 React Native 有数千个库,逐一浏览它们几乎毫无意义。为了找到所需的包,有一个名为 React Native 目录 的项目收集并评估了大量包。该项目可以在以下地址找到:reactnative.directory/

我们现在知道了 React Native 的内部组织结构以及如何扩展其功能。我们的下一步是检查这个框架提供的 API 和组件。

探索 React Native 组件和 API

每个新章节将详细讨论主要模块和组件,但就目前而言,让我们先熟悉它们。React Native 框架中提供了一些核心组件,可以在应用中使用。

几乎所有应用都至少使用这些组件中的一个。这些是 React Native 应用的基本构建块:

  • View:任何应用的基石。这相当于 <div>,在移动设备上表示为 UIViewandroid.view。任何 <View/> 组件都可以嵌套在另一个 <View/> 组件内部,并且可以有零个或多个任何类型的子组件。

  • Text:这是一个用于显示文本的 React 组件。与 View 一样,<Text/> 支持嵌套、样式化和触摸处理。

  • Image:这显示来自各种来源的图像,如网络图像、静态资源、临时本地图像和相册中的图像。

  • TextInput:这允许用户使用键盘输入文本。属性可以配置各种功能,包括自动更正、自动大写、占位文本以及不同的键盘类型,如数字键盘。

  • ScrollView:这个组件是用于滚动多个视图和组件的通用容器。对于可滚动项,可以有垂直和水平滚动(通过调整水平属性)。如果你需要渲染大量或无限列表项,你应该使用FlatList。它支持一系列特殊属性,如下拉刷新滚动加载(懒加载)。如果你的列表需要分成几个部分,那么也有专门用于此的特殊组件:SectionList

  • Button:React Native 有高级组件可以用来创建自定义按钮和其他触摸组件,例如TouchableHighlightTouchableOpacityTouchableWithoutFeedback

  • Pressable:这为 React Native 0.63 版本提供了更精确的触摸控制。基本上,它是一个用于检测触摸的包装器。它是一个定义良好的组件,可以用作TouchableOpacityButton等触摸组件的替代品。

  • Switch:这个组件类似于复选框;然而,它以我们在移动设备上熟悉的开关形式呈现。

在接下来的章节中,我们将更深入地探讨常见组件及其属性,以及探索很少使用的组件。我们还将查看代码示例,展示如何组合组件以创建应用程序界面。

所有可用组件的详细信息可以在reactnative.dev/docs/components-and-apis找到。

摘要

在本章中,我们探讨了跨平台框架 React Native 的历史以及它为公司解决了哪些问题。有了它,公司可以使用单一的全能开发团队构建一个业务逻辑,并将其同时应用于所有平台,从而节省大量时间和金钱。详细考虑 React Native 在底层的工作原理使我们能够在规划阶段识别潜在问题并解决它们。

此外,我们开始检查 React Native 的基本组件,并且随着每一章的新内容,我们将更多地了解它们。

在下一章中,你将学习如何开始新的 React Native 项目。

第十七章:快速启动 React Native 项目

在本章中,你将开始使用 React Native。幸运的是,创建新项目时涉及的大量样板代码由命令行工具为你处理。我们将探讨 React Native 应用程序的不同 CLI 工具,并创建我们的第一个简单应用程序,你将能够直接在你的设备上上传并启动。

在本章中,我们将涵盖以下主题:

  • 探索 React Native CLI 工具

  • 安装和使用 Expo 命令行工具

  • 在你的手机上查看你的应用程序

  • 在 Expo Snack 上查看你的应用程序

技术要求

你可以在 GitHub 上找到本章的代码文件,地址为github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter17

探索 React Native CLI 工具

为了简化并加快开发过程,我们使用特殊的命令行工具,这些工具安装带有应用程序模板、依赖项和其他工具的空白项目,以启动、构建和测试。我们可以应用两种主要的 CLI 方法:

  • React Native CLI

  • Expo CLI

React Native CLI是由 Meta 创建的一个工具。该项目基于原始 CLI 工具,包括三个部分:原生 iOS 和 Android 项目以及一个 React Native JavaScript 应用程序。要开始,你需要XcodeAndroid Studio。React Native CLI 的主要优势之一是其灵活性。你可以将任何具有原生模块的库连接起来,或者直接编写代码到原生部分。然而,所有这些都需要至少对移动开发有基本的了解。

Expo CLI是开发 React Native 应用程序的大生态系统的一部分。Expo是一个用于通用 React 应用程序的框架和平台。围绕 React Native 和原生平台构建,它允许你从单个 JavaScript/TypeScript 代码库中构建、部署、测试,并快速迭代 iOS、Android 和 Web 应用程序。

Expo 框架提供以下功能:

  • Expo CLI:一个命令行工具,可以创建空白项目,然后运行、构建和更新它们。

  • Expo Go:一个 Android 和 iOS 应用程序,可以直接在你的设备上运行你的项目(无需编译和签名原生应用程序)并与你的整个团队共享。

  • Expo Snack:一个在线游乐场,允许你在浏览器中开发 React Native 应用程序。

  • Expo 应用程序服务EAS):一套深度集成的云服务,用于 Expo 和 React Native 应用程序。应用程序可以使用 EAS 在云中编译、签名并上传到商店。

Expo 附带大量可用的功能。以前,它对项目施加了限制,因为它不支持自定义原生模块。然而,这种限制不再存在。现在,Expo 支持通过 Expo 开发构建添加自定义原生代码和自定义原生代码(Android/Xcode 项目)。要使用任何自定义原生代码,您可以创建开发构建和配置插件。

由于 Expo 对没有移动开发技能的新开发者很有用,我们将用它来设置我们的第一个 React Native 项目。

安装和使用 Expo 命令行工具

Expo 命令行工具负责创建项目所需的所有脚手架,以便运行基本的 React Native 应用程序。此外,Expo 还有一些其他工具,使在开发期间运行我们的应用程序变得简单直接。但首先,我们需要设置环境和项目:

  1. 在我们能够使用 Expo 之前,我们需要安装Node.jsGitWatchman。Watchman 是一个用于监视项目中文件的工具,当文件发生变化时,它可以触发诸如重建等操作。所有必需的工具和详细信息都可以在这里找到:docs.expo.dev/get-started/installation/#requirements

  2. 一旦安装完成,我们可以通过运行以下命令来启动新项目:

    npx create-expo-app --template 
    
  3. 接下来,CLI 将询问您关于您未来项目的问题。您应该在终端看到类似以下内容:

    ? Choose a template: ' - Use arrow-keys. Return to submit.
        Blank
    ❯   Blank (TypeScript) - blank app with TypeScript enabled
        Navigation (TypeScript)
        Blank (Bare) 
    

    我们将选择Blank (TypeScript)选项。

  4. 接下来,进程将询问您项目名称:

    ? What is your app named? ' my-project 
    

    让我们称它为my-project

  5. 安装所有依赖项后,Expo 将为您完成项目的创建:

    Your project is ready! 
    

现在我们已经创建了一个空白 React Native 项目,您将学习如何在您的计算机上启动 Expo 开发服务器并在您的设备之一上查看应用程序。

在手机上查看您的应用

为了在开发期间在您的设备上查看 React Native 项目,我们需要启动 Expo 开发服务器:

  1. 在命令行终端中,请确保您位于项目目录中:

    cd path/to/my-project 
    
  2. 一旦您进入my-project目录,您可以通过运行以下命令来启动开发服务器:

    npm start 
    
  3. 这将在终端显示有关开发服务器的一些信息:

    ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
    █ ▄▄▄▄▄ █▄▀▀▄▄▀▀█ █ ▄▄▄▄▄ █
    █ █   █ ███▄█  ▀▄▄█ █   █ █
    █ █▄▄▄█  █▄▀▄▀ ██▀█ █▄▄▄█ █
    █▄▄▄▄▄▄▄█ █ ▀▄▀ ▀ █▄▄▄▄▄▄▄█
    █ ▄▀▄▄▀▄▀█ ▄▄▀▀█▀ █▄█▀█▀▀▄█
    █ █▄█▀▀▄▀▄▀  ▀█▄▄ ▀███▄▀▀ █
    █ █▄ ▀█▄▄▀▄█▄▄▀▄ █ ▄▀▀█▀ ██
    █ ▄ ▀▄▀▄▄ █▄ ▄▄▀ ▄  ██▄▀  █
    █▄██▄▄█▄▄ █ ▀▀  █ ▄▄▄  ▄▀▄█
    █ ▄▄▄▄▄ ██ █▄▀  █ █▄█ ██▀▄█
    █ █   █ █ ███▄██▄ ▄  ▄ █  █
    █ █▄▄▄█ █▀█▄█▄█  ▄█▀▀▄█   █
    █▄▄▄▄▄▄▄█▄▄██▄▄▄▄▄▄█▄▄███▄█
    ' Metro waiting on exp://192.168.1.15:8081
    ' Scan the QR code above with Expo Go (Android) or the Camera app (iOS)
    ' Using Expo Go
    ' Press s │ switch to development build
    ' Press a │ open Android
    ' Press i │ open iOS simulator
    ' Press w │ open web
    	' Press j │ open debugger
    ' Press r │ reload app
    ' Press m │ toggle menu
    ' Press o │ open project code in your editor
    ' Press ? │ show all commands 
    
  4. 为了在我们的设备上查看应用程序,我们需要安装Expo Go应用程序。您可以在 Android 设备的 Play Store 或 iOS 设备的 App Store 中找到它。一旦您安装了 Expo,您可以使用设备上的原生相机扫描二维码:图 16.2 – Expo Go 应用

    图 17.1:Expo Go 应用

    如果您登录到 Expo Go 和 Expo CLI,您将能够运行应用程序而无需二维码。在图 17.1中,您可以查看为my-project打开的开发会话;如果您点击它,应用程序将运行。

  5. 一旦扫描二维码或您在 Expo Go 上打开的会话被点击,您将在终端中注意到新的日志和新的连接设备:

    iOS Bundling complete 205ms 
    
  6. 现在您应该能看到应用程序正在运行:

图 17.2:在 Expo Go 中打开的应用程序

到目前为止,您已经准备好开始开发您的应用程序了。实际上,如果您同时想要使用多个物理设备,您可以重复此过程。这个 Expo 设置的最好部分是我们可以在计算机上对代码进行更新时,在物理设备上免费获得实时重新加载。现在让我们尝试一下,以确保一切按预期工作:

  1. 让我们打开 my-project 文件夹内的 App.ts 文件。在那里,您将看到 App 组件:

    export default function App() {
      return (
        <View style={styles.container}>
          <Text>Open up App.tsx to start working on your app!</Text>
          <StatusBar style="auto" />
        </View>
      );
    } 
    
  2. 现在让我们进行一个小小的样式更改,使字体加粗:

    export default function App() {
      return (
        <View style={styles.container}>
          <Text style={styles.text}>
            Open up App.tsx to start working on your app!
          </Text>
          <StatusBar style="auto" />
        </View>
      );
    }
    const styles = StyleSheet.create({
      container: {
        flex: 1,
        backgroundColor: "#fff",
        alignItems: "center",
        justifyContent: "center",
      },
    **text****: {** **fontWeight****:** **"bold"** **},**
    }); 
    
  3. 我们添加了一个名为 text 的新样式,并将其应用于 Text 组件。如果您保存文件并返回到您的设备,您将立即看到更改被应用:

图 17.3:更新了文本样式的应用程序

现在您能够在物理设备上本地运行您的应用程序了,是时候看看如何使用 Expo Snack 服务在多种虚拟设备模拟器上运行您的 React Native 应用程序了。

在 Expo Snack 中查看您的应用程序

Expo 提供的 Snack 服务是您 React Native 代码的游乐场。它允许您像在本地计算机上一样组织您的 React Native 项目文件。如果您最终组合出值得构建的东西,您可以导出您的 Snack。您还可以创建一个 Expo 账户并保存您的 Snacks 以继续工作或与他人分享。您可以通过此链接找到 Expo Snack:snack.expo.dev/.

我们可以在 Expo Snack 中从头开始创建一个 React Native 应用程序,它将被存储在 Expo 账户中,或者我们可以从 Git 仓库导入现有项目。导入仓库的好处是,当您向 Git 推送更改时,您的 Snack 也会更新。本章中我们工作的示例的 Git URL 看起来是这样的:github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter17/my-project.

我们可以在 Snack 项目菜单中点击 导入 Git 仓库 按钮,并粘贴此 URL:

图 17.4:将 Git 仓库导入到 Expo Snack

一旦导入仓库并保存 Snack,您将获得一个更新的 Snack URL,该 URL 反映了 Git 仓库的位置。例如,本章中的 Snack URL 看起来是这样的:https://snack.expo.dev/@sakhnyuk/2a2429.

如果您打开此 URL,Snack 界面将加载,您可以在运行之前对代码进行更改以进行测试。Snack 的主要优势是能够在虚拟化设备上轻松运行。运行应用程序在虚拟设备上的控件可以在 UI 的右侧找到,看起来像这样:

图 17.5:Expo Snack 模拟器

在手机图片上方的顶部控制栏用于选择要模拟的设备类型:AndroidiOSWeb点击播放按钮将启动选定的虚拟设备。在您的设备上运行按钮允许您使用二维码方法在 Expo Go 中运行应用。

这是我们的应用在虚拟 iOS 设备上的样子:

图片

图 17.6:Expo Snack iOS 模拟器

然后这是我们的应用在虚拟安卓设备上的样子:

图片

图 17.7:Expo Snack 安卓模拟器

此应用仅显示文本并对它应用一些样式,因此在不同的平台上看起来几乎相同。随着我们在这本书的 React Native 章节中继续前进,你会看到 Snack 这样的工具在比较两个平台以及理解它们之间的差异方面是多么有用。

摘要

在本章中,你学习了如何使用 Expo 命令行工具启动 React Native 项目。首先,你学习了如何安装 Expo 工具。然后,你学习了如何初始化一个新的 React Native 项目。接下来,你启动了 Expo 开发服务器,并了解了开发服务器 UI 的各个部分。

特别是,你学习了如何将开发服务器与任何你想要测试应用的设备上的 Expo 应用连接起来。Expo 还提供了 Snack 服务,它允许我们实验代码片段或整个 Git 仓库。你学习了如何导入仓库并在虚拟 iOS 和 Android 设备上运行它。

在下一章中,我们将探讨如何在我们的 React Native 应用中构建响应式布局。

第十八章:使用 Flexbox 构建响应式布局

在本章中,你将感受到在移动设备屏幕上布局组件的感觉。幸运的是,React Native 填充了许多你过去可能用于在 Web 应用程序中实现页面布局的 CSS 属性。

在你深入实现布局之前,你将简要了解 Flexbox 以及如何在 React Native 应用程序中使用 CSS 样式属性:它并不完全像你习惯的常规 CSS 样式表那样。然后,你将使用 Flexbox 实现几个 React Native 布局。

在本章中,我们将涵盖以下主题:

  • 介绍 Flexbox

  • 介绍 React Native 样式

  • 使用 Styled Components 库

  • 构建 Flexbox 布局

技术要求

你可以在 GitHub 上找到本章中存在的代码文件,链接为github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter18

介绍 Flexbox

在引入灵活的盒模型布局模型到 CSS 之前,用于构建布局的各种方法都是复杂的,并且容易出错。例如,我们使用了浮动,它最初是为了让文本围绕图像而设计的,用于表格布局。Flexbox通过抽象出许多你通常需要提供的属性来解决这一问题,以便使布局工作。

从本质上讲,Flexbox 模型可能对你来说听起来就是这样:一个灵活的盒模型。这就是 Flexbox 的美丽之处:它的简单性。你有一个充当容器的盒子,你在这个盒子内有子元素。容器和子元素在屏幕上的渲染方式都是灵活的,如下所示:

图片 1

图 18.1:Flexbox 元素

Flexbox 容器有一个方向,要么是列(上/下),要么是行(左/右)。这实际上在我最初学习 Flexbox 时让我感到困惑;我的大脑拒绝相信行是从左到右并排组织的。行是堆叠在一起的!要记住的关键点是,它是盒子伸缩的方向,而不是盒子在屏幕上放置的方向。

对于 Flexbox 概念的更深入探讨,请参阅css-tricks.com/snippets/css/a-guide-to-Flexbox

现在我们已经从高层次上了解了 Flexbox 布局的基础知识,是时候学习 React Native 应用程序中的样式是如何工作的了。

介绍 React Native 样式

是时候实现你的第一个 React Native 应用程序了,超越由Expo生成的样板代码。我想确保你在开始下一节实现 Flexbox 布局之前,对使用 React Native 样式表感到舒适。

这就是 React Native 样式表的样子:

import { Platform, StyleSheet, StatusBar } from "react-native";
export default StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: "center",
    alignItems: "center",
    backgroundColor: "ghostwhite",
    ...Platform.select({
      ios: { paddingTop: 20 },
      android: { paddingTop: StatusBar.currentHeight },
    }),
  },
  box: {
    width: 100,
    height: 100,
    justifyContent: "center",
    alignItems: "center",
    backgroundColor: "lightgray",
  },
  boxText: {
    color: "darkslategray",
    fontWeight: "bold",
  },
}); 

这是一个 JavaScript 模块,而不是 CSS 模块。如果你想声明 React Native 样式,你需要使用纯对象。然后,你调用 StyleSheet.create() 并从样式模块导出它。请注意,样式名称与 web CSS 非常相似,只是它们是驼峰式命名的;例如,justifyContent 而不是 justify-content

如你所见,这个样式表有三个样式:containerboxboxText。在 container 样式中,有一个对 Platform.select() 的调用:

...Platform.select({
ios: { paddingTop: 20 },
android: { paddingTop: StatusBar.currentHeight }
}) 

这个函数将根据移动设备的平台返回不同的样式。在这里,你正在处理顶级 container 视图的顶部填充。你可能会在大多数应用中使用这段代码来确保你的 React 组件不会渲染在设备的状态栏下方。根据平台的不同,填充需要不同的值。如果是 iOS,paddingTop20。如果是 Android,paddingTop 将是 StatusBar.currentHeight 的值。

之前的 Platform.select() 代码是一个需要实现平台差异解决方案的例子。例如,如果 StatusBar.currentHeight 在 iOS 和 Android 上都可用,你就不需要调用 Platform.select()

让我们看看这些样式是如何导入并应用到 React Native 组件中的:

import React from "react";
import { Text, View } from "react-native";
import styles from "./styles";
export default function App() {
  return (
    <View style={styles.container}>
      <View style={styles.box}>
        <Text style={styles.boxText}>I'm in a box</Text>
      </View>
    </View>
  );
} 

样式是通过 style 属性分配给每个组件的。你正在尝试渲染一个在屏幕中间带有文本的盒子。让我们确保它看起来像我们预期的那样。

图片 2

图 18.2:屏幕中间的盒子

我们已经找到了如何使用内置模块应用样式的办法,但定义样式的方法不止一种。我们还有在 React Native 中编写 CSS 的选项。让我们快速了解一下。

使用 Styled Components 库

Styled Components 是一个 CSS-in-JS 库,它使用纯 CSS 来设置 React Native 组件的样式。使用这种方法,你不需要通过对象定义样式类并提供样式属性。CSS 本身是通过 styled-components 提供的标签模板字面量来确定的。

要安装 styled-components,在你的项目中运行以下命令:

npm install --save styled-components 

让我们尝试重写 介绍 React Native 样式 部分的组件。这是我们的 Box 组件的样子:

import styled from "styled-components/native";
const Box = styled.View'
  width: 100px;
  height: 100px;
  justify-content: center;
  align-items: center;
  background-color: lightgray;
';
const BoxText = styled.Text'
  color: darkslategray;
  font-weight: bold;
'; 

在这个例子中,我们有两个组件,BoxBoxText。现在我们可以像平常一样使用它们,但不需要任何其他额外的样式属性:

const App = () => {
  return (
    <Box>
      <BoxText>I'm in a box</BoxText>
    </Box>
  );
}; 

在接下来的章节中,我将使用 StyleSheet 对象,但为了避免性能问题,我会避免使用 styled-components。如果你想了解更多关于 styled-components 的信息,你可以在这里阅读更多:styled-components.com/

完美!现在你已经了解了如何在 React Native 元素上设置样式,让我们使用 Flexbox 开始创建一些屏幕布局。

构建 Flexbox 布局

在本节中,你将了解你可以在你的 React Native 应用程序中使用的一些潜在布局。我不想强调一个布局比另一个布局更好。相反,我会向你展示 Flexbox 布局模型在移动屏幕上的强大之处,这样你就可以设计最适合你应用程序的布局。

简单的三列布局

首先,让我们实现一个简单的布局,包含三个在列方向(从上到下)上伸缩的部分。我们首先看看我们想要达到的结果。

图片 3

图 18.3:简单三列布局

在这个例子中,想法是这样的:你给三个屏幕部分添加样式和标签,使它们突出。换句话说,在真实的应用程序中,这些组件可能不会有任何样式,因为它们是用来在屏幕上排列其他组件的。

现在,让我们看看创建这个屏幕布局所使用的组件:

import React from "react";
import { Text, View } from "react-native";
import styles from "./styles";
export default function App() {
  return (
    <View style={styles.container}>
      <View style={styles.box}>
        <Text style={styles.boxText}>#1</Text>
      </View>
      <View style={styles.box}>
        <Text style={styles.boxText}>#2</Text>
      </View>
      <View style={styles.box}>
        <Text style={styles.boxText}>#3</Text>
      </View>
    </View>
  );
} 

容器视图(最外层的<View>组件)是列,子视图是行。<Text>组件用于标记每一行。从 HTML 元素的角度来看,<View>类似于<div>元素,而<Text>类似于<p>元素。

也许这个例子可以被称为三行布局,因为它有三行。但与此同时,这三个布局部分正在它们所在的列方向上伸缩。使用对你最有概念意义的命名约定。

现在,让我们看看创建这个布局所使用的样式:

import { Platform, StyleSheet, StatusBar } from "react-native";
export default StyleSheet.create({
  container: {
    flex: 1,
    flexDirection: "column",
    alignItems: "center",
    justifyContent: "space-around",
    backgroundColor: "ghostwhite",
    ...Platform.select({
      ios: { paddingTop: 20 },
      android: { paddingTop: StatusBar.currentHeight }
    })
  },
  box: {
    width: 300,
    height: 100,
    justifyContent: "center",
    alignItems: "center",
    backgroundColor: "lightgray",
    borderWidth: 1,
    borderStyle: "dashed",
    borderColor: "darkslategray"
  },
  boxText: {
    color: "darkslategray",
    fontWeight: "bold"
  }
}); 

containerflexflexDirection属性使得行的布局可以从上到下流动。alignItemsjustifyContent属性分别将子元素对齐到容器的中心,并在它们周围添加空间。

让我们看看当您将设备从纵向旋转到横向时,这个布局看起来如何:

图片 4

图 18.4:横幅方向

Flexbox 自动为你解决了布局问题。然而,你可以稍微改进一下。例如,横幅方向现在左右两侧有很多浪费的空间。你可以为你要渲染的盒子创建自己的抽象。在下一节中,我们将改进这个布局。

改进的三列布局

有几件事情我认为你可以从上一个例子中改进。让我们调整样式,使得 Flexbox 的子元素可以拉伸以利用可用空间。你还记得在最后一个例子中,当您将设备从纵向旋转到横向时吗?有很多浪费的空间。如果组件能自动调整自己会很好。下面是新样式模块的样子:

import { Platform, StyleSheet, StatusBar } from "react-native";
export default StyleSheet.create({
  container: {
    flex: 1,
    flexDirection: "column",
    backgroundColor: "ghostwhite",
    justifyContent: "space-around",
    ...Platform.select({
      ios: { paddingTop: 20 },
      android: { paddingTop: StatusBar.currentHeight },
    }),
  },
  box: {
    height: 100,
    justifyContent: "center",
    alignSelf: "stretch",
    alignItems: "center",
    backgroundColor: "lightgray",
    borderWidth: 1,
    borderStyle: "dashed",
    borderColor: "darkslategray",
  },
  boxText: {
    color: "darkslategray",
    fontWeight: "bold",
  },
}); 

关键的改变在于 alignSelf 属性。这告诉具有 box 样式的元素根据其容器的 flexDirection 改变其 widthheight(取决于容器的 flexDirection),以填充空间。此外,box 样式不再定义 width 属性,因为这将现在实时计算。

下面是在纵向模式下的部分外观:

图片 5

图 18.5:纵向布局中的改进三列布局

现在,每个部分都占据了屏幕的全宽,这正是你想要的。实际上,浪费空间的问题在横向布局中更为普遍,所以让我们旋转设备,看看这些部分现在会发生什么。

图片 6

图 18.6:横向布局中的改进三列布局

现在布局正在利用屏幕的整个宽度,无论方向如何。最后,让我们实现一个可以由 App.js 使用的正确 Box 组件,而不是在布局中放置重复的样式属性。以下是 Box 组件的外观:

import React from "react";
import { PropTypes } from "prop-types";
import { View, Text } from "react-native";
import styles from "./styles";
export default function Box({ children }) {
  return (
    <View style={styles.box}>
      <Text style={styles.boxText}>{children}</Text>
    </View>
  );
}
Box.propTypes = {
  children: PropTypes.node.isRequired,
}; 

现在,你已经有了良好的布局基础。接下来,你将学习关于其他方向的弹性:从左到右。

灵活的行

在本节中,你将学习如何使屏幕布局部分从顶部延伸到底部。要做到这一点,你需要一个 灵活的行。以下是该屏幕的样式:

import { Platform, StyleSheet, StatusBar } from "react-native";
export default StyleSheet.create({
  container: {
    flex: 1,
    flexDirection: "row",
    backgroundColor: "ghostwhite",
    alignItems: "center",
    justifyContent: "space-around",
    ...Platform.select({
      ios: { paddingTop: 20 },
      android: { paddingTop: StatusBar.currentHeight },
    }),
  },
  box: {
    width: 100,
    justifyContent: "center",
    alignSelf: "stretch",
    alignItems: "center",
    backgroundColor: "lightgray",
    borderWidth: 1,
    borderStyle: "dashed",
    borderColor: "darkslategray",
  },
  boxText: {
    color: "darkslategray",
    fontWeight: "bold",
  },
}); 

下面是 App 组件,使用你在上一节中实现的相同的 Box 组件:

import React from "react";
import { Text, View, StatusBar } from "react-native";
import styles from "./styles";
import Box from "./Box";
export default function App() {
  return (
    <View style={styles.container}>
      <Box>#1</Box>
      <Box>#2</Box>
    </View>
  );
} 

下面是在纵向模式下的屏幕结果:

图片 7

图 18.7:纵向布局中的灵活行

由于 alignSelf 属性,两列从屏幕顶部延伸到底部,实际上并没有指定拉伸的方向。两个 Box 组件从顶部到底部拉伸,因为它们以 flex 行 的形式显示。注意这两个部分之间的间距是如何从左到右变化的?这是因为容器具有 flexDirection 属性,其值为 row

现在,让我们看看这个弹性方向如何影响屏幕旋转到横向布局时的布局。

图片 8

图 18.8:横向布局中的灵活行

由于 FlexboxjustifyContent 样式属性值为 space-around,空间会按比例添加到左侧、右侧以及部分之间。在下一节中,你将了解灵活的网格。

灵活的网格

有时,你需要一个像网格一样流动的屏幕布局。例如,如果你有多个宽度相同、高度相同的部分,但你不确定这些部分将渲染多少个?Flexbox 使得构建从左到右流动直到屏幕末尾的行变得容易。然后,它将自动在下一行从左到右渲染元素。

下面是一个纵向模式下的布局示例:

图片 9

图 18.9:纵向布局的灵活网格

这种方法的优点是您不需要事先知道给定行中有多少列。每个子组件的尺寸决定了什么可以放入给定行中。

要查看创建此布局所使用的样式,您可以点击此链接:github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter18/flexible-grids/styles.ts.

这是渲染每个部分的App组件:

import React from "react";
import { View, StatusBar } from "react-native";
import styles from "./styles";
import Box from "./Box";
const boxes = new Array(10).fill(null).map((v, i) => i + 1);
export default function App() {
  return (
    <View style={styles.container}>
      <StatusBar hidden={false} />
      {boxes.map((i) => (
        <Box key={i}>#{i}</Box>
      ))}
    </View>
  );
} 

最后,让我们确保横向布局与这个布局兼容:

图片 10

图 17.10:横向布局的灵活网格

您可能已经注意到右侧有一些多余的空间。记住,这些部分之所以在这个书中可见,是因为我们希望它们可见。在实际应用中,它们只是对其他 React Native 组件进行分组。然而,如果屏幕右侧的空间成为问题,您可以尝试调整子组件的边距和宽度。

现在您已经了解了灵活网格的工作原理,我们将接下来查看灵活的行和列。

灵活的行和列

让我们学习如何组合行和列来为您的应用创建一个复杂的布局。例如,有时您需要将列嵌套在行内或行嵌套在列内的能力。要查看嵌套列在行内的应用的App组件,您可以点击此链接:github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter18/flexible-rows-and-columns/App.tsx.

您已经为布局组件(<Row><Column>)和内容组件(<Box>)创建了抽象。让我们看看这个屏幕看起来像什么:

图片 11

图 18.11:灵活的行和列

这个布局可能看起来很熟悉,因为您在灵活网格部分已经做过。与图 18.9相比,关键的区别在于这些内容部分的顺序。

例如,#2不会放在**#1的右边,它会放在下面。这是因为我们将#1#2放在了<Column>中。#3#4**也是同样的情况。这两个列被放置在同一行中。然后,下一行开始,以此类推。

这只是通过嵌套行 Flexbox 和列 Flexbox 所能实现的可能布局之一。现在让我们看看Row组件:

import React from "react";
import PropTypes from "prop-types";
import { View, Text } from "react-native";
import styles from "./styles";
export default function Box({ children }) {
  return (
    <View style={styles.box}>
      <Text style={styles.boxText}>{children}</Text>
    </View>
  );
}
Box.propTypes = {
  children: PropTypes.node.isRequired,
}; 

此组件将行样式应用于<View>组件。当创建复杂布局时,App组件中的最终结果是更干净的 JSX 标记。最后,让我们看看Column组件:

import React from "react";
import PropTypes from "prop-types";
import { View } from "react-native";
import styles from "./styles";
export default function Column({ children }) {
  return <View style={styles.column}>{children}</View>;
}
Column.propTypes = {
  children: PropTypes.node.isRequired,
}; 

这看起来就像Row组件,只是应用了不同的样式。它也服务于与Row相同的目的:为其他组件中的布局提供更简单的 JSX 标记。

摘要

本章向你介绍了 React Native 中的样式。虽然你可以使用许多你熟悉的相同 CSS 样式属性,但用于 Web 应用的 CSS 样式表看起来非常不同。具体来说,它们由纯 JavaScript 对象组成。

然后,你学习了如何使用 React Native 的主要布局机制:Flexbox。这是目前大多数 Web 应用布局的首选方式,因此能够在原生应用中重用这种方法是有意义的。你创建了几个不同的布局,并看到了它们在纵向和横向方向上的外观。

在下一章中,你将开始为你的应用实现导航功能。

第十九章:屏幕间导航

本章的重点是导航 React Native 应用程序中构成屏幕之间的导航。在原生应用中的导航与在网页应用中的导航略有不同:主要是因为用户没有意识到任何 URL 的概念。在 React Native 的早期版本中,有一些原始的导航组件,你可以使用它们来控制屏幕间的导航。这些组件存在一些挑战,导致完成基本导航任务需要更多的代码。例如,初始导航组件,如 NavigatorNavigatorIOS,实现起来复杂且功能不足,导致性能问题和跨平台的不一致性。

更新版本的 React Native 鼓励你使用 react-navigation 包,这将是本章的重点,尽管还有其他几个选项。你将学习导航基础知识、向屏幕传递参数、更改标题内容、使用标签和抽屉导航以及使用导航处理状态。我们还将探讨一种现代导航方法,称为基于文件的导航。

本章我们将涵盖以下主题:

  • 导航的基本知识

  • 路由参数

  • 导航标题

  • 标签和抽屉导航

  • 基于文件的导航

技术要求

你可以在 GitHub 上找到本章的代码文件,链接为 github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter19

导航的基本知识

在 React Native 中,导航至关重要,因为它管理着应用中不同屏幕之间的过渡。它通过逻辑组织应用流程来提高用户体验,使用户能够直观地了解如何访问功能和信息。有效的导航使应用感觉快速且响应灵敏,减少用户挫败感并提高用户参与度。它还支持应用架构,通过明确定义组件之间的链接和交互,使应用更容易扩展和维护。没有适当的导航,应用可能会变得令人困惑且难以使用,这会严重影响其成功和用户留存。本节将通过创建一个小型应用来引导你设置应用中的导航,在这个小应用中你可以导航到不同的屏幕。

让我们从使用 react-navigation 包从一个页面跳转到另一个页面的基本操作开始。

在开始之前,你应该将 react-navigation 包安装到一个新项目中,以及一些与示例相关的附加依赖:

npm install @react-navigation/native 

然后,使用 expo 安装本地依赖:

npx expo install react-native-screens react-native-safe-area-context 

上一节的安装步骤将适用于本章的每个示例,但我们还需要添加一个与堆栈导航相关的包:

npm install @react-navigation/native-stack 

现在,我们已经准备好开发导航。下面是 App 组件的样貌:

import Home from "./Home";
import Settings from "./Settings";
const Stack = createNativeStackNavigator<RootStackParamList>();
export default function App() {
  return (
    <NavigationContainer>
      <Stack.Navigator>
        <Stack.Screen name="Home" component={Home} />
        <Stack.Screen name="Settings" component={Settings} />
      </Stack.Navigator>
    </NavigationContainer>
  );
} 

createNativeStackNavigator()是一个设置导航的函数。它返回一个对象,包含两个属性,即ScreenNavigator组件,它们用于配置堆栈导航器。

此函数的第一个参数映射到可以导航的屏幕组件。第二个参数用于更通用的导航选项:在这种情况下,你正在告诉导航器主页应该是默认的屏幕组件。<NavigationContainer>组件是必要的,这样屏幕组件就能获得它们需要的所有导航属性。

这是Home组件的外观:

type Props = NativeStackScreenProps<RootStackParamList>;
export default function Home({ navigation }: Props) {
  return (
    <View style={styles.container}>
      <StatusBar barStyle="dark-content" />
      <Text>Home Screen</Text>
      <Button
        title="Settings"
        onPress={() => navigation.navigate("Settings")}
      />
    </View>
  );
} 

这是一个典型的功能 React 组件。你在这里可以使用基于类的组件,但不需要,因为没有状态或生命周期方法。它渲染一个应用了容器样式的View组件。

这后面跟着一个Text组件,用于标记screen,然后是一个Button组件。screen可以是任何你想要的东西:它只是一个普通的 React Native 组件。导航组件为你处理路由和屏幕之间的转换。

此按钮的onPress处理程序在点击时导航到设置屏幕。这是通过调用navigation.navigate('Settings')完成的。导航属性通过react-navigation传递给你的screen组件,并包含你需要的所有路由功能。与在 React web 应用中处理 URL 相比,这里你调用导航器 API 函数,并传递屏幕名称。

要在导航中获得类型安全的环境,我们需要定义一个名为RootStackParamList的类型,它包含有关我们路由的所有信息。我们使用它和NativeStackScreenProps一起定义路由Props。这是RootStackParamList的外观:

export type RootStackParamList = {
  Home: undefined;
  Settings: undefined;
}; 

我们为每个路由传递 undefined,因为我们没有在路由上设置任何参数。因此,我们只能用SettingsHome调用navigation.navigate()

让我们看看Settings组件:

type Props = NativeStackScreenProps<RootStackParamList>;
export default function Settings({ navigation }: Props) {
  return (
    <View style={styles.container}>
      <StatusBar barStyle="dark-content" />
      <Text>Settings Screen</Text>
      <Button title="Home" onPress={() => navigation.navigate("Home")} />
    </View>
  );
} 

此组件与Home组件类似,只是文本不同,当按钮被点击时,你将被带回到主页

这就是主页的外观:

图片 1

图 19.1:主页

如果你点击设置按钮,你将被带到设置屏幕,其外观如下:

图片 2

图 19.2:设置屏幕

这个屏幕看起来几乎与Home 屏幕完全相同。它有不同的文本和不同的按钮,点击该按钮将带你返回到Home 屏幕。然而,还有另一种返回Home 屏幕的方法。看看屏幕顶部,你会注意到一个白色的导航栏。在导航栏的左侧,有一个返回箭头。这就像网页浏览器中的返回按钮一样,会带你回到上一个屏幕。react-navigation 的好处是它会为你渲染这个导航栏。

在这个导航栏设置好之后,你不必担心你的布局样式如何影响状态栏。你只需要担心你每个屏幕的布局。

如果你在这个 Android 应用上运行,你会在导航栏中看到相同的返回按钮。但你也可以使用大多数 Android 设备上应用外部的标准返回按钮。

在下一节中,你将学习如何向你的路由传递参数。

路由参数

当你开发 React Web 应用时,一些路由中包含动态数据。例如,你可以链接到一个详情页面,在该 URL 中,你将有一个某种标识符。组件将拥有渲染特定详细信息所需的内容。在react-navigation中,也存在同样的概念。你不仅可以指定你想要导航到的屏幕名称,还可以传递额外的数据。

让我们看看路由参数的实际应用。

我们将从App组件开始:

const Stack = createNativeStackNavigator<RootStackParamList>();
export default function App() {
  return (
    <NavigationContainer>
      <Stack.Navigator>
        <Stack.Screen name="Home" component={Home} />
        <Stack.Screen name="Details" component={Details} />
      </Stack.Navigator>
    </NavigationContainer>
  );
} 

这看起来就像导航基础部分中的例子,只不过没有Settings页面,而是有一个Details页面。这是你想要动态传递数据以渲染适当信息的页面。

要为我们的路由启用 TypeScript,需要定义RootStackParamList

export type RootStackParamList = {
  Home: undefined;
  Details: { title: string };
}; 

接下来,让我们看看Home屏幕组件:

type Props = NativeStackScreenProps<RootStackParamList, "Home">;
export default function Home({ navigation }: Props) {
  return (
    <View style={styles.container}>
      <StatusBar barStyle="dark-content" />
      <Text>Home Screen</Text>
      <Button
        title="First Item"
        onPress={() => navigation.navigate("Details", { title: "First Item" })}
      />
      <Button
        title="Second Item"
        onPress={() => navigation.navigate("Details", { title: "Second Item" })}
      />
      <Button
        title="Third Item"
        onPress={() => navigation.navigate("Details", { title: "Third Item" })}
      />
    </View>
  );
} 

Home屏幕有三个Button组件,每个都导航到Details屏幕。注意,在navigation.navigate()调用中,除了屏幕名称外,每个都有一个第二个参数。这些参数是包含特定数据的对象,这些数据被传递到Details屏幕。

接下来,让我们看看Details屏幕,看看它是如何消费这些路由参数的:

type Props = NativeStackScreenProps<RootStackParamList, "Details">;
export default function ({ route }: Props) {
  const { title } = route.params;
  return (
    <View style={styles.container}>
      <StatusBar barStyle="dark-content" />
      <Text>{title}</Text>
    </View>
  );
} 

尽管这个例子只传递了一个title参数,但你可以向屏幕传递你需要的任意多个参数。你可以使用路由属性paramsvalue来访问这些参数。

这是渲染后的Home 屏幕的样子:

图片 3

图 19.3:Home 屏幕

如果你点击第一个项目按钮,你将被带到使用路由参数数据渲染的Details屏幕:

图片 4

图 19.4:Details 屏幕

你可以点击导航栏中的返回按钮回到主屏幕。如果你点击主屏幕上的其他任何按钮,你将返回到带有更新数据的详情屏幕。路由参数是必要的,以避免不得不编写重复的组件。你可以将向navigator.navigate()传递参数视为向 React 组件传递 props。

在下一节中,你将学习如何用内容填充导航部分标题。

导航标题

本章中你创建的导航栏到目前为止相当简单。这是因为你没有配置它们执行任何操作,所以react-navigation只会渲染一个带有返回按钮的普通栏。你创建的每个屏幕组件都可以配置特定的导航标题内容。

让我们基于在Route参数部分讨论的例子进行扩展,该例子使用了按钮来导航到详情页面。

App组件有重大更新,让我们看看它:

const Stack = createNativeStackNavigator<RoutesParams>();
export default function App() {
  return (
    <NavigationContainer>
      <Stack.Navigator>
        <Stack.Screen name="Home" component={Home} />
        <Stack.Screen
          name="Details"
          component={Details}
          options={({ route }) => ({
            headerRight: () => {
              return (
                <Button
                  title="Buy"
                  onPress={() => {}}
                  disabled={route.params.stock === 0}
                />
              );
            },
          })}
        />
      </Stack.Navigator>
    </NavigationContainer>
  );
} 

Screen组件接受options属性作为对象或函数,以提供额外的屏幕属性。

使用headerRight选项在导航栏的右侧添加一个Button组件。这就是stock参数发挥作用的地方。如果这个值是0,因为stock中没有内容,你想要禁用购买按钮。

在我们的例子中,我们传递options作为一个函数,并读取stock屏幕参数来禁用按钮。这是向Screen组件传递选项的几种方法之一。我们将应用另一种方法到Details组件。

要了解股票 props 是如何传递的,请看这里的Home组件:

type Props = NativeStackScreenProps<RoutesParams, "Home">;
export default function Home({ navigation }: Props) {
  return (
    <View style={styles.container}>
      <StatusBar barStyle="dark-content" />
      <Button
        title="First Item"
        onPress={() =>
          navigation.navigate("Details", {
            title: "First Item",
            content: "First Item Content",
            stock: 1,
          })
        }
      />
      ...
    </View>
  );
} 

首先要注意的是,每个按钮都向Details组件传递了更多的路由参数:contentstock。你很快就会明白原因。

接下来,让我们看看Details组件:

type Props = NativeStackScreenProps<RoutesParams, "Details">;
export default function Details({ route, navigation }: Props) {
  const { content, title } = route.params;
  React.useEffect(() => {
    navigation.setOptions({ title });
  }, []);
  return (
    <View style={styles.container}>
      <StatusBar barStyle="dark-content" />
      <Text>{content}</Text>
    </View>
  );
} 

这次,Details组件渲染了route参数的内容。与App组件一样,我们向屏幕添加了额外的选项。在这种情况下,我们使用navigation.setOptions()方法更新screen选项。为了自定义标题,我们还可以通过App组件向该屏幕添加一个标题。

让我们看看所有这些是如何工作的,从主屏幕开始:

图片 5

图 19.5:主屏幕

现在导航栏中已经有了标题文本,这是通过Screen组件中的name属性设置的。

接下来,尝试按下第一个项目按钮:

图片 6

图 19.6:第一个项目屏幕

导航栏中的标题是基于传递给Details组件的title参数设置的,使用navigation.setOptions()方法。渲染在导航栏右侧的购买按钮是由放置在App组件中的Screen组件的options属性渲染的。它被启用,因为stock参数的值是1

现在,尝试返回到Home屏幕并按下第二个项目按钮:

图片 7

图 19.7:第二个项目屏幕

标题和页面内容都反映了传递给Details的新参数值,但Buy按钮也是如此。它处于禁用状态,因为库存参数值为0,这意味着不能购买。

现在你已经学会了如何使用导航标题,在下一节中,你将学习关于标签和抽屉导航的内容。

标签和抽屉导航

到目前为止,在本章中,每个示例都使用了Button组件来链接到应用中的其他屏幕。你可以使用react-navigation中的函数来自动为你创建tabdrawer导航,这些函数基于你提供的屏幕组件。

让我们创建一个示例,使用 iOS 上的底部 tab 导航和 Android 上的抽屉导航。

你不仅限于在 iOS 上使用标签导航或在 Android 上使用抽屉导航。我只是选择这两个来演示如何根据平台使用不同的导航模式。如果你更喜欢,你可以在两个平台上使用完全相同的导航模式。

对于这个示例,我们需要安装一些其他用于标签和抽屉导航器的包:

npm install @react-navigation/bottom-tabs @react-navigation/drawer 

此外,抽屉导航器需要一些原生模块。让我们来安装它们:

npx expo install react-native-gesture-handler react-native-reanimated 

然后,向babel.config.js文件添加一个插件。结果,文件应该看起来像以下这样:

module.exports = function (api) {
  api.cache(true);
  return {
    presets: ["babel-preset-expo"],
    plugins: ["react-native-reanimated/plugin"],
  };
}; 

现在,我们已经准备好继续编码。以下是App组件的样式:

const Tab = createBottomTabNavigator<Routes>();
const Drawer = createDrawerNavigator<Routes>();
export default function App() {
  return (
    <NavigationContainer>
      {Platform.OS === "ios" && (
        <Tab.Navigator>
          <Tab.Screen name="Home" component={Home} />
          <Tab.Screen name="News" component={News} />
          <Tab.Screen name="Settings" component={Settings} />
        </Tab.Navigator>
      )}
      {Platform.OS == "android" && (
        <Drawer.Navigator> 
          <Drawer.Screen name="Home" component={Home} />
          <Drawer.Screen name="News" component={News} />
          <Drawer.Screen name="Settings" component={Settings} />
        </Drawer.Navigator>
      )}
    </NavigationContainer>
  );
} 

你不是使用createNativeStackNavigator()函数来创建你的导航器,而是导入createBottomTabNavigator()createDrawerNavigator()函数:

import { createDrawerNavigator } from "@react-navigation/drawer";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"; 

然后,你使用react-native中的Platform实用工具来决定使用哪个导航器。结果,根据平台,被分配给App。每个导航器都包含NavigatorScreen组件,你可以将它们传递给你的App。为你创建并渲染的tabdrawer导航将自动生成。

接下来,让我们看看Home屏幕组件:

export default function Home() {
  return (
    <View style={styles.container}>
      <Text>Home Content</Text>
    </View>
  );
} 

NewsSettings组件与Home组件基本相同。以下是 iOS 上底部tab导航的样式:

图片 8

图 19.8:标签导航器

你的应用由三个屏幕组成,列在底部。当前屏幕被标记为活动状态,你可以点击其他标签来移动。

现在,让我们看看 Android 上的drawer布局是什么样的:

图片 9

图 19.9:抽屉导航器

要打开drawer,你需要从屏幕左侧滑动。一旦打开,你会看到按钮,它们会带你到应用的各种屏幕。

从屏幕左侧滑动打开drawer是默认模式。你可以配置drawer从任何方向滑动打开。

现在,你已经学会了如何使用 tabdrawer 导航。接下来,我们将探讨仅基于文件定义导航的方法。

基于文件的导航

在本节中,我们将讨论 Expo Router,这是一个基于文件的路由器,其工作方式与 Next.js 中的路由类似。要添加新的屏幕,你只需在 app 文件夹中添加一个新的文件。它是建立在 React Navigation 之上的,因此路由具有相同的选项和参数。

更多关于 Expo Router 的信息和细节,请查看此链接:

docs.expo.dev/routing/introduction/

要尝试它,我们将使用以下命令安装一个新的项目:

npx create-expo-app –template 

要使用准备好 Expo Router 的项目进行安装,我们只需选择 Navigation (TypeScript) 模板:

 Blank
    Blank (TypeScript)
❯   Navigation (TypeScript) - File-based routing with TypeScript enabled
    Blank (Bare) 

安装完成后,你将找到项目的 app 文件夹。这个文件夹将用于所有你的屏幕。让我们尝试复制 导航基础 部分的示例。首先,我们需要在 app 文件夹内创建 _layout.tsx 文件。这个文件作为我们 approot 层工作。它看起来是这样的:

import { Stack } from "expo-router";
export default function RootLayout() {
  return <Stack />;
} 

然后,让我们创建包含 Home 屏幕的 index.tsx 文件。与 _layout.tsx 相比,它有一些不同,让我们看看:

import { Link } from "expo-router";
export default function Home() {
  return (
    <View style={styles.container}>
      <StatusBar barStyle="dark-content" />
      <Text>Home Screen</Text>
      <Link href="/settings" asChild>
        <Button title="Settings" />
      </Link>
    </View>
  );
} 

如你所见,我们没有使用 navigation 属性。我们而是使用一个接受 href 属性的 Link 组件,就像一个网页。点击那个按钮会带我们到 Settings 屏幕。

让我们创建 settings.tsx 文件:

import { Link } from "expo-router";
export default function Settings() {
  return (
    <View style={styles.container}>
      <StatusBar barStyle="dark-content" />
      <Text>Settings Screen</Text>
      <Link href="/" asChild>
        <Button title="Home" />
      </Link>
    </View>
  );
} 

在这里,我们使用与 index.tsx 文件相同的方法,但在 Link 中,我们将 href 设置为 “/”。

这就是我们可以如此轻松地以声明式方式定义屏幕,并且屏幕之间的导航 URL 方法是即插即用的。此外,我们在这里获得的一个好处是深度链接也是即插即用的;使用这种方法,我们可以通过应用链接打开特定的屏幕。

现在,你知道如何使用基于文件的路由,这可以提高你开发移动应用的经验,尤其是在基于 URL 和链接的 Web 态度下。

摘要

在本章中,你了解到移动应用需要导航,就像 Web 应用一样。尽管它们不同,Web 应用和移动应用导航在概念上有足够的相似性,使得移动应用的路由和导航不必成为麻烦。

旧版本的 React Native 尝试提供组件来帮助管理移动应用内的导航,但它们从未真正流行起来。相反,React Native 社区主导了这个领域。一个例子是 react-navigation 库:本章的重点。

你学习了如何使用 react-navigation 进行基本导航。然后,你学习了如何在导航栏中控制 header 组件。接下来,你了解了 tabdrawer 导航组件。这两个导航组件可以根据屏幕组件自动渲染你的应用的导航按钮。你还学习了如何与基于文件的 Expo Router 一起工作。

在下一章中,你将学习如何渲染数据列表。