现实世界的-NextJS-全-
现实世界的 NextJS(全)
原文:
zh.annas-archive.org/md5/1565fa4ea031637baa80c54efb0e59bd译者:飞龙
前言
Next.js 是一个用于现代 Web 开发的可扩展和高性能的 React.js 框架。它提供了一组丰富的功能,如混合渲染、路由预取、自动图像优化和国际化等。
Next.js 是一种令人兴奋的技术,可用于多种用途。如果你(或你的公司)想创建一个电子商务平台、博客或简单的网站,通过本书,你可以学习如何在性能、用户体验或开发者满意度上不做妥协。从 Next.js 的基础开始,你将了解框架如何帮助你实现目标,通过逐步解释构建实际应用,你将意识到 Next.js 的通用性。你将学习如何为你的网站选择合适的渲染方法,如何确保其安全,以及如何将其部署到不同的提供商。我们将始终关注性能和开发者满意度。
在本书结束时,你将能够使用任何无头 CMS 或数据源,使用 Next.js 设计、构建和部署美观且现代的架构。
本书面向对象
本书面向希望通过构建可扩展和可维护的全栈应用程序来提高其 React 技能的 Web 开发者——使用现代 Web 框架 Next.js。假设读者具备 ES6+、React、Node.js 和 REST 的中级知识。
本书涵盖内容
第一章, 《Next.js 简明介绍》,作为框架的入门指南,展示了如何设置新项目、自定义其配置,以及(如果需要)如何采用 TypeScript 作为 Next.js 开发的默认编程语言。
第二章, 《探索不同的渲染策略》,深入探讨了渲染方法,教授了服务器端渲染、静态站点生成、增量静态再生等之间的区别。
第三章, 《Next.js 基础和内置组件》,对 Next.js 的路由系统以及必要的内置组件进行了全面解释,重点关注搜索引擎优化和性能。
第四章, 《在 Next.js 中组织代码库和获取数据》,介绍了一些关于组织 Next.js 项目和在服务器端和客户端获取数据的实用技巧。
第五章, 《在 Next.js 中管理本地和全局状态》,通过 React Context 和 Redux 介绍状态管理,教你如何处理本地状态(组件级别)和全局状态(应用范围)。
第六章, CSS 和内置样式方法,介绍了 Next.js 中内置的基本样式方法,如 Styled JSX 和 CSS 模块。它还展示了如何为本地开发和生产构建启用 SASS 预处理器。
第七章, 使用 UI 框架,通过介绍一些现代 UI 框架,如 TailwindCSS、Chakra UI 和 Headless UI,总结了关于样式的讨论。
第八章, 使用自定义服务器,探讨了为什么我们可能(也可能不!)需要为我们的 Next.js 应用程序使用自定义服务器。它还展示了如何将 Next.js 与 Express.js 和 Fastify(两种最流行的 Node.js 网络框架)集成。
第九章, 测试 Next.js,通过采用 Cypress 和 react-testing-library,介绍了单元测试和端到端测试的最佳实践。
第十章, 使用 SEO 和管理性能,通过介绍一些有用的技巧和窍门,深入探讨了如何提升任何 Next.js 应用程序的 SEO 和性能。
第十一章, 不同的部署平台,展示了如何根据其功能和许多其他方面选择合适的平台来托管 Next.js 应用程序。
第十二章, 管理身份验证和用户会话,描述了如何通过选择合适的身份验证提供商来安全地管理用户身份验证。它还展示了如何将 Auth0(一个流行的身份管理平台)与任何 Next.js 应用程序集成。
第十三章, 使用 Next.js 和 GraphCMS 构建电子商务网站,深入探讨了如何使用 Next.js、Chakra UI 和 GraphCMS 创建一个真实的 Next.js 电子商务平台。
第十四章, 示例项目和进一步学习的下一步行动,通过提供一些有关如何继续学习框架的宝贵建议,以及一些示例项目来实现,来结束本书,以增强对 Next.js 的信心。
要充分利用这本书
要充分利用这本书,您可以按照以下章节中显示的所有代码示例进行编写。如果您遇到错误,您可以从本书的 GitHub 仓库下载所有可工作的代码示例。

如果您正在使用本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将有助于避免与代码复制和粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件 github.com/PacktPublishing/Real-World-Next.js。如果代码有更新,它将在 GitHub 仓库中更新。
我们还提供其他来自我们丰富图书和视频目录的代码包,可在 github.com/PacktPublishing/ 找到。查看它们吧!
下载彩色图片
我们还提供了一份包含本书中使用的截图和图表的彩色图片 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781801073493_ColorImages.pdf。
static.packt-cdn.com/downloads/9781801073493_ColorImages.pdf。
使用的约定
本书使用了多种文本约定。
文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“我们将使用 Next.js 的内置getServerSideProps函数动态从 URL 中获取[name]变量,并向用户打招呼。”
代码块设置如下:
export async function getServerSideProps({ params }) { const { name } = params; return { props: { name } } }function Greet(props) { return ( <h1> Hello, {props.name}! </h1> )}export default Greet;
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
<Link href='/blog/2021-01-01/happy-new-year'> Read post </Link><Link href='/blog/2021-03-05/match-update'> Read post </Link><Link href='/blog/2021-04-23/i-love-nextjs'> Read post </Link>
任何命令行输入或输出都按以下方式编写:
echo "Hello, world!" >> ./public/index.txt
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“实际上,如果我们打开 Google Chrome 开发者工具并转到网络,我们可以选择上述端点的 HTTP 请求,并在请求头部分看到授权令牌。”
小贴士或重要注意事项
看起来像这样。
联系我们
我们读者的反馈总是受欢迎的。
一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。
勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,我们将不胜感激,如果您能向我们报告,我们将不胜感激。请访问 www.packtpub.com/support/errata 并填写表格。
盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,我们将不胜感激,如果您能提供位置地址或网站名称,我们将不胜感激。请通过电子邮件发送至 copyright@packt.com 并附上材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com。
分享您的想法
一旦您阅读了《真实世界 Next.js》,我们非常期待听到您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区都至关重要,并将帮助我们确保我们提供高质量的内容。
第一部分:Next.js 简介
在本部分,我们将介绍 Next.js 的基础知识,从它与其他框架的不同之处、其独特特性,以及如何从头开始启动一个新项目开始。
本节包括以下章节:
-
第一章, *Next.js 简介
-
第二章, 探索不同的渲染策略
-
第三章, Next.js 基础和内置组件
第一章:Next.js 简明介绍
Next.js 是一个开源的 JavaScript 网络框架,为 React 提供了一套丰富的内置功能,例如服务器端渲染、静态站点生成和增量静态再生。这些只是使 Next.js 成为既适用于企业级应用又适用于小型网站的框架的许多内置组件和插件中的少数几个。
本书旨在在构建真实世界的应用和用例(如电子商务网站和博客平台)的同时,向您展示该框架的全部潜力。您将学习 Next.js 的基础知识,如何在不同渲染策略和部署方法之间进行选择,以及如何使您的网络应用既可扩展又易于维护的技巧和方法。
在本章中,我们将涵盖以下主题:
-
Next.js 框架简介
-
将 Next.js 与其他流行的替代方案进行比较
-
Next.js 与客户端 React 之间的区别
-
默认 Next.js 项目的结构
-
如何使用 TypeScript 开发 Next.js 应用
-
如何自定义 Babel 和 webpack 配置
技术要求
要开始使用 Next.js,您需要在您的机器上安装一些依赖项。
首先,您需要安装Node.js和npm。如果您需要安装它们的详细指南,请参阅这篇博客文章:www.nodejsdesignpatterns.com/blog/5-ways-to-install-node-js。
如果您不想在本地机器上安装 Node.js,一些在线平台将允许您免费使用在线 IDE 跟随本书中的代码示例,例如codesandbox.io和 https://repl.it。
一旦您安装了 Node.js 和 npm(或者您正在使用在线环境),您只需遵循本书每个部分显示的说明,使用 npm 安装所需的项目特定依赖项。
您可以在以下 GitHub 仓库中找到完整的代码示例:github.com/PacktPublishing/Real-World-Next.js。请随意分支、克隆和编辑此仓库,以进行任何 Next.js 的实验。
介绍 Next.js
在过去的几年里,网络开发发生了很大的变化。在现代 JavaScript 框架出现之前,创建动态网络应用非常复杂,需要许多不同的库和配置才能按预期工作。
Angular、React、Vue 以及其他框架使网络发展非常迅速,并为前端网络开发带来了许多非常创新的想法。
React 最初是由 乔丹·沃尔克 在 Facebook 创建的,并受到了 XHP Hack Library 的强烈影响。XHP 允许 Facebook 的 PHP 和 Hack 开发者为其应用程序的前端创建可重用组件。该 JavaScript 库于 2013 年开源,并永远改变了我们构建网站、网络应用、原生应用(后来是 React Native)以及甚至 VR 体验(后来是 React VR)的方式。因此,React 迅速成为最受欢迎和流行的 JavaScript 库之一,数百万个网站在生产中使用它用于各种不同的目的。
只有一个问题:默认情况下,React 在 客户端 运行(这意味着它在网页浏览器上运行),因此完全使用该库编写的网络应用可能会对 搜索引擎优化(SEO)和初始加载性能产生负面影响,因为它需要一些时间才能正确渲染到屏幕上。实际上,为了显示完整的网络应用,浏览器必须下载整个应用包,解析其内容,然后执行它并在浏览器中渲染结果,这可能需要几秒钟(对于非常大的应用)。
许多公司和开发者开始研究如何在服务器端预渲染应用,让浏览器显示渲染后的 React 应用作为纯 HTML,一旦 JavaScript 包被传输到客户端,它就可以立即交互。
然后,Vercel 推出了 Next.js,它最终成为了一个颠覆性的产品。
自从首次发布以来,该框架已经提供了一些开箱即用的创新功能,例如自动代码拆分、服务器端渲染、基于文件的路由系统、路由预取等等。Next.js 通过允许开发者编写用于客户端和服务器两端的可重用代码,以及使非常复杂的工作(如代码拆分和服务器端渲染)变得容易实现,展示了编写通用网络应用应该是多么简单。
今天,Next.js 提供了大量的新功能,例如以下内容:
-
静态站点生成
-
渐进式静态生成
-
原生 TypeScript 支持
-
自动填充
-
图片优化
-
国际化支持
-
性能分析
所有这些,再加上本书后面我们将深入探讨的许多其他优秀功能。
今天,Next.js 被 Netflix、Twitch、TikTok、Hulu、Nike、Uber、Elastic 等顶级公司用于生产。如果你感兴趣,可以阅读完整的列表,网址为 https://nextjs.org/showcase。
Next.js 展示了 React 在构建任何规模的不同应用时的多功能性,它被大公司和小型初创公司使用并不令人惊讶。顺便说一下,它并不是唯一允许你在服务器端渲染 JavaScript 的框架,正如我们将在下一节中看到的。
将 Next.js 与其他替代方案进行比较
如你所想,Next.js 并不是服务器端渲染 JavaScript 世界中的唯一玩家。然而,根据项目的最终目的,可能会考虑其他替代方案。
Gatsby
一个流行的替代方案是 Gatsby。如果你想要构建静态网站,你可能需要考虑这个框架。与 Next.js 不同,Gatsby 只支持静态网站生成,并且做得非常出色。每个页面在构建时都会预先渲染,并且可以作为静态资源在任何 内容分发网络 (CDN) 上提供服务,这使得性能与动态服务器端渲染的替代方案相比具有极高的竞争力。使用 Gatsby 而不是 Next.js 的最大缺点是,你将失去动态服务器端渲染的能力,这对于构建更动态、数据驱动和复杂的网站来说是一个重要的功能。
Razzle
相比 Next.js,Razzle 的知名度较低,它是一个用于创建服务器端渲染 JavaScript 应用的工具。它的目标是保持 create-react-app 的易用性,同时抽象出在服务器和客户端渲染应用所需的所有复杂配置。使用 Razzle 而不是 Next.js(或以下替代方案)的最显著优势是它不依赖于任何框架。你可以选择你喜欢的前端框架(或语言),例如 React、Vue、Angular、Elm 或 Reason-React…由你选择。
Nuxt.js
如果你熟悉 Vue,那么 Nuxt.js 可以成为 Next.js 的一个有效竞争对手。它们都提供了服务器端渲染、静态网站生成、渐进式 Web 应用管理等功能,在性能、SEO 或开发速度方面没有显著差异。虽然 Nuxt.js 和 Next.js 都服务于相同的目的,但 Nuxt.js 需要更多的配置,这有时并不是一件坏事。在你的 Nuxt.js 配置文件中,你可以定义布局、全局插件和组件、路由等,而使用 Next.js,你需要按照 React 方式 来做。除此之外,它们共享许多功能,但最显著的区别是底层的库。也就是说,如果你已经有一个 Vue 组件库,你可以考虑使用 Nuxt.js 来进行服务器端渲染。
Angular Universal
当然,Angular 也已经进入了 JavaScript 服务器端渲染的领域,并提出了 Angular Universal 作为官方的 Angular 应用服务器端渲染方式。它支持静态网站生成和服务器端渲染,与 Nuxt.js 和 Next.js 不同,它是由世界上最大的公司之一 Google 开发的。所以如果你从事 Angular 开发并且已经使用该库编写了一些组件,Angular Universal 可以是 Nuxt.js、Next.js 和其他类似框架的自然替代方案。
那么,为什么选择 Next.js 呢?
我们现在已经看到了一些 Next.js 的流行替代方案,以及它们的优缺点。
我建议使用 Next.js 而不是其他任何框架的主要原因是因为它令人难以置信的功能集。使用 Next.js,你将获得所有你需要的东西,直接从盒子里出来,我不仅指的是组件、配置和部署选项,尽管它们可能是我所见过的最完整的。
此外,Next.js 拥有一个非常友好和活跃的社区,随时准备在你构建应用程序的每一步提供支持。我会把这看作是一个巨大的加分项,因为一旦你在代码库中遇到问题,你将能够从遍布许多不同平台的庞大社区中获得帮助,包括 StackOverflow 和 GitHub,Vercel 团队也经常参与讨论和支持请求。
现在你已经知道了 Next.js 如何与其他类似框架竞争,让我们看看默认客户端 React 应用程序和具有完整功能的用于动态渲染你的 JavaScript 代码库的每个请求以及构建时的静态环境的区别。
从 React 迁移到 Next.js
如果你已经有一些 React 的经验,你会发现构建你的第一个 Next.js 网站非常容易。它的哲学与 React 非常接近,并为大多数设置提供了一种 约定优于配置 的方法,所以如果你想利用特定的 Next.js 功能,你将很容易找到官方的做法来做,而不需要任何复杂的配置。一个例子?在一个 Next.js 应用中,你可以在构建时指定哪些页面应该进行服务器端渲染,哪些应该进行静态生成,而不需要编写任何配置文件或其他类似的东西。你只需要从你的页面导出一个特定的函数,让 Next.js 做它的魔法(我们将在 第二章**,探索不同的渲染策略中看到)。
React 和 Next.js 之间最显著的区别在于,虽然 React 只是一个 JavaScript 库,但 Next.js 是一个用于在客户端和服务器端构建丰富和完整用户体验的框架,增加了大量极其有用的功能。每个服务器端渲染或静态生成的页面都将运行在 Node.js 上,因此你将失去访问一些浏览器特定的全局对象,例如 fetch、window 和 document,以及一些 HTML 元素,如 canvas。当你编写 Next.js 页面时,你始终需要记住这一点,即使框架提供了处理必须使用此类全局变量和 HTML 元素的组件的自己的方式,正如我们将在第二章**,探索不同的渲染策略中看到的那样。
另一方面,可能会有时候你想使用 Node.js 特定的库或 API,例如fs或child_process,Next.js 允许你在将数据发送到客户端之前,通过在每个请求或构建时间(取决于你如何选择渲染你的页面)运行你的服务器端代码来使用它们。
即使你想创建一个客户端渲染的应用程序,Next.js 也可以成为知名create-react-app的一个很好的替代品。实际上,Next.js 可以轻松地用作编写渐进式和离线优先 Web 应用的框架,利用其惊人的内置组件和优化。那么,让我们开始使用 Next.js 吧。
开始使用 Next.js
现在我们对 Next.js 的使用案例和客户端 React 与其他框架之间的区别有了基本的了解,是时候看看代码了。我们将从创建一个新的 Next.js 应用并自定义其默认 webpack 和 Babel 配置开始。我们还将了解如何将 TypeScript 作为开发 Next.js 应用的主要语言。
默认项目结构
开始使用 Next.js 极其简单。唯一的要求是在你的机器(或开发环境)上安装 Node.js 和npm。Vercel 团队创建并发布了一个简单但强大的工具,名为create-next-app,用于生成基本 Next.js 应用的脚手架代码。你可以在终端中输入以下命令来使用它:
npx create-next-app <app-name>
它将安装所有所需的依赖项并创建几个默认页面。此时,你只需运行npm run dev,开发服务器就会在端口3000上启动,显示一个着陆页。
如果你的机器上安装了 Yarn 包管理器,Next.js 将使用 Yarn 初始化你的项目。你可以通过传递一个标志来覆盖此选项,告诉create-next-app使用npm:
npx create-next-app <app-name> --use-npm
你也可以要求create-next-app通过从 Next.js GitHub 仓库下载脚手架代码来初始化一个新的 Next.js 项目。实际上,在 Next.js 仓库中有一个examples文件夹,里面包含大量关于如何使用 Next.js 与不同技术结合的精彩示例。
假设你想在 Docker 上使用 Next.js 做一些实验——你只需将--example标志传递给脚手架代码生成器:
npx create-next-app <app-name> --example with-docker
create-next-app将从github.com/vercel/next.js/tree/canary/examples/with-docker下载代码,并为你安装所需的依赖项。此时,你只需编辑下载的文件,进行定制,就可以开始了。
你可以在 https://github.com/vercel/next.js/tree/canary/examples 找到其他精彩示例。如果你已经熟悉 Next.js,请随意探索 Next.js 如何与不同的服务和工具包集成(我们将在本书的后续部分更详细地介绍一些)。
现在,让我们暂时回到默认的 create-next-app 安装。让我们打开终端,一起生成一个新的 Next.js 应用程序:
npx create-next-app my-first-next-app --use-npm
几秒钟后,模板生成将成功完成,你将找到一个名为 my-first-next-app 的新文件夹,其结构如下:
- README.md
- next.config.js
- node_modules/
- package-lock.json
- package.json
- pages/
- _app.js
- api/
- hello.js
- index.js
- public/
- favicon.ico
- vercel.svg
- styles/
- Home.module.css
- globals.css
如果你来自 React,你可能已经习惯了 pages/ 目录。实际上,pages/ 目录中的每个 JavaScript 文件都将是一个公开页面,所以如果你尝试复制 index.js 页面并将其重命名为 about.js,你将能够访问 http://localhost:3000/about 并看到你主页的精确副本。我们将在下一章详细探讨 Next.js 如何处理客户端和服务器端路由;现在,让我们只是把 pages/ 目录看作是公开页面的容器。
public/ 文件夹包含你网站中使用的所有公开和静态资产。例如,你可以将你的图片、编译后的 CSS 样式表、编译后的 JavaScript 文件、字体等放在那里。
默认情况下,你还会看到一个 styles/ 目录;虽然这对于组织你的应用程序样式表非常有用,但对于 Next.js 项目来说并不是严格必需的。唯一必需且保留的目录是 public/ 和 pages/,所以请确保不要删除或用于其他目的。
话虽如此,你可以在项目根目录中自由添加更多目录和文件,因为它不会对 Next.js 的构建或开发过程产生负面影响。如果你想将组件组织在 components/ 目录下,将实用工具组织在 utilities/ 目录下,请随意在你的项目中添加这些文件夹。
如果你不喜欢模板生成器,你可以通过添加所有必需的依赖(如之前所列)以及我们刚刚看到的基目录结构到现有的 React 应用程序中,来启动一个新的 Next.js 应用程序,这样它就可以直接工作,无需其他配置。
TypeScript 集成
Next.js 的源代码是用 TypeScript 编写的,并在项目根目录中提供了高质量的 tsconfig.json。如果你尝试运行 npm run dev,你会看到以下输出:
It looks like you're trying to use TypeScript but do not have the required package(s) installed.
Please install typescript and @types/react by running:
npm install --save typescript @types/react
If you are not trying to use TypeScript, please remove
the tsconfig.json file from your package root (and any
TypeScript files in your pages directory).
正如你所见,Next.js 已经正确地检测到你正在尝试使用 TypeScript,并要求你安装所有作为项目主要语言所需依赖。因此,你现在只需将你的 JavaScript 文件转换为 TypeScript,就可以开始使用了。
你可能会注意到,即使你创建了一个空的 tsconfig.json 文件,在安装了必需的依赖并重新运行项目后,Next.js 也会用它的默认配置填充它。当然,你总是可以自定义该文件中的 TypeScript 选项,但请记住,Next.js 使用 Babel 来处理 TypeScript 文件(通过 @babel/plugin-transform-typescript),并且有一些注意事项,包括以下内容:
-
@babel/plugin-transform-typescript插件不支持 TypeScript 中常用的const enum,为了支持它,请确保将babel-plugin-const-enum添加到 Babel 配置中(我们将在 自定义 Babel 和 webpack 配置 部分看到如何操作)。 -
既不支持
export =也不支持import =,因为它们无法编译成有效的 ECMAScript 代码。你应该安装babel-plugin-replace-ts-export-assignment,或者将你的导入和导出转换为有效的 ECMAScript 指令,例如import x, {y} from 'some-package'和export default x。
还有其他注意事项,我建议你在进一步使用 TypeScript 作为 Next.js 应用程序的主要开发语言之前阅读它们:babeljs.io/docs/en/babel-plugin-transform-typescript#caveats。
此外,一些编译器选项可能与默认的 TypeScript 选项略有不同;再次建议你阅读官方的 Babel 文档,它将始终是最新的:babeljs.io/docs/en/babel-plugin-transform-typescript#typescript-compiler-options。
Next.js 还会在你的项目根目录下创建一个 next-env.d.ts 文件;如果你需要,可以自由编辑它,但请确保不要删除它。
自定义 Babel 和 webpack 配置
如同在 TypeScript 集成 部分已提到的,我们可以自定义 Babel 和 webpack 配置。
我们可能有很多原因想要自定义 Babel 配置。如果你不太熟悉它,让我快速解释一下我在说什么。Babel 是一个 JavaScript 转换编译器,主要用于将现代 JavaScript 代码转换为向后兼容的脚本,这样它就可以在任何浏览器上无问题地运行。
如果你正在编写一个必须支持旧版浏览器(如 Internet Explorer (IE)10 或 Internet Explorer 11)的 Web 应用程序,Babel 会对你有很大帮助。它允许你使用现代 ES6/ESNext 功能,并在构建时将它们转换为与 IE 兼容的代码,让你在几乎不妥协的情况下保持良好的开发者体验。
此外,JavaScript 语言(在 ECMAScript 规范下标准化)正在快速发展。因此,尽管一些出色的功能已经宣布,但你可能需要等待数年才能在浏览器和 Node.js 环境中使用它们。这是因为 ECMA 委员会接受这些功能后,开发 Web 浏览器的公司和在 Node.js 项目上工作的社区必须制定一个路线图,以添加对这些增强功能的支持。Babel 通过将现代代码转换为适用于今天环境的兼容脚本来解决此问题。
例如,你可能熟悉以下代码:
export default function() {
console.log("Hello, World!");
};
但如果您尝试在 Node.js 中运行它,它将抛出一个语法错误,因为 JavaScript 引擎不会识别 export default 关键字。
Babel 将将前面的代码转换为等效的 ECMAScript 代码,至少直到 Node.js 支持 export default 语法:
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = _default;
function _default() {
console.log("Hello, World!");
};
这使得在 Node.js 上运行此代码没有任何问题。
您可以通过在项目根目录下创建一个名为 .babelrc 的新文件来自定义 Next.js 的默认 Babel 配置。您会注意到,如果您将其留空,Next.js 的构建/开发过程将抛出错误,因此请确保至少添加以下代码:
{
"presets": ["next/babel"]
}
这是 Vercel 团队专门为构建和开发 Next.js 应用程序而创建的 Babel 预设。假设我们正在构建一个应用程序,并且我们想使用实验性的 ECMAScript 功能,例如管道操作符;如果您不熟悉它,它基本上允许您将此代码重写如下:
console.log(Math.random() * 10);
// written using the pipeline operator becomes:
Math.random()
|> x => x * 10
|> console.log
此操作符尚未被 TC39(ECMAScript 规范背后的技术委员会)官方接受,但您可以通过 Babel 开始使用它。
要在您的 Next.js 应用中提供对这个操作符的支持,您只需要使用 npm 安装 Babel 插件:
npm install --save-dev @babel/plugin-proposal-pipeline-operator @babel/core
然后按照以下方式更新您的自定义 .babelrc 文件:
{
"presets": ["next/babel"],
"plugins": [
[
"@babel/plugin-proposal-pipeline-operator",
{ "proposal": "fsharp" }
]
]
}
您现在可以重新启动开发服务器并使用这个实验性功能。
如果您有兴趣将 TypeScript 作为 Next.js 应用的主要开发语言,您可以遵循相同的步骤将所有 TypeScript 特定插件添加到您的 Babel 配置中。在您的 Next.js 开发过程中,您可能还希望自定义默认的 webpack 配置。
虽然 Babel 只接受现代代码作为输入并生成向后兼容的脚本作为输出,但 webpack 会创建包含特定库、页面或功能的编译代码的包。例如,如果您创建了一个包含来自三个不同库的三个组件的页面,webpack 将将所有内容合并成一个单独的包发送到客户端。简单来说,我们可以将 webpack 视为为每个网络资源(JavaScript 文件、CSS、SVG 等)的不同编译、打包和压缩任务进行编排的基础设施。
如果您想使用 SASS 或 LESS 等 CSS 预处理器来创建您的应用程序样式,您需要自定义默认的 webpack 配置以解析 SASS/LESS 文件并生成纯 CSS 作为输出。当然,对于使用 Babel 作为转换器的 JavaScript 代码也是如此。
我们将在后续章节中更详细地讨论 CSS 预处理器,但就目前而言,我们只需要记住 Next.js 提供了一种简单的方式来自定义默认的 webpack 配置。
如我们之前所见,Next.js 提供了一种基于约定的配置方法,因此你不需要为构建实际应用而自定义其大部分设置;你只需遵循一些代码约定即可。
但如果你真的需要构建一些自定义的东西,你通常可以通过编辑next.config.js文件来修改默认设置。你可以在项目的根目录下创建这个文件。默认情况下,它应该导出一个对象,其中它的属性将覆盖 Next.js 的默认配置:
module.exports = {
// custom settings here
};
你可以通过在这个对象中创建一个新的属性来自定义默认的 webpack 配置,称为webpack。假设我们想要添加一个名为my-custom-loader的新虚拟 webpack 加载器;我们可以按照以下步骤进行:
module.exports = {
webpack: (config, options) => {
config.module.rules.push({
test: /\.js/,
use: [
options.defaultLoaders.babel,
// This is just an example
//don't try to run this as it won't work
{
loader: "my-custom-loader", // Set your loader
options: loaderOptions, // Set your loader
options
},
],
});
return config;
},
};
因此,正如你所见,我们正在编写一个合适的 webpack 配置,稍后它将与 Next.js 的默认设置合并。这将允许我们扩展、覆盖或甚至删除默认配置中的任何设置,尽管删除默认设置通常不是一个好主意,但在某些情况下你可能需要这样做(如果你足够勇敢的话!)。
摘要
在本章中,你已经看到了默认和客户端 React 应用以及 Next.js 之间的主要区别,以及 Next.js 与其他知名替代方案的比较。你还学习了如何通过编辑 Babel 和 webpack 配置以及添加 TypeScript 作为 JavaScript 的替代品来定制默认的 Next.js 项目。
在下一章中,我们将更详细地探讨三种不同的渲染策略:客户端渲染、服务器端渲染和静态站点生成。
第二章:探索不同的渲染策略
当谈到渲染策略时,我们指的是我们如何向网络浏览器提供网页(或网络应用程序)。有一些框架,如 Gatsby(如前一章所示),在提供静态生成的页面方面非常好。其他框架将使创建服务器端渲染的页面变得容易。
但 Next.js 将这些概念提升到了全新的水平,让你决定哪些页面应该在构建时渲染,哪些应该在运行时动态提供,为每个请求重新生成整个页面,使应用程序的某些部分变得极其动态。该框架还允许你决定哪些组件应该仅客户端渲染,使你的开发体验非常满意。
在本章中,我们将更深入地了解:
-
如何使用服务器端渲染为每个请求动态渲染页面
-
在客户端仅渲染某些组件的不同方式
-
在构建时生成静态页面
-
如何使用增量静态重新生成在生产中重新生成静态页面
技术要求
要运行本章中的代码示例,请确保你的机器上已安装 Node.js 和npm。作为替代方案,你可以使用在线 IDE,如repl.it或codesandbox.io。
你可以在 GitHub 仓库中找到本章的代码:github.com/PacktPublishing/Real-World-Next.js。
服务器端渲染(SSR)
即使服务器端渲染(SSR)在开发者的词汇中听起来像是一个新术语,但实际上它是提供网页最常见的方式。如果你想到 PHP、Ruby 或 Python 这样的语言,它们都会在发送到浏览器之前在服务器上渲染 HTML,一旦所有 JavaScript 内容都加载完毕,这将使标记动态化。
嗯,Next.js 通过在每个请求上动态在服务器上渲染一个 HTML 页面,然后将其发送到网络浏览器来做同样的事情。该框架还会注入它自己的脚本,通过一个称为激活的过程使服务器端渲染的页面动态化。
想象你正在构建一个博客,你希望在单个页面上显示特定作者撰写的所有文章。这可以是一个很好的 SSR 用例:用户想要访问这个页面,所以服务器渲染它,并将生成的 HTML 发送到客户端。在这个时候,浏览器将下载页面请求的所有脚本,并使 DOM 活跃,使其在没有页面刷新或故障的情况下交互(你可以在reactjs.org/docs/react-dom.html#hydrate上了解更多关于 React hydration 的信息)。从这一点开始,多亏了 React hydration,Web 应用也可以成为一个单页应用(SPA),同时拥有客户端渲染(CSR)(我们将在下一节中看到)和 SSR 的所有优势。
谈到采用特定渲染策略的优势,SSR 相对于标准的 React CSR 提供了多个好处:
-
更安全的 Web 应用:在服务器端渲染页面意味着诸如管理 cookies、调用私有 API 和数据验证等活动都在服务器上发生,因此我们永远不会将私有数据暴露给客户端。
-
更兼容的网站:即使用户禁用了 JavaScript 或使用较旧的浏览器,网站也将可用。
-
增强搜索引擎优化:由于客户端将在服务器渲染并发送 HTML 内容后立即接收,搜索引擎蜘蛛(爬取网页的机器人)将不需要等待页面在客户端渲染。这将提高你的 Web 应用的 SEO 评分。
尽管有这些巨大的优势,但在某些情况下,SSR 可能不是你网站的最佳解决方案。事实上,使用 SSR,你需要将你的 Web 应用部署到服务器,该服务器将在需要时重新渲染页面。正如我们稍后看到的,使用 CSR 和静态站点生成(SSG),你可以免费(或以微小的成本)将静态 HTML 文件部署到任何云服务提供商,如 Vercel 或 Netlify;如果你已经使用自定义服务器部署你的 Web 应用,你必须记住,SSR 应用将始终导致更大的服务器工作负载和维护成本。
当你想要在服务器端渲染你的页面时,还有一点需要记住,那就是你为每个请求增加了一些延迟;你的页面可能需要调用一些外部 API 或数据源,并且它们会在每次页面渲染时调用。在服务器端渲染的页面之间导航总是会比在客户端渲染或静态服务页面之间导航慢一些。
当然,Next.js 提供了一些很棒的功能来提高导航性能,正如我们将在第三章,“Next.js 基础和内置组件”中看到的。
另一件需要考虑的事情是,默认情况下,Next.js 页面在构建时是静态生成的。如果我们想通过调用外部 API、数据库或其他数据源来使其更具动态性,我们需要从我们的页面导出特定的函数:
function IndexPage() {
return <div>This is the index page.</div>;
}
export default IndexPage;
如你所见,页面只打印了 div。它不需要调用外部 API 或其他任何数据源来工作,并且其内容对于每个请求都是相同的。但现在,让我们假设我们想在每个请求上问候用户;我们需要在服务器上调用一个 REST API 来获取一些特定的用户信息,并使用 Next.js 流将其结果传递给客户端。我们将通过使用保留的 getServerSideProps 函数来完成这项工作:
export async function getServerSideProps() {
const userRequest = await fetch('https://example.com/api/user');
const userData = await userRequest.json();
return {
props: {
user: userData
}
};
}
function IndexPage(props) {
return <div>Welcome, {props.user.name}!</div>;
}
export default IndexPage;
在前面的示例中,我们使用了 Next.js 保留的 getServerSideProps 函数,在每个请求的服务器端进行 REST API 调用。让我们将其分解成小步骤,以便我们更好地理解我们在做什么:
-
我们首先导出一个名为
getServerSideProps的异步函数。在构建阶段,Next.js 会查找每个导出此函数的页面,并使它们针对每个请求进行动态服务器端渲染。在这个函数作用域内编写的所有代码都将始终在服务器端执行。 -
在
getServerSideProps函数内部,我们返回一个包含名为props的属性的对象。这是必需的,因为 Next.js 会将这些属性注入到我们的page组件中,使它们在客户端和服务器端都可用。如果你想知道,当我们使用它时,我们不需要 polyfill fetch API,因为 Next.js 已经为我们做了这件事。 -
然后,我们重构了
IndexPage函数,现在它接受一个props参数,包含从getServerSideProps函数传递的所有属性。
就这样!在我们发布这段代码后,Next.js 将始终在服务器上动态渲染我们的 IndexPage,调用外部 API,并在我们更改数据源时立即显示不同的结果。
如本节开头所见,SSR 提供了一些显著的优势,但也存在一些注意事项。如果你想使用任何依赖于浏览器特定 API 的组件,你将需要显式地在浏览器上渲染它,因为默认情况下,Next.js 在服务器上渲染整个页面内容,这不会暴露某些 API,如 window 或 document。因此,出现了 CSR 的概念。
客户端渲染(CSR)
如前一章所见,一个标准的 React 应用在 JavaScript 包从服务器传输到客户端后才会渲染。
如果你熟悉 create-react-app(CRA),你可能已经注意到在网页渲染之前,整个网页都是完全白色的。这是因为服务器只提供了一个非常基本的 HTML 标记,其中包含所有必要的脚本和样式,使我们的网页动态化。让我们更仔细地看看 CRA 生成的那个 HTML:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon"
href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>React App</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
</body>
</html>
正如你所见,我们只能在body标签内找到一个div:<div id="root"></div>。
在构建阶段,create-react-app会将编译后的 JavaScript 和 CSS 文件注入到这个 HTML 页面中,并使用root div 作为渲染整个应用的目标容器。
这意味着一旦我们将这个页面发布到任何托管提供商(Vercel、Netlify、Google Cloud、AWS 等),第一次调用所需的 URL 时,我们的浏览器将首先渲染前面的 HTML。然后,根据前面标记(在构建时由 CRA 注入)中的script和link标签,浏览器将渲染整个应用,使其可用于任何类型的交互。
CSR 的主要优势是:
-
让你的应用感觉像原生应用:下载整个 JavaScript 包意味着你已经在浏览器中下载了你的 Web 应用的每一页。如果你想导航到不同的页面,它会交换页面内容而不是从服务器下载新内容。你不需要刷新页面来更新其内容。
-
页面过渡变得简单:客户端导航允许我们在不重新加载浏览器窗口的情况下从一个页面切换到另一个页面。当你想轻松地展示页面间的酷炫过渡效果时,这非常有用,因为你没有任何重新加载可能会打断你的动画。
-
懒加载和性能:使用 CSR,浏览器将只渲染 Web 应用工作所需的最低 HTML 标记。如果你有一个在用户点击按钮后出现的模态框,其 HTML 标记不会出现在 HTML 页面上。它将在按钮点击事件发生时由 React 动态创建。
-
减少服务器端工作量:由于整个渲染阶段都委托给了浏览器,服务器只需要向客户端发送一个非常基本的 HTML 页面。因此,你不需要一个非常强大的服务器;实际上,有些情况下你可以在无服务器环境中托管你的 Web 应用,例如 AWS Lambda、Firebase 等。
但所有这些好处都是要付出代价的。正如我们之前看到的,服务器只发送一个空的 HTML 页面。如果用户的互联网连接速度慢,JavaScript 和 CSS 文件的下载将需要几秒钟才能完成,让用户在空屏幕上等待几秒钟。
这也会影响你的 Web 应用 SEO 评分;搜索引擎蜘蛛会到达你的页面,并发现它是空的。例如,谷歌机器人将等待 JavaScript 包被传输,但由于他们的等待时间,将给你的网站分配一个低性能评分。
默认情况下,Next.js 会将所有 React 组件在给定页面上在服务器端(如前文所述)或构建时渲染。在第一章的“从 React 迁移到 Next.js”部分,我们看到了 Node.js 运行时不暴露一些浏览器特定的 API,例如 window 或 document,或者 HTML 元素,例如 canvas,因此如果您尝试渲染需要访问这些 API 的任何组件,渲染过程将会崩溃。
有许多不同的方法可以避免 Next.js 中这类问题,要求将特定组件渲染到浏览器上。
使用 React.useEffect 钩子
如果您来自 React 16.8.0 之前的版本,您可能已经习惯了 React.Component 类的 componentDidMount 方法。在更现代的 React 版本中,它强调使用 React.useEffect 钩子。它将允许您在函数组件内部执行副作用(如数据获取和手动 DOM 更改),并且它将在组件挂载后执行。这意味着在 Next.js 中,useEffect 回调将在 React 活化后运行在浏览器上,让您能够仅在客户端执行某些操作。
例如,假设我们想在网页上使用 Highlight.js 库显示一个代码片段,使其易于高亮显示并使代码更易读。我们可以创建一个名为 Highlight 的组件,其外观如下:
import Head from 'next/head';
import hljs from 'highlight.js';
import javascript from 'highlight.js/lib/languages/javascript';
function Highlight({ code }) {
hljs.registerLanguage('javascript', javascript);
hljs.initHighlighting();
return (
<>
<Head>
<link rel='stylesheet' href='/highlight.css' />
</Head>
<pre>
<code className='js'>{code}</code>
</pre>
</>
);
}
export default Highlight;
虽然这段代码在客户端 React 应用中可以完美运行,但在 Next.js 的渲染或构建阶段将会崩溃,因为 Highlight.js 需要全局变量 document,而 Node.js 中不存在这个变量,因为它是浏览器暴露的。
您可以通过将所有的 hljs 调用包裹在 useEffect 钩子中来轻松解决这个问题:
import { useEffect } from 'react';
import Head from 'next/head';
import hljs from 'highlight.js';
import javascript from
'highlight.js/lib/languages/javascript';
function Highlight({ code }) {
useEffect(() => {
hljs.registerLanguage('javascript', javascript);
hljs.initHighlighting();
}, []);
return (
<>
<Head>
<link rel='stylesheet' href='/highlight.css' />
</Head>
<pre>
<code className='js'>{code}</code>
</pre>
</>
);
}
export default Highlight;
这样,Next.js 将渲染我们组件返回的 HTML 标记,将 Highlight.js 脚本注入到我们的页面中,一旦组件在浏览器上挂载,它将在客户端调用库函数。
您也可以通过同时使用 React.useEffect 和 React.useState 来实现仅在客户端渲染组件的相同方法:
import {useEffect, useState} from 'react';
import Highlight from '../components/Highlight';
function UseEffectPage() {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
return (
<div>
{isClient &&
(<Highlight
code={"console.log('Hello, world!')"}
language='js'
/>)
}
</div>
);
}
export default UseEffectPage;
这样,Highlight 组件将仅在浏览器上渲染。
使用 process.browser 变量
避免在服务器端使用浏览器特定 API 时崩溃的另一种方法是条件性地执行脚本和组件,这取决于全局变量 process.browser。实际上,Next.js 将这个极其有用的属性附加到 Node.js 的 process 对象上。它是一个布尔值,当代码在客户端运行时设置为 true,在服务器上运行时设置为 false。让我们看看它是如何工作的:
function IndexPage() {
const side = process.browser ? 'client' : 'server';
return <div>You're currently on the {side}-side.</div>;
}
export default IndexPage;
如果您尝试运行前面的示例,您会注意到浏览器会短暂地显示以下文本:您当前正在服务器端运行;一旦 React 活化发生,它将被 您当前正在客户端运行 的文本所替换。
使用动态组件加载
正如我们在第一章中看到的,Next.js 通过添加一些优秀的内置组件和实用函数来扩展 React 的功能。其中之一被称为dynamic,这是框架提供的最有趣的模块之一。
记得我们构建的 Highlight.js 组件,以了解如何使用React.useEffect钩子在浏览器上渲染组件吗?这里还有另一种使用 Next.js 的dynamic函数来渲染它的方法:
import dynamic from 'next/dynamic';
const Highlight = dynamic(
() => import('../components/Highlight'),
{ ssr: false }
);
import styles from '../styles/Home.module.css';
function DynamicPage() {
return (
<div className={styles.main}>
<Highlight
code={"console.log('Hello, world!')"}
language='js'
/>
</div>
);
}
export default DynamicPage;
在前面的代码中,我们通过ssr: false选项导入我们的Highlight组件。这样,Next.js 就不会尝试在服务器上渲染该组件,我们则需要等待 React 激活过程使其在浏览器上可用。
CSR(客户端渲染)可以是构建非常动态的 Web 页面的 SSR(服务器端渲染)的一个绝佳替代方案。如果你正在处理一个不需要被搜索引擎索引的页面,那么首先加载应用程序的 JavaScript,然后从客户端获取任何必要的数据,这可能是有意义的;这种方法可以减轻服务器端的工作负载,因为这种方法不涉及 SSR,并且你的应用程序可以更好地扩展。
那么,这里有一个问题——如果我们需要构建一个动态页面,而 SEO(搜索引擎优化)并不是特别重要(例如管理页面、私有个人资料页面等),为什么我们不直接向客户端发送一个静态页面,并在页面传输到浏览器后一次性加载所有数据呢?我们将在下一节探讨这种可能性。
静态站点生成
到目前为止,我们已经看到了两种渲染我们的 Web 应用的不同方式:客户端和服务器端。Next.js 为我们提供了一个名为静态站点生成(SSG)的第三种选择。
使用 SSG(静态站点生成),我们将在构建时能够预渲染一些特定的页面(如果需要,甚至整个网站);这意味着在我们构建我们的 Web 应用时,可能会有一些页面内容不会经常改变,因此将它们作为静态资源提供服务是有意义的。Next.js 将在构建阶段渲染这些页面,并且始终提供那个特定的 HTML,就像 SSR 一样,将得益于 React 的激活过程而变得交互式。
与 CSR(客户端渲染)和 SSR(服务器端渲染)相比,SSG 带来了许多优势:
-
易于扩展:静态页面只是可以被任何内容分发网络(以下简称CDN)轻松提供和缓存的 HTML 文件。但即使你想使用自己的 Web 服务器来提供服务,由于不需要为提供静态资源进行复杂计算,这也会导致非常低的工作负载。
-
卓越的性能:正如之前所说,HTML 是在构建时预渲染的,因此客户端和服务器都可以绕过每个请求的运行时渲染阶段。Web 服务器将发送静态文件,浏览器只需显示它,就这么简单。服务器端不需要进行数据获取;我们需要的所有内容都已经预渲染在静态 HTML 标记中,这减少了每个请求的潜在延迟。
-
更安全的请求:我们不需要向 Web 服务器发送任何敏感数据来渲染页面,这使得恶意用户的生活变得有些困难。不需要访问 API、数据库或其他私人信息,因为所需的所有信息都已经包含在预渲染的页面中。
SSG 可能是构建高性能和高度可扩展的前端应用的最佳解决方案之一。关于这种渲染技术的最大担忧是,一旦页面构建完成,内容将保持不变,直到下一次部署。
例如,假设我们正在撰写一篇博客文章,但在标题中拼写了一个单词。使用其他静态站点生成器,如 Gatsby 或 Jekyll,我们需要重建整个网站来更改博客文章标题中的一个单词,因为我们需要在构建时重复数据获取和渲染阶段。记住我们在本节开头所说的话:静态生成的页面是在构建时创建的,并且作为每个请求的静态资源提供。
虽然这对于其他静态站点生成器也是正确的,但 Next.js 提供了一种独特的解决方案来解决这个问题:增量静态再生(ISR)。得益于 ISR,我们可以在页面级别指定 Next.js 在重新渲染更新内容的静态页面之前应该等待多长时间。
例如,假设我们想要构建一个显示一些动态内容的页面,但由于某种原因,数据获取阶段需要太长时间才能成功。这将导致性能不佳,给我们的用户带来糟糕的用户体验。SSG 和 ISR 的组合通过采用 SSR 和 SSG 之间的混合方法来解决这个问题。
让我们假设我们已经构建了一个非常复杂的仪表板,它可以处理大量数据……但获取这些数据的 REST API 请求可能需要几秒钟才能成功。在这种情况下,我们很幸运,因为在这段时间内,这些数据不会变化太多,所以我们可以使用 SSG 和 ISR 将其缓存长达 10 分钟(600 秒):
import fetch from 'isomorphic-unfetch';
import Dashboard from './components/Dashboard';
export async function getStaticProps() {
const userReq = await fetch('/api/user');
const userData = await userReq.json();
const dashboardReq = await fetch('/api/dashboard');
const dashboardData = await dashboardReq.json();
return {
props: {
user: userData,
data: dashboardData,
},
revalidate: 600 // time in seconds (10 minutes)
};
}
function IndexPage(props) {
return (
<div>
<Dashboard
user={props.user}
data={props.data}
/>
</div>
);
}
export default IndexPage;
我们现在使用一个名为getStaticProps的函数,它看起来与我们在上一节中看到的getServerSideProps函数相似。正如你可能已经猜到的,getStaticProps由 Next.js 在构建时用于获取数据和渲染页面,并且直到下一次构建之前不会再次调用。正如之前所说,虽然这可以非常强大,但它也有代价:如果我们想更新页面内容,我们必须重建整个网站。
为了避免整个网站重建,Next.js 最近引入了一个名为revalidate的选项,它可以在我们的getStaticProps函数的返回对象中设置。它表示在收到新请求后,我们应该过多少秒重建页面。
在前面的代码中,我们将我们的revalidate选项设置为600秒,因此 Next.js 将如下行为:
-
Next.js 在构建时使用
getStaticProps的结果填充页面,在构建过程中静态生成页面。 -
在最初的 10 分钟内,每位用户都将访问完全相同的静态页面。
-
10 分钟后,如果发生新的请求,Next.js 将在服务器端渲染该页面,重新执行
getStaticProps函数,保存并缓存新渲染的页面作为静态资产,覆盖在构建时创建的先前的页面。 -
在接下来的 10 分钟内,每个新的请求都将使用那个新静态生成的页面进行服务。
请记住,ISR 过程是懒惰的,所以如果 10 分钟后没有发生任何请求,Next.js 不会重建其页面。
如果你有所疑问,目前还没有通过 API 强制 ISR 重新验证的方法;一旦你的网站已经部署,你必须等待在revalidate选项中设置的过期时间长度,页面才能重建。
静态站点生成是一种创建快速和安全的网页的绝佳方式,但有时我们可能想要有更多动态的内容。多亏了 Next.js,我们总是可以决定在构建时间(SSG)或请求时间(SSR)渲染哪个页面。通过使用 SSG + ISR,我们可以取两者之长,使我们的页面成为 SSR 和 SSG 之间的“混合”体,这对现代 Web 开发来说是一个变革性的突破。
摘要
在本章中,我们看到了三种不同的渲染策略,以及 Next.js 如何通过其混合渲染方法将它们提升到全新的水平。我们还看到了这些策略的好处,当我们想要使用它们时,以及它们如何影响用户体验或服务器负载。在接下来的章节中,我们将始终关注这些渲染方法,为每个方法添加越来越多的示例和用例。它们是选择使用 Next.js 作为框架背后的核心概念。
在下一章中,我们将更详细地探讨一些最有用的内置 Next.js 组件,其路由系统,以及如何动态管理元数据以改善 SEO 和用户体验。
第三章:Next.js 基础和内置组件
Next.js 不仅关于服务器端渲染。它提供了一些非常实用的内置组件和函数,我们可以使用它们来创建性能良好、动态和现代的网站。
在本章中,我们将探讨 Next.js 核心的一些概念,例如路由系统、客户端导航、服务优化图像、处理元数据等。一旦我们开始使用这个框架构建一些实际的应用程序,这些概念将非常有用。
我们还将更深入地研究_app.js和_document.js页面,这将使我们能够以多种方式自定义我们的 Web 应用行为。
在本章中,我们将涵盖以下主题:
-
路由系统如何在客户端和服务器端工作
-
如何优化页面间的导航
-
Next.js 如何服务静态资源
-
如何通过自动图像优化和新的
Image组件优化图像服务 -
如何动态处理来自任何组件的 HTML 元数据
-
_app.js和_document.js文件是什么,以及如何自定义它们?
技术要求
要运行本章中的代码示例,您需要在您的本地机器上安装 Node.js 和 npm。
如果您喜欢,可以使用在线 IDE,例如repl.it或codesandbox.io;它们都支持 Next.js,您不需要在您的电脑上安装任何依赖。
您可以在 GitHub 仓库中找到本章的代码:github.com/PacktPublishing/Real-World-Next.js。
路由系统
如果您来自客户端 React,您可能熟悉像React Router、Reach Router或Wouter这样的库。它们允许您仅创建客户端路由,这意味着所有页面都将创建并在客户端渲染;不涉及服务器端渲染。
Next.js 采用不同的方法:基于文件系统的页面和路由。如第二章中所述,探索不同的渲染策略,一个默认的 Next.js 项目包含一个pages/目录。该文件夹内的每个文件都代表您应用程序的一个新页面/路由。
因此,当我们谈论一个页面时,我们指的是从pages/文件夹内的.js、.jsx、.ts或.tsx文件中导出的 React 组件。
为了让事情更清晰,让我们假设我们只想创建一个包含两个页面的简单网站;第一个将是主页,而第二个将是一个简单的联系页面。为此,我们只需要在我们的pages/文件夹内创建两个新文件:index.js和contacts.js。这两个文件都需要导出一个返回一些 JSX 内容的函数;它将在服务器端渲染并发送到浏览器作为标准 HTML。
正如我们刚才看到的,一个页面必须返回有效的 JSX 代码,所以让我们创建一个非常简单和简洁的index.js页面:
function Homepage() {
return (
<div> This is the homepage </div>
)
};
export default Homepage;
如果我们在终端中运行yarn dev或npm run dev,然后在我们的浏览器中转到localhost:3000,我们将在屏幕上只看到这是主页的消息。我们刚刚制作了我们的第一个页面!
我们也可以用我们的联系页面做同样的事情:
function ContactPage() {
return (
<div>
<ul>
<li> Email: myemail@example.com</li>
<li> Twitter: @myusername </li>
<li> Instagram: myusername </li>
</ul>
</div>
)
};
export default ContactPage;
由于我们已将我们的联系页面命名为contacts.js,我们可以导航到 http://localhost:3000/contacts 并在浏览器上看到显示的联系人列表。如果我们想要将该页面移动到 http://localhost:3000/contact-us,我们只需将我们的contacts.js文件重命名为contact-us.js,Next.js 将自动使用新的路由名称为我们重建页面。
现在,让我们尝试使事情变得稍微复杂一些。我们正在构建一个博客,因此我们想要为每篇文章创建一个路由。我们还想要创建一个/posts页面,该页面将显示网站上所有的文章。
要做到这一点,我们将使用以下动态路由:
pages/
- index.js
- contact-us.js
- posts/
- index.js
- [slug].js
我们还没有提到,我们可以使用pages/目录内的文件夹创建嵌套路由。如果我们想要创建一个/posts路由,我们可以在pages/posts/文件夹内创建一个新的index.js文件,导出一个包含一些 JSX 代码的函数,并访问 http://localhost:3000/posts。
然后,我们想要为每篇博客文章创建一个动态路由,这样我们就不必每次想要在网站上发布文章时手动创建一个新页面。要做到这一点,我们可以在pages/posts/文件夹内创建一个新文件,pages/posts/[slug].js,其中[slug]标识一个可以包含任何值的路由变量,这取决于用户在浏览器地址栏中输入的内容。在这种情况下,我们正在创建一个包含名为slug的变量的路由,该变量可以针对每篇博客文章而变化。我们可以从这个文件中导出一个简单的函数,返回一些 JSX 代码,然后浏览到 http://localhost:3000/posts/my-firstpost、http://localhost:3000/posts/foo-bar-baz 或任何其他 http://localhost:3000/posts/*路由。无论你浏览到哪个路由,它都会始终渲染相同的 JSX 代码。
我们还可以在pages/文件夹内嵌套多个动态路由;假设我们想要我们的文章页面结构如下:/posts/[date]/[slug]。我们只需在我们的pages/目录内添加一个新的名为[date]的文件夹,并将slug.js文件移入其中:
pages/
- index.js
- contact-us.js
- posts/
- index.js
- [date]/
- [slug].js
我们现在可以访问localhost:3000/posts/2021-01-01/my-first-post并查看我们之前创建的 JSX 内容。再次强调,[date]和[slug]变量可以代表任何你想要的内容,所以请随意通过在浏览器中调用不同的路由进行实验。
到目前为止,我们一直使用路由变量来渲染相同的页面,但这些变量主要用于创建高度动态的页面,其内容根据我们使用的路由变量而有所不同。让我们在下一节中看看如何根据变量渲染不同的内容。
在我们的页面中使用路由变量
路由变量对于创建非常动态的页面内容非常有用。
让我们用一个简单的例子来说明:一个问候页面。在上一节中使用的项目中,让我们创建以下文件:pages/greet/[name].js。我们将使用 Next.js 内置的 getServerSideProps 函数动态地从 URL 获取 [name] 变量并向用户问候:
export async function getServerSideProps({ params }) {
const { name } = params;
return {
props: {
name
}
}
}
function Greet(props) {
return (
<h1> Hello, {props.name}! </h1>
)
}
export default Greet;
现在,打开您最喜欢的浏览器并转到 http://localhost:3000/greet/Mitch;您应该看到一个 "name 变量,所以请随意尝试一些不同的名字!
重要提示
当同时使用 getServerSideProps 和 getStaticProps 函数时,请记住它们 必须 返回一个对象。另外,如果你想从这两个函数之一传递任何属性到你的页面,确保在返回对象的 props 属性中传递它们。
能够从 URL 获取数据对于许多原因来说都是基本的。在之前的代码示例中,我们创建了一个简单的问候页面,但我们可以使用 [name] 变量用于其他目的,例如从数据库获取用户数据以显示他们的个人资料。我们将在 第四章 中更详细地了解数据获取,Next.js 中的代码库组织和数据获取。
有时候你需要从你的组件而不是你的页面中获取路由变量。Next.js 通过我们将在下一节中看到的 React 钩子使这一切变得轻松。
在组件中使用路由变量
在上一节中,我们学习了如何在页面中使用路由变量。Next.js 不允许我们在页面之外使用 getServerSideProps 和 getStaticProps 函数,那么我们如何在其他组件中使用它们呢?
Next.js 通过 useRouter 钩子使这一切变得轻松;我们可以从 next/router 文件中导入它:
import { useRouter } from 'next/router';
它的工作方式就像任何其他 React 钩子(一个让你在函数组件中与 React 状态和生命周期交互的函数)一样,我们可以在任何组件中实例化它。让我们按照以下方式重构之前的问候页面:
import { useRouter } from 'next/router';
function Greet() {
const { query } = useRouter();
return <h1>Hello {query.name}!</h1>;
}
export default Greet;
如您所见,我们正在从 useRouter 钩子中提取 query 参数。它包含我们的路由变量(在这个例子中,它只包含 name 变量)和解析后的查询字符串参数。
我们可以通过尝试将任何查询参数附加到我们的 URL 并在组件中记录 query 变量来观察 Next.js 如何通过 useRouter 钩子传递路由变量和查询字符串:
import { useRouter } from 'next/router';
function Greet() {
const { query } = useRouter();
console.log(query);
return <h1>Hello {query.name}!</h1>;
}
export default Greet;
如果我们现在尝试调用以下 URL,http://localhost:3000/greet/Mitch?learning_nextjs=true,我们将在我们的终端中看到以下对象:
{learning_nextjs: "true", name: "Mitch"}
重要提示
如果您尝试附加与您的路由变量具有相同键的查询参数,Next.js 不会抛出任何错误。您可以通过调用以下 URL 来轻松尝试:http://localhost:3000/greet/Mitch?name=Christine。您将注意到 Next.js 将优先考虑您的路由变量,因此您将在页面上看到 Hello, Mitch! 的显示。
客户端导航
如我们所见,Next.js 不仅关乎在服务器上渲染 React。它提供了多种方式来优化您网站的性能,其中之一就是它处理客户端导航的方式。
实际上,它支持 HTML 标准 <a> 标签用于链接页面,但它还提供了一种更优化的方式在各个路由之间导航:Link 组件。
我们可以将其作为标准 React 组件导入,并用于链接我们网站的不同页面或部分。让我们看看一个简单的例子:
import Link from 'next/link';
function Navbar() {
return (
<div>
<Link href='/about'>Home</Link>
<Link href='/about'>About</Link>
<Link href='/about'>Contacts</Link>
</div>
);
}
export default Navbar;
默认情况下,Next.js 会预加载视口中找到的每一个 Link,这意味着一旦我们点击其中一个链接,浏览器就已经拥有了渲染页面所需的所有数据。
您可以通过将 preload={false} 属性传递给 Link 组件来禁用此功能:
import Link from 'next/link';
function Navbar() {
return (
<div>
<Link href='/about' preload={false}>Home</Link>
<Link href='/about' preload={false}>About</Link>
<Link href='/about' preload={false}>Contacts</Link>
</div>
);
}
export default Navbar;
从 Next.js 10 版本开始,我们也可以轻松地使用动态路由变量链接页面。
假设我们想要链接以下页面:/blog/[date]/[slug].js。在使用 Next.js 的早期版本时,我们需要添加两个不同的属性:
<Link href='/blog/[date]/[slug]' as='/blog/2021-01-01/happy-new-year'>
Read post
</Link>
href 属性告诉 Next.js 我们想要渲染哪个页面,而 as 属性将告诉我们在浏览器地址栏中如何显示它。
多亏了 Next.js 10 中引入的增强功能,我们不再需要使用 as 属性,因为 href 属性足以设置我们想要渲染的页面以及浏览器地址栏中显示的 URL。例如,我们现在可以这样编写我们的链接:
<Link href='/blog/2021-01-01/happy-new-year'> Read post </Link>
<Link href='/blog/2021-03-05/match-update'> Read post </Link>
<Link href='/blog/2021-04-23/i-love-nextjs'> Read post </Link>
重要提示
虽然使用 Link 组件链接动态页面的传统方法在 Next.js >10 中仍然有效,但框架的最新版本使它变得更加简单。如果您有使用先前 Next.js 版本的经验,或者您愿意升级到版本 >10,请记住这个新功能,因为它将简化组件的开发,包括动态链接。
如果我们正在构建复杂的 URL,我们也可以将一个对象传递给 href 属性:
<Link
href={{
pathname: '/blog/[date]/[slug]'
query: {
date: '2020-01-01',
slug: 'happy-new-year',
foo: 'bar'
}
}}
/>
Read post
</Link>
一旦用户点击该链接,Next.js 将将浏览器重定向到以下 URL:http://localhost:3000/blog/2020-01-01/happy-new-year?foo=bar。
使用 router.push 方法
在 Next.js 网站页面之间移动的另一种方法是使用 useRouter 钩子。
假设我们只想让登录用户访问特定页面,并且我们已经有一个 useAuth 钩子来实现这一点。我们可以使用 useRouter 钩子动态地将用户重定向,如果在这种情况下他们未登录:
import { useEffect } from 'react';
import { useRouter } from 'next/router';
import PrivateComponent from '../components/Private';
import useAuth from '../hooks/auth';
function MyPage() {
const router = useRouter();
const { loggedIn } = useAuth();
useEffect(() => {
if (!loggedIn) {
router.push('/login')
}
}, [loggedIn]);
return loggedIn
? <PrivateComponent />
: null;
}
export default MyPage;
如您所见,我们使用useEffect钩子在客户端仅运行代码。在这种情况下,如果用户未登录,我们使用router.push方法将他们重定向到登录页面。
就像Link组件一样,我们可以通过传递一个对象到push方法来创建更复杂的页面路由:
router.push({
pathname: '/blog/[date]/[slug]',
query: {
date: '2021-01-01',
slug: 'happy-new-year',
foo: 'bar'
}
});
一旦调用router.push函数,浏览器将被重定向到 http://localhost:3000/blog/2020-01-01/happy-new-year?foo=bar。
重要提示
Next.js 无法像使用Link组件那样预取所有链接的页面。
当需要在某些动作发生后在客户端重定向用户时,使用router.push方法很方便,但不建议将其用作处理客户端导航的默认方式。
到目前为止,我们已经看到了 Next.js 如何处理静态和动态路由的导航,以及如何在客户端和服务器端程序化地强制重定向和导航。
在下一节中,我们将探讨 Next.js 如何帮助我们提供静态资源并即时优化图片以提高性能和 SEO 评分。
提供静态资源
使用术语静态资源,我们指的是所有那些非动态文件,例如图片、字体、图标、编译后的 CSS 和 JS 文件。
提供这些资源的最简单方法是通过 Next.js 提供的默认/public文件夹。实际上,这个文件夹中的每个文件都将被视为静态资源。我们可以通过创建一个名为index.txt的新文件并将其放入/public文件夹来证明这一点:
echo "Hello, world!" >> ./public/index.txt
如果我们现在尝试启动服务器,当我们访问localhost:3000/index.txt时,我们将在浏览器中看到文本Hello, world!显示。
在第四章 Next.js 中的代码库组织和数据获取中,我们将更详细地探讨组织公共文件夹以提供常见的 CSS 和 JS 文件、图片、图标以及其他类型的静态文件。
提供静态资源相对简单。然而,特定类型的文件可能会严重影响您的网站性能(以及 SEO):图片文件。
大多数情况下,提供未优化的图片会降低用户体验,因为它们可能需要一些时间来加载,一旦加载,它们会在渲染后移动布局的一部分,这可能会在用户体验方面引起许多问题。当这种情况发生时,我们谈论的是累积布局偏移(CLS)。以下是如何工作的简单表示:

图 3.1 – CLS 工作表示
在第一个浏览器标签中,图片尚未加载,因此两个文本区域看起来非常接近。图片加载后,它会将第二个文本区域向下移动。如果用户正在阅读第二个文本区域,他们很容易错过标记。
重要提示
如果你想了解更多关于 CLS 的信息,我推荐以下文章:web.dev/cls。
当然,Next.js 通过一个新的内置Image组件使得避免 CLS 变得简单。我们将在下一节中探讨这一点。
Next.js 的自动图片优化
从 Next.js 10 版本开始,该框架引入了一个新的有用Image组件和自动图片优化功能。
在 Next.js 引入这两个新功能之前,我们必须使用外部工具对每个图片进行优化,然后为每个 HTML <img>标签编写复杂的srcset属性,以设置不同屏幕尺寸的响应式图片。
事实上,自动图片优化将负责使用现代格式(如WebP)为所有支持这些格式的浏览器提供服务。但如果有浏览器不支持,它也能回退到较旧的图片格式,如png或jpg。它还会调整图片大小,以避免向客户端发送重量级的图片,因为这会负面影响资产的下载速度。
一个值得记住的伟大之处在于,自动图片优化是按需工作的,因为它仅在浏览器请求图片时才进行优化、调整大小和渲染。这很重要,因为它将与任何外部数据源(任何 CMS 或图片服务,如 Unsplash 或 Pexels)一起工作,并且不会减慢构建阶段。
我们可以在几分钟内在本地机器上尝试这个功能,亲自看看它是如何工作的。假设我们想要提供以下图片:

图 3.2 – 由Łukasz Rawa 在 Unsplash 上的图片 (unsplash.com/@lukasz_rawa)
使用标准的 HTML 标签,我们可以这样做:
<img
src='https://images.unsplash.com/photo-1605460375648-
278bcbd579a6'
alt='A beautiful English Setter'
/>
然而,我们也可能想要使用srcset属性来为响应式图片,因此我们实际上需要为不同的屏幕分辨率优化图片,这涉及到为我们的资产提供服务的一些额外步骤。
Next.js 通过仅配置next.config.js文件和使用Image组件,使得这个过程变得非常简单。我们之前提到过,我们想要提供来自 Unsplash 的图片,所以让我们将这个服务主机名添加到我们的next.config.js文件中的images属性下:
module.exports = {
images: {
domains: ['images.unsplash.com']
}
};
这样,每次我们在Image组件中使用来自该主机名的图片时,Next.js 都会自动为我们优化它。
现在,让我们尝试在页面中导入这张图片:
import Image from 'next/image';
function IndexPage() {
return (
<div>
<Image
src='https://images.unsplash.com/photo-
1605460375648-278bcbd579a6'
width={500}
height={200}
alt='A beautiful English Setter'
/>
</div>
);
}
export default IndexPage;
打开浏览器,你会注意到图片被拉伸以适应你在Image组件中指定的width和height属性。

图 3.3 – 我们刚刚创建的图片组件的表示
我们可以使用可选的 layout 属性裁剪图像以适应所需的尺寸。它接受四个不同的值:fixed、intrinsic、responsive 和 fill。让我们更详细地看看这些:
-
fixed与imgHTML 标签的工作方式相同。如果我们更改视口大小,它将保持相同的大小,这意味着它不会为较小(或较大)的屏幕提供响应式图像。 -
响应式与固定相反;当我们调整视口大小时,它会为我们的屏幕尺寸提供不同优化的图片。 -
固有介于固定和响应式之间;当我们调整视口大小时,它会提供不同的图像大小,但在大屏幕上会保留最大的图像不变。 -
fill会根据其父元素的宽度和高度拉伸图像;然而,我们不能同时使用fill和width、height属性。您可以使用fill或width和height)。
现在,如果我们想将我们的英语设定器图像固定显示在我们的屏幕上,我们可以按照以下方式重构我们的 Image 组件:
import Image from 'next/image';
function IndexPage() {
return (
<div>
<div
style={{ width: 500, height: 200, position:
'relative' }}
>
<Image
src='https://images.unsplash.com/photo-
1605460375648-278bcbd579a6'
layout='fill'
objectFit='cover'
alt='A beautiful English Setter'
/>
</div>
</div>
);
}
export default IndexPage;
如您所见,我们用固定大小的 div 和设置为 relative 的 CSS position 属性包裹了 Image 组件。我们还从我们的 Image 组件中移除了 width 和 height 属性,因为它们会根据其父 div 的大小进行拉伸。
我们还添加了设置为 cover 的 objectFit 属性,以便根据其父 div 的大小裁剪图像,这就是最终的结果。

图 3.4 – 使用布局属性设置为 "fill" 的图像组件表示
如果我们现在尝试在浏览器中检查生成的 HTML,我们将看到 Image 组件生成了许多不同的图像大小,这些大小将通过标准 img HTML 标签的 srcset 属性提供:
<div style="..."
<img alt="A beautiful English Setter"src="img/image?url=https%3A%2F%2Fimages.unsplash.com%2Fphoto-1605460375648-278bcbd579a6&w=3840&q=75" decoding="async" sizes="100vw" srcset="/_next/image?url=https%3A%2F%2Fimages.unsplash.com%2Fphoto-1605460375648-278bcbd579a6&w=640&q=75 640w, /_next/image?url=https%3A%2F%2Fimages.unsplash.com%2Fphoto-1605460375648-278bcbd579a6&w=750&q=75 750w, /_next/image?url=https%3A%2F%2Fimages.unsplash.com%2Fphoto-1605460375648-278bcbd579a6&w=828&q=75 828w, /_next/image?url=https%3A%2F%2Fimages.unsplash.com%2Fphoto-1605460375648-278bcbd579a6&w=1080&q=75 1080w, /_next/image?url=https%3A%2F%2Fimages.unsplash.com%2Fphoto-1605460375648-278bcbd579a6&w=1200&q=75 1200w, /_next/image?url=https%3A%2F%2Fimages.unsplash.com%2Fphoto-1605460375648-278bcbd579a6&w=1920&q=75 1920w, /_next/image?url=https%3A%2F%2Fimages.unsplash.com%2Fphoto-1605460375648-278bcbd579a6&w=2048&q=75 2048w, /_next/image?url=https%3A%2F%2Fimages.unsplash.com%2Fphoto-1605460375648-278bcbd579a6&w=3840&q=75 3840w" style="..."
</div>
最后值得一提的是,如果我们使用 Google Chrome 或 Firefox 检查图像格式,我们会看到它已经被作为 WebP 提供服务,即使从 Unsplash 提供的原始图像是 jpeg。如果我们现在尝试使用 iOS 上的 Safari 渲染相同的页面,Next.js 将提供原始的 jpeg 格式,因为(在撰写本文时)该 iOS 浏览器尚未支持 WebP 格式。
如本节开头所述,Next.js 会根据需要自动进行图像优化,这意味着如果某个图像从未被请求,它将不会被优化。
整个优化阶段都在 Next.js 运行的服务器上完成。如果您正在运行包含大量图像的 Web 应用程序,它可能会影响您的服务器性能。在下一节中,我们将看到如何将优化阶段委托给外部服务。
在外部服务上运行自动图像优化
默认情况下,自动图像优化是在与 Next.js 相同的服务器上运行的。当然,如果你在一个资源较少的小型服务器上运行你的网站,这可能会影响其性能。因此,Next.js 允许你通过在 next.config.js 文件中设置 loader 选项来在外部服务上运行自动图像优化:
module.exports = {
images: {
loader: 'akamai',
domains: ['images.unsplash.com']
}
};
如果你将你的网络应用程序部署到 Vercel,你实际上不需要在 next.config.js 文件中设置任何加载器,因为 Vercel 会为你优化和提供图像文件。否则,你可以使用以下外部服务:
-
Akamai:
www.akamai.com -
Imgix:
www.imgix.com -
Cloudinary:
cloudinary.com
如果你不想使用这些服务中的任何一个,或者你想使用你自己的自定义图像优化服务器,你可以在组件内部直接使用 loader 属性:
import Image from 'next/image'
const loader = ({src, width, quality}) => {
return `https://example.com/${src}?w=${width}&q=${quality
|| 75}`
}
function CustomImage() {
return (
<Image
loader={loader}
src="img/myimage.png"
alt="My image alt text"
width={350}
height={540}
/>
)
}
这样,你将能够提供来自任何外部服务的图像,这让你能够利用自定义图像优化服务器或免费、开源项目,如 Imgproxy (github.com/imgproxy/imgproxy) 或 Thumbor (https://github.com/thumbor/thumbor)。
重要提示
当你使用自定义加载器时,请记住,每个服务都有自己的 API 用于调整图像大小和提供图像。例如,要从 Imgproxy 提供图像,你需要使用以下 URL 调用它:https://imgproxy.example.com/thumbor.example.com/500x500/smart/example.com/images/myImage.jpg。
在创建自定义加载器之前,请阅读你的图像优化服务器的文档。
在过去几年中,正确提供图像的过程变得越来越复杂,但花些时间微调这个过程是值得的,因为它可以从许多关键方面影响我们的用户体验。幸运的是,Next.js 通过其内置组件和优化使这个过程变得相当简单。
然而,当我们构建网络应用程序时,我们也应该考虑网络爬虫、机器人和网络蜘蛛!我指的是那些将查看我们网页的元数据以执行索引、链接和评估等操作的网络技术。我们将在下一节中看到如何处理元数据。
处理元数据
正确处理元数据是现代网络开发的关键部分。为了简化,让我们考虑一下我们在 Facebook 或 Twitter 上分享链接的情况。如果我们把 React 网站 (reactjs.org) 分享到 Facebook,我们将在我们的帖子中看到以下卡片:

图 3.5 – https://reactjs.org 的 Open Graph 数据
)
为了知道应该显示在卡片内的哪些数据,Facebook 使用了一个名为 Open Graph 的协议(ogp.me)。为了将此信息提供给任何社交网络或网站,我们需要在我们的页面上添加一些元数据。
到目前为止,我们还没有讨论过如何动态设置开放图数据、HTML 标题或 HTML 元数据。虽然一个网站在技术上可以不使用这些数据而工作,但搜索引擎会惩罚你的页面,因为它们会错过重要信息。用户体验也可能受到负面影响,因为这些元标签会帮助浏览器为我们的用户创建优化的体验。
再次强调,Next.js 提供了一种解决这些问题的绝佳方法:内置的 Head 组件。确实,这个组件允许我们从任何组件更新我们 HTML 页面的 <head> 部分,这意味着我们可以根据用户的导航动态更改、添加或删除任何元数据、链接或脚本。
我们可以从我们元数据中最常见的动态部分开始:HTML <title> 标签。让我们设置一个新的 Next.js 项目,然后创建两个新的页面。
我们将创建的第一个页面是 index.js:
import Head from 'next/head';
import Link from 'next/link';
function IndexPage() {
return (
<>
<Head>
<title> Welcome to my Next.js website </title>
</Head>
<div>
<Link href='/about' passHref>
<a>About us</a>
</Link>
</div>
</>
);
}
export default IndexPage;
第二个页面是 about.js:
import Head from 'next/head';
import Link from 'next/link';
function AboutPage() {
return (
<>
<Head>
<title> About this website </title>
</Head>
<div>
<Link href='/'passHref>
<a>Back to home</a>
</Link>
</div>
</>
);
}
export default AboutPage;
运行服务器后,您可以在这两个页面之间导航,并看到 <title> 内容会根据您访问的路由而变化。
现在,让我们使事情变得稍微复杂一些。我们想要创建一个新的组件,它只显示一个按钮。一旦我们点击它,我们当前所在的页面标题将改变;我们可以通过再次点击按钮来始终恢复到原始标题。
让我们在项目根目录中创建一个新的文件夹 components/,并在其中创建一个新的文件 components/Widget.js:
import { useState } from 'react;
import Head from 'next/head';
function Widget({pageName}) {
const [active, setActive] = useState(false);
if (active) {
return (
<>
<Head>
<title> You're browsing the {pageName} page
</title>
</Head>
<div>
<button onClick={() =>setActive(false)}>
Restore original title
</button>
Take a look at the title!
</div>
</>
);
}
return (
<>
<button onClick={() =>setActive(true)}>
Change page title
</button>
</>
);
}
export default Widget;
太好了!现在让我们编辑我们的 index.js 和 about.js 页面,以包含该组件。
我们将首先打开 index.js 文件,导入 Widget 组件,然后我们将将其渲染在一个新的 <div> 中:
import Head from 'next/head';
import Link from 'next/link';
import Widget from '../components/Widget';
function IndexPage() {
return (
<>
<Head>
<title> Welcome to my Next.js website </title>
</Head>
<div>
<Link href='/about' passHref>
<a>About us</a>
</Link>
</div>
<div>
<Widget pageName='index' />
</div>
</>
);
}
export default IndexPage;
让我们对 about.js 页面做同样的事情:
import Head from 'next/head';
import Link from 'next/link';
import Widget from '../components/Widget';
function AboutPage() {
return (
<>
<Head>
<title> About this website </title>
</Head>
<div>
<Link href='/'passHref>
<a>Back to home</a>
</Link>
</div>
<div>
<Widget pageName='about' />
</div>
</>
);
}
export default AboutPage;
在这个标题重构之后,每次我们点击 <title> 元素。
重要提示
如果多个组件试图更新同一个元标签,Next.js 有时会重复相同的标签,但内容不同。例如,如果我们有两个组件正在编辑 <title> 标签,我们最终可能会在我们的 <head> 中有两个不同的 <title> 标签。我们可以通过向我们的 HTML 标签添加 key 属性来避免这种情况:
<title key='htmlTitle'>some content</title>。这样,Next.js 将寻找带有该特定键的每个 HTML 标签,并更新它而不是添加一个新的。
到目前为止,我们已经看到了如何在页面和组件中处理元数据,但在某些情况下,您可能希望在不同的组件上使用相同的元标签。在这些情况下,您可能不想为每个组件从头开始重写所有元数据,因此出现了通过创建一个专门用于处理这种 HTML 标签的整个组件来分组元数据的概念。我们将在下一节中更详细地探讨这种方法。
将常见的元标签分组
到目前为止,我们可能想要向我们的网站添加许多其他元标签以提高其 SEO 性能。问题是,我们很容易就创建出包含基本上相同标签的大型页面组件。因此,创建一个或多个组件(根据您的需求而定)来处理大多数常见的 head 元标签是一种常见的做法。
假设我们想在网站上添加一个博客部分。我们可能希望为我们的博客帖子添加对开放图数据、Twitter 卡片和其他元数据的支持,这样我们就可以轻松地将所有这些常见数据组合在一个 PostHead 组件中。
让我们创建一个新的文件,components/PostHead.js,并添加以下脚本:
import Head from 'next/head';
function PostMeta(props) {
return (
<Head>
<title> {props.title} </title>
<meta name="description" content={props.subtitle} />
{/* open-graph meta */}
<meta property="og:title" content={props.title} />
<meta property="og:description"
content={props.subtitle} />
<meta property="og:image" content={props.image} />
{/* twitter card meta */}
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content={props.title} />
<meta name="twitter:description"
content={props.description} />
<meta name="twitter:image" content={props.image} />
</Head>
);
}
export default PostMeta;
现在,让我们为我们的帖子创建一个模拟。我们将在 data 文件夹中创建一个新的文件夹,并创建一个名为 posts.js 的文件:
export default [
{
id: 'qWD3Pzce',
slug: 'dog-of-the-day-the-english-setter',
title: 'Dog of the day: the English Setter',
subtitle: 'The English Setter dog breed was named
for these dogs\' practice of "setting", or crouching
low, when they found birds so hunters could throw
their nets over them',
image: 'https://images.unsplash.com/photo-
1605460375648-278bcbd579a6'
},
{
id: 'yI6BK404',
slug: 'about-rottweiler',
title: 'About Rottweiler',
subtitle:
"The Rottweiler is a breed of domestic dog, regarded
as medium-to-large or large. The dogs were known in
German as Rottweiler Metzgerhund, meaning Rottweil
butchers' dogs, because their main use was to herd
livestock and pull carts laden with butchered meat
to market",
image: 'https://images.unsplash.com/photo-
1567752881298-894bb81f9379'
},
{
id: 'VFOyZVyH',
slug: 'running-free-with-collies',
title: 'Running free with Collies',
subtitle:
'Collies form a distinctive type of herding dogs,
including many related landraces and standardized
breeds. The type originated in Scotland and Northern
England. Collies are medium-sized, fairly lightly-
built dogs, with pointed snouts. Many types have a
distinctive white color over the shoulders',
image: 'https://images.unsplash.com/photo-
1517662613602-4b8e02886677'
}
];
太好了!现在我们只需要创建一个 [slug] 页面来显示我们的帖子。完整的路由将是 /blog/[slug],所以让我们在 pages/blog/ 中创建一个名为 [slug].js 的新文件,并添加以下内容:
import PostHead from '../../components/PostHead';
import posts from '../../data/posts';
export function getServerSideProps({ params }) {
const { slug } = params;
const post = posts.find((p) => p.slug === slug);
return {
props: {
post
}
};
}
function Post({ post }) {
return (
<div>
<PostHead {...post} />
<h1>{post.title}</h1>
<p>{post.subtitle}</p>
</div>
);
}
export default Post;
如果我们现在访问 http://localhost:3000/blog/dog-of-the-day-the-english-setter 并检查生成的 HTML,我们将看到以下标签:
<head>
...
<title> Dog of the day: the English Setter </title>
<meta name="description" content="The English Setter dog
breed was named for these dogs' practice of "setting",
or crouching low, when they found birds so hunters could
throw their nets over them">
<meta property="og:title" content="Dog of the day: the
English Setter">
<meta property="og:description" content="The English
Setter dog breed was named for these dogs' practice of
"setting", or crouching low, when they found birds so
hunters could throw their nets over them">
<meta property="og:image" content=
"https://images.unsplash.com/photo-1605460375648-
278bcbd579a6">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Dog of the day: the
English Setter">
<meta name="twitter:description">
<meta name="twitter:image" content=
"https://images.unsplash.com/photo-1605460375648-
278bcbd579a6">
...
</head>
现在,尝试浏览其他博客帖子,看看每个帖子的 HTML 内容是如何变化的。
这种方法不是强制性的,但它允许您逻辑上分离与头部相关的组件和其他组件,从而使得代码库更加有序。
但如果我们需要在每个页面上都有相同的元标签(或者至少是一些常见的基本数据)怎么办?我们实际上不需要在每个页面上重写每个标签或导入一个通用组件。我们将在下一节中看到如何通过自定义我们的 _app.js 文件来避免这种情况。
自定义 _app.js 和 _document.js 页面
在某些情况下,您需要控制页面初始化,以便每次渲染页面时,Next.js 都需要在向客户端发送结果 HTML 之前运行某些操作。为此,该框架允许我们在 pages/ 目录中创建两个新文件,分别称为 _app.js 和 _document.js。
_app.js 页面
默认情况下,Next.js 附带以下pages/_app.js文件:
import '../styles/globals.css'
function MyApp({ Component, pageProps }) {
return <Component {...pageProps} />
}
export default MyApp
如您所见,该函数只是返回 Next.js 页面组件(Component属性)及其属性(pageProps)。
但现在,假设我们想在所有页面上共享一个导航栏,而不需要在每个页面上手动导入该组件。我们可以从在 components/Navbar.js 中创建导航栏开始:
import Link from 'next/link';
function Navbar() {
return (
<div
style={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 25
}}
>
<div>My Website</div>
<div>
<Link href="/">Home </Link>
<Link href="/about">About </Link>
<Link href="/contacts">Contacts </Link>
</div>
</div>
);
}
export default Navbar;
那是一个非常简单的导航栏,只有三个链接,这将允许我们在网站上导航。
现在,我们需要在 _app.js 页面中导入它,如下所示:
import Navbar from '../components/Navbar';
function MyApp({ Component, pageProps }) {
return (
<>
<Navbar />
<Component {...pageProps} />
</>
);
}
export default MyApp;
如果我们现在再创建两个页面(about.js 和 contacts.js),我们会看到导航栏组件将在任何页面上渲染。
现在,让我们通过添加对暗色和亮色主题的支持使其变得更加复杂。我们将通过创建一个 React 上下文并在我们的 _app.js 文件中包裹 <Component /> 组件来实现这一点。
让我们从在 components/themeContext.js 中创建一个上下文开始:
import { createContext } from 'react';
const ThemeContext = createContext({
theme: 'light',
toggleTheme: () => null
});
export default ThemeContext;
现在,让我们回到我们的 _app.js 文件,创建主题状态,内联 CSS 样式,并将页面组件包裹在一个上下文提供者中:
import { useState } from 'react';
import ThemeContext from '../components/themeContext';
import Navbar from '../components/Navbar';
const themes = {
dark: {
background: 'black',
color: 'white'
},
light: {
background: 'white',
color: 'black'
}
};
function MyApp({ Component, pageProps }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(theme === 'dark' ? 'light' : 'dark');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
<div
style={{
width: '100%',
minHeight: '100vh',
...themes[theme]
}}
>
<Navbar />
<Component {...pageProps} />
</div>
</ThemeContext.Provider>
);
}
export default MyApp;
最后但同样重要的是,我们需要添加一个按钮来切换暗色/亮色主题。我们将将其添加到导航栏中,所以让我们打开 components/Navbar.js 文件并添加以下代码:
import { useContext } from 'react';
import Link from 'next/link';
import themeContext from '../components/themeContext';
function Navbar() {
const { toggleTheme, theme } = useContext(themeContext);
const newThemeName = theme === 'dark' ? 'light' : 'dark';
return (
<div
style={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 25
}}
>
<div>My Website</div>
<div>
<Link href="/">Home </Link>
<Link href="/about">About </Link>
<Link href="/contacts">Contacts </Link>
<button onClick={toggleTheme}>
Set {newThemeName} theme
</button>
</div>
</div>
);
}
export default Navbar;
如果你尝试切换暗色主题,然后使用导航栏在所有网站页面之间导航,你会看到 Next.js 在每个路由之间保持主题状态的一致性。
在自定义 _app.js 页面时,有一件重要的事情需要记住,它不是为了使用 getServerSideProps 或 getStaticProps 运行数据获取而设计的,就像其他页面那样。它的主要用例是在导航期间在页面之间维护状态(暗色/亮色主题、购物车中的项目等)、添加全局样式、处理页面布局或向页面 props 添加附加数据。
如果出于某种原因,你绝对需要在每次渲染页面时在服务器端获取数据,你仍然可以使用内置的 getInitialProps 函数,但这样做是有代价的。你将失去动态页面中的自动静态优化,因为 Next.js 将需要为每个页面执行服务器端渲染。
如果这个代价对你的 Web 应用来说是可接受的,你可以很容易地按照以下方式使用那个内置方法:
import App from 'next/app'
function MyApp({ Component, pageProps }) {
return <Component {...pageProps} />
};
MyApp.getInitialProps = async (appContext) => {
const appProps = await App.getInitialProps(appContext);
const additionalProps = await fetch(...)
return {
...appProps,
...additionalProps
}
};
export default MyApp;
当一个自定义 _app.js 文件允许我们自定义渲染页面组件的方式时,可能会有一些情况它无法帮助;例如,当我们需要自定义 HTML 标签,如 <html> 或 <body> 时。我们将在下一节中学习如何做到这一点。
_document.js 页面
当我们编写 Next.js 页面组件时,我们不需要定义基本的 HTML 标签,如 <head>、<html> 或 <body>。我们已经看到如何使用 Head 组件来自定义 <head> 标签,但我们需要对 <html> 和 <body> 标签采取不同的方法。
为了渲染这两个基本标签,Next.js 使用一个名为 Document 的内置类,它允许我们通过在 pages/ 目录中创建一个名为 _document.js 的新文件来扩展它,就像我们对 _app.js 文件所做的那样:
import Document,{
Html,
Head,
Main,
NextScript
} from 'next/document';
class MyDocument extends Document {
static async getInitialProps(ctx) {
const initialProps =
await Document.getInitialProps(ctx);
return { ...initialProps };
}
render() {
return (
<Html>
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
}
export default MyDocument;
让我们分解一下我们刚刚创建的 _document.js 页面。首先,我们通过导入 Document 类开始,我们将扩展这个类来添加我们的自定义脚本。然后,我们导入四个强制组件,以便我们的 Next.js 应用程序能够正常工作:
-
Html: 这是我们的 Next.js 应用的<html>标签。我们可以将其任何标准 HTML 属性(如lang)作为属性传递给它。 -
Head: 我们可以使用这个组件来处理所有应用页面共有的标签。这不是我们在上一章中看到的Head组件。它们的行为相似,但我们应该只将其用于所有网站页面共有的代码。 -
Main: 这将是 Next.js 渲染我们页面组件的地方。浏览器不会初始化<Main>之外的所有组件,所以如果我们需要在页面之间共享通用组件,我们应该将它们放在_app.js文件中。 -
NextScript: 如果你尝试检查由 Next.js 生成的 HTML 页面,你可能已经注意到它添加了一些自定义 JavaScript 脚本到你的标记中。在这些脚本内部,我们可以找到运行客户端逻辑、React hydration 等所需的所有代码。
移除前面提到的任何四个组件都会破坏我们的 Next.js 应用,所以在编辑_document.js页面之前,请确保导入它们。
就像_app.js一样,_document.js不支持服务器端数据获取方法,如getServerSideProps和getStaticProps。我们仍然可以访问getInitialProps方法,但我们应该避免在其中放置数据获取函数,因为这会禁用自动站点优化,迫使服务器在每次请求时都进行服务器端渲染页面。
摘要
在本章中,我们介绍了许多使 Next.js 成为一个出色的框架的重要概念。我们现在知道如何以最少的努力正确地提供图像,通过预取目标页面在页面之间导航,动态创建和删除自定义元数据,以及创建动态路由以使用户体验更加动态。我们还查看了对_app.js和_document.js文件的定制,这将使我们能够以最少的努力在所有应用页面之间保持用户界面的一致性。
到目前为止,我们一直避免调用外部 REST API,因为这给我们的应用引入了额外的复杂性。我们将在下一章中介绍这个主题,了解如何在客户端和服务器端集成REST和GraphQL API。
第二部分:动手实践 Next.js
在本部分,我们将开始编写一些小的 Next.js 应用程序,重点关注每一章的主要主题。我们将了解在采用 UI 框架、样式方法、测试策略等方面如何做出正确的决策。
本节包括以下章节:
-
第四章, 在 Next.js 中组织代码库和获取数据
-
第五章, 在 Next.js 中管理本地和全局状态
-
第六章, CSS 和内置样式方法
-
第七章, 使用 UI 框架
-
第八章, 使用自定义服务器
-
第九章, 测试 Next.js
-
第十章, 与 SEO 合作并管理性能
-
第十一章, 不同的部署平台
第四章:组织 Next.js 中的代码库和获取数据
Next.js 之所以最初受到欢迎,是因为它能够使在服务器上而不是仅客户端渲染 React 页面变得容易。然而,为了渲染特定的组件,我们通常需要从外部来源(如 API 和数据库)获取一些数据。
在本章中,我们将首先了解如何组织我们的文件夹结构,因为这将是保持 Next.js 数据流整洁时管理应用程序状态的决定因素(正如我们将在第五章,Next.js 中的本地和全局状态管理中看到的那样),然后我们将了解如何在客户端和服务器端集成外部 REST 和 GraphQL API。
随着我们的应用程序的增长,其复杂性不可避免地会增加,我们需要为此做好准备,因为项目的启动阶段。一旦我们实现新功能,我们就需要添加新的组件、实用工具、样式和页面。因此,我们将更详细地研究基于原子设计原则组织我们的组件,实用函数,样式,以及如何使您的代码库能够快速且整洁地处理应用程序状态。
我们将详细介绍以下主题:
-
使用原子设计原则组织我们的组件
-
组织我们的实用函数
-
整洁地组织静态资源
-
组织样式文件的简介
-
lib文件是什么以及如何组织它们 -
仅在服务器端消费 REST API
-
仅在客户端消费 REST API
-
设置 Apollo 以在客户端和服务器端消费 GraphQL API
到本章结束时,您将了解如何通过遵循组件的原子设计原则来组织您的代码库,以及如何逻辑地分割不同的实用文件。您还将学习如何消费 REST 和 GraphQL API。
技术要求
要运行本章中的代码示例,您需要在您的本地机器上安装 Node.js 和 npm。如果您愿意,可以使用在线 IDE,例如repl.it或codesandbox.io,因为它们都支持 Next.js,并且您不需要在您的计算机上安装任何依赖项。
您可以在 GitHub 上找到本章的代码库:github.com/PacktPublishing/Real-World-Next.js。
组织文件夹结构
整洁且清晰地组织您的新项目文件夹结构对于保持代码库可扩展和可维护至关重要。
正如我们已经看到的,Next.js 强制你将一些文件和文件夹放置在你的代码库的特定位置(想想_app.js和_documents.js文件,pages/和public/目录等),但它也提供了一种方法来自定义它们在你项目仓库中的位置。
我们已经看到了这一点,但让我们快速回顾一下默认的 Next.js 文件夹结构:
next-js-app
- node_modules/
- package.json
- pages/
- public/
- styles/
从上到下阅读,当我们使用 create-next-app 创建一个新的 Next.js 应用程序时,我们会得到以下文件夹:
-
node_modules/:Node.js 项目依赖项的默认文件夹 -
pages/:放置我们的页面并构建我们的 Web 应用程序路由系统的目录 -
public/:放置要作为静态资源(编译后的 CSS 和 JavaScript 文件、图像和图标)提供的文件的目录 -
styles/:放置我们的样式模块的目录,无论其格式如何(CSS、SASS、LESS)
从这里,我们可以开始自定义我们的仓库结构,使其更容易导航。首先要知道的是,Next.js 允许我们将 pages/ 目录移动到 src/ 文件夹内部。我们还可以将所有其他目录(当然不包括 public/ 和 node_modules)移动到 src/ 内部,使我们的根目录更加整洁。
重要提示
记住,如果您在项目中同时有 pages/ 和 src/pages/ 目录,Next.js 将忽略 src/pages/,因为根级别的 pages/ 目录具有优先级。
在下一节中,我们将探讨一些组织整个代码库的流行约定,从 React 组件开始。
组织组件
现在,让我们看看一个真实世界的文件夹结构示例,包括一些样式资产(第六章,CSS 和内置样式方法)和测试文件(第九章,测试 Next.js)。
就目前而言,我们将只讨论一个可以帮助我们轻松编写和查找配置文件、组件、测试和样式的文件夹结构。我们将在各自的章节中深入探讨之前引用的技术。
我们有不同方式来设置我们的文件夹结构。我们可以从将组件分为三个不同的类别开始,然后将样式和测试放在每个组件的同一文件夹中。
要做到这一点,在我们的根目录内创建一个新的 components/ 文件夹。然后,进入它,创建以下文件夹:
mkdir components && cd components
mkdir atoms
mkdir molecules
mkdir organisms
mkdir templates
如您可能已经注意到的,我们正在遵循 原子设计原则,我们希望将我们的组件划分为不同的层级,以便更好地组织我们的代码库。这只是一个流行的约定,您可以根据自己的喜好选择任何其他方法来组织您的代码。
我们将我们的组件划分为四个类别:
-
atoms:这是我们将在代码库中编写的最基本组件。有时,它们充当标准 HTML 元素(如button、input和p)的包装器,但我们也可以向这类组件添加动画、调色板等。 -
molecules:这些是由原子组合而成的小组,用于创建具有最小实用性的稍微复杂一些的结构。输入原子和标签原子一起可以是一个分子是什么的简单例子。 -
organisms:分子和原子结合形成复杂结构,例如注册表单、页脚和轮播图。 -
templates:我们可以将模板视为我们页面的骨架。在这里,我们决定将有机体、原子和分子放在一起,以创建用户最终将浏览的最终页面。
如果你想要了解更多关于原子设计的信息,这里有一篇很好的文章详细解释了它:bradfrost.com/blog/post/atomic-web-design。
现在,让我们假设我们想要创建一个Button组件。当我们创建一个新的组件时,我们通常至少需要三个不同的文件:组件本身、其样式和一个测试文件。我们可以通过移动到components/atoms/并创建一个名为Button/的新文件夹来创建这些文件。一旦我们创建了此文件夹,我们就可以继续创建组件的文件:
cd components/atoms/Button
touch index.js
touch button.test.js
touch button.styled.js # or style.module.css
以这种方式组织我们的组件将极大地帮助我们,当我们需要搜索、更新或修复给定的组件时。假设我们在生产环境中发现了一个涉及我们的Button组件的 bug。我们可以在我们的代码库中轻松找到该组件,找到其测试和样式文件,并修复它们。
当然,遵循原子设计原则不是必须的,但我个人推荐这样做,因为它有助于保持项目结构整洁且易于长期维护。
组织实用工具
有一些文件不导出任何组件;它们只是用于许多不同目的的模块化脚本。我们在这里谈论的是实用脚本。
让我们假设我们有几个组件,它们的目的是检查一天中的特定小时是否已经过去以显示某些信息。在每一个组件内部编写相同的函数是没有意义的。因此,我们可以编写一个通用的实用函数,然后将其导入需要这种功能的每个组件中。
我们可以将所有的实用函数放在一个utility/文件夹中,并根据它们的目的将我们的实用工具分成不同的文件。例如,假设我们需要四个实用函数:第一个将基于当前时间进行计算,第二个将在localStorage上执行某些操作,第三个将处理JWT (JSON Web Token),最后一个将帮助我们为我们的应用程序编写更好的日志。
我们可以通过在utilities/目录内创建四个不同的文件来继续:
cd utilities/
touch time.js
touch localStorage.js
touch jwt.js
touch logs.js
现在我们已经创建了文件,我们可以通过创建它们各自的测试文件来继续:
touch time.test.js
touch localStorage.test.js
touch jwt.test.js
touch logs.test.js
到目前为止,我们已经根据它们的范围将我们的实用工具分组,这使得在开发过程中记住在哪个文件中导入特定的函数变得容易。
可能还有其他组织实用文件的方法。你可能想为每个实用文件创建一个文件夹,以便你可以在其中放置测试、样式和其他内容,从而使你的代码库更加有序。这完全取决于你!
组织静态资源
如前一章所示,Next.js 使提供静态文件变得容易,您只需将它们放在 public/ 文件夹中,框架就会完成剩余的工作。
从这个点开始,我们需要确定我们需要从 Next.js 应用程序中提供哪些静态文件。
在一个标准网站上,我们可能至少想要提供以下静态资源:
-
图片
-
编译后的 JavaScript 文件
-
编译后的 CSS 文件
-
图标(包括 favicon 和 web 应用图标)
-
manifest.json、robot.txt和其他静态文件
进入我们的 public/ 文件夹,我们可以创建一个名为 assets/ 的新目录:
cd public && mkdir assets
在新创建的目录内部,我们将为每种类型的静态资源创建一个新的文件夹:
cd assets
mkdir js
mkdir css
mkdir icons
mkdir images
我们将把编译后的供应商 JavaScript 文件放在 js/ 目录中,同样地,编译后的供应商 CSS 文件也会放在 css/ 目录中(当然是在 css/ 目录中)。当我们启动 Next.js 服务器时,我们可以在 http://localhost:3000/assets/js/<任何-js 文件> 和 http://localhost:3000/assets/css/<任何-css 文件> 下访问这些公共文件。我们还可以通过调用以下 URL 来访问每个公共图像,http://localhost:3000/assets/image/<任何图像文件>,但我建议您使用内置的 Image 组件来提供这类资源,正如前一章所展示的。
icons/ 目录主要用于提供我们的 web 应用清单 图标。web 应用清单是一个包含有关您正在构建的渐进式网络应用的一些有用信息的 JSON 文件,例如应用名称和安装到移动设备时使用的图标。您可以在 web.dev/add-manifest 上了解更多关于 web 应用清单的信息。
我们可以很容易地创建这个清单文件,通过进入 public/ 文件夹并添加一个名为 manifest.json 的新文件:
cd public/ && touch manifest.json
到目前为止,我们可以用一些基本信息填充 JSON 文件。以下是一个 JSON 示例:
{
"name": "My Next.js App",
"short_name": "Next.js App",
"description": "A test app made with next.js",
"background_color": "#a600ff",
"display": "standalone",
"theme_color": "#a600ff",
"icons": [
{
"src": "/assets/icons/icon-192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "/assets/icons/icon-512.png",
"type": "image/png",
"sizes": "512x512"
}
]
}
我们可以使用 HTML meta 标签来包含该文件,如第三章中所示,Next.js 基础和内置组件:
<link rel="manifest" href="/manifest.json">
这样,从移动设备浏览您的 Next.js 应用程序的用户将能够在他们的智能手机或平板电脑上安装它。
样式组织
样式组织实际上可以取决于您想要用于样式化 Next.js 应用的堆栈。
从 CSSinJS 框架,如 Emotion、styled-components、JSS 和类似框架开始,一个常见的方法是为每个组件创建一个特定的样式文件;这样,当我们需要做出一些更改时,我们更容易在我们的代码库中找到特定的组件样式。
然而,尽管根据各自的组件来分离样式文件可以帮助我们保持代码库的整洁,我们可能还需要创建一些通用样式或实用文件,例如调色板、主题和媒体查询。
在这种情况下,重用默认 Next.js 安装中提供的默认 styles/ 目录可能很有用。我们可以将我们的常用样式放在这个文件夹中,并在需要时在其他样式文件中导入它们。
话虽如此,实际上并没有一个标准的组织样式文件的方法。我们将在 第六章,CSS 和内置样式方法,以及 第七章,使用 UI 框架 中更详细地查看这些文件。
Lib 文件
当谈到 lib 文件时,我们指的是明确封装第三方库的脚本作为 lib 文件。虽然实用脚本非常通用,可以被许多不同的组件和库使用,但 lib 文件是针对特定库的。为了使这个概念更清晰,让我们暂时谈谈 GraphQL。
正如我们将在本章的最后部分 数据获取 中看到的,我们需要初始化一个 GraphQL 客户端,本地保存一些 GraphQL 查询和突变,等等。为了使这些脚本更模块化,我们将它们存储在一个名为 graphql/ 的新文件夹中,该文件夹位于 lib/ 目录内,位于我们项目的根目录。
如果我们尝试可视化前述示例的文件夹结构,我们将得到以下模式:
next-js-app
- lib/
- graphql/
- index.js
- queries/
- query1.js
- query2.js
- mutations/
- mutation1.js
- mutation2.js
其他 lib 脚本可以包括所有连接和向 Redis、RabbitMQ 等进行查询的文件,或者针对任何外部库的特定函数。
当谈论 Next.js 数据流时,一个有组织的文件夹结构似乎与上下文不符,但实际上它可以帮助我们管理应用程序状态,正如我们将在 第五章,在 Next.js 中管理本地和全局状态 中看到的。
但谈到应用程序状态,我们希望大多数时候我们的组件是动态的,这意味着它们可以根据全局应用程序状态或来自外部服务的数据进行内容渲染和行为变化。实际上,在许多情况下,我们需要调用外部 API 来动态检索我们的网络应用程序内容。在下一节中,我们将看到如何使用 GraphQL 和 REST 客户端在客户端和服务器端获取数据。
数据获取
如前几章所述,Next.js 允许我们在客户端和服务器端获取数据。服务器端数据获取可以在两个不同的时刻发生:在构建时(使用 getStaticProps 为静态页面),以及在运行时(使用 getServerSideProps 为服务器端渲染的页面)。
数据可以来自多个资源:数据库、搜索引擎、外部 API、文件系统,以及许多其他来源。尽管技术上 Next.js 可以访问数据库并查询特定数据,但我个人会劝阻这种做法,因为 Next.js 应该只关心我们应用程序的前端。
让我们举一个例子:我们正在构建一个博客,我们想要显示一个作者页面,展示他们的姓名、职位和传记。在这个例子中,数据存储在 MySQL 数据库中,我们可以使用任何 Node.js 的 MySQL 客户端轻松访问它。
尽管从 Next.js 访问这些数据相对容易,但这会使我们的应用安全性降低。恶意用户可能会找到一种方法利用未知框架漏洞来利用我们的数据,注入恶意代码,并使用其他技术窃取我们的数据。
因此,我强烈建议将数据库连接和查询委托给外部系统(换句话说,如 WordPress、Strapi 和 Contentful 这样的 CMS)或后端框架(换句话说,Spring、Laravel 和 Ruby on Rails),这将确保数据来自可信的来源,将检测到潜在的恶意代码的用户输入进行清理,并在您的 Next.js 应用程序和它们的 API 之间建立安全的连接。
在接下来的章节中,我们将看到如何从客户端和服务器端集成 REST 和 GraphQL API。
在服务器端获取数据
如我们所见,Next.js 允许我们通过使用其内置的 getStaticProps 和 getServerSideProps 函数在服务器端获取数据。
由于 Node.js 不支持浏览器那样的 JavaScript fetch API,我们在服务器上发起 HTTP 请求有两个选择:
-
使用 Node.js 内置的
http库:我们可以不安装任何外部依赖项就使用此模块,但即使它的 API 简单且制作精良,与第三方 HTTP 客户端相比,它可能需要做更多额外的工作。 -
使用 HTTP 客户端库:Next.js 有几个优秀的 HTTP 客户端,使得从服务器发起 HTTP 请求变得非常简单。流行的库包括 isomorphic-unfetch(使 JavaScript
fetchAPI 在 Node.js 上可用)、Undici(官方 Node.js HTTP 1.1 客户端)和 Axios(一个非常流行的 HTTP 客户端,在客户端和服务器上运行,具有相同的 API)。
在下一节中,我们将使用 Axios 来发起 REST 请求,因为它可能是客户端和服务器上最常用的 HTTP 客户端之一(每周在 npm 上下载量约为 ~17,000,000 次),你迟早会用到它。
在服务器端消费 REST API
当讨论 REST API 的集成时,我们需要将它们分为 公共 和 私有 API。公共 API 任何人都可以无授权访问,而私有 API 总是需要授权才能返回某些数据。
此外,授权方法并不总是相同的(不同的 API 可能需要不同的授权方法),因为它取决于谁开发了 API 以及他们的选择。例如,如果您想使用任何 Google API,您将需要进入一个称为 OAuth 2.0 的过程,这是在用户身份验证下保护 API 的行业标准。您可以在官方 Google 文档中了解更多关于 OAuth 2.0 的信息:developers.google.com/identity/protocols/oauth2。
其他 API,如 Pexels API(www.pexels.com/api/documentation),允许您使用 API key 消费其内容,这基本上是一个授权令牌,您需要在请求中发送它。
可能还有其他方式可以授权您的请求,但 Oauth 2.0、JWT 和 API Key 是在开发 Next.js 应用程序时您可能会遇到的最常见方式。
如果在阅读本节后,您想尝试不同的 API 和授权方法,这里有一个包含免费 REST API 列表的出色的 GitHub 仓库:https://github.com/public-apis/public-apis。
目前,我们将使用为本书专门制作的自定义 API:api.realworldnextjs.com(或者,如果您更喜欢:https://api.rwnjs.com)。我们可以从创建一个新的 Next.js 项目开始:
npx create-next-app ssr-rest-api
在运行 Next.js 初始化脚本后,我们可以添加 axios 作为依赖项,因为我们将其用作 HTTP 客户端来制作 REST 请求:
cd ssr-rest-api
yarn add axios
在这一点上,我们可以轻松地编辑默认的 Next.js 首页。在这里,我们将使用公开 API 列出一些用户,仅暴露他们的用户名和个人 ID。点击其中一个用户名后,我们将被重定向到一个详情页面,以查看更多关于我们用户的个人信息。
让我们从创建 pages/index.js 页面布局开始:
import { useEffect } from 'react';
import Link from 'next/link';
export async function getServerSideProps() {
// Here we will make the REST request to our APIs
}
function HomePage({ users }) {
return (
<ul>
{
users.map((user) =>
<li key={user.id}>
<Link
href={`/users/${user.username}`}
passHref
>
<a> {user.username} </a>
</Link>
</li>
)
}
</ul>
)
}
export default HomePage;
如果我们尝试运行前面的代码,我们会看到错误,因为我们还没有我们的用户数据。我们需要从内置的 getServerSideProps 调用一个 REST API,并将请求结果作为 prop 传递给 HomePage 组件:
import { useEffect } from 'react';
import Link from 'next/link';
import axios from 'axios';
export async function getServerSideProps() {
const usersReq =
await axios.get('https://api.rwnjs.com/04/users')
return {
props: {
users: usersReq.data
}
}
}
function HomePage({ users }) {
return (
<ul>
{
users.map((user) =>
<li key={user.id}>
<Link
href={`/users/${user.username}`}
passHref
>
<a> {user.username} </a>
</Link>
</li>
)
}
</ul>
)
}
export default HomePage;
现在,运行服务器然后访问 http://localhost:3000。我们应该在浏览器中看到以下用户列表:
![Figure 4.1 – API result rendered on the browser]
![Figure 4.01_B16985.jpg]
图 4.1 – 浏览器上渲染的 API 结果
如果我们现在尝试点击列表中的任何一个用户,我们将被重定向到一个 404 页面,因为我们还没有创建单个页面用户。
我们可以通过创建一个新的文件,pages/users/[username].js,并调用另一个 REST API 来获取单个用户数据来解决这个问题。
要获取单个用户数据,我们可以调用以下 URL,https://api.rwnjs.com/04/users/[username],其中 [username] 是一个路由变量,代表我们想要获取数据的用户。
让我们转到 pages/users/[username].js 文件,并添加以下内容,从 getServerSideProps 函数开始:
import Link from 'next/link';
import axios from 'axios';
export async function getServerSideProps(ctx) {
const { username } = ctx.query;
const userReq =
await axios.get(
`https://api.rwnjs.com/04/users/${username}`
);
return {
props: {
user: userReq.data
}
};
}
现在,在同一个文件中,让我们添加一个 UserPage 函数,它将是我们的 /users/[username] 路由的页面模板:
function UserPage({ user }) {
return (
<div>
<div>
<Link href="/" passHref>
Back to home
</Link>
</div>
<hr />
<div style={{ display: 'flex' }}>
<img
src={user.profile_picture}
alt={user.username}
width={150}
height={150}
/>
<div>
<div>
<b>Username:</b> {user.username}
</div>
<div>
<b>Full name:</b>
{user.first_name} {user.last_name}
</div>
<div>
<b>Email:</b> {user.email}
</div>
<div>
<b>Company:</b> {user.company}
</div>
<div>
<b>Job title:</b> {user.job_title}
</div>
</div>
</div>
</div>
);
}
export default UserPage;
但仍然存在一个问题:如果我们尝试渲染单个用户页面,由于我们没有权限从该 API 获取数据,我们将在服务器端收到错误。还记得我们在这个部分开头提到的话吗?并非所有 API 都是公开的,这在很多情况下是有意义的,因为有时我们想访问非常私人的信息,公司和开发人员通过仅允许授权人员访问他们的 API 来保护这些信息。
在那种情况下,我们需要在发起 API 请求时传递一个有效的令牌作为 HTTP 授权头,这样服务器就会知道我们有权限访问这些信息:
export async function getServerSideProps(ctx) {
const { username } = ctx.query;
const userReq = await axios.get(
`https://api.rwnjs.com/04/users/${username}`,
{
headers: {
authorization: process.env.API_TOKEN
}
}
);
return {
props: {
user: userReq.data
}
};
}
如您所见,axios 使得向请求中添加 HTTP 头变得非常简单,因为我们只需要将一个对象作为其 get 方法的第二个参数传递,该对象包含一个名为 headers 的属性,它是一个对象,包含我们想要在请求中发送到服务器的所有 HTTP 头。
你可能想知道 process.env.API_TOKEN 是什么意思。虽然可以将硬编码的字符串作为该头部的值传递,但出于以下原因,这并不是一个好的做法:
-
当你使用 Git 或其他版本控制系统提交代码时,所有有权访问该存储库的人都将能够读取私人信息,如授权令牌(即使是在外部协作者)。请将其视为应该保密的密码。
-
大多数情况下,API 令牌会根据我们运行应用程序的阶段而变化:在本地运行我们的应用程序时,我们可能想使用测试令牌访问 API,而在部署时使用生产令牌。使用环境变量将使我们能够根据环境使用不同的令牌。对于 API 端点也是如此,但我们将稍后在本文档的这一部分中看到。
-
如果出于任何原因 API 令牌发生变化,你可以轻松地使用整个应用程序的共享环境文件来编辑它,而不是在每个 HTTP 请求中更改令牌值。
因此,我们不必手动在我们的文件中写入敏感数据,我们可以在项目的根目录内创建一个新的文件名为 .env,并将我们应用程序运行所需的所有信息添加到该文件中。
永远不要提交 .env 文件
.env 文件包含敏感和私人信息,不应使用任何版本控制软件提交。在部署或提交代码之前,请确保将 .env 添加到 .gitignore、.dockerignore 和其他类似文件中。
现在,让我们创建并编辑 .env 文件,添加以下内容:
API_TOKEN=realworldnextjs
API_ENDPOINT=https://api.rwnjs.com
Next.js 内置了对 .env 和 .env.local 文件的支持,因此你不需要安装外部库来访问这些环境变量。
编辑完文件后,我们可以重启 Next.js 服务器,并点击主页上列出的任何用户,从而访问用户详情页面,其外观应如下所示:

图 4.2 – 用户详情页面
如果我们尝试访问像http://localhost:3000/users/mitch这样的页面,我们会得到一个错误,因为不存在用户名为mitch的用户,REST API 将返回404状态码。我们可以轻松地捕获这个错误,并通过在getServerSideProps函数中添加以下脚本返回 Next.js 默认的 404 页面:
export async function getServerSideProps(ctx) {
const { username } = ctx.query;
const userReq = await axios.get(
`${process.env.API_ENDPOINT}/04/users/${username}`,
{
headers: {
authorization: process.env.API_TOKEN
}
}
);
if (userReq.status === 404) {
return {
notFound: true
};
}
return {
props: {
user: userReq.data
}
};
}
这样,Next.js 将自动将我们重定向到其默认的404页面,无需其他配置。
因此,我们已经看到了 Next.js 如何通过使用其内置的getServerSideProps函数允许我们在服务器端独家获取数据。我们本可以使用getStaticProps函数,这意味着页面将在构建时进行静态渲染,正如在第二章中看到的,探索不同的渲染策略。
在下一节中,我们将看到如何仅从客户端获取数据。
在客户端获取数据
客户端数据获取是任何动态 Web 应用的关键部分。虽然服务器端数据获取相对安全(当谨慎操作时),但在浏览器上获取数据可能会增加一些额外的复杂性和漏洞。
在服务器上执行 HTTP 请求会隐藏 API 端点、参数、HTTP 头信息以及可能存在的授权令牌,从而保护用户信息不被泄露。然而,从浏览器端执行此类操作可能会暴露这些私人信息,使得恶意用户能够轻易地执行一系列可能的攻击,从而利用你的数据。
在浏览器上发起 HTTP 请求时,一些特定的规则是必不可少的:
-
仅向可信来源发起 HTTP 请求。你应该始终对开发你使用的 API 的开发者及其安全标准进行一些研究。
-
仅当使用 SSL 证书加密时才调用 HTTP API。如果一个远程 API 没有在 HTTPS 下进行加密,那么你和你用户的个人信息就会暴露在许多攻击之下,例如中间人攻击,恶意用户可以通过简单的代理嗅探客户端和服务器之间传输的所有数据。
-
永远不要从浏览器连接到远程数据库。这看似显而易见,但从技术上讲,JavaScript 可以访问远程数据库。这会使你和你用户面临高风险,因为任何人都有可能利用漏洞并访问你的数据库。
在下一节中,我们将更深入地探讨客户端如何消费 REST API。
在客户端消费 REST API
与服务器端类似,客户端获取数据相对简单,如果你已经具备 React 或其他 JavaScript 框架或库的经验,你可以直接利用现有知识,从浏览器端发起 REST 请求而无需任何复杂操作。
在 Next.js 中,服务器端数据获取阶段仅在声明在其内置的getServerSideProps和getStaticProps函数内时发生,如果我们在一个给定组件内发起 fetch 请求,它将默认在客户端执行。
我们通常希望客户端请求在两种情况下运行:
-
在组件挂载后立即
-
在特定事件发生后
在这两种情况下,Next.js 不会强制您以不同于 React 的方式执行这些请求,因此您基本上可以使用浏览器的内置fetch API 或像axios这样的外部库来发起 HTTP 请求,就像我们在上一节中看到的那样。让我们尝试重新创建上一节中相同的简单 Next.js 应用程序,但将所有 API 调用移动到客户端。
创建一个新的 Next.js 项目,并编辑pages/index.js文件,如下所示:
import { useEffect, useState } from 'react';
import Link from 'next/link';
function List({users}) {
return (
<ul>
{
users.map((user) =>
<li key={user.id}>
<Link
href={`/users/${user.username}`}
passHref
>
<a> {user.username} </a>
</Link>
</li>
)
}
</ul>
)
}
function Users() {
const [loading, setLoading] = useState(true);
const [data, setData] = useState(null);
useEffect(async () => {
const req =
await fetch('https://api.rwnjs.com/04/users');
const users = await req.json();
setLoading(false);
setData(users);
}, []);
return (
<div>
{loading &&<div>Loading users...</div>}
{data &&<List users={data} />}
</div>
)
}
export default Users;
您能发现这个组件与其 SSR 对应组件之间的区别吗?
-
服务器端生成的 HTML 包含
Loading users...文本,因为这是我们HomePage组件的初始状态。 -
只有在 React hydration 完成后,我们才能看到用户列表。我们需要等待组件在客户端挂载,并使用浏览器的
fetchAPI 发起 HTTP 请求。
现在我们需要按照以下方式实现单用户页面:
-
让我们创建一个新的文件,
pages/users/[username].js,并开始编写getServerSideProps函数,其中我们从路由中获取[username]变量,并从.env文件中获取授权令牌:import { useEffect, useState } from 'react' import Link from 'next/link'; export async function getServerSideProps({ query }) { const { username } = query; return { props: { username, authorization: process.env.API_TOKEN } } } -
现在,在同一个文件中,让我们创建
UserPage组件,我们将在这里执行客户端数据获取函数:function UserPage({ username, authorization }) { const [loading, setLoading] = useState(true); const [data, setData] = useState(null); useEffect(async () => { const req = await fetch( `https://api.rwnjs.com/04/users/${username}`, { headers: { authorization } } ); const reqData = await req.json(); setLoading(false); setData(reqData); }, []); return ( <div> <div> <Link href="/" passHref> Back to home </Link> </div> <hr /> {loading && <div>Loading user data...</div>} {data && <UserData user={data} />} </div> ); } export default UserPage;正如您可能已经注意到的,一旦我们使用
setData钩子函数设置数据,我们就渲染一个<UserData />组件。 -
创建该组件,始终在同一个
pages/users/[username].js组件内部:function UserData({ user }) { return ( <div style={{ display: 'flex' }}> <img src={user.profile_picture} alt={user.username} width={150} height={150} /> <div> <div> <b>Username:</b> {user.username} </div> <div> <b>Full name:</b> {user.first_name} {user.last_name} </div> <div> <b>Email:</b> {user.email} </div> <div> <b>Company:</b> {user.company} </div> <div> <b>Job title:</b> {user.job_title} </div> </div> </div> ) }如您所见,我们正在使用与主页相同的方法,即组件在客户端挂载后立即发起 HTTP 请求。我们还通过
getServerSideProps将服务器的API_TOKEN传递到客户端,以便我们可以用它来发起授权请求。然而,如果您尝试运行前面的代码,您将至少看到两个问题。第一个问题与CORS有关。
在HomePage组件中,我们已经能够从不同域名(localhost、replit.co 域名、CodeSandbox 域名等)调用https://api.rwnjs.com/04/users API,因为服务器允许任何域名访问该特定路由的资源。
然而,在这种情况下,浏览器对https://api.rwnjs.com/04/users/[username]端点有一些限制,我们无法直接从客户端调用此 API,因为我们被 CORS 策略阻止。CORS 有时可能很棘手,我鼓励你阅读 Mozilla 开发者网络页面上的更多关于此安全策略的信息:developer.mozilla.org/en-US/docs/Web/HTTP/CORS。
第二个问题与向客户端暴露授权令牌有关。实际上,如果我们打开谷歌 Chrome 开发者工具并转到网络,我们可以选择端点的 HTTP 请求,并在请求头部分看到授权令牌的纯文本:


图 4.3 – HTTP 请求头
那么,这有什么问题吗?
假设您正在为通过 API 公开实时天气更新的服务付费,并且假设每 100 次请求的费用为 1 美元。
一个想要免费使用相同服务的恶意用户可以轻易地在请求头中找到您的私有授权令牌,并使用它来为他们的天气网络应用供电。这样,如果恶意用户发起 1,000 次请求,您将支付 10 美元,而实际上并没有使用他们的服务。
我们可以快速解决这两个问题,多亏了 Next.js API 页面,它允许我们快速创建 REST API,在服务器端使用 HTTP 请求,并将结果返回给客户端。
让我们在pages/内部创建一个名为api/的新文件夹和一个新文件,pages/api/singleUser.js:
import axios from 'axios';
export default async function handler(req, res) {
const username = req.query.username;
const API_ENDPOINT = process.env.API_ENDPOINT;
const API_TOKEN = process.env.API_TOKEN;
const userReq = await axios.get(
`${API_ENDPOINT}/04/users/${username}`,
{ headers: { authorization: API_TOKEN } }
);
res
.status(200)
.json(userReq.data);
}
如您所见,在这种情况下,我们暴露了一个接受两个参数的简单函数:
-
req:Node.js 的http.IncomingMessage实例(nodejs.org/api/http.html#http_class_http_incomingmessage),与一些预构建的中间件合并,例如req.cookies、req.query和req.body。 -
res:Node.js 的http.serverResponse实例(nodejs.org/api/http.html#http_class_http_serverresponse),与一些预构建的中间件合并,例如res.status(code)用于设置 HTTP 状态码,res.json(json)用于返回有效的 JSON,res.send(body)用于发送包含string、object或Buffer的 HTTP 响应,以及res.redirect([status,] path)用于重定向到具有给定(可选)状态码的特定页面。
pages/api/目录中的每个文件都将被 Next.js 视为一个 API 路由。
现在我们可以通过将 API 端点更改为新创建的一个来重构我们的UserPage组件:
function UserPage({ username }) {
const [loading, setLoading] = useState(true);
const [data, setData] = useState(null);
useEffect(async () => {
const req = await fetch(
`/api/singleUser?username=${username}`,
);
const data = await req.json();
setLoading(false);
setData(data);
}, []);
return (
<div>
<div>
<Link href="/" passHref>
Back to home
</Link>
</div>
<hr />
{loading && <div>Loading user data...</div>}
{data && <UserData user={data} />}
</div>
);
}
如果我们现在尝试运行我们的网站,我们将看到我们的两个问题都得到了解决!
但我们仍然需要注意一些事情。我们通过编写一种为单个用户 API 的 代理 来隐藏了 API 令牌,但恶意用户仍然能够轻松地使用 /api/singleUser 路由来访问私有数据。
为了解决那个特定的问题,我们可以采取各种不同的方式:
-
仅在服务器上渲染组件列表,就像在上一节中那样:这样,恶意用户就不会调用私有 API 或窃取秘密的 API 令牌。然而,在某些情况下,你只能在服务器上运行这些类型的 API 调用;如果你需要在用户点击按钮后进行 REST 请求,你被迫在客户端进行。
-
使用一种身份验证方法,让经过身份验证的用户只能访问特定的 API(JWT、API 密钥等)。
-
使用如 Ruby on Rails、Spring、Laravel、Nest.js 和 Strapi 这样的后端框架:它们都提供了不同的方法来保护你的 API 调用免受客户端的攻击,这使得我们创建安全的 Next.js 应用程序变得更加容易。
在 第十三章 使用 Next.js 和 GraphCMS 构建电子商务网站 中,我们将看到如何将 Next.js 作为不同 CMS 和电子商务平台的前端使用,我们还将涵盖用户身份验证和安全的 API 调用。现在,在本章中,我们只关注如何从服务器和客户端进行 HTTP 请求。
在下一节中,我们将看到如何将 GraphQL 作为 REST 的替代方案用于 Next.js 中的数据获取。
消费 GraphQL API
GraphQL 已经在 API 世界中掀起了一场革命,并且由于其易用性、模块化和灵活性,它的受欢迎程度正在不断增加。
对于那些不太熟悉 GraphQL 的人来说,它基本上是一种查询语言,由 Facebook 在 2012 年首次发明。与 REST 或 SOAP 等其他网络服务架构相比,它在数据获取和处理的关键方面进行了许多改进。实际上,它允许你避免数据过度获取(你可以简单地查询你需要的字段),在单个请求中获取多个资源,为你的数据提供一个强类型和静态类型的接口,避免 API 版本化,等等。
在本节中,我们将使用 Apollo Client (www.apollographql.com/docs/react),这是一个非常流行的 GraphQL 客户端,内置了对 React 和 Next.js 的支持,用于构建一个非常简单的在线签名簿。
让我们先创建一个新的项目:
npx create-next-app signbook
现在,让我们添加一些依赖项:
yarn add @apollo/client graphql isomorphic-unfetch
我们现在需要为我们的 Next.js 应用程序创建一个 Apollo 客户端。我们将通过在 lib/apollo/index.js 内部创建一个新文件,然后编写以下函数来完成:
import { useMemo } from 'react';
import {
ApolloClient,
HttpLink,
InMemoryCache
} from '@apollo/client';
let uri = 'https://rwnjssignbook.herokuapp.com/v1/graphql';
let apolloClient;
function createApolloClient() {
return new ApolloClient({
ssrMode: typeof window === 'undefined',
link: new HttpLink({ uri }),
cache: new InMemoryCache(),
});
}
如您所假设,通过设置ssrMode: typeof window === "undefined",我们将使用相同的 Apollo 实例来处理客户端和服务器。此外,ApolloClient使用浏览器的fetch API 来发送 HTTP 请求,因此我们需要导入一个 polyfill 来使其在服务器端也能工作;在这种情况下,我们将使用isomorphic-unfetch。
如果您尝试在浏览器上运行api.realworldnextjs.com/04/signbook/graphql,它将重定向您到一个公共的GraphCMS GraphQL 编辑器。实际上,我们将使用那个无头 CMS 作为我们目前正在编写的应用程序的数据源。
在同一个lib/apollo/index.js文件中,让我们添加一个新的函数来初始化 Apollo 客户端:
export function initApollo(initialState = null) {
const client = apolloClient || createApolloClient();
if (initialState) {
client.cache.restore({
...client.extract(),
...initialState
});
}
if (typeof window === "undefined") {
return client;
}
if (!apolloClient) {
apolloClient = client;
}
return client;
}
这个函数将允许我们避免为每个页面重新创建一个新的 Apollo 客户端。实际上,我们将在服务器上(在之前编写的apolloClient变量中)存储一个客户端实例,我们可以将初始状态作为参数传递。如果我们将此参数传递给initApollo函数,它将与本地缓存合并,以便在移动到另一个页面时恢复状态的完整表示。
为了实现这一点,我们首先需要在lib/apollo/index.js文件中添加另一个import语句。鉴于使用复杂的初始状态重新初始化 Apollo 客户端在性能上可能是一个昂贵的任务,我们将使用 React 的useMemo钩子来加速这个过程:
import { useMemo } from "react";
然后,我们将导出最后一个函数:
export function useApollo(initialState) {
return useMemo(
() => initApollo(initialState),
[initialState]
);
}
转到我们的pages/目录,我们现在可以创建一个新的_app.js文件,正如在第三章,Next.js 基础和内置组件中看到的。在这里,我们将使用官方的 Apollo 上下文提供者包裹整个应用:
import { ApolloProvider } from "@apollo/client";
import { useApollo } from "../lib/apollo";
export default function App({ Component, pageProps }) {
const apolloClient =
useApollo(pageProps.initialApolloState);
return (
<ApolloProvider client={apolloClient}>
<Component {...pageProps} />
</ApolloProvider>
);
}
我们现在可以开始编写我们的查询了!
我们将把我们的查询组织在一个名为lib/apollo/queries/的新文件夹中。
让我们从创建一个新的文件lib/apollo/queries/getLatestSigns.js开始,公开以下 GraphQL 查询:
import { gql } from "@apollo/client";
const GET_LATEST_SIGNS = gql`
query GetLatestSigns($limit: Int! = 10, $skip: Int! = 0){
sign(
offset: $skip,
limit: $limit,
order_by: { created_at: desc }
) {
uuid
created_at
content
nickname
country
}
}
`;
export default GET_LATEST_SIGNS;
我们现在可以在pages/index.js文件中导入这个查询,并尝试使用 Apollo 和 Next.js 进行我们的第一个 GraphQL 请求:
import { useQuery } from "@apollo/client";
import GET_LATEST_SIGNS from
'../lib/apollo/queries/getLatestSigns'
function HomePage() {
const { loading, data } = useQuery(GET_LATEST_SIGNS, {
fetchPolicy: 'no-cache',
});
return <div></div>
}
export default HomePage
如您所见,Apollo 客户端的使用非常简单。多亏了useQuery钩子,我们将能够访问三个不同的状态:
-
loading:正如其名所示,它仅在请求完成或仍在挂起时返回true或false。 -
error:如果请求因任何原因失败,我们将能够捕获错误并向用户发送一条友好的消息。 -
data:包含我们通过查询请求的数据。
现在,让我们暂时回到主页。为了简单起见,我们将只添加一个远程的TailwindCSS依赖项来为我们的演示应用进行样式设计。在第六章 CSS 和内置样式方法和第七章 使用 UI 框架中,我们将看到如何优化和集成 UI 框架,但现在,我们将保持简单,因为我们只想关注应用的数据获取部分。
打开pages/index.js文件并按照以下方式编辑它:
import Head from "next/head";
import { ApolloProvider } from "@apollo/client";
import { useApollo } from "../lib/apollo";
export default function App({ Component, pageProps }) {
const apolloClient =
useApollo(pageProps.initialApolloState || {});
return (
<ApolloProvider client={apolloClient}>
<Head>
<link href="https://unpkg.com/tailwindcss@²/dist/ tailwind.min.css"
rel="stylesheet"
/>
</Head>
<Component {...pageProps} />
</ApolloProvider>
);
}
现在,我们可以创建一个新的文件,components/Loading.js。在我们从GraphCMS获取标志时,我们将渲染它:
function Loading() {
return (
<div
className="min-h-screen w-screen flex justify-center
items-center">
Loading signs from Hasura...
</div>
);
}
export default Loading;
一旦我们成功获取了所需的数据,我们需要在主页上显示它。为此,我们将在components/Sign.js文件内创建一个新的组件,内容如下:
function Sign({ content, nickname, country }) {
return (
<div className="max-w-7xl rounded-md border-2 border-
purple-800 shadow-xl bg-purple-50 p-7 mb-10">
<p className="text-gray-700"> {content} </p>
<hr className="mt-3 mb-3 border-t-0 border-b-2
border-purple-800" />
<div>
<div className="text-purple-900">
Written by <b>{nickname}</b>
{country && <span> from {country}</span>}
</div>
</div>
</div>
);
}
export default Sign;
现在,让我们将这两个新组件集成到我们的主页中:
import { useQuery } from "@apollo/client";
import GET_LATEST_SIGNS from
'../lib/apollo/queries/getLatestSigns'
import Sign from '../components/Sign'
import Loading from '../components/Loading'
function HomePage() {
const { loading, error, data } =
useQuery(GET_LATEST_SIGNS, {
fetchPolicy: 'no-cache',
});
if (loading) {
return <Loading />;
}
return (
<div className="flex justify-center items-center flex-
col mt-20">
<h1 className="text-3xl mb-5">Real-World Next.js
signbook</h1>
<Link href="/new-sign">
<button className="mb-8 border-2 border-purple-800
text-purple-900 p-2 rounded-lg text-gray-50
m-auto mt-4">
Add new sign
</button>
</Link>
<div>
{data.sign.map((sign) => (
<Sign key={sign.uuid} {...sign} />
))}
</div>
</div>
);
}
export default HomePage
如果我们现在尝试浏览主页,我们将看到一个标志列表!
我们也可以通过在pages/new-sign.js下创建一个新的页面来创建一个添加新标志的简单路由。让我们先为该页面添加所需的imports:
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useMutation } from "@apollo/client";
import ADD_SIGN from "../lib/apollo/queries/addSign";
如您所见,我们正在从不同的库中导入大量函数。我们将使用useStateReact 钩子来跟踪我们的表单变化以便提交标志,Next.js 的useRouter钩子用于在用户创建了一个新标志后将用户重定向到主页,以及 Apollo 的useMutation钩子用于在 GraphCMS 上创建一个新的标志。我们还import了一个名为ADD_SIGN的新 GraphQL 变异,我们将在创建此页面后详细了解它。
接下来,我们创建页面结构:
function NewSign() {
const router = useRouter();
const [formState, setFormState] = useState({});
const [addSign] = useMutation(ADD_SIGN, {
onCompleted() {
router.push("/");
}
});
const handleInput = ({ e, name }) => {
setFormState({
...formState,
[name]: e.target.value
});
};
}
export default NewSign;
从上到下阅读,我们可以看到我们正在使用 Apollo 的useMutation钩子创建一个新的标志。一旦标志被正确创建,它将运行onCompleted回调,我们将在此回调中将用户重定向到主页。
接下来,我们来看看组件体内声明的下一个函数,我们可以清楚地看到我们将使用handleInput函数,通过 React 的useState钩子动态设置表单状态,一旦用户在表单输入中输入任何内容。
现在,我们需要渲染包含三个输入的实际 HTML 表单:用户的nickname,要在signbook中写入的消息,以及(可选的)用户写作的country:
return (
<div className="flex justify-center items-center flex-
col mt-20">
<h1 className="text-3xl mb-10">Sign the Real-World
Next.js signbook!</h1>
<div className="max-w-7xl shadow-xl bg-purple-50 p-7
mb-10 grid grid-rows-1 gap-4 rounded-md border-2
border- purple-800">
<div>
<label htmlFor="nickname" className="text-purple-
900 mb-2">
Nickname
</label>
<input
id="nickname"
type="text"
onChange={(e) => handleInput({ e, name:
'nickname' })}
placeholder="Your name"
className="p-2 rounded-lg w-full"
/>
</div>
<div>
<label htmlFor="content" className="text-purple-
900 mb-2">
Leave a message!
</label>
<textarea
id="content"
placeholder="Leave a message here!"
onChange={(e) => handleInput({ e, name:
'content' })}
className="p-2 rounded-lg w-full"
/>
</div>
<div>
<label htmlFor="country" className="text-purple-
900 mb-2">
If you want, write your country name and its
emoji flag
</label>
<input
id="country"
type="text"
onChange={(e) => handleInput({ e, name:
'country' })}
placeholder="Country"
className="p-2 rounded-lg w-full"
/>
<button
className="bg-purple-600 p-4 rounded-lg text-
gray-50 m-auto mt-4"
onClick={() => addSign({ variables: formState })}>
Submit
</button>
</div>
</div>
<Link href="/" passHref>
<a className="mt-5 underline"> Back to the
homepage</a>
</Link>
</div>
);
)
让我们更详细地看看我们是如何通过点击提交按钮创建一个变异的:
onClick={() => addSign({ variables: formState})}
如您所见,我们正在将来自useState钩子的formState变量中存储的整个状态传递给addSign函数使用的variables属性:
const [addSign] = useMutation(ADD_SIGN, {
onCompleted() {
router.push("/");
}
});
addSign函数代表将新标志添加到 GraphCMS 的变异,我们可以通过传递一个与lib/apollo/queries/addSign.js文件中编写的变异变量匹配的对象来添加动态数据:
import { gql } from "@apollo/client";
const ADD_SIGN = gql`
mutation InsertNewSign(
$nickname: String!,
$content: String!,
$country: String
) {
insert_sign(objects: {
nickname: $nickname,
country: $country,
content: $content
}) {
returning {
uuid
}
}
}
`;
export default ADD_SIGN;
事实上,ADD_SIGN 变异操作需要三个参数变量:$nickname、$content 和 $country。使用反映变异变量命名的表单字段名,我们可以简单地将整个表单状态作为值传递给我们的变异操作。
你现在可以尝试创建一个新的签名。提交表单后,你将被自动重定向到主页,你将在页面顶部看到你的签名。
摘要
在本章中,我们讨论了在谈论 Next.js 时的两个关键主题:项目结构组织和获取数据的不同方式。即使这两个主题看似无关,能够逻辑上分离组件和工具,以及以不同的方式获取数据,都是让你更好地理解下一章,第五章**, 在 Next.js 中管理本地和全局状态 的基本技能。正如我们在本章中所看到的,任何应用程序的复杂性都只能随着时间的推移而增长,因为我们添加了更多功能、错误修复等等。拥有一个良好的文件夹结构和清晰的数据流可以帮助我们跟踪应用程序的状态。
我们还探讨了如何使用 GraphQL 获取数据。这是一个令人兴奋的话题,因为在下一章中,我们将看到如何将 Apollo Client 作为除 GraphQL 客户端之外的状态管理器来使用。
第五章:在 Next.js 中管理本地和全局状态
状态管理是任何 React 应用程序(包括 Next.js 应用程序)的核心部分之一。当谈到状态时,我们指的是那些动态信息片段,使我们能够创建高度交互的用户界面(UIs),使客户的体验尽可能美丽和愉悦。
考虑到现代网站,我们可以在 UI 的许多部分中发现状态变化:从浅色主题切换到深色主题意味着我们在改变 UI 主题状态,用我们的运输信息填写电子商务表单意味着我们在改变该表单状态,甚至点击一个简单的按钮也可能潜在地改变本地状态,因为它可以使我们的 UI 以许多不同的方式反应,这取决于开发者如何决定管理那个状态更新。
尽管状态管理使我们能够在应用程序内部创建美丽的交互,但它也带来了一些额外的复杂性。许多开发者提出了非常不同的解决方案来管理它们,使我们能够以更直接和更有组织的方式管理应用程序状态。
谈到 React,特别是从库的第一个版本开始,我们就有了访问类组件的权限,其中类保持本地状态,使我们能够通过setState方法与之交互。随着更现代的 React 版本(>16.8.0)的引入,这个过程通过引入 React Hooks(包括useState钩子)得到了简化。
在 React 应用程序中管理状态的最大困难是数据流应该是单向的,这意味着我们可以将给定状态作为 prop 传递给子组件,但不能对父元素做同样的事情。这意味着本地状态管理可以因为类组件和 Hooks 而变得轻松,但全局状态管理可能会变得非常复杂。
在本章中,我们将探讨两种不同的管理全局应用程序状态的方法。首先,我们将了解如何使用 React Context API;然后,我们将使用Redux重写应用程序,这将让我们了解如何在客户端和服务器端初始化外部状态管理库。
我们将详细探讨以下主题:
-
本地状态管理
-
通过 Context API 管理应用程序状态
-
通过 Redux 管理应用程序状态
到本章结束时,您将了解本地状态管理和全局状态管理之间的区别。您还将学习如何使用 React 内置的 Context API 或外部库(如 Redux)来管理全局应用程序状态。
技术要求
要运行本章中的代码示例,您需要在您的本地计算机上安装 Node.js 和 npm。如果您愿意,可以使用在线 IDE,例如repl.it或 https://codesandbox.io,因为它们都支持 Next.js,您不需要在您的计算机上安装任何依赖项。
与其他章节一样,您可以在 GitHub 上找到本章的代码库:github.com/PacktPublishing/Real-World-Next.js。
本地状态管理
当我们谈论本地状态管理时,我们指的是组件作用域的应用状态。我们可以用一个基本的Counter组件来概括这个概念:
import React, { useState } from "react";
function Counter({ initialCount = 0 }) {
const [count, setCount] = useState(initialCount);
return (
<div>
<b>Count is: {count}</b><br />
<button onClick={() => setCount(count + 1)}>
Increment +
</button>
<button onClick={() => setCount(count - 1)}>
Decrement -
</button>
</div>
)
}
export default Counter;
当我们点击Increment按钮时,我们将1添加到当前的count值。反之,当我们点击Decrement按钮时,我们将从该值中减去1;没什么特别的!
虽然对于父组件来说,将initialCount值作为Counter元素的 prop 传递很容易,但将当前的count值传递给父组件可能要复杂得多。在许多情况下,我们只需要管理本地状态,React 的useState钩子可以是一个做这件事的绝佳方式。这些情况可能包括(但不限于)以下内容:
-
原子组件:如第四章中所述,在 Next.js 中组织代码库和获取数据,原子是我们可能遇到的最重要的 React 组件,它们很可能只管理少量本地状态。在许多情况下,更复杂的状态可以委托给分子或组织体。
-
在获取请求完成之前将
loading状态设置为true,以便在 UI 上显示一个漂亮的加载指示器。
React 钩子如useState和useReducer使本地状态管理变得轻而易举,而且大多数时候,您不需要任何外部库来处理它。
当您需要维护所有组件的全局应用状态时,情况可能会有所变化。一个典型的例子可能是一个电子商务网站,一旦您将商品添加到购物车,您可能希望在导航栏内显示您购买的产品数量。
我们将在下一节中详细讨论这个特定的例子。
全局状态管理
当我们谈论全局应用状态时,我们指的是给定 Web 应用中所有组件之间共享的状态,因此任何组件都可以访问和修改。
如前所述,React 的数据流是单向的,这意味着组件可以向其子组件传递数据,但不能向其父组件传递(与 Vue 或 Angular 不同)。这使得我们的组件更不容易出错,更容易调试,更高效,但增加了额外的复杂性:默认情况下,不能有全局状态。
让我们看看以下场景:

图 5.1 – 产品卡片和购物车中项目之间的链接
在前一个屏幕截图所示的 Web 应用中,我们希望显示许多产品,并让我们的用户将它们放入购物车。这里最大的问题是导航栏中显示的数据与产品卡片之间没有链接,并且当用户点击某个产品的“添加”按钮时,立即更新购物车中产品数量的操作可能相当复杂。而且如果我们想在页面更改时保持这些信息呢?一旦单个卡片组件及其本地状态卸载,这些信息就会丢失。
今天,许多库使管理这些情况变得容易一些:Redux、Recoil和MobX只是最受欢迎的解决方案之一,但还有其他方法。实际上,随着 React Hooks 的引入,我们可以使用Context API来管理全局应用状态,而无需外部库。还有一个不太受欢迎的方法,我想考虑一下:使用Apollo Client(及其内存缓存)。这将改变我们看待状态的方式,并为我们提供了一个正式的查询语言,用于与全局应用数据交互。如果您对这个方法感兴趣,我强烈建议阅读官方的 Apollo GraphQL 教程:www.apollographql.com/docs/react/local-state/local-state-management.
从下一节开始,我们将构建一个非常简单的店面,就像我们在上一幅图中看到的那样。一旦用户将一个或多个产品添加到购物车中,我们将在导航栏中更新计数。一旦用户决定进行结账,我们需要在结账页面上显示所选的产品。
使用 Context API
随着React v16.3.0的发布,该版本于 2018 年发布,我们终于可以访问稳定的 Context API。它们为我们提供了一个简单的方法,在给定上下文内的所有组件之间共享数据,而无需显式地通过 props 从一个组件传递到另一个组件,甚至是从子组件传递到父组件。如果您想了解更多关于 React Context 的信息,我强烈建议阅读官方的 React 文档:https://reactjs.org/docs/context.html.
从本节开始,我们将始终使用相同的样板代码来使用不同的库处理全局状态管理。您可以在以下位置找到此样板代码:https://github.com/PacktPublishing/Real-World-Next.js/tree/main/05-state-management-made-easy/boilerplate.
为了简化起见,我们将采用相同的方法在全局状态中存储所选产品;我们的状态将是一个 JavaScript 对象。每个属性是产品的 ID,其值将表示用户所选产品的数量。如果你打开data/items.js文件,你会找到一个表示我们产品的对象数组。如果用户选择了四个胡萝卜和两个洋葱,我们的状态将如下所示:
{
"8321-k532": 4,
"9126-b921": 2
}
话虽如此,让我们首先为我们的购物车创建上下文。我们可以通过创建一个新的文件来实现:components/context/cartContext.js:
import { createContext } from 'react';
const ShoppingCartContext = createContext({
items: {},
setItems: () => null,
});
export default ShoppingCartContext;
就像在典型的客户端渲染的 React 应用中一样,我们现在希望将所有需要共享购物车数据的组件都包裹在同一个上下文中。例如,/components/Navbar.js组件需要被挂载在/components/ProductCard.js组件相同的上下文中。
我们还应该考虑,当页面发生变化时,我们希望我们的全局状态保持持久,因为我们希望在结账页面上显示用户所选产品的数量。因此,我们可以自定义/pages/_app.js页面,如第三章**,Next.js 基础和内置组件所示,将整个应用程序包裹在同一个 React 上下文中:
import { useState } from 'react';
import Head from 'next/head';
import CartContext from
'../components/context/cartContext';
import Navbar from '../components/Navbar';
function MyApp({ Component, pageProps }) {
const [items, setItems] = useState({});
return (
<>
<Head>
<link
href="https://unpkg.com/tailwindcss@²/dist/tailwind.min.css"
rel="stylesheet"
/>
</Head>
<CartContext.Provider value={{ items, setItems }}>
<Navbar />
<div className="w-9/12 m-auto pt-10">
<Component {...pageProps} />
</div>
</CartContext.Provider>
</>
);
}
export default MyApp;
正如你所见,我们在同一个上下文中包裹了<Navbar />和<Component {...pageProps />。这样,它们就能访问相同的全局状态,从而在每一页上渲染的所有组件和导航栏之间建立联系。
现在,让我们快速看一下/pages/index.js页面:
import ProductCard from '../components/ProductCard';
import products from '../data/items';
function Home() {
return (
<div className="grid grid-cols-4 gap-4">
{products.map((product) => (
<ProductCard key={product.id} {...product} />
))}
</div>
);
}
export default Home;
为了简化,我们正在从本地 JavaScript 文件中导入所有产品,但当然,它们也可以来自远程 API。对于每个产品,我们渲染ProductCard组件,这将使用户能够将它们添加到购物车,然后进行结账。
让我们来看看ProductCard组件:
function ProductCard({ id, name, price, picture }) {
return (
<div className="bg-gray-200 p-6 rounded-md">
<div className="relative 100% h-40 m-auto">
<img src={picture} alt={name} className="object-cover" />
</div>
<div className="flex justify-between mt-4">
<div className="font-bold text-l"> {name} </div>
<div className="font-bold text-l text-gray-500"> ${price} per kg </div>
</div>
<div className="flex justify-between mt-4 w-2/4 m-auto">
<button
className="pl-2 pr-2 bg-red-400 text-white rounded-md"
disabled={false /* To be implemented */}
onClick={() => {} /* To be implemented */}>
-
</button>
<div>{/* To be implemented */}</div>
<button
className="pl-2 pr-2 bg-green-400 text-white rounded-md" onClick={() => {} /* To be implemented */}>
+
</button>
</div>
</div>
);
}
export default ProductCard;
正如你所见,我们已经在构建该组件的 UI,但在点击increment和decrement按钮时没有任何反应。现在,我们需要将该组件链接到cartContext上下文,并在用户点击这两个按钮之一时立即更新上下文状态:
import { useContext } from 'react';
import cartContext from '../components/context/cartContext';
function ProductCard({ id, name, price, picture }) {
const { setItems, items } = useContext(cartContext);
// ...
使用useContext钩子,我们将_app.js页面中的setItems和items链接到我们的ProductCard组件。每次我们在该组件上调用setItems时,我们都会更新全局的items对象,并且这个变化将传播到所有位于相同上下文并链接到相同全局状态的组件。这也意味着我们不需要为每个ProductCard组件保留本地状态,因为关于单个产品添加到购物车中的数量信息已经存在于我们的上下文状态中。因此,如果我们想知道添加到购物车中的产品数量,我们可以按以下步骤进行:
import { useContext } from 'react';
import cartContext from '../components/context/cartContext';
function ProductCard({ id, name, price, picture })
const { setItems, items } = useContext(cartContext);
const productAmount = id in items ? items[id] : 0;
// ...
这样,每次用户点击特定产品的增加按钮时,全局items状态将改变,ProductCard组件将被重新渲染,productAmount常量将最终具有新的值。
再次谈到处理增加和减少操作,我们需要控制用户对那些按钮的点击。我们可以编写一个通用的handleAmount函数,它接受一个参数,可以是"increment"或"decrement"。如果传递的参数是"increment",我们需要检查当前产品是否已经存在于全局状态中(记住,初始的全局状态是一个空对象)。如果存在,我们只需要将其值增加一;否则,我们需要在items对象中创建一个新的属性,其键将是我们的产品 ID,其值将被设置为1。
如果参数是"decrement",我们应该检查当前产品是否已经存在于全局items对象中。如果是这种情况,并且值大于0,我们只需减少它。在所有其他情况下,我们只需退出函数,因为我们不能将负数作为产品数量的值:
import { useContext } from 'react';
import cartContext from '../components/context/cartContext';
function ProductCard({ id, name, price, picture }) {
const { setItems, items } = useContext(cartContext);
const productAmount = items?.[id] ?? 0;
const handleAmount = (action) => {
if (action === 'increment') {
const newItemAmount = id in items ? items[id] + 1 : 1;
setItems({ ...items, [id]: newItemAmount });
}
if (action === 'decrement') {
if (items?.[id] > 0) {
setItems({ ...items, [id]: items[id] - 1 });
}
}
};
// ...
我们现在只需要更新增加和减少按钮,以便在点击时触发handleAmount函数:
<div className="flex justify-between mt-4 w-2/4 m-auto">
<button
className="pl-2 pr-2 bg-red-400 text-white rounded-md"
disabled={productAmount === 0}
onClick={() => handleAmount('decrement')}>
-
</button>
<div>{productAmount}</div>
<button
className="pl-2 pr-2 bg-green-400 text-white rounded-md"
onClick={() => handleAmount('increment')}>
+
</button>
</div>
如果我们现在尝试增加和减少产品的数量,我们将在每次按钮点击后看到ProductCard组件中的数字变化!但是,当我们查看导航栏时,值将保持为0,因为我们还没有将全局项目状态链接到Navbar组件。让我们打开/components/Navbar.js文件并输入以下内容:
import { useContext } from 'react';
import Link from 'next/link';
import cartContext from '../components/context/cartContext';
function Navbar() {
const { items } = useContext(cartContext);
// ...
我们不需要从我们的导航栏更新全局items状态,所以在这种情况下,我们不需要声明setItems函数。在这个组件中,我们只想显示添加到购物车中的产品总数(例如,如果我们添加了两根胡萝卜和一颗洋葱,我们应该在“导航栏”中看到总数为3)。我们可以很容易地做到这一点:
import { useContext } from 'react';
import Link from 'next/link';
import cartContext from '../components/context/cartContext';
function Navbar() {
const { items } = useContext(cartContext);
const totalItemsAmount = Object.values(items)
.reduce((x, y) => x + y, 0);
// ...
现在让我们只显示totalItemsAmount变量在生成的 HTML 中:
// ...
<div className="font-bold underline">
<Link href="/cart" passHref>
<a>{totalItemsAmount} items in cart</a>
</Link>
</div>
// ...
太好了!我们只错过了一件事:点击“导航栏”链接到结算页面,我们看不到页面上的任何产品显示。我们可以通过修复/pages/cart.js页面来解决这个问题:
import { useContext } from 'react';
import cartContext from '../components/context/cartContext';
import data from '../data/items';
function Cart() {
const { items } = useContext(cartContext);
// ...
如您所见,我们像往常一样导入上下文对象和完整的商品列表。这是因为我们需要获取完整的商品信息(在状态中,我们只有产品 ID 和产品数量的关系)以显示产品的名称、数量和该产品的总价。然后我们需要一种方法来获取给定产品 ID 的整个商品对象。我们可以在组件外部编写一个getFullItem函数,它只接受一个 ID 并返回整个商品对象:
import { useContext } from 'react';
import cartContext from '../components/context/cartContext';
import data from '../data/items';
function getFullItem(id) {
const idx = data.findIndex((item) => item.id === id);
return data[idx];
}
function Cart() {
const { items } = useContext(cartContext);
// ...
现在我们已经可以访问完整的商品对象,我们可以在购物车中的所有产品内获取总价:
// ...
function Cart() {
const { items } = useContext(cartContext);
const total = Object.keys(items)
.map((id) => getFullItem(id).price * items[id])
.reduce((x, y) => x + y, 0);
// ...
我们还希望在购物车中显示产品列表,格式为x2 Carrots ($7)。我们可以轻松创建一个新的数组amounts,并填充我们添加到购物车中的所有产品以及每个产品的数量:
// ...
function Cart() {
const { items } = useContext(cartContext);
const total = Object.keys(items)
.map((id) => getFullItem(id).price * items[id])
.reduce((x, y) => x + y, 0);
const amounts = Object.keys(items).map((id) => {
const item = getFullItem(id);
return { item, amount: items[id] };
});
// ...
现在,我们只需要更新该组件的返回模板:
// ...
<div>
<h1 className="text-xl font-bold"> Total: ${total} </h1>
<div>
{amounts.map(({ item, amount }) => (
<div key={item.id}>
x{amount} {item.name} (${amount *
item.price})
</div>
))}
</div>
</div>
// ...
完成了!在启动服务器后,我们可以将尽可能多的产品添加到购物车中,并看到总价会显示在/cart页面上。
在 Next.js 中使用上下文 API 并不困难,因为对于纯 React 应用,这些概念是相同的。在下一节中,我们将看到如何使用 Redux 作为全局状态管理器来实现相同的结果。
使用 Redux
在 2015 年,React 首次公开发布后的两年,用于处理大规模应用程序状态的框架和库并没有像今天这样多。处理单向数据流最先进的方式是 Flux,但随着时间的推移,它已经被更直接、更现代的库如Redux和MobX所取代。
特别是 Redux 对 React 社区产生了重大影响,并迅速成为构建 React 大规模应用程序的事实上的状态管理器。
在本节中,我们将使用纯 Redux(不使用如redux-thunk或redux-saga等中间件)来管理店面状态,而不是使用 React Context API。
让我们从克隆github.com/PacktPublishing/Real-World-Next.js/tree/main/05-managing-local-and-global-states-in-nextjs/boilerplate的样板代码开始(就像我们在上一节中所做的那样)。
到目前为止,我们需要安装两个新的依赖项:
yarn add redux react-redux
我们还可以安装Redux DevTools 扩展程序,它允许我们从浏览器中检查和调试应用程序状态:
yarn add -D redux-devtools-extension
现在,我们可以开始编写我们的 Next.js + Redux 应用程序了。
首先,我们需要初始化全局存储,这是我们应用程序包含应用程序状态的部分。我们可以通过在项目根目录下创建一个新文件夹,命名为redux/来实现。在这里,我们可以编写一个新的store.js文件,包含初始化我们存储的客户端和服务器端逻辑:
import { useMemo } from 'react';
import { createStore, applyMiddleware } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension';
let store;
const initialState = {};
// ...
如您所见,我们首先实例化一个新的变量store,它(正如您可能已经猜到的)将用于稍后保持 Redux 存储。
然后,我们初始化 Redux 存储的initialState。在这种情况下,它将是一个空对象,因为我们将在用户在店面选择哪个产品的基础上添加更多属性。
我们现在需要创建我们的第一个也是唯一的一个 reducer。在现实世界的应用中,我们会在许多不同的文件中编写许多不同的 reducer,这使得我们的项目在可维护性方面更加易于管理。在这种情况下,我们将只编写一个 reducer(因为我们只需要一个),并且为了简便起见,我们将将其包含在store.js文件中:
//...
const reducer = (state = initialState, action) => {
const itemID = action.id;
switch (action.type) {
case 'INCREMENT':
const newItemAmount = itemID in state ?
state[itemID] + 1 : 1;
return {
...state,
[itemID]: newItemAmount,
};
case 'DECREMENT':
if (state?.[itemID] > 0) {
return {
...state,
[itemID]: state[itemID] - 1,
};
}
return state;
default:
return state;
}
};
Reducer 的逻辑与我们之前在ProductCard组件的handleAmount函数中编写的逻辑并没有太大的不同。
现在我们需要初始化我们的存储,我们可以通过创建两个不同的函数来实现这一点。第一个将是一个简单的辅助函数,称为initStore,它将使后续操作更加简便:
// ...
function initStore(preloadedState = initialState) {
return createStore(
reducer,
preloadedState,
composeWithDevTools(applyMiddleware())
);
}
我们需要创建的第二个函数是我们将用于正确初始化存储的函数,我们将称之为initializeStore:
// ...
export const initializeStore = (preloadedState) => {
let _store = store ?? initStore(preloadedState);
if (preloadedState && store) {
_store = initStore({
...store.getState(),
...preloadedState,
});
store = undefined;
}
//Return '_store' when initializing Redux on the server-side
if (typeof window === 'undefined') return _store;
if (!store) store = _store;
return _store;
};
现在我们已经设置了存储,我们可以创建最后一个函数,一个我们将要在我们的组件中使用的钩子。我们将它包裹在一个useMemo函数中,以利用 React 内置的 memoization 系统,这将缓存复杂的初始状态,避免在每次useStore函数调用时重新解析系统:
// ...
export function useStore(initialState) {
return useMemo(
() => initializeStore(initialState), [initialState]
);
}
太好了!我们现在可以继续前进,将 Redux 附加到我们的 Next.js 应用上了。
正如我们在上一节中使用 Context API 所做的那样,我们需要编辑我们的_app.js文件,以便 Redux 将全局可用,对于 Next.js 应用中每个居住的组件:
import Head from 'next/head';
import { Provider } from 'react-redux';
import { useStore } from '../redux/store';
import Navbar from '../components/Navbar';
function MyApp({ Component, pageProps }) {
const store = useStore(pageProps.initialReduxState);
return (
<>
<Head>
<link href="https://unpkg.com/tailwindcss@²/dist/tailwind. min.css" rel="stylesheet" />
</Head>
<Provider store={store}>
<Navbar />
<div className="w-9/12 m-auto pt-10">
<Component {...pageProps} />
</div>
</Provider>
</>
);
}
export default MyApp;
如果你将这个_app.js文件与我们之前创建的文件进行比较,你可能会注意到一些相似之处。从现在开始,这两个实现将看起来非常相似,因为 Context API 试图使全局状态管理对每个人来说更加可访问和容易,Redux 对这些 API 的影响是显而易见的。
我们现在需要实现ProductCard组件的increment/decrement逻辑,使用 Redux。让我们首先打开components/ProductCard.js文件,并添加以下导入:
import { useDispatch, useSelector, shallowEqual } from 'react-redux';
// ...
现在,让我们创建一个钩子,当我们需要从我们的 Redux 存储中获取所有产品时,它会很有用:
import { useDispatch, useSelector, shallowEqual } from 'react-redux';
function useGlobalItems() {
return useSelector((state) => state, shallowEqual);
}
// ...
在同一文件中,让我们通过集成我们需要的 Redux Hooks 来编辑ProductCard组件:
// ...
function ProductCard({ id, name, price, picture }) {
const dispatch = useDispatch();
const items = useGlobalItems();
const productAmount = items?.[id] ?? 0;
return (
// ...
最后,我们需要在用户点击我们组件的按钮之一时触发一个分发操作。多亏了我们之前导入的useDispatch钩子,这个操作将非常容易实现。我们只需要更新渲染函数中 HTML 按钮的onClick回调,如下所示:
// ...
<div className="flex justify-between mt-4 w-2/4 m-auto">
<button
className="pl-2 pr-2 bg-red-400 text-white rounded-md"
disabled={productAmount === 0}
onClick={() => dispatch({ type: 'DECREMENT', id })}>
-
</button>
<div>{productAmount}</div>
<button
className="pl-2 pr-2 bg-green-400 text-white rounded-md"
onClick={() => dispatch({ type: 'INCREMENT', id })}>
+
</button>
</div>
// ...
假设你已经为你的浏览器安装了 Redux DevTools 扩展。在这种情况下,你现在可以开始增加或减少产品,并直接在你的调试工具中看到分发的动作。
顺便说一下,我们仍然需要在添加或从购物车中移除产品时更新导航栏。我们可以通过编辑components/NavBar.js组件,就像我们为ProductCard所做的那样,轻松地做到这一点:
import Link from 'next/link';
import { useSelector, shallowEqual } from 'react-redux';
function useGlobalItems() {
return useSelector((state) => state, shallowEqual);
}
function Navbar() {
const items = useGlobalItems();
const totalItemsAmount = Object.keys(items)
.map((key) => items[key])
.reduce((x, y) => x + y, 0);
return (
<div className="w-full bg-purple-600 p-4 text-white">
<div className="w-9/12 m-auto flex justify-between">
<div className="font-bold">
<Link href="/" passHref>
<a> My e-commerce </a>
</Link>
</div>
<div className="font-bold underline">
<Link href="/cart" passHref>
<a>{totalItemsAmount} items in cart</a>
</Link>
</div>
</div>
</div>
);
}
export default Navbar;
我们现在可以尝试添加和删除我们的店面产品,并看到状态变化在导航栏中反映出来。
在我们考虑我们的电子商务应用程序完整之前,还有最后一件事:我们需要更新/cart页面,以便在进入结账步骤之前查看购物车摘要。这将非常简单,因为我们将会结合之前章节中学到的 Context API 和刚刚获得的 Redux Hooks 知识。让我们打开pages/Cart.js文件,并导入我们用于其他组件的相同 Redux Hook:
import { useSelector, shallowEqual } from 'react-redux';
import data from '../data/items';
function useGlobalItems() {
return useSelector((state) => state, shallowEqual);
}
// ...
在这一点上,我们只需复制我们在上一节为 Context API 创建的getFullItem函数:
// ...
function getFullItem(id) {
const idx = data.findIndex((item) => item.id === id);
return data[idx];
}
// ...
对于Cart组件也是如此。我们将基本上复制我们在上一节所做的一切,唯一的区别是items对象将来自 Redux 存储而不是 React 上下文:
function Cart() {
const items = useGlobalItems();
const total = Object.keys(items)
.map((id) => getFullItem(id).price * items[id])
.reduce((x, y) => x + y, 0);
const amounts = Object.keys(items).map((id) => {
const item = getFullItem(id);
return { item, amount: items[id] };
});
return (
<div>
<h1 className="text-xl font-bold"> Total: ${total}
</h1>
<div>
{amounts.map(({ item, amount }) => (
<div key={item.id}>
x{amount} {item.name} (${amount * item.price})
</div>
))}
</div>
</div>
);
}
export default Cart;
如果您现在尝试将一些产品添加到您的购物车中,然后转到/cart页面,您将看到您的费用摘要。
正如您可能已经注意到的,Context API 和纯 Redux(不使用任何中间件)之间没有太多区别。顺便说一句,通过使用 Redux,您将获得一个极其庞大的生态系统,包括插件、中间件和调试工具,这将使您的开发体验在需要扩展和处理 Web 应用程序内部非常复杂业务逻辑时变得更加轻松。
摘要
在本章中,我们专注于使用 React 内置 API(Context API 和 Hooks)以及外部库(Redux)进行状态管理。还有许多其他工具和库用于管理应用程序的全局状态(MobX、Recoil、XState、Unistore,仅举几个例子)。您可以在 Next.js 应用程序中使用它们,通过为客户端和服务器端使用初始化它们,就像我们使用 Redux 一样。
此外,您可以使用 Apollo GraphQL 及其内存缓存来管理您的应用程序状态,从而获得一个用于修改和查询全局数据的正式查询语言。
我们现在可以创建更复杂和交互式的 Web 应用程序,使用我们想要的任何库来管理不同种类的状态。
但一旦您的数据组织得很好并且准备好使用,您就需要根据您的应用程序状态显示它并渲染您的应用程序 UI。在下一章中,您将看到如何通过配置和使用不同的 CSS 和 JavaScript 库来设置您的 Web 应用程序样式。
第六章:CSS 和内置样式方法
优秀 UI 和糟糕 UI 之间的区别是什么?有些人可能会回答“功能!”而其他人可能会说“交互速度!”但我会个人定义为优秀设计和易用性的良好结合。你的 Web 应用可能潜在地是世界上功能最强大的应用。然而,如果 UI 设计不佳且实现不当,用户很难按照预期使用它。因此,样式概念应运而生。
我们都知道 CSS 是什么:一组基本规则,告诉浏览器如何图形化渲染 HTML 内容。虽然这似乎是一个简单的任务,但 CSS 生态系统在近年来已经发生了很大的变化,所有帮助开发者使用模块化、轻量级和性能良好的 CSS 规则构建优秀用户界面的工具也发生了变化。
在本章中,我们将探讨编写 CSS 规则的不同方法。这将为第七章,“使用 UI 框架”,铺平道路,我们将使用外部 UI 框架和实用工具实现 UI,以使开发者的体验更加流畅。
注意
本章的目的不是教你如何在任何特定的技术或语言中编写 CSS 规则。相反,我们将探讨 Next.js 默认集成的编写模块化、可维护和性能良好的 CSS 样式的技术。如果你对以下任何技术感兴趣,我建议在进一步实现 UI 之前先阅读官方文档。
我们将详细探讨以下主题:
-
Styled JSX
-
CSS 模块
-
如何集成 SASS 预处理器
到本章结束时,你将了解三种不同的内置样式方法,它们之间的区别,以及如何根据你的需求进行配置。
技术要求
要运行本章中的代码示例,你需要在本地机器上安装 Node.js 和 npm。
如果你愿意,可以使用在线 IDE,例如repl.it或codesandbox.io;它们都支持 Next.js,你不需要在电脑上安装任何依赖。至于其他章节,你可以在 GitHub 上找到本章的代码库:https://github.com/PacktPublishing/Real-World-Next.js。
探索和使用 Styled JSX
在本节中,我们将探讨 Styled JSX,这是 Next.js 默认提供的内置样式机制。
如果你不想学习新的样式语言,例如SASS或LESS,并且想在 CSS 规则中集成一些 JavaScript,那么你可能对Styled JSX感兴趣。它是由 Next.js 背后的公司 Vercel 创建的CSS-in-JS库(这意味着我们可以使用 JavaScript 来编写 CSS 属性),允许你编写作用域特定组件的 CSS 规则和类。
让我用一个简单的例子来解释这个概念。假设我们有一个Button组件,我们想使用 Styled JSX 来为其添加样式:
export default function Button(props) {
return (
<>
<button className="button">{props.children}</button>
<style jsx>{`
.button {
padding: 1em;
border-radius: 1em;
border: none;
background: green;
color: white;
}
`}</style>
</>
);
}
正如你所见,我们使用了一个非常通用的button类名,这很可能会与其他使用相同类名的组件产生冲突,对吧?答案是:不会!这就是为什么 Styled JSX 如此强大的原因。它不仅允许你通过 JavaScript 编写高度动态的 CSS,而且还确保你声明的规则不会影响你正在编写的组件以外的任何组件。
因此,如果我们现在想要创建一个名为FancyButton的新组件,我们可以使用相同的类名,并且由于 Styled JSX,当两者都在页面上渲染时,它不会覆盖Button组件的样式:
export default function FancyButton(props) {
return (
<>
<button className="button">{props.children}</button>
<style jsx>{`
.button {
padding: 2em;
border-radius: 2em;
background: purple;
color: white;
font-size: bold;
border: pink solid 2px;
}
`}</style>
</>
);
}
同样的情况也发生在 HTML 选择器上。如果我们正在编写一个Highlight组件,我们可以简单地使用 Styled JSX 来为整个组件添加样式,甚至不需要声明一个特定的类:
export default function Highlight(props) {
return (
<>
<span>{props.text}</span>
<style jsx>{`
span {
background: yellow;
font-weight: bold;
}
`}</style>
</>
);
}
在这种情况下,我们编写的<span>样式只会应用于Highlight组件,而不会影响你页面中的任何其他<span>元素。
如果你想要创建一个应用于所有组件的 CSS 规则,你只需使用global属性,并且 Styled JSX 会将该规则应用于所有匹配你的选择器的 HTML 元素:
export default function Highlight(props) {
return (
<>
<span>{props.text}</span>
<style jsx global>{`
span {
background: yellow;
font-weight: bold;
}
`}</style>
</>
)
}
在上一个示例中,我们在我们的样式声明中添加了global属性,因此现在每次我们使用一个<span>元素时,它都会继承我们在Highlight组件内部声明的样式。当然,这可能会有些风险,所以请确保这是你想要的。
如果你想知道如何开始使用 Styled JSX 以及为什么我们还没有介绍这个包的安装...那是因为 Styled JSX 是内置在 Next.js 中的,所以你可以在项目初始化后立即开始使用它!
在下一个部分,我们将探讨编写 CSS 规则的一个更经典的方法:CSS 模块。
CSS 模块
在上一个部分,我们看到了一个 CSS-in-JS 库,这意味着我们必须在 JavaScript 中编写我们的 CSS 定义,根据我们选择的库以及如何配置它,在运行时或编译时将这些样式规则转换为纯 CSS。
虽然我个人喜欢 CSS-in-JS 的方法,但我最终意识到,在选择新 Next.js 应用的样式方法时,它有一些显著的缺点需要考虑。
许多 CSS-in-JS 库没有提供良好的 IDE/代码编辑器支持,这使得开发者的工作变得更加困难(没有语法高亮、自动完成、代码检查等等)。此外,使用 CSS-in-JS 迫使你采用越来越多的依赖项,使得你的应用程序包更大、速度更慢。
谈到性能,这里还有一个很大的缺点:即使我们在服务器端预先生成 CSS 规则,我们仍然需要在客户端的 React hydration 之后重新生成它们。这会增加很高的运行时成本,使得网络应用程序变得越来越慢,而且当我们向我们的应用程序添加更多功能时,情况只会变得更糟。
但这里有一个出色的替代方案:CSS 模块。在前一节中,我们讨论了局部作用域的 CSS 规则以及 Styled-JSX 如何使创建具有相同名称但不同目的的 CSS 类变得容易(避免命名冲突)。CSS 模块通过编写纯 CSS 类并将它们导入 React 组件而不产生任何运行时成本,将同样的概念带到了桌面上。
让我们看看一个基本的例子:一个简单的蓝色背景和欢迎文本的着陆页。让我们先创建一个新的 Next.js 应用,然后创建pages/index.js文件,如下所示:
import styles from '../styles/Home.module.css';
export default function Home() {
return (
<div className={styles.homepage}>
<h1> Welcome to the CSS Modules example </h1>
</div>
);
}
如您所见,我们正在从以.module.css结尾的纯 CSS 文件中导入我们的 CSS 类。尽管Home.module.css是一个 CSS 文件,但 CSS 模块将其内容转换为一个 JavaScript 对象,其中每个键都是一个类名。让我们详细看看Home.module.css文件:
.homepage {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
min-height: 100vh;
background-color: #2196f3;
}
.title {
color: #f5f5f5;
}
运行后,这里是结果:

图 6.1 – 使用 CSS 模块设计的首页
如前所述,这些类是组件作用域的。如果您检查生成的 HTML 页面,您的着陆页将包含一个看起来像这样的div类:
<div class="Home_homepage__14e3j">
<h1 class="Home_title__3DjR7">
Welcome to the CSS Modules example
</h1>
</div>
如您所见,CSS 模块为我们的规则生成了唯一的类名。即使我们现在在其他 CSS 文件中使用相同的通用title和homepage名称创建新类,由于这种策略,也不会有任何命名冲突。
但可能存在我们希望我们的规则是全局的情况。例如,如果我们尝试渲染我们刚刚创建的首页,我们会注意到字体仍然是默认的。还有默认的body边距,我们可能想要覆盖这些默认设置。我们可以通过创建一个包含以下内容的新的styles/globals.css文件来快速解决这个问题:
html,
body {
padding: 0;
margin: 0;
font-family: sans-serif;
}
然后,我们可以将其导入到我们的pages/_app.js文件中:
import '../styles/globals.css';
function MyApp({ Component, pageProps }) {
return <Component {...pageProps} />;
}
export default MyApp;
如果我们现在尝试渲染首页,我们会看到默认的body边距已经消失,现在字体是 sans-serif 类型的:

图 6.2 – 使用全局 CSS 模块样式设计的首页
我们还可以使用:global关键字来创建全局可用的 CSS 规则,例如:
.button :global {
padding: 5px;
background-color: blue;
color: white;
border: none;
border-radius: 5px;
}
此外,还有一个我想要您在测试这种样式方法时考虑的出色的 CSS 模块功能:选择器组合。
事实上,您可以通过创建一个非常通用的规则,然后使用composes属性来覆盖其中的一些属性:
.button-default {
padding: 5px;
border: none;
border-radius: 5px;
background-color: grey;
color: black;
}
.button-success {
composes: button-default;
background-color: green;
color: white;
}
.button-danger {
composes: button-default;
background-color: red;
color: white;
}
CSS 模块的主要思想是提供一个简单直接的方式来编写模块化的 CSS 类,在每种语言中都具有零运行时成本。多亏了PostCSS 模块(github.com/madyankin/postcss-modules),您几乎可以在任何语言(PHP、Ruby、Java 等)和模板引擎(Pug、Mustache、EJS 等)中使用 CSS 模块。
我们只是触及了 CSS 模块的表面以及为什么它们是编写模块化、轻量级、无运行时成本的类的优秀解决方案。如果你想了解更多关于 CSS 模块规范的信息,你可以查看官方仓库:github.com/css-modules/css-modules。
就像 Styled JSX 一样,CSS 模块在每次 Next.js 安装中都是现成的,所以一旦你启动了你的项目,你就可以立即开始使用它们。尽管如此,你可能需要调整默认配置来添加、删除或编辑一些功能,而 Next.js 也使得这一步骤变得简单。
事实上,Next.js 使用PostCSS编译 CSS 模块,这是一个在构建时编译 CSS 的流行工具。
默认情况下,Next.js 已经配置了以下功能:
-
Autoprefixer:它使用
::placeholder选择器的值为你添加供应商前缀到 CSS 规则中,它将编译它以使其与所有选择器略有不同的浏览器兼容,例如:-ms-input-placeholder、::-moz-placeholder等等。你可以了解更多关于这个功能的信息:github.com/postcss/autoprefixer。 -
跨浏览器 flexbox 错误修复:PostCSS 遵循社区维护的 flexbox 问题列表(可以在
github.com/philipwalton/flexbugs找到),并为使其在所有浏览器上正确工作添加了一些解决方案。 -
IE11 兼容性:PostCSS 编译新的 CSS 功能,使其在旧浏览器(如 IE11)上可用。然而,有一个例外:CSS 变量不会被编译,因为这并不安全。如果你真的需要支持旧浏览器并且还想使用它们,你可以跳到下一节(将 SASS 与 Next.js 集成)并使用 SASS/SCSS 变量。
我们可以通过在项目根目录中创建一个postcss.config.json文件来编辑 PostCSS 的默认配置,然后添加默认的 Next.js 配置:
{
"plugins": [
"postcss-flexbugs-fixes",
[
"postcss-preset-env",
{
"autoprefixer": {
"flexbox": "no-2009"
},
"stage": 3,
"features": {
"custom-properties": false
}
}
]
]
}
从这个点开始,我们可以根据喜好编辑配置,添加、删除或更改任何属性。
在下一节中,我们将看到如何集成另一个流行的 CSS 预处理器:Sass。
将 SASS 与 Next.js 集成
SASS 可能是最受欢迎和使用的 CSS 预处理器之一,Next.js 也做得很好,使得它能够轻松地与之集成。事实上,就像 CSS 模块和 Styled JSX 一样,SASS 也是现成的;我们只需在我们的 Next.js 项目中安装sass npm包,就可以开始了:
yarn add sass
到目前为止,你可以开始使用 CSS 模块和 SASS/SCSS 语法,就像我们在上一节中做的那样。
让我们来看一个简单的例子。如果我们打开上一节中的pages/index.js文件,我们只需将 CSS 导入更改如下:
import styles from '../styles/Home.module.scss';
export default function Home() {
return (
<div className={styles.homepage}>
<h1> Welcome to the CSS Modules example </h1>
</div>
);
}
现在,我们需要将我们的styles/Home.module.css文件重命名为styles/Home.module.scss,然后我们就可以使用 Sass(或 SCSS)特定的语法编辑该文件了。
多亏了 SASS 和 SCSS 语法,我们可以利用一组丰富的功能,使我们的代码更加模块化且易于维护。
注意名称!
SASS 和 SCSS 是同一 CSS 预处理器(预处理器)的两种不同语法。然而,它们都提供了增强的 CSS 样式编写方式,例如for变量、循环、混入以及许多其他功能。
虽然名称可能看起来很相似,最终目的也相同,但主要区别在于 SCSS(Sassy CSS)通过添加每个.scss文件中可用的那些功能来扩展 CSS 语法。任何标准的.css文件都可以无任何问题重命名为.scss,因为 CSS 语法在.scss文件中是有效的。
SASS 是一种较旧的语法,与标准 CSS 不兼容。它不使用花括号或分号;它只使用缩进和换行来分隔属性和嵌套选择器。
这两种语法都需要被转换成纯 CSS,以便在常规网页浏览器中使用。
让我们以 CSS 模块的compose属性为例。我们之前已经看到如何创建新的 CSS 类,它扩展了现有的一个:
.button-default {
padding: 5px;
border: none;
border-radius: 5px;
background-color: grey;
color: black;
}
.button-success {
composes: button-default;
background-color: green;
color: white;
}
.button-danger {
composes: button-default;
background-color: red;
color: white;
}
使用 SCSS,我们可以选择多种不同的方法,例如使用@extend关键字(它的工作方式与 CSS 模块中的compose关键字类似):
.button-default {
padding: 5px;
border: none;
border-radius: 5px;
background-color: grey;
color: black;
}
.button-success {
@extend .button-default;
background-color: green;
color: white;
}
.button-danger {
@extend .button-default;
background-color: red;
color: white;
}
或者,我们可以稍微改变一下类名,并利用选择器嵌套功能:
.button {
padding: 5px;
border: none;
border-radius: 5px;
background-color: grey;
color: black;
&.success {
background-color: green;
color: white;
}
&.danger {
background-color: red;
color: white;
}
}
SCSS 附带了一组丰富的功能,例如循环、混入、函数等,使得任何开发者都能轻松编写复杂的 UI。
尽管 Next.js 原生集成了它,但你可能仍然需要启用或禁用某些特定功能或编辑默认的 SASS 配置。
你可以通过编辑next.config.js配置文件轻松做到这一点:
module.exports = {
sassOptions: {
outputStyle: 'compressed'
// ...add any SASS configuration here
},
}
如果你想要了解更多关于 SASS 和 SCSS 的信息,我强烈建议查看官方文档sass-lang.com。
摘要
近年来,CSS 生态系统已经发生了很大的变化,Next.js 团队不断更新框架,以提供最现代、性能最佳和模块化的 CSS 样式编写解决方案。
在本章中,我们探讨了三种不同的内置解决方案,当然,与其它方案相比,任何一种方案都有一些权衡。
例如,Styled JSX 确实是编写 CSS 规则最容易的方法之一。你可以与 JavaScript 交互,根据用户操作动态更改一些 CSS 规则和属性,等等,但它也有一些显著的缺点。像大多数 CSS-in-JS 库一样,Styled JSX 首先在服务器端渲染,但在 React 活化后立即在客户端重新渲染整个生成的 CSS。这会给你的应用程序增加一些运行时成本,使应用程序的性能降低,并且更难以扩展。此外,它使得浏览器无法缓存你的 CSS 规则,因为它们在服务器端和客户端渲染的每个请求中都会被重新生成。
SASS 和 SCSS 语法与 Next.js 集成得很好,并且它们为你提供了大量的功能,用于以零运行时成本编写复杂的 UI。实际上,Next.js 会在构建时将所有的 .scss 和 .sass 文件编译成纯 CSS,使得浏览器能够缓存所有的样式规则。然而,我们应该考虑的是,我们无法在最终构建阶段看到生产优化的纯 CSS 输出。与 CSS 模块不同,我们在最终生产包中得到的确实非常接近我们编写的代码,SASS 提供的庞大功能集可能会生成一个巨大的最终 CSS 文件,并且在编写深层嵌套规则、循环等时,预测编译器的输出并不总是容易。
最终,CSS 模块和 PostCSS 似乎是为编写现代 CSS 样式提供了一个极好的选择。生成的输出更容易预测,并且 PostCSS 会自动为旧浏览器(甚至到 IE11)填充现代 CSS 功能。
在下一章中,我们将看到如何集成外部 UI 库,这将使编写样式丰富和功能齐全的组件和 UI 更加容易。
第七章:使用 UI 框架
在上一章中,我们看到了 Next.js 如何通过为我们提供许多无需安装和配置多个不同外部包即可编写 CSS 的有效替代方案来提高我们的生产力。
尽管如此,有些情况下我们可能希望使用预构建的 UI 库来利用它们的组件、主题和内置功能,这样我们就不必从头开始构建,并利用庞大的社区在出现任何问题时帮助我们。
在本章中,我们将发现一些现代 UI 库,并学习如何正确地将它们集成到任何 Next.js 应用程序中。我们将详细探讨以下内容:
-
UI 库是什么以及为什么我们可能需要它们
-
如何集成 Chakra UI
-
如何集成 TailwindCSS
-
如何使用 Headless UI 组件
到本章结束时,您将能够通过遵循以下章节中我们将看到的提示和原则来集成任何 UI 库。
技术要求
要运行本章中的代码示例,您需要在您的本地计算机上安装 Node.js 和 npm。
如果您愿意,可以使用在线 IDE,例如 repl.it 或 codesandbox.io;它们都支持 Next.js,您不需要在您的计算机上安装任何依赖项。至于其他章节,您可以在 GitHub 上找到本章的代码库:https://github.com/PacktPublishing/Real-World-Next.js。
UI 库简介
UI 库、框架和实用工具并非必需品。我们可以从头开始使用纯 JavaScript、HTML 和 CSS 构建任何用户界面(尽管可能很复杂)。然而,我们经常发现自己会在构建的每个用户界面上使用相同的模式、可访问性规则、优化和实用函数。因此,UI 库的概念应运而生。
策略是将我们最常用的用例抽象化,在不同的用户界面中重用大部分代码,提高我们的生产力,并使用知名、经过测试且 可主题化 的 UI 组件。
“可主题化”指的是那些允许我们自定义给定框架的颜色方案、间距和整个设计语言的库和组件。
我们可以以流行的 Bootstrap 库为例。它允许我们覆盖其默认变量(如颜色、字体、混入等)以自定义默认主题。多亏了这个特性,我们有可能在数十个不同的 UI 上使用 Bootstrap,每个 UI 都有非常不同的外观和感觉。
虽然 Bootstrap 仍然是一个好、经过测试且广为人知的库,但在以下章节中,我们将关注更现代的替代品。每个选项将采用不同的方法,让您了解在选择 UI 库时应该寻找什么。
集成 Next.js 中的 Chakra UI
Chakra UI 是一个开源组件库,用于构建模块化、可访问且美观的用户界面。
它的主要优势如下:
-
可访问性:Chakra UI 允许我们使用 Chakra UI 团队创建的预构建组件(如按钮、模态框、输入框等),并额外关注可访问性。
-
可主题化:该库附带默认主题,其中(例如)按钮具有特定的默认背景颜色、边框半径、填充等。我们可以始终使用 Chakra UI 内置的编辑库组件每个样式的函数来自定义默认主题。
-
浅色和深色模式:它们都支持默认设置,并且可以依赖于用户的系统设置。如果用户将计算机设置为默认使用深色模式,Chakra UI 将在加载时立即加载深色主题。
-
可组合性:我们可以从 Chakra UI 组件开始创建更多组件。该库将为我们提供创建自定义组件的构建块。
-
TypeScript 支持:Chakra UI 使用 TypeScript 编写,并提供了一流的类型,以提供美好的开发者体验。
要了解如何将 Chakra UI 集成到 Next.js 应用程序中,我们将通过使用静态 Markdown 文档作为页面来构建一个简单的公司员工目录。
因此,让我们首先创建一个新的 Next.js 项目:
npx create-next-app employee-directory-with-chakra-ui
我们现在需要安装 Chakra UI 及其依赖项:
yarn add @chakra-ui/react @emotion/react@¹¹ @emotion/styled@¹¹ framer-motion@⁴ @chakra-ui/icons
现在,我们已经准备好将 Chakra UI 与 Next.js 集成。要做到这一点,让我们打开 pages/_app.js 文件并将默认的 <Component /> 组件包裹在 Chakra 提供者中:
import { ChakraProvider } from '@chakra-ui/react';
function MyApp({ Component, pageProps }) {
return (
<ChakraProvider>
<Component {...pageProps} />
</ChakraProvider>
);
}
export default MyApp;
使用 ChakraProvider,我们还可以传递一个包含表示主题覆盖的对象的 theme 属性。实际上,我们可以通过使用内置的 extendTheme 函数来使用我们的自定义颜色、字体、间距等来覆盖默认的 Chakra UI 主题:
import { ChakraProvider, extendTheme } from '@chakra-
ui/react';
const customTheme = extendTheme({
colors: {
brand: {
100: '#ffebee',
200: '#e57373',
300: '#f44336',
400: '#e53935',
},
},
});
function MyApp({ Component, pageProps }) {
return (
<ChakraProvider theme={customTheme}>
<Component {...pageProps} />
</ChakraProvider>
);
}
export default MyApp;
现在,我们可以打开 pages/index.js 文件并使用我们的自定义颜色添加一些 Chakra UI 组件:
import { VStack, Button } from '@chakra-ui/react';
export default function Home() {
return (
<VStack padding="10">
<Button backgroundColor="brand.100"> brand.100
</Button>
<Button backgroundColor="brand.200"> brand.200
</Button>
<Button backgroundColor="brand.300"> brand.300
</Button>
<Button backgroundColor="brand.400"> brand.400
</Button>
</VStack>
);
}
在网页浏览器中打开页面,我们将看到以下结果:

图 7.1 – 带自定义主题颜色的 Chakra UI 按钮
在继续本章之前,你可以自由地向该 Chakra UI 安装添加你的自定义样式,以确保结果符合你的品味!
如果你想了解更多关于自定义属性名称的信息,我建议你在继续之前先阅读官方指南:https://chakra-ui.com/docs/theming/customize-theme。
我们已经讨论过但尚未看到的是 Chakra UI 提供的内置深色/浅色模式支持。
该库默认使用浅色模式,但我们可以通过打开 pages/_document.js 文件并添加以下内容来修改此行为:
import { ColorModeScript } from '@chakra-ui/react';
import NextDocument, {
Html,
Head,
Main,
NextScript
} from 'next/document';
import { extendTheme } from '@chakra-ui/react';
const config = {
useSystemColorMode: true,
};
const theme = extendTheme({ config });
export default class Document extends NextDocument {
render() {
return (
<Html lang="en">
<Head />
<body>
<ColorModeScript
initialColorMode={theme.config.initialColorMode}
/>
<Main />
<NextScript />
</body>
</Html>
);
}
}
ColorModeScript组件将注入一个脚本,允许我们的应用程序根据用户的偏好以浅色/深色模式运行。鉴于前面的配置,我们将采用用户系统的偏好来渲染组件。例如,假设用户已将操作系统设置为深色模式。在这种情况下,我们的网站将默认以深色模式渲染组件,反之亦然,如果用户将操作系统设置为该偏好,它将以浅色模式渲染组件。
我们可以通过打开pages/index.js文件并替换其内容来测试脚本是否正确工作:
import {
VStack,
Button,
Text,
useColorMode
} from '@chakra-ui/react';
export default function Home() {
const { colorMode, toggleColorMode } = useColorMode();
return (
<VStack padding="10">
<Text fontSize="4xl" fontWeight="bold" as="h1">
Chakra UI
</Text>
<Text fontSize="2xl" fontWeight="semibold" as="h2">
Rendering in {colorMode} mode
</Text>
<Button
aria-label="UI Theme"
onClick={toggleColorMode}
>
Toggle {colorMode === 'light' ? 'dark' : 'light'}
mode
</Button>
</VStack>
);
}
感谢 Chakra UI 的useColorMode钩子,我们总能知道正在使用哪种颜色模式,并且可以根据该值渲染特定的组件(或更改颜色)。此外,Chakra UI 将记住用户的决定,因此如果他们设置了深色模式,当他们回到网站时,他们将发现网页应用了相同的颜色模式。
如果我们现在打开我们网站的首页,我们将能够更改其颜色模式。结果应该看起来像这样:

图 7.2 – Chakra UI 颜色模式
现在我们已经迈出了与 Chakra UI 和 Next.js 的第一步,我们终于准备好开始开发员工目录了。
该网站将非常简单:它将只有一个列出虚构公司 ACME Corporation 所有员工的首页,以及每个用户的单个页面。
在每个页面上,我们将有一个按钮用于在深色和浅色模式之间切换。
使用 Chakra UI 和 Next.js 构建员工目录
我们可以重用介绍部分中为 Chakra UI 和 Next.js 设置的现有项目来构建我们的员工目录。但我们仍需要对已编写的代码进行一些小的修改。
如果您有任何疑问,您可以在 GitHub 上查看完整的网站示例:https://github.com/PacktPublishing/Real-World-Next.js/tree/main/07-using-ui-frameworks/with-chakra-ui。
首先,我们需要员工数据。您可以在以下 URL 找到完整的员工列表(使用假数据生成):github.com/PacktPublishing/Real-World-Next.js/blob/main/07-using-ui-frameworks/with-chakra-ui/data/users.js。如果您愿意,您可以通过创建一个对象数组来编写自定义员工数据,其中每个对象必须具有以下属性:
-
id -
用户名 -
名字 -
姓氏 -
描述 -
职位名称 -
头像 -
封面图片
现在创建一个新的目录/data和一个名为users.js的 JavaScript 文件,我们将在这里放置我们的员工数据:
export default [
{
id: 'QW3xhqQmTI4',
username: 'elegrice5',
first_name: 'Edi',
last_name: 'Le Grice',
description: 'Aenean lectus. Pellentesque eget
nunc...',
job_title: 'Marketing Assistant',
avatar:
'https://robohash.org/elegrice5.jpg?size=350x350',
Cover_image:
'https://picsum.photos/seed/elegrice5/1920/1080',
},
// ...other employee's data
];
我们可以保留pages/_document.js文件中的介绍部分不变。这样,我们将能够访问我们网站的深色/浅色主题切换功能。
前往 pages/_app.js 页面,我们可以通过包含一个新的 TopBar 组件(我们将在下一刻创建)并移除自定义主题来修改其内容,因为我们目前不需要它:
import { ChakraProvider, Box } from '@chakra-ui/react';
import TopBar from '../components/TopBar';
function MyApp({ Component, pageProps }) {
return (
<ChakraProvider>
<TopBar />
<Box maxWidth="container.xl" margin="auto">
<Component {...pageProps} />
</Box>
</ChakraProvider>
);
}
export default MyApp;
如前一个代码块所示,我们将 <Component /> 组件包裹在一个 Chakra UI Box 组件中。
默认情况下,<Box> 作为空的 <div>,并且像任何其他 Chakra UI 组件一样,它接受任何 CSS 指令作为属性。在这种情况下,我们使用 margin="auto"(这相当于 margin: auto)和 maxWidth="container.xl",这相当于 max-width: var(--chakra-sizes-container-xl)。
让我们创建一个新的文件,/components/TopBar/index.js,并创建 TopBar 组件:
import { Box, Button, useColorMode } from '@chakra-ui/react';
import { MoonIcon, SunIcon } from '@chakra-ui/icons';
function TopBar() {
const { colorMode, toggleColorMode } = useColorMode();
const ColorModeIcon = colorMode === 'light' ? SunIcon :
MoonIcon;
return (
<Box width="100%" padding="1"
backgroundColor="whatsapp.500">
<Box maxWidth="container.xl" margin="auto">
<Button
aria-label="UI Theme"
leftIcon={<ColorModeIcon />}
onClick={toggleColorMode}
size="xs"
marginRight="2"
borderRadius="sm">
Toggle theme
</Button>
</Box>
</Box>
);
}
export default TopBar;
此组件与我们之前在上一节中创建的组件没有区别;每次用户点击按钮时,它将使用 Chakra UI 内置的 toggleColorMode 函数切换暗/亮模式。
我们现在可以在新的 components/UserCard/index.js 文件中创建一个额外的组件:
import Link from 'next/link';
import {
Box, Text, Avatar, Center, VStack, useColorModeValue
} from '@chakra-ui/react';
function UserCard(props) {
return (
<Link href={`/user/${props.username}`} passHref>
<a>
<VStack
spacing="4"
borderRadius="md"
boxShadow="xl"
padding="5"
backgroundColor={
useColorModeValue('gray.50', 'gray.700')
}>
<Center>
<Avatar size="lg" src={props.avatar} />
</Center>
<Center>
<Box textAlign="center">
<Text fontWeight="bold" fontSize="xl">
{props.first_name} {props.last_name}
</Text>
<Text fontSize="xs"> {props.job_title}</Text>
</Box>
</Center>
</VStack>
</a>
</Link>
);
}
export default UserCard;
如您所见,我们将整个组件包裹在一个 Next.js <Link> 组件中,将 href 值传递给 <a> 子元素。
我们随后使用垂直堆叠(VStack)组件,它底层使用 flexbox 来帮助我们垂直排列子元素。
根据所选的颜色主题,我们可能希望为我们的用户卡片设置不同的背景颜色。我们可以通过使用 Chakra UI 内置的 useColorModeValue 来实现这一点:
backgroundColor={useColorModeValue('gray.50', 'gray.700')}>
第一个值('gray.50')将在用户选择亮色主题时由 Chakra UI 应用。当选择暗色主题时,UI 库将使用第二个值('gray.700')代替。
如果我们现在向 <UserCard> 组件传递正确的属性,我们将看到如下内容:

图 7.3 – 用户卡片组件
我们终于准备好在主页上渲染员工列表了!让我们转到我们的 pages/index.js 文件,导入员工列表,并使用新创建的 UserCard 组件显示它们:
import { Box, Grid, Text, GridItem } from '@chakra-ui/react';
import UserCard from '../components/UserCard';
import users from '../data/users';
export default function Home() {
return (
<Box>
<Text
fontSize="xxx-large"
fontWeight="extrabold"
textAlign="center"
marginTop="9">
ACME Corporation Employees
</Text>
<Grid
gridTemplateColumns={
['1fr', 'repeat(2, 1fr)', 'repeat(3, 1fr)']
}
gridGap="10"
padding="10">
{users.map((user) => (
<GridItem key={user.id}>
<UserCard {...user} />
</GridItem>
))}
</Grid>
</Box>
);
}
在这个页面上,我们可以看到另一个不错的 Chakra UI 功能:响应式属性。我们使用 <Grid> 组件为用户的卡片构建一个网格模板:
gridTemplateColumns={
['1fr', 'repeat(2, 1fr)', 'repeat(3, 1fr)']
}
每个 Chakra UI 属性都可以接受一个值数组作为输入。在上面的例子中,UI 库将在移动屏幕上渲染 '1fr',在中等屏幕(例如平板电脑)上渲染 'repeat(2, 1fr)',在更大屏幕(桌面)上渲染 'repeat(3, 1fr)'。
我们现在可以运行开发服务器并查看结果:

图 7.4 – 亮色模式下的员工目录主页
在我的情况下,我设置了系统偏好为 TopBar 组件:

图 7.5 – 暗色模式下的员工目录主页
我们现在只需要创建一个员工页面。
让我们创建一个名为 pages/users/[username].js 的新文件;在这里,我们将使用 Next.js 内置方法在构建时静态渲染每个页面。
我们可以先导入 users.js 文件,并使用 Next.js 的 getStaticPaths 函数创建所有静态路径:
import users from '../../data/users';
export function getStaticPaths() {
const paths = users.map((user) => ({
params: {
username: user.username
}
}));
return {
paths,
fallback: false
};
}
使用 getStaticPaths 函数,我们告诉 Next.js 我们需要为用户数组中找到的每个用户渲染一个新页面。
我们还告诉 Next.js 如果请求的路径在构建时未生成,则显示 404 页面;我们通过使用 fallback: false 属性来完成此操作。
如果设置为 true,该属性告诉 Next.js 如果在构建时没有渲染页面,则尝试在服务器端渲染页面。这是因为我们可能希望从数据库或外部 API 获取页面,我们不希望在创建新页面时每次都重建整个网站。因此,当我们设置 fallback 为 true 时,Next.js 将在服务器端重新运行 getStaticProps 函数,渲染页面,并将其作为静态页面提供服务。
在这个例子中,我们不需要这个功能,因为我们是从静态 JavaScript 文件中获取数据,但我们在后面的章节中会使用这个功能。
让我们继续编写 getStaticProps 函数:
export function getStaticProps({ params }) {
const { username } = params;
return {
props: {
user: users.find((user) => user.username ===
username)
}
};
}
通过这个函数,我们通过过滤用户数组来查询我们想在页面上显示的特定用户。
在我们开始编写页面内容之前,让我们导入所需的 Chakra UI 和 Next.js 依赖项:
import Link from 'next/link';
import {
Avatar,
Box,
Center,
Text,
Image,
Button,
Flex,
useColorModeValue
} from '@chakra-ui/react';
我们现在可以编写我们的 UserPage 组件。我们将把所有内容都包裹在一个 Chakra UI <Center> 组件中,该组件底层使用 flexbox 来居中所有子元素。
然后,我们将使用其他 Chakra UI 内置组件,如 <Image>、<Flex>、<Avatar>、<Text> 等,来创建我们的组件:
function UserPage({ user }) {
return (
<Center
marginTop={['0', '0', '8']}
boxShadow="lg"
minHeight="fit-content">
<Box>
<Box position="relative">
<Image
src={user.cover_image}
width="fit-content"
height="250px"
objectFit="cover" />
<Flex
alignItems="flex-end"
position="absolute"
top="0"
left="0"
backgroundColor={
useColorModeValue('blackAlpha.400',
'blackAlpha.600')
}
width="100%"
height="100%"
padding="8"
color="white">
<Avatar size="lg" src={user.avatar} />
<Box marginLeft="6">
<Text as="h1" fontSize="xx-large"
fontWeight="bold">
{user.first_name} {user.last_name}
</Text>
<Text as="p" fontSize="large"
lineHeight="1.5">
{user.job_title}
</Text>
</Box>
</Flex>
</Box>
<Box
maxW="container.xl"
margin="auto"
padding="8"
backgroundColor={useColorModeValue('white',
'gray.700')}>
<Text as="p">{user.description}</Text>
<Link href="/" passHref>
<Button marginTop="8" colorScheme="whatsapp"
as="a">
Back to all users
</Button>
</Link>
</Box>
</Box>
</Center>
);
}
export default UserPage;
我们可以注意到其他优秀的 Chakra UI 功能,例如在“返回所有用户”按钮中使用的 as 属性:
<Button marginTop="8" colorScheme="whatsapp" as="a">
在这里,我们告诉 Chakra UI 将 Button 组件渲染为 <a> HTML 元素。这样,我们就可以在 Next.js 的父 Link 组件中使用 passHref 属性将 href 值传递给按钮,从而创建一个更易于访问的 UI;这样做时,我们将创建一个带有适当 href 属性的实际 <a> 元素。
我们现在可以运行开发服务器并测试最终结果:

图 7.6 – 亮模式下的单个员工
通过点击“切换主题”按钮,我们还可以访问用户界面的暗黑版本,它看起来是这样的:

图 7.7 – 暗黑模式下的单个员工
我们还使用了响应式样式,因此我们可以通过调整浏览器页面的大小来测试我们的 UI:

图像 7.8 – 单个员工页面(移动视图)
如您所见,使用 Chakra UI 内置组件实现响应式用户界面非常简单。
如果您想深入了解所有现有的组件、钩子和实用工具,您可以在 chakra-ui.com 上了解更多信息。
关于 Chakra UI 的总结性话语
Chakra UI 是一个优秀的现代 UI 库,我本人也在我工作的许多项目中使用它。它是开源的,并且免费使用,有一个每天都在优化它并使其更加易于使用、性能更佳和更完整的社区。
它还提供了一套由 Chakra UI 核心团队构建的预制的 UI 组件。如果您感兴趣,可以在 pro.chakra-ui.com/components 上查看。
在下一节中,我们将把我们的重点转移到另一个流行的但完全不同的 UI 库:TailwindCSS。
在 Next.js 中集成 TailwindCSS
TailwindCSS 是一个以工具优先的 CSS 框架,它允许您使用预制的 CSS 类来构建任何用户界面,这些类以直接的方式映射 CSS 规则。
与 Chakra UI、Material UI 以及许多其他 UI 框架不同,它只提供 CSS 规则;框架不提供任何 JavaScript 脚本或 React 组件,因此我们需要自己编写它们。
它的主要优势如下:
-
框架无关性:您可以在 React、Vue、Angular 以及纯 HTML 和 JavaScript 中使用 TailwindCSS。它只是一组 CSS 规则。
-
可定制主题:就像 Chakra UI 一样,您可以自定义所有 TailwindCSS 变量,使它们与您的设计令牌相匹配。
-
暗黑和亮色主题支持:您可以通过修改
<html>元素中的一个特定 CSS 类来轻松启用或禁用暗黑主题。 -
高度优化:TailwindCSS 由许多 CSS 类组成,但在构建时能够修剪未使用的类,从而减少最终包的大小,因为未使用的 CSS 类会被移除。
-
移动端兼容性:您可以使用特定 CSS 类的前缀来仅将某些规则应用于移动、桌面或平板屏幕。
在本节中,我们将看到如何通过重建上一节中做的相同项目来在 Next.js 中集成、自定义和优化 TailwindCSS。这样,Chakra UI 和 TailwindCSS 之间的差异将更加明显。
让我们创建一个新的项目并安装所有必需的依赖项:
npx create-next-app employee-directory-with-tailwindcss
TailwindCSS 只需要三个 devDependencies,所以让我们进入新创建的项目并安装它们:
yarn add -D autoprefixer postcss tailwindcss
正如我们已经看到的,TailwindCSS 不附带任何 JavaScript 实用工具,因此,与 Chakra UI 不同,我们将需要自己管理暗黑/亮色主题切换。然而,我们可以利用 next-themes 库来帮助我们管理主题,所以让我们安装这个包:
yarn add next-themes
现在我们已经安装了所有依赖项,我们需要设置基本的 TailwindCSS 配置文件。我们可以通过使用 tailwindcss init 命令来完成:
npx tailwindcss init -p
这将创建两个不同的文件:
-
tailwind.config.js:此文件将帮助我们配置 TailwindCSS 主题、未使用 CSS 清除、暗黑模式、插件等。 -
postcss.config.js:我们可以随时按我们的喜好编辑postcss.config.js。
首先,我们想要配置 TailwindCSS 以从最终构建中移除未使用的 CSS。我们可以通过打开 tailwind.config.js 文件并按以下方式编辑它来实现:
module.exports = {
purge: ['./pages/**/*.{js,jsx}',
'./components/**/*.{js,jsx}'],
darkMode: 'class',
theme: {
extend: {},
},
variants: {
extend: {},
},
plugins: [],
};
如您所见,我们告诉 TailwindCSS 检查 pages/ 和 components/ 目录中所有以 .js 或 .jsx 结尾的文件,并删除其中任何文件未使用的所有 CSS 类。
我们还将 darkMode 属性设置为 'class'。这样,框架将查看 <html> 类元素以确定我们是否需要使用深色或浅色模式渲染组件。
现在,我们只需要在我们的应用程序的每一页上包含默认的 tailwind.css CSS 文件,我们就可以开始了。我们可以通过在 pages/_app.js 文件中导入 'tailwindcss/tailwind.css' 来做到这一点:
import 'tailwindcss/tailwind.css';
function MyApp({ Component, pageProps }) {
return <Component {...pageProps} />
}
export default MyApp;
现在,我们可以开始包含特定的 TailwindCSS 类。保持我们的代码编辑器中的 pages/_app.js 文件打开,我们可以首先从 next-themes 包中导入 ThemeProvider,这将帮助我们管理深色/浅色主题切换,并将其他所有组件包裹在其中:
import { ThemeProvider } from 'next-themes';
import TopBar from '../components/TopBar';
import 'tailwindcss/tailwind.css';
function MyApp({ Component, pageProps }) {
return (
// attribute="class" will set a "dark" CSS class
// to the main <html> tag
<ThemeProvider attribute="class">
<div
className="dark:bg-gray-900 bg-gray-50 w-full min-
h-screen"
>
<TopBar />
<Component {...pageProps} />
</div>
</ThemeProvider>
);
}
export default MyApp;
如您所见,我们正在遵循与 Chakra UI 相同的步骤。我们导入 TopBar 组件(它将适用于我们网站上的所有页面)并将 Next.js <Component /> 组件包裹在一个容器中。
我们很快就会看到如何编写 TopBar 组件;现在,让我们集中精力在包裹 <Component /> 组件的 <div> 上:
<div className="dark:bg-gray-900 bg-gray-50 w-full min-h-screen">
我们使用了四个不同的 CSS 类;让我们逐一分析:
-
dark:bg-gray-900:当主题设置为深色模式时,此<div>的背景颜色将设置为bg-gray-900,这是一个映射到#111927HEX 颜色的 TailwindCSS 变量。 -
bg-gray-50:默认情况下(因此在浅色模式下),此div的背景颜色将设置为bg-gray-50,它映射到#f9fafbHEX 颜色。 -
w-full:这意味着“全宽”,因此<div>将将width属性设置为100%。 -
min-h-screen:这个 CSS 类代表 将最小高度属性设置为整个屏幕高度,是min-height: 100vh的缩写。
现在,我们可以创建一个新的 /components/TopBar/index.js 文件并添加以下内容:
import ThemeSwitch from '../ThemeSwitch';
function TopBar() {
return (
<div className="w-full p-2 bg-green-500">
<div className="w-10/12 m-auto">
<ThemeSwitch />
</div>
</div>
);
}
export default TopBar;
在这里,我们创建了一个全宽的水平绿色条(className="w-full p-2 bg-green-500"),具有 0.5rem 的填充(p-2 类)和 #12b981 作为背景颜色(bg-green-500)。
在那个 <div> 内部,我们放置另一个居中的 <div>,宽度为 75%(w-10/12)。
然后,我们导入 ThemeSwitch 按钮,该按钮仍需创建。让我们在 components/ThemeSwitch/index.js 的新文件中创建它:
import { useTheme } from 'next-themes';
function ThemeSwitch() {
const { theme, setTheme } = useTheme();
const dark = theme === 'dark';
const toggleTheme = () => {
setTheme(dark ? 'light' : 'dark');
};
if (typeof window === 'undefined') return null;
return (
<button
onClick={toggleTheme}
className="
dark:bg-green-900 dark:bg-opacity-20 dark:text-
gray-50
bg-green-100 text-gray-500 pl-2 pr-2 rounded-md
text-xs
p-1"
>
Toggle theme
</button>
);
}
export default ThemeSwitch;
这个组件相当简单;我们使用 next-themes 库打包的 useTheme 钩子,并根据当前设置的主题将主题值更改为 light 或 dark。
一件需要注意的事情是我们只在客户端执行这个操作(通过编写typeof window === 'undefined')。实际上,这个钩子会在浏览器的localStorage中添加一个theme条目,当然,在服务器端是无法访问的。
因此,ThemeSwitch组件将仅在客户端渲染。
谈到<button> CSS 类,我们可以看到我们正在构建一个带有绿色背景的圆角按钮。顺便说一句,绿色色调将根据当前选定的主题而有所不同。
现在让我们编写UserCard组件。在components/UserCard/index.js下创建一个新文件,并添加以下内容:
import Link from 'next/link';
function UserCard(props) {
return (
<Link href={`/user/${props.username}`} passHref>
<div
className="
dark:bg-gray-800 bg-gray-100 cursor-pointer
dark:text-white p-4 rounded-md text-center
shadow-xl"
>
<img
src={props.avatar}
alt={props.username}
className="w-16 bg-gray-400 rounded-full m-auto"
/>
<div className="mt-2 font-bold">
{props.first_name} {props.last_name}
</div>
<div className="font-light">{props.job_title}</div>
</div>
</Link>
);
}
export default UserCard;
除了 CSS 类名外,这个组件与 Chakra UI 的组件没有太大区别。
图片优化
如您所见,我们目前没有对图片进行优化,并且使用默认的<img /> HTML 元素来提供它们。不幸的是,这可能会使我们的网站变慢,并导致糟糕的 SEO 评分。
尝试配置自动图片优化,并使用 Next.js 的<Image />组件来提供它们!
还记得怎么做吗?您可以查看第三章,Next.js 基础和内置组件。
现在,我们已经准备好编写 ACME 员工目录的首页。首先,确保将我们在上一节中使用的相同users.js文件放置在data/users.js下。
如果您需要再次下载,可以通过复制以下内容来完成:github.com/PacktPublishing/Real-World-Next.js/blob/main/07-using-ui-frameworks/with-tailwindcss/data/users.js。
现在,我们可以打开pages/index.js文件,导入users.js文件和UserCard组件,然后将所有内容组合起来创建一个用户网格,就像我们使用 Chakra UI 所做的那样:
import UserCard from '../components/UserCard';
import users from '../data/users';
export default function Home() {
return (
<div className="sm:w-9/12 sm:m-auto pt-16 pb-16">
<h1 className="
dark:text-white text-5xl font-bold text-center
">
ACME Corporation Employees
</h1>
<div className="
grid gap-8 grid-cols-1 sm:grid-cols-3 mt-14
ml-8 mr-8 sm:mr-0 sm:ml-0
">
{users.map((user) => (
<div key={user.id}>
<UserCard {...user} />
</div>
))}
</div>
</div>
);
}
如您所见,这里我们开始使用一些响应式指令:
<div className="sm:w-9/12 sm:m-auto pt-16 pb-16">
sm:前缀用于在窗口宽度大于或等于640px时应用特定规则。
默认情况下,TailwindCSS 类是针对移动端优先的,如果我们想要将特定类应用于更宽的屏幕,我们需要在这些类名前加上以下前缀之一:sm:(640px)、md:(768px)、lg:(1024px)、xl:(1280px)或2xl:(1536px)。
现在,我们可以运行开发服务器并转到首页。我们将看到以下结果:

图 7.9 – 使用 TailwindCSS(浅色主题)构建的员工目录
我们还可以通过点击屏幕顶部的绿色栏中的按钮来切换到深色主题:

图 7.10 – 使用 TailwindCSS(深色主题)构建的员工目录
如果您比较 Chakra UI 实现和 TailwindCSS 实现的视觉结果,您将看到它们多么相似!
让我们通过创建单用户页面来完成我们的网站。如果您还没有创建,请创建一个名为pages/user/[username].js的新页面,并首先导入所需的依赖项:
import Link from 'next/link';
import users from '../../data/users';
现在,我们可以编写getStaticPaths函数:
export function getStaticPaths() {
const paths = users.map((user) => ({
params: {
username: user.username
}
}));
return {
paths,
fallback: false
};
}
接下来,让我们编写getStaticProps函数:
export function getStaticProps({ params }) {
const { username } = params;
return {
props: {
user: users.find((user) => user.username ===
username)
}
};
}
您可能已经注意到,这些函数与我们之前在 Chakra UI 部分编写的函数相同。实际上,通过这种实现方式,我们只改变了渲染页面内容的方式;所有服务器端的数据获取和处理将保持不变。
我们终于准备好编写单用户页面组件了。在这里,我们将创建与 Chakra UI 类似的架构,但当然,使用 TailwindCSS 类和标准 HTML 元素:
function UserPage({ user }) {
return (
<div className="pt-0 sm:pt-16">
<div className="
dark:bg-gray-800 text-white w-12/12
shadow-lg sm:w-9/12 sm:m-auto">
<div className="relative sm:w-full">
<img
src={user.cover_image}
alt={user.username}
className="w-full h-96 object-cover object-
center"
/>
<div className="
bg-gray-800 bg-opacity-50 absolute
flex items-end w-full h-full top-0 left-0 p-8">
<img
src={user.avatar}
alt={user.username}
className="bg-gray-300 w-20
rounded-full mr-4"
/>
<div>
<h1 className="font-bold text-3xl">
{user.first_name} {user.last_name}
</h1>
<p> {user.job_title} </p>
</div>
</div>
</div>
<div className="p-8">
<p className="text-black dark:text-white">
{user.description}
</p>
<Link href="/" passHref>
<button className="
dark:bg-green-400 dark:text-gray-800
bg-green-400
text-white font-semibold p-2
rounded-md mt-6">
Back to all users
</button>
</Link>
</div>
</div>
</div>
);
}
export default UserPage;
完成了!我们通过使用 TailwindCSS 重写了整个应用。
在撰写本文时,原始的 TailwindCSS 样式表大小约为 4.7 MB。通过仅运行yarn build来构建用于生产的网站,最终的 TailwindCSS 文件将大约为 6 KB。
您可以通过在tailwind.config.js文件中注释掉purge属性来快速测试这一点。
到目前为止,我们已经看到了两种不同的(但非常现代的)为网页应用添加样式的方案,它们各有优缺点。
虽然它们在如何为任何网站编写样式方面有一些共同的想法,但 Chakra UI 的优势在于提供预构建的 React 组件,这在您想要将库集成到 Next.js/React 应用中并轻松使其更动态时非常有用。
幸运的是,TailwindCSS 团队提出了一个创新的想法,即提供 TailwindCSS(以及可能的其他 UI 框架)的动态接口:Headless UI。
在下一节中,我们将探讨 Headless UI 以及它如何帮助我们使用 Next.js 构建现代、高性能和优化的网页应用。
集成 Headless UI
如前节所述,TailwindCSS 仅提供用于任何网页组件内部的 CSS 类。
如果我们要实现一些动态内容,例如模态框、开关按钮等,我们需要自己编写一些 JavaScript 逻辑。
Headless UI通过提供 TailwindCSS 的相反面:没有 CSS 类或样式的动态组件,来解决这一问题。这样,我们可以自由地使用 TailwindCSS 或任何其他 CSS 框架以简单的方式对预构建组件进行样式化。
Headless UI 是由Tailwind Labs团队(TailwindCSS 背后的组织)创建的免费开源项目,如果您感兴趣,可以浏览以下 URL 的源代码:github.com/tailwindlabs/headlessui。
将 Headless UI 和 TailwindCSS 集成与仅集成 TailwindCSS 并没有很大区别。我们可以设置一个新的项目并安装所有 TailwindCSS 依赖项,就像我们在上一节中所做的那样。
之后,我们可以安装 Headless UI 本身。我们还将安装classnames,这是一个简单且广泛使用的实用工具,它将帮助我们创建动态 CSS 类名:
yarn add @headlessui/react classnames
我们现在将使用 Headless UI 和 TailwindCSS 开发一个简单的菜单组件。
让我们去pages/index.js文件中导入 Headless UI、classnames和 Next.js 的Link组件:
import Link from 'next/link';
import { Menu } from '@headlessui/react';
import cx from 'classnames';
现在,在同一页面上,让我们创建一个菜单元素的数组。我们将使用它们用模拟数据填充菜单:
const entries = [
{
name: 'Home',
href: '/'
enabled: true,
},
{
name: 'About',
href: '/about',
enabled: true,
},
{
name: 'Contact',
href: '/contact',
enabled: false,
},
];
现在,我们可以解构 Headless UI 的Menu组件,并获取我们构建菜单所需的所有组件:
const { Button, Items, Item } = Menu;
每个菜单条目都将被包裹在一个Item组件中。鉴于每个菜单条目都将以相同的方式表现,我们可以创建一个通用的MenuEntry组件并将其应用于条目数组:
const MenuEntry = (props) => (
<Item disabled={!props.enabled}>
{({ active }) => (
<Link href={props.href} passHref>
<a>{props.name}</a>
</Link>
)}
</Item>
);
正如你所见,Headless UI 将传递一个active状态给Item内的所有元素。我们将使用这个状态来显示用户当前激活的菜单元素。
现在,我们只需要将所有内容包裹在Menu组件中:
export default function Home() {
return (
<div className="w-9/12 m-auto pt-16 pb-16">
<Menu>
<Button>My Menu</Button>
<Items>
{entries.map((entry) => (
<MenuEntry key={entry.name} {...entry} />
))}
</Items>
</Menu>
</div>
);
}
如果我们现在启动开发服务器,我们将在屏幕右上角看到一个完全没有样式的按钮。我们可以点击这个按钮来显示——然后隐藏——其内容。
现在,我们可以开始为我们的菜单进行样式设计,从MenuEntry组件开始:
const MenuEntry = (props) => (
<Item disabled={!props.enabled}>
{({ active }) => {
const classNames = cx(
'w-full', 'p-2', 'rounded-lg', 'mt-2', 'mb-2',
{
'opacity-50': !props.enabled,
'bg-blue-600': active,
'text-white': active,
});
return (
<Link href={props.href} passHref>
<a className={classNames}>{props.name}</a>
</Link>
);
}}
</Item>
);
接下来,让我们转到主组件,我们可以简单地添加所需的 CSS 类来为Button和Item组件进行样式设计。我们希望菜单按钮是紫色并带有白色文字,而下拉菜单要有圆角和漂亮的阴影,所以让我们添加以下类:
export default function Home() {
return (
<div className="w-9/12 m-auto pt-16 pb-16">
<Menu>
<Button className="
bg-purple-500 text-white p-2 pl-4 pr-4 rounded-lg
"> My Menu </Button>
<Items className="
flex flex-col w-52 mt-4 p-2 rounded-xl shadow-lg
">
{entries.map((entry) => (
<MenuEntry key={entry.name} {...entry} />
))}
</Items>
</Menu>
</div>
);
}
我们还可以给我们的菜单添加一个过渡效果,使显示/隐藏部分更加平滑。我们只需要从 Headless UI 导入Transition组件,并将菜单项包裹在其中:
import { Menu, Transition } from '@headlessui/react';
// ...
export default function Home() {
return (
<div className="w-9/12 m-auto pt-16 pb-16">
<Menu>
<Button className="
bg-purple-500 text-white p-2 pl-4 pr-4 rounded-lg
"> My Menu </Button>
<Transition
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0">
<Items className="
flex flex-col w-52 mt-4 p-2
rounded-xl shadow-lg
">
{entries.map((entry) => (
<MenuEntry key={entry.name} {...entry} />
))}
</Items>
</Transition>
</Menu>
</div>
);
}
我们刚刚使用 TailwindCSS 样式化了第一个无头组件,但我们可以使用自己的 CSS 规则或任何其他 CSS 框架!
就像 Chakra UI 一样,TailwindCSS 提供了一系列高级组件,其中许多组件依赖于 Headless UI 来管理它们的交互。如果你对此感兴趣,你可以在tailwindui.com了解更多信息。
摘要
在本章中,我们看到了使用 Next.js、React 甚至纯 HTML 构建用户界面的三种不同且现代的方法。
在接下来的章节中,我们将使用这些部分学到的经验来加速 UI 开发,同时始终关注性能、可访问性和开发者体验。
如果你想要了解 Chakra UI 和 TailwindCSS 之间的具体差异,你可以在 Chakra UI 网站上阅读官方指南:https://chakra-ui.com/docs/comparison。
这两个库都为实施美观的用户界面提供了出色的支持,尽管它们共享一些功能,但在实践中它们相当不同。
Chakra UI 提供了一套出色的组件,但它们仅适用于 React 和 Vue。如果你的项目使用 Angular 或 Svelte 怎么办?
另一方面,TailwindCSS 是 100%框架无关的:你可以独立于你使用的任何技术来用它编写任何网络应用程序的前端。
在我看来,并没有明显的胜者:选择这两个库中的任何一个完全取决于个人喜好。
在下一章中,我们将把我们的关注点转向我们应用程序的后端,学习如何从自定义 Node.js 服务器动态地提供 Next.js 网络应用程序。
第八章:使用自定义服务器
Next.js 是一个非常强大的框架。在这本书的前七章中,我们已经能够创建一些不错的服务器端渲染 Web 应用程序,而无需真正关心调整和自定义 Web 服务器。当然,在现实生活中,我们讨论在 Express.js 或 Fastify 服务器中实现 Next.js 应用程序的机会很少,但了解如何这样做在很多情况下可能很有用。
以我自己的经验来说,在过去几年里,我使用 Next.js 创建了数十个大规模 Web 应用程序,我很少需要使用自定义服务器。然而,在某些情况下,这是不可避免的。
我们将详细探讨以下主题:
-
当我们可能需要使用“自定义服务器”时,它的含义是什么,以及有哪些选项。
-
如何一起使用 Express.js 和 Next.js
-
如何一起使用 Fastify 和 Next.js
-
部署自定义服务器有哪些要求?
到本章结束时,你将能够确定何时使用自定义服务器,它的优缺点是什么,以及它可以解决什么问题。
技术要求
要运行本章中的代码示例,你需要在本地机器上安装 Node.js 和 npm。
如果你愿意,你可以使用在线 IDE,例如 repl.it 或 codesandbox.io;它们都支持 Next.js,你不需要在电脑上安装任何依赖。与其他章节一样,你可以在 GitHub 上找到本章的代码库:https://github.com/PacktPublishing/Real-World-Next.js。
关于使用自定义服务器
正如我们已经看到的,Next.js 随带自己的服务器,因此我们不需要配置自定义服务器就可以开始使用这个框架编写 Web 应用程序。然而,在某些情况下,我们可能希望从自定义 Web 服务器(如 Express.js 或 Fastify)中提供服务 Next.js 应用程序,框架通过公开一些简单的 API 来实现这一点,我们将在下一刻探讨。但在查看实现之前,让我们回答一个重要的问题:我们真的需要自定义服务器吗?
简短的回答是,大多数时候,不需要。Next.js 是一个如此完整的框架,我们很少需要通过 Express.js、Fastify 或其他任何服务器端框架来定制服务器端逻辑。但有时,这是不可避免的,因为它可以解决特定的问题。
自定义服务器的常见用例如下:
-
将 Next.js 集成到现有服务器中:假设你正在重构一个现有的 Web 应用程序以采用 Next.js;你可能希望尽可能多地保留服务器端逻辑,包括你的中间件和路由。在这种情况下,你可以通过选择你的网站哪些页面将由框架提供服务,哪些页面将由其他方式渲染来逐步添加 Next.js。
-
多租户模式:尽管 Next.js 支持根据当前主机名支持多个域名和条件渲染(如果你对原生解决方案感兴趣,请查看
github.com/leerob/nextjs-multiple-domains),但在某些情况下,你可能需要更多的控制权以及一个简化的工作流程来处理多达数千个不同的域名。如果你对 Next.js 的 Express.js/Fastify 多租户中间件感兴趣,可以查看github.com/micheleriva/krabs。 -
你想要更多的控制权:尽管 Next.js 为创建强大和完整的用户体验提供了所需的一切,但在某些情况下,如果你的应用程序正在变得更加复杂,你想要使用不同的方法来组织后端代码,例如采用 MVC 哲学,其中 Next.js 只是过程中的“视图”部分。
虽然自定义服务器可以解决一些问题,但它也有一些缺点。例如,你不能将自定义服务器部署到由 Next.js 作者创建的 Vercel 平台,该平台高度优化了框架。此外,你将需要编写和维护更多的代码,如果你在一个侧项目中工作,在一个小团队中,或者在一个小公司中,这可能会是一个重大的缺点。
在下一节中,我们将看到如何使用 Node.js 最受欢迎的 Web 框架之一 Express.js 为 Next.js 编写自定义服务器。
使用自定义 Express.js 服务器
编写用于渲染 Next.js 页面的自定义 Express.js 服务器比你想象的要简单。让我们创建一个新的项目并安装以下依赖项:
yarn add express react react-dom next
一旦我们安装了这四个包,我们就可以开始编写自定义的 Express.js 服务器。让我们在项目根目录下创建一个 index.js 文件,并首先导入所需的依赖项:
const { parse } = require('url');
const express = require('express');
const next = require('next');
我们现在需要实例化 Next.js 应用程序,我们可以在导入语句之后添加以下代码来完成此操作:
const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
让我们通过编写 main 函数来完成我们的服务器,该函数将每个传入的 GET 请求传递给 Next.js 进行服务器端渲染:
async function main() {
try {
await app.prepare();
const handle = app.getRequestHandler();
const server = express();
server
.get('*', (req, res) => {
const url = parse(req.url, true);
handle(req, res, url);
})
.listen(3000, () => console.log('server ready'));
} catch (err) {
console.log(err.stack);
}
}
main();
让我们关注 main 函数的主体,看看发生了什么。
首先,我们等待 Next.js 应用程序准备好进行渲染。然后,我们创建一个 handle 常量,该常量将代表 Next.js 处理传入的请求。然后,我们创建 Express.js 服务器,并要求它使用 Next.js 请求处理程序处理所有 GET 请求。
我们现在可以通过创建一个新的 pages/ 目录和一个 pages/index.js 文件来创建一个主页,该文件包含以下内容:
export default function Homepage() {
return <div> Homepage </div>;
}
如果我们尝试运行 node index.js,然后访问 http://localhost:3000,我们将在屏幕上看到 主页 文本。我们做到了!
我们还可以通过创建一个包含以下内容的 pages/greet/[user].js 文件来测试动态路由:
export function getServerSideProps(req) {
return {
props: {
user: req.params.user,
},
};
}
export default function GreetUser({ user }) {
return (
<div>
<h1>Hello {user}!</h1>
</div>
);
}
访问 http://localhost:3000/greet/Mitch,我们将在屏幕上看到一个友好的 Hello Mitch! 消息。正如你所见,实现动态路由非常简单!
从这个点开始,我们可以继续像以前一样工作在 Next.js 上。与上一章相比,没有太多区别,但如果我们没有充分利用自定义服务器的全部潜力,那么拥有一个自定义服务器有什么意义呢?
我们已经看到,当我们将现有的 Web 应用程序逐步迁移到 Next.js 时,自定义服务器可能非常有帮助。
通过如下重构服务器来添加一些更多功能:
server
.get('/', (req, res) => {
res.send('Hello World!');
})
.get('/api/greet', (req, res) => {
res.json({ name: req.query?.name ?? 'unknown' });
})
.listen(3000, () => console.log('server ready'));
正如你所见,我们现在并没有用 Next.js 服务任何页面。所以,我们只是提供了一个主页,并在 /api/greet 提供了一个假 API。
现在,我们想要创建一个新的 /about 页面并使用 Next.js 来提供服务。但首先,我们需要在 /pages/about 路径下创建一个由 Next.js 驱动的页面:
export default function About() {
return <div> This about page is served from Next.js </div>;
}
现在,我们可以回到我们的 index.js 文件并编辑 main 函数,如下所示:
server
.get('/', (req, res) => {
res.send('Hello World!');
})
.get('/about', (req, res) => {
const { query } = parse(req.url, true);
app.render(req, res, '/about', query);
})
.get('/api/greet', (req, res) => {
res.json({ name: req.query?.name ?? 'unknown' });
})
.listen(3000, () => console.log('server ready'));
我们现在使用一个不同的函数来渲染 Next.js 页面:app.render。
此函数接受以下参数:Express.js 的 request 和 response、要渲染的页面以及解析后的查询字符串。
但当我们启动服务器并访问 http://localhost:3000/about 时,我们会注意到一个空白页面。如果我们检查此页面的网络调用,我们会看到以下情况:

图 8.1 – Next.js 脚本未找到
这里发生了什么?Next.js 正确渲染了页面,正如你可以通过检查 HTML 输出所知,但页面完全是白色的!
我们忘记告诉 Express.js,每个以 _next/ 开头的静态资源都需要由 Next.js 本身处理。这是因为所有这些静态资源(通常是 JavaScript 文件)都负责将 React 导入浏览器,处理水合,以及管理所有 Next.js 前端特定功能。
我们可以通过添加以下路由来快速修复这个问题:
// ...
await app.prepare();
const handle = app.getRequestHandler();
const server = express();
server
.get('/', (req, res) => {
res.send('Hello World!');
})
.get('/about', (req, res) => {
const { query } = parse(req.url, true);
app.render(req, res, '/about', query);
})
.get('/api/greet', (req, res) => {
res.json({ name: req.query?.name ?? 'unknown' });
})
.get(/_next\/.+/, (req, res) => {
const parsedUrl = parse(req.url, true);
handle(req, res, parsedUrl);
})
.listen(3000, () => console.log('server ready'));
由于我们无法预测 Next.js 静态资源名称,我们将使用一个正则表达式 (/_next\/.+/) 匹配所有以 _next/ 开头的文件。然后我们使用 Next.js 的 handle 方法来服务这些文件。
我们现在可以启动我们的服务器并看到它按预期工作。
正如我们之前所看到的,从现在开始,开发 Next.js 页面的开发者体验将保持不变。我们仍然可以访问 _app.js 和 _document.js 文件,我们仍然可以使用内置的 Link 组件,等等。
在下一节中,我们将了解如何将 Next.js 与另一个非常流行的 Node.js 网络框架 Fastify 集成。
使用自定义 Fastify 服务器
Fastify 是一个出色的 Node.js 网络框架。正如其名称所暗示的,与其他网络框架(如 Express.js、Koa 和 Hapi)相比,它非常吸引人,因为它确实非常快。如果您想了解更多关于其性能的信息,您可以在以下存储库中找到官方基准测试:github.com/fastify/benchmarks。
这个网络框架是由一些 Node.js 的核心开发者开发和维护的,例如 Matteo Collina(Node.js 技术指导委员会成员)。因此,正如您所想象的,Fastify 背后的团队完美地了解运行时的工作原理,并使框架尽可能优化。
但 Fastify 不仅仅关于性能:它还强制执行优秀的设计最佳实践,以尽可能保持开发者的体验。它还有一个强大的插件系统,允许每个人轻松编写自己的插件或中间件。如果您还没有这样做,我强烈建议您在 https://github.com/fastify/fastify 上查看。
Fastify 提供了一个官方插件来管理 Next.js 渲染的路由:fastify-nextjs。您可以在以下位置找到其源代码:https://github.com/fastify/fastify-nextjs。
让我们创建一个新的空项目,并安装以下依赖项以查看其效果:
yarn add react react-dom fastify fastify-nextjs next
我们现在可以创建与过去章节中相同的三个页面。
在 /pages/index.js 下实现一个简单的首页如下:
export default function Homepage() {
return <div> Homepage </div>;
}
在 /pages/about.js 下实现一个“关于”页面如下:
export default function About() {
return <div> This about page is served from Next.js </div>;
}
最后,一个用于在 /pages/greet/[user].js 下问候用户的动态页面可以按以下方式实现:
export function getServerSideProps(req) {
return {
props: {
user: req.params.user,
},
};
}
export default function GreetUser({ user }) {
return (
<div>
<h1>Hello {user}!</h1>
</div>
);
}
我们现在可以编写我们的 Fastify 服务器了,与 Express.js 相比,这将非常简单。让我们在项目的根目录下创建一个 index.js 文件,并添加以下内容:
const fastify = require('fastify')();
fastify
.register(require('fastify-nextjs'))
.after(() => {
fastify.next('/');
fastify.next('/about');
fastify.next('/greet/:user');
});
fastify.listen(3000, () => {
console.log('Server listening on http://localhost:3000');
});
启动服务器后,我们将能够渲染我们在 index.js 文件中指定的所有页面!如您所注意到的,这种实现甚至比 Express.js 更简单。我们只需调用 fastify.next 函数来渲染一个 Next.js 页面,而且我们甚至不需要担心 Next.js 的静态资源;Fastify 会代表我们处理它们。
从这一点开始,我们可以开始编写不同的路由,提供不同的内容,例如 JSON 响应、HTML 页面和静态文件:
fastify.register(require('fastify-nextjs')).after(() => {
fastify.next('/');
fastify.next('/about');
fastify.next('/greet/:user');
fastify.get('/contacts', (req, reply) => {
reply
.type('html')
.send('<h1>Contacts page</h1>');
});
});
如您所见,将 Next.js 与 Fastify 集成非常简单。从这一点开始,就像与 Express.js 一样,我们可以做任何我们想做的事情,就像我们正在编写一个普通的 Next.js 网络应用程序。
我们可以创建 _app.js 和 _document.js 文件来定制 Next.js 页面的行为,集成任何 UI 库,并执行我们在前面章节中看到的所有事情。
摘要
在本章中,我们看到了如何将 Next.js 与 Node.js 最受欢迎的两个网络框架集成:Express.js 和 Fastify。Next.js 与其他网络框架集成也是可能的,其实现方式与前面章节中看到的不同。
在使用任何类型的自定义服务器时(无论是 Express.js、Fastify 还是任何其他框架),需要考虑的一点是我们不能将其部署到某些提供商,例如 Vercel 或 Netlify。
从技术上来说,许多提供商(Vercel、Netlify、Cloudflare 等等)提供了一种很好的方式来服务由 Node.js 驱动的应用程序:无服务器函数。然而,由于这是一个相当高级的话题,我们将在第十一章,“不同的部署平台”中深入讨论它。
正如我们将在第十一章,“不同的部署平台”中看到的那样,Next.js 是一个高度优化以在 Vercel 上运行的框架,这是框架背后的公司提供的基础设施。使用自定义服务器,我们将失去部署到这个基础设施的能力,这使得事情变得稍微不那么优化和集成。
仍然,还有其他一些很棒的选择,例如 DigitalOcean、Heroku、AWS 和 Azure。从这一点来看,我们可以在所有支持 Node.js 环境的这些服务上部署我们的自定义 Next.js 服务器。
从第十一章,“不同的部署平台”开始,我们将更深入地讨论 Next.js 的部署。但到目前为止,我们只想集中讨论其功能和集成。
具体谈到其集成,一旦我们为我们的 Next.js 应用程序编写了一个页面、一些中间件或一个组件,我们希望在将其部署到生产环境之前测试它是否正常工作。在下一章中,我们将讨论使用两个最常用的测试库来实现单元和端到端测试:Jest 和 Cypress。
第九章: 测试 Next.js
测试是整个开发工作流程的重要组成部分。它使您更有信心,您不会将错误引入代码,也不会破坏任何现有功能。
专门测试 Next.js 与测试任何其他 React 应用程序或 Express.js、Fastify 或 Koa 应用程序并无不同。实际上,我们可以将测试阶段分为三个不同的阶段:
-
单元测试
-
端到端测试
-
集成测试
我们将在本章的各个部分中详细探讨这些概念。
如果您已经具备编写 React 应用程序的先前经验,您很可能会将您的知识重新用于测试基于 Next.js 的网站。
在本章中,我们将详细探讨以下内容:
-
测试和测试框架简介
-
设置测试环境
-
如何使用一些最受欢迎的测试运行器、框架和实用库
到本章结束时,您将能够使用测试运行器和测试库设置测试环境,并在将代码发送到生产之前运行测试。
技术要求
要运行本章中的代码示例,您需要在您的本地机器上安装 Node.js 和 npm。
如果您愿意,可以使用在线 IDE,例如 https://repl.it 或 https://codesandbox.io;它们都支持 Next.js,并且您不需要在您的计算机上安装任何依赖项。与其他章节一样,您可以在 GitHub 上找到本章的代码库:github.com/PacktPublishing/Real-World-Next.js。
测试简介
正如本章引言中所见,测试是任何开发工作流程的重要组成部分,可以分为三个独立的测试阶段:
-
单元测试:这些测试旨在确保您的代码中的每个函数都在正常工作。它们通过针对正确和错误的输入单独测试代码库中的函数,断言其结果和可能的错误,以确保它们按预期工作。
-
端到端测试:这种测试策略重现了用户与应用程序的典型交互,确保在给定动作发生时,应用程序会以特定的输出响应,就像我们在浏览器上手动测试网站一样。例如,如果我们构建了一个表单,我们希望自动确保它能够正确工作,验证输入,并在表单提交时执行特定的操作。此外,我们还想通过使用特定的 CSS 类、挂载某些 HTML 元素等方式来测试用户界面是否按预期渲染。
-
集成测试:在这种情况下,我们想要确保我们应用程序的各个部分,如函数和模块,能够协同工作。例如,我们想要断言组合两个函数会产生特定的输出,等等。与单元测试不同,在单元测试中我们单独测试我们的函数,而在集成测试中,我们确保当给定不同的输入集时,整个聚合函数和模块组能够产生正确的输出。
可能还有其他测试阶段和哲学,但在接下来的章节中,我们将专注于我们在这里提到的那些,因为它们是测试工作流程的基本部分,我强烈建议你在将代码部署到生产环境时采用所有这些阶段。
如本章引言所述,测试 Next.js 与测试 React 应用程序或 Express.js/Fastify/Koa 网络服务器没有区别。我们需要选择合适的测试运行器和库,并确保我们的代码按预期工作。
当谈到测试运行器时,我们指的是负责执行代码库中找到的每个测试、收集覆盖率并在控制台显示测试结果的工具。如果测试运行器进程失败(并以非零退出代码退出),则认为测试失败。
Node.js 和 JavaScript 生态系统为测试运行器提供了大量的选择,但从下一节开始,我们将专注于两个最受欢迎的替代方案:Jest(用于单元和集成测试)和Cypress(用于e2e,即端到端测试)。
运行单元和集成测试
在本节中,我们将使用 JavaScript 生态系统中最受欢迎的测试运行器之一:Jest,编写一些集成和单元测试。
在安装所有需要的依赖项之前,克隆以下存储库,它已经包含了一个小型网络应用程序,我们将用它作为编写测试的示例:github.com/PacktPublishing/Real-World-Next.js/tree/main/09-testing-nextjs/boilerplate。
这是一个具有以下功能的简单网站:
-
两个页面:一个包含我们博客中所有文章的主页和一个单独的文章页面。
-
文章页面 URL 实现了以下格式:
<article_slug>-<article-id>。 -
有一些实用函数用于创建页面的 URL、从文章 URL 中检索文章 ID 等。
-
两个 REST API:一个用于获取所有文章,另一个用于根据 ID 获取特定文章。
现在,让我们进入我们克隆的项目,并安装以下依赖项:
yarn add -D jest
Jest 是我们测试所需的唯一依赖项,因为它既充当测试框架,也充当测试运行器。它提供了一套广泛的功能,将使我们的开发(和测试)体验变得愉快。
由于我们正在使用 ESNext 特性编写函数和组件,我们希望告诉 Jest 使用默认的 Next.js babel 预设来正确转换这些模块。我们可以在项目的根目录中创建一个 .babelrc 文件,并添加以下内容:
{
"presets": ["next/babel"]
}
next/babel 预设是 Next.js 预先安装的,所以我们不需要安装任何东西,我们已经准备好开始使用了。
我们可以开始使用它而无需任何其他配置,因为它已经预先配置为运行所有以 .test.js 或 .spec.js 结尾的文件。
尽管如此,关于如何编写和放置这些文件的方法有很多种。例如,有些人喜欢将测试文件放在源文件附近,而有些人则喜欢将所有测试放在 tests/ 目录中。当然,这些方法都没有错:这取决于你的喜好。
在编写 Next.js 页面测试时请注意
Next.js 将放置在 pages/ 目录内的所有 .js、.jsx、.ts 和 .tsx 文件作为应用程序页面提供服务。因此,你不应该在那个目录中放置任何测试文件,否则 Next.js 将尝试将其渲染为应用程序页面。我们将在下一节中编写端到端测试时看到如何测试 Next.js 页面。
让我们编写我们的第一个测试,从代码库中最简单的一部分开始:实用函数。我们可以创建一个新文件,utils/tests/index.test.js,并首先导入我们可以在 utils/index.js 文件中找到的所有函数:
import {
trimTextToLength,
slugify,
composeArticleSlug,
extractArticleIdFromSlug
} from '../index';
我们现在可以编写 trimTextToLength 函数的第一个测试。这个函数接受两个参数:一个字符串和我们将要切割的长度,并在其末尾添加省略号。我们使用这个函数来展示文章内容的预览,以诱使读者阅读整篇文章。
例如,假设我们有一个以下字符串:
const str = "The quick brown fox jumps over the lazy dog";
如果我们将 trimTextToLength 应用到它上面,我们应该看到以下输出:
const str = "The quick brown fox jumps over the lazy dog";
const cut = trimTextToLength(str, 5);
cut === "The q..." // true
我们可以将前面的函数描述翻译成以下代码:
describe("trimTextToLength", () => {
test('Should cut a string that exceeds 10 characters', () => {
const initialString = 'This is a 34 character long
string';
const cutResult = trimTextToLength(initialString, 10);
expect(cutResult).toEqual('This is a ...');
});
});
如您所见,我们正在使用 Jest 的内置函数,例如 describe、test 和 expect。它们各自都有其特定的功能,我们可以总结如下:
-
describe: 创建一个相关测试的组。例如,我们应该在该函数内部包含与相同函数或模块相关的测试。 -
test: 声明一个测试并运行它。 -
expect: 这是我们将用于将我们的函数输出与固定数量的结果进行比较的函数。
正如我们所见,我们可以在 describe 组中添加多个测试,以便我们可以针对多个值测试我们的函数:
describe("trimTextToLength cuts a string when it's too long, () => {
test('Should cut a string that exceeds 10 characters', ()
=> {
const initialString = 'This is a 35 characters long
string';
const cutResult = trimTextToLength(initialString, 10);
expect(cutResult).toEqual('This is a ...');
});
test("Should not cut a string if it's shorter than 10
characters",
() => {
const initialString = '7 chars';
const cutResult = trimTextToLength(initialString,
10);
expect(cutResult).toEqual('7 chars');
}
);
});
接下来是 slugify 函数,让我们尝试为其编写自己的测试:
describe('slugify makes a string URL-safe', () => {
test('Should convert a string to URL-safe format', () =>
{
const initialString = 'This is a string to slugify';
const slugifiedString = slugify(initialString);
expect(slugifiedString).
toEqual('this-is-a-string-to-slugify');
});
test('Should slugify a string with special
characters', () => {
const initialString = 'This is a string to
slugify!@#$%^&*()+';
const slugifiedString = slugify(initialString);
expect(slugifiedString).
toEqual('this-is-a-string-to-slugify');
});
});
现在,尝试自己实现剩余函数的测试。如果你有任何疑问,可以在以下链接中找到完整的测试实现:github.com/PacktPublishing/Real-World-Next.js/blob/main/09-testing-nextjs/unit-integration-tests/utils/tests/index.test.js。
一旦我们编写了所有剩余的测试,我们就可以最终运行我们的测试套件。为了使其更简单和标准化,我们可以在 package.json 文件中创建一个新的脚本:
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"test": "jest"
},
就这些了!现在我们可以在控制台中输入 yarn test 并欣赏以下输出:

图 9.1 – 单元测试输出
我们现在可以继续编写更复杂的测试。如果你打开 components/ArticleCard/index.js 文件,你会看到一个简单的 React 组件,它创建了一个指向 Next.js 页面的链接。
在那种情况下,我们想要测试我们的 composeArticleSlug 和 trimTextToLength 函数(在该组件中使用)是否正确集成,并产生预期的输出。我们还想测试当给定一个文章作为输入时,显示的文本将匹配一个固定的结果。
很遗憾,Jest 单独不足以测试 React 组件。我们需要挂载和渲染它们来测试它们的输出,而特定的库在这方面做得非常好。
目前最受欢迎的选项是 react-testing-library,但你可以自由地尝试使用 Enzyme,看看哪种方法更符合你的喜好。
让我们通过运行以下命令来安装 react-testing-library 包:
yarn add @testing-library/react
现在,让我们继续创建一个名为 components/ArticleCard/tests/index.test.js 的新文件。
在继续测试实现之前,让我们考虑一下。我们现在需要测试我们的 ArticleCard 组件与 REST API 的兼容性,但在测试执行期间我们不会运行服务器。目前,我们不是测试我们的 API 是否以正确的 JSON 格式响应包含文章,我们只是在测试给定一个文章作为输入时,组件是否会生成固定的输出。
话虽如此,我们可以轻松地创建一个包含我们期望文章包含的所有信息的模拟,并将其作为输入提供给我们的组件。
让我们创建一个新文件,components/ArticleCard/tests/mock.js,并包含以下内容(或者直接从本书的 GitHub 仓库中的 09-testing-nextjs/unit-integration-tests/components/ArticleCard/tests/mock.js 复制过来):
export const article = {
id: 'u12w3o0d',
title: 'Healthy summer melon-carrot soup',
body: 'Lorem ipsum dolor sit amet, consectetur adipiscing
elit. Morbi iaculis, felis quis sagittis molestie, mi
sem lobortis dui, a sollicitudin nibh erat id ex.',
author: {
id: '93ksj19s',
name: 'John Doe',
},
image: {
url: 'https://images.unsplash.com/photo-1629032355262-
d751086c475d',
author: 'Karolin Baitinger',
},
};
如果你尝试运行 Next.js 服务器,你会看到 pages/api/ 内部的 API 将返回一个文章数组或单个文章,格式与我们用于模拟的相同。
我们终于准备好编写测试了。打开 components/ArticleCard/tests/index.test.js 文件,并首先导入我们想要测试的 react-testing-library 函数、组件、模拟和实用工具:
import { render, screen } from '@testing-library/react';
import ArticleCard from '../index';
import { trimTextToLength } from '../../../utils';
import { article } from '../tests/mock';
现在我们来编写第一个测试用例。如果我们打开ArticleCard组件,我们会看到整个卡片都被 Next.js 链接组件包裹。这个链接的href应该遵循/articles/<article-title-slugified>-id的格式。
作为第一个测试用例,我们将测试是否存在一个链接,其中href属性等于/articles/healthy-summer-meloncarrot-soup-u12w3o0d(这是我们模拟中可以看到的标题加上文章 ID):
describe('ArticleCard', () => {
test('Generated link should be in the correct format', ()
=> {
const component = render(<ArticleCard {...article} />);
const link = component.getByRole('
link').getAttribute('href');
expect(link).toBe(
'/articles/healthy-summer-meloncarrot-soup-u12w3o0d'
);
});
});
我们使用 react-testing-library 的render方法来挂载和渲染组件,然后获取链接并提取其href属性。我们最终将这个属性值与一个固定字符串进行比较,这是预期的值。
然而,我们的测试还存在一个问题。如果我们尝试运行它,我们将在控制台看到以下错误:
The error below may be caused by using the wrong test environment, see https://jestjs.io/docs/configuration#testenvironment-string.
Consider using the "jsdom" test environment.
这是因为 react-testing-library 依赖于浏览器文档的全局变量,而在 Node.js 中不可用。
我们可以通过将此测试文件的 Jest 环境更改为 JSDOM(一个用于测试目的模拟浏览器大部分功能的库)来快速解决这个问题。我们不需要安装任何东西;我们只需在测试文件顶部添加以下注释,在import语句之前,Jest 就会完成剩余的工作:
/**
* @jest-environment jsdom
*/
如果我们现在在终端中运行yarn test,测试将如预期那样成功。
在ArticleCard组件内部,我们展示文章内容的简要摘录,以诱使读者阅读整篇文章。它使用trimTextToLength函数将文章内容截断到最大长度为 100 个字符,因此我们期望在渲染的组件中看到前 100 个章节。
我们可以按照以下方式编写测试:
describe('ArticleCard', () => {
test('Generated link should be in the correct format', ()
=> {
const component = render(<ArticleCard {...article} />);
const link = component.getByRole('link')
.getAttribute('href');
expect(link).toBe(
'/articles/healthy-summer-meloncarrot-soup-u12w3o0d'
);
});
test('Generated summary should not exceed 100
characters',
async () => {
render(<ArticleCard {...article} />);
const summary = screen.getByText(
trimTextToLength(article.body, 100)
);
expect(summary).toBeDefined();
});
});
在这种情况下,我们渲染整个组件,然后生成文章摘要,并期望它存在于我们的文档中。
那是一个如何使用 Jest 和 react-testing-library 测试我们的代码库的基本示例。在编写实际应用时,我们也想测试我们的组件对错误数据的处理能力,看看它们是否可以正确处理任何错误,无论是抛出错误、在屏幕上显示消息等等。
测试不是一个容易的话题,但我们必须认真对待,因为它可以帮助我们避免发布损坏的代码或引入回归(例如,破坏之前工作良好的组件)到现有的代码库中。这是一个如此复杂的问题,以至于还有一本关于如何使用 react-testing-library 测试 React 组件的整本书:由 Scottie Crump 所著,Packt 出版社出版的《使用 React Testing Library 简化测试》。
如果你对此感兴趣并想深入了解 React 测试,我强烈建议阅读这本书。
话虽如此,我们的测试中仍然缺少一部分。我们没有测试完整页面的渲染,API 是否发送了正确数据,以及我们是否可以在页面之间正确导航。但这正是端到端测试的全部内容,我们将在下一节中讨论这一点。
使用 Cypress 进行端到端测试
Cypress 是一个强大的测试工具,可以测试在网页浏览器上运行的任何内容。
它允许您通过在基于 Firefox 和 Chromium 的浏览器(例如 Google Chrome)上运行测试来高效地编写和运行单元、集成和端到端测试。
到目前为止,我们已经编写了测试来了解我们的函数和组件是否按预期工作。现在,我们需要测试整个应用程序是否正确工作。
要开始使用 Cypress,我们只需将其作为项目的 dev 依赖项安装。我们将使用与最新部分相同的同一个项目,但如果您想从一个干净的项目开始,您可以克隆以下存储库 并从这里开始:https://github.com/PacktPublishing/Real-World-Next.js/tree/main/09-testing-nextjs/unit-integration-tests。
让我们在终端中输入以下命令来安装 Cypress:
yarn add -D cypress
一旦安装了 Cypress,我们就可以通过添加以下脚本来编辑我们的主 package.json 文件:
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"test": "jest",
"cypress": "cypress run",
},
现在,我们需要创建一个 Cypress 配置文件。让我们在项目根目录中编写一个 cypress.json 文件,包含以下内容:localhost:3000
{
"baseUrl": http://localhost:3000
}
在这里,我们告诉 Cypress 在运行测试时查找的位置;在我们的案例中,localhost:3000。现在我们已经准备好了,让我们继续编写我们的第一个测试!
按照惯例,我们将把我们的端到端测试放在一个名为 cypress/ 的文件夹中,该文件夹位于存储库的根级别。
我们将从一项简单的测试开始,以验证我们的 REST API 是否正确工作。
如果您打开 pages/api/ 文件夹,您将看到两个不同的 API:
-
articles.js,它返回一系列文章:import data from '../../data/articles'; export default (req, res) => { res.status(200).json(data); }; -
article/index.js,它接受一个文章 ID 作为查询字符串参数,并返回具有该 ID 的单个文章:import data from '../../../data/articles'; export default (req, res) => { const id = req.query.id; const requestedArticle = data.find( (article) => article.id === id ); requestedArticle ? res.status(200).json(requestedArticle) : res.status(404).json({ error: 'Not found' }); };
让我们创建我们的第一个 Cypress 测试文件,命名为 cypress/integration/api.spec.js,并添加以下内容:
describe('articles APIs', () => {
test('should correctly set application/json header', ()
=> {
cy.request('http://localhost:3000/api/articles')
.its('headers')
.its('content-type')
.should('include', 'application/json');
});
});
API 与 Jest 的略有不同,但我们仍然可以看出它们共享相同的理念。我们使用它们来描述来自服务器的响应,并对其进行固定值的测试。
在前面的例子中,我们只是在测试 HTTP 头部是否包含 content-type=application/json 头部。
我们可以通过测试状态码来继续操作,该状态码应等于 200:
describe('articles APIs', () => {
test('should correctly set application/json header', ()
=> {
cy.request('http://localhost:3000/api/articles')
.its('headers')
.its('content-type')
.should('include', 'application/json');
});
test('should correctly return a 200 status code', () => {
cy.request('http://localhost:3000/api/articles')
.its('status')
.should('be.equal', 200);
});
});
接下来,我们将进行一个更复杂的测试案例,测试 API 输出是否为对象的数组,其中每个对象必须包含一组最小属性。测试实现将如下所示:
test('should correctly return a list of articles', (done) => {
cy.request('http://localhost:3000/api/articles')
.its('body')
.each((article) => {
expect(article)
.to.have.keys('id', 'title', 'body', 'author',
'image');
expect(article.author).to.have.keys('id', 'name');
expect(article.image).to.have.keys('url', 'author');
done();
});
});
如您所见,我们正在使用 .to.have.keys 方法来测试返回的对象是否包含函数参数中指定的所有键。
另一个需要注意的事情是我们正在 each 循环中这样做。因此,一旦我们测试了所有期望的属性,我们就需要调用 done 方法(代码片段中突出显示),因为 Cypress 无法控制 each 回调内部的代码何时返回。
我们可以继续编写另外几个测试,看看我们是否可以给定一个固定的文章 ID 获取单个文章:
test('should correctly return a an article given an ID', (done) => {
cy.request('http://localhost:3000/api/article?id=u12w3o0d')
.then(({ body }) => {
expect(body)
.to.have.keys('id', 'title', 'body', 'author',
'image');
expect(body.author).to.have.keys('id', 'name');
expect(body.image).to.have.keys('url', 'author');
done();
});
});
我们还可以测试当文章未找到时,服务器返回的 404 状态码。为此,我们需要稍微更改我们的请求方法,因为 Cypress 默认情况下,在遇到大于或等于 400 的状态码时会抛出错误:
test('should return 404 when an article is not found', () => {
cy.request({
url: 'http://localhost:3000/api/article?id=unexistingID',
failOnStatusCode: false,
})
.its('status')
.should('be.equal', 404);
});
现在我们已经编写了测试,我们准备运行它们,但仍然存在问题。如果我们尝试运行 yarn cypress,我们将在控制台看到以下错误:

图 9.2 – Cypress 无法连接到服务器
事实上,Cypress 是在针对一个真实的服务器运行我们的测试,但目前这个服务器是不可访问的。我们可以通过添加以下依赖项快速解决这个问题:
yarn add -D start-server-and-test
这将帮助我们通过构建和启动服务器,一旦它可访问,它将运行 Cypress。为此,我们还需要编辑我们的 package.json 文件:
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"test": "jest",
"cypress": "cypress run",
"e2e": "start-server-and-test 'yarn build && yarn start'
http://localhost:3000 cypress"
},
如果我们现在尝试运行 yarn e2e,我们将看到测试正在正确通过!
让我们创建一个最后的测试文件,我们将在这里测试页面之间的导航。我们可以将其命名为 cypress/integration/navigation.spec.js,并添加以下内容:
describe('Navigation', () => {
test('should correctly navigate to the article page', ()
=> {
cy.visit('http://localhost:3000/');
cy.get('a[href*="/articles"]').first().click();
cy.url().should('be.equal',
'http://localhost:3000/articles/healthy-summer-meloncarrot-
soup-u12w3o0d');
cy.get('h1').contains('Healthy summer melon-carrot
soup');
});
test('should correctly navigate back to the homepage', ()
=> {
cy.visit('http://localhost:3000/articles/
healthy-summer-meloncarrot-soup-u12w3o0d');
cy.get('a[href*="/"]').first().click();
cy.url().should('be.equal', 'http://localhost:3000/');
cy.get('h1').contains('My awesome blog');
});
});
在第一个测试用例中,我们要求 Cypress 访问我们网站的首页。然后,我们查找所有 href 属性包含 /articles 的链接。然后我们点击第一个出现的链接,并期望新的 URL 等于一个固定的值(http://localhost:3000/articles/healthy-summer-meloncarrot-soup-u12w3o0d)。
我们还测试了 <h1> HTML 元素包含正确的标题。但这个测试告诉我们什么?
-
我们可以在页面之间导航;链接没有损坏。当然,我们接下来应该为链接添加更多的测试,但我们现在只想看看这个概念。
-
Next.js 服务器正确地请求并提供了正确数据,因为我们可以在渲染的页面中找到正确的标题。
在第二个测试用例中,我们要求 Cypress 访问单个文章页面,然后点击链接返回主页。我们再次测试新的 URL 是否正确,以及 <h1> HTML 元素包含主页的正确标题。
当然,这些测试并不完整,因为我们可能还想检查网站的行为在不同浏览器之间是否一致(特别是如果我们做了很多客户端渲染),现有的表单是否得到正确验证,为用户提供准确的反馈,等等。
就像单元测试和集成测试一样,端到端测试是一个庞大且复杂的主题,在我们将代码部署到生产环境之前必须处理,因为它可以确保我们的产品拥有更高的质量,更少的错误,并对回归有更多的控制。
如果你想要了解更多关于 Cypress 的信息,我建议你阅读由 Waweru Mwaura 撰写并由 Packt 出版的书籍《使用 Cypress 进行端到端 Web 测试》。
摘要
在本章中,我们看到了如何使用一些最受欢迎的库和测试运行器来编写单元、集成和端到端测试,例如 Cypress、Jest 和 react-testing-library。
正如本章多次提到的,测试对于任何应用程序的开发和发布过程都是至关重要的。它应该被认真对待,因为它可能是成功产品与失败产品之间的区别。
在下一章中,我们将关注一个不同但同样关键的主题:SEO 和性能。即使我们的代码库已经 100%经过测试、设计良好且运行出色,我们也需要考虑其 SEO 分数和性能。在许多情况下,我们希望尽可能多的人浏览我们的应用程序,我们必须注意搜索引擎优化,以吸引大量受众来验证我们的产品。
第十章:与 SEO 合作并管理性能
SEO(即搜索引擎优化)和性能是两个在整体开发过程中紧密相连的话题。
尽管 Next.js 方面已经进行了多次增强,以改进性能并促进 SEO 最佳实践,但我们仍然需要知道我们的应用程序可能在哪些方面可能产生问题,从而导致搜索引擎索引不佳和糟糕的用户体验。
在本章中,我们将详细介绍以下主题:
-
为您的应用程序选择合适的渲染方法(SSR、SSG、CSR)。
-
应用程序在性能方面通常会失败的情况
-
如何使用 Vercel Analytics 模块
-
帮助我们编写 SEO 友好型 Web 应用程序的工具
到本章结束时,您将通过学习处理这些复杂主题的一些最佳实践和工具,能够优化您的 Web 应用程序以实现 SEO 和性能。
技术要求
要运行本章中的代码示例,您需要在您的本地计算机上安装 Node.js 和 npm。
如果您愿意,可以使用在线 IDE,例如repl.it或codesandbox.io;它们都支持 Next.js,您不需要在您的计算机上安装任何依赖项。至于其他章节,您可以在 GitHub 上找到本章的代码库:github.com/PacktPublishing/Real-World-Next.js。
SEO 和性能 - 简介
自从第一个大型搜索引擎兴起以来,Web 开发者一直在努力寻找一种方法来优化他们的 Web 应用程序,以便在 Google、Bing、Yandex、DuckDuckGo 和其他许多流行搜索引擎的搜索结果中获得更好的排名。
随着前端 Web 框架的演变,事情变得更加复杂。虽然 React、Angular、Vue(以及许多其他框架)提供了一种处理复杂 UI 的绝佳方式,但它们让网络爬虫(负责将网站索引到搜索引擎中的机器人)的工作变得更加困难。它们需要执行 JavaScript,等待 UI 渲染,并最终索引高度动态的网页。此外,许多内容最初都是隐藏的,因为它们在用户交互后由前端 JavaScript 直接动态生成。
这导致了许多问题,让无数开发者后悔“那些美好的旧时光”,当时 Web 基本上是服务器端渲染的,JavaScript 仅用于在 UI 中添加一点动态效果。
好吧,我有点夸张了。开发者最终面对了这样一个事实:React、Angular、Vue 以及其他同行框架给 Web 开发领域带来了如此重大的创新,他们不会放弃它们。
Next.js 部分是对这些问题的回应。虽然有一些框架只关心 SEO 和性能,通过在构建时静态生成所有 Web 页面(如第二章中所述,探索不同的渲染策略),Next.js 允许你决定哪些页面需要静态生成和服务器端渲染,哪些组件需要仅在客户端渲染。
在第二章,探索不同的渲染策略中,我们描述了这些渲染方法之间的差异。在下一节中,我们将讨论一些使用 Next.js 渲染网页时选择渲染策略的实际例子。
从性能和 SEO 的角度来看,渲染策略
根据你想要构建的网站或 Web 应用,你可能需要考虑不同的渲染策略。
每种渲染策略都有其优缺点,但 Next.js 的伟大之处在于你不必做出妥协。相反,你可以为你的 Web 应用中的每一页选择最佳的渲染策略。
让我们假设一下,Next.js 此刻并不存在。这听起来是不是很可怕?
我们想使用 React 构建一个 Web 应用,但我们必须在渲染策略之间做出妥协。
客户端渲染是一个很好的起点。应用将以 JavaScript 包的形式部署,一旦下载到浏览器,就会动态生成 HTML 内容。性能将非常出色,因为所有计算都在客户端完成。此外,用户体验将非常出色,因为客户会感觉自己就像在使用原生应用。另一方面,你将不得不在 SEO 上挣扎,因为客户端渲染让搜索引擎爬虫的工作变得更难。
重新考虑一下,我们可能会考虑服务器端渲染。我们会将所有对 SEO 重要的内容在服务器端渲染,允许客户端生成其余内容。这在安全性方面可能是最佳选择,因为我们可以在后端隐藏许多数据获取、验证和敏感的 API 调用。这是一个好的替代方案,但也有一些缺点。在客户端渲染中,我们已经看到应用如何被捆绑成一个独特的 JavaScript 文件。在 SSR(服务器端渲染)中,我们需要设置、维护和扩展服务器。随着流量的增加,它将变慢、更昂贵,且更难维护。是时候寻找第三个选项了。
我们最后的选项是在构建时静态生成整个网站。我们会实现最佳的性能,同时 SEO 评分将显著提高,但仍然有一些显著的缺点。
如果我们的 SEO 敏感内容频繁更改,我们可能需要在几小时内多次重新渲染整个网站。这在大网站上可能是一个重大问题,因为构建可能需要相当长的时间。此外,处理用户安全性也会更困难,因为每个敏感 API 调用(在构建阶段之后发生)或计算都会在客户端独家进行。
让我们回顾一下我们的选择:
-
客户端渲染(CSR):出色的性能,高度动态的内容,但 SEO 和安全性较差
-
服务器端渲染(SSR):更好的 SEO,出色的安全性,但可能性能较差,且管理服务器更具挑战性
-
静态站点生成(SSG):最佳性能,最佳 SEO 评分,但缺乏安全性且不适合高度动态的内容
现在,我们终于可以停止假装 Next.js 不存在,并开始欣赏这个框架为我们提供的可能性。
我们不必选择单一渲染方法来实现我们的 Web 应用。我们可以选择所有这些方法。

图 10.1 – Next.js 渲染策略
Next.js 的一个关键特性是能够选择是否在服务器上渲染页面或在其构建时生成(甚至完全在客户端端进行)。
基于这种可能性,我们可以开始将我们的网站视为由不同部分组成,这些部分以多种不同的方式渲染,具体取决于每个部分的目的。
在下一节中,我们将通过一个真实网站示例来了解如何选择正确的渲染方法。
真实网站示例背后的推理
让我们假设我们正在构建一个摄影网站。用户可以上传他们的照片,并从平台上的其他用户那里获得反馈和投票。当用户登录时,主页将显示用户关注者的照片列表。点击任何这些照片将打开图片详情页面,我们可以阅读关于照片的评论、反馈和历史。
基于这些信息,我们可以开始思考我们想要如何渲染这些网站部分。
首先,我们知道主页内容会根据用户的浏览方式而变化。因此,我们可以在构建时排除静态生成主页上的主要图片列表,因为内容高度动态。
我们有以下几种选择:
-
我们使用一些占位符静态渲染主页上的图片,这些图片将在客户端的 React hydration 之后加载,具体取决于用户是否登录以及是否关注网站上的任何人。
-
我们可以在服务器端渲染页面。多亏了会话 cookie,我们可能已经知道用户是否登录,我们可以在将页面发送到客户端之前在服务器上预先渲染这个列表。
一件事是肯定的:当我们处理这个特定的图片列表时,我们并不真的关心 SEO。谷歌机器人永远不会登录到这个网站,因为没有理由索引每个用户都不同的自定义内容。
谈到性能,我们在决定如何渲染主页之前应该考虑几个因素。如果用于生成定制图片流的 API 足够快,并且图片高度优化,我们当然可以在服务器端预先渲染整个列表。否则,我们可以创建一些看起来不错的骨架加载占位符,在我们等待 API 响应和前端渲染图片时,可以娱乐用户。
最坏的情况是 API 很慢,图片没有优化,因此我们需要为此做好准备。然后我们决定在构建时静态生成整个页面,但我们将在 React 水合后进行 API 调用并生成优化后的图片(也许使用 Next.js 内置的图片组件,如第三章,Next.js 基础和内置组件所示)。
因此,最终决定是 SSG 和 CSR。我们将静态生成主页并在客户端创建图像列表。
在下一节中,我们将看到处理图片详情页的最佳方式。
渲染图片详情页
接下来,我们想要为我们的网站创建一个单独的图片页面模板。在这里,我们将渲染用户发布的照片、描述、一些标签以及其他用户给出的所有评论和反馈。
在这种情况下,我们希望这个页面被搜索引擎索引,因为其内容不依赖于用户会话或任何其他此类变量。
再次强调,我们必须选择我们想要渲染此页面的方式。我们已经知道 SEO 至关重要,因此我们排除了全客户端渲染作为选项。我们必须在构建时静态生成此页面或在每个请求时服务器端渲染它之间做出选择。
我们知道无论选择哪个选项都会帮助我们进行 SEO,但在这里做出错误的决定将会在网站需要扩展时立即影响其性能。是时候比较 SSG 和 SSR 在这个特定用例中的优缺点了。
静态站点生成动态页面的优缺点
静态站点生成为这种类型的应用程序提供了许多优点:
-
一旦在构建过程中生成静态页面,服务器就不需要在每次请求时重新渲染它。这减少了服务器的负载,从而降低了基础设施成本,并在高负载下轻松扩展。
-
图片作者可能希望在生成后更改一些静态内容。然而,在这个时候,我们不想等待下一次构建发生:我们可以在(比如说)每 30 分钟更改一次内容的情况下,仅使用增量静态重新生成,在服务器上重新渲染静态页面。
-
页面性能将是最佳可能的。
-
动态部分,如评论和点赞数(可能对 SEO 不重要)可以在客户端稍后渲染。
-
当用户想要添加一张新图片时,他们不必等待下一次构建才能在网站上看到他们的图片。实际上,我们可以在
getStaticPaths函数的返回对象中设置fallback: true参数,让 Next.js 在请求时静态渲染一个新页面。
在构建时渲染这类网页有一个很大的缺点:如果你有成千上万的页面,你的网站构建将花费大量时间。选择 SSG 进行动态路由时,这是需要考虑的事情。我们未来可能支持多少动态页面?构建过程生成它们需要多少时间?
现在让我们继续探讨单张图片详情页服务器端渲染的优缺点。
服务器端渲染动态页面的优缺点
与针对此特定页面的静态网站生成相比,服务器端渲染提供了一些重要的优点。
第一点是,如果用户更改了页面的内容,我们不必等待增量静态再生成发生。当图片作者更改其照片的任何信息时,我们可以在生产页面上立即看到更改的反映。
第二个优点甚至更为关键。正如之前所见,当生成大量静态页面时,SSG 可能需要几分钟才能完成。服务器端渲染通过仅在请求时渲染页面来解决这个问题,使得整个部署管道更快。
如果你考虑像 Google 和 Facebook 这样的大型网站,你很容易理解为什么在构建时生成这些页面可能是个问题。如果你只想渲染几十或几百个页面,这可以工作得很好,但如果你生成数百万甚至数十亿个页面,它将变成一个巨大的瓶颈。
在我们的案例中,我们预计将托管成千上万张图片,每张图片都有一个详情页。因此,我们最终将决定为它们采用服务器端渲染。
另一个选择是在构建时静态生成最受欢迎的页面(比如说前一千页),然后使用“回退”属性在运行时生成它们。
我们现在只需要定义私有路由的渲染策略,用户可以在那里更改自己的个人资料详情。我们将在下一节中详细说明。
私有路由
如形容词“私有”所暗示的,私有页面不是为了让每个人都能访问。相反,它们应该只对已登录用户开放,并包含管理账户设置所需的基本信息(用户名、密码、电子邮件等)。
话虽如此,我们不会真正关心 SEO,但我们会专注于安全。这些页面中的数据是敏感的,我们不惜一切代价来保护它。
这是我们想牺牲一些性能以改善我们安全性的罕见情况之一。
我们可以快速生成私有路由的静态版本,然后在客户端执行所有必要的 API 调用,但如果不正确处理,这可能会暴露一些个人(或私有)数据。因此,我们将采用服务器端渲染策略,在渲染页面之前检测匿名用户并将他们重定向走。此外,如果请求的用户已登录,我们可以在后端预加载所有数据,并通过getServerSideProps将其传递到客户端,这极大地提高了在向客户端传输数据时的安全性。
现在我们已经定义了如何管理私有路由,我们已经完成了基本渲染策略分析,因此现在是快速回顾的时候了。我们将在下一节中看到这一点。
关于我们决策的快速回顾
在前面的章节中,我们根据我们需要为我们的摄影网站渲染的页面类型做出了一些决定。
这项分析至关重要,应该考虑我们未来将要工作的每一个网站。如果我们需要向现有的 Next.js 网站添加新页面,我们将需要执行类似的分析,以了解最佳解决方案,以获得最佳性能、安全性和 SEO 合规性。
对于我们的摄影网站,我们提出了以下结构:
-
主页:我们将静态生成整个主页,除了自定义图片列表,它将根据用户浏览情况进行客户端渲染。
-
图片详情页:我们可以选择在服务器端渲染它(因为这将允许我们优化页面以适应 SEO,并保证以最佳方式扩展我们的网站以适应数百万个不同的图片详情页)或者,在构建时静态生成最受欢迎的页面,然后使用“回退”属性在运行时生成缺失的页面。
-
私有页面:我们将服务器端渲染它们,以确定在渲染页面之前用户是否已登录。此外,我们将在服务器端获取所有私有数据,将此 API 调用隐藏在前端。
例如,在第十三章,“使用 Next.js 和 GraphCMS 构建电子商务网站”,我们将需要做出这样的决定来构建一个真实的 Next.js 电子商务网站。然而,如果你想在进入之前练习,我建议你考虑如何重新创建你最喜欢的网站。
Facebook、Google、YouTube、Amazon——它们都有特定的需求、安全要求和 SEO 规范。那么,你将如何处理?他们是如何处理这些功能的?
在下一节中,我们将专注于通过使用一些开源工具来提高 SEO,这些工具将帮助我们处理搜索引擎爬虫。
与 SEO 合作
在 Next.js 中的 SEO 与其他任何框架并无不同。搜索引擎机器人不会区分差异;它们只关心网站内容和质量。因此,尽管 Next.js 试图简化事情,我们仍然需要尊重特定的规则,并在搜索引擎规范的基础上开发我们的网站,以获得良好的索引评分。
考虑到 Next.js 为我们提供的渲染可能性,我们已经知道特定的决策可能会对最终的 SEO 评分产生负面影响(例如,在客户端渲染重要数据)。我们已经在前面的章节中讨论过这一点,所以不会再深入探讨。
在开发网站时,有一些特定的 SEO 指标可能超出了我们的控制范围。域名权威性、引用域名、页面浏览量、点击率和有机市场份额只是其中的一些。尽管我们不太可能在开发过程中提高这些指标(因为它们是网站良好内容管理的结果),但我们应尽最大努力通过编码网站来改善我们能控制的部分。这包括一系列优化和发展,包括但不限于以下内容:
-
创建一个对 SEO 友好的路由结构:一个制作精良的路由系统对于搜索引擎机器人正确索引网站至关重要。URL 应始终对人类友好,并按照一定的逻辑组成。例如,如果我们正在创建一个博客,我们应该使用 URL 结构来帮助用户仅通过查看页面 URL 就能识别页面内容。虽然像
myblog.com/posts/1这样的 URL 可能更容易处理,但它对于博客用户(以及搜索引擎!)来说更困难,因为通过查看页面地址,我们无法知道内容是什么。myblog.com/posts/how-to-deal-with-seo是一个更好的 URL,它告诉我们在这个页面上,我们正在讨论 SEO 以及如何处理它。 -
在页面中填充正确的完整元数据:在 第三章,Next.js 基础和内置组件 中,我们已经看到了如何处理元数据。这是我们应该始终包含在页面中的基本数据,没有例外。有一些优秀的库,如
next-seo(github.com/garmeeh/next-seo),可以在开发过程中显著减少管理元数据所需的时间。 -
优化你的图片:我们已经讨论了如何优化图片。内置的图片组件是与 Google Chrome 团队合作开发的,以提供更好的图片支持,这也在一些 SEO 指标(如累积布局偏移和首次内容绘制)中得到了体现。
-
生成合适的网站地图:一旦我们准备好部署网站,我们可以将网站地图提交给搜索引擎,以帮助他们索引我们的内容。一个制作精良的网站地图对任何网站都是必不可少的,因为它允许搜索引擎创建一个整洁、结构化的路径,以便索引网站。至于今天,Next.js 中没有内置的解决方案来创建网站地图,但有一些优秀的库,包括
nextjs-sitemap-generator(github.com/IlusionDev/nextjs-sitemap-generator),可以帮助创建它。 -
使用正确的 HTML 标签:使用语义 HTML 标签构建网站是至关重要的,因为它们告诉搜索引擎机器人如何根据优先级和重要性索引内容。例如,虽然我们总是希望我们的内容被索引,但使用
<h1>HTML 标签为所有文本内容并不是 SEO 的最佳选择。我们总是需要找到合适的平衡,以便我们的 HTML 标签对用户和搜索引擎机器人都有意义。
处理 SEO 不是一个容易的任务。它一直具有挑战性,并且随着新技术和规则的兴起,未来可能会变得更加困难。好消息是,每条规则对每个网站都是相同的,所以你可以轻松地将你在其他框架、CMS 和开发工具上的经验带到 Next.js 中,因为它只能帮助你以更少的努力创建更优化的网站。
另一个可能影响 SEO 的指标是网站的性能。这同样是一个关键话题,我们将在下一节中探讨。
处理性能
性能和 SEO 是任何 Web 应用的两个重要方面。特别是性能,它可以影响 SEO 评分,因为性能差的网站会降低 SEO 评分。
在本章的开头,我们已经看到了选择正确的渲染策略如何帮助我们提高性能,但有时,我们不得不在为了安全、业务逻辑等因素而略微降低性能之间做出妥协。
另一个可能影响(或降低)性能的因素是部署平台。例如,如果你将 Next.js 静态网站部署到 CDN 如 Cloudflare 或 AWS Cloudfront,你很可能会获得最佳性能。另一方面,将服务器端渲染的应用程序部署到小型、便宜的服务器上,一旦网站开始扩展而服务器无法处理所有传入的请求,很可能会遇到一些麻烦,导致性能下降。我们将在第十一章深入讨论这个话题,不同的部署平台。至于现在,请记住,这也是性能分析期间需要考虑的另一个重要话题。
当我们谈论性能时,我们并不总是指代服务器端指标;前端性能同样至关重要,如果不加以妥善处理,这可能会导致糟糕的 SEO 评分和不良的用户体验。
随着 Next.js 10 的发布,Vercel 团队宣布了一个新的内置函数,我们可以在我们的页面中使用:reportWebVitals。
它是与 Google 合作开发的,使我们能够收集有关我们前端性能的宝贵信息,包括以下内容:
-
最大内容渲染 (LCP):这衡量的是加载性能,应该在页面初始加载后的 2.5 秒内完成。
-
首次输入延迟 (FID):这衡量的是页面变得可交互所需的时间。它应该少于 100 毫秒。
-
累积布局偏移 (CLS):这衡量的是视觉稳定性。还记得我们讨论图片的时候吗?一张重量级的图片可能需要很长时间才能加载。一旦它出现,就会改变布局,导致用户失去他们正在查看的部分的跟踪。图片是一个典型的例子,但其他元素也可能涉及其中:广告横幅、第三方小部件等等。
当我们部署我们的 Next.js 网站时,我们可以启用平台跟踪这些值,以帮助我们了解我们的 Web 应用程序在实际数据上的性能。Vercel 将为我们提供一个精心制作的仪表板,该仪表板将跟踪部署以及新功能如何影响整体网站性能。让我们看看以下示例仪表板:

图 10.2 – Vercel 分析仪表板
如你所见,前面的仪表板显示了整个网站的平均数据。虽然 CLS 和 FID 值已经得到很好的实现,但我们明显可以看出 FCP 和 LCP 可以得到改善。
如果你不愿意在 Vercel 上托管你的 Web 应用程序,你仍然可以通过在 _app.js 页面上实现 reportWebVitals 函数来收集这些数据。让我们举一个简单的例子:
export const reportWebVitals = (metrics) => console. log(metrics);
export default function MyApp({ Component, pageProps }) {
return <Component {...pageProps} />;
}
多亏了这个单行函数,每次我们进入新页面时,我们都会在控制台看到以下输出:

图 10.3 – Web Vitals
我们可以决定将此数据发送到任何外部服务,例如 Google Analytics 或 Plausible,以收集这些有用的信息:
export const reportWebVitals = (metrics) =>
sendToGoogleAnalytics(metric);
export default function MyApp({ Component, pageProps }) {
return <Component {...pageProps} />;
}
如果你想了解更多关于 Web Vitals 的信息,Google 维护的官方网站总是更新最新的改进和规则:web.dev/vitals。我强烈建议你在开始收集和测量你的 Web 应用程序前端性能之前阅读此内容。
摘要
在本章中,我们看到了如何就我们的页面与 SEO、性能和安全进行推理。尽管这些主题相当复杂,但本章的主要目的是提供一个思考框架。实际上,随着网络本身以新的性能指标、SEO 规则和安全标准快速前进,这些主题很可能会在未来发生变化。
在下一章中,我们将从另一个角度继续讨论这些主题。我们将了解如何根据我们的需求部署我们的 Web 应用程序并选择合适的托管平台。
第十一章:不同的部署平台
在前面的章节中,我们看到了 Next.js 是如何工作的,如何为 SEO 优化它,如何处理性能,如何采用 UI 框架,以及如何在客户端和服务器端获取数据,最终能够创建一个出色的 Web 应用程序。但是,我们遇到了一个问题:我们应该如何将其部署到生产环境中?有许多不同的托管提供商、云平台,甚至平台即服务(PaaS)解决方案;我们应该如何选择?
在本章中,我们将了解如何选择正确的部署平台。
我们将详细探讨以下内容:
-
选择正确的部署平台如何影响性能
-
如何在不同的云解决方案之间做出选择
-
最受欢迎的 Next.js 应用程序托管替代方案有哪些?
到本章结束时,您将能够将任何 Next.js 应用程序部署到任何主机,并了解如何从最受欢迎的托管解决方案中选择正确的提供商。
技术要求
要运行本章中的代码示例,您需要在您的本地机器上安装 Node.js 和 npm。
如果您愿意,可以使用在线 IDE,例如repl.it或codesandbox.io;它们都支持 Next.js,您不需要在您的电脑上安装任何依赖。与其他章节一样,您可以在 GitHub 上找到本章的代码库:https://github.com/PacktPublishing/Real-World-Next.js。
不同部署平台的简要介绍
当我们在考虑一个新的 Web 应用程序时,有许多事情需要考虑。例如,我们希望如何渲染其页面,我们希望采用哪种样式方法,数据从哪里来,我们如何管理应用程序状态,以及我们希望将应用程序部署在哪里?
专注于最后一部分,我们可以将一个问题分为两个:我们希望在哪里部署我们的应用程序,以及我们希望如何进行部署?
事实上,大多数时候,选择一个部署平台也意味着选择一种略微不同的部署方法。有一些特定的云平台,如 Vercel、Netlify 和 Heroku,它们的部署过程是标准化的,并且极大地简化,以便让每个人都能使用。对于其他云提供商,如 AWS、Azure 和 DigitalOcean,您对整个部署过程有完全的控制权。不幸的是,在许多情况下,您必须自己实现这个过程或使用第三方软件。
在过去几年中,云基础设施的数量急剧增加,竞争也带来了这个领域的一些重大创新。尽管有许多替代方案,但我们将专注于最受欢迎的方案,因为我们更有可能找到更多关于它们的文档和支持。
在下一节中,我们将讨论将 Next.js 应用程序部署到最突出的平台:Vercel。
部署到 Vercel 平台
开发、预览、发布不仅仅是一个口号。它完美地描述了开发 Next.js(以及许多其他开源库)的公司,以及一个优秀的云基础设施,用于部署和托管 Web 应用程序。
使用 Vercel,你几乎不需要进行任何配置。你可以通过他们的 CLI 工具从命令行部署你的 Web 应用程序,或者在你将代码推送到主 Git 分支后创建自动部署。
在开始使用 Vercel 之前,有一件事需要知道的是,该平台是专门为静态网站和前端框架构建的。不幸的是,这意味着不支持自定义 Node.js 服务器。
但在这个时候,你可能想知道是否只支持静态生成或客户端渲染的 Next.js 网站。简短的回答是不。实际上,Vercel 通过提供无服务器函数来支持服务器端渲染的页面。
“无服务器函数”是什么意思?
当谈到“无服务器函数”时,我们指的是一个单独的函数(用任何编程语言编写),它在托管的基础设施上被调用。实际上,它被称为“无服务器”,因为我们只需要编写函数,而不必真正考虑执行它的服务器。与传统的服务器不同,我们通常按小时付费(例如,即使服务器没有处理任何数据,我们可能每小时支付 1 美元),无服务器函数有一个不同的定价模型:我们根据执行时间、内存使用和其他类似指标,为每次执行支付几分之一的美分。例如,在撰写本文时,AWS Lambda(最受欢迎的无服务器环境)每百万请求收费0.20 美元,每毫秒收费0.0000000021 美元(当分配 128MB 内存时)。正如你可以想象的那样,这种定价模型与更传统的替代方案相比非常有吸引力,因为你只需为实际使用的部分付费。
Vercel 在部署 Next.js 应用程序时为我们设置无服务器函数做得非常出色,所以我们不必担心它们;我们只需专注于我们正在构建的 Web 应用程序。
将应用程序部署到 Vercel 相当直接。我们可以以两种不同的方式操作:
-
通过将我们的 GitHub、GitLab 或 Bitbucket 仓库链接到 Vercel。每次我们创建一个拉取请求时,Vercel 都会部署一个预览应用程序来测试我们刚刚开发的功能,在它们发布到生产之前。一旦我们合并或推送到我们的主分支,Vercel 将自动将应用程序部署到生产环境。
-
我们可以从命令行手动完成所有操作。例如,我们可以决定创建一个预览应用程序,在本地预览它,或者直接从我们的终端使用 Vercel CLI 工具将其发布到生产,只需输入
vercel --prod就足以将应用程序提升到生产状态。
无论哪种方式,开发者的体验都是出色的,所以请随意测试这两种部署策略,找到您最喜欢的一种。
在所有可能的 Next.js 应用程序部署和托管替代方案中,Vercel 可能是其中最容易的一个。此外,它还允许您访问分析模块(您还记得我们在第十章,与 SEO 合作和管理性能?)中提到的内容,这对于测量前端性能随时间的变化非常有用。这将帮助我们关注前端优化,而其他平台并不提供(这也是一项基本功能!)。
如果您正在寻找与 Vercel 相当的东西,您可以考虑的一个很好的替代方案是 Netlify。整个部署工作流程与 Vercel 的非常相似,开发者的体验同样出色。然而,我鼓励您在决定选择哪个平台之前,考虑一下定价模型的不同。
Vercel 和 Netlify 在部署静态网站时也表现出色。但在那里,与其他平台的竞争将会加剧;我们将在下一节中看到一些替代方案。
将静态网站部署到 CDN
当我们谈论CDN(即内容分发网络)时,我们指的是一个地理分布式的数据中心网络,用于在向世界任何地区的用户提供内容时实现高可用性和高性能。
为了简化,让我们举一个例子。我目前住在意大利米兰附近,我希望我的 Web 应用程序能够在世界任何地方使用。那么,从地理角度来看,我应该在哪里托管它呢?
某些提供商,如 Amazon AWS、DigitalOcean 和 Microsoft Azure(以及许多其他提供商),允许您选择一个特定的数据中心来托管您的应用程序。例如,我可以选择 AWS eu-south-1(意大利米兰),ap-northeast-2(韩国首尔),或sa-east-1(巴西圣保罗)。如果我选择从米兰托管我的 Web 应用程序,意大利用户在尝试访问 Web 应用程序时将注意到非常低的延迟;它在地理位置上非常接近他们。对于法国、瑞士和德国用户来说,情况可能相同,但对于生活在亚洲、非洲或美洲的人来说,情况则相反。您离数据中心越远,延迟就越大,导致性能不佳、客户端到服务器请求延迟较差,等等。如果我们考虑静态资源,如图像、CSS 或 JavaScript 文件,这一点将更加明显。
大文件大小 + 数据中心距离 = 下载性能差。这很简单,不是吗?
CDN 通过在(几乎)每个大陆上提供整个基础设施来解决这个特定问题。一旦您将静态资产部署到 CDN,它将在网络的所有区域中复制,使得它在世界任何地方的用户的附近都可用。
如果你查看 Next.js 的静态生成网站,你会很快注意到在请求时间不需要服务器来渲染页面。相反,网站在构建时完全生成并静态渲染,所以我们最终会得到一组可以部署到 CDN 的静态 HTML、CSS 和 JavaScript 文件。
如果我们处于那种情况,那么我们就很幸运了。我们即将通过从 CDN 提供静态 HTML 页面来实现最佳性能。但我应该选择哪个 CDN 呢?我们将在下一节中找到答案。
选择 CDN
当我们在寻找 CDN 来部署我们的 Web 应用时,我们会发现许多不同的选择。该领域的知名玩家包括(但不限于)Amazon AWS、Microsoft Azure CDN和Cloudflare。当然,还有很多其他的选择,但这些是我尝试过并且有良好体验的,所以我自信地向你推荐它们。
CDN 部署增加了几个配置步骤,但花点额外的时间以实现最佳性能可能值得。
以 AWS 为例,流程不会像 Vercel 那样直接。我们需要构建一个管道(使用 GitHub Actions 或 GitLab Pipelines 等),以静态生成 Web 应用,然后将其推送到AWS S3(一个用于存储静态资源的服务),最终使用CloudFront(AWS CDN)分发,让用户通过 HTTP 请求访问这些静态资源。我们还需要将我们的 CloudFront 分发链接到一个域名,我们可以使用AWS Route 53(一个 AWS 专有的 DNS 服务)来实现这一点。
与之相比,Cloudflare 使事情变得简单一些。它有一个更直观的 UI,称为 Cloudflare Pages,可以帮助我们将项目链接到 Git 仓库,并且每次我们向任何分支推送新代码时,都会自动部署一个新的网站版本。当然,每次我们将代码推送到主分支时,它都会在生产环境中发布;如果我们想预览位于功能分支上的某些功能,我们只需将代码推送到那里,然后等待 Cloudflare 发布预览部署,就像 Vercel 做的那样。
Microsoft Azure 提供了另一种令人兴奋的方法。我们可以进入 Azure 门户(Azure 管理仪表板),创建一个新的资源,选择“静态 Web 应用”作为资源类型,并输入所需的数据来配置它。之后,我们可以链接我们的 GitHub 账户,就像我们在 Cloudflare 和 Vercel 上做的那样,实现自动部署。Azure 会为我们创建一个 GitHub 工作流文件,这样构建阶段就会在 GitHub 上运行,一旦成功就会将内容推送到 Azure。
现在,你可能想知道如何从之前列出的 CDN 中选择最好的一个。好吧,它们都很优秀,但有一种方法可以确定哪个最适合我们的需求。
以 AWS 为例,可能看起来是最复杂的。但如果我们已经拥有 AWS 基础设施,那么在那里设置部署会更容易。同样适用于 Microsoft Azure,我们可能已经在该平台上运行了现有的项目,并且我们不希望将单个 Web 应用程序移出该平台。
相反,Cloudflare 可以是所有不需要依赖其他服务(除了无服务器函数(Cloudflare 提供名为 Cloudflare Workers 的无服务器函数服务)和其他类似服务,您可以在 https://developers.cloudflare.com 找到这些服务)的静态网站的完美解决方案。
尽管有方法在不脱离静态网站的情况下执行无服务器函数(通过使用 AWS Lambda、Azure Functions、Cloudflare Workers 等),但有时我们需要创建数十个甚至数百个无服务器函数。组织这样的部署可能具有挑战性,尤其是如果我们是在一个没有 DevOps 人员支持的小团队中工作。
有时,我们只需要服务器端渲染与静态生成的页面一起,并且需要在运行时使用 Node.js 代码的应用程序进行部署。一个有趣的方法是将网站完全以无服务器的方式部署。
有一个名为 serverless-next.js 的开源项目(github.com/serverless-nextjs/serverless-next.js)可以帮助我们实现这一结果。它作为一个 "无服务器组件"(在这种情况下,无服务器 是一个 npm 库的名称,用于将代码部署到任何无服务器平台)来配置 AWS 上的部署,通过适应以下规则:
-
SSR 页面和 API 路由将由 AWS Lambda(无服务器函数)部署和提供服务。
-
静态页面、客户端资源和公共文件将部署到 S3,并由 CloudFront 自动提供服务。
这种方法将导致一种混合部署,我们总是试图实现每种类型请求的最佳性能。SSR 和 API 页面(需要 Node.js 运行时)将由无服务器函数提供服务,其余的由 CDN 提供。
不要担心这听起来很复杂,因为它并不复杂。但如果您觉得这将是应用程序生命周期中过度工程化的部分(并且您仍然需要服务器端渲染和 API 路由),您可能希望考虑其他方法。我们将在下一节讨论如何正确地将 SSR Next.js 应用程序部署到任何平台。
在任何服务器上部署 Next.js
到目前为止,我们已经看到了一些将我们的 Next.js 应用程序部署到 CDN 和托管基础设施的替代方案,例如 Vercel 和 Netlify。然而,还有一个我们尚未考虑的替代方案;如果我们想将我们的应用程序部署到我们的私有服务器上怎么办?
尽管这是一个常见的情况,但它也是迄今为止最复杂的情况。虽然平台如 Vercel、Netlify 和 Heroku 为我们管理服务器,但有时我们可能希望将我们的应用程序托管在私有服务器上,在那里我们必须独立控制一切。
让我们快速回顾一下之前提到的托管平台能为我们做什么:
-
自动部署
-
回滚到之前的部署
-
特性分支的自动部署
-
自动服务器配置(Node.js 运行时、反向代理等)
-
内置缩放功能
通过选择自定义服务器,我们必须自己实现所有这些功能。但这值得吗?嗯,这取决于。当在一个大型公司工作,该公司已经在特定的云服务提供商上运行着显著的基础设施(无论是亚马逊 AWS、谷歌云、微软 Azure 等等)时,对于我们来说,在相同的基础设施中确定部署我们的 Next.js 应用程序的最佳解决方案可能是有意义的。
如果我们在进行一个副项目或小型商业网站,或者从头开始创建一个新的网络应用程序,我们可以考虑替代方案,如托管平台或 CDN,但我们已经讨论过这一点。
让我们暂时假设选择已经做出,我们必须将我们的应用程序部署到亚马逊 AWS、谷歌云或微软 Azure 之一。那么我们如何从那里开始部署和托管呢?
首先要考虑的是我们希望如何提供我们的应用程序。从一个空服务器开始意味着我们必须手动设置一堆东西来使其准备好提供 Node.js 应用程序。这包括(但不限于)以下内容:
-
Node.js 运行时:Node.js 并非预安装在每一个操作系统上,因此我们需要安装它来提供 API 和服务器端渲染的页面。
-
进程管理器:如果你以前已经使用过 Node.js,你可能知道如果主进程崩溃,整个应用程序将保持关闭状态,直到我们手动重新启动它。这是由于 Node.js 的单线程架构,并且未来不太可能改变,因此我们需要为此可能性做好准备。解决该问题的流行方法之一是使用进程管理器,如 PM2 (
github.com/Unitech/pm2),它监控 Node.js 进程并管理它们以保持应用程序运行。它还提供了许多其他附加功能来处理任何 Node.js 程序,所以如果你对此感兴趣,我建议你阅读官方文档在pm2.keymetrics.io。 -
反向代理:尽管我们可以轻松设置任何 Node.js 应用程序来管理传入的 HTTP 请求,但将它们放在 NGINX、Caddy 或 Envoy 等反向代理之后是一种最佳实践。这除了增加了额外的安全层之外,还意味着我们必须在我们的服务器上维护一个反向代理。
-
设置防火墙规则:我们需要打开防火墙以接受对
:443和:80端口的入站 HTTP 请求。 -
设置高效的部署管道:我们可以使用 Jenkins、CircleCI,甚至 GitHub Actions。但这又是另一件需要注意的事情。
一旦我们完成了整个环境的设置,我们也应该考虑,当我们需要扩展我们的基础设施以接受越来越多的入站请求时,我们可能需要尽快在另一台服务器上复制相同的设置。在新的服务器上复制它可能相当容易,但如果我们需要在数十台新机器上进行扩展怎么办?如果我们需要升级所有机器上的 Node.js 运行时或反向代理怎么办?事情变得越来越复杂和耗时,所以我们可能想要寻找另一种方法,这就是我们将在下一节中讨论的内容:如何通过 Docker 将我们的 Next.js 应用程序部署到任何服务器。
在 Docker 容器中运行 Next.js
Docker,以及一般意义上的虚拟化,已经永远改变了我们构建和部署应用程序的方式。它提供了一套有用的实用工具、命令和配置,使我们的构建在任何服务器上都具有可重复性,通过创建运行我们程序(或网络应用程序)的虚拟机,使我们的应用程序几乎可以在任何操作系统上运行。
如果你刚开始接触 Docker
Docker 是构建和部署任何计算机程序(网络应用程序、数据库或其他任何东西)时需要考虑的重要工具。如果你对这个技术还不太熟悉,我强烈建议你在开始使用它之前阅读官方的 Docker 文档www.docker.com。如果你对通过实践学习 Docker 感兴趣,我也建议你阅读 Russ McKendrick 的《Mastering Docker – 第四版》(www.packtpub.com/product/mastering-docker-fourth-edition/9781839216572);它提供了完整的入门和了解 Docker 的指南。
在 Docker 中运行 Next.js 相对直接。一个非常基本的 Dockerfile 包含以下命令:
FROM node:16-alpine
RUN mkdir -p /app
WORKDIR /app
COPY . /app/
RUN npm install
RUN npm run build
EXPOSE 3000
CMD npm run start
这几乎不费吹灰之力,不是吗?让我们将其分解成小步骤:
-
首先,声明我们想要在哪个镜像中运行我们的服务器。在这种情况下,我们选择
node:14-alpine。 -
创建一个新的工作目录是一个最佳实践,因此作为第一步,创建它并命名为
/app。 -
选择
/app作为我们的工作目录。 -
将我们本地目录的所有内容复制到 Docker 的工作目录中。
-
安装所有必需的依赖项。
-
在容器的工作目录内构建 Next.js。
-
将端口
3000暴露给容器外部。 -
运行启动脚本以启动 Next.js 内置服务器。
我们可以通过创建一个新的、空的 Next.js 应用程序并在新目录中运行以下命令来测试之前的 Dockerfile:
npx create-next-app my-first-dockerized-nextjs-app
让我们创建一个包含我们刚刚讨论内容的 Dockerfile。我们还应该创建一个包含node_modules和 Next.js 输出目录的.dockerignore文件,这样我们就不会将它们复制到容器中:
.next
node_modules
我们现在可以继续构建 Docker 容器:
docker build -t my-first-dockerized-nextjs-app .
我们将使用一个自定义名称标记它,在这个例子中,是my-first-dockerized-nextjs-app。
一旦构建成功,我们可以按照以下方式运行容器:
docker run -p 3000:3000 my-first-dockerized-nextjs-app
我们最终能够通过 http://localhost:3000 访问我们的 Web 应用!
从这个简单的配置开始,我们将能够将我们的应用部署到任何托管容器服务(如 AWS ECS 或 Google Cloud Run)、任何 Kubernetes 集群或任何安装了 Docker 的机器。
在生产中使用容器有许多好处,因为我们只需要一个非常简单的配置文件来设置 Linux 机器的虚拟化,以便运行我们的应用。无论何时我们需要复制、扩展或重新生成我们的构建,我们都可以通过共享 Dockerfile 并执行它来简单地做到这一点,使整个过程变得极其简单、可扩展且易于维护。
话虽如此,我们是否总是需要 Docker?让我们在下一节的总结中讨论这个问题。
总结
在本章中,我们看到了 Next.js 应用的多种部署平台。在构建和部署 Next.js 应用方面,没有完美的解决方案,因为它取决于每个项目带来的特定用例和挑战。
Vercel、Netlify 和 Heroku(仅举几个例子)都是快速将 Next.js 应用部署到生产的优秀替代方案。另一方面,Cloudflare Pages、AWS S3 和 AWS CloudFront 以及 Microsoft Azure CDN 确实可以为我们的静态网站提供出色的性能,在提供静态生成网站方面,与我们在本章中看到的所有其他优秀解决方案竞争。
Docker 可能是最灵活的解决方案之一。它允许我们在任何地方部署我们的应用,使得在每台机器上复制生产环境变得容易。
再次强调,对于部署 Next.js 应用来说,并没有一个“完美”的解决方案,因为这个领域的竞争非常激烈,许多公司提供了优秀的解决方案来简化我们的开发生活,并使用户的浏览体验始终如一地更好。
当决定在哪里部署 Next.js 应用时,我能给出的最好建议是考虑以下方面:我所在的团队有多大?虽然 Vercel、Netlify、Heroku 和 Cloudflare 等解决方案非常适合小型和大型团队,但还有其他提供者,所需的知识、技能集和容量要高得多。在 AWS EC2 实例或 DigitalOcean 或 Google Cloud 上的自定义机器上设置,给我们提供了对整个应用生命周期的更多控制,但成本(在配置、设置和所需时间方面)是相当可观的。
另一方面,当在大公司工作,那里有一个专门的 DevOps 团队可以负责应用程序的发布流程时,他们可能会更倾向于采用自定义解决方案,这样他们就有越来越多的控制权。
即使我们在单独工作,我们也可以选择将我们的应用程序部署到自定义云基础设施。如果我们这么做,我们应该确保我们没有无意中重新发明轮子,即重新创建 Vercel、Netlify、Cloudflare 等甚至免费计划就能提供的基础设施。
我们到目前为止已经取得了一些显著的进步。我们学习了框架的基础知识以及如何将其与不同的库和数据源集成,现在我们也知道了如何为任何需求选择部署平台。
从下一章开始,我们将构建一些实际的应用程序,这将使我们能够理解在 Next.js 中创建生产就绪的 Web 应用程序时我们将面临的现实世界挑战。
第三部分:通过示例学习 Next.js
在本部分,我们将使用在前几章中探索的所有方法和策略编写一些生产级别的应用程序。我们将管理生产就绪的身份验证,消费 GraphQL API,并集成 Stripe 支付服务。
我们还将通过开发一些示例应用程序并学习如何跟上这个快速发展的框架的步伐,来了解如何通过 Next.js 变得更加自信。
本节包括以下章节:
-
第十二章, 管理身份验证和用户会话
-
第十三章, 使用 Next.js 和 GraphCMS 构建电子商务网站
-
第十四章, 示例项目和深入学习下一步
第十二章:管理认证和用户会话
在前面的章节中,我们看到了如何使用一些基本的 Next.js 功能。我们学习了如何在渲染策略之间进行选择以及这些策略如何影响 SEO 和性能。我们还学习了如何使用内置和外部样式方法和库来设计我们的应用,管理应用状态,集成外部 API,以及许多其他有价值的内容。
从本章开始,我们将通过结合之前学到的课程和行业标准策略,开始学习和开发实际应用,以保持我们的应用在各个方面都安全、高效和高度优化。
在本章中,我们将看到如何管理用户会话和认证,这是每个高度动态的 Web 应用的一个基本部分。
我们将详细介绍以下主题:
-
如何将我们的应用与自定义认证服务集成
-
如何使用行业标准的服务提供商,如 Auth0、NextAuth.js 和 Firebase
-
如何在页面变化之间保持会话
-
如何确保用户数据的安全和隐私
到本章结束时,您将能够对 Next.js 应用中的用户进行认证和管理他们的会话,了解不同认证策略之间的差异,甚至采用一个自定义的认证策略。
技术要求
要运行本章中的代码示例,您需要在您的本地机器上安装 Node.js 和 npm。如果您愿意,可以使用在线 IDE,例如repl.it或codesandbox.io;它们都支持 Next.js,您不需要在您的计算机上安装任何依赖项。至于其他章节,您可以在 GitHub 上找到本章的代码库:github.com/PacktPublishing/Real-World-Next.js。
用户会话和认证的简要介绍
当谈到用户认证时,我们指的是识别特定用户的过程,根据他们的授权级别,让他们读取、写入、更新或删除任何受保护的内容。
一个典型的例子可能是一个简单的博客系统:我们只能在验证身份后发布、编辑甚至删除内容。
有许多不同的认证策略,但最常见的是:
-
基于凭证的认证:这种方法允许我们使用个人凭证登录系统,通常是一个电子邮件地址和密码。
-
社交登录:我们可以使用我们的社交账户(Facebook、Twitter、LinkedIn 等)登录系统。
-
无密码登录:近年来,这种方法已经成为一种相当流行的认证方式。例如 Medium 和 Slack 这样的平台会向您发送所谓的“魔法链接”到您的电子邮件地址,让您无需输入任何密码即可登录您的账户。
-
单点登录(SSO):如果你在大公司工作过,你可能经历过这种情况。例如,Okta 等服务提供了一种使用唯一凭证访问许多不同服务的方法,通过它们自己的服务集中用户认证。一旦你登录到 SSO 系统,它将把你重定向到所需的网站,并授予你的身份。
但一旦我们登录到系统,我们希望它记住我们,这样我们就不必在导航过程中每次页面变化时都进行认证。这就是会话管理介入的地方。
再次强调,管理用户会话有许多方法。如果你熟悉 PHP,你可能知道它提供了一个内置方法来控制用户会话。让我们看看下面的代码片段:
<?php
session_start();
$_SESSION["first_name"] = "John";
$_SESSION["last_name"] = "Doe";
?>
这是一个典型的服务器端会话管理示例。
它创建了一个会话 cookie,并跟踪与该会话相关联的所有属性。例如,我们可以将登录用户的电子邮件或用户名与该会话关联,每次我们渲染页面时,我们都可以根据认证用户的数据来完成。
我们可以将这种策略称为状态化会话,因为用户状态保存在服务器端,并通过特定的会话 cookie 与客户端关联。
在原型设计阶段管理状态化会话相对容易,但一旦你开始在生产中进行扩展,事情往往会变得复杂一些。
在上一章中,我们讨论了将我们的应用程序部署到 Vercel、AWS 或其他任何托管平台。以 Vercel 为例,因为它是最直接(但也是优化过的)用于托管我们的 Next.js Web 应用程序的平台。我们已经看到了每个 API 和 SSR 页面是如何在无服务器函数上渲染的,对吧?现在想象一下,在这种情况下,当我们甚至没有服务器来管理时,如何保持服务器端的状态化会话?
让我们假设我们在用户登录后为他们渲染一个欢迎页面。我们可以设置一个会话 cookie,但每当 Lambda 函数终止其执行时,每个服务器端状态化数据实例都将被取消。那么我们如何保持会话?一旦用户离开这个页面会发生什么?服务器端会话将会丢失,他们需要重新进行认证。
这正是无状态会话概念真正发挥作用的地方。
我们不想设置一个将服务器端会话与前端关联的会话 cookie,我们希望释放一些信息,以识别每个新请求中的用户。每当认证用户向后端发送请求时,他们必须遵循一个授权机制,例如传递特定的 cookie 或 HTTP 头。在每次新请求中,服务器将获取这些信息,验证它们,识别用户(如果传递的 cookie 或头是有效的),然后提供所需的内容。
一种遵循此模式的行业标准方法是JWT(JSON Web Tokens)基于的认证,但我们将在这个下一节中讨论。
理解 JSON Web Tokens
如jwt.io网站所述,JWT(即JSON Web Token)是一种开放、行业标准的RFC 7519方法,用于在双方之间安全地表示声明。
为了简单起见,我们可以将 JWT 视为三个不同的 base64 编码的 JSON 数据块。
让我们以下面的 JWT 为例:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MDhlYWZhNy03MWJkLTQyMDMtOGY3Ni1iNjA3MmNkMTFlODciLCJuYW1lIjoiSmFuZSBEb2UiLCJpYXQiOjE1MTYyMzkwMjJ9.HCl73CTg8960TvLP7i5mV2hKQlSJLaLAlmvHk38kL8o
如果我们足够注意,我们可以看到由句点分隔的三个不同的数据块。
第一部分代表 JWT 头。它包含两个基本信息:令牌类型和用于签名的算法(我们将在下一秒讨论更多关于这一点的内容)。
第二部分是有效负载。在这里,我们放置所有可以帮助我们识别用户的不敏感数据。永远不要在 JWT 有效负载中存储如密码和银行详情等数据。
JWT 令牌的第三部分和最后一部分是其签名。这就是 JWT 令牌之所以安全的原因,我们将在本节稍后详细讨论。
如果我们使用任何客户端库或专门的网站(如jwt.io)解码我们的 JWT 令牌,我们将看到以下 JSON 数据:
// First chunk
{
"alg": "HS256", // Algorithm used to sign the token
"typ": "JWT" // Token type
}
// Second chunk
{
"sub": "908eafa7-71bd-4203-8f76-b6072cd11e87", // JWT subject
"name": "Jane Doe", // User name
"iat": 1516239022 // Issued at
}
第一部分告诉我们,给定的令牌是使用 HS256 算法签名的 JWT。
第二个数据块为我们提供了有关用户的一些有用信息,例如 JWT 主题(通常是用户 ID)、用户名以及我们签发令牌的时间戳。
JWT 有效负载最佳实践
官方的RFC7519指定了一些可选的有效负载属性,例如"sub"(主题)、"aud"(受众)、"exp"(过期时间)等。尽管它们是可选的,但最佳实践是根据官方 RFC 规范实现它们,该规范可在datatracker.ietf.org/doc/html/rfc7519#section-4找到。
一旦我们需要个人用户数据,我们可以将 JWT 作为 cookie 设置,或者将其用作 HTTP 授权头中的 bearer 令牌。一旦服务器获取这些数据,它将验证令牌,这就是第三令牌部分变得至关重要的地方。
正如我们已经看到的,任何 JWT 的第三部分是其签名。让我们再次保持简单,并举例说明为什么(以及如何)我们想要对 JWT 令牌进行签名。
对任何人来说,解码 JWT 令牌都非常简单;它只是一个 base64 编码的 JSON,因此我们可以使用 JavaScript 内置函数来解码它,对其进行操作(例如添加"admin": true属性),然后再以所需的格式重新编码它。
如果 JWT 令牌如此容易被破解,那会非常糟糕,对吧?好消息是:解码、操作然后再次编码令牌是不够的。我们还需要使用在签发 JWT 的服务器上使用的相同密钥对其进行签名。
例如,我们可以使用jsonwebtoken库为我们用户生成令牌,如下所示:
const jwt = require('jsonwebtoken');
const myToken = jwt.sign(
{
name: 'Jane Doe',
admin: false,
},
'secretpassword',
);
我们最终会得到以下 JWT 令牌:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSmFuZSBEb2UiLCJhZG1pbiI6ZmFsc2UsImlhdCI6MTYzNDEzMTI2OH0.AxLW0CwWpsIUk71WNbbZS9jTPpab8z4LVfJH6rsa4Nk
现在,我们想要验证它,只是为了确保它按预期工作:
const jwt = require('jsonwebtoken');
const myToken = jwt.sign(
{
name: 'Jane Doe',
admin: false,
},
'secretpassword',
);
const tokenValue = jwt.verify(myToken, 'secretpassword');
console.log(tokenValue);
// => { name: 'Jane Doe', admin: false, iat: 1634131396 }
在那个库中,jwt.verify方法在签名验证成功后返回解码后的有效载荷。如果验证失败,它将抛出一个错误。
我们可以通过复制并粘贴前面的 JWT 到jwt.io首页来测试这一点。它将允许我们自由编辑,因此我们可以尝试将"admin": true声明设置到我们的 JWT 中:

图 12.1 – 在 https://jwt.io 上编辑 JWT 令牌
如您所注意到的,Web 应用将立即在我们在头部或有效载荷部分输入内容时更新 JWT 令牌。一旦我们完成编辑,我们就可以用我们的脚本最终测试它:
const tokenValue = jwt.verify(
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSmFuZSBEb2UiLCJhZG1pbiI6dHJ1ZSwiaWF0IjoxNjM0MTMxMjY4fQ.SAv6UDKdlb3CokrC8o3e_pZs_Fk0JyY2oDpYJ0bDUyU',
'secretpassword',
);
一旦我们尝试验证这个令牌,我们将在控制台看到以下错误:
JsonWebTokenError: invalid signature
正是这一点使得 JWT 安全:每个人都可以潜在地读取和操作它。但一旦你这样做,你就无法使用有效的签名来签名它,因为它在服务器端保持秘密和隐藏。
在下一节中,我们将看到一个将 JWT 认证集成到 Next.js 应用中的实际示例。
自定义认证 – 好的、坏的和不那么好的
让我们从一开始就明确这一点:在可能的情况下,我们应该避免实现自定义认证策略。有多个优秀的提供商(包括 Auth0、Firebase、AWS Cognito 和 Magic.link 等,仅举几个例子)正在投入大量精力使认证安全、可靠,并针对许多不同情况进行优化。在调查 Web 应用的认证策略时,我强烈建议考虑一个成熟的供应商,因为这可能是动态 Web 应用最关键的部分之一。
在本节中,我们正在探讨为简单原因创建自定义认证机制:我们只是想从高层次上了解认证是如何工作的,如何使其尽可能安全,以及自定义认证系统的关键因素是什么。
正如我们将在本节中发现的那样,在实现自定义认证机制时将存在一些限制。例如,我强烈反对在静态生成的网站上实现客户端认证,因为它迫使我们仅在客户端进行用户认证,可能将敏感数据暴露给网络。
因此,我们将创建一个新的 Next.js Web 应用,该应用将使用 API 路由与数据源(通常是数据库)进行通信并检索用户数据。
因此,让我们首先创建一个新的、空的 Next.js 应用:
npx create-next-app with-custom-auth
一旦准备好样板代码,我们就可以开始编写登录 API。请注意,以下代码并不打算用于生产;我们只是在简化、高层次地概述认证的工作原理。
让我们从创建一个/pages/api/login.js文件开始,该文件通过导出以下函数:
export default (req, res) => {}
这是我们处理用户输入并进行验证的地方。
我们可以做的第一件事是获取用户输入并过滤请求方法,只接受 POST 请求:
export default (req, res) => {
const { method } = req;
const { email, password } = req.body;
if (method !== 'POST') {
return res.status(404).end();
}
}
我们为什么需要过滤 POST 请求?
默认情况下,所有 Next.js API 路由都接受任何 HTTP 方法。顺便说一句,在特定路由上只允许特定方法是最佳实践,例如,在创建新内容时启用POST请求,在读取数据时启用GET,在修改内容时启用PUT,或在删除数据时启用DELETE。
我们现在可以验证用户输入。例如,在验证电子邮件和密码时,我们可以检查传递的电子邮件是否在有效格式中,以及密码是否遵循特定的策略。这样,如果任何给定的数据无效,我们只需回复一个401状态码(未授权),因为我们不会在数据库中找到该电子邮件和密码组合的任何记录。这也有助于我们避免无用的数据库调用。
目前,我们没有数据库,我们将依赖于硬编码的值,因为我们只想从高层次上理解认证。话虽如此,我们只会检查请求体中是否包含电子邮件和密码,因此我们可以保持简单:
export default (req, res) => {
const { method } = req;
const { email, password } = req.body;
if (method !== 'POST') {
return res.status(404).end();
}
if (!email || !password) {
return res.status(400).json({
error: 'Missing required params',
});
}
}
如果请求体中不存在电子邮件或密码,我们将返回一个400状态码(错误请求)并附带一个错误信息,解释请求失败的原因。
如果请求使用 HTTP POST 方法发送并提供电子邮件和密码,我们可以使用任何认证机制来处理它们。例如,我们可以在数据库中查找具有该特定电子邮件的用户,检索他们的密码,然后在服务器端验证它或请求外部认证服务为我们完成这项工作。
目前,鉴于我们只是在概述自定义认证策略,我们将使用一个基础的函数来检查电子邮件和密码的组合是否与两个固定的字符串匹配。再次强调,这并不是为了生产使用。
在同一个pages/api/login.js文件中,我们可以创建一个非常基础的函数来为我们完成这项工作:
function authenticateUser(email, password) {
const validEmail = 'johndoe@somecompany.com';
const validPassword = 'strongpassword';
if (email === validEmail && password === validPassword) {
return {
id: 'f678f078-fcfe-43ca-9d20-e8c9a95209b6',
name: 'John Doe',
email: 'johndoe@somecompany.com',
};
}
return null;
}
在生产环境中,我们永远不会使用这样的认证函数。相反,我们将与数据库或外部服务通信,以动态检索用户数据。
我们终于可以将前面的函数与我们的 API 处理器结合起来。如果传递的数据是正确的,我们将获取一些用户数据并将其发送给客户端。否则,我们只需发送一个401状态码(未授权)并附带一个错误信息,解释传递的数据是不正确的:
export default (req, res) => {
const { method } = req;
const { email, password } = req.body;
if (method !== 'POST') {
return res.status(404).end();
}
if (!email || !password) {
return res.status(400).json({
error: 'Missing required params',
});
}
const user = authenticateUser(email, password);
if (user) {
return res.json({ user });
} else {
return res.status(401).json({
error: 'Wrong email of password',
});
}
};
在这一点上,我们可以开始分析这种方法的潜在风险。让我们假设一下,我们将从前端登录,服务器将回复这样的信息,我们将将其存储在 cookie 中。一旦我们需要获取更多关于(比如说)我们的用户的数据,我们只需向服务器提交一个请求,服务器将读取 cookie,获取当前用户 ID,然后查询数据库以获取他们的数据。
你能看出这种解决方案的失败点吗?
每个人都可能通过使用每个现代网络浏览器内置的开发者工具来编辑他们的 Cookies。这意味着每个人都可以简单地读取 Cookie,更改它,并且可以冒充另一个用户,甚至不需要登录。
我们为什么要讨论 Cookies?
Cookies 是存储会话数据的好方法。我们可以使用不同的浏览器功能,如localStorage、sessionStorage,甚至indexedDB。问题是,任何人都可以通过在你的网页中注入恶意脚本来窃取这些数据。在处理 Cookies 时,我们可以(并且我们应该)将httpOnly标志设置为true,以便仅在服务器端提供 Cookies。这为存储这些数据添加了一个额外的安全层。尽管我们应该意识到,每个用户都可以通过使用现代浏览器提供的开发者工具来检查 Cookies,但我们绝不应该在那里共享敏感信息。
这就是 JWT 可以发挥作用的地方。我们可以简单地编辑我们的登录处理程序,通过在返回任何数据之前设置包含 JWT 的 Cookie 来使其更加安全。
让我们从安装jsonwebtokennpm 包开始:
yarn add jsonwebtoken
让我们创建一个新文件,lib/jwt.js,并添加以下内容:
import jwt from 'jsonwebtoken';
const JWT_SECRET = 'my_jwt_password';
export function encode(payload) {
return jwt.sign(payload, JWT_SECRET);
}
export function decode(token) {
return jwt.verify(token, JWT_SECRET);
}
现在,回到我们的pages/api/login.js文件,我们可以通过将用户有效负载编码成 JWT 来编辑它:
import { encode } from '../../lib/jwt';
function authenticateUser(email, password) {
const validEmail = 'johndoe@somecompany.com';
const validPassword = 'strongpassword';
if (email === validEmail && password === validPassword) {
return encode({
id: 'f678f078-fcfe-43ca-9d20-e8c9a95209b6',
name: 'John Doe',
email: 'johndoe@somecompany.com',
});
}
return null;
}
最后一点:我们说过我们想要设置一个包含我们刚刚创建的 JWT 的 Cookie。我们可以安装一个方便的库来帮助我们实现这一点:
yarn add cookie
安装完成后,我们可以通过设置会话 Cookie 来编辑我们的pages/api/login.js文件:
import { serialize } from 'cookie';
// ...
export default (req, res) => {
const { method } = req;
const { email, password } = req.body;
if (method !== 'POST') {
return res.status(404).end();
}
if (!email || !password) {
return res.status(400).json({
error: 'Missing required params',
});
}
const user = authenticateUser(email, password);
if (user) {
res.setHeader('Set-Cookie',
serialize('my_auth', user, { path: '/', httpOnly: true })
);
return res.json({ success: true });
} else {
return res.status(401).json({
success: false,
error: 'Wrong email of password',
});
}
};
如你所见,我们正在创建一个名为my_auth的 Cookie,它将包含用户 JWT。我们不会直接将 JWT 传递给客户端,因为我们希望将其隐藏在客户端上可能运行的任何潜在恶意脚本之外。
我们可以通过使用 Postman 或 Insomnia 等有用的 HTTP 客户端来测试该过程是否按预期工作(你可以免费下载 Insomnia:insomnia.rest):

图 12.2 – Insomnia 中的登录 API 响应
如果我们切换到我们选择工具(在我的情况下,是 Insomnia)的响应部分的Cookie标签页,我们最终可以看到认证 Cookie:

图 12.3 – Insomnia 中的认证 Cookie
现在是时候在客户端通过创建登录表单和受保护的路由来管理认证了,只有登录后才能看到这些路由。所以,让我们从这里开始:让我们创建一个包含以下内容的新的/pages/protected-route.js文件:
import styles from '../styles/app.module.css';
export default function ProtectedRoute() {
return (
<div className={styles.container}>
<h1>Protected Route</h1>
<p>You can't see me if not logged-in!</p>
</div>
);
}
通过查看ProtectedRoute函数,你可以看出我们并没有阻止匿名用户浏览该页面;我们将在创建登录页面后立即做到这一点。
让我们再创建一个 /styles/app.module.css 文件,我们将在这里放置我们应用程序的所有样式;我们并不旨在制作一个获奖的 UI,所以我们只需在那里创建几个简单的样式:
.container {
min-height: 100vh;
padding: 0 0.5rem;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
}
现在,我们可以开始专注于登录。让我们创建一个新的页面,/pages/login.js,并开始编写登录 UI:
import { useState } from 'react';
import { useRouter } from 'next/router';
import styles from '../styles/app.module.css';
export default function Home() {
const [loginError, setLoginError] = useState(null);
return (
<div className={styles.container}>
<h1>Login</h1>
<form className={styles.form}
onSubmit={handleSubmit}>
<label htmlFor="email">Email</label>
<input type="email" id="email" />
<label htmlFor="password">Password</label>
<input type="password" id="password" />
<button type="submit">Login</button>
{loginError && (
<div className={styles.formError}>
{loginError} </div>
)}
</form>
</div>
);
}
在创建缺少的 handleSubmit 函数之前,让我们向 styles/app.module.css 文件添加一些样式:
.form {
display: flex;
flex-direction: column;
}
.form input {
padding: 0.5rem;
margin: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
width: 15rem;
}
.form label {
margin: 0 0.5rem;
}
.form button {
padding: 0.5rem;
margin: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
width: 15rem;
cursor: pointer;
}
.formError {
color: red;
font-size: 0.8rem;
text-align: center;
}
现在,我们可以编写 handleSubmit 函数。在这里,我们将捕获表单提交事件,阻止浏览器默认行为(向远程 API 提交请求),并处理我们登录的两个可能情况:成功和失败。如果登录成功,我们将重定向用户到我们的受保护页面。如果失败,我们将在 loginError 状态中设置一个错误:
export default function Home() {
const router = useRouter();
const [loginError, setLoginError] = useState(null);
const handleSubmit = (event) => {
event.preventDefault();
const { email, password } = event.target.elements;
setLoginError(null);
handleLogin(email.value, password.value)
.then(() => router.push('/protected-route'))
.catch((err) => setLoginError(err.message));
};
// ...
我们现在缺少最后一个函数,即负责发起登录 API 请求的函数。由于在测试阶段,我们可能想单独测试它,所以我们可以将其创建在 Home 组件外部:
// ...
async function handleLogin(email, password) {
const resp = await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
password,
}),
});
const data = await resp.json();
if (data.success) {
return;
}
throw new Error('Wrong email or password');
}
// ...
我们现在可以最终测试我们的登录页面,看看它是否工作正常!如果它确实工作,我们应该被重定向到我们的私有路由;否则,我们应该在表单提交按钮下方看到一个友好的错误消息。
现在是时候保护我们的私有页面了。如果我们没有登录,就不应该能看到它。类似的事情也应该适用于登录页面:一旦我们登录,就不应该能看到它。
在继续实施之前,我们应该决定如何在我们的应用程序中实现认证。
我们可以在服务器端渲染我们的页面来检查每个请求的 cookie(记得吗?我们不想在客户端访问认证 cookie!),或者我们可以在前端简单地渲染一个加载器,并在渲染实际页面内容之前等待钩子检查我们是否已登录。
在做出这样的选择之前,我们应该考虑什么?
有几种情况可能会影响这个选择。例如,让我们考虑 SEO;如果我们正在构建一个只有登录用户可以(例如)发表评论的博客,那不是什么大问题。我们可以发送一个静态生成的页面,并等待钩子告诉我们用户是否已认证。同时,我们可以渲染公共内容(如文章正文、作者和标签),这样 SEO 就不会受到影响。用户一旦客户端知道他们已登录,就可以立即发表评论。
此外,性能将非常出色,因为我们可以用静态生成的页面来提供,动态数据将在客户端独立渲染。
作为一种替代方案,我们可以在服务器端简单地获取用户 cookie,验证 JWT,然后根据用户的认证状态渲染页面;这可能更容易实现(我们可以在内置的 getServerSideProps 函数内部做这个),但无疑会增加一些延迟,并迫使我们必须在服务器端渲染所有页面。
我们将实施第一个解决方案,其中我们需要创建一个自定义钩子来决定用户是否已登录。
为了做到这一点,我们首先需要实现一个 API,该 API 解析我们的 cookie 并回复关于我们会话的最基本信息。让我们通过创建一个pages/api/get-session.js文件来实现这一点:
import { parse } from 'cookie';
import { decode } from '../../lib/jwt';
export default (req, res) => {
if (req.method !== 'GET') {
return res.status(404).end();
}
const { my_auth } = parse(req.headers.cookie || '');
if (!my_auth) {
return res.json({ loggedIn: false });
}
return res.json({
loggedIn: true,
user: decode(my_auth),
});
};
我们现在可以使用我们刚刚创建的表单进行登录,然后通过http://localhost:3000/api/get-session调用 API。我们将看到以下类似的结果:
{
"loggedIn": true,
"user": {
"id": "f678f078-fcfe-43ca-9d20-e8c9a95209b6",
"name": "John Doe",
"email": "johndoe@somecompany.com",
"iat": 1635085226
}
}
如果我们在隐身会话中调用相同的 API,我们只会得到一个{ "loggedIn": false }的响应。
我们可以通过创建一个自定义钩子来使用这个 API 来确定用户是否已登录。让我们通过创建一个包含以下内容的lib/hooks/auth.js文件来实现这一点:
import { useState, useEffect } from 'react';
export function useAuth() {
const [loggedIn, setLoggedIn] = useState(false);
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch('/api/get-session')
.then((res) => res.json())
.then((data) => {
if (data.loggedIn) {
setLoggedIn(true);
setUser(data.user);
}
})
.catch((err) => setError(err))
.finally(() => setLoading(false));
}, []);
return {
user,
loggedIn,
loading,
error,
};
}
钩子本身很简单。一旦它加载(也就是说,当useEffect React 钩子被触发时),它将向我们的/api/get-session API 发起 HTTP 调用。一旦 API 成功(或失败),它将返回用户状态、错误(如果有),并将loading状态设置为false,这样我们就会知道是时候重新渲染 UI 了。
我们现在可以通过导入它并根据认证状态显示私有内容来在我们的受保护页面上实现这个钩子:
import { useRouter } from 'next/router';
import { useAuth } from '../lib/hooks/auth';
import styles from '../styles/app.module.css';
export default function ProtectedRoute() {
const router = useRouter();
const { loading, error, loggedIn } = useAuth();
if (!loading && !loggedIn) {
router.push('/login');
}
return (
<div className={styles.container}>
{loading && <p>Loading...</p>}
{error && <p> An error occurred. </p>}
{loggedIn && (
<>
<h1>Protected Route</h1>
<p>You can't see me if not logged-in!</p>
</>
)}
</div>
);
}
我们现在可以尝试访问我们的私有页面,看看登录后是否工作正常!首先,应该有一个短暂的“加载”文本出现;然后,我们应该看到受保护的路由内容。
我们可以采用类似的方法来隐藏登录页面,不让已登录的用户看到;让我们打开pages/login.js文件并按照以下方式编辑它:
import { useState } from 'react';
import { useRouter } from 'next/router';
import { useAuth } from '../lib/hooks/auth';
import styles from '../styles/app.module.css';
// ...
一旦我们导入useAuth钩子,我们就可以开始编写组件逻辑。在我们知道用户是否已登录之前,我们不会渲染登录表单:
// ...
export default function Home() {
const router = useRouter();
const [loginError, setLoginError] = useState(null);
const { loading, loggedIn } = useAuth();
if (loading) {
return <p>Loading...</p>;
}
if (!loading && loggedIn) {
router.push('/protected-route');
return null;
}
// ...
在这里,我们告诉我们的登录页面与受保护的路由页面相反。我们将等待钩子完成加载阶段,当它结束时,我们将检查用户是否已登录。如果他们已登录,我们将简单地使用 Next.js 的useRouter钩子将他们重定向到受保护页面。
我们成功实现了一个非常简单(但无论如何都不适合生产环境)的登录策略,但我们错过了什么?接下来会出现什么问题?我们应该追求编写自定义认证策略吗?
嗯,我认为除非我们在一个大型的专家团队中工作,否则这并不值得。
这本书的这一部分标题为自定义认证 – 优点、缺点和丑陋之处,所以让我们将一些考虑因素分为这三个类别:
优点:我们可能都会欣赏编写自定义认证系统,因为它让我们对安全有了很多了解,并让我们完全控制整个认证流程。
The bad: 我们将承担相当大的风险。编写健壮的认证机制并不容易,公司投入大量资金以提供安全的认证策略。对于在这个行业外工作的公司来说,达到 Auth0、Okta、Google 或 Amazon AWS 相同的保安级别是很困难的。
The ugly: 即使我们可以创建一个健壮的认证系统,我们也必须手动实现许多自定义流程——重置密码和用户注册工作流程、双因素认证和交易性电子邮件,仅举几例。这将需要大量的额外工作,并且会导致复制现有服务,而无法达到相同的保安和可靠性水平,因为要与 Auth0、Google 或 AWS 标准相匹配是困难的。
在下一节中,我们将看到如何使用一个行业标准、广为人知的认证提供商——Auth0——为任何 Next.js 应用实现认证。
使用 Auth0 实现认证
在上一节中,我们看到了如何实现一个基本且直接的认证方法。我不会重复得太多:我们所看到的是一个高级概述,不应用于任何生产就绪的产品。
当构建生产就绪的 Web 应用时,我们可能会采用外部认证方法,这些方法是安全和可靠的。
有许多不同的认证提供商(AWS Cognito、Firebase、Magic.link 等等),我相信它们都在出色地保护他们的用户。在本章中,我们将使用一个流行、安全且经济的认证提供商,利用其慷慨的免费计划:Auth0。
如果你想跟随本章内容,你可以在auth0.com(免费计划用户无需信用卡)创建一个免费账户。
Auth0 将管理任何认证策略中最复杂的步骤,并将提供一些友好的 API 供我们使用。
多亏了这个认证提供商,我们不必担心以下任何一点:
-
用户注册
-
用户登录
-
邮件验证
-
忘记密码流程
-
重置密码流程
我们也不必担心任何认证策略的其他许多关键部分。
因此,让我们先创建一个新的 Next.js 应用:
npx create-next-app with-auth0
现在,登录 Auth0 并创建一个新的应用:

图 12.4 – 创建新的 Auth0 应用
一旦我们创建了我们的应用程序,Auth0 将询问我们将使用哪种技术。我们可以选择Next.js,Auth0 将带我们到一个关于如何在框架中采用其认证机制的优秀教程。
如果我们进入设置,我们将能够设置我们的回调 URL。这些 URL 代表用户完成特定操作(如登录、登出和注册)后将被重定向到的页面。
在这一点上,我们需要通过添加 http://localhost:3000/api/auth/callback 来设置允许的回调 URL,并通过设置 http://localhost:3000/ 来设置允许的注销 URL。
这将授权我们在每次 Auth0 相关操作(如登录、注册和密码重置)之后采用 Auth0 进行本地开发,因为 Auth0 将将我们重定向到操作起源的 URL。
因此,例如,如果我们想登录到 example.com,在登录操作之后,Auth0 将自动将我们重定向到 example.com/api/auth/callback,这需要在刚刚看到的章节中进行授权。
由于我们的本地开发 URL 很可能是 http://localhost:3000(这是 Next.js 的默认设置),我们可能需要在允许的回调 URL和允许的注销 URL部分授权其他预发布或生产 URL。当然,我们总是可以通过添加更多 URL 并用逗号分隔它们来实现这一点。
一旦我们设置好了重定向 URL,我们就可以开始设置我们的本地环境。
首先,我们需要为本地环境创建一个环境文件。所以,让我们创建它,并将其命名为 .env.local,然后添加以下内容:
AUTH0_SECRET=f915324d4e18d45318179e733fc25d7aed95ee6d6734c8786c03 AUTH0_BASE_URL='http://localhost:3000'AUTH0_ISSUER_BASE_URL='https://YOUR_AUTH0_DOMAIN.auth0.com'AUTH0_CLIENT_ID='YOUR_AUTH0_CLIENT_ID'AUTH0_CLIENT_SECRET='YOUR_AUTH0_CLIENT_SECRET'
请记住,我们绝不应该提交环境文件,因为它包含可能危害我们应用程序安全性的敏感数据。
如您所见,我们正在设置五个基本的环境变量:
-
AUTH0_SECRET:Auth0 用来加密会话 cookie 的随机生成的字符串。您可以在终端中运行openssl rand -hex 32来生成一个新的、安全的随机密钥。 -
AUTH0_BASE_URL:我们应用程序的基本 URL。对于本地开发环境,它将是http://localhost:3000。如果您想在不同的端口上启动应用程序,请确保更新.env.local文件以反映此更改。 -
AUTH0_ISSUER_BASE_URL:您的 Auth0 应用程序的 URL。您可以在我们刚刚访问的设置部分的开始处找到它,用于设置回调 URL(在 Auth0 控制台中标记为域名)。 -
AUTH0_CLIENT_ID:Auth0 应用程序的客户端 ID。您可以在域名设置下方找到它。 -
AUTH0_CLIENT_SECRET:Auth0 应用程序的客户端密钥。您可以在 Auth0 控制台中的客户端 ID设置下找到它。
一旦我们设置了所有这些环境变量,我们就可以在我们的 Next.js 应用程序中为 Auth0 创建一个 API 路由。记得我们之前讨论过在编写自定义身份验证策略时应该实现多少事情吗?登录、注销、密码重置、用户注册... Auth0 为我们处理一切,它通过要求我们在 /pages/api/auth/[...auth0].js 下创建一个简单的 API 路由来实现这一点。
一旦我们创建了此页面,我们就可以向其中添加以下内容:
import { handleAuth } from '@auth0/nextjs-auth0';
export default handleAuth();
如果您还没有这样做,您可以通过运行以下命令来安装官方的 Auth0 Next.js SDK:
yarn add @auth0/nextjs-auth0
一旦我们启动 Next.js 服务器,handleAuth()方法将为我们创建以下路由:
-
/api/auth/login,这是允许我们登录到我们应用的路径 -
/api/auth/callback,这是 Auth0 在登录成功后立即重定向我们的回调 URL -
/api/auth/logout,我们可以从这里注销我们的 Web 应用 -
/api/auth/me,这是一个端点,一旦我们登录,我们可以从中以 JSON 格式获取我们自己的信息
要使我们的会话在所有 Web 应用页面之间持久化,我们可以将我们的组件包裹在官方 Auth0 UserProvider上下文中。我们可以通过打开我们的pages/_app.js文件并添加以下内容来实现这一点:
import { UserProvider } from '@auth0/nextjs-auth0';
export default function App({ Component, pageProps }) {
return (
<UserProvider>
<Component {...pageProps} />
</UserProvider>
);
}
我们现在可以通过浏览 http://localhost:3000/api/auth/login 来尝试访问我们的应用登录页面。我们最终应该看到以下页面:

图 12.5 – 默认 Auth0 登录页面
我们还没有账户,因为这是我们第一次访问登录页面。我们可以点击注册来创建一个新账户。
一旦创建,我们将被重定向到应用主页,并收到一封确认邮件。
现在我们已经登录,我们可以在前端显示一些根据登录用户显示的有用信息;让我们从一个简单的事情开始,只显示一个问候消息。
我们可以通过打开/pages/index.js文件并添加以下内容来实现这一点:
import { useUser } from '@auth0/nextjs-auth0';
export default function Index() {
const { user, error, isLoading } = useUser();
if (isLoading) {
return <div>Loading...</div>;
}
if (error) {
return <div>{error.message}</div>;
}
if (user) {
return (
<div>
<h1> Welcome back! </h1>
<p>
You're logged in with the following email
address:
{user.email}!
</p>
<a href="/api/auth/logout">Logout</a>
</div>
);
}
return (
<div>
<h1> Welcome, stranger! </h1>
<p>Please <a href="/api/auth/login">Login</a>.</p>
</div>
);
}
如您所见,这个模式与我们实现自定义认证机制时使用的模式非常相似。我们静态生成页面,然后等待客户端获取用户信息,一旦我们得到它,我们就在屏幕上打印私有内容。
您现在可以尝试登录和注销应用以测试它是否正常工作。
一旦我们登录和注销,我们可能会想知道:我们如何自定义认证表单?如果我们想将数据保留在我们自己的数据库中怎么办?我们将在下一节讨论这个问题。
定制 Auth0
到目前为止,我们已经使用 Auth0 构建了一个简单的认证机制。然而,与自定义认证机制相比,它带来的优势是显而易见的:安全的认证流程,功能齐全的认证管理,等等,仅举几例。
我们可能遗漏的一件事是在构建自定义认证策略时我们有多少控制权;我们可以控制每个认证步骤,表单的外观和感觉,以及创建新账户所需的数据...我们如何使用 Auth0 来做这些?
谈到登录/注册表单方面,我们可以通过导航到 Auth0 仪表板中的品牌部分来自定义它:

图 12.6 – Auth0 品牌部分
在这里,我们可以直接编辑 HTML 表单以符合我们的应用风格。我们还可以自定义电子邮件模板,使其与我们的 Web 应用外观和感觉保持一致。
另一个重要的话题是 Auth0 如何存储用户数据。默认情况下,它将所有登录数据保存在自己的数据库中,但一旦进入 Auth0 仪表板,我们可以前往认证/数据库/自定义数据库页面,并设置一些自定义脚本来授予访问外部数据库的权限,在那里我们对数据所有权拥有完全的控制。
我们还可以设置一系列 webhooks,以便每次有新用户注册、登录、删除账户等操作时,一个由我们管理的外部 REST API(得到通知),我们可以在外部服务和数据库上复制数据变化。
Auth0 为我们提供了很多定制整个认证体验的可能性,它是市场上最完整的提供商之一。它还提供了一项慷慨的免费计划,我们可以免费测试其许多功能,在决定它是否满足所有需求之前。因此,如果你愿意构建一个生产就绪的应用程序,我强烈建议考虑使用 Auth0 来安全地管理认证。
摘要
在本章中,我们看到了使用第三方认证提供商如何在我们处理复杂且敏感的话题,如私人数据管理和用户会话时,避免许多问题。
因此,最终的问题可能是:在什么情况下实施自定义认证策略是有意义的?根据我谦逊的观点,我们应该尽量避免在几乎所有场景下编写自定义认证机制,除非我们与一个能够检测安全漏洞并识别整个认证流程中漏洞的专家团队合作。
有许多好的 Auth0 替代品(NextAuth.js、Firebase、AWS Cognito 等),复制它们经过实战检验的功能风险太大。
如果你不喜欢与外部供应商合作,你也可以使用任何 Web 框架及其内置的认证策略。例如,假设你习惯使用 Ruby on Rails、Laravel 或 Spring Boot。在这种情况下,这些都是在外部认证供应商之上的优秀替代品。它们也会为你提供你可能需要的所有灵活性和安全性,同时得到社区的大量支持和持续的安全发布和修复。
另一个选择可能是使用无头 CMS 来管理用户及其数据;例如,开源 CMS 如 Strapi,它原生支持认证,并允许我们利用社区和开发 CMS 的公司支持的认证机制。
在任何情况下,实施自定义认证是一项非常有教育意义的任务,因为它教会你很多关于安全机制如何工作以及如何保护自己免受恶意用户侵害的知识。例如,在下一章中,我们将构建一个使用 GraphCMS 的电子商务网站;想象一下,如果我们在这里实施一个自定义认证机制,让恶意用户利用漏洞并访问用户的私人数据,这会值得冒险吗?
第十三章:使用 Next.js 和 GraphCMS 构建电子商务网站
在我们探索 Next.js 的旅程中,我们学到了很多。我们探索了不同的渲染方法、样式技术、集成甚至部署策略。
现在是时候开始创建一些值得投入生产的作品了,利用我们迄今为止所学的一切。
在本章中,我们将看到如何采用 Next.js 从头开始构建电子商务店面。
我们将详细探讨以下内容:
-
什么是 GraphCMS 以及如何采用它
-
如何集成支付方式,例如 Stripe
-
如何部署电子商务网站
到本章结束时,您将能够描述一个 Next.js 电子商务架构,找到合适的 SEO 和性能权衡,并在正确的云平台上部署您的 Next.js 实例。
技术要求
要运行本章中的代码示例,您需要在您的本地机器上安装 Node.js 和 npm。
如果您愿意,可以使用在线 IDE,例如repl.it或codesandbox.io;它们都支持 Next.js,您不需要在您的电脑上安装任何依赖。与其他章节一样,您可以在 GitHub 上找到本章的代码库:https://github.com/PacktPublishing/Real-World-Next.js。
为现代网络创建电子商务网站
自从 90 年代末互联网开始传播以来,它为在线业务开辟了一个充满可能性的世界。因此,许多公司开始开发软件即服务(SaaS)产品,以帮助人们建立自己的在线购物平台。
今天,这个领域有几个重要的参与者:Shopify、Big Cartel、WordPress(使用 WooCommerce 或其他插件)和 Magento,仅举几个例子。
还有像 PayPal 和 Stripe 这样的公司,它们使在任何平台上集成支付方式变得极其简单,为定制电子商务创建铺平了道路,我们的想象力是唯一的限制。
当谈论电子商务创建中的“限制”时,我指的是某些 SaaS 平台可能会让开发者难以自定义 UI、支付流程等。
以 Shopify 为例,通过创建一个新的服务器端渲染的 React.js 框架来解决此问题,名为Hydrogen,它包含预构建的组件和 Hooks 以与 GraphQL API 通信,使开发者能够轻松地在前端创建独特的用户体验。
Next.js 发布了 Next.js Commerce,这是一个高度可定制的入门套件,可以轻松创建电子商务体验,能够与许多不同的平台集成。
Next.js Commerce 并没有为 Next.js 框架添加任何新功能。相反,它作为一个模板来启动一个新的电子商务网站,知道我们可以极其容易地定制它的每一个部分。我们不会涉及实际的定制能力;然而,我们仍然会部署一个性能卓越且优化的在线商店。
我们可以使用 Next.js Commerce 与任何无头后端服务一起使用。无论我们使用 Shopify、BigCommerce、Saleor 还是任何其他服务,只要它们公开一些 API 与后端通信即可。
从下一节开始,我们将使用目前最好的无头 CMS 平台之一,它可以管理现代电子商务平台的任何方面,从产品库存到内容翻译,始终采用 API 首选的方法:GraphCMS。
设置 GraphCMS
在电子商务领域有许多不同的竞争对手;它们都提供了一套构建现代和高效解决方案的功能,但在分析后台功能、前端定制能力、API、集成等方面总会有一定的权衡。
在本章中,我们将使用 GraphCMS 的一个简单原因:它易于集成,提供慷慨的免费计划,并且不需要为复杂的发布流程、数据库或其他任何东西进行设置。我们只需要开设一个账户,利用庞大的免费功能集来构建一个完全工作的电子商务网站。
它还提供了一个带有预构建(但完全可定制)内容的电子商务入门模板,这意味着一个预构建的 GraphQL 模式,可以在前端消费以创建产品页面、目录等。
我们可以通过访问 graphcms.com 来创建一个新的 GraphCMS 账户。一旦我们登录到我们的控制台,我们会看到 GraphCMS 会提示我们创建一个新的项目,我们将在几个预制的模板中进行选择。我们可以选择商业商店模板,这将为我们生成一些模拟内容。

图 13.1 – GraphCMS 控制台
一旦我们通过选择商业商店作为模板创建了项目,我们就可以在我们的 GraphCMS 控制台中浏览内容部分,看看我们有什么模拟数据。
我们将看到许多有用且预先填充的部分,如产品、产品变体、类别和评论;我们将在我们的 Next.js 电子商务应用中很快使用这些数据。
现在我们有了内容,我们需要创建一个 Next.js 应用程序,通过使用强大的 GraphCMS GraphQL API 在前端显示它:
npx create-next-app with-graphcms
一旦我们创建了应用程序,我们就可以开始考虑我们想要的 UI 是什么样子。在这种情况下,我们希望保持简单,我们将使用 Chakra UI 来为我们的组件进行样式设计。让我们安装它并在我们的 Next.js 应用程序中设置它:
yarn add @chakra-ui/react @emotion/react@¹¹ @emotion/styled@¹¹ framer-motion@⁴
让我们打开 _app.js 文件并添加 Chakra 提供者:
import { ChakraProvider } from '@chakra-ui/react';
function MyApp({ Component, pageProps }) {
return (
<ChakraProvider>
<Component {...pageProps} />
</ChakraProvider>
);
}
export default MyApp;
现在我们已经设置了一个基本的 Next.js 应用程序,我们可以开始考虑将 GraphCMS 连接到它。
如前所述,GraphCMS 提供了出色的 GraphQL API,因此我们需要通过该协议连接到它。我们已经在 第四章 中讨论了如何使用 Apollo 连接到任何 GraphQL 端点,在 Next.js 中组织代码库和获取数据。为了简化,我们现在将使用一个更直接的库来连接到 GraphCMS:graphql-request。
我们可以通过使用 Yarn 来安装它:
yarn add graphql-request graphql
现在,让我们创建一个基本的 GraphQL 接口,将 GraphCMS 连接到我们的店面。首先,让我们创建一个名为 lib/graphql/index.js 的新文件,并添加以下内容:
import { GraphQLClient } from 'graphql-request';
const { GRAPHCMS_ENDPOINT, GRAPHCMS_API_KEY = null } =
process.env;
const authorization = `Bearer ${GRAPHCMS_API_KEY}`;
export default new GraphQLClient(GRAPHCMS_ENDPOINT, {
headers: {
...(GRAPHCMS_API_KEY && { authorization} }),
},
});
这里发生了什么?
如您所见,我们需要创建几个环境变量:GRAPHCMS_ENDPOINT 和 GRAPHCMS_API_KEY。第一个包含 GraphCMS 端点 URL,第二个是用于访问受保护数据的可选 API 密钥。
实际上,GraphCMS 允许您公开其数据,这在某些情况下可能很有用。然而,在其他情况下,我们希望我们的数据只能被授权客户端访问,因此我们需要使用一个 API 密钥。
我们可以通过访问我们的 GraphCMS 控制面板上的设置然后API 访问来检索这些环境变量值。

图 13.2 – GraphCMS 的 API 访问管理
现在,我们可以从我们的代码库中的.env.local文件中获取GRAPHCMS_ENDPOINT值。当然,如果这个文件不存在,我们可以从头创建它:
GRAPHCMS_ENDPOINT=https://api-eu-central-1.graphcms.com/v2/ckvt6q8oe1h5d01xpfkub364l/master
现在,我们需要设置 API 密钥,这允许我们在 CMS 上执行突变(例如,一旦付款,保存订单)。我们可以在 .env.local 文件中使用默认的 GRAPHCMS_API_KEY 值。
我们现在准备就绪!我们已经与 CMS 建立了连接,因此我们可以通过 GraphQL API 读取、写入,甚至更新或删除数据。在下一节中,我们将使用它们来创建店面和产品详情页面。
创建店面、购物车和产品详情页面
GraphCMS 提供了一个制作精良、坚如磐石的开源模板,用于创建电子商务网站,您可以通过此 URL 获取:github.com/GraphCMS/graphcms-commerce-starter。
我们没有采用这个入门模板,因为我们想完全理解某些技术决策背后的推理以及如何处理开发阶段可能出现的问题。
话虽如此,我们可以专注于开发我们店面所需的第一批基本组件。
我们将把整个应用程序包裹在一个 Chakra UI 框架中,这样每个页面都将拥有相似的布局。我们可以通过打开 _app.js 文件并添加以下组件来实现这一点:
import { Box, Flex, ChakraProvider } from '@chakra-ui/react';
function MyApp({ Component, pageProps }) {
return (
<ChakraProvider>
<Flex w="full" minH="100vh" bgColor="gray.100">
<Box maxW="70vw" m="auto">
<Component {...pageProps} />
</Box>
</Flex>
</ChakraProvider>
);
}
export default MyApp;
现在,我们可以开始考虑我们如何在主页上展示我们的产品。然而,在这样做之前,我们可能想检查 CMS 通过 GraphQL API 提供的数据,我们可以通过进入仪表板的API 演示场部分轻松做到这一点。在这里,我们可以编写我们的 GraphQL 查询,利用探索器功能帮助我们轻松创建高度可定制的 GraphQL 查询。

图 13.3 – GraphCMS API 演示场
在前面的屏幕截图所示的查询中,我们正在检索所有公开可用的产品。我们可以在我们的 Next.js 应用程序中使用这个确切的查询,所以让我们创建一个新的/lib/graphql/queries/getAllProducts.js文件并添加以下内容:
import { gql } from 'graphql-request';
export default gql`
query GetAllProducs {
products {
id
name
slug
price
images {
id
url
}
}
}
`;
我们现在已准备好获取所有产品以填充我们的主页。为了在构建时生成静态页面,让我们前往pages/index.js页面并在getStaticProps函数中检索产品:
import graphql from '../lib/graphql';
import getAllProducts from '../lib/graphql/queries/getAllProducts';
export const getStaticProps = async () => {
const { products } = await graphql.request(getAllProducts)
return {
props: {
products,
},
};
};
在这个阶段,我们可能会想知道如何处理我们创建一个新产品并希望立即在主页上显示的情况。这里,我们有两种选择:
-
使用
getServerSideProps而不是getStaticProps,这将根据每个请求动态生成页面,但我们已经知道它的缺点,如在第十章中所述,与 SEO 合作和管理性能。 -
使用增量静态再生,这样在给定的时间后,页面会得到再生,包括任何新的 API 更改。
我们将通过添加以下属性到我们的返回的getStaticProps对象中继续进行第二种选择:
import graphql from '../lib/graphql';
import getAllProducts from '../lib/graphql/queries/getAllProducts';
export const getStaticProps = async () => {
const { products } = await graphql.request(getAllProducts)
return {
revalidate: 60, // 60 seconds
props: {
products,
},
};
};
我们现在已准备好在主页上展示所有产品。我们将通过在/components/ProductCard/index.js下创建一个新的组件来实现这一点,并公开以下函数:
import Link from 'next/link';
import { Box, Text, Image, Divider } from '@chakra-ui/react';
export default function ProductCard(props) {
return (
<Link href={`/product/${props.slug}`} passHref>
<Box
as="a"
border="1px"
borderColor="gray.200"
px="10"
py="5"
rounded="lg"
boxShadow="lg"
bgColor="white"
transition="ease 0.2s"
_hover={{
boxShadow: 'xl',
transform: 'scale(1.02)',
}}>
<Image src={props.images[0]?.url} alt={props.name} />
<Divider my="3" />
<Box>
<Text fontWeight="bold" textColor="purple"
fontSize="lg">{props.name}
</Text>
<Text textColor="gray.700">€{props.price/ 100}</Text>
</Box>
</Box>
</Link>
);
}
如您所见,这是一个简单的组件,它显示包含产品图片、名称和价格的产品卡片。
如果你查看使用的属性(在前面的代码片段中突出显示),你会注意到它与从 GraphCMS 返回的数据一一对应。这是使用 GraphQL 的另一个微小优势:它允许你在查询数据的同时对数据进行建模,这使得围绕它构建组件、函数甚至算法变得极其容易。
现在我们有了ProductCard组件,我们可以将其导入到我们的主页中,并使用它来显示从 CMS 获取的所有产品:
import { Grid } from '@chakra-ui/layout';
import graphql from '../lib/graphql';
import getAllProducts from '../lib/graphql/queries/getAllProducts';
import ProductCard from '../components/ProductCard';
export async const getStaticProps = () => {
// ...
}
export default function Home(props) {
return (
<Grid gridTemplateColumns="repeat(4, 1fr)" gap="5">
{props.products.map((product) => (
<ProductCard key={product.id} {...product} />
))}
</Grid>
);
}
如果我们现在启动开发服务器并转到localhost:3000,我们将能够看到我们的店面。

图 13.4 – 我们基于 Next.js 的第一个店面
现在我们有一个工作的店面,我们需要创建一个单独的产品页面。
至于主页,我们将使用 SSG + ISR 来构建所有产品页面,这将帮助我们保持良好的性能并提高 SEO 和用户体验。
我们可以通过在 pages/product/[slug].js 下创建一个新文件来实现这一点,在那里我们可以开始编写以下函数定义:
export async function getStaticPaths() {}
export async function getStaticProps() {}
export default function ProductPage() {}
正如你可能猜到的,我们需要为每个产品生成一个新的页面,我们可以通过使用 Next.js 的保留函数 getStaticPaths 来实现这一点。
在该函数内部,我们将查询我们 CMS 中的所有产品,然后为每个产品生成动态 URL 路径;这样,在构建时,Next.js 将生成我们网站中需要的所有页面。
其他两个函数应该已经很熟悉了,所以我们将稍后实现它们。
现在,我们需要编写一个 GraphQL 查询来获取 GraphCMS 中的所有产品。为了保持简单,我们可以重用我们为主页编写的查询,该查询已经获取了所有产品,包括它们的 slugs(这将是产品 URL 的一部分)。
让我们通过向 GraphCMS 发送请求来更新我们的产品页面,获取库存中的所有产品:
import graphql from '../../lib/graphql';
import getAllProducts from '../../lib/graphql/queries/getAllProducts';
export async function getStaticPaths() {
const { products } = await
graphql.request(getAllProducts);
const paths = products.map((product) => ({
params: {
slug: product.slug,
},
}));
return {
paths,
fallback: false,
};
}
通过这次编辑,我们正在返回一个包含我们在构建时需要生成的所有页面的对象。实际上,返回的对象将看起来像这样:
{
paths: [
{
params: {
slug: "unisex-long-sleeve-tee"
}
},
{
params: {
slug: "snapback"
}
},
// ...
]
fallback: false
}
正如你可能猜到的,这将帮助 Next.js 匹配给定的 /product/[slug] 路由与正确的产品 slugs。
在这一点上,我们需要创建一个 GraphQL 查询来获取单个产品的详细信息。我们可以在 lib/graphql/queries/getProductDetail.js 下创建一个新文件并添加以下内容:
import { gql } from 'graphql-request';
export default gql`
query GetProductBySlug($slug: String!) {
products(where: { slug: $slug }) {
id
images(first: 1) {
id
url
}
name
price
slug
description
}
}
`;
通过这个查询,我们将获取所有与 $slug 查询变量匹配的产品。鉴于 slug 属性在 GraphCMS 中是唯一的,如果请求的产品存在,它将返回一个只有一个结果的数组;如果不存在,则返回一个空数组。
现在我们已经准备好导入这个查询并编辑 getStaticProps 函数:
import graphql from '../../lib/graphql';
import getAllProducts from '../../lib/graphql/queries/getAllProducts';
import getProductDetail from '../../lib/graphql/queries/getProductDetail';
export async function getStaticProps({ params }) {
const { products } = await
graphql.request(getProductDetail, {
slug: params.slug,
});
return {
props: {
product: products[0],
},
};
}
现在我们只需要创建产品页面布局,包含我们产品的图片、标题、简要描述、价格和数量选择器。为此,我们可以按照以下方式编辑 ProductPage 函数:
import { Box, Flex, Grid, Text, Image, Divider, Button,
Select } from '@chakra-ui/react';
// ...
function SelectQuantity(props) {
const quantity = [...Array.from({ length: 10 })];
return (
<Select placeholder="Quantity"
onChange={(event) =>props.onChange(event.target.value)}>
{quantity.map((_, i) => (
<option key={i + 1} value={i + 1}>
{i + 1}
</option>
))}
</Select>
);
}
export default function ProductPage({ product }) {
return (
<Flex rounded="xl" boxShadow="2xl" w="full" p="16"
bgColor="white">
<Image height="96" width="96" src={product.images[0].url}/>
<Box ml="12" width="container.xs">
<Text as="h1" fontSize="4xl" fontWeight="bold">
{product.name}
</Text>
<Text lineHeight="none" fontSize="xl" my="3"
fontWeight="bold" textColor="blue.500">
€{product.price / 100}
</Text>
<Text maxW="96" textAlign="justify" fontSize="sm">
{product.description}
</Text>
<Divider my="6" />
<Grid gridTemplateColumns="2fr 1fr" gap="5"
alignItems="center">
<SelectQuantityonChange={() => {}} />
<Button colorScheme="blue">
Add to cart
</Button>
</Grid>
</Box>
</Flex>
);
}
如果我们现在启动开发服务器并打开单个产品页面,我们将看到以下内容:

图 13.5 – 单个产品详情页面
既然我们现在可以从主页导航到产品页面,我们需要构建一个导航栏,以便我们可以返回店面或转到购物车来查看我们想要购买的产品,然后进行支付。
我们可以通过在 components/NavBar/index.js 下创建一个新文件并添加以下内容来轻松创建一个导航栏:
import Link from 'next/link';
import { Flex, Box, Button, Text } from '@chakra-ui/react';
import { MdShoppingCart } from 'react-icons/md';
export default function NavBar() {
return (
<Box position="fixed" top={0} left={0} w="full"
bgColor="white" boxShadow="md">
<Flex width="container.xl" m="auto" p="5"
justifyContent="space-between">
<Link href="/" passHref>
<Text textColor="blue.800" fontWeight="bold"
fontSize="2xl" as="a">
My e-commerce
</Text>
</Link>
<Box>
<Link href="/cart" passHref>
<Button as="a">
<MdShoppingCart />
</Button>
</Link>
</Box>
</Flex>
</Box>
);
}
我们还需要安装 react-icons 库,正如其名所示,这是一个包含数百个精心制作且实用的图标,非常适合我们的基于 React 的项目的优秀包:
yarn add react-icons
现在我们只需要更新我们的 _app.js 文件,包括最新的 NavBar 组件,这样它就会在所有应用程序页面上渲染:
import { Box, Flex, ChakraProvider } from '@chakra-ui/react';
import NavBar from '../components/NavBar';
function MyApp({ Component, pageProps }) {
return (
<ChakraProvider>
<Flex w="full" minH="100vh" bgColor="gray.100">
<NavBar />
<Box maxW="70vw" m="auto">
<Component {...pageProps} />
</Box>
</Flex>
</ChakraProvider>
);
}
export default MyApp;
我们现在可以从店面导航到产品页面,然后再返回!
现在网站已经初具规模,我们想要将产品添加到我们的购物篮中。我们曾在 第五章 中讨论过类似的场景,在 Next.js 中管理本地和全局状态。
我们需要创建一个 React 上下文来保存购物清单,直到用户付款。
首先,我们需要在 lib/context/Cart/index.js 下创建一个新文件。在这里,我们将编写以下脚本:
import { createContext } from 'react';
const CartContext = createContext({
items: {},
setItems: () => {},
});
export default CartContext;
现在,我们需要将整个应用程序包裹在这个上下文中,因此我们需要打开 _app.js 文件并按以下方式编辑它:
import { useState } from 'react';
import { Box, Flex, ChakraProvider } from '@chakra-ui/react';
import NavBar from '../components/NavBar';
import CartContext from '../lib/context/Cart';
function MyApp({ Component, pageProps }) {
const [items, setItems] = useState({});
return (
<ChakraProvider>
<CartContext.Provider value={{ items, setItems }}>
<Flex w="full" minH="100vh" bgColor="gray.100">
<NavBar />
<Box maxW="70vw" m="auto">
<Component {...pageProps} />
</Box>
</Flex>
</CartContext.Provider>
</ChakraProvider>
);
}
export default MyApp;
这与我们在 第五章 中创建的上下文非常相似,在 Next.js 中管理本地和全局状态,对吧?
现在,我们需要将单个产品页面链接到上下文,以便将产品添加到购物篮中。让我们打开 components/ProductCard/index.js 文件,并将上下文链接到选择数量和添加到购物篮操作:
import { useContext, useState } from 'react';
import CartContext from '../../lib/context/Cart';
// ...
export default function ProductPage({ product }) {
const [quantity, setQuantity] = useState(0);
const { items, setItems } = useContext(CartContext);
const alreadyInCart = product.id in items;
function addToCart() {
setItems({
...items,
[product.id]: quantity,
});
}
return (
<Flex rounded="xl" boxShadow="2xl" w="full" p="16"
bgColor="white">
<Image height="96" width="96" src={product.images[0].url} />
<Box ml="12" width="container.xs">
<Text as="h1" fontSize="4xl" fontWeight="bold">
{product.name}
</Text>
<Text lineHeight="none" fontSize="xl" my="3"
fontWeight="bold" textColor="blue.500">
€{product.price / 100}
</Text>
<Text maxW="96" textAlign="justify" fontSize="sm">
{product.description}
</Text>
<Divider my="6" />
<Grid gridTemplateColumns="2fr 1fr" gap="5"
alignItems="center">
<SelectQuantity
onChange={(quantity)=>setQuantity
(parseInt(quantity))}
/>
<Button colorScheme="blue" onClick={addToCart}>
{alreadyInCart ? 'Update' : 'Add to cart'}
</Button>
</Grid>
</Box>
</Flex>
);
}
我们还可以通过显示购物车中有多少产品来使事情更加有趣和动态。我们可以通过将 NavBar 组件链接到相同的 CartContext 来实现这一点,只需添加几行代码:
import { useContext } from 'react';
import Link from 'next/link';
import { Flex, Box, Button, Text } from '@chakra-ui/react';
import { MdShoppingCart } from 'react-icons/md';
import CartContext from '../../lib/context/Cart';
export default function NavBar() {
const { items } = useContext(CartContext);
const itemsCount = Object
.values(items)
.reduce((x, y) => x + y, 0);
return (
<Box position="fixed" top={0} left={0} w="full"
bgColor="white" boxShadow="md">
<Flex width="container.xl" m="auto" p="5"
justifyContent="space-between">
<Link href="/" passHref>
<Text textColor="blue.800" fontWeight="bold"
fontSize="2xl" as="a">
My e-commerce
</Text>
</Link>
<Box>
<Link href="/cart" passHref>
<Button as="a">
<MdShoppingCart />
<Text ml="3">{itemsCount}</Text>
</Button>
</Link>
</Box>
</Flex>
</Box>
);
}
现在我们有了向购物篮添加项目的方法,我们需要创建购物页本身。让我们创建一个新的 pages/cart.js 文件,我们将添加以下组件:
import { useContext, useEffect, useState } from 'react';
import { Box, Divider, Text } from '@chakra-ui/react';
export default function Cart() {
return (
<Box
rounded="xl"
boxShadow="2xl"
w="container.lg"
p="16"
bgColor="white"
>
<Text as="h1" fontSize="2xl" fontWeight="bold">
Cart
</Text>
<Divider my="10" />
<Box>
<Text>The cart is empty.</Text>
</Box>
</Box>
);
}
这将是购物篮页面的默认状态。当用户将任何产品放入篮子时,我们需要在这里显示它。
为了做到这一点,我们可以轻松地使用我们刚刚创建的购物车上下文,这将告诉我们每个产品的 ID 和数量:
import { useContext, useEffect, useState } from 'react';
import { Box, Divider, Text } from '@chakra-ui/react';
import cartContext from '../lib/context/Cart';
export default function Cart() {
const { items } = useContext(cartContext);
return (
// ...
);
}
我们最终得到了一个包含每个产品的 ID 和数量的对象,格式为 { [product_id]: quantity }。
我们将使用此对象的键,通过一个新的查询从 GraphCMS 获取所有所需的产品,该查询位于 lib/graphql/queries/getProductsById.js:
import { gql } from 'graphql-request';
export default gql`
query GetProductByID($ids: [ID!]) {
products(where: { id_in: $ids }) {
id
name
price
slug
}
}
`;
一旦我们完成查询的编写,我们可以回到我们的 cart.js 文件,并使用 useEffect React 钩子实现它,以便在页面加载时立即获取所有产品:
import { useContext, useEffect, useState } from 'react';
import { Box, Divider, Text } from '@chakra-ui/react';
import graphql from '../lib/graphql';
import cartContext from '../lib/context/Cart';
import getProductsById from '../lib/graphql/queries/getProductsById';
export default function Cart() {
const { items } = useContext(cartContext);
const [products, setProducts] = useState([]);
const hasProducts = Object.keys(items).length;
useEffect(() => {
// only fetch data if user has selected any product
if (!hasProducts) return;
graphql.request(getProductsById, {
ids: Object.keys(items),
})
.then((data) => {
setProducts(data.products);
})
.catch((err) =>console.error(err));
}, [JSON.stringify(products)]);
return (
// ...
);
}
当我们尝试将几个产品添加到购物篮中,然后转到购物页时,我们会看到以下错误:

图 13.6 – 浏览器找不到进程变量
Next.js 告诉我们,包含所有环境变量的 process 变量在浏览器中不可用。幸运的是,即使这个变量没有被任何浏览器官方支持,Next.js 为我们提供了一个出色的 polyfill;我们只需要做一些更改使其有效。
首先,我们需要将 GRAPHCMS_ENDPOINT 变量重命名为 NEXT_PUBLIC_GRAPHCMS_ENDPOINT。通过在环境变量前添加 NEXT_PUBLIC_,Next.js 将添加一个 process.env 对象,在浏览器中可用,仅公开公共变量。
让我们在.env.local文件中进行更改,然后回到lib/graphql/index.js文件并对其进行一些小的修改:
import { GraphQLClient } from 'graphql-request';
const GRAPHCMS_ENDPOINT = process.env.NEXT_PUBLIC_GRAPHCMS_ENDPOINT;
const GRAPHCMS_API_KEY = process.env.GRAPHCMS_API_KEY;
const authorization = `Bearer ${GRAPHCMS_API_KEY}`;
export default new GraphQLClient(GRAPHCMS_ENDPOINT, {
headers: {
...(GRAPHCMS_API_KEY && { authorization }),
},
});
请注意,我们没有修改GRAPHCMS_API_KEY环境变量名称,因为它包含私有数据,永远不应该暴露。
现在我们已经解决了这个小问题,我们终于准备好编写购物车页面了。
首先,我们需要编写一个函数来计算最终费用,通过将产品价格乘以它们的数量来求和。我们可以在组件的主体中添加此函数来实现:
export default function Cart() {
// ...
function getTotal() {
if (!products.length) return 0;
return Object.keys(items)
.map(
(id) =>
products.find((product) => product.id === id).price
* (items[id] / 100) // Stripe requires the prices to be
// integers (i.e., €4.99 should be
// written as 499). That's why
// we need to divide by 100 the prices
// we get from GraphCMS, which are
// already in the correct
// Stripe format
)
.reduce((x, y) => x + y)
.toFixed(2);
}
// ...
}
现在,我们可以通过包括我们添加到购物车中的产品列表来更新我们的组件返回的 JSX:
return (
<Box
rounded="xl"
boxShadow="2xl"
w="container.lg"
p="16"
bgColor="white">
<Text as="h1" fontSize="2xl" fontWeight="bold">
Cart
</Text>
<Divider my="10" />
<Box>
{!hasProducts ? (
<Text>The cart is empty.</Text>
) : (
<>
{products.map((product) => (
<Flex
key={product.id}
justifyContent="space-between"
mb="4">
<Box>
<Link href={`/product/${product.slug}`} passHref>
<Text
as="a"
fontWeight="bold"
_hover={{
textDecoration: 'underline',
color: 'blue.500' }}>
{product.name}
<Text as="span" color="gray.500">
{''}
x{items[product.id]}
</Text>
</Text>
</Link>
</Box>
<Box>
€{(items[product.id] *
(product.price / 100)).toFixed(2)}
</Box>
</Flex>
))}
<Divider my="10" />
<Flex
alignItems="center"
justifyContent="space-between">
<Text fontSize="xl" fontWeight="bold">
Total: €{getTotal()}
</Text>
<Button colorScheme="blue"> Pay now </Button>
</Flex>
</>
)}
</Box>
</Box>
);
}
我们已经准备好管理购物车了!我们现在需要通过选择一个金融服务,例如 Stripe、PayPal 或 Braintree 来处理支付。
在下一节中,我们将看到如何使用 Stripe 实现支付功能。
使用 Stripe 处理支付
Stripe 是市面上最好的金融服务之一;它使用简单,并提供优秀的文档,帮助我们了解如何集成他们的 API。
在继续本节之前,请确保在 https://stripe.com 上开设账户。
一旦我们有了账户,我们可以登录并前往dashboard.stripe.com/apikeys,在那里我们将检索以下信息:发布密钥和秘密密钥。我们需要将它们存储在两个环境变量中,遵循以下命名约定:
NEXT_PUBLIC_STRIPE_SHARABLE_KEY=
STRIPE_SECRET_KEY=
请务必检查您没有暴露STRIPE_SECRET_KEY变量,并且.env.local文件没有被添加到 Git 历史记录中,通过将其包含在.gitignore文件中来实现。
现在我们将 Stripe JavaScript SDK 安装到我们的项目中:
yarn add @stripe/stripe-js stripe
两个包安装完成后,我们可以在lib/stripe/index.js下创建一个新文件,包含以下脚本:
import { loadStripe } from '@stripe/stripe-js';
const key = process.env.NEXT_PUBLIC_STRIPE_SHARABLE_KEY;
let stripePromise;
const getStripe = () => {
if (!stripePromise) {
stripePromise = loadStripe(key);
}
return stripePromise;
};
export default getStripe;
此脚本将确保我们只加载一次 Stripe,即使我们多次返回购物车页面。
在这一点上,我们需要创建一个 API 页面来创建 Stripe 会话。通过这样做,Stripe 将创建一个美丽且安全的结账页面,将我们的用户重定向到输入他们的支付和配送详情。一旦用户下订单,他们将被重定向到我们选择的着陆页,但我们会在这个部分的后面看到。
让我们在/pages/api/checkout/index.js下创建一个新的 API 路由,我们将在这里编写一个非常基本的 Stripe 结账会话请求:
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export default async function handler(req, res) {
}
一旦我们创建了这个基本函数,我们需要了解 Stripe 需要哪些数据来完成会话。
我们需要按照特定的顺序传递以下数据:
-
所有要购买的产品,包含名称、数量、价格和(可选)图片
-
所有可用的支付方式(信用卡、支付宝、SEPA 借记或其他支付方式,如 Klarna)
-
运费
-
无论是成功还是取消重定向 URL
我们可以从考虑第一个点开始;我们可以轻松地将整个购物车上下文对象传递到这个端点,包括要购买的产品 ID 和它们的数量。然后我们需要向 GraphCMS 请求产品详情,我们可以在 lib/graphql/queries/getProductDetailsById.js 下创建一个新的特定查询来完成这个操作:
import { gql } from 'graphql-request';
export default gql`
query GetProductDetailsByID($ids: [ID!]) {
products(where: { id_in: $ids }) {
id
name
price
slug
description
images {
id
url
}
}
}
`;
回到我们的 /pages/api/checkout/index.js API 页面,我们可以从实现查询以检索产品详情开始:
import Stripe from 'stripe';
import graphql from '../../../lib/graphql';
import getProductsDetailsById from '../../../lib/graphql/queries/getProductDetailsById';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export default async function handler(req, res) {
const { items } = req.body;
const { products } = await graphql
.request(getProductsDetailsById, { ids: Object.keys(items) });
}
Stripe 需要一个包含一个名为 line_items 的属性的配置对象,该属性描述了所有准备购买的产品。现在我们有了所有产品信息,我们可以以下这种方式来组合这个属性:
export default async function handler(req, res) {
const { items } = req.body;
const { products } = await graphql
.request(getProductsDetailsById, { ids: Object.keys(items) });
const line_items = products.map((product) => ({
// user can change the quantity during checkout
adjustable_quantity: {
enabled: true,
minimum: 1,
},
price_data: {
// of course, it can be any currency of your choice
currency: 'EUR',
product_data: {
name: product.name,
images: product.images.map((img) => img.url),
},
// please note that GraphCMS already returns the price in the
// format required by Strapi: €4.99, for instance, should be
// passed to Stripe as 499.
unit_amount: product.price,
},
quantity: items[product.id],
}));
作为参考,如果用户从我们的商店购买几个背包,line_items 数组看起来会是这样:
[
{
"adjustable_quantity": {
"enabled": true,
"minimum": 1
},
"price_data": {
"currency": "EUR",
"product_data": {
"name": "Backpack",
"images": [
https://media.graphcms.com/U5y09n80TpuRKJU6Gue1
]
},
"unit_amount": 4999
},
"quantity": 2
}
]
现在,我们可以开始通过使用 line_items 数组和一些更多信息来编写 Stripe 结账会话请求:
export default async function handle(req, res) {
// ...
const session = await stripe.checkout.sessions.create({
mode: 'payment', // can also be "subscription" or "setup"
line_items,
payment_method_types: ['card', 'sepa_debit'],
// the server doesn't know the current URL, so we need to write
// it into an environment variable depending on the current
// environment. Locally, it should be URL=http:// localhost:3000
success_url: `${process.env.URL}/success`,
cancel_url: `${process.env.URL}/cancel`,
});
res.status(201).json({ session });
}
我们几乎完成了。现在,我们只需要获取配送信息并将其存储在两个不同的 Stripe 会话属性中:shipping_address_collection 和 shipping_options。
我们可以在 handler 函数外部创建两个新变量。尽管如此,正如你所想象的,这可以完全由 CMS 驱动。
为了保持简单,让我们创建第一个 shipping_address_collection 变量:
export const shipping_address_collection = {
allowed_countries: ['IT', 'US'],
};
如你所见,我们可以通过手动选择我们配送的国家来限制配送。如果你想全球配送,你可以简单地避免将 shipping_address_collection 属性传递到 Stripe 会话中。
第二个变量更复杂,但允许我们创建具有不同费用的不同配送方式。比如说,我们提供免费配送,通常需要 3 到 5 个工作日才能送达,而快递次日配送需要 €4.99。
我们可以创建以下一系列的配送选项:
export const shipping_options = [
{
shipping_rate_data: {
type: 'fixed_amount',
fixed_amount: {
amount: 0,
currency: 'EUR',
},
display_name: 'Free Shipping',
delivery_estimate: {
minimum: {
unit: 'business_day',
value: 3,
},
maximum: {
unit: 'business_day',
value: 5,
},
},
},
},
{
shipping_rate_data: {
type: 'fixed_amount',
fixed_amount: {
amount: 499,
currency: 'EUR',
},
display_name: 'Next day air',
delivery_estimate: {
minimum: {
unit: 'business_day',
value: 1,
},
maximum: {
unit: 'business_day',
value: 1,
},
},
},
},
];
配送对象是自解释的。我们终于可以添加这两个新属性到我们的 Stripe 结账会话中:
export default async function handle(req, res) {
// ...
const session = await stripe.checkout.sessions.create({
mode: 'payment', // can also be "subscription" or "setup"
line_items,
payment_method_types: ['card', 'sepa_debit'],
// the server doesn't know the current URL, so we need to write
// it into an environment variable depending on the current
// environment. Locally, it should be URL=http:// localhost:3000
shipping_address_collection,
shipping_options,
success_url: `${process.env.URL}/success`,
cancel_url: `${process.env.URL}/cancel`,
});
res.status(201).json({ session });
}
完成了!我们现在回复一个包含用于在前端重定向用户到 Stripe 托管的结账页面的重定向 URL 的会话对象。
我们可以通过回到我们的 pages/cart.js 页面并添加以下函数来实现这一点:
import loadStripe from '../lib/stripe';
// ...
export default function Cart() {
// ...
async function handlePayment() {
const stripe = await loadStripe();
const res = await fetch('/api/checkout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
items,
}),
});
const { session } = await res.json();
await stripe.redirectToCheckout({
sessionId: session.id,
});
}
// ...
}
最后,我们只需要将这个函数链接到 Cart 函数:
// ...
<Button colorScheme="blue" onClick={handlePayment}>
Pay now
</Button>
// ...
我们终于准备好测试我们的结账流程了!让我们启动开发服务器,将几个产品添加到购物车中,然后转到 购物车 部分,点击 立即支付 按钮。我们应该会到达这个美丽的、由 Stripe 提供支持的结账页面,在那里我们可以输入我们的配送信息,选择所需的支付方式,并修改购物车中每个产品的数量:

图 13.7 – Stripe 结账页面
在我的情况下,您可以看到我被重定向到了一个Michele Riva商店(左上角),因为我使用我的名字打开了 Stripe 账户,但如果你也这样做并想自定义商店名称,你可以在 Stripe 仪表板上编辑它。
点击左上角的商店名称,我们将被重定向到我们在pages/api/checkout/index.js页面设置的cancel_url;如果我们成功完成购买,我们将被重定向到success_url。我将把这个创建这两个页面的任务留给你,作为在进入下一章之前完成的小作业。
摘要
在前面的章节中,我们看到了如何使用 GraphCMS 和 Stripe 这两个令人难以置信的产品创建一个简单的电子商务网站,这两个产品可以帮助构建可扩展、安全且易于维护的店面。
尽管我们在本章中取得了一些显著的进展,但我们仍然缺少一些值得专门写一本书来介绍的功能。
例如,如果我们现在想从 Stripe 结账导航回购物车页面,我们会发现我们的购物车是空的,因为购物车上下文不是持久的。那么,如果我们想允许我们的用户创建账户并查看运输进度、订单历史和其他有用的信息呢?
如您所想,这些都是复杂的话题,我们不可能在一个章节中专门处理它们。有一点可以肯定:一旦我们知道了如何通过 Auth0 处理用户和身份验证,如何在 GraphCMS 上管理产品库存和订单历史,以及如何在 Stripe 上完成结账,我们就拥有了创建稳固的用户体验和开发工作流程所需的所有元素。
Vercel 团队也在最新版本中宣布了 Next.js Commerce,这是一个可以立即附加到 Shopify、Saleor、BigCommerce 等几个电子商务平台的模板,以创建店面自定义 UI。我们之所以没有深入研究这个模板,原因很简单:它很有吸引力,但它抽象了连接不同系统(如 Stripe 和 GraphCMS 或 PayPal 和 WordPress)所需的大部分工作,而我们想为了学习而自己理解如何完成这些工作。
在本章中,我们看到了如何将无头 CMS 集成到我们的 Next.js 前端。但如果你觉得这很容易,那主要是因为 GraphCMS 被明智地构建,以开发者的体验为出发点,使我们能够利用为现代网络时代构建的优秀的 GraphQL API。
我们不能说同样的情况适用于其他在互联网仍处于幼年和成长时期诞生的 CMS,它们采用全栈方法,即我们使用 CMS 本身来构建应用的后端和前端。但如今,即使是那些较老的 CMS 平台也在社区不可思议的努力下不断发展,旨在通过允许我们采用 Next.js(或任何其他框架)作为前端来提供出色的开发者体验。例如,有一个出色的 WordPress 插件可以从现有网站生成优秀的 GraphQL API;这样,我们可以将 WordPress 作为一个完整的无头 CMS 使用,创建一个强大、性能优异、定制的 Next.js 前端。您可以在 https://www.wpgraphql.com 了解更多关于这个插件的信息。同样的情况也适用于 Drupal,另一个流行的开源 CMS,它可以通过 GraphQL 模块暴露 GraphQL API:www.drupal.org/project/graphql。
在下一章中,我们将简要回顾到目前为止我们所看到的内容,并看看我们可以构建的一些示例项目,以进一步练习使用 Next.js。
第十四章:示例项目和更多学习的下一步
我们即将结束这段旅程,到目前为止,它已经是一次疯狂的旅程。
我们已经学到了很多关于 Next.js 的知识;我们现在准备好创建下一个大型网站,或者只是在我们自己的框架中享受无尽的乐趣。
在本章的结尾,我们将看到更多关于 Next.js 的学习步骤,同时也会简要回顾我们迄今为止所发现的内容。
我们将详细探讨以下内容:
-
本书所学内容的简要回顾
-
接下来的学习步骤是什么?
-
一些用于练习 Next.js 的项目想法
到本章结束时,你将了解作为 Next.js 开发者的旅程中的下一步。
一个框架,无限可能
自从我们开始 Next.js 的冒险之旅以来,我们已经看到了框架为我们提供构建更好、更快网页的许多不同功能。
在谈论框架时,我们应该考虑的一点是,它不仅仅关乎技术。社区、理念和生态系统是至关重要的,值得更详细地讨论。
实际上,Next.js 不仅仅是一个网页框架。我们已经看到它如何通过提供令人兴奋且独特的功能,使我们的前端和后端应用构建方式发生革命,这些功能让我们的工作更轻松,同时没有削弱我们对作为开发者的工作的热爱。
不提及 Vercel 如何创造性地构建出如此独特的东西,很难谈论 Next.js。
Vercel 不仅为我们提供了部署应用程序的不可思议的平台,而且还投入了大量精力来增强网页框架及其生态系统。
随着 Next.js 11 的发布,Vercel 团队也宣布了 Next.js Live,这是一个基于网页浏览器的环境,可以在编码任何 Next.js 应用程序时实时与团队协作。
尽管它仍在测试版,但它极具潜力。我必须承认,我第一次尝试时非常兴奋,因为我清楚地看到这如何能提高团队在调试、设计和测试基于 Next.js 的网站时的生产力。
![Figure 14.1 – 使用 Next.js Live 进行实时编码]
![Figure_14.01_B16985.jpg]
![Figure 14.1 – 使用 Next.js Live 进行实时编码]
除了 Vercel,还有一个由公司和个人贡献者组成的整个社区,他们正在创建令人难以置信的扩展和库,以简化我们在构建真实世界 Next.js 应用程序时的工作。
在我们的旅程中,我们已经使用了一些工具,但还有很多可以帮助我们轻松实现卓越成果的包。
此外,还有一些优秀的 GitHub 仓库列出了工具、教程和库的选择,例如github.com/unicodeveloper/awesome-nextjs。在这里,你可以找到满足各种需求的优质包的详尽列表。
在浏览所有那些库、文章和教程时,你最终会看到 Next.js 的力量依赖于许多不同的特性。它可能最初是一个“全栈 React.js 框架”,但现在它已经变得更加重要和多样化。
事实上,我们可以清楚地看到 Next.js 更像是一个通用框架,我们可以用它来构建任何类型的应用。
在过去,我们通常根据它们感兴趣的主要领域来区分 Web 框架和技术。例如,如果我们需要构建一个复杂且交互式的产品,我们的选择通常局限于 Ruby on Rails、Symphony 或 Spring Boot 等几种。
假设我们需要构建一个简单的公司网站。在这种情况下,我们可能会选择一个静态站点生成器,如 Jekyll,或者一个简单的 CMS,如 WordPress。
我并不是说 Next.js 改变了所有这些。然而,它用一种简单的 Web 开发方法取代了大量的技术和框架,这种方法使得整个团队能够轻松地在单个项目上协作,无论是构建 REST API(通过 API 页面)、React 组件、后端逻辑、用户界面(UIs)等。
在采用 Next.js 作为 Web 框架时,我们还应该考虑它在架构层面上的影响。
在第十一章 不同的部署平台中,我们讨论了如何根据 Next.js 应用的特性和目的来做出部署决策。
几年前,标准做法要求将任何 Web 应用部署在托管服务器上。如今,我们有多种不同的机会,通过选择多种服务应用的方式,来提升用户的浏览体验。对于更多经典的科技栈,如 Laravel 或 Ruby on Rails,我们的选择有限;我们可以在 AWS EC2 集群或任何公司提供的虚拟专用服务器上部署它们。Next.js 允许我们考虑许多替代方案,通过在构建时静态渲染某些页面或在运行时服务器端渲染其他页面,来创建更好的部署管道和用户体验。这是一个颠覆性的变化。
总结来说,Next.js 并不仅仅适用于一种类型的 Web 应用。由于其灵活性、健壮性和庞大的生态系统,它可以用于构建任何应用。
到我写作的时候,我无法告诉你一个使用 Next.js 是错误选择的场景。
说到可能的场景,可能正是时候介绍一些小项目,以练习我们的 Next.js 知识,在投入生产网站开发之前。在下一节中,我们将看到一些优秀的小项目想法,这些项目可以帮助我们练习实战场景。
使用 Next.js 进行实战练习的实际应用
最佳的学习方式是通过亲身体验。在这本书中,我们涵盖了几个越来越复杂的话题,并描述了构建真实世界 Next.js 应用程序的各种方法。
现在是时候动手实践,开始编写一些优秀的应用程序了!
当我开始作为软件工程师职业生涯时,我很难找到合适的示例应用程序来构建以供进一步学习;现在,我想给你一个机会,通过创建一些值得与你的同事或朋友分享,甚至在你下一次求职面试中分享的东西来练习。
流媒体网站
流媒体应用程序已经成为我们生活的重要组成部分,并且永远改变了我们观看电影和电视剧的方式。它们也是那些想要在了解特定技术时创建真实世界应用程序的人的一个极好的用例。
作为第一个真实世界项目,我希望你构建你最喜欢的流媒体服务的克隆。它必须遵守以下规则:
-
它必须显示从
www.themoviedb.org数据库中获取的电影列表。这个网站公开了一些美丽的免费 REST API;你可以在这里找到文档:www.themoviedb.org/documentation/api。 -
为了使事情复杂化(就像在现实世界场景中一样),用户必须经过身份验证才能查看应用程序中所有可用的电影。
-
当有预告片可用时,用户应该能够在电影页面上观看它。
-
所有图像都必须使用 Next.js 的
<Image/>组件提供。 -
用户可以登录和登出。
在开始编写这个应用程序的代码之前,我建议你尝试回答以下问题:
-
我应该选择什么样的渲染策略来为单个电影页面?
-
我应该在何处部署这个应用程序?
-
我如何确保用户在浏览网站时已登录?
-
如果有数百(或数千)个并发用户浏览该应用程序,它的表现会如何?这会改变我对第一个问题的答案吗?
当然,在构建应用程序时还有很多其他事情需要考虑,但这是一个良好的起点。
在下面的示例中,我们将看到不同类型的应用程序。你将需要满足固定的技术要求,正如你在为任何公司工作时一样。
博客平台
让我们假设你正在为一家公司工作,你需要通过将 Next.js 与无头 CMS(在这种情况下,是 GraphCMS)结合来构建一个博客网站。
你必须遵守以下要求:
-
你必须使用 TailwindCSS 来设计 UI。
-
你必须使用 TypeScript 编码应用程序。
-
每个博客页面必须在构建时静态渲染。
-
UI 必须尽可能接近你最喜欢的博客。
-
用户可以登录并将文章保存到阅读列表中。
-
所有图像都必须使用 Next.js 的
<Image/>组件提供。 -
SEO 是至关重要的。它必须达到 100% 的 Lighthouse SEO 分数。
如果你刚开始接触 TypeScript,不要担心!Next.js 允许你逐步采用它。一旦你习惯了它,你将永远不会回到 vanilla JavaScript,我保证!
奖励点:如果你足够自信,你还可以构建一个简单的编辑页面,用户可以在其中撰写文章并在网站上分享。
在这个练习中,你应遵循一些严格的要求(要使用的 CMS、样式方法以及语言)。在下一个练习中,你将完全自由地做出任何关于技术栈的决定。
实时聊天网站
这可能是 Next.js 强大功能的最令人信服的例子之一。对于这个练习,你需要构建一个具有以下功能的实时聊天应用程序:
-
必须有多个聊天室。
-
人们只需输入他们的名字就可以加入任何房间;无需登录。
-
当人们进入房间时,他们可以访问整个聊天室的历史记录。
-
通信必须是实时的。
-
奖励点:允许用户创建新的聊天室。
这是一个迷人的练习,因为有许多不同的事情需要考虑。例如,如果用户知道一个特定的房间 URL 并试图加入而不输入他们的名字怎么办?所有的消息应该存储在哪里?如何实时发送和检索这些消息?
为了回答这两个最后的问题,有多个优秀的产品可以帮助构建安全、实时的软件;其中最有趣的一个无疑是 Google Firebase。它提供了一个带有端到端加密的免费实时数据库,使得创建任何聊天应用程序都变得容易。
下一步
在前面的章节中,我们看到了一些小小的想法,它们包含了你需要练习和提升你的 Next.js 知识所需的一切。
尽管我们已经涵盖了多个不同的主题,但仍然有太多东西要学习!但这次,你拥有了启动任何 Next.js 项目所需的所有信息,并且在阅读了整个主题的整本书之后,最好的做法是通过实现实际应用来继续前进。
从现在开始,你知道如何从头开始使用 TypeScript 或 vanilla JavaScript 创建 Next.js 项目,如何自定义其 webpack 配置,如何添加任何外部 UI 库,如何选择渲染策略,在哪里部署它,以及许多其他优秀概念。
Next.js 是一个快速发展的框架,我现在能给你的最好建议是,通过关注 Next.js 核心开发者、Vercel(以及当然,@MicheleRivaCode我本人!)在 Twitter 上的任何新闻,通过参加 Next.js Conf(在线活动),以及阅读官方 Next.js 博客nextjs.org/blog来跟踪有关 Next.js 的任何新闻。
你会惊讶地看到 Next.js 如何快速发展和改变网络。
因此,再次强调,当你开始使用 Next.js 时,最好的做法是了解其最新的发布和功能。
摘要
这份总结标志着一本我希望永远不会结束的书。这不仅是因为我真正享受了写作的过程,还因为我相信关于这个美丽的框架还有很多话要说。
这本书涵盖了从零开始编写真实世界 Next.js 应用程序所需的所有基础知识。因此,我坚信你会感到使用 Next.js(一个用于生产的 React 框架)编写快速、可靠和可维护的网站会非常舒适。
在这一特定章节中,我们看到了 Next.js 在许多不同情况下是如何改变游戏规则的,以及它如何永远地改变了我们编写 Web 应用程序的方式。鉴于它是一个不断发展的框架,我们还讨论了跟踪其最新和频繁的发布以及利用其新功能和改进的重要性。
我们还看到了三个可以实施的真实世界应用,我们可以用它们来练习 Next.js,这对于学习如何编写生产级应用程序的任何人的旅程来说都是至关重要的。
现在是时候关闭这本书,开始编码,享受我们正在生活的时光了,在这个时光里,Next.js 存在,让我们的开发者体验变得如此美好。

订阅我们的在线数字图书馆,全面访问超过 7,000 本书和视频,以及领先的工具来帮助你规划个人发展和提升职业生涯。欲了解更多信息,请访问我们的网站。
第十五章:为什么订阅?
-
通过来自 4,000 多位行业专业人士的实用电子书和视频,节省学习时间,多花时间编码。
-
通过为你量身定制的技能计划提高你的学习效果
-
每月免费获得一本电子书或视频
-
完全可搜索,便于轻松访问关键信息
-
复制粘贴、打印和收藏内容
你知道 Packt 为每本书都提供电子书版本,包括 PDF 和 ePub 文件吗?你可以在 packt.com 升级到电子书版本,作为印刷书客户,你有权在电子书副本上获得折扣。如需更多详情,请联系我们 customercare@packtpub.com。
在 www.packt.com,你还可以阅读一系列免费的技术文章,订阅各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。
你可能还会喜欢其他书籍
如果你喜欢这本书,你可能对 Packt 的其他这些书籍也感兴趣:
![包含图形用户界面的图片]
自动生成的描述](https://www.packtpub.com/product/simplify-testing-with-react-testing-library/9781800564459)
使用 React Testing Library 简化测试
Scottie Crump
ISBN: 978-1-80056-445-9
-
探索 React Testing Library 及其用例。
-
掌握 RTL 生态系统。
-
将 jest-dom 应用于增强你的测试使用 RTL。
-
获得使用 RTL 创建不因更改而中断的测试所需的信心。
![包含文本的图片,名片]
React 17 设计模式和最佳实践
Carlos Santana Roldán
ISBN: 978-1-80056-044-4
-
掌握样式化和优化 React 组件的技术。
-
使用新的 React Hooks 创建组件。
-
掌握新的 React Suspense 技术以及在项目中使用 GraphQL。
-
使用服务器端渲染来加快应用程序的加载速度。
-
编写一套全面的测试来创建健壮且易于维护的代码。
Packt 正在寻找像你这样的作者
如果你对成为 Packt 的作者感兴趣,请访问 authors.packtpub.com 并今天申请。我们曾与成千上万的开发者和技术专业人士合作,就像你一样,帮助他们将见解分享给全球技术社区。你可以提交一个一般申请,申请我们正在招募作者的特定热门话题,或者提交你自己的想法。
嗨!
我真心希望您喜欢阅读Real-World Next.js,并发现它在提高您编写生产级 React 应用程序的生产力和效率方面非常有帮助!
如果您能在亚马逊上留下对Real-World Next.js的评论,分享您的想法,这将真正帮助我(以及其他潜在的读者!)。
点击以下链接留下您的评论:
https://packt.link/r/180107349X
您的评论将帮助我了解这本书中哪些内容做得很好,哪些内容可以在未来的版本中改进,所以这真的非常感谢。
祝好,




浙公网安备 33010602011771号