全栈开发者指南-全-

全栈开发者指南(全)

原文:zh.annas-archive.org/md5/a819a36ffdb827e744d9e0e0630d9d6a

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

几乎所有的编程职位如今都要求至少对全栈开发有一个粗略的了解,但如果你是初学者,你可能会很难找到切入这个庞大话题的正确入口。你甚至可能不知道这个术语的含义。

简单来说,全栈 Web 开发通常指的是使用 JavaScript 及其构建的各种框架来创建完整的 Web 应用。这要求精通前端和后端开发的传统学科,并且能够编写中间件和各种类型的应用程序接口(API)。

最后,一名全面的全栈开发人员能够处理数据库,并具备专业技能,比如能够编写自动化测试并自行部署代码。要做到这一点,他们必须理解 HTML、CSS 和 JavaScript,以及该语言的类型化对应物 TypeScript。有关这些术语的速成课程,请参见第 xxiv 页的《全栈应用的组成部分》。

如果这听起来很多,你来对地方了。本书将向你介绍现代应用的各个组成部分,并教你如何使用一些最广泛使用的技术来构建它们。

谁应该阅读这本书?

本书的主要读者有两类。一类是希望通过掌握全栈开发来提升职业生涯的前端或后端工程师;另一类是对 Web 开发感兴趣的初学者。

虽然本书从零开始介绍了许多技术,但它假设读者对 HTML、CSS 和 JavaScript 有一定的基础了解,并且了解大多数 Web 应用的客户端/服务器架构。作为复习,可以参考 Sam Taylor 的《编码工作手册》(No Starch Press, 2020),该书教你如何使用 HTML 和 CSS 构建网站;以及 Peter Gasston 的《CSS3 书籍》第二版(No Starch Press, 2014),来提升你的 CSS 技能。为了熟悉 JavaScript,我推荐 Nick Morgan 的《JavaScript 速成课程》(No Starch Press, 2024),这是一本为初学者设计的快速 JavaScript 教程;以及 Marijn Haverbeke 的《Eloquent JavaScript》第三版(No Starch Press, 2018),深入探讨 JavaScript。

本书内容

本书分为两部分。第一部分,包括第一章到第十章,将向你介绍现代技术栈的各个组成部分。每一章重点介绍一种技术,并强调作为全栈开发人员需要掌握的知识点。练习将鼓励你从第 1 页开始编写应用代码。

第一章: Node.js  介绍了 Node.js 及其生态系统,使你能够在浏览器外运行 JavaScript 代码。然后,你将使用 Node.js 和 Express.js 框架创建一个简单的 JavaScript 网页服务器。

第二章: 现代 JavaScript  聚焦于现代 JavaScript 语法,适用于全栈开发人员,包含如何使用模块编写可维护的代码包。我们将探讨定义变量和常量的不同方式、箭头函数以及异步代码的技巧。你将使用这些知识重写你的 JavaScript 服务器。

第三章: TypeScript  介绍了 TypeScript,这是一种 JavaScript 的超集,并强调了现代全栈开发如何从中受益。我们讨论了 JavaScript 的不足和陷阱,以及如何通过类型推断有效利用 TypeScript 的类型系统。最后,你将通过类型注解、自定义类型和接口重构你的 JavaScript 服务器。

第四章: React  讨论了 React,这是最常用的用户界面组件库之一。你将看到它的组件如何简化全栈开发,并学习如何使用其 JSX 元素、虚拟 DOM 和 Hooks。然后,你将使用 React 为你的 Express.js 服务器添加一个响应式用户界面。

第五章: Next.js  重点介绍了 Next.js,这是一个基于 React 构建的领先 Web 应用框架。你将使用 Next.js 的基于文件的路由创建页面和自定义 API 路由,之后学习在框架内渲染页面的不同方式。最后,你将进行一个练习,将 Express.js 服务器迁移到 Next.js。

第六章: REST 和 GraphQL APIs  教你关于 API 的所有知识,API 是什么,以及如何在全栈 Web 开发中使用它们。我们探索了两种类型的 API:REST 和 GraphQL。最后,你将通过向你的 Next.js 全栈应用添加 Apollo GraphQL 服务器来完成本章内容。

第七章: MongoDB 和 Mongoose  讨论了传统关系型数据库和非关系型数据库(如 MongoDB)之间的区别。你将把 Mongoose 对象数据建模工具添加到你的技术栈中,以简化数据库操作。接着,你将把 GraphQL API 连接到你自己的 MongoDB 数据库。

第八章: 使用 Jest 框架进行测试  解释了自动化测试和测试驱动开发对全栈开发的重要性。我们探索了不同类型的测试、常见的测试模式以及测试双胞胎、存根、假对象和模拟的概念。最后,你将使用 Jest 框架向你的 Next.js 应用添加一些基本的快照测试。

第九章:OAuth 授权  讨论身份验证和授权,以及全栈开发人员如何通过集成第三方服务使用 OAuth 协议来处理这些任务。我们将详细讲解此授权流程及其组件。你将通过命令行运行一次完整的 OAuth 交互,深入探讨每个步骤。

第十章:使用 Docker 进行容器化  介绍了如何使用 Docker 部署应用程序。我们首先讲解微服务架构的概念,然后介绍 Docker 生态系统的所有相关组件:主机、Docker 守护进程、Dockerfile、镜像、容器、卷和 Docker Compose。最后,你将通过将应用程序拆分为自包含的微服务来完成这部分内容。

在第二部分中,你将运用新学到的知识构建一个 Web 应用程序,应用第一部分中介绍的概念、工具和框架。Food Finder 应用是一个位置搜索服务,允许用户通过 GitHub 帐户登录并维护一个想要访问的地点愿望清单。

第十一章:设置 Docker 环境  通过运用你对 Docker 和容器化的知识,创建你的 Food Finder 应用的基础。你将使用 Docker Compose 将应用开发与本地系统解耦,然后添加一个作为独立服务的 MongoDB 服务器。

第十二章:构建中间件  创建 Food Finder 应用的第一个中间件部分。在这里,你将连接 Mongoose 到 MongoDB 服务,并创建其架构、模型、服务和自定义类型。有了这些组件,你将能够从数据库中创建、读取、更新和删除数据。

第十三章:构建 GraphQL API  运用你对 GraphQL 的知识,在 Food Finder 应用中添加一个 Apollo GraphQL 服务器,然后实现一个公共的 GraphQL API。你将能够使用 Apollo 沙盒来读取和更新 MongoDB 服务器上的数据。

第十四章:构建前端  使用 React 组件和 Next.js 框架构建 Food Finder 应用的前端。在这一阶段,你将实现一个完整的现代全栈应用程序,通过自定义中间件从数据库读取数据并将数据呈现到应用的前端。

第十五章:添加 OAuth  向你的应用程序添加 OAuth 流程,让访客能够登录并维护个人地点愿望清单。你将使用next-auth包从 Auth.js 中添加通过 GitHub 的登录选项。

第十六章:在 Docker 中运行自动化测试  使用 Jest 设置自动化快照测试,并配置一个新的服务来自动运行这些测试。

然后,在附录中,你将获得关于 TypeScript 编译器选项和最常见 Jest 匹配器的详细信息。此外,你还将运用你新获得的知识,探索并理解 Next.js 的现代应用程序目录方法。

附录 A:TypeScript 编译器选项展示了最常见的 TypeScript 编译器(TSC)选项,以便你可以根据个人喜好自定义自己的 TypeScript 项目。

附录 B:Next.js 应用程序目录探索了 Next.js 在版本 13 中引入的使用app目录的新路由模式。你可以选择继续使用传统的页面方法(详见第五章),或者在即将到来的项目中使用现代的app目录。

附录 C:常见的匹配器展示了用于使用 Jest 和 Jest DOM 测试应用程序的最常见匹配器。

全栈应用程序的各个部分

在本书中,我们将讨论应用程序的各个部分。本节为你提供一个速成课程,讲解当我们使用术语前端中间件后端时的含义。

前端

前端是网站或 Web 应用程序的面向用户部分。它运行在客户端,通常是一个 Web 浏览器。你可以将其视为 Web 应用程序的“前台”。例如,在 https://www.google.com上,前端是一个带有简单搜索栏的页面,当然,前端开发可能比这更复杂;看看谷歌的搜索结果页面或你最近访问的最后一个网站的界面。

前端开发者专注于用户参与、体验和界面。他们依赖 HTML 来创建网站界面的元素,CSS 用于样式,JavaScript 用于用户交互,以及 Next.js 等框架来将所有内容结合在一起。

中间件

中间件连接应用程序的前端和后端,并执行所有任务,例如与第三方服务的集成、数据的传输和更新。你可以将其看作是公司楼层上的员工。

作为全栈开发者,我们经常为路由应用程序编写中间件,这意味着为特定 URL 提供正确的数据,处理数据库连接并执行授权。例如,在 https://www.google.com上,中间件会向服务器请求登录页面的 HTML。然后,另一部分中间件会检查用户是否已登录,如果已登录,应该显示哪些个人数据。与此同时,第三部分中间件会整合这些数据流中的信息,然后以正确的 HTML 响应服务器的请求。

一个全栈应用程序的中间件的一个重要部分是它的 API 层,该层公开了应用程序的 API。通常,API 是用来连接两台机器的代码。通常,API 让前端代码(或第三方)访问应用程序的后端。由 JavaScript 驱动的开发依赖于两种主要的架构框架来创建 API:REST 和 GraphQL,二者在第六章中有详细介绍。

你可以使用任何编程语言来编写中间件。大多数全栈开发者使用现代 JavaScript 或 TypeScript,但他们也可以选择使用 PHP、Ruby 或 Go。

后端

后端是 Web 应用程序中看不见的部分。在一个由 JavaScript 驱动的应用程序中,后端运行在服务器上,通常是 Express.js,尽管其他人可能使用 Apache 或 NGINX。你可以把它看作是 Web 应用程序的“后台”部分。

更具体地说,后端处理涉及应用程序数据的任何操作。它对存储在数据库中的值执行创建、读取、更新和删除(CRUD)操作,并通过中间件的 API 层返回用户请求的数据集。对于https://www.google.com,后端是用来搜索数据库中你在前端输入的关键词的代码,这些关键词通过中间件传递给后端。中间件将这些搜索结果与其他相关信息结合起来。然后,用户将在前端呈现的搜索结果页面中看到这些内容。

后端开发可以使用任何编程语言进行。全栈开发者通常使用现代 JavaScript 或 TypeScript。其他选择包括 PHP、Ruby、Elixir、Python、Java 以及像 Symfony、Ruby on Rails、Phoenix 和 Django 这样的框架。

JavaScript 和全栈开发的简史

所有开发者都应该理解他们所使用工具的背景。在我们开始开发之前,让我们先了解一点历史。

全栈开发者职位是与 JavaScript 一同发展的,JavaScript 最初只不过是一个在用户浏览器中运行的脚本语言。开发者使用它来为网站添加元素,如手风琴、弹出菜单和覆盖层,这些元素会根据用户的行为立即响应,而无需向应用程序的服务器发出请求。

直到 2000 年代末,大多数 JavaScript 库的设计都是为了提供一致的接口,以处理供应商特定的特殊情况。通常,JavaScript 引擎的速度较慢,特别是在与 HTML 交互、更新或修改时。因此,JavaScript 曾被视为一个有些怪异的前端脚本语言,并且不被后端开发者所看好。

几个项目曾试图普及 JavaScript 在后端的应用,但直到 2009 年 Node.js 发布之前,这些尝试都没有取得显著进展。Node.js(在第一章中讨论)是一个用于开发后端的 JavaScript 工具。随后,Node.js 的包管理器 npm 构建了全栈 JavaScript 开发所需的生态系统。

这个生态系统包括了一系列用于处理数据库、构建用户界面和编写服务器端代码的 JavaScript 库(我们将在本书中探讨其中的许多)。这些新工具使得开发人员可以在客户端和服务器端可靠地使用 JavaScript。特别重要的是,谷歌于 2010 年发布了 Angular 框架,Meta(当时被称为 Facebook)于 2013 年发布了 React。互联网巨头们致力于构建 JavaScript 工具,使得全栈 Web 开发成为一个备受追捧的职位。

设置

在本书中,您将编写代码并运行命令行工具。您可以使用任何开发环境,但以下是一些指导建议。

目前最常见的代码编辑器是 Visual Studio Code,您可以从https://code.visualstudio.com下载。它是微软的开源编辑器,适用于 Windows、macOS 和 Linux,且免费。此外,您可以通过大量第三方插件扩展和配置它,并根据个人喜好调整外观。不过,如果您习惯使用其他编辑器,比如 Vim 或 Emacs,您也可以继续使用。本书并不要求使用特定的工具。

根据您的操作系统,默认的命令行程序可能是命令提示符(Windows)或终端(macOS 和 Linux)。这些程序在执行诸如创建、修改和列出目录内容等任务时,使用略有不同的语法。本书展示的是 Linux 和 macOS 版本的命令。如果您使用的是 Windows,您需要根据操作系统调整命令。例如,Windows 使用dir来列出当前目录中的文件和文件夹,而不是 Linux 中的ls。微软的官方命令行参考文档列出了所有可用的命令,您可以在这里查看: https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/windows-commands#command-line-reference-a-z

本书中与操作系统相关的最显著区别是多行 cURL 命令中用于换行的转义字符。这个转义字符在 macOS 中是\,而在 Windows 中是^。我们将在第六章中指出这些区别,当我们首次使用 cURL 时。

你可以从https://www.usemodernfullstack.dev/downloads下载本书第一部分的代码清单以及 Food Finder 应用程序的完整源代码。

第一部分 技术架构

第一章:1 NODE.JS

Node.js 是一个开源的运行时环境,用于在 Web 浏览器外执行 JavaScript 代码。例如,你可以将它用作脚本语言,执行各种任务,如删除和移动文件、在服务器端记录数据,甚至创建自己的 Web 服务器(如我们在本章练习中将要做的那样)。

学会使用 Node.js 其实并不是了解单个命令或包的使用,因为它基于标准的 JavaScript,你可以参考文档来了解其语法和参数。相反,所有开发者应该努力理解 Node.js 生态系统,并利用它来发挥自己的优势。本章将向你介绍这一点。

安装 Node.js

首先通过在命令行中运行 node 命令来检查本地机器上是否已经安装了 Node.js。版本标志 (-v) 应该返回当前的 Node.js 版本:

$ **node -v**

如果你看到带有版本号的输出,说明 Node.js 已经安装。如果没有,或者版本低于 https://nodejs.org 上列出的当前推荐稳定版本,你应该安装这个稳定版本。

若要在本地安装 Node.js,请访问 https://nodejs.org/en/download,并选择适合你操作系统的安装程序。我建议安装 Node.js 的长期支持 (LTS) 版本,因为许多 Node.js 模块要求使用该版本。运行 Node.js LTS 和 npm 的安装包,然后再次检查版本号。它应该与刚才安装的版本相匹配。

接下来,我们将回顾 Node.js 运行时环境的基本命令和功能。如果你不想安装 Node.js,你可以在在线代码编辑器中运行 Node.js 命令行示例和 JavaScript 代码,地址是 https://codesandbox.io/s/newhttps://stackblitz.com

使用 npm

Node.js 的默认包管理器是 npm。你可以在这里找到各种任务的模块,这些模块来自于在线注册表 https://www.npmjs.com。通过在命令行中运行以下命令,确认你的本地机器上是否安装了 npm:

$ **npm -v**

如果没有列出版本,或者版本低于当前发布的版本,请安装最新的 Node.js LTS 版本,包括 npm。

请注意,https://www.npmjs.com 上没有审核过程或质量控制。任何人都可以发布包,网站依赖社区报告任何恶意或损坏的包。

运行以下命令会显示可用命令列表:

$ **npm**

注意

npm 的最流行替代品是 yarn,它也使用 <wbr>www<wbr>.npmjs<wbr>.com 注册表,并且与 npm 完全兼容。

package.json 文件

package.json 文件是每个基于 Node.js 的项目中的关键元素。虽然 node_modules 文件夹包含实际的代码,但 package.json 文件保存了关于项目的所有元数据。它位于项目的根目录,必须包含项目的名称和版本;此外,它还可以包含可选数据,例如项目描述、许可证、脚本以及更多详细信息。

让我们来看看你将在 练习 1 中创建的网页服务器的 package.json 文件,位于第 13 页。它应该与 列表 1-1 中展示的类似。

{
    "name": "sample-express",
    "version": "1.0.0",
    "description": "sample express server",
    "main": "index.js",
    "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1",
        "run": "node index.js"
    },
    "author": "",
    "license": "ISC",
    "dependencies": {
        "express":"⁴.18.2"
    }
} 

列表 1-1:用于 Express.js 服务器项目的 package.json 文件,见 练习 1

package.json 文件包含其他人需要在他们的机器上安装所需模块并运行应用程序的所有信息。因此,你不需要将 node_modules 文件夹包含在代码库中,这可以最小化代码库的大小。我们来详细看看 package.json 文件。

必填字段

package.json 文件必须包含 name 字段和 version 字段。所有其他字段都是可选的。name 字段包含包的名称,必须是一个小写字母单词,但可以包含连字符和下划线。

version 字段必须遵循语义版本控制指南,建议使用以下格式:major.minor.patch;例如,1.2.3。我们称之为语义版本控制,因为每个数字都有特定含义。major 版本引入不兼容的 API 更改。通常,切换到另一个 major 版本时要非常小心,因为你无法预期你的应用程序能完美运行。minor 版本更改以向后兼容的方式添加新功能,因此一般不会对你的应用程序造成问题。patch 版本修复向后兼容的 bug,且你应该始终保持它的最新版本。

注意

你可以阅读更多关于语义版本控制和如何定义不同版本范围的信息,访问 <wbr>semver<wbr>.org

依赖关系

最重要的可选字段指定了依赖关系和开发依赖关系。dependencies 字段列出了运行项目所需的所有依赖项及其所需的版本范围,遵循语义化版本控制语法。默认情况下,npm 只要求指定主版本,并保持次版本和修订版本范围的灵活性。这样,npm 就能始终使用最新的兼容版本初始化你的项目。

这些依赖项是你打包应用的一部分。当你在新机器上安装一个项目时,package.json 文件中列出的所有依赖项将被安装,并放置在 node_modules 文件夹中,紧邻 package.json 文件。

你的应用可能需要各种依赖项,例如框架和辅助模块。例如,我们将在第二部分中构建的 Food Finder 应用必须至少包含 Next.js 作为单页应用框架,以及 Mongoose 和 MongoDB 作为数据库层。

开发依赖关系

devDependencies 字段列出了开发项目所需的所有依赖项及其版本。再次强调,只有主版本是固定的。这些依赖项仅在开发时需要,并不用于运行应用程序。因此,它们会被打包脚本忽略,并不包含在部署的应用中。当你在新机器上安装项目时,package.json 文件中列出的所有开发依赖项将被安装并放置在 node_modules 文件夹中,紧邻 package.json 文件。对于我们的 Food Finder 应用,我们的开发依赖项将包括 TypeScript 的类型定义。其他常见的依赖项包括测试框架、代码检查工具和构建工具,例如 webpack 和 Babel。

package-lock.json 文件

npm 包管理器会为每个项目自动生成 package-lock.json 文件。这个锁文件解决了使用语义化版本控制来管理依赖时所引入的问题。如前所述,npm 默认只定义主版本,并使用最新的次版本和修订版本。虽然这样可以确保应用包含最新的 bug 修复,但它也引入了一个新问题:没有确切的版本时,构建无法复现。由于 npm 注册表没有质量控制,即使是修订版或次版本更新,也可能引入不兼容的 API 更改,而这种更改本应该是主版本更新。因此,版本之间的轻微偏差可能导致构建失败。

package-lock.json 文件通过跟踪每个包及其依赖项的确切版本来解决这个问题。这个文件通常相当大,但它列出的与你将在本章末尾创建的 Web 服务器相关的条目将类似于 列表 1-2。

{
    "name": "sample-express",
    "lockfileVersion": 2,
    "requires": true,
    "packages": {
        "": {
            "dependencies": {
                "express": "⁴.18.2"
            }
        },
        "node_modules/accepts": {
            "version": "1.3.8",
            "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
            "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEhosdQ==",
            `--snip--`
        },
        `--snip--`
        "node_modules/express": {
            "version": "4.18.2",
            "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
            "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEI==",
            "dependencies": {
                "accepts": "~1.3.8",
                `--snip--`
                "vary": "~1.1.2"
            },
            "engines": {
                "node": ">= 0.10.0"
            }
        },
        `--snip--`
        "vary": {
            "version": "1.1.2",
            "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
            "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+bfhskkh=="
        }
    }
} 

列表 1-2:练习 1 的 package-lock.json 文件

锁定文件包含对项目的引用,并列出来自相应 package.json 文件的信息。然后,它列出所有项目的依赖项;对我们来说,唯一的依赖项是 Express.js,并且版本是固定的。(我们将在 练习 1 中讲解 Express.js。)此外,该文件列出了正在使用的 Express.js 版本的所有依赖项,在本例中是 acceptvary 包。存储的工件的 SHA 哈希使得 npm 在下载资源后能够验证其完整性。

现在,所有模块版本已被锁定,每次运行 npm install 命令都会创建与原始设置完全相同的克隆。像 package.json 一样,package-lock.json 文件也是代码仓库的一部分。

创建项目

让我们来看看日常工作中最重要的命令,按照你在创建和维护项目时逻辑上会使用它们的顺序。在执行这些步骤之后,你将拥有一个 package.json 文件和一个包含已安装包 Express.js 的生产就绪项目文件夹。

初始化新模块或项目

要启动一个新项目,运行 npm init,它会初始化一个新模块。这将启动一个交互式向导,您可以根据自己的输入填写项目的 package.json 文件:

$ **mkdir sample-express**
$ **cd sample-express**
$ **npm init**
This utility will walk you through creating a package.json file.
It only covers the most common items, and tries to guess sensible defaults.
`--snip--`
Is this OK? **(yes)** 

在每个项目的开始,你需要在一个空文件夹中初始化一个新的 Node.js 设置(这里通过 mkdir sample-express 创建)并使用 npm init。为了简便起见,在这里保持默认建议。助手将在你的项目文件夹中创建一个基本的 package.json 文件。它应该类似于 列表 1-3。

{
    "name": " sample-express",
    "version": "1.0.0",
    "description": "",
    "main": "index.js",
    "scripts": {
      "test": "echo \"Error: no test specified\" && exit 1"
    },
    "author": "",
    "license": "ISC"
} 

列表 1-3:默认的 package.json 文件

当我们将这个文件与 列表 1-1 中显示的文件进行比较时,我们可以看到它们非常相似,除了依赖项和开发依赖项不同。准备好 package.json 文件后,我们现在可以使用 npm install 安装这些依赖项。

安装依赖项

Node.js 提供了用于执行任务的模块,例如访问文件系统的输入输出、使用网络协议(如 DNS、HTTP、TCP、TLS/SSL 和 UDP)以及处理二进制数据。它还提供了加密模块、用于处理数据流的接口等。

运行 npm install 会下载并将特定的包放置在 node_modules 文件夹中,紧邻你的 package.json 文件,并将其添加到 package.json 中的依赖列表中。每当你需要添加运行应用程序所需的新模块时,应使用此命令。

假设你想创建一个基于 Express.js 的新服务器。你需要从 https://npmjs.com 安装 Express.js 包。在这里,我们安装一个特定版本,但如果要安装最新版本,可以省略版本号,改用 npm install express:

$ **npm install express@4.18.2**
added 57 packages, and audited 58 packages in 1s
found 0 vulnerabilities 

现在,node_modules 文件夹包含一个 express 文件夹和其他一些依赖文件夹。此外,Express.js 被列为 package.json 中的一个依赖项,如 清单 1-4 所示。

{
    "name": " sample-express",
    "version": "1.0.0",
    "description": "",
    "main": "index.js",
    "scripts": {
      "test": "echo \"Error: no test specified\" && exit 1"
    },
    "author": "",
    "license": "ISC",
    "dependencies": {
      "express": "⁴.18.2"
    }
} 

清单 1-4:默认的 package.json 文件,其中 Express.js 是一个依赖项

我们已成功将 Express.js 作为一个依赖项添加。

安装开发依赖

假设你现在想使用一个叫做 karma 的包来进行服务器的端到端测试。与 Express.js 不同,这个包仅在开发过程中使用,并且不需要在实际应用运行时使用。

在这种情况下,你应该运行 npm install --save-dev package 来下载该包,并将其添加到本地 package.json 文件中的 devDependencies 列表中:

$ **npm install --save-dev karma@5.0.0**
added 128 packages, and audited 186 packages in 3m
9 vulnerabilities (1 moderate, 4 high, 4 critical)

To address issues that do not require attention, run:
  npm audit fix

To address all issues (including breaking changes), run:
  npm audit fix --force

Run `npm audit` for details. 

请注意,在安装 karma 包后,npm 表示该版本存在已知漏洞。尽管如此,它仍被添加到 node_modules 文件夹,并作为 devDependency 列出在 package.json 中。稍后我们将按照建议修复这些问题。

审核 package.json 文件

在安装过程中,npm 提示 karma 存在一个漏洞,我们来验证一下。npm audit 命令会检查本地的 package.json 文件是否有已知的漏洞:

$ **npm audit**

# npm audit report
`--snip--`
karma  <=6.3.15
Severity: high
Open redirect in karma - https://github.com/advisories/GHSA-rc3x-jf5g-xvc5
Cross-site Scripting in karma - https://github.com/advisories/GHSA-7x7c-qm48-pq9c
Depends on vulnerable versions of log4js
Depends on vulnerable versions of ua-parser-js
fix available via `npm audit fix --force`
Will install karma@6.4.1, which is a breaking change
`--snip--`
9 vulnerabilities (1 moderate, 4 high, 4 critical)

To address issues that do not require attention, run:
  npm audit fix

To address all issues (including breaking changes), run:
  npm audit fix --force 

运行该命令后,会返回一个关于每个问题包的版本和严重性详细报告,以及当前安装的 Node.js 模块中所有问题的总结。

npm 包管理器还表示,问题可以通过npm audit fix自动修复。可惜的是,它警告我们最新的karma版本存在破坏性变更。为了适应这些变化,我们需要使用--force标志。我建议每隔几个月使用一次npm audit,并结合使用npm update,以避免使用过时的依赖并造成安全风险:

$ **npm audit fix --force**
added 13 packages, removed 41 packages, changed 27 packages, and audited 158 packages in 5s 

现在我们看到package.json中的devDependencies列表已经包含了最新的karma版本,并且再次运行npm audit报告显示已安装的软件包没有已知漏洞。

清理 node_modules 文件夹

运行npm prune会检查本地package.json文件,将其与本地node_modules文件夹进行比较,并移除所有不必要的包。你应该在开发过程中使用它,尤其是在添加或移除包后,或者进行常规清理工作时。

让我们检查一下我们刚刚执行的审计是否安装了不必要的包:

$ **npm prune**
up to date, audited 136 packages in 1s

found 0 vulnerabilities 

输出看起来没问题;我们的包没有问题。

更新所有包

运行npm update会将所有已安装的包更新到最新的可接受版本。你应该经常使用此命令,以避免过时的依赖和安全风险:

$ **npm update**
added 1 package, removed 1 package, changed 1 package, and audited 158 packages in 8s

found 0 vulnerabilities 

如你所见,npm update会显示更新摘要。

移除依赖

运行npm uninstall package会从本地node_modules文件夹和package.json文件中移除该包及其依赖项。你应该在删除不再需要的模块时使用此命令。比如,你决定不再需要与karma的端到端测试:

$ **npm uninstall karma**
removed 71 packages, and audited 138 packages in 3s

found 0 vulnerabilities 

该命令的输出显示了对node_modules文件夹所做的更改。该软件包也已从package.json中移除。

安装依赖

运行 npm install 会从 npm 仓库下载所有依赖项和 devDependencies,并将它们放置在 node_modules 文件夹中。使用此命令可以在新机器上安装现有项目。例如,要在新文件夹中安装 Express.js 项目的副本,您可以创建一个新的空文件夹,只将 package.jsonpackage-lock.json 文件复制到其中。然后,您可以在该文件夹中运行 npm install 命令:

$ **npm install**
added 137 packages, and audited 138 packages in 3s

found 0 vulnerabilities 

每当您克隆仓库或从 package.json 文件创建新项目时,运行 npm install。与所有以前的命令一样,npm 会显示一个状态报告,列出任何漏洞。

仅使用 npx 执行一次脚本

当您安装 Node.js 时,您也安装了 npx,它代表 node package execute。该工具使您能够在不预先安装的情况下执行注册表中的任何包。当您只需要执行某些代码一次时,这非常有用。例如,您可能会使用一个脚手架脚本来初始化一个项目,但它既不是依赖项,也不是开发依赖项。

npx 工具通过检查您尝试运行的可执行文件是否通过 $PATH 环境变量或本地项目的二进制文件可用来工作。如果不可用,npx 会将包安装到中央缓存中,而不是您的本地 node_modules 文件夹中。假设您想检查包 JSON 是否有语法错误。为此,您可以使用 jsonlint 包。由于该包既不需要运行项目,也不是您开发过程的一部分,因此您不希望将其安装到 node_modules 文件夹中:

$ **npx jsonlint package.json**
Need to install the following packages:
  jsonlint
Ok to proceed? (y) **y**
{
    "name": " sample-express",
    "version": "1.0.0",
    "description": "",
    "main": "index.js",
    "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1"
    },
    "author": "",
    "license": "ISC",
    "dependencies": {
        "express": "⁴.18.2"
    }
} 

这会调用 jsonlint 来通过 npx 验证我们的 package.json 文件。首先,npx 会将包安装到全局缓存文件夹中,然后运行 jsonlint。它会打印我们的 package.json 文件内容,并报告没有错误。检查您的 node_modules 文件夹;jsonlint 不应该被安装。然而,在随后的每次调用 npx 时,您会发现 jsonlint 可用。

练习 1:构建一个“Hello World” Express.js 服务器

Express.js 是一个免费且开源的后端框架,建立在 Node.js 之上。它旨在构建 web 应用程序和 API,是 Node.js 生态系统中事实上的标准服务器框架,也是全栈 web 开发的基础。

Express.js 提供了 HTTP 服务器常用的中间件,用于任务如缓存、内容协商、Cookie 处理、跨域请求处理、重定向等。

注意

Next.js 使用自己内置的服务器,该服务器大量借鉴了 Express.js。在本书的第二部分中,你将构建一个“食物查找器”应用程序,Next.js 将成为你所使用的中间件的基础。由于 Next.js 为你抽象了这个中间件,你将不会直接与服务器进行交互。

让我们构建一个基于 Express.js 的简单 Node.js 服务器,以练习你的 Node.js 技能。

设置

如果你在跟随本章的过程中已经创建了sample-express文件夹和package.json文件,那么可以跳过此设置。否则,创建并切换到一个名为sample-express的新文件夹。然后,在命令行中运行 npm init 来初始化一个新的 Node.js 项目。交互式向导会要求你提供一些细节,例如应用程序的名称和版本。现在可以接受默认设置。

接下来,你需要使用 Express.js 包作为服务器的基础。运行 npm install express@4 来安装主要版本 4 的最新发布版。你会看到package.json文件现在将express作为依赖项。

编写服务器代码

sample-express文件夹中创建一个index.js文件,并添加列表 1-5 中的代码。

const express = require('express');
const server = express();
const port = 3000;

server.get('/hello', function (req, res) {
    res.send('Hello World!');
});

server.listen(port, function () {
    console.log('Listening on ' + port);
}); 

列表 1-5:一个基本的 Express.js 服务器

首先,我们将express包加载到文件中,实例化应用程序,并定义一个常量来指定要使用的端口。然后,我们为服务器创建一个路由,使其能够响应每一个发送到/hello基本 URL 的 GET 请求,并返回 Hello World!。我们使用 Express.js 的 get 方法,并将 /hello 作为第一个参数,回调函数作为第二个参数。现在,每次发送到/hello端点的 GET 请求,服务器都会运行回调函数并返回 Hello World! 作为响应。最后,我们使用 Express.js 的 listen 方法启动 Web 服务器,并告诉它在 3000 端口监听。

从命令行启动服务器:

$ **node index.js**
Listening on 3000 

现在,在浏览器中访问http://localhost:3000/hello。你应该会看到 Hello World! 消息。恭喜你!你刚刚用 JavaScript 写了你的第一个 Node.js Web 服务器。

总结

本章教会了你如何使用 Node.js 及其模块生态系统在浏览器外运行 JavaScript 代码。你学习了如何在全栈应用中使用、添加和移除模块,掌握了 npm 命令的使用方法,以及如何读取和使用package.jsonpackage-lock.json文件。最后,你对 Express.js 进行了初步了解,它是全栈开发的事实标准服务器,并使用它通过几行代码构建了一个示例 Node.js 服务器。

本章仅仅触及了 Node.js 的表面。如果你想深入探索它的全部潜力,我推荐 W3Schools 的 Node.js 教程,网址是https://www.w3schools.com/nodejs/以及 Udemy 上免费的 ExpressJS 基础课程,网址是https://www.udemy.com/course/expressjs-fundamentals/

在下一章,你将了解 ES.Next,这是 JavaScript 的最新版本,并掌握它为开发带来的现代特性。

第二章:2 现代 JavaScript

在第一章中,你使用了基础的 JavaScript 通过 Node.js 创建了一个 Web 服务器。现在,我们将更深入地探讨语言的高级特性,以及如何有效地使用这些特性来创建全栈 Web 应用程序。

你有时会听到ES.Next这个术语,它指的是 JavaScript 的新版本。在本书中,我们使用 ES.Next 作为现代 JavaScript 及其概念的广义标签。大多数运行时环境都已经实现了这里讨论的特性。如果没有,你可以通过 Babel.js 进行转译,生成与旧版运行时兼容的 JavaScript,从而模拟新特性。

ES.Next 模块

ES.Next 模块允许你将代码分离到不同的文件中,以提高可维护性和可测试性。它们将一段逻辑封装成易于重用的代码,并且因为变量和函数的作用域仅限于模块本身,你可以在不同的模块中使用相同的变量名而不会发生冲突。(我们在“声明变量”一节中讨论了作用域的概念,见第 17 页。)

官方的 ES.Next 模块取代了各种非官方的模块格式,如 UMD 和 AMD,这些格式会通过require语句加载。例如,你在第一章中使用了require来包含 Express.js 包,供 Node.js 服务器代码使用。相反,ES.Next 模块使用export和import语句来从一个模块的作用域中导出函数,并在其他地方导入它们。换句话说,模块允许你创建函数和变量,并将它们暴露到一个新的作用域。

使用命名导出和默认导出

Next.js 导出有两种类型:命名导出默认导出。当你稍后导入它们时,这些导出使用略有不同的语法。默认导出要求你在导入时定义新的函数名称。对于命名导出,重命名是可选的,可以使用as语句完成。

被认为是最佳实践的是使用命名导出,而不是默认导出,因为命名导出为模块的功能定义了一个清晰且唯一的接口。当我们使用默认导出时,用户可能会在不同的名称下导入相同的函数。TypeScript(我们将在第三章中讨论)推荐在模块有一个明确的用途和单个导出时使用默认导出。相反,当模块导出多个项时,它推荐使用命名导出。

你应该了解默认导出的语法,这样你才能与使用它们的第三方模块一起工作。与命名导出不同,每个文件只能有一个默认导出,并由default关键字标记(列表 2-1)。

const getFoo = function () {
    return 'foo';
};

export default getFoo; 

列表 2-1:默认导出

在这个列表中,我们定义了一个匿名函数并将其存储在常量getFoo中。然后,我们使用default关键字导出常量,使其成为模块的默认导出。

你可以在文件的开头或结尾导出命名导出,使用大括号({})。列表 2-2 展示了几个命名导出。

export const getFooBar = function () {
    return 'foo bar';
};

const getBar = function () {
    return 'bar';
};

const getBaz = function () {
    return 'baz';
};

export {getBar, getBaz}; 

列表 2-2:命名导出

在这里,我们定义了一个匿名函数,将其存储在常量getFooBar中,并立即将其作为getFooBar导出。然后,我们定义了另外两个匿名函数,并将它们作为命名导出在大括号中导出。

导入模块

导入一个 ES.Next 模块的语法取决于你创建的导出类型。命名导出需要使用大括号导入,而默认导出则不需要。在列表 2-3 中,我们通过使用import语句并后跟分配给它的本地名称来导入默认导出getFoo。最后,我们通过引用包含代码的文件来结束导入。

import getFoo from "default.js";

列表 2-3:导入默认导出

我们在列表 2-4 中遵循了类似的模式来进行命名导出,唯一不同的是我们需要在大括号内引用原始函数名。要在本地重命名函数,我们需要明确地使用as语句,通常没有必要这样做。

import {getFooBar, getBar, getBaz} from "named.js";

列表 2-4:导入命名导出

现在,你可以在代码中使用导入的函数,因为它们在导入的作用域内是可用的。

声明变量

JavaScript 提供了三种不同的声明变量的方式:var、let 和 const。本节讨论了它们的使用场景。通常,你会被建议避免使用var,因为它“过时了”。你可以放心,它并没有过时,你必须理解这三种变量声明方式,以便选择适合工作的工具。

这些变量在它们的 作用域 上有所不同,作用域定义了我们可以访问和使用它们的代码区域。JavaScript 有多个级别的作用域:全局作用域、模块作用域、函数作用域和块作用域。适用于任何用花括号括起来的代码块的 作用域是作用域的最小单元。每次你使用花括号时,你都会创建一个新的块作用域。相比之下,当你定义一个函数时,你会创建一个 函数 作用域。该作用域仅限于特定函数内部的代码区域。模块 作用域仅适用于特定模块,而 全局 作用域适用于整个程序。定义在全局作用域中的变量在代码的任何部分都是可用的。

如你在以下代码清单中看到的,一个变量在其所在的作用域及其所有子作用域中始终可用。因此,你应记住,例如,一个函数作用域可以包含多个块作用域。同样的变量名可以在一个程序中定义两次,只要每个变量出现在不同的作用域中。

提升的变量

传统的 JavaScript 使用 var 关键字声明变量。这些变量的作用域是当前的执行上下文(通常是封闭的函数)。如果在任何函数外部声明,则变量的作用域是全局的,并且该变量会在全局对象上创建一个属性。

与所有其他变量不同,运行时环境会在执行时将 var 的声明提升到作用域的顶部。因此,你可以在代码中先调用这些变量再定义它们。清单 2-5 展示了提升的简短示例。

function scope() {
    foo = 1;
    var foo;
} 

清单 2-5:在定义变量之前使用提升的变量

在这个代码清单中,我们在下一行声明变量之前给变量赋了一个值。在像 Java 和 C 这样的语言中,我们不能在声明之前使用变量,任何尝试这么做都会抛出错误。然而,由于 JavaScript 中的提升机制,解析器会将所有使用 var 关键字定义的变量声明提升到作用域的顶部。因此,代码等价于 清单 2-6 中的代码。

function scope() {
    var foo;
    foo = 1;
} 

清单 2-6:在使用变量之前定义变量

由于提升机制,块作用域不适用于使用 var 关键字声明的变量。它们总是被提升。为了说明这一点,看看 清单 2-7,我们在其中声明了一个全局变量 globalVar、一个函数作用域内的变量 foo 和一个块作用域内的变量 bar,所有这些变量都使用了 var 关键字。

var globalVar = "global";
function scope() {
    var foo = "1";
    if (true) {
        var bar = "2";
    }
    console.log(globalVar);
    console.log(window.globalVar);
    console.log(foo);
    console.log(bar);
}
scope(); 

示例 2-7:var的作用域

我们运行scope函数,并看到globalVar和window.globalVar是相同的;解析器将foo和bar这两个变量提升到函数作用域的顶部。因此,变量bar可以在块作用域外使用,且函数作用域会将这两个变量的值1和2打印到控制台。

作用域变量

现代 JavaScript 引入了let关键字来补充var。使用let,我们可以声明块级作用域的变量,且这些变量只能在声明后访问。因此,它们被认为是非提升变量。块级作用域的变量仅限于其定义所在的块语句的作用域内。与使用var定义的全局变量不同,全局的let变量不会被添加到window对象中。

让我们看看使用let声明的变量的作用域。在示例 2-8 中,我们在一个函数作用域内声明了变量foo,在一个块作用域内声明了变量bar,以及一个全局变量globalVar。

let globalVar = "global";
function scope() {
    let foo = "1";
    if (true) {
        let bar = "2";
    }
    console.log(globalVar);
    console.log(window.globalVar);
    console.log(foo);
    console.log(bar);
}
scope(); 

示例 2-8:let的作用域

每个变量仅在其各自的作用域内可用。解析器不会提升它们,因此变量bar在块语句外不可用。如果你尝试在其他地方引用它,解析器会抛出一个错误并通知你bar未定义。

我们执行该函数,与 var 代码不同,它只会将 foo 的值写入控制台。当我们尝试访问 bar 时,会收到错误 Uncaught ReferenceError: bar is not defined。对于 globalVar,我们看到控制台打印出值 global,而 window.globalVar 的值为 undefined。

常量型数据

现代 JavaScript 引入了另一个新的关键字,const,用于声明常量,如数据类型。与 let 类似,const 在全局声明时不会创建全局对象的属性。它们也被视为非提升的,因为在声明之前无法访问。

JavaScript 中的常量与许多其他语言中的常量不同,在那些语言中,常量是不可变的数据类型。在 JavaScript 中,常量仅仅是看起来不可变。实际上,它们是对其值的只读引用。因此,你不能直接为原始数据类型的变量标识符重新赋值。然而,对象或数组是非原始数据类型,因此即使使用 const,你仍然可以通过方法或直接访问属性来修改它们的值。

在 列表 2-9 中,我们用 const 关键字声明了原始数据类型和非原始数据类型,并尝试修改它们的内容。

const primitiveDataType = 1;
try {
    primitiveDataType = 2;
} catch (err) {
    console.log(err);
}

const nonPrimitiveDataType = [];
nonPrimitiveDataType.push(1);

console.log(nonPrimitiveDataType); 

列表 2-9:使用 const 声明原始类型和非原始类型

我们声明并为两个常量型数据结构赋值。当我们尝试重新为原始数据结构赋值时,运行时会抛出错误 Attempted to assign to readonly property。由于我们使用了 const,我们无法重新赋值。相反,我们可以修改 nonPrimitiveDataType 数组(这里使用 push 方法)并追加一个值,而不会遇到错误。现在该数组应包含一个值为 1 的项;因此,我们在控制台看到 [1]。

箭头函数

现代 JavaScript 引入了箭头函数,作为常规函数的替代方案。你需要了解箭头函数的两个概念。首先,它们使用与常规函数不同的语法。定义一个箭头函数要快得多,只需几个字符和一行代码。第二个重要但不那么明显的变化是它们使用被称为词法作用域的东西,使得箭头函数更加直观且不容易出错。

编写箭头函数

我们不是使用function关键字来声明箭头函数,而是使用等号和大于号组成箭头(=>)。这种语法,也叫做胖箭头,减少了冗余,使代码更简洁。因此,现代 JavaScript 在将函数作为参数传递时倾向于使用这种语法。

此外,如果一个箭头函数只有一个参数和一条语句,我们可以省略花括号和return关键字。在这种紧凑的形式中,我们称该函数为简洁函数体函数。列表 2-10 显示了传统函数的定义,随后是箭头函数的定义。

const traditional = function (x) {
    return x * x;
}

const conciseBody = x => x * x; 

列表 2-10:一个传统函数和一个具有简洁函数体语法的箭头函数

我们首先使用function关键字和常见的return语句定义一个标准函数。然后,我们以简洁的函数体语法编写相同的功能。这里我们省略了花括号,使用隐式的return语句,而不使用return关键字。

理解词法作用域

与常规函数不同,箭头函数不会将它们的作用域绑定到调用函数的对象上。相反,它们使用一种词法作用域,其中周围的作用域决定了this关键字的值。因此,在箭头函数中,this所指代的作用域始终表示定义箭头函数的对象,而不是调用该函数的对象。列表 2-11 说明了词法作用域和定义作用域的概念。

❶ this.scope = "lexical scope";

const scopeOf = {
  ❷ scope: "defining scope",

    traditional: function () {
      ❸ return this.scope;
    },

    arrow: () => {
        return this.scope;
    },
};

console.log(scopeOf.traditional());
console.log(scopeOf.arrow()); 

列表 2-11:箭头函数的作用域

我们首先在词法作用域❶上声明scope属性;这是定义对象。然后我们在定义作用域❷内部创建一个具有相同名称属性的对象。接下来,我们定义两个函数,它们都使用this来返回this.scope的值❸。

在调用它们时,您可以看到两者引用之间的区别。箭头函数中的this.scope指的是词法作用域中定义的属性,而传统函数的this指的是我们定义的第二个属性。因此,scopeOf.traditional函数输出定义作用域,而scopeOf.arrow函数输出词法作用域。

探索实际应用场景

由于函数是 JavaScript 中的头等公民,我们可以将它们作为参数传递给其他函数。在第一章中,您使用这种模式在 Node.js 中定义回调,或者之前在浏览器中处理事件处理程序时也使用过。当您将常规函数作为回调时,即使回调中的实际代码非常简单,代码也会迅速被函数语句和花括号弄得杂乱无章。箭头函数在回调中提供了干净简洁的语法。在列表 2-12 中,我们使用回调来处理数组的filter方法,并将其定义为传统函数和箭头函数。

let numbers = [-2, -1, 0, 1, 2];

let traditional = numbers.filter(function(num) {
 return num >= 0;
 }
);

let arrow = numbers.filter(num => num >= 0);

console.log(traditional);
console.log(arrow); 

列表 2-12:将胖箭头函数作为参数传递

回调的第一个版本是传统函数,而第二个实现使用了具有简洁主体语法的箭头函数。两者都返回相同的数组:[0, 1, 2]。我们看到,实际功能是从数组中删除所有负数,这只是简单地检查当前项是否大于或等于零。传统函数较难理解,因为它需要额外的字符。一旦您完全掌握箭头语法,您将提高代码的可读性,从而提升代码质量。

创建字符串

现代 JavaScript 引入了未标记和标记的模板字面量。模板字面量 是一种简单的方法,可以将变量和表达式添加到字符串中。这个字符串插值可以跨越多行,并且可以包含单引号和双引号,而无需进行转义。我们使用反引号 (`) 将模板字面量括起来,并使用美元符号 ($) 和大括号来表示模板中的变量或表达式。

未标记 模板字面量只是一个用反引号括起来的字符串。解析器会进行变量和表达式的插值并返回一个字符串。作为全栈开发者,每次需要将变量添加到字符串中或连接多个字符串时,你都会使用这种模式。清单 2-13 展示了一个未标记模板字面量的示例。它们可以跨越多行,而无需任何控制字符。

let a = 1;
let b = 2;
let string = `${a} + ${b} = ${a + b}`;
console.log(string); 

清单 2-13:一个未标记的模板字面量

解析器会替换占位符并计算模板字面量中的表达式,结果是字符串 1 + 2 = 3。

一旦一个表达式出现在模板字面量之前,它就变成了标记模板字面量。在这些情况下,函数接收模板字面量和替换值作为参数,然后对它们进行处理并返回一个值。这个返回值可以是任何原始类型或非原始类型。在清单 2-14 中,我们使用带有自定义函数的标记模板字面量来加减数字,并通过文字解释这个过程。

function tag(literal, ...values) {
 console.log("literal", literal);
 console.log("values", values);

 let result;
  switch (literal[1]) {
 case " plus ":
 result = values[0] + values[1];
 break;
 case " minus ":
 result = values[0] - values[1];
 break;
 }
 return `${values[0]}${literal[1]}${values[1]} is ${result}`;
}

let a = 1;
let b = 2;
let output = tag`What is ${a} plus ${b}?`;

console.log(output); 

清单 2-14:一个基础的标记模板字面量

在这里,解析器调用了 tag 表达式,然后将模板字面量和替换值作为参数传递给函数。该函数根据这些参数构建字符串并返回它。

让我们更深入地看看我们的代码。在我们的tag表达式中,第一个参数literal是一个数组,数组在变量处被分割,如下所示:['What is ', ' minus ', '?']。参数value也是一个数组,包含我们传递给函数的模板字面量变量的值:[1, 2]。我们使用一个简单的switch/case语句来根据字面量和数值计算结果。最后,我们返回一个新字符串,显示“问题”的答案,并在控制台上看到1 plus 2 is 3。

通过其简洁的界面来进行复杂的字符串替换,标签模板字面量为在 JavaScript 中创建领域特定语言(DSL)提供了一种优雅的方法。DSL 是一种针对特定领域中解决某一任务的语言。与之相对的是通用语言,比如 JavaScript,我们可以用它来解决各种软件相关问题。一个常见的 DSL 示例是 HTML,它用于 web 开发领域标记文本,但我们不能用它进行数学运算或读取文件内容。你将为全栈开发定义自己的 DSL,通过 GraphQL 模式。在第六章中定义第一个 GraphQL 模式时,你会明白它的 DSL 其实不过是一个标签模板字面量。

异步脚本

JavaScript 是单线程的,这意味着它一次只能执行一个任务。因此,长时间运行的任务可能会阻塞应用程序。一种简单的解决方案是异步编程,这是一种在不阻塞整个应用程序的情况下启动长时间运行任务的模式。当你的脚本等待结果时,应用程序的其余部分仍然可以响应交互或用户界面事件并执行其他计算。

避免传统的回调函数

传统的 JavaScript 使用回调函数来实现异步代码,在另一个函数返回结果后执行回调函数。你可能已经在需要对事件作出反应而不是立即执行代码时使用过回调函数。这种技术在全栈 web 开发中的一个常见使用场景是执行 Node.js 中的 I/O 操作或调用远程 API。列表 2-15 提供了一个 I/O 操作的示例。我们导入 Node.js 的fs模块,它处理文件系统操作,并使用回调函数在操作完成后立即显示文件内容。

const fs = require("fs");

const callback = (err, data) => {
    if (err) {
        return console.log("error");
    }
    console.log(`File content ${data}`);
};

fs.readFile(" file.txt", callback); 

列表 2-15:使用回调函数在 Node.js 中读取文件

读取文件是异步脚本的一个常见示例。我们不希望在等待文件内容准备好时应用程序被阻塞;但是,我们也需要在应用程序的特定部分使用文件内容。

在这里,我们创建回调函数并将其作为参数传递给 fs.readFile 函数。此函数从文件系统中读取文件,并在 I/O 操作失败或成功时执行回调。回调接收文件数据和一个可选的错误对象,暂时将其写入控制台。

回调函数是处理异步脚本的一个笨重解决方案。一旦你有多个依赖回调函数,就会陷入所谓的回调地狱,在这种情况下,每个回调函数都将另一个回调函数作为参数。结果就是一个难以阅读、容易出错的函数金字塔。现代 JavaScript 引入了 promises 和 async/await,作为回调函数的替代方案。

使用 Promises

Promises 提供了一个更简洁的语法,用于链式异步任务。与回调类似,它们会将后续任务推迟,直到前一个操作完成或失败。实际上,promises 是不立即返回结果的函数调用。相反,它们承诺在稍后的某个时间点返回结果。如果发生错误,promise 会被拒绝,而不是被解决。

Promise 对象有两个属性:状态和结果。当状态为 pending 时,结果是未定义的。然而,一旦 promise 被解决,状态会变为 fulfilled,结果会反映返回值。如果 promise 被拒绝,状态也会设置为 rejected,结果将包含一个错误对象。

Promise 遵循独特的语法规则。要使用它们,首先需要创建一个新的 Promise,或者调用返回 Promise 的函数。然后你消费这个 Promise 对象,最后进行清理。清理工作通过注册消费函数 then、catch 和 finally 来完成。当状态从待处理变为已完成时,Promise 最初会调用 then 并将返回的数据传递给它。随后的每个 then 方法都接收前一个方法的返回值,这样你就可以创建一个任务链来处理和操作这些返回值。

Promise 链仅在任务链中的某个地方发生错误时才会调用 catch 方法,或者在此 Promise 状态变化为 rejected 时也会触发它。在任何情况下,解析器会在所有 then 方法完成或 catch 方法被调用之后,调用 finally 方法。你可以使用 finally 方法来进行清理工作,比如解锁用户界面或关闭数据库连接。它类似于 try...catch 语句中的 finally 调用。

你可以在任何函数中使用 Promise。在 列表 2-16 中,我们使用原生的 fetch API 请求 JSON 数据。

function fetchData(url) {
    fetch(url)
        .then((response) => response.json())
        .then((json) => console.log(json))
        .catch((error) => {
            console.error(`Error : ${error}`);
        });
}
fetchData("https://www.usemodernfullstack.dev/api/v1/users"); 

列表 2-16:使用 Promise 获取远程数据

与文件系统上的 I/O 操作类似,网络请求是长期运行的任务,会阻塞应用程序。因此,我们应当使用异步模式来加载远程数据集。如 列表 2-15 所示,我们需要等待操作完成后,才能处理请求的数据或处理错误。

fetch API 默认是基于 Promise 的。只要 Promise 解决并且状态变为 fulfilled,接下来的 then 函数就会接收到响应对象。我们接着解析数据,并将 JSON 对象传递给下一个函数,这个函数位于 Promise 链 中,这是由点(.then)连接的函数序列。如果出现错误,Promise 会被拒绝。在这种情况下,我们会 catch 错误并将其输出到控制台。

简化异步脚本

现代 JavaScript 引入了一种新的、更简单的模式来处理异步请求:async/await 关键字。我们不再依赖链式函数,而是通过使用这些关键字编写与常规同步代码结构相似的代码。

使用这种模式时,你需要显式地将函数标记为异步函数,使用 async。然后,你使用 await,而不是基于 Promise 的语法来处理异步代码。在示例 2-17 中,我们使用本地的 fetch API 配合 async/await 来执行另一个长时间运行的任务,并从远程位置获取 JSON 数据。此代码在功能上与示例 2-16 相同,你会发现它的语法比一系列 then 调用更直观、更简洁。

async function fetchData (url) {
    try {
        const response = await fetch(url);
        const json = await response.json();
        console.log(json);
    } catch (error) {
        console.error(`Error : ${error}`);
    }
}

fetchData("https://www.usemodernfullstack.dev/api/v1/users"); 

示例 2-17:使用 async/await 获取远程数据

首先,我们将函数声明为 async,以启用函数内部的 await 关键字。然后,我们使用 await 来等待 fetch 调用的响应。与我们之前使用的 Promise 语法不同,await 简化了代码。它等待响应对象并返回它。因此,代码块看起来与常规的同步代码相似。

这种模式要求我们手动处理错误。与 Promise 不同,这里没有默认的拒绝函数。因此,我们必须将await语句包裹在try...catch块中,以优雅地处理错误状态。

遍历数组

现代 JavaScript 引入了一整套新的数组函数。对于全栈 Web 开发,最重要的一个是array.map。它允许我们对每个数组项执行一个函数,并返回一个包含修改后项的新数组,同时保留原始数组。开发人员通常在 React 中使用它来生成列表或使用数组中的数据集填充 JSX。我们将在第四章引入 React 后,你将广泛使用这种模式。

在示例 2-18 中,我们使用array.map来遍历一个数字数组,并创建一个箭头函数作为回调函数。

const original = [1,2,3,4];
const multiplied = original.map((item) => item * 10);
console.log(`original array: ${original}`);
console.log(`multiplied array: ${multiplied}`); 

示例 2-18:使用array.map来操作数组的每一项

我们遍历数组中的每一项,并将它们传递给回调函数。在这里,我们将每个元素乘以 10,然后array.map返回一个包含乘法结果的新数组。

当我们记录初始数组和返回的数组时,我们会看到原始数组仍然包含实际的、未更改的数字(1,2,3,4)。只有乘法数组包含新的、已修改的项(10,20,30,40)。

分散数组和对象

现代 JavaScript 的展开运算符用三个点(...)表示。它展开了数组的值或对象的属性,将它们扩展到各自的变量或常量中。

从技术上讲,展开运算符将其内容复制到为各自分配内存的变量中。在示例 2-19 中,我们使用展开运算符将对象的多个值赋给多个常量。在几乎所有的 React 代码中,你都会使用这种模式来访问组件属性。

let object = {fruit: "apple", color: "green"};
let {fruit, color} = {...object};

console.log(`fruit: ${fruit}, color: ${color}`);

color = "red";
console.log(`object.color: ${object.color}, color: ${color}`); 

示例 2-19:使用展开运算符将对象分散到常量中

我们首先创建一个包含两个属性的对象,fruit 和 color。然后我们使用扩展运算符将对象展开为变量,并将它们打印到控制台。变量的名称与对象属性的名称相同。然而,现在我们可以直接通过变量访问这些值,而不需要引用对象。我们在模板字面量中这样做,并看到控制台输出 fruit: apple, color: green。

另外,由于这些变量分配了自己的内存,因此它们是完整的克隆。因此,将变量 color 修改为 red 不会改变原始值:object.color 在我们将两个变量打印到控制台时仍然返回 green。

使用扩展运算符克隆数组或对象非常有用,因为 JavaScript 将数组视为对其值的引用。当你将一个数组或对象赋值给一个新变量或常量时,这仅仅是复制了对原始数组或对象的引用,并没有通过分配内存来克隆数组或对象。因此,修改副本也会改变原始值。使用扩展运算符代替等号操作符 (=) 会分配内存并不再保持对原始值的引用。因此,它是克隆数组或对象的一个极好解决方案,如列表 2-20 所示。

let originalArray = [1,2,3];
let clonedArray = [...originalArray];

clonedArray[0] = "one";
clonedArray[1] = "two";
clonedArray[2] = "three";

console.log (`originalArray: ${originalArray}, clonedArray: ${clonedArray}`); 

列表 2-20:使用扩展运算符克隆数组

在这里,我们使用扩展运算符将原始数组的值复制到克隆数组中,并修改克隆数组的项。最后,我们将这两个数组输出到控制台,看到原始数组与克隆数组有所不同。

练习 2:用现代 JavaScript 扩展 Express.js

现代 JavaScript 提供了编写干净且高效代码所需的工具。在第二部分中,你将在“食品查找器”应用程序中使用这些工具。现在,让我们将你新学到的知识应用到优化你在第一章中创建的简单 Express.js 服务器上。

编辑 package.json 文件

我们将用命名模块替换服务器的 require 调用,用于不同的路由。为此,我们需要明确指定我们的项目使用本地模块,否则,Node.js 会抛出错误。修改你的 package.json 文件,使其看起来像列表 2-21。

{
    "name": "sample-express",
    "version": "1.0.0",
    "description": "sample express server",
    "license": "ISC",
    **"type": "module",**
    "dependencies": {
        "express":"⁴.18.2",
        **"node-fetch": "³.2.6"**
    },
    "devDependencies": {}
} 

列表 2-21:修改后的 package.json 文件

添加属性type,值为module。另外,您需要安装node-fetch包,以便在某个路由中进行异步 API 调用。运行npm install node-fetch来实现。

编写具有异步代码的 ES.Next 模块

sample-express文件夹中创建文件routes.js,与index.js文件位于同一目录,并添加清单 2-22 中的代码。

import fetch from "node-fetch";

const routeHello = () => "Hello World!";

const routeAPINames = async () => {
    const url = "https://www.usemodernfullstack.dev/api/v1/users";
    let data;
    try {
        const response = await fetch(url);
        data = await response.json();
    } catch (err) {
        return err;
    }
    const names = data
        .map((item) => `id: ${item.id}, name: ${item.name}`)
        .join("<br>");
    return names;
};

export {routeHello, routeAPINames}; 

清单 2-22:routes.js 文件中的route模块

首先,我们导入fetch模块,以进行异步请求。接着我们创建第一个路由,处理我们现有的/hello端点。其行为应与之前相同;使用简洁的箭头函数语法,它返回字符串Hello World!

接下来,我们为新的/api/names端点创建一个路由。这个端点将向我们的 Web 服务器添加一个页面,显示用户名和 ID 的列表。但首先,我们显式定义一个async函数,以便我们可以在fetch调用中使用await语法。然后我们在常量中定义 API 端点,并使用另一个变量存储异步数据。我们需要在使用它们之前定义这些变量,因为await调用发生在try...catch块内,而这些变量是块作用域的。如果我们在块内定义它们,后续将无法使用。

我们调用 API 并等待响应数据,当调用成功时,我们立即将数据转换为 JSON。data变量现在保存的是一个对象数组。我们使用array.map来遍历数据,并创建我们想要显示的字符串。然后我们用换行标签(
)将所有数组项连接起来,并返回该字符串。

最后,我们根据路由名称导出这两个路由。

将模块添加到服务器

修改index.js文件,位于sample-express文件夹中,使其与清单 2-23 匹配。我们使用原生模块导入require模块,以及我们在清单 2-22 中创建的路由。

**import {routeHello, routeAPINames} from "./routes.js";**
**import express from "express";**

const server = express();
const port = 3000;

server.get("/hello", function (req, res) {
    **const response = routeHello(req, res);**
    res.send(response);
});

server.get("/api/names", **async** function (req, res) {
    let response;
    try {
        **response = await routeAPINames(req, res);**
    } catch (err) {
        console.log(err);
    }
    res.send(response);
});

server.listen(port, function () {
    console.log("Listening on " + port);
}); 

清单 2-23:使用现代 JavaScript 的基本 Express.js 服务器

首先,我们使用命名导入的语法导入 routes。然后,我们用 import 语句替代了对 express 包的 require 调用。我们之前创建的 /hello 端点调用了我们导入的路由,服务器向浏览器发送了 Hello World! 作为响应。

最后,我们创建了一个新的端点 /api/names,其中包含异步代码。因此,我们将处理程序标记为 async,并在 try...catch 块中等待路由。

从命令行启动服务器:

$ **node index.js**
Listening on 3000 

现在,在浏览器中访问 http://localhost:3000/api/names,如 图 2-1 所示。

图 2-1:浏览器从 Node.js Web 服务器接收到的响应

你应该能看到新的用户 ID 和姓名列表。

总结

本章教会了你足够的现代 JavaScript 和 ES.Next 知识,以便创建一个全栈应用程序。我们涵盖了如何使用 JavaScript 模块创建可维护的包,并导入和导出代码,声明变量和常量的不同方式,箭头函数,以及带标签和不带标签的模板字面量。我们使用 Promise 和 async/await 编写了异步代码。我们还涵盖了 array.map、展开运算符及其在全栈代码中的实用性。最后,你使用新学到的知识更新了 第一章 中的示例 Node.js 服务器,加入了现代 JavaScript 概念。

现代 JavaScript 有许多本章未涵盖的功能。根据公开的资源,我推荐 https://www.javascripttutorial.net 上的 JavaScript 教程。

在下一章,我们将介绍 TypeScript,这是 JavaScript 的超集,支持类型。

第三章:3 TYPESCRIPT

TypeScript 是一种为动态类型的 JavaScript 语言添加静态类型的编程语言。它是 JavaScript 的严格语法超集,这意味着所有现有的 JavaScript 都是有效的 TypeScript。相比之下,TypeScript 不是有效的 JavaScript,因为它提供了额外的功能。

本章将介绍使用 JavaScript 动态类型时可能遇到的陷阱,并解释 TypeScript 的静态类型如何帮助尽早捕捉错误,从而提高代码的稳定性。全栈开发者已广泛采用 TypeScript:它在最近的 Stack Overflow 开发者调查中获得了 最受欢迎 类别的亚军,并且在 State of JS 调查中有 78% 的参与者报告使用它。根据 https://builtwith.com,TypeScript 是 7% 的前 10,000 个网站的基础。

我们将涵盖构建全栈应用程序所需的基础和高级 TypeScript 概念。在此过程中,你将了解语言中最常见的配置选项、最重要的类型,以及如何以及何时使用 TypeScript 的静态类型特性。

TypeScript 的优势

TypeScript 使得使用 JavaScript 的类型系统时更少出错,因为它的编译器帮助我们立即看到类型错误。由于 JavaScript 是 动态类型,你在声明变量时不需要指定类型。只要运行时执行脚本,它就会根据使用情况检查这些类型。然而,这也意味着由于无效类型(例如,在一个存储数字而不是数组的变量上调用 array.map)导致的错误,直到运行时才会被发现,而此时整个程序将失败。

除了是动态类型的,JavaScript 还是 弱类型,这意味着它会隐式地将变量转换为最合理的值。列表 3-1 显示了从数字到字符串的隐式转换。

let string = "1";
let number = 1;
let result;

result = number + number;
console.log("value: ", result, " type of ", typeof(result));

result = number + string;
console.log("value: ", result, " type of ", typeof(result)); 

列表 3-1:JavaScript 中的隐式数字到字符串的转换

我们声明了三个变量,首先为第一个变量赋值一个字符串,第二个变量赋值一个数字,第三个变量则是使用算术加法(+)运算符将数字与自身相加的结果。然后,我们将这个加法运算的结果及其类型输出到控制台。如果你执行了这段代码,你会看到值是数字类型,并且运行时将类型指定为 number。

接下来,我们再次使用相同的运算符,但这次我们不是将数值添加到number变量中,而是将一个字符串添加到它上面。你应该看到日志中的值是11,而不是2,这是你可能预期的结果。此外,变量的赋值类型已变为string。这发生是因为运行时环境需要处理一个不可能完成的任务:将数字与字符串相加。它通过隐式将数字转换为字符串,然后使用加号运算符将两个字符串连接起来,来解决这个问题。如果没有 TypeScript,我们只有在运行代码时才会注意到这种转换。

另一个由未指定类型的变量引起的常见问题与函数和 API 契约(即代码接受和返回内容的约定)有关。当函数接受一个参数时,它隐式地期望该参数是特定类型的。但是没有 TypeScript,就无法确保参数类型正确。函数返回值也存在相同的问题。为了说明这一点,列表 3-2 将列表 3-1 中的代码进行了修改,使其使用一个函数来计算result变量的值。

let string = "1";
let number = 1;
let result;

const calculate = (a, b) => a + b;

result = calculate(number, number);
console.log("value: ", result, " type of ", typeof(result));

result = calculate(number, string);
console.log("value: ", result, " type of ", typeof(result)); 

列表 3-2:由于隐式类型转换可能返回无效类型的函数

新的calculate函数接受两个参数,a和b,并像之前一样将两个值相加。就像在列表 3-1 中一样,一旦我们将一个数字和一个字符串作为参数传递,函数就会返回一个字符串而不是数字。我们的函数可能期望两个参数都是数字,但在没有 TypeScript 的情况下,我们无法在不手动检查类型的情况下验证这一点,检查逻辑类似于列表 3-3 中的代码。

let string = "1";
let number = 1;
let result;

const calculate = (a, b) => {
    if (Number.isInteger(a) === false || Number.isInteger(b) === false) {
        throw new Error("Invalid type: a parameter is not an integer");
    } else {
        return a + b;
    }
};

result = calculate(number, number);
console.log("value: ", result, " type of ", typeof(result));

result = calculate(number, string);
console.log("value: ", result, " type of ", typeof(result)); 

列表 3-3:重构后的类型安全函数

在这里,我们使用原生的isInteger函数来验证参数a和b是否为整数。第一次调用该函数时,我们传递两个整数,应该能按预期计算出结果。第二次调用时,我们传递了一个整数和一个字符串,在编辑器中看起来没问题。然而,当我们运行代码时,运行时环境应抛出错误Invalid type: a parameter is not an integer。

手动检查类型有两个主要问题。首先,它会为我们的代码添加大量噪音,因为每次处理函数或 API 契约时,我们都需要检查所有可能的类型,例如在接收参数或返回值时。其次,我们在开发过程中无法收到问题的通知。为了查看动态类型语言中的错误,我们需要执行代码,以便解释器在运行时通知我们错误。

与动态类型语言不同,静态类型语言在代码编译时进行类型检查,而不是在运行时。TypeScript 编译器(TSC)负责这个任务;它可以在我们的代码编辑器或集成开发环境(IDE)的后台运行,并根据无效的类型使用即时报告所有错误。因此,你可以提前捕获错误,并查看每个变量的赋值类型和数据结构。

即使你没有设置即时反馈,在将代码投入使用之前运行 TSC 也是必要的,这样可以确保这些类型错误在它们可能出现之前就被捕获。检查这些错误的能力是使用 TypeScript 相较于 JavaScript 的最大优势之一。我们将在第 38 页的“类型注解”中讨论如何利用类型注解以及何时使用它们。

设置 TypeScript

TypeScript 的语法不是有效的 JavaScript,因此常规的 JavaScript 运行时环境无法执行它。要在 Node.js 或浏览器中运行 TypeScript,我们需要首先使用 TSC 将其转换为常规的、向后兼容的 JavaScript。然后,我们可以执行生成的 JavaScript。

尽管被称为编译器,TSC 实际上并不会将 TypeScript 编译成 JavaScript。相反,它会将其转译。二者的区别在于抽象级别。编译器创建低级代码,而转译器是源代码到源代码的编译器,它生成的是大致相同抽象级别的语言的等效源代码。例如,你可以将 ES.Next 转译为传统的 JavaScript,或将 Python 2 转译为 Python 3。(话虽如此,转译编译这两个术语经常被交替使用。)

除了将 TypeScript 转换为 JavaScript,TSC 还会检查你的代码是否存在类型错误,并验证函数之间的契约。转换和类型检查是独立进行的,TSC 会生成 JavaScript,无论你定义了什么类型。TypeScript 错误只是构建过程中发出的警告,它们不会阻止转换步骤,只要生成的 JavaScript 本身没有错误。

使用 TypeScript 不会影响你的代码性能。编译器会在转译步骤中移除类型和类型操作,本质上将所有 TypeScript 语法从实际的 JavaScript 代码中剥离。因此,它们不会影响运行时或最终代码的大小。尽管转译过程可能需要一些时间,但 TypeScript 的运行速度不会慢于 JavaScript。

在 Node.js 中安装

如果你使用的是 Node.js,应该在项目的 package.json 文件中使用 --save-dev 标志将 TypeScript 和所有类型定义定义为开发依赖。无需全局安装 TypeScript。只需通过以下 npm 命令将 TypeScript 直接添加到你的项目中:

$ **npm install** **-****-save-dev typescript**

TypeScript 文件使用扩展名 .ts,因为 TypeScript 是 JavaScript 的超集,所有有效的 JavaScript 代码也自动是有效的 TypeScript 代码。因此,你可以将 .js 文件重命名为 .ts,并立即在现有代码中使用静态类型检查器。

tsconfig.json 文件定义了 TSC 配置选项。我们将在下一部分介绍最重要的选项。目前,运行以下命令以使用默认配置生成一个新的文件:

$ **npx tsc -init**

TSC 会在当前路径和所有父目录中查找这个文件。可选的 -p 标志将 TypeScript 编译器直接指向该文件。然后,TSC 从该文件读取配置信息,并将其文件夹视为 TypeScript 的根目录。

注意

如果你想在没有创建专用项目的情况下跟随本章示例,你可以在在线 playground 上运行代码,访问链接: <wbr>www<wbr>.typescriptlang<wbr>.org<wbr>/play ,而不是在本地安装 TypeScript。

tsconfig.json 文件

看一下 tsconfig.json 文件的基本结构。生成的文件内容取决于你安装的 TypeScript 版本,配置项大约有 100 个,但对于大多数项目,只有以下几个是相关的:

{
    "extends": "@tsconfig/recommended/tsconfig.json",
    "compilerOptions": {},
    "include": [],
    "exclude": []
} 

extends 选项是一个字符串,用于配置指向另一个类似配置文件的路径。通常,此属性会扩展你用作模板的预设,并做一些小的、特定于项目的调整。它的工作方式类似于面向对象编程中的基于类的继承。预设会覆盖基础配置,而配置中的键值对会覆盖预设。这里的示例使用了推荐的 TypeScript 配置文件来覆盖默认设置。

compilerOptions 字段配置了转译步骤。我们在 附录 A 中列出了其选项。include 的值是一个字符串数组,指定了要包含在转译中的模式或文件名。exclude 的值是一个字符串数组,指定了要排除的模式或文件名。请记住,TSC 会将这些模式应用于通过包含的模式找到的文件列表。通常,我们不需要包含或排除文件,因为整个项目将由 TypeScript 代码组成。因此,我们可以将这些数组留空。

使用 TypeScript 进行动态反馈

大多数现代代码编辑器都支持 TypeScript,并且它们可以直接在代码中显示由 TSC 生成的错误。还记得我们用来解释 TypeScript 如何验证函数契约的 calculate 函数吗?图 3-1 是来自 Visual Studio Code 的截图,突出了类型错误并提示了解决方案。

图 3-1:在 Visual Studio Code 中使用 TypeScript

你可以使用任何代码编辑器或 IDE 来编写 TypeScript 代码,尽管建议使用像这样的能够提供动态反馈的编辑器。

类型注解

类型注解是一种可选的方式,用于明确告诉运行时环境应该期待哪些类型。你可以按照以下模式添加它们:变量: 类型。以下示例展示了一个版本的 calculate 函数,在其中我们将两个参数的类型都标注为数字:

const calculate = (a: number, b: number) => a + b;

一些开发者倾向于在代码中的每一部分都添加类型,结果是增加了噪音,导致代码可读性降低。这种反模式被称为 过度类型化,源于对类型注解如何工作的错误理解。TypeScript 编译器会根据使用情况推断类型。因此,你不需要明确地为每个元素添加类型。相反,代码编辑器会在后台运行 TSC,并利用其结果显示推断出的类型信息和编译器错误,正如你在“使用 TypeScript 进行动态反馈”部分看到的那样。

类型注解的目的是确保代码遵守 API 契约。你会遇到三种需要验证契约的情况,其中只有一种特别重要。第一种情况是在声明变量时,通常不推荐使用;第二种情况是注解函数的返回值,这是可选的;而第三种情况是注解函数的参数,这是至关重要的。接下来我们将详细探讨这三种情况。

声明变量

类型化变量的最直接方法是赋值或声明时。清单 3-4 通过显式地将变量 weather 类型化为字符串,并将其赋值为字符串值来展示这一点。

let weather: string = "sunny";

清单 3-4:在变量声明时进行过度类型化

然而,在大多数情况下,这是一种过度类型声明,因为你可以利用编译器的类型推断。清单 3-5 展示了使用类型推断的替代模式。

let weather = "sunny";

清单 3-5:根据变量的值推断类型

由于 TSC 会自动推断该变量的类型,代码编辑器应该在你悬停在变量上时显示类型信息。如果没有显式注解,我们将拥有更简洁的语法,并避免冗余的类型声明给代码带来的杂音。这提高了代码的可读性,这也是为什么通常应该避免这种过度类型化。

声明返回值

虽然 TypeScript 可以推断函数的返回类型,但你通常会希望显式地注解它。这种代码模式确保函数契约得到遵守,因为编译器会在函数定义的位置而不是使用位置显示实现错误。

在这种情况下使用类型注解的另一个原因是,作为程序员,你必须明确地定义函数的作用。通过澄清函数的输入和输出类型,你将更好地理解你实际上希望函数执行什么。清单 3-6 展示了如何在声明时声明函数的返回类型。

function getWeather(): string {
    const weather = "sunny";
    return weather;
} 

清单 3-6:在声明时类型化函数的返回值

我们创建一个返回先前声明的 weather 变量的函数。weather 变量具有推断出的字符串类型。因此,函数返回一个字符串。我们的类型定义显式地设置了函数的返回类型。

声明函数的参数

注解函数的参数是至关重要的,因为 TypeScript 在大多数情况下没有足够的信息来推断函数参数。通过类型化这些参数,你告诉编译器在调用函数并传递参数时检查类型。请查看 清单 3-7 以查看此模式的实际应用。

const weather = "sunny";
function getWeather(weather: string): string {
    return weather;
};
getWeather(weather); 

清单 3-7:类型化函数的参数

我们不再将 weather 变量声明为函数内部的常量,而是希望返回值动态变化。因此,我们修改函数以接受一个参数并立即返回它。然后,我们用 weather 常量作为参数来调用该函数。

良好的 TypeScript 代码避免冗余,并依赖于类型推断注解。它始终对函数的参数进行注解,并选择注解返回值,但从不注解局部变量。

内建类型

在使用 TypeScript 及其注解之前,你需要了解可用的类型。TypeScript 的主要优点之一是,它允许你显式声明 JavaScript 的任何原始类型。此外,TypeScript 还添加了自己的类型,其中最重要的包括 联合类型、元组、any 和 void。你还可以定义自定义类型和接口。

原始 JavaScript 类型

JavaScript 有五种原始类型:字符串、数字、布尔值、undefined 和 null。语言中的其他所有内容都被视为对象。列表 3-8 展示了使用额外的 TypeScript 类型注解定义这些原始 JavaScript 类型的语法。(记住,大多数时候,你可以依赖编译器的类型推断来处理这种情况。)

let stringType: string = "bar";
let booleanType: boolean = true;
let integerType: number = 1;
let floatType: number = 1.5;
let nullType: null = null;
let undefinedType: undefined = undefined; 

列表 3-8:带有 TypeScript 类型注解的 JavaScript 原始类型

首先,我们定义一个字符串变量和一个布尔值,并使用 TypeScript 注解。这些类型与 JavaScript 中的字符串和布尔值相同。然后,我们定义两个数字。像 JavaScript 一样,TypeScript 使用一个通用类型来表示数字,不区分整数和浮点数。最后,我们来看 TypeScript 的 null 和 undefined 类型。它们的行为与 JavaScript 中同名的原始类型相同。Null 表示一个为空或不存在的值,表示故意缺少一个值。相比之下,undefined 表示一个值的无意缺失。在 列表 3-5 中,我们没有为 undefined 类型赋值,因为我们并不知道它的值。

联合类型

你需要了解一些额外的类型,因为类型注解越精确,TSC 越能提供更多帮助。TypeScript 将 union 类型引入了 JavaScript 生态系统。联合类型是可以拥有多种数据类型的变量或参数。列表 3-9 展示了一个示例,说明了一个可以是字符串或数字的 union 类型。

let stringOrNumberUnionType: string | number;
stringOrNumberUnionType = "bar";
stringOrNumberUnionType = 1;
stringOrNumberUnionType = true; 

列表 3-9:TypeScript 的 union 类型

我们声明了一个 union 类型的变量,该变量可以包含一个字符串或一个数字,但不能包含其他类型。只要我们赋值一个布尔变量,TSC 就会抛出错误,IDE 会显示消息 Type 'boolean' is not assignable to type 'string | number'。

尽管你可能会发现 union 类型在标注可以包含不同类型的函数参数和数组时很有用,但你应该谨慎使用它们,并尽量避免使用。这是因为,在处理 union 类型的项之前,你需要进行额外的手动类型检查,否则它们可能会导致错误。例如,如果你遍历一个字符串或数字数组并尝试对所有项求和,你首先需要将所有字符串转换为数字。否则,JavaScript 会隐式地将数字转换为字符串,就像本章前面所示的那样。

数组类型

TypeScript 提供了一个通用的 array 类型,提供类似 JavaScript 数组的数组函数。然而,仔细查看数组类型注解的语法,见列表 3-10。你会注意到,数组的类型取决于数组项的类型。

let genericArray: [] = [];
genericArray.push(1);

let numberArray: number[] = [];
numberArray.push(1); 

列表 3-10:已标注类型的数组

首先,我们定义了一个没有指定项类型的数组。不幸的是,看似通用数组的定义,实际上会在后续出现问题。只要我们尝试添加一个值,TSC 就会抛出错误 Argument of type 'number' is not assignable to parameter of type 'never',因为该数组没有类型标注。

因此,我们需要为数组中的项指定类型。因此,我们创建了一个数组 numberArray,其中每个项的类型都是数字。现在,我们可以向数组中添加数值,而不会遇到错误。

对象类型

TypeScript 的内置 object 类型与 JavaScript 的 object 类型相同。尽管可以为 TSC 定义属性类型以进行类型检查,但编译器无法确保属性的顺序。尽管如此,它仍然会对它们进行类型检查,如 列表 3-11 所示。

let weatherDetail: {
    weather: string,
    zipcode: string,
    temp: number
} = {weather: "sunny", zipcode: "00000", temp: 1};
weatherDetail.weather = 2; 

列表 3-11:已类型化的对象

这里我们定义了一个包含三个属性的对象:两个属性为字符串类型,另一个属性为数字类型。然后我们尝试将一个数字赋值给被注解为字符串类型的属性 weather。现在 TSC 会提示我们错误,解释说我们赋值的类型不正确。

请注意,通常应避免像这个例子中那样内联为对象指定类型。相反,最好创建一个自定义类型,它是可重用的,能够避免代码混乱,从而提高代码的可读性。我们在第 44 页的“自定义类型和接口”中讨论如何创建和使用它们。

The tuple Type

TypeScript 为 JavaScript 添加的另一个常见类型是 tuple 类型。如 列表 3-12 所示,元组 是具有指定类型项数量的数组。TypeScript 的元组与您可能在 Python 和 C# 等编程语言中遇到的类似。

let validTuple: [string, number] = ["bar", 1];
let invalidTuple: [string, number] = [1, "bar"]; 

列表 3-12:TypeScript 的 tuple 类型

我们定义了两个元组。在这两个元组中,第一个数组项是一个字符串,第二个是一个数字。如果添加到元组中的类型、顺序或项数与元组声明不符,TSC 会抛出错误。这里,第一个赋值是可以接受的,而第二个赋值会抛出两个错误,表明类型不匹配。

The any Type

TypeScript 的 any 类型是通用的,这意味着它可以接受任何值,因此应避免使用它。正如在 列表 3-13 中所示,它接受所有值而不抛出错误,这违背了静态类型检查的目的。

let indifferent: any = true;
indifferent = 1;
indifferent = []; 

列表 3-13:TypeScript 的 any 类型

使用 any 可能看起来是一个简单的选择,且它确实很诱人作为一种逃生路径。但必须避免这样做。当你将 any 作为值传递给某个函数时,你打破了函数声明中所指定的契约,而当你使用 any 来定义契约时,实际上根本没有契约。

若要查看使用 any 类型导致问题的场景,请参见 Listing 3-14。

const calculate = (a**: any**, b**: any**)**: any** => a + b;
console.log(calculate (1,1));
console.log(calculate (**"1"**,1)); 

Listing 3-14:使用 any 类型带来的问题

我们复用了 calculate 函数,它用于加法运算。当我们传递两个数字值时,我们得到了预期的输出 2。在之前的示例中,我们将参数类型指定为数字,从而避免了使用无效类型作为参数。

然而,当我们用 any 替代数字并传递一个字符串给函数时,TSC 并不会抛出错误。JavaScript 会隐式地将数字转换为字符串并返回一个意外的值 11。我们在本章开始时的无类型版本函数中看到了这种行为。如你所见,使用 any 就等同于根本没有使用任何类型。

尽管 any 类型很方便,但它会在编程时掩盖错误,隐藏你的类型设计,使得类型检查失去意义。它还会阻止你的 IDE 显示错误和无效类型。

The void Type

TypeScript 的 void 类型与 any 类型相反:它表示没有类型。它唯一的使用场景是注释那些不应返回值的函数的返回值,如 Listing 3-15 所示。

function log(msg: string)**: void** {
    console.log(msg);
} 

Listing 3-15:TypeScript 的 void 类型

我们在这里定义的自定义日志函数向控制台传递一个参数。它没有返回任何值,因此我们将 void 作为返回类型。

要了解更多 TypeScript 类型和其他语言的重要细节,请查看 TypeScript 手册,访问 https://www.typescriptlang.org/docs/handbook/intro.html

自定义类型和接口

之前的章节已经介绍了足够的 TypeScript 内容,可以开始使用该语言。然而,了解一些更高级的概念会对你有帮助。本节将向你展示如何在 TypeScript 代码中创建自定义类型和使用未类型化的第三方库。你还将学习何时创建新类型以及何时使用自定义接口。

在使用 TypeScript 时,请记住,没有顶级导入或导出的 TypeScript 文件不是一个模块;因此,它在 全局 范围内运行。因此,它的所有声明在其他模块中都可以访问。相比之下,顶级导入或导出的 TypeScript 文件是其自己的模块,所有声明都限制在 模块 范围内,意味着它们只在此模块的范围内可用。

定义自定义类型

TypeScript 允许你使用 type 关键字定义自定义类型。自定义类型是简化代码的好方法。为了了解如何简化代码,可以再次查看 列表 3-8 中的代码,在那里你创建了一个类型化对象。现在看看 列表 3-16,它通过自定义类型定义优化了代码。你应该会发现它更简洁、更易读。

**type** WeatherDetailType **= {**
    weather: string;
    zipcode: string;
    temp?: number;
};

let weatherDetail: WeatherDetailType = {
    weather: "sunny",
    zipcode: "00000",
    temp: 30
};
const getWeatherDetail = (data: WeatherDetailType): WeatherDetailType => data; 

列表 3-16:使用 TypeScript 定义的类型化对象的自定义类型

我们使用 type 关键字创建了一个自定义类型 WeatherDetailType。请注意,整体语法类似于定义一个对象;我们使用等号(=)将定义赋值给自定义类型。

自定义类型有两个必需的属性:weather 和 zipcode。此外,它还有一个可选的 temp 属性,如问号(?)所示。现在,当我们创建 getWeatherDetail 函数时,我们可以将参数 weatherDetail 注解为类型为 WeatherDetailType 的对象。使用这种技巧,我们避免了使用内联注解,并且可以在以后重用我们的自定义类型,例如注解函数的返回类型。

定义接口

除了类型,TypeScript 还有接口。然而,类型和接口之间的区别并不明确。只要你在代码中遵循一种约定,可以自由选择使用其中之一。

一般来说,我们认为 类型 定义回答了这样一个问题:“这个数据是什么类型?”一个可能的答案是联合类型或元组。接口 是一种描述某些数据形状的方式,比如一个对象的属性。它回答了这样一个问题:“这个对象有哪些属性?”最实际的区别是,与接口不同,类型在声明后不能直接修改。有关这一区别的深入了解,请参考 TypeScript 手册

作为一种经验法则,使用接口来定义一个新对象或对象的方法。更一般来说,考虑使用接口而不是类型,因为接口提供了更精确的错误信息。一个经典的 React 接口用例是定义特定组件的属性。列表 3-17 展示了如何使用 interface 关键字创建一个新接口,替代 列表 3-16 中的类型。

**interface** WeatherProps **{**
    weather: string;
    zipcode: string;
    temp?: number;
}

const weatherComponent = (props: WeatherProps): string => props.weather; 

列表 3-17:TypeScript 函数的自定义接口

在这里,我们使用 interface 关键字来定义一个新的接口。与自定义类型的定义不同,接口定义不会使用等号将接口的属性分配给其名称。然后,我们使用自定义接口来类型化 weatherComponent 的属性对象 props,它返回一个字符串。

使用类型声明文件

要普遍使用自定义类型,你可以将它们定义在 类型声明文件 中,这些文件的扩展名是 .d.ts。与常规的 TypeScript 文件(.ts.tsx 扩展名)不同,类型声明文件不应包含任何实现代码。相反,TSC 使用这些类型定义来理解自定义类型并进行类型检查。它们不会被转译为 JavaScript,也永远不会成为执行脚本的一部分。

当你需要与外部代码库合作时,类型声明文件非常有用。通常,第三方库不是用 TypeScript 编写的,因此它们没有为其代码库提供类型声明文件。幸运的是,DefinitelyTyped 仓库在 http://definitelytyped.github.io 提供了超过 7,000 个库的类型声明文件。使用这些文件,你可以为这些库添加 TypeScript 支持。

类型声明文件被集中在 npm 的 @types 范围内。这个范围包含了来自 DefinitelyTyped 的所有声明。因此,它们易于查找,并且在你的 package.json 文件中被分组在一起。所有来自 @types 范围的类型声明文件应视为项目的开发依赖。因此,我们在 npm install 命令中使用 --save-dev 标志来添加它们。

列表 3-18 显示了一个最小化的类型声明文件示例,该文件导出了一个 API 的类型和接口。

interface WeatherQueryInterface {
    zipcode: string;
}

type WeatherDetailType = {
    weather: string;
    zipcode: string;
    temp?: number;
}; 

列表 3-18:定义自定义类型和接口

将这些定义保存在名为 custom.d.ts 的文件中,放在你的根目录下。TSC 应该会自动加载这些定义。现在,你可以在 TypeScript 模块中使用来自该文件的类型和接口。

练习 3:使用 TypeScript 扩展 Express.js

让我们运用你对 TypeScript 的新知识,重写你在练习 1 和 2 中创建的 Express.js 服务器。除了添加类型注解,我们还将通过使用自定义类型向服务器添加一个新路由。

设置

首先按照第 36 页“设置 TypeScript”中描述的步骤将 TypeScript 添加到项目中。接着,因为 Express.js 没有类型定义,运行以下命令从 DefinitelyTyped 向项目中添加类型定义:

$ **npm install --save-dev @types/express**

你的 package.json 文件现在应该如下所示:

{
    "name": "sample-express",
    "version": "1.0.0",
    "description": "sample express server",
    "license": "ISC",
    "type": "module",
    "dependencies": {
        "express": "⁴.18.2",
        "node-fetch": "³.2.6"
    },
    "devDependencies": {
        **"@types/express": "⁴.17.15",**
        **"typescript": "⁴.9.4"**
    }
} 

现在你可以为项目创建配置文件和类型声明文件了。

创建 tsconfig.json 文件

sample-express 文件夹中创建一个新的 tsconfig.json 文件,紧挨着 index.ts 文件,或者打开你之前创建的文件。然后将其内容添加或替换为以下代码:

{
    "compilerOptions": {
        "esModuleInterop": true,
        "module": "es6",
        "moduleResolution": "node",
        "target": "es6",
        "noImplicitAny": true
    }
} 

我们为我们的简单 Express.js 服务器配置 TypeScript,这只需要一些设置。我们为 TypeScript 代码使用 ES.Next 模块,并且因为我们希望在将 TypeScript 转换为 JavaScript 后仍然保留这些模块,我们将 module 和 target 设置为 es6。express 包是一个 CommonJS 模块。因此,我们需要使用 esModuleInterop 选项,并将 moduleResolution 设置为 node。最后,我们使用 noImplicitAny 选项,禁止隐式使用 any 类型,并要求显式指定类型。附录 A 更详细地描述了这些配置选项。

定义自定义类型

对于我们的服务器,我们将遵循一个简单的经验法则:每次使用一个对象时,我们应该考虑向项目中添加自定义类型或接口。如果对象是函数参数,我们将创建一个自定义接口。如果我们多次使用这个特定的对象,我们将创建一个自定义类型。

为了为这个示例项目定义自定义类型,我们在 sample-express 文件夹中创建一个名为 custom.d.ts 的文件,并添加来自 Listing 3-19 的代码。

type responseItemType = {
    id: string;
    name: string;
};

type WeatherDetailType = {
    zipcode: string;
    weather: string;
    temp?: number;
};

interface WeatherQueryInterface {
    zipcode: string;
} 

Listing 3-19:custom.d.ts 文件

我们创建了两个自定义类型和一个接口。一个定义了异步 API 调用的响应项。另一个类型和接口类似于本章前面显示的示例。它们对于我们即将创建的新天气路由是必要的。

向 routes.ts 文件添加类型注解

接下来,我们必须向服务器代码添加类型注解。将 sample-express 文件夹中的 routes.js 文件重命名为 routes.ts,以便为该文件启用 TSC。你应该立即看到错误和警告出现在编辑器中。花些时间查看这些错误,然后调整内容以匹配 Listing 3-20 中的代码。我们已将所有类型注解加粗。

import fetch from "node-fetch";

const routeHello = ()**: string** => "Hello World!";

const routeAPINames = async ()**: Promise<string>** => {
    const url = "https://www.usemodernfullstack.dev/api/v1/users";
    let data**: responseItemType[];**
    try {
        const response = await fetch(url);
        data = **(**await response.json()**) as responseItemType[];**
    } catch (err) {
        return "Error";
    }
    const names = data
        .map((item) => `id: ${item.id}, name: ${item.name}`)
        .join("<br>");
    return names;
};

const routeWeather = (query**: WeatherQueryInterface**)**: WeatherDetailType** =>
    queryWeatherData(query);

const queryWeatherData = (query**: WeatherQueryInterface**)**: WeatherDetailType** => {
    return {
        zipcode: query.zipcode,
        weather: "sunny",
        temp: 35
    };
};

export {routeHello, routeAPINames, routeWeather}; 

Listing 3-20:typed routes.ts 文件

根据第 38 页“类型注解”部分讨论的原则,我们只注解函数的参数和返回类型。当局部变量的类型无法推断时,我们才对其进行注解,例如在将fetch响应转换为 JSON 时。在这里,我们需要显式地为变量指定自定义类型responseItemType,并将转换的返回值强制转换为responseItemType数组。

在清单的其余部分,我们为额外的天气路由创建函数。我们使用自定义接口为两个函数的参数类型注解,并使用自定义类型为它们的返回类型注解。在这个基本示例中,查询函数返回大多数静态数据,唯一不同的是邮政编码,它是从传递的参数中获取的。常规实现会使用邮政编码查询数据库并检索实际数据。

最后,我们将新的天气端点路由添加到export语句中。

为 index.ts 文件添加类型注解

sample-express文件夹中的文件index.js重命名为index.ts,并调整代码以匹配清单 3-20。除了必要的类型注解,还需要创建一个新的端点,并按照 TypeScript 约定,为未使用的参数加上下划线前缀(_),如清单 3-21 所示。

import {routeHello, routeAPINames, **routeWeather**} from "./routes.js";
import express, {**Request, Response**} from "express";

const server = express();
const port = 3000;

server.get("/hello", function (**_req: Request**, res**: Response**): void {
    const response = routeHello();
    res.send(response);
});

server.get("/api/names",
    async function (**_req: Request**, res**: Response): Promise<void>** {
        let response: string;
        try {
            response = await routeAPINames();
            res.send(response);
        } catch (err) {
            console.log(err);
        }
    }
);

server.get(
    "/api/weather/**:zipcode**",
    function (req**: Request**, res**: Response**): **void** {
        const response = routeWeather({zipcode: **req.params.zipcode**});
        res.send(response);
    }
);

server.listen(port, function (): void {
    console.log("Listening on " + port);
}); 

清单 3-21:带有类型注解的 index.ts 文件

首先,我们从可用的路由中导入新的天气路由,并从express包中导入类型。这些都是具名导出。因此,我们使用花括号({})。

然后,按照最佳实践,我们添加代码注解,并同时为未使用的req参数加上下划线前缀。TSC 会遵循函数式编程语言的约定,忽略这些参数。api/names入口点被标记为异步函数,因此它需要返回一个包裹在 Promise 中的值。因此,什么也不返回,我们将void作为 Promise 的值返回。

在以下代码行中,我们为新的 /api/weather/:zipcode 端点创建了一个额外的路由。冒号 (😃 在请求的 params 对象上创建了一个参数。我们通过 req.params.zipcode 获取 zipcode 的值,并将其传递给 routeWeather 函数。请注意,这次请求参数中没有下划线。最后,我们使用与之前相同的函数启动 Express.js 服务器并监听 3000 端口。

转译并运行代码

要使用 TypeScript 编译器将代码转译为 JavaScript,请在命令行中使用 npx 运行 TSC:

$ **npx tsc**

TSC 从 TypeScript 文件生成了两个新文件,index.jsroutes.js。通过命令行使用常规的 Node.js 调用启动服务器:

$ **node index.js**
Listening on 3000 

现在在浏览器中访问 http://localhost:3000/api/weather/12345。你应该能看到 ZIP 代码 12345 的天气详情,如图 3-2 所示。

图 3-2:来自 Node.js Web 服务器的浏览器响应

成功!你写出了你的第一个 TypeScript 应用程序。

总结

本章介绍了你需要了解的关于 TypeScript 的知识,以便创建一个全栈应用程序。我们在一个新项目中设置了 TypeScript 和 TSC,然后讨论了其最重要的配置选项。接下来,你学习了如何高效使用 TypeScript,利用类型注解推断来避免过度输入。

我们还讨论了原始类型和高级内置类型,并讲解了如何创建自定义类型和接口。最后,你利用新学到的知识将 TypeScript 添加到之前练习中构建的 Express.js 服务器,并使用类型注解、自定义类型和接口重构了代码。

如果你想成为 TypeScript 专家,我推荐阅读 TypeScript 手册https://www.typescripttutorial.net 上的教程。在下一章,你将了解 React,这是一种用于构建用户界面的声明式 JavaScript 库。

第四章:4 REACT

开发人员可以使用 React 库来创建全栈应用程序的用户界面。React 基于 Node.js 生态系统构建,作为最常用的 Web 框架之一,目前它是超过百分之多少最受欢迎网站的基础。

要有效地使用 React,必须理解用于定义用户界面元素外观的语法,并将这些语法组合成可以动态更新的 React 组件。本章涵盖了开始使用此库开发全栈应用程序所需了解的所有内容。

React 的作用

现代前端架构将应用程序的用户界面拆分成小的、独立的和可重用的项目。其中一些,如标题、导航和徽标,可能每个页面只出现一次,而其他一些则是重复出现的元素,形成页面的内容,如标题、按钮和广告。 图 4-1 显示了其中的一些项目。React 的语法遵循这一模式;该库专注于构建这些独立的组件,并通过这样做,帮助我们更高效地开发应用程序。

图 4-1:用户界面组件

React 使用声明式编程范式,通过这种方式,你可以通过描述期望的结果来创建用户界面,而不是像在命令式编程中那样明确列出创建它所需的所有步骤。声明式范式的经典例子是 HTML。使用 HTML,你描述网页的元素,然后浏览器渲染页面。相比之下,你可以使用 JavaScript 编写一个命令式程序来创建每个 HTML 元素,在此过程中,你需要明确列出构建网站的步骤。

此外,这些用户界面组件是响应式的。这意味着两件事:一是它们处理自己独立的状态,二是每个组件在其状态变化后立即更新页面的 HTML。对 React 代码的修改会立刻影响浏览器的文档对象模型(DOM),DOM 将网站表示为一棵树,每个 HTML 元素都是一个节点。DOM 还为每个节点和网站本身提供 API,使得脚本能够修改网站或特定的节点。

DOM 操作,如重新渲染一个组件,是比较昂贵的。为了更新 DOM,React 使用虚拟 DOM,这是实际浏览器 DOM 的内存中克隆版本,稍后会与真实 DOM 进行同步。这个虚拟 DOM 允许增量更新,从而减少浏览器中耗费资源的操作次数。虚拟 DOM 是 React 的一个关键原则。React 通过每次调用其渲染函数来计算虚拟 DOM 与真实 DOM 之间的差异,然后决定更新什么内容。通常,React 会执行批量更新,以进一步降低性能影响。这个协调过程使 React 能够提供快速且响应迅速的用户界面。

虽然 React 主要是一个用户界面库,但开发者也可以用它来构建不需要中间件或后端的单页面应用。这些应用无非是一个在浏览器中渲染的视图层。它们在某种程度上是动态的:例如,我们可以更改页面的语言、打开图片画廊或切换某个元素的可见性。然而,所有这些都发生在浏览器中,通过额外的 React 模块,而不是在服务器上。

我们还可以执行更高级的功能,比如通过 React 的 Router 模块,仅在浏览器内更新浏览器的地址栏位置,以模拟不同页面的存在。这个模块允许我们在前端定义路由,类似于我们在 Express.js 服务器中定义的路由。一旦用户点击一个内部链接,路由组件就会更新视图并更改浏览器的地址。这让用户感觉他们加载了另一个 HTML 页面。实际上,我们只是更改了当前页面的内容。通过这样做,我们避免了另一轮服务器请求,因此模拟的页面加载得更快。此外,因为我们的 JavaScript 代码控制了页面之间的过渡,我们可以为这些过渡添加效果和动画。

设置 React

与你在第 13 页的练习 1 中创建的基本 Express.js 服务器不同,后者使用标准的 JavaScript 并可以直接在 Node.js 上运行,React 依赖于一个完整的构建工具链进行高级设置。例如,它使用自定义的 JavaScript 语法扩展(JSX)来描述 HTML 元素,并使用 TypeScript 进行静态类型检查,这两者都需要一个转译器将代码转换为 JavaScript。因此,React 的手动设置过程相当复杂。

因此,我们通常依赖其他工具。在单页应用的情况下,我们使用代码生成器,例如 create-react-app 来搭建项目。在此搭建过程中,create-react-app 为新的 React 应用程序生成基础代码,以及项目的构建链和文件夹结构。它还提供了一个一致的项目布局,帮助我们轻松理解其他 React 项目。

要运行本章中的示例,一种方法是使用 create-react-app 创建一个简单的 TypeScript React 应用程序,按照 https://create-react-app.dev/docs/getting-started/ 上的步骤操作。如果你不想创建一个专门的项目,可以选择使用 React 和 TypeScript 模板,在在线游乐场中运行代码,例如 https://codesandbox.iohttps://stackblitz.com。这些游乐场和 create-react-app 使用相同的文件结构。在这两种情况下,你应该将代码保存到默认的 App.tsx 文件中。

对于更复杂的应用程序,我们将使用完整的 web 应用框架,例如 Next.js,它提供了开箱即用的必要设置。如 第五章 中所述,Next.js 是最流行的用于 React 的全栈 web 应用框架。内部,Next.js 使用了 create-react-app 的变种来进行搭建。我们将在后续章节中依赖它来处理 React。

JavaScript 语法扩展

React 使用 JSX 来定义用户界面组件的外观。JSX 是 JavaScript 的一种扩展,浏览器渲染到 DOM 之前,必须通过转译器进行转换。虽然它的语法类似于 HTML,但它不仅仅是一个简单的模板语言。相反,它允许我们使用任何 JavaScript 特性来描述 React 元素。例如,我们可以在条件语句中使用 JSX 语法,将其赋值给变量,并从函数中返回。编译器会将任何包裹在大括号 ({}) 中的变量或有效 JavaScript 表达式嵌入到 HTML 中。

这种逻辑使我们能够,例如,使用 array.map 遍历数组,检查每个项是否满足某个条件,将该项传递给另一个函数,并根据该函数的返回值直接在页面模板中创建一组 JSX 元素。虽然这听起来有些抽象,但当我们在 第二部分 中构建 Food Finder 应用程序时,将广泛使用这种模式来创建 React 组件。

一个 JSX 表达式示例

JSX 表达式,如 列表 4-1 中的表达式,是 React 用户界面中最基本的部分。此 JavaScript 代码定义了一个 JSX 函数表达式,getElement,它接受一个字符串作为参数并返回一个 JSX.Element。

import React from "react";

export default function App() {
    const getElement = (weather: string): JSX.Element => {
        const element = <h1>The weather is {weather}</h1>;
        return element;
    };
    return getElement("sunny");
} 

列表 4-1:一个简单的 JSX 表达式示例

每个 React 应用程序的入口点是 App 函数。就像我们的 Express.js 服务器的 index.js 文件一样,这个函数在应用程序启动时执行。在这里,我们通常会设置全局元素,如样式表和整体页面布局。

React 将函数的返回值渲染到浏览器中。在 列表 4-1 中,我们立即返回一个元素。作为 React 用户界面的最小构建块,元素 描述了你将在屏幕上看到的内容,正如 HTML 元素所做的那样。元素的例子包括自定义按钮、标题和图片。

在导入 React 包之后,我们创建 JSX 元素并将其存储在 element 常量中。乍一看,你可能会想它为什么没有用引号括起来,因为它包含了一个看起来像普通 HTML 的 h1 元素,并且看起来像一个字符串。答案是,它不是一个字符串,而是一个 JSX 元素,React 库通过它来程序化地创建 HTML 元素。因此,代码会在页面上显示一条关于天气的消息。

一旦我们调用 JSX 表达式,React 库会将其转译成一个常规的 JavaScript 函数调用,并从浏览器中显示的 JSX 元素创建一个 HTML 字符串。在 第三章 中,你学到了所有有效的 JavaScript 也是有效的 TypeScript。因此,我们也可以将 JSX 与 TypeScript 一起使用。JSX 文件使用 .jsx(JavaScript)或 .tsx(TypeScript)扩展名。将这段代码粘贴到你创建的项目的 App.tsx 文件中,浏览器应该会在在线开发环境的预览窗格中或在浏览器中渲染一个带有文本 The weather is sunny 的 h1 HTML 元素。

ReactDOM 包

与元素打交道的一种简单方法是使用 ReactDOM 包,它包含了用于处理 DOM 的 API。请注意,你创建的元素不是浏览器的 DOM 元素。相反,它们是普通的 JavaScript 对象,使用 React 的 render 函数渲染到虚拟 DOM 的根元素,并随后附加到浏览器的 DOM 上。

React 元素是不可变的:一旦创建,它们就不能更改。如果你修改了元素的任何部分,React 会创建一个新元素并重新渲染虚拟 DOM,然后将虚拟 DOM 与浏览器 DOM 进行比较,决定浏览器 DOM 是否需要更新。我们将使用 JSX 抽象来完成这些任务;尽管如此,了解 React 如何在后台工作仍然是有益的。如果你想深入了解,可以查阅官方文档:https://react.dev/learn

将代码组织成组件

我们提到过,组件是由 React 元素构建的独立、可重用的代码块。元素是可以包含其他元素的对象。一旦渲染到虚拟 DOM 或浏览器 DOM,它们会创建 DOM 节点或整个 DOM 子树。同时,React 的组件是输出元素并将其渲染到虚拟 DOM 的类或函数。我们将使用 React 组件构建用户界面。关于这种区别的更多信息,请阅读官方 React 博客中的深度文章:https://reactjs.org/blog/2015/12/18/react-components-elements-and-instances.html

虽然其他框架可能会按照技术划分用户界面的代码,将其分为 HTML、CSS 和 JavaScript 文件,但 React 则将代码划分为这些逻辑构建块。因此,一个物理文件包含了组件所需的所有信息,而不管底层技术如何。

更具体地说,React 组件是一个 JavaScript 函数,按照约定,它的名称以大写字母开头。此外,它接收一个名为 props 的对象参数,并返回一个 React 元素。这个 props 参数在组件内部不应被修改,在 React 代码中被视为不可变。

清单 4-2 展示了一个基础的 React 组件,该组件显示与前面列表相同的天气字符串。此外,我们还添加了一个自定义接口和一个点击事件处理程序。自定义接口使我们能够在 JSX 组件上设置属性,并在 TypeScript 代码中读取其值。这是将值传递给函数组件的一种常见方式,无需全局状态管理库。

在这里,我们只是将与前面列表中相同的字符串传递给组件,并将其渲染到 DOM 中,但在实际应用中,天气字符串可能是 API 响应的一部分。为了获取天气数据,父组件可能会查询 API,然后通过组件的属性将数据传递给组件的代码,或者应用程序中的每个组件都需要查询 API 来访问数据,这会影响整个应用的性能。

点击处理程序使我们能够对用户交互做出反应。在 JSX 中,点击处理程序与 HTML 中的名称相同,我们通过添加它们的方式,就像我们添加内联 DOM 事件一样。例如,为了响应用户点击一个元素,我们添加一个 onClick 属性,并指定一个回调函数。

import React from "react";

export default function App() {

    interface WeatherProps {
        weather: string;
    }

    const clickHandler = (text: string): void => {
        alert(text);
    };

    const WeatherComponent = (props: WeatherProps): JSX.Element => {
        const text = `The weather is ${props.weather}`;
        return (<h1 onClick={() => clickHandler(text)}>{text}</h1>);
    };

    return (<WeatherComponent weather="sunny" />);
} 

列表 4-2:一个基本的 React 组件

首先,我们为新组件的属性创建一个自定义接口。稍后我们将在组件的 prop 参数中使用这个接口。因为我们在组件上设置了一个 weather 属性,并在接口上定义了匹配的 weather 属性,所以我们可以通过 props.weather 在 TypeScript 代码中访问 weather 属性的值。

然后,我们创建事件处理器作为一个箭头函数,接受一个字符串参数。我们使用一个类似于内联 DOM 事件的 onClick 事件属性,并分配一个回调函数 clickHandler。一旦用户点击页面的标题,我们就会显示一个简单的警告框。

接下来,我们定义组件。如你所见,这是一个实现了 WeatherProps 接口并返回 JSX 元素的 JSX 表达式。在组件内部,我们使用一个没有标签的模板字面量来创建文本,并通过 props.weather 动态添加来自 weather 属性的天气信息。然后我们返回 JSX 元素,最后返回并渲染天气组件,并将 sunny 设置为该属性的值。

将这段代码粘贴到 App.tsx 文件中。浏览器应在预览窗格中渲染一个带有文本 The weather is sunny 的 h1 HTML 元素。当你点击文本时,一个警告框会再次显示它。更改 weather 属性的值,以显示不同的天气字符串。

编写类组件

React 中有两种类型的组件:类组件和函数组件。清单 4-2 中的组件是一个 函数组件,它大量借鉴了函数式编程的思想。特别是,这些组件遵循纯函数的模式:它们根据一些输入(props 参数和 JSX 组件的属性)创建一些输出(JSX 元素)。尽管我们在本章中强调了这种类型的组件,但你也应该了解类组件的基础知识。

类组件遵循面向对象编程的典型模式:它被定义为一个类,并继承自其父类 React.Component 类。像所有组件一样,它有一个名为 props 的参数,并返回一个 JSX 元素。类组件还具有 constructor 和 super 函数,并且可以使用 this 关键字来引用当前组件的实例。

特别有价值的是,内部属性 this.state 提供了一个接口,用于存储和访问有关组件内部状态的信息,例如打开的元素、图像画廊中的当前图像,或者像下一个示例中的简单点击计数器。类似重要的是类的 生命周期 方法,这些方法在特定生命周期步骤中运行:例如,组件挂载、渲染、更新或卸载时。 在清单 4-3 中,我们使用了 componentDidMount 生命周期方法。React 在组件成为 DOM 的一部分后立即运行此方法。这类似于浏览器的 DOMReady 事件,您可能已经熟悉它。

清单 4-3 展示了之前创建的天气组件,它被定义为一个类组件。为了练习访问组件的状态,我们添加了一个计数器,用于统计点击标题元素的次数。因为它记录了组件的内部状态,所以计数器会在页面重新加载时重置。将此代码粘贴到 App.tsx 文件中,点击标题即可进行计数。

import React from "react";

export default function App() {
    interface WeatherProps {
        weather: string;
    }

 type WeatherState = {
        count: number;
    };

    class WeatherComponent extends React.Component<WeatherProps, WeatherState> {
        constructor(props: WeatherProps) {
            super(props);
            this.state = {
                count: 0
            };
        }

        componentDidMount() {
            this.setState({count: 1});
        }

        clickHandler(): void {
            this.setState({count: this.state.count + 1});
        }

        render() {
            return (
                <h1 onClick={() => this.clickHandler()}>
                    The weather is {this.props.weather}, and the counter shows{" "}
                    {this.state.count}
                </h1>
            );
        }
    }

    return (<WeatherComponent weather="sunny" />);
} 

清单 4-3:一个基本的 React 类组件

首先,我们定义一个自定义接口来用于组件的属性。我们还定义了一个类型,以便在稍后创建计数器时使用。

接下来,我们定义类组件,扩展基类 React.Component。遵循面向对象编程模式,构造函数调用了 super 函数并初始化了组件的状态。我们将计数器设置为 0。一旦浏览器挂载组件,它就会调用生命周期方法 componentDidMount,将组件的 count 变量更改为 1。我们修改点击处理程序以计算点击次数而不是显示警告框,并调用 render 函数。在这里,我们返回显示天气属性和当前状态的 JSX 元素作为 HTML。

最后,我们返回 WeatherComponent,然后 React 进行初始化。预览窗格显示字符串 The weather is sunny, and the counter shows 1。从数字 1 我们可以看出生命周期方法确实被调用了。每次点击标题都会立即增加数字,这是因为组件状态的响应性特性。状态一旦改变,React 就会重新渲染组件并更新视图显示当前状态值。

使用 Hooks 提供可重用行为

函数组件可以使用 hooks 提供可重用的行为,例如访问组件的状态。Hooks 是提供简单和可重用接口以访问状态和生命周期特性的函数。列表 4-4 展示了我们在 列表 4-3 中创建的同一个天气组件,这次是作为函数组件编写的。它使用 hooks 替代生命周期方法来更新组件的计数器。

import React, {useState,useEffect} from "react";

export default function App() {

    interface WeatherProps {
        weather: string;
    }

    const WeatherComponent = (props: WeatherProps): JSX.Element => {

        const [count, setCount] = useState(0);
        useEffect(() => {setCount(1)},[]);

        return (
            <h1 onClick={() => setCount(count + 1)}>
                The weather is {props.weather},
                and the counter shows {count}
            </h1>
        );
    };

    return (<WeatherComponent weather="sunny" />);
} 

列表 4-4:使用 hooks 的 React 函数组件

我们向这个组件添加了两个新特性:一个组件状态指示器和一种在组件挂载时立即运行代码的方式。因此,我们通过从 React 模块中以命名导入的方式,使用两个钩子:useState 和 useEffect,然后将它们添加到函数组件中。useState 钩子取代了类组件中的 this.state 属性,而 useEffect 则挂载了 componentDidMount 生命周期方法。此外,我们将前一个示例中的 clickHandler 替换为一个简单的内联函数来更新计数器。

每次调用钩子都会生成一个完全隔离的状态,因此我们可以在同一个组件中多次使用相同的钩子,并且可以放心地认为状态会更新。这个模式保持了钩子回调函数的简洁性和聚焦性。还要注意,运行时并不会提升钩子。它们会按我们在代码中定义的顺序被调用。

当你比较 Listings 4-3 和 4-4 时,你应该能立刻看到,函数组件更具可读性且更易于理解。正因如此,我们将在本书的其余部分中专门使用函数组件。

使用内置钩子

React 提供了一组内置钩子。你刚才看到的最常用的钩子是 useState 和 useEffect。另一个有用的钩子是 useContext,用于在组件之间共享数据。其他内置钩子涵盖了更具体的使用场景,用于提高应用程序性能或处理特定的边界情况。你可以根据需要查阅 React 文档来了解更多。

每当你需要将一个庞大的组件拆分为更小、更可复用的模块时,你也可以创建自定义钩子。自定义钩子遵循特定的命名约定。它们以 use 开头,后面跟着一个以大写字母开头的动作。你应该为每个钩子定义一个功能,以便于测试。

本节将引导你了解三种最常见的钩子以及使用它们的好处。

使用 useState 管理内部状态

纯函数只使用函数内部可用的数据。尽管如此,它仍然可以对局部状态的变化作出反应,比如我们创建的天气组件中的计数器。useState 钩子可能是最常用的处理区域状态的钩子。这个内部组件的状态仅在组件内部可用,永远不会暴露给外部。

因为组件的状态是反应式的,React 会在我们更新状态后立即重新渲染组件,改变整个组件中的值。然而,React 保证状态是稳定的,并且在重新渲染时不会发生变化。

useState 钩子返回反应式的状态变量和一个用于设置状态的 setter 函数,如 列表 4-5 中所示。

const [**count**, **setCount**] = useState(**0**);

列表 4-5:单独查看的 useState 钩子

我们使用默认值初始化 useState 钩子。钩子本身返回状态变量 count 和我们用来修改状态变量值的 setter 函数,因为我们不能直接修改该变量。例如,要将 列表 4-5 中创建的状态变量 count 设置为 1,我们需要调用 setCount 函数,并将新值作为参数传入,像这样: setCount(1)。根据惯例,setter 函数以 set 开头,后跟状态变量的名称。

使用 useEffect 处理副作用

纯函数应该只依赖于传递给它们的数据。当一个函数使用或修改其局部作用域外的数据时,我们称之为 副作用。副作用的最简单示例是修改全局变量。这在 JavaScript 和函数式编程中都被认为是不好的做法。

然而,有时我们的组件需要与“外部世界”交互或具有外部依赖。在这些情况下,我们可以使用 useEffect 钩子,它处理副作用,为组件的函数式部分提供了一个逃生通道。例如,useEffect 可以管理依赖项、调用 API 和获取组件所需的数据。

这个钩子在 React 将组件挂载到布局并完成组件的渲染过程后运行。它有一个可选的返回对象,在组件卸载之前运行。你可以使用它来进行清理操作,例如移除事件监听器。

使用这个钩子的一种方法是观察并响应依赖关系。为此,我们可以传递一个可选的依赖项数组。任何依赖项的变化都会触发钩子的重新运行。如果依赖项数组为空,钩子将不依赖任何外部值并且永不重新运行。在我们的天气组件中就是这种情况,useEffect 仅在组件挂载和卸载后执行。它没有外部依赖项,因此依赖项数组保持为空,钩子只运行一次。

使用 useContext 和上下文提供者共享全局数据

理想情况下,React 的函数组件应为纯函数,仅操作通过 props 参数传递的数据。遗憾的是,组件有时可能需要消耗共享的全局状态。在这种情况下,React 实现了 上下文提供者 来与子组件树共享全局数据。

上下文提供者将子组件包裹起来,我们可以通过 useContext 钩子访问共享的数据。随着上下文值的变化,React 会自动重新渲染所有子组件。因此,这是一个相对昂贵的钩子。你不应该将其用于频繁变化的数据集。

在你将在 第二部分 中构建的全栈应用中,你将使用 useContext 来与子组件共享会话数据。共享上下文也经常用于跟踪颜色方案和主题。清单 4-6 展示了如何通过上下文提供者来消费主题。

import React, {useState, createContext, useContext} from "react";

export default function App() {
    const ThemeContext = createContext("");

    const ContextComponent = (): JSX.Element => {

        const [theme, setTheme] = useState("dark");

        return (
            <div>
                <ThemeContext.Provider value={theme}>
                    <button onClick={() => setTheme(theme == "dark" ? "light" : "dark")}>
                        Toggle theme
                    </button>
                    <Headline />
 </ThemeContext.Provider>
            </div>
        );
    };

    const Headline = (): JSX.Element => {
        const theme = useContext(ThemeContext);
        return (<h1 className={theme}>Current theme: {theme}</h1>);
    };

    return (<ContextComponent />);
} 

清单 4-6:完整的上下文提供者示例

首先,我们从 React 包中导入必要的函数,并使用 createContext 函数初始化 ThemeContext。接下来,我们创建父组件并命名为 ContextComponent。这是一个包含上下文提供者和所有子组件的包装器。

在 ContextComponent 中,我们使用 useState 创建局部 theme 变量,并将有状态的变量设置为上下文提供的内容。这使我们能够从子组件内部更改上下文中的变量。由于我们使用了反应式的有状态变量作为值,所有 theme 变量的实例将在所有子组件中即时更新。

我们添加了一个按钮元素,并且每当用户点击按钮时,切换状态变量的值,在明亮和黑暗模式之间切换。最后,我们创建了 Headline 组件,它调用 useContext 钩子来获取由 ThemeContext 提供的 theme 值,并将其传递给所有子组件。Headline 组件使用 theme 值来设置 HTML 类,并显示当前的 theme。

练习 4:为 Express.js 服务器创建一个响应式用户界面

让我们利用你学到的新知识和我们的天气组件,为 Express.js 服务器创建一个响应式用户界面。新的 React 组件将允许我们通过点击页面上的文本来更新网页内容。

将 React 添加到服务器

首先,我们将在项目中包含 React。为了进行实验,你可以将 React 库和 Babel.js 转译器的独立版本直接添加到 HTML 的 head 标签中。不过要注意,这种方法不适合用于生产环境。因为在浏览器中转译代码是一个非常慢的过程,而且我们在这里添加的 JavaScript 库并未进行优化。使用 React 和骨架 Express.js 服务器需要相当多的繁琐设置步骤和一定的维护工作。我们将在第五章中使用 Next.js 来简化开发 React 应用程序的过程。

package.json 文件旁边创建一个名为 public 的文件夹,然后在其中创建一个空的 weather.html 文件。添加 列表 4-7 中的代码,其中包含我们的 React 示例和天气组件。稍后,我们将创建一个新的端点 /components/weather,直接返回该 HTML 文件。

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8" />
        **<title>Weather Component</title>**
        **<script src="https://unpkg.com/react@18/umd/react.development.js"></script>**
        **<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>**
        **<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>**
    </head>
    <body>
        <div id="root"></div>

        <script type="text/babel">
            function App() {

                **const WeatherComponent = (props) => {**

                    const [count, setCount] = **React.useState**(0);
                    **React.useEffect**(() => {
                        setCount(1);
                    }, []);

                    return (
                        <h1 onClick={() => setCount(count + 1)}>
                            The weather is {props.weather}, 
                            and the counter shows {count}
                        </h1>
                    );
                };
                return (<WeatherComponent weather="sunny" />);
          }

            const container = document.getElementById("root");
            const root = ReactDOM.createRoot(container);
            root.render(<App />);
        </script>
    </body>
</html> 

列表 4-7:静态文件 /public/weather.html 在浏览器中渲染 React。

首先,我们向 weather.html 文件中添加三个 React 脚本:它们分别是 react.developmentreact.dom.development 和独立的 babel.js,这些都类似于我们之前在 App.tsx 文件中使用的 React 引入方式。然后我们添加 ReactDOM,以便 React 能够与 DOM 进行交互。这三个文件会向 window.object 添加一个全局属性 React。我们使用这个属性作为全局变量来引用 React 函数。独立的 Babel 脚本添加了 Babel.js 转译器,我们需要它来将代码从 JSX 转换为 JavaScript。

接下来,我们添加之前开发的天气组件代码。我们不再引用 App.tsx 文件,而是将应用函数直接放入 HTML 文件中,并将脚本块标记为 text/babel。此类型告诉 Babel 将脚本标签中的代码转译为标准 JavaScript。

我们对天气组件的代码进行了几个简单的修改。首先,我们删除了类型注解,因为它们仅在 TypeScript 文件中允许使用。然后,由于我们使用的是浏览器环境,我们在钩子前添加了全局属性名称 React。最后,我们使用 ReactDOM 创建 React 根容器,并在那里渲染 组件。

为静态 HTML 文件创建端点

我们要编辑的第二个文件是根目录中的 index.ts 文件。我们将 列表 4-8 中突出显示的代码添加到文件中,以添加新的入口点 /components/weather

import {routeHello, routeAPINames, routeWeather} from "./routes.js";
import express, {Request, Response} from "express";

**import path from "path";**

const server = express();
const port = 3000;

`--snip--`
**server.get("/components/weather", function (req: Request, res: Response): void {**
    **const filePath = path.join(process.cwd(), "public", "weather.html");**
    **res.setHeader("Content-Type", "text/html");**
    **res.sendFile(filePath);**
**});**

server.listen(port, function (): void {
    console.log("Listening on " + port);
}); 

列表 4-8:重构后的 index.ts

为了加载静态 HTML 文件,我们从 Node.js 的默认路径模块导入 path。路径模块提供了用于处理文件和目录的各种工具。特别是,我们将使用 join 函数来创建符合操作系统格式的有效路径。

我们使用默认的全局函数 process.cwd 获取当前工作目录,并从那里创建到 HTML 文件的路径。然后,我们添加天气组件的入口点,并将响应的 Content-Type 头设置为 text/html。最后,我们使用 sendFile 函数将之前创建的 weather.html 文件发送到浏览器。

运行服务器

我们需要将服务器代码转译为 JavaScript,因此我们在命令行中使用 npx 运行 TSC:

$ **npx tsc**

生成的文件 index.jsroutes.js 与之前创建的文件类似。TSC 不会处理静态 HTML 文件。独立的 Babel.js 脚本在浏览器中运行时将 JSX 代码转换。通过命令行启动服务器:

$ **node index.js**
Listening on 3000 

现在在浏览器中访问 http://localhost:3000/components/weather-component。你会看到与在 React 游乐场渲染天气组件时看到的相同文本,如 图 4-2 所示。一旦点击该文本,点击处理程序将增加响应式状态变量,计数器会显示新的值。

图 4-2:来自 Node.js Web 服务器的浏览器响应

你成功创建了你的第一个 React 应用。为了获得更多 React 经验,尝试为点击计数器添加一个自定义按钮组件,使用 style 属性,并通过 JSX 表达式根据奇数和偶数计数值改变背景色。

总结

现在你应该已经有了坚实的基础,可以开始创建你的 React 应用了。JSX 元素是 React 组件的构建块,这些组件返回 JSX,通过 React 的虚拟 DOM 渲染为 HTML 到 DOM 中。你还探讨了类组件和现代函数组件之间的区别,深入了解了 React hooks,并使用这些 hooks 构建了一个函数组件。

如果你想探索 React 的全部潜力,可以查看 W3Schools 提供的 React 教程,网址是 https://www.w3schools.com/REACT/DEFAULT.ASP,以及 React 团队创建的教程,网址是 https://react.dev/learn/tutorial-tic-tac-toe

在下一章,我们将学习 Next.js。Next.js 是基于 React 构建的,作为一个生产级的全栈网页开发框架,专为单页面应用而设计。

第五章:5 NEXT.JS

在第四章中,你使用了 React 来创建响应式用户界面组件。但是,由于 React 只是一个库,构建全栈应用程序需要额外的工具。在本章中,我们将使用 Next.js,这是一个构建在 React 之上的领先 Web 应用程序框架。要使用 Next.js 创建应用程序,你只需要了解几个基本概念。本章将介绍这些概念。

Next.js 简化了应用程序前端、中间件和后端的创建。在前端,它使用 React。它还添加了本地的 CSS 模块来定义样式,并使用自定义的 Next.js 模块来执行路由、图像处理和其他前端任务。至于中间件和后端,Next.js 使用内置服务器来提供 HTTP 请求的入口点,并提供一个干净的 API 以便处理请求和响应对象。

我们将讨论其基于文件系统的路由方法,讨论构建和渲染交付给客户端的网页的方式,探索如何添加 CSS 文件来为页面添加样式,并重构我们的 Express.js 服务器以与 Next.js 一起使用。本章使用传统的pages目录来教授这些基本概念。要了解 Next.js 的替代app目录,请参见附录 B。

设置 Next.js

Next.js 是 npm 生态系统的一部分。虽然你可以通过运行 npm install next react react-dom 手动安装所有必需的模块,并随后自己创建项目的所有文件和文件夹,但有一个更简单的方式来设置:运行 create-next-app 命令。

让我们创建一个示例应用程序,以便在本章中使用。按照以下步骤设置一个名为sample-next的新空文件夹,并在其中构建你的第一个 Next.js 应用程序。保持设置向导中的默认答案,并选择使用传统的pages目录,而不是app目录:

$ **mkdir sample-next**
$ **cd ./sample-next**
$ **npx create-next-app@latest** **-****-typescript** **-****-use-npm**
--`snip`--
What is your project named? ... **my-app**
--`snip`--
Creating a new Next.js app in /Users/.../my-app.

Installing dependencies:
- react
- react-dom
- next

Installing devDependencies:
- eslint
- eslint-config-next
- typescript
- @types/react
- @types/node
- @types/react-dom 

我们创建一个新文件夹,切换到该文件夹,然后初始化一个新的 Next.js 项目。我们使用 npx 命令,而不是 npm,因为正如你在第一章中学到的,npx 不需要我们安装任何东西作为依赖项或开发依赖项。我们提到过它的典型用途是脚手架工具,这正是我们在这里所做的。

create-react-app 命令有几个选项,其中只有两个与我们相关:--typescript 选项创建一个支持 TypeScript 的 Next.js 项目,而 --use-npm 标志选择 npm 作为包管理器。

我们接受默认的项目名称 my-app,以及所有其他默认设置。脚本会根据项目名称创建一个包含package.json文件和一个完整示例项目的文件夹,里面有所有必需的文件和文件夹。最后,脚本通过 npm 安装依赖和开发依赖。

注意

如果不想设置一个新项目,你可以使用在线的沙盒环境,如 <wbr>codesandbox<wbr>.io<wbr>/s<wbr>/ 或者 <wbr>stackblitz<wbr>.com 来运行本章的 Next.js 代码示例。只需选择 pages 目录设置,而不是 app 目录即可。

项目结构

让我们来探索一下 Next.js 应用程序的项目结构。输入以下命令来运行它:

$ **cd my-app**
$ **npm run dev**

> my-app@0.1.0 dev
> next dev

ready - started server on 0.0.0.0:3000, url: http://localhost:3000 

在浏览器中访问提供的 URL。你应该能看到一个默认页面,类似于图 5-1 中所示的页面(这个欢迎页面可能会根据你的 Next.js 版本有所不同)。

图 5-1:在浏览器中查看的 Next.js 基本应用

现在打开my-app文件夹,这是脚手架脚本创建的,并浏览其中的内容。my-app文件夹包含了许多文件夹,但目前只有三个对你来说比较重要:publicstylespages

public 文件夹保存所有静态资源,如自定义字体文件、所有图像以及应用程序提供下载的文件。我们将在应用程序的 HTML 和 CSS 文件中链接这些资源。pages 文件夹包含了应用程序的所有路由。它的每个文件都是一个属于页面路由或 API 路由(在 api 子文件夹中的)端点。

注意

Next.js 的最新版本还包含了一个 app 目录,你可以选择使用它进行路由设置,作为 pages 目录的替代方案。由于 app 目录使用了更先进的概念,本章将讲解更简单的 pages 架构风格。不过,你可以在附录 B 中进一步了解 app 目录,我们将在那里详细介绍它的使用。

my-app 文件夹中,我们还可以找到 _app.tsx 文件,这是 Next.js 对我们在第四章中使用的 App.tsx 文件的等价物。它是整个应用程序的入口点,也是我们将添加全局样式、组件和上下文提供者的地方。最后,styles 文件夹包含全局 CSS 文件以及用于本地作用域、组件特定的模块文件。

开发脚本

我们的应用程序使用的技术,包括 TypeScript、React 和 JSX,并不能直接在浏览器中运行。它们都需要一个包含合理复杂转译器的构建管道。Next.js 提供了四个命令行脚本,以简化开发过程。

npx next dev 和 npm run dev 命令以开发模式启动应用程序,地址是 http://localhost:3000。因此,每当我们更改一个文件时,Next.js 会在浏览器窗口中重新构建并重新加载渲染后的应用程序。除了这种 热代码 重载外,开发服务器还会显示错误和警告信息,以帮助应用程序的开发。安装向导会将服务器添加到 package.json 的脚本部分,因此我们也可以通过运行 npm run dev 来启动它。

npx next build 和 npm run build 命令创建了我们应用程序的优化构建版本。它们会移除未使用的代码,并减少脚本、样式和其他资源文件的大小。你将在实时部署时使用这些命令。npx next start 和 npm run start 命令将在内置服务器或无服务器环境中,以生产模式运行优化后的应用程序,地址是 http://localhost:3000。此生产构建依赖于之前创建的构建版本,因此,我们必须首先运行 build 命令。

最后,npx next export 和 npm run export 命令创建了一个独立版本的应用程序,该版本不依赖于内置的 Next.js 服务器,可以在任何基础设施上运行。然而,此版本的应用程序将无法使用需要 Next.js 服务器端功能的特性。请参考官方 Next.js 文档 https://nextjs.org,获取使用指南。

路由应用程序

当我们构建我们的示例 Express.js 服务器时,我们创建了一个明确的路由文件,将应用程序的每个端点映射到执行相应行为的不同函数。Next.js 提供了一个不同的、也许更简单的路由系统;它基于 pages 目录中的文件自动创建应用程序的路由。如果该文件导出了一个 React 组件(对于网页)或一个异步函数(对于 API),它就成为一个有效的端点,既可以是一个 HTML 页面,也可以是一个 API。

在本节中,我们将重新审视我们在 Express.js 服务器中创建的路由,并使用 Next.js 的路由技术重新构建它们。

简单页面路由

对于我们的 Express.js 服务器,我们在 index.ts 文件中手动创建了一个 /hello 路由。当访问该路由时,它返回了 Hello World!。现在让我们将此路由转换为 Next.js 中基于页面的路由。最简单的页面路由类型是将文件直接放置在 pages 目录中。例如,默认创建的 pages/index.tsx 文件映射到 http://localhost:3000。要创建一个简单的 /hello 路由,只需在该目录下创建一个新文件 hello.tsx。然后,将列表 5-1 中的代码添加到该文件中。

import type {NextPage} from "next";

const Hello: NextPage = () => {
    return (<>Hello World!</>);
}

export default Hello; 

列表 5-1:pages/hello.tsx 文件

我们的 Express.js 服务器使用 routeHello 函数返回 Hello World! 字符串。在这里,我们需要添加更多代码来导出一个 React 组件。首先,我们从 Next.js 模块导入自定义类型 NextPage,并用它创建一个常量 Hello。我们将常量赋值为一个返回 NextPage 的箭头函数,NextPage 实际上是 React 组件的一个自定义包装器。在这种情况下,我们返回 JSX 渲染 Hello World! 字符串。最后,我们将 NextPage 作为文件的默认导出。

运行服务器并导航到 http://localhost:3000/hello。您看到的页面应该显示 Hello World! 作为其内容。

该页面与示例 Express.js 服务器中的页面看起来不同。这是因为 Next.js 会自动将 _app.tsx 文件中定义的所有全局样式应用到每个页面。因此,即使我们在 hello.tsx 文件中没有显式定义任何样式,字体看起来也会不同。

嵌套路由页面

嵌套路由,如示例中的/components/weather,是其他路由的逻辑子路由。换句话说,weather是嵌套在components入口点下的。你可能已经猜到我们如何使用 Next.js 的页面路由模式来创建嵌套路由了。没错,我们只需要创建一个子文件夹,Next.js 会将文件夹结构映射到 URL 模式。

pages文件夹内创建一个新的文件夹components,并在其中添加一个新的文件weather.tsx。图 5-2 展示了 URL components/weather 与文件结构 pages/components/weather.tsx 之间的关系。

图 5-2:URL components/weather 与文件结构之间的关系 pages/components/weather.tsx

我们的pages文件夹是 URL 的根目录,每个嵌套文件夹都成为 URL 的一部分。导出NextPage的文件是叶子节点,即 URL 的最终部分。对于这个文件,我们重用了在第四章中编写的天气组件代码,见清单 5-2。

import type {NextPage} from "next";
import React, {useState, useEffect} from "react";

const PageComponentWeather: NextPage = () => {

    interface WeatherProps {
        weather: string;
    }

    const WeatherComponent = (props: WeatherProps) => {

        const [count, setCount] = useState(0);
        useEffect(() => {
            setCount(1);
        }, []);

        return (
            <h1 onClick={() => setCount(count + 1)}>
                The weather is {props.weather},
                and the counter shows {count}
            </h1>
        );
    };

 return (<WeatherComponent weather="sunny" />);
};

export default PageComponentWeather; 

清单 5-2:pages/components/weather.tsx 文件

与第四章中创建的功能组件的唯一区别是,我们将代码包装在一个返回NextPage的函数中,然后将其作为默认导出。这与我们在清单 5-1 中创建的页面一致,并遵循 Next.js 的模式要求。

在浏览器中访问新的页面 http://localhost:3000/components/weather,它应该与图 5-3 相似。

图 5-3:该 pages/components/weather.tsx 文件在 /components/weather URL 中渲染。

你应该能认出在第四章中看到的点击处理器功能。

API 路由

除了用户友好的界面外,完整栈应用程序可能还需要一个机器可读的接口。例如,在第二部分中,你将创建的 Food Finder 应用程序将为外部服务提供一个 API,以便移动应用或第三方小部件可以显示我们的愿望清单。作为 JavaScript 驱动的全栈开发者,我们最常用的 API 格式是 GraphQL 和 REST,我们将在第六章中深入讨论这两种格式。在这里,我们将创建 REST API,因为它们简洁易用。

使用 Next.js,我们可以通过与页面相同的模式设计和创建 API。pages/api/ 文件夹中的每个文件都是一个单独的 API 端点,我们可以像定义嵌套路由一样定义嵌套的 API 路由。然而,与页面路由不同,API 路由不是 React 组件。相反,它们是异步函数,接受两个参数,NextApiRequest 和 NextApiResponse,并返回一个 NextApiResponse 和 JSON 数据。

在处理 API 路由时,有两个需要记住的注意事项。首先,默认情况下,它们不会指定 跨域资源共享(CORS) 头部。这组 HTTP 头部,尤其是 Access-Control-Allow-Origin 头部,允许服务器定义客户端脚本可以请求资源的源。如果你希望在第三方域名上的网站中的客户端脚本访问你的 API 端点,你需要添加额外的中间件,在 Next.js 服务器中直接启用 CORS。否则,外部请求将触发 CORS 错误。这不仅仅是 Next.js 的问题;Express.js 和大多数其他服务器框架也要求你做同样的事情。

第二个注意事项是,通过运行 next export 进行的静态导出不支持 API 路由。它们依赖于内置的 Next.js 服务器,无法作为静态文件运行。

我们在 Express.js 服务器中使用了一个 API 路由,api/names。现在,让我们重构代码并将其转换为 Next.js API 路由。如之前所述,创建一个新文件,names.ts,并将其放置在 api 文件夹中。因为 API 路由返回的是一个异步函数,而不是 JSX,所以我们使用 .ts 扩展名,而不是用于 JSX 代码的 .tsx 扩展名。将列表 5-3 中的代码粘贴到该文件中。

import type {NextApiRequest, NextApiResponse} from "next";

type responseItemType = {
    id: string;
    name: string;
};

export default async function handler(
    req: NextApiRequest,
    res: NextApiResponse
): Promise<NextApiResponse<responseItemType[]> | void> {
    const url = "https://www.usemodernfullstack.dev/api/v1/users";
    let data;
    try {
        const response = await fetch(url);
        data = (await response.json()) as responseItemType[];
    } catch (err) {
        return res.status(500);
    }
    const names = data.map((item) => {
        return {id: item.id, name: item.name};
    });
    return res.status(200).json(names);
} 

列表 5-3:pages/api/names.ts 文件

首先,我们从 Next.js 包中导入 API 请求和响应的自定义类型。然后,我们定义 API 响应的自定义类型。在第三章中,我们为 routes.ts 文件中的 await 调用创建了相同的类型。我们在这里也使用相同的代码和 await 调用,因此我们也复用了该类型。接着,我们创建并直接导出前面提到的 API 处理函数。你在第二章中学到,异步函数需要返回一个 promise 作为返回类型。因此,我们将这个 API 响应包装在一个 promise 中。

函数主体中的代码与第四章中的 routeAPINames 函数中的代码类似。它发出 API 请求来获取用户数据,将接收到的数据转换为所需的返回格式,最后返回数据。然而,我们需要做一些修改。首先,我们不再返回一个错误字符串,而是返回一个没有内容的 API 响应,并附上通用的状态码 500,表示 内部服务器错误

第二个调整涉及数据映射。之前,我们返回的是一个在浏览器中呈现的字符串。现在,我们不再返回这个字符串,而是返回一个 JSON 对象。因此,我们修改了 array.map 函数,以创建一个对象数组。最后,我们修改 return 语句,将 API 响应以 JSON 格式返回,并附上状态码 200: OK

现在在浏览器中打开新的 API 路径 http://localhost:3000/api/names。你应该能看到在图 5-4 中显示的 API 响应。

图 5-4:从 pages/api/names.ts 文件在浏览器中呈现的 /api/names

动态 URL

现在你已经知道如何创建页面和 API 路径,这些是任何全栈应用的基础。然而,你可能在想如何创建 动态 URL,它们会根据输入发生变化。我们通常使用动态 URL 来展示个人资料页面,其中用户的姓名成为 URL 的一部分。事实上,当我们在 index.ts 文件中定义路由 /api/weather/:zipcode 时,我们就实现了一个动态 URL。在那里,zipcode 是一个动态参数,或者说是一个动态叶子段,其值是由 req.params.zipcode 函数提供的。

Next.js 对动态 URL 使用了稍有不同的模式。由于它是基于文件夹和文件来创建路由的,我们需要通过将变量部分包裹在方括号([])中来定义动态片段。Express.js 服务器中的动态路由 /api/weather/:zipcode 因此会转化为文件 /api/weather/[zipcode].ts

让我们在示例 Next.js 应用程序中创建一个动态路由,模拟来自 Express.js 服务器的 /api/weather/:zipcode 路由。在 api 文件夹中创建一个新的文件夹 weather,并在其中放置一个名为 [zipcode].ts 的文件。然后将列表 5-4 中的代码粘贴到该文件中。

import type {NextApiRequest, NextApiResponse} from "next";

type WeatherDetailType = {
    zipcode: string;
    weather: string;
    temp?: number;
};

export default async function handler(
    req: NextApiRequest,
    res: NextApiResponse
): Promise<NextApiResponse<WeatherDetailType> | void> {

    return res.status(200).json({
        zipcode: req.query.zipcode,
        weather: "sunny",
        temp: 35
    });

} 

列表 5-4:api/weather/[zipcode].ts 文件

这段代码应该对你来说很熟悉,因为它遵循了 Next.js 中 API 路由的基本框架。我们导入了必要的类型,然后定义了一个自定义类型 WeatherDetailType,并将其用作函数返回数据的类型。(顺便说一下,这正是我们在第三章中创建的类型定义。)在函数的主体中,我们返回状态码为 200: OK 的响应和一个 JSON 对象。我们用从动态 URL 参数中获取的邮政编码填充 zipcode 属性,获取方式为 req.query .zipcode。

当你运行服务器时,浏览器应显示带有动态 URL 参数的 JSON 响应。如果你访问 http://localhost:3000/api/weather/12345,你应该能看到 API 响应。如果你更改 URL 中的“12345”部分并重新请求数据,响应数据应相应地变化。

请注意,动态路由 /api/weather/[zipcode].ts 匹配 /api/weather/12345/api/weather/54321,但不匹配这些路由的子路径,例如 /api/weather/location/12345/api/weather/location/54321。为此,你需要使用 catch all API 路由,它包括当前路径内的所有路径。你可以通过在文件名前添加三个点(...)来创建一个 catch all 路由。例如,catch all 路由 /api/weather/[...zipcode].ts 可以处理本文中提到的所有四个 API 端点。

样式化应用程序

为了向我们的 Next.js 应用程序添加样式,我们创建常规的 CSS 文件,这些文件没有使用其他框架中常见的供应商前缀。稍后,Next.js 的后处理器将添加必要的属性,以生成向后兼容的样式。虽然 CSS 语法超出了本书的范围,但本节将介绍如何使用 Next.js 的两种 CSS 样式:全局样式和在 CSS 模块中定义的局部作用域组件样式。

全局样式

全球样式影响应用的所有页面。当我们渲染 hello.tsx 文件时,我们遇到了这种行为;尽管我们没有自己添加任何样式信息,页面还是使用了 CSS。

从实际角度来看,全球样式只是常规的 CSS 文件。它们在构建过程中不会被修改,并且它们的类名保证保持不变。因此,我们可以在整个应用程序中将它们作为常规 CSS 类使用。我们将这些 CSS 文件导入到应用的入口文件 pages/_app.tsx 中。看看模板项目中的代码,你应该能看到类似于 Listing 5-5 的一行代码。

import "@/styles/globals.css";

Listing 5-5:在 _app.tsx 文件中导入全球样式

当然,你可以调整导入样式的文件名和位置,或者导入多个文件。试着通过在 global.css 文件中添加一些样式,并在 hello.tsx 文件中的 HTML 元素上添加一些常规 CSS 类来进行实验。然后访问 htttp://localhost:3000/hello 页面,看看它的变化。

组件样式

在 第四章 中,你看到 React 允许我们通过独立的、可重用的组件来创建用户界面。全球样式并不是为独立组件提供样式的最佳方法,因为它们要求我们跟踪已经在各种组件中使用过的类名,如果我们从先前的项目中导入组件,还可能会导致 CSS 类名发生冲突。

我们需要将 CSS 类作用域限定到单独的模块,以便与模块化组件高效协作。实现这一目标有多种架构模式。例如,使用块元素修饰符(Block Element Modifier)方法,你可以手动将样式作用域限定到某个组件或用户界面块。

幸运的是,我们不需要为这种笨重的解决方案烦恼。Next.js 允许我们使用在构建过程中作用域限定的 CSS 模块。这些 CSS 模块遵循命名约定 <component>.module.css。编译器会自动在模块内部的每个 CSS 类名上添加组件的名称和唯一标识符前缀。这使得我们能够在多个组件中使用相同的样式名称而不会出现问题。

实际编写的 CSS 不会包含这些前缀。例如,查看 styles 文件夹中的 Home.module.css 文件,见 Listing 5-6。

.container {
    padding: 0 2rem;
} 

Listing 5-6:styles/Home.module.css 中的常规 CSS 代码

一个问题是,由于构建过程会修改类名并为其添加前缀,我们不能直接在其他文件中使用这些样式。相反,我们必须导入样式,并将其当作 JavaScript 对象来处理。然后,我们可以将其作为 styles 对象的属性来引用。例如,列表 5-7 中的 pages/index.tsx 文件使用了来自列表 5-6 的 container 类,展示了如何使用作用域样式的示例。

import styles from "../styles/Home.module.css"
--`snip`--
const Home: NextPage = () => {
    return (
        <div className={styles.container}>
        --`snip`--
        </div>
    );
}; 

列表 5-7:在 index.tsx 文件中使用来自 CSS 模块样式/Home.module.css 的样式

这段代码将 CSS 文件导入到一个名为 styles 的常量中。现在,所有的 CSS 类名都可以作为 styles 对象的属性使用。在 JSX 中,我们使用被大括号包裹的变量({}),因此我们将对 container 类的引用写为 {styles.container}

你现在可以根据 React 组件构建 API 和自定义样式页面。下一部分介绍了 Next.js 提供的一些有用的自定义组件,帮助增强你的全栈应用程序。

内置的 Next.js 组件

Next.js 提供了一组自定义组件。每个组件针对一个特定的使用场景:例如,访问内部页面属性,如页面标题或 SEO 元数据(next/head),提升应用程序整体渲染性能和用户体验(next/image),或启用应用程序的路由功能(next/link)。我们将在第二部分的全栈应用程序中使用本章涉及的 Next.js 组件,你可以在实际应用中看到它们的使用。有关其他属性和细分的使用案例,请参考 Next.js 文档。

next/head 组件

next/head 组件导出了一个自定义的 Next.js 特有的 Head 组件。我们使用它来设置页面的 HTML 标题和 meta 元素,这些元素位于 HTML 的 head 组件内。为了提高 SEO 排名和增强可用性,每个页面都应该拥有自己的元数据。列表 5-8 显示了来自列表 5-1 的 hello.tsx 页面示例,包含了自定义的标题和 meta 元素。

需要记住的是,Head 元素不会跨页面合并。Next.js 的客户端路由在页面过渡时会移除 Head 元素的内容。

import type {NextPage} from "next";
**import Head from "next/head";**

const Hello: NextPage = () => {
    return (
        <div>
            **<Head>**
                **<title>Hello World Page Title</title>**
                **<meta property="og:title" content="Hello World" key="title" />**
            **</Head>**
            <div>Hello World!</div>
        </div>
    );
};

export default Hello; 

清单 5-8:包含自定义标题和 meta 元素的 pages/hello.tsx 文件

我们从 next/head 组件中导入 Head 元素,并将其添加到返回的 JSX 元素中,将其放置在现有内容之上,并将两者包裹在另一个 div 元素中,因为我们需要返回一个元素而不是两个。

next/link 组件导出了一个 Link 组件。这个组件是建立在 React 的 Link 元素之上的。当我们想要在应用中链接到另一个页面时,我们使用它来替代 HTML 锚点标签,从而启用客户端之间的页面过渡。点击时,Link 组件会更新浏览器 DOM,显示新的 DOM,滚动到新页面的顶部,并调整浏览器历史记录。此外,它提供了内置的性能优化,当 Link 组件进入 视口(网站当前可见部分)时,会预取链接的页面及其数据。这种后台预取使得页面过渡更加流畅。清单 5-9 将上一个清单中的 Next.js Link 元素添加到页面中。

import type {NextPage} from "next";
import Head from "next/head";
**import Link from "next/link";**

const Hello: NextPage = () => {
    return (
        <div>
            <Head>
                <title>Hello World Page Title</title>
                <meta property="og:title" content="Hello World" key="title" />
            </Head>
            <div>Hello World!</div>
            <div>
                Use the HTML anchor for an
                **<a href="https://nostarch.com" > external link</a>**
                and the Link component for an
                **<Link href=****"/components/weather"> internal page**
                **</Link>**
                .
            </div>
        </div>
    );
};

export default Hello; 

清单 5-9:包含外部链接和内部 next/link 元素的 pages/hello.tsx 文件

我们导入该组件,然后将其添加到返回的 JSX 元素中。为了进行比较,我们使用常规的 HTML 锚点链接到 No Starch Press 首页,并使用自定义的 Link 连接到我们的 Next.js 应用中的天气组件页面。在应用中,尝试点击这两个链接,看看它们的区别。

next/image 组件

next/image 组件导出了一个 Image 组件,用于显示图像。该组件是基于原生 HTML 中的 元素构建的。它处理常见的布局需求,如填充所有可用空间和缩放图像。该组件可以加载现代图像格式,如 AVIFWebP,并根据客户端的屏幕显示合适大小的图像。此外,你可以选择使用模糊的占位图像,并在图像进入视口时延迟加载实际图像;这可以通过防止 累积布局位移 来强制网站的视觉稳定性,累积布局位移发生在图像在页面之后渲染时,导致页面内容向下移动。累积布局位移被认为是糟糕的用户体验,它会使用户失去注意力。Listing 5-10 提供了 next/image 组件的基本示例。

import type {NextPage} from "next";
import Head from "next/head";
import Link from "next/link";
**import Image from "next/image";**

const Hello: NextPage = () => {
    return (
        <div>
            <Head>
                <title>Hello World Page Title</title>
                <meta property="og:title" content="Hello World" key="title" />
            </Head>
            <div>Hello World!</div>
            <div>
                Use the HTML anchor for an <a href="https://nostarch.com">
                external link</a> and the Link component for an
                <Link href="/components/weather"> internal page</Link>.
                **<Image**
                    **src****="/vercel.svg"**
                    **alt="Vercel Logo"**
                    **width={72}**
                    **height={16}**
                **/>**
            </div>
        </div>
    );
};
export default Hello; 

Listing 5-10: 使用 next/image 元素的 pages/hello.tsx 文件

在这里,我们展示了来自应用程序的 Vercel logo,它位于 public 文件夹中。首先,我们从 next/image 包中导入该组件。然后我们将其添加到页面内容中。我们的最小示例的语法和属性类似于 HTML 中的 img 元素。你可以在官方文档中了解更多关于该组件的高级属性,链接为 https://nextjs.org/docs/api-reference/next/image

预渲染和发布

虽然你可以根据目前所学的内容开始构建全栈的 Next.js 应用程序,但了解一个更高级的话题会很有帮助:不同的渲染和发布方式及其对性能的影响。

Next.js 提供了三种预渲染应用程序的选项,利用其内置的服务器。第一种,静态网站生成(SSG),在构建时生成 HTML。因此,每次请求都会返回相同的 HTML,它是静态的且永远不会被重新创建。第二种选项,服务器端渲染(SSR),在每次请求时生成新的 HTML 文件,第三种,增量静态再生(ISR),将两者的优点结合起来。

Next.js 允许我们根据每个页面选择不同的预渲染选项,这意味着全栈应用可以包含使用 SSG、SSR、ISR 的页面,以及一些 React 组件的客户端渲染。你还可以通过运行 next export 来创建网站的完整静态导出。导出的应用将独立运行在所有基础设施上,因为它不需要内置的 Next.js 服务器。

为了体验这些渲染方法,我们将创建一个新页面,展示来自我们的名称 API 的数据,针对每种渲染选项。创建一个新文件夹 utils,与 pages 文件夹并列,并在其中添加一个空文件 fetch-names.ts。然后将 Listing 5-11 中的代码添加进去。这个工具函数调用远程 API 并返回数据集。

type responseItemType = {
    id: string;
    name: string;
};

export const fetchNames = async () => {
    const url = "https://www.usemodernfullstack.dev/api/v1/users";
    let data: responseItemType[] | [] = [];
    let names: responseItemType[] | [];
    try {
        const response = await fetch(url);
        data = (await response.json()) as responseItemType[];
    } catch (err) {
        names = [];
    }
    names = data.map((item) => {return {id: item.id, name: item.name}});

    return names;
}; 

Listing 5-11: 位于 utils/fetch-names.ts 中的异步工具函数

在定义了自定义类型之后,我们创建一个函数并直接导出它。这个函数包含了之前创建的 names.ts 文件中的代码,做了两处调整:首先我们需要将数据数组定义为可能为空;接着,如果 API 调用失败,我们返回一个空数组而不是错误字符串。这个改变意味着,在生成 JSX 字符串时,我们不需要在遍历数组之前验证类型。

服务器端渲染

使用 SSR 时,Next.js 内置的 Node.js 服务器会根据每个请求生成应用的 HTML。如果你的页面依赖外部 API 提供的新鲜数据,你应该使用这种技术。不幸的是,SSR 在生产环境中较慢,因为页面不容易缓存。

要为页面使用 SSR,从该页面导出一个额外的异步函数 getServerSideProps。Next.js 会在每个请求时调用该函数,并将获取的数据传递给页面的 props 参数,在将其发送到客户端之前进行预渲染。

尝试通过在 pages 文件夹中创建一个新文件 names-ssr.tsx 来实现这一点。将 Listing 5-12 中的代码粘贴到该文件中。

import type {
    **GetServerSideProps,**
    **GetServerSidePropsContext,**
    **InferGetServerSidePropsType,**
    NextPage,
    PreviewData
} from "next";
import {ParsedUrlQuery} from "querystring";
import {fetchNames} from "../utils/fetch-names";

type responseItemType = {
    id: string;
    name: string;
};

const NamesSSR: NextPage = **(props: InferGetServerSidePropsType<typeof getServerSideProps>****)** => {

    const output = props.names.map((item: responseItemType, idx: number) => {
        return (
            <li key={`name-${idx}`}>
                {item.id} : {item.name}
            </li>
        );
    });

    return (
        <ul>
            {output}
        </ul>
    );
};

**export const getServerSideProps: GetServerSideProps** = async (
    context: GetServerSidePropsContext<ParsedUrlQuery, PreviewData>
) => {

    let names: responseItemType[] | [] = [];
    try {
        names = await fetchNames();
    } catch(err) {}
    return {
        props: {
          names
        }
    };
};

export default NamesSSR; 

Listing 5-12: 使用 SSR 显示数据的基础页面,page/names-ssr.tsx

要使用 Next.js 的 SSR,我们需要导出额外的异步函数 getServerSideProps。我们还需要从 nextquerystring 包中导入必要的功能,以及之前创建的 fetchNames 函数。然后我们定义 API 请求响应的自定义类型。这与我们在 第三章 中使用的自定义类型相同。

接下来,我们创建页面并将导出作为默认选项。页面返回一个 NextPage 并采用该页面类型的默认属性。我们遍历 props 参数的 names 数组,创建一个 JSX 字符串并将其渲染返回到浏览器。然后我们定义 getServerSideProps 函数,它从 API 获取数据。我们从异步函数返回创建的数据集,并将其传递给页面属性中的 NextPage。

导航到新页面 http://localhost:3000/names-ssr。你应该能够看到用户名列表。

静态站点生成

SSG 只创建一次 HTML 文件,并在每个请求中重复使用它们。这是推荐的选项,因为预渲染的页面易于缓存并且传递速度快。例如,内容分发网络可以轻松获取你的静态文件。

通常,SSG 应用程序具有更低的 首次绘制时间,即用户请求页面(例如,通过点击链接)到内容在浏览器中出现之间的时间。SSG 还可以减少 阻塞时间,即用户实际与页面内容交互前的时间。这些指标的良好得分表明网站响应迅速,且它们是 Google 打分算法的一部分。因此,这些页面的 SEO 排名更高。

如果你的页面依赖外部数据,你仍然可以通过从页面文件中导出一个额外的异步函数 getStaticProps 来使用 SSG。Next.js 会在构建时调用此函数,将获取的数据传递给页面的 props 参数,并使用 SSG 预渲染页面。当然,只有在外部数据不是动态的情况下,这种方法才有效。

尝试创建与 SSR 示例中相同的页面,这次使用 SSG。首先在 pages 文件夹中添加一个新文件,names-ssg.tsx,然后粘贴 示例 5-13 中显示的代码。

import type {
    **GetStaticProps,**
    **GetStaticPropsContext,**
    **InferGetStaticPropsType,**
    NextPage,
    PreviewData,
} from "next";
import {ParsedUrlQuery} from "querystring";
import {fetchNames} from "../utils/fetch-names";

type responseItemType = {
    id: string,
    name: string,
};

const **NamesSSG**: NextPage = **(props: InferGetStaticPropsType<typeof getStaticProps>)** => {

    const output = props.names.map((item: responseItemType, idx: number) => {
        return (
            <li key={`name-${idx}`}>
                {item.id} : {item.name}
            </li>
        );
    });

    return (
        <ul>
            {output}
        </ul>
    );
};

**export const getStaticProps: GetStaticProps = async (**
    context: **GetStaticPropsContext<ParsedUrlQuery, PreviewData>**
) => {

    let names: responseItemType[] | [] = [];
    try {
        **names = await fetchNames();**
    } catch (err) {}

    return {
        props: {
            names
        }
    };
};

export default NamesSSG; 

示例 5-13:使用 SSG 显示数据的页面,page/names-ssg.tsx

代码与 示例 5-9 基本相同。我们只需要将特定于 SSR 的代码更改为使用 SSG。因此,我们导出 getStaticProps 而不是 getServerSideProps,并相应调整类型。

当你访问该页面时,它应该与 SSR 页面类似。但与每次访问 http://localhost:3000/names-ssg 都请求新数据不同,数据只在页面构建时请求一次。

增量静态再生

ISR 是 SSG 和 SSR 的混合体,完全在服务器端运行。在初次构建时,它在服务器上生成 HTML,并在第一次请求页面时发送这个预生成的 HTML。在指定时间过后,Next.js 会获取数据并在后台重新生成页面。在此过程中,它会使内部服务器缓存失效,并用新页面更新缓存。每个后续请求将收到最新的页面。与 SSG 类似,ISR 比 SSR 成本更低,并能提升页面的 SEO 排名。

要在 SSG 页面中启用 ISR,我们需要向 getStaticProp 的返回对象添加一个属性以重新验证。我们以秒为单位定义数据的有效性,如 列表 5-14 所示。

return {
        props: {
        names,
        revalidate: 30
    }
}; 

列表 5-14:更改 getServerSideProps 以启用 ISR

我们添加了 revalidate 属性,值为 30。因此,定制的 Next.js 服务器将在第一次页面请求后 30 秒使当前 HTML 失效。

客户端渲染

完全不同的方法,客户端渲染 首先使用 SSR 或 SSG 生成 HTML 并发送到客户端。然后客户端在运行时获取额外的数据,并在浏览器的 DOM 中渲染。客户端渲染是处理高度灵活、不断变化的数据集(如实时股票市场或货币价格)的好选择。其他网站使用它将页面的骨架版本发送到客户端,然后用更多内容进行增强。然而,客户端渲染会降低你的 SEO 性能,因为其数据无法被索引。

列表 5-15 显示了我们之前创建的页面,已配置为客户端渲染。请在 pages 文件夹中创建一个新文件,命名为 names-csr.tsx,然后将代码添加到其中。

import type {
    NextPage
} from "next";
import {**useEffect, useState**} from "react";
import {fetchNames} from "../utils/fetch-names";

type responseItemType = {
    id: string,
    name: string,
};

const NamesCSR: NextPage = () => {
    const [data, setData] = **useState<responseItemType[] | []>**();
    **useEffect**(() => {
        const fetchData = async () => {
            let names;
            try {
                names = await fetchNames();
            } catch (err) {
                console.log("ERR", err);
            }
            setData(names);
        };
        fetchData();
    }**);**

    const output = data?.map((item: responseItemType, idx: number) => {
        return (
            <li key={`name-${idx}`}>
                {item.id} : {item.name}
            </li>
        );
    });

    return (
        <ul>
            {output}
        </ul>
    );

};

export default NamesCSR; 

列表 5-15:客户端渲染页面,page/names-csr.tsx

这段代码与之前的示例有很大不同。这里我们导入了 useStateuseEffect 钩子。后者会在页面可用后获取数据。一旦 fetchNames 函数返回数据,我们使用 useState 钩子和响应式的 data 状态变量来更新浏览器的 DOM。

我们不能将 useEffect 钩子声明为一个异步函数,因为它返回的是一个未定义的值或一个函数,而异步函数返回的是一个 Promise,因此 TSC 会抛出错误。为避免这种情况,我们需要将 await 调用封装在一个异步函数 fetchData 中,然后调用该函数。

配置为客户端渲染的页面应该与其他版本相似。但是,当你访问 http://localhost:3000/names-csr 时,可能会看到一闪而过的空白页面。这是因为页面正在等待异步 API 请求。

为了更好地了解不同的渲染类型,可以修改本节中每个示例的代码,使用API www.usemodernfullstack.dev/api/v1/now,该 API 返回一个包含请求时间戳的对象。

静态 HTML 导出

next export 命令生成一个静态的 HTML 版本的网页应用。这个版本独立于内置的基于 Node.js 的 Next.js Web 服务器,可以在任何基础设施上运行,比如 Apache、NGINX 或 IIS 服务器。

要使用此命令,您的页面必须实现 getStaticProps,就像在 SSG 中一样。此命令不支持 getServerSideProps 函数、ISR 或 API 路由。

练习 5:将 Express.js 和 React 重构为 Next.js

让我们将前几章中的 React 和 Express.js 应用重构为一个 Next.js 应用,在接下来的章节中我们将对其进行扩展。第一步,我们总结一下需要构建的功能。我们的应用有一个 API 路由 api/names,返回用户名;另一个 API 路由 api/weather/:zipcode,返回一个静态 JSON 对象和 URL 参数。我们使用它来理解动态 URL。此外,我们还创建了 /hellocomponent/weather 页面。

在本章中,我们已经将这些不同的元素重构为与 Next.js 的路由风格兼容。在本练习中,我们将所有内容整合在一起。按照第 70 页中“设置 Next.js”部分的步骤初始化 Next.js 应用。在sample-next文件夹中,将应用命名为refactored-app

存储自定义接口和类型

我们在项目的根目录创建了一个新的文件custom.d.ts,用于存储自定义接口和类型定义(列表 5-16)。它与我们在第三章和第四章中使用的类似,主要的区别在于我们为 Next.js 应用添加了自定义类型。

interface WeatherProps {
    weather: string;
}

type WeatherDetailType = {
    zipcode: string;
    weather: string;
    temp?: number;
};

type responseItemType = {
    id: string;
    name: string;
}; 

列表 5-16:custom.d.ts 文件

我们将使用自定义接口WeatherProps作为显示天气组件的页面components/weather的props参数。WeatherDetailType用于 API 路由api/weather/:zipcode,它使用动态获取的 ZIP 代码。最后,我们在 API 路由api/names中使用responseItemType来为 fetch 响应类型化。

创建 API 路由

接下来,我们重新创建 Express.js 服务器中的两个 API 路由。本章前面的部分展示了此重构代码。对于api/names路由,首先在api文件夹中创建一个新文件nimes.ts,然后添加列表 5-3 中的代码。请参考该部分以获取代码的详细解释。

通过在api文件夹中创建一个[zipcode].js文件,并添加列表 5-4 中的代码(见第 77 页的“动态 URL”部分),将动态路由api/weather/:zipcode从 Express.js 服务器迁移到 Next.js 应用程序。您可以参考该部分获取更多详细信息。

创建页面路由

现在我们开始处理页面。首先,对于简单页面hello.tsx,我们在pages文件夹中创建一个新文件,并添加列表 5-10 中的代码。此代码渲染了Hello World!示例,并使用了自定义的 Next.js 组件Head、Link和Image,这些组件在第 80 页的“内建 Next.js 组件”部分中有详细说明。

第二个页面是嵌套路由pages/components/weather.tsx。与之前一样,我们在pages文件夹内的components文件夹中创建一个新文件weather.tsx。添加列表 5-2 中的代码。此列表使用了useState和useEffect钩子来创建一个响应式的用户界面。我们可以从此文件中移除自定义接口定义WeatherProps,因为custom.d.ts文件已经将它们添加到 TSC 中。

运行应用程序

使用 npm run dev 命令启动应用程序。现在,你可以访问我们为 Express.js 服务器创建的相同路由,并看到它们在功能上完全相同。恭喜!你创建了第一个基于 Next.js 的全栈应用程序。可以尝试修改代码,使用全局 CSS 和组件 CSS 来为页面添加样式。

总结

Next.js 增加了创建全栈应用所需的缺失功能,帮助你与 React 一起构建应用。在搭建一个示例项目并探索默认文件结构后,你学习了如何在框架中创建页面和 API 路由。你还了解了全局和组件级 CSS、Next.js 的四个内置命令行脚本以及它的最有用的自定义组件。

我们还讨论了使用 Next.js 渲染内容和页面的不同方式,以及何时选择每种选项。最后,你使用本章的代码将之前章节中构建的 Express.js 应用程序快速迁移到 Next.js。为了继续探索这个有用的框架,我推荐访问官方教程:https://nextjs.org

在下一章,我们将探讨两种类型的 Web API:标准的 RESTful API 和现代的 GraphQL。

第六章:6 REST 和 GRAPHQL API

API 是一种通用模式,用于连接计算机或计算机程序。与用户界面不同,它的设计目的是让软件而非用户来访问。API 的一个目的是隐藏系统内部工作细节,同时暴露出一个标准化的网关,供访问系统的数据或功能。

作为全栈开发人员,你通常会与两种类型的 API 进行交互或使用:内部 API 和第三方 API。在查询内部 API 时,你是从自己的系统中获取数据,通常来自自己的数据库或服务。私有 API 不对外部方开放。例如,你的银行可能使用私有 API 来检查其内部系统中的信用评分或账户余额,并将这些信息显示在你的在线银行账户页面上。

第三方 API 提供对外部系统数据的访问。例如,你将在第二部分中实现的 OAuth 登录就使用了一个 API。你还可以使用 API 从外部提供者获取社交媒体动态或天气信息,并在网站上展示它们。由于外部 API 对公众开放,你可以通过公共 URL 访问它们,它们会在API 合同中记录你应使用的访问数据的约定。这个合同定义了通信格式、API 期望的参数以及每个请求可能收到的响应。我们在第三章中简要讨论了 API 和功能合同,并解释了为什么你应该为它们指定类型。

全栈 web 开发主要使用两种类型的 API,分别是 REST 和 GraphQL,它们都通过 HTTP 传输数据。本章将介绍这两种 API。

REST API

REST 是一种用于设计 RESTful web API 的架构模式。这些 API 本质上是一组 URL,每个 URL 提供对单一资源的访问。它们依赖于使用 HTTP 方法和标准 HTTP 状态码来传输数据,并接受 URL 编码或请求头参数。通常,它们会以 JSON 或纯文本的形式响应请求的数据。

实际上,你已经构建了第一个 REST API。回想一下你在第五部分练习中创建的 Next.js 服务器,它提供了api/weather/:zipcode端点。到目前为止,我们已使用这个端点来玩转 Next.js 的路由,理解动态 URL,并学习如何访问查询参数。然而,很快你就会发现,这个 API 遵循了 REST 的约定:为了访问它,我们使用了 HTTP 的 GET 方法来访问 URL 端点,并收到了一个带有 HTTP 状态码200: OK的 JSON 响应。常见的状态码范围是2XX表示请求成功,3XX表示重定向。如果请求失败,我们会看到4XX范围表示与客户端相关的错误,例如401: Unauthorized,以及5XX表示服务器错误,通常是通用的500: Internal Server Error

作为全栈开发人员,我们有时可能会创建自己的 API;但更多的时候,我们会使用第三方 API。为什么我们会使用第三方的天气 API 呢?好吧,想象一下,我们希望我们的应用在多个远程地点显示当前天气。与其自行设置并维护多个天气站,然后从传感器中读取数据,这不仅需要为每个天气站提供和消费 API,不如直接从现有天气服务提供的第三方 API 中获取数据。我们的代码可能会调用该 API,传递一个邮政编码作为参数,并以预定的格式接收该地点的天气数据。然后,我们会在网站上显示这些数据。

RESTful API 使我们能够与数据交互,而无需了解数据是如何存储的或由何种底层技术提供的。如果你遵循 API 的规范,即使底层技术或架构发生变化,你也应该能够收到请求的数据。除此之外,还有一些要求,API 才能被认为是 RESTful 的。

URL

一个独特的 URL 为 RESTful API 提供了接口。每个提供者的 API 通常都有相同的基本 URL,称为根入口点,例如http://localhost:3000/api。你可以把它看作是 API 的姓氏。通常,你会看到根入口点后面加上版本号,因为提供者可能有多个 API 版本。例如,可能会有旧版的http://localhost:3000/api/v1和新版的http://localhost:3000/api/v2。为了遵循这个模式,你可以在api文件夹中创建一个v1文件夹,并将 REST API 代码移动到该文件夹中。

注意

API 版本控制的其他常见方式包括自定义头部和查询字符串。在第一种情况下,客户端会使用自定义的 Accept-Version 头部请求 API,并收到匹配的 Content-Version 头部。在第二种情况下,API 请求会在 URL 中使用 ?version=1.0.0 作为查询参数。

API URL 的下一部分是路径,通常被称为端点。它指定我们想要查询的资源(例如天气 API)。API 规范通常只提到端点本身,如/v1/weather,并暗示根入口点。URL 通常还接受参数。这些可以是路径参数,它们是 URL 的一部分,例如我们的邮政编码 API 端点/v1/weather/{zipcode},也可以是查询参数,它们作为编码的键值对在初始问号后添加,如/v1/weather?zipcode=<zipcode>。按照惯例,路径参数通常用于表示一个或多个资源,而查询参数用于对返回的数据执行操作,如排序或过滤。

规范

资源本身与返回给客户端的表示是分开的。换句话说,服务器可能会以 HTML、XML、JSON 等格式发送数据,无论数据是如何存储在应用程序的数据库中的。你可以在 API 的规范中了解 API 的响应格式,它作为 API 的手册。记录 API 的一种优秀方法是使用 OpenAPI 格式,它在行业中被广泛使用,并且是 Linux 基金会的一部分。你可以使用 Swagger 图形编辑器,访问https://editor.swagger.io进行实验。

例如,清单 6-1 显示了一个v1/weather/{zipcode}端点的规范,格式为 JSON。将代码粘贴到 Swagger 编辑器中,以更友好的方式浏览生成的文档。

{
    "openapi": "3.0.0",
    "info": {
        "title": "Sample Next.js - OpenAPI 3.x",
        "description": "The example APIs from our Next.js application",
        "version": "1.0.0"
    },
    "servers": [
        {"url": "https://www.usemodernfullstack.dev/api/"},
        {"url": "http://localhost:3000/api/"}
    ],
    "paths": {
        "/v1/weather/{zipcode}": {
            "get": {
                "summary": "Get weather by zip code",
                "parameters": [
                    {
                        "name": "zipcode",
                        "in": "path",
                        "description": "The zip code for the location as string.",
                        "required": true,
                        "schema": {
                          "type": "string",
                          "example": 96815
                        }
                    }
                ],
                "responses": {
                    "200": {
                        "description": "Successful operation",
                        "content": {
                            "application/json": {
                                "schema": {
                                    "$ref": "#/components/schemas/weatherDetailType"
                                }
                              }
                        }
                    }
                }
            }
          }
    },
    "components": {
        "schemas": {
            "weatherDetailType": {
                "type": "object",
                "properties": {
                    "zipcode": {
                        "type": "string",
                        "example": 96815
                    },
                    "weather": {
                        "type": "string",
                        "example": "sunny"
                    },
                    "temp": {
                        "type": "integer",
                        "format": "int64",
                        "example": 35
                    }
                }
            }
        }
    }
} 

清单 6-1:/v1/weather/{zipcode}端点的 OpenAPI 规范

首先我们定义一般信息,如 API 的标题和描述。这里最重要的值是 API 版本。在练习 6 中,我们将调整服务器以反映这个版本。在下一步中,我们设置服务器,即 API 的根入口点。这里我们使用 localhost,因为我们的 Next.js 应用程序现在是在本地运行的。

然后我们在paths下指定唯一的 API 端点,设置每个端点的路径、参数和响应。在这个示例中,我们为一个端点/v1/weather/{zipcode}指定了最小的必需数据,并明确指出它使用 GET 方法。大括号({})表示 URL 参数,但我们也在路径中显式设置了名为zipcode的参数。此外,我们定义了schema,即该参数的格式,应该是一个字符串。

接下来,在 responses 部分,我们设置了如果 HTTP 状态码为 200: OK 时,API 应该返回的响应。该内容采用 application/json 格式,是 weatherDetailType,你应该已经从之前的章节中熟悉了它。它类似于我们 custom.d.ts 文件中的自定义类型定义,只不过这里我们使用的是 JSON,而不是 TypeScript。

请注意,Swagger 编辑器还会根据规范生成一个交互式的“游乐场”,允许我们在运行中的服务器上测试 API 的端点。此外,我们可以直接在编辑器的界面中生成服务器和客户端。生成的服务器将提供规范中描述的 REST API,而客户端将生成一个库,我们可以在任何使用该 API 的应用程序中使用。这个交互式“游乐场”和生成的代码使得与第三方 API 的协作变得非常简单。

状态与身份验证

RESTful API 是无状态的,这意味着它们不在服务器上存储会话信息。会话信息是关于之前用户交互的任何数据。例如,想象一个在线商店的购物车。在有状态设计中,应用程序会将购物车的内容存储在服务器上,并在你添加新商品时进行更新。在 RESTful 设计中,客户端会在每次请求中发送所有相关的会话数据。用户与服务器的交互是孤立理解的,不依赖于之前请求的上下文。

尽管如此,公共的 RESTful API 通常要求某种形式的身份验证。为了区分认证用户的请求和未认证用户的请求,这些 API 通常会提供一个令牌,用户应该在后续的请求中包含该令牌。消费者将此令牌作为请求数据的一部分,或者放在 HTTP Authorization 头部。我们将在第九章中提供关于授权令牌及其工作原理的更多细节。

这种无状态设计意味着,无论客户端是直接从终端服务器、代理服务器,还是负载均衡器请求数据,身份验证都能正常工作。因此,RESTful API 能够处理分层系统。无状态架构在高流量情况下也很理想,因为它们消除了因从数据库检索会话信息而导致的服务器负载。

HTTP 方法

在 REST 中,有四种标准方式与数据集交互:创建、读取、更新和删除。这些交互通常被称为CRUD操作。REST API 使用以下 HTTP 方法在请求的资源上执行这些操作:

**GET  **用于从资源中检索数据。这是最常见的请求;每当你在浏览器中访问一个网站时,实际上就是向该网站地址发送 GET 请求。

**POST  **用于向集合资源中添加新元素。多次发送相同的 POST 请求会为每个请求创建一个新的元素,导致多个具有相同内容的元素。当你发送电子邮件或提交网页表单时,你的客户端通常会在后台发送一个 POST 请求,因为你在数据库中创建了一个新资源。

**PUT  **用于覆盖或更新现有资源。多次发送相同的 PUT 请求会创建或覆盖一个元素并更新其内容。例如,当你重新上传 Instagram 或 Facebook 上的图片时,你可能会发送一个 PUT 请求。

**PATCH  **用于部分更新现有资源。与 PUT 不同,你只会发送与当前数据集不同的数据。因此,这是一项更小且性能更好的操作。例如,你在社交媒体页面上更新个人资料时,可能会使用 PATCH 请求。

**DELETE  **用于删除资源(例如,在 Instagram 上删除一张图片)。

REST API 请求面临与所有 HTTP 请求相同的性能问题。开发者必须考虑关键因素,如网络带宽、延迟和服务器负载。虽然应用程序通常无法影响网络延迟或用户带宽,但它可以通过缓存请求并返回之前缓存的结果来提高性能。

通常,推荐的做法是积极缓存请求。通过避免额外的服务器请求,我们可以显著加快应用程序的速度。不幸的是,并非所有 HTTP 请求都可以缓存。GET 请求的响应默认是可缓存的,但 PUT 和 DELETE 的响应完全不可缓存,因为它们不能保证可预测的响应。在两个类似的 PUT 请求之间,可能会有 DELETE 请求删除了资源,或者反之。理论上,POST 和 PATCH 请求的响应可以缓存,如果响应提供了Expire头部或Cache-Control头部,并且后续调用是对相同资源的 GET 请求。然而,服务器通常不会缓存这两种类型的请求。

与 REST 工作

让我们通过查看一个虚构的天气服务来练习使用 REST。假设我们阅读了 API 文档,发现授权用户可以通过使用其公开的 REST API 接收和更新服务中的数据集。该 API 返回 JSON 数据,服务器的 URL 是https://www.usemodernfullstack.dev,并且/api/v2/weather/{zipcode}端点支持 GET 和 PUT 请求。在本节中,我们将通过 GET 请求获取特定邮政编码的当前天气数据,以及通过 PUT 请求更新存储的天气数据。

读取数据

为了获取你所在位置的天气,你可能会发出一个包含邮政编码 96815 和授权令牌的 GET 请求。我们可以使用像 cURL 这样的命令行工具来发出此类 GET 请求,cURL 应该是你系统的一部分。如有必要,你可以从https://curl.se安装它。一个典型的 cURL 请求如下所示:

$ **curl -i** **`url`**

-i标志显示我们感兴趣的头部详情。我们可以使用-X标志设置 HTTP 方法,并通过-H标志发送额外的头部。使用转义字符发送多行命令(macOS 上使用\,Windows 上使用^)。避免在转义字符后添加空格。如果你感兴趣,可以尝试使用 cURL 查询你在第五章练习第 89 页中创建的应用程序的一个 API 端点。对天气 API v2/weather/{zipcode} 发起 GET 请求的 cURL 调用如下所示:

$ **curl -i \**
    **-X GET \**
    **-H "Accept: application/json" \**
    **-H "Authorization: Bearer 83dedad0728baaef3ad3f50bd05ed030" \**
    **https://www.usemodernfullstack.dev/api/v2/weather/96815** 

我们向服务器上的 API 端点 v2/weather/{zipcode} 发出此请求,服务器的地址为 https://www.usemodernfullstack.dev/api。邮政编码包含在 URL 中。我们在Accept头部中设置返回格式为 JSON,并在Authorization头部中传递访问令牌。由于这是一个示例 API,它接受任何令牌;如果未提供令牌,API 将返回状态码401

这是 API 对我们 GET 请求的响应示例:

HTTP/2 200
content-type: application/json ; charset=utf-8
access-control-allow-origin: *

{"weather":"sunny","tempC":"25","tempF":"77","friends":["96814","96826"]} 

API 响应的 HTTP 状态码为200,表示请求成功。我们要求返回 JSON 响应,并且content-type头部确认响应数据确实是该类型。

Access-Control-Allow-Origin头部,我们在第五章中讨论过,它允许访问任何域名。通过这个设置,任何客户端 JavaScript 希望访问 API 的浏览器都会允许这些请求,而不管网站的域名是什么。如果没有 CORS 头部,浏览器会阻止请求,脚本无法访问响应,反而会抛出 CORS 错误。

最后,我们看到响应体包含一个 JSON 字符串,包含 API 的响应。

更新数据

现在假设你想在你的网站上添加来自你所在邻里的显示数据(邮政编码 96814)以及邻近区域(邮政编码 96826)的数据。不幸的是,这些邮政编码还没有在 API 中提供。幸运的是,由于它是开源的,我们可以连接我们自己的气象站并扩展系统。假设我们已经设置了气象传感器并将它们连接到 API。一旦天气发生变化,我们就将数据集添加到其中。

这里是我们发送的 PUT 请求,用于更新邮政编码 96814 的天气数据。PUT 请求将数据存储在请求体中;因此,我们在 cURL 命令中使用-d标志来发送编码后的 JSON:

$ **curl -i \**
    **-X PUT \**
    **-H "Accept: application/json" \**
    **-H "Authorization: Bearer 83dedad0728baaef3ad3f50bd05ed030" \**
    **-H "Content-Type: application/json" \**
    **-d "{\"weather\":\"sunny\",\"tempC\":\"20\",\"tempF\":\"68\",**
        **\"friends\":\"['96815','96826']\"}" \**
    **https://www.usemodernfullstack.dev/api/v2/weather/96815** 

我们请求相同的 API 端点,/api/v2/weather/,但将 GET 方法替换为 PUT,因为我们不想从数据库获取数据,而是想添加数据。我们使用Content-Type头部告诉 API 提供者请求体中的有效负载是一个 JSON 字符串。API 更新数据集并返回状态码200,以及包含额外状态信息的 JSON 对象:

HTTP/2 200
content-type: application/json ; charset=utf-8
access-control-allow-origin: *
{"status":"ok"} 

你可以在https://restfulapi.net了解更多关于 RESTful API 的内容,该网站涵盖了更具体的话题,比如压缩和安全模型,并指导你设计自己的 RESTful API。现在,让我们将注意力转向 GraphQL,这是一种不同且更先进的 API 类型。

GraphQL API

与 REST 不同,GraphQL 不仅仅是一个架构模式。它是一个完整的、开源的数据查询和操作语言,专为 API 设计。它也是全栈 Web 开发中最受欢迎的 REST 替代方案,Airbnb、GitHub、PayPal 等众多公司都在使用它。事实上,据报道,前 10,000 个网站中有 10%的站点使用 GraphQL。本节仅涵盖其部分功能,但应该能让你对 GraphQL 的原理有一个扎实的理解。

注意

尽管其名称中带有“Graph”,GraphQL 并不要求使用像 Neo4j 这样的图数据库。我们可以用它查询连接到 GraphQL 服务器的任何数据源,包括常见的数据库如 MySQL 和 MongoDB。

和 REST 一样,GraphQL API 也是通过 HTTP 操作的。然而,GraphQL 的实现只暴露一个单一的 API 端点,通常称为 /graphql,用于访问所有资源并执行所有 CRUD 操作。相比之下,REST 为每个资源提供一个专门的端点。

另一个区别是,我们只能通过 POST 请求连接到 GraphQL 服务器。与其使用 HTTP 方法来定义所需的 CRUD 操作,我们在 POST 请求体中使用查询和变更。查询是读取操作,而变更则是创建、更新和删除数据的操作。

与依赖标准 HTTP 状态码的 REST 不同,GraphQL 在操作无法执行时会返回500,即内部服务器错误。否则,即使查询或变更存在问题,响应也会使用200。这是因为解析器可能在遇到问题之前已经部分执行。部署 GraphQL API 到生产环境时,需牢记这一点。许多标准操作实践和工具可能需要调整,以适应这一行为。

架构

GraphQL API 在其架构中定义了可用的查询和变更,这相当于 REST API 的规范。架构也叫做typedef,它是用架构定义语言(SDL)编写的。SDL 的核心元素是类型,这些类型是包含有类型的字段的对象,字段定义了它们的属性,还有可选的指令,它们可以添加额外的信息,例如,指定查询的缓存规则或标记字段为废弃。

列表 6-2 展示了我们虚构的天气 API 的 GraphQL 架构,它返回某个地点的天气数据。

export const typeDefs = gql`

    type LocationWeatherType {
        zip: String!
        weather: String!
        tempC: String!
        tempF: String!
        friends: [String]!
    }

    input LocationWeatherInput {
        zip: String!
        weather: String
        tempC: String
        tempF: String
        friends: [String]
    }

    type Query {
        weather(zip: String): [LocationWeatherType]!
    }

    type Mutation {
        weather(data: LocationWeatherInput): [LocationWeatherType]!
    }
`; 

列表 6-2:天气 API 的 GraphQL 架构

你应该注意到,架构是一个标签模板字面量,这是你在第二章中学到的内容。我们首先描述自定义的 GraphQL 对象类型。这些对象类型表示 API 返回的数据。它们类似于我们在 TypeScript 中定义的自定义类型。一个类型有一个名称,并可以实现一个接口。每个自定义对象类型包含字段,每个字段都有名称和类型。GraphQL 有内建的标量类型 Int、Float、String、Boolean 和 ID。感叹号 (!) 表示不可为空的字段,而方括号 ([]) 表示数组。

第一个自定义对象类型是 LocationWeatherType,用于描述天气查询的位置信息。这里我们使用 String! 表达式将 ZIP 字段标记为非空。由此,GraphQL 服务始终会为此字段返回一个值。我们将 friends 字段定义为一个字符串数组,表示通过 ZIP 代码关联的天气站。它也是非空的,因此在添加到返回值中时,它将始终包含一个数组(零个或多个项)。friends 数组中的 String 确保每个项都是一个字符串。

然后,我们为第一个变更定义输入类型对象。这些类型在变更中是必要的,它们代表从 API 消费者处接收到的输入。因为消费者应该仅传入他们想要更新的字段,所以我们省略了感叹号,使字段变为可选。在 GraphQL 中,我们需要分别为返回值定义输入对象和类型,使用内建类型。与 TypeScript 不同,我们不能使用通用自定义类型。

模式还定义了查询和变更函数。这些是消费者可以发送到 API 的操作。weather 查询将 ZIP 代码作为参数,并始终返回一个 WeatherLocationType 对象数组。weather 变更函数接受一个 WeatherLocationInput 参数,并始终返回修改后的 WeatherLocationType 对象。

我们的模式中没有包含任何指令,且在本章中我们不会深入探讨它们的语法。然而,使用指令的一个原因是缓存。由于 GraphQL 查询使用 POST 方法,而默认的 HTTP 缓存无法缓存 POST 请求,因此我们必须在服务器端手动实现缓存。我们可以通过在类型定义中静态配置指令 @cacheControl 或在解析器函数中动态配置 cacheControl.setCacheHint 来实现缓存。

解析器

在 GraphQL 中,解析器是实现模式的函数。每个解析器函数映射到一个字段。查询解析器实现数据的读取,而变更解析器实现数据的创建、更新和删除。它们共同提供完整的 CRUD 功能。

为了理解解析器是如何工作的,你可以将每个 GraphQL 操作看作是一个嵌套函数调用的树。在这样的抽象语法树(AST)中,操作的每个部分代表一个节点。例如,考虑一个复杂的嵌套 GraphQL 查询,它请求位置的当前天气以及所有邻居的天气。我们为此示例设计的 GraphQL 架构如清单 6-3 所示。

export const typeDefs = gql`

    **type FriendsType {**
 **zip: String!**
 **weather: String!**
    **}**

    type LocationWeatherType {
        zip: String!
        weather: String!
        tempC: String!
        tempF: String!
        friends: [**FriendsType**]!
    }

    type Query {
        weather(zip: String): [LocationWeatherType]!
    }
`; 

清单 6-3:嵌套 GraphQL 查询示例的 GraphQL 架构

在这个示例的架构中,我们替换了friends数组的内容。我们希望它包含一个对象,而不是一个简单的字符串,这个对象包含邮政编码和天气信息。因此,我们定义了一个新的FriendsType并使用该类型作为数组项的类型。

清单 6-4 定义了复杂的示例查询。

query GetWeatherWithFriends {
    weather(zip: "96815") {
        weather
        friends {
            weather
        }
    }
} 

清单 6-4:嵌套的 GraphQL 查询

这个查询接受 zip 参数 96815,然后返回其 weather 属性,以及所有朋友的 weather 属性,作为字符串。但这个查询在幕后是如何工作的呢?

图 6-1 展示了解析器链和相应的 AST。GraphQL 服务器首先将查询解析成这种结构,然后将 AST 与类型定义架构进行验证,以确保查询可以在不遇到逻辑问题的情况下执行。最后,服务器执行查询。

图 6-1:查询 GraphQL AST

让我们来检查这个查询的解析器链。Query.weather 函数接受一个参数,即邮政编码,并返回该邮政编码对应的 location 对象。然后,服务器分别沿着每个分支继续执行。对于查询中的天气,它返回 location 对象的 weather 属性,即 Location.weather,此时分支结束。查询的第二部分请求所有来自 location 对象的朋友及其 weather 属性,它执行 Location.Friends 查询,然后返回每个结果的 Friends.weather 属性。每个步骤的解析器对象包含由父字段的解析器返回的结果。

让我们回到我们的天气架构并定义解析器。我们将保持这些解析器简单。在 清单 6-5 中,你可以看到它们的名称与架构中定义的名称一致。

export const resolvers = {
    Query: {
        weather: async (_: any, param: WeatherInterface) => {
            return [
                {
                    zip: param.zip,
                    weather: "sunny",
                    tempC: "25C",
                    tempF: "70F",
                    friends: []
                }
            ];
        },
    },
    Mutation: {
        weather: async (_: any, param: {data: WeatherInterface}) => {
            return [
                {
                    zip: param.data.zip,
                    weather: "sunny",
                    tempC: "25C",
                    tempF: "70F",
                    friends: []
                }
            ];
        }
    },
}; 

清单 6-5:天气 API 的 GraphQL 解析器

我们首先为查询和变更属性定义异步函数,并将对象赋值给常量 resolvers。每个函数都有两个参数。第一个参数表示解析器链中前一个解析器对象。我们没有使用嵌套或复杂的查询;因此,在这里它始终是未定义的。为此,我们使用 any 类型来避免 TypeScript 错误,并使用你在 第三章 中学到的下划线 (_) 约定将其标记为未使用。第二个参数是一个包含在调用时传递给函数的数据的对象。对于 weather 查询和 weather 变更,它是一个实现了 WeatherInterface 的对象。

目前,两个函数大部分时间忽略这个参数,仅使用 zip 属性来反映输入。此外,它们返回一个类似于我们在前面列表中创建的 REST API 的静态 JSON 对象。静态数据仅是一个占位符,稍后我们将用来自数据库查询的结果替换它。该响应遵循我们在 GraphQL 架构中定义的 API 合同,因为这些数据是包含天气位置数据集的数组。

将 GraphQL 与 REST 进行比较

我们已经在 Next.js 应用程序中实现了 RESTful API,正如你在本章中看到的,REST 相对简单易用。你可能会想,为什么还要考虑使用 GraphQL。其实,GraphQL 解决了 REST API 中常见的两个问题:过度获取(over-fetching)和获取不足(under-fetching)。

过度获取(Over-Fetching)

当客户端查询 REST 端点时,API 总是返回该端点的完整数据集。这意味着,通常情况下,API 返回的数据比实际需要的要多,这是一个常见的性能问题,称为 过度获取。例如,我们的示例 RESTful 天气 API 在 /api/v2/weather/zip/96815 返回该邮政编码的所有天气数据,即使你只需要摄氏温度。你还需要手动筛选结果。而在 GraphQL 中,API 请求明确指定它们想要返回的数据。

让我们来看一个例子,看看 GraphQL 如何让我们将 API 响应数据保持在最低限度。以下 GraphQL 查询仅返回 ZIP 码为 96815 的位置的摄氏温度:

query Weather {
    weather(zip: "96815") {
        tempC
    }
} 

在 GraphQL 中,我们通过 POST 请求的数据将查询作为 JSON 字符串发送:

$ **curl -i \**
 **-X POST \**
 **-H "Accept: application/json" \**
 **-H "Authorization: Bearer 83dedad0728baaef3ad3f50bd05ed030" \**
 **-H "Content-Type: application/json" \**
 **-d '{"query":"\nquery Weather  {\n  weather(zip: \"96815\") {\n    tempC  \n  }\n}"}' \**
 **https://www.usemodernfullstack.dev/api/graphql** 

我们使用 POST 请求访问 /api/graphql 端点,设置 Content-Type 和 Accept 头为 JSON,明确告知 API 我们正在请求体中发送一个 JSON 对象,并期待 JSON 响应。我们像在 RESTful 请求中一样,在 Authorization 头中设置访问令牌。POST 请求体包含天气数据的查询,\n 控制字符表示 GraphQL 查询中的换行符。如合同所定义,查询期望一个参数 zip,我们传入了邮政编码 96815。另外,我们请求 API 仅返回天气节点中的 tempC 字段。

下面是来自 GraphQL API 的响应:

HTTP/2 200
content-type: application/json ; charset=utf-8
access-control-allow-origin: *

{"data":{"weather":[{"tempC":"25C"}]}} 

API 以 200 状态码响应。我们在请求的查询中指定,只关心 weather 对象中的 tempC 字段,所以这就是我们接收到的数据。API 不返回邮政编码、华氏温度、天气字符串或朋友数组。

欠取数据

另一方面,REST 数据集可能不包含您所需的所有数据,这需要您发送后续请求。这种问题被称为 欠取数据。假设您的朋友也有气象站,您想获取他们邮政编码的当前天气。RESTful 天气 API 返回一个包含相关邮政编码(friends)的数组。然而,您需要为每个邮政编码发出额外的请求,以获取他们的天气信息,这可能导致性能问题。

GraphQL 将数据集视为图中的节点,并且它们之间存在关系。因此,扩展单一查询以接收相关数据非常简单。我们的示例 GraphQL 服务器的解析器已设置好,如果请求的查询包含 friends 字段,则会获取关于朋友的额外数据。我们定义 GraphQL 查询如下:

query Weather {
    weather(zip: "96815") {
        tempC
        friends {
            tempC
        }
    }
} 

以下是一个示例请求,展示如何通过 friends 数组获取所有相关节点。再次地,我们定义返回数据并仅查询 tempC 字段:

$ **curl -i \**
 **-X POST \**
 **-H "Accept: application/json" \**
 **-H "Authorization: Bearer 83dedad0728baaef3ad3f50bd05ed030" \**
 **-H "Content-Type: application/json" \**
 **-d '{"query":"query Weather  {\n  weather(zip: \"96815\")**
 **{\n    tempC\n    friends {\n      tempC\n    }\n  }\n}"}' \**
 **https://www.usemodernfullstack.dev/api/graphql** 

POST 请求体包含了针对 96815 邮政编码的天气数据查询,要求返回 tempC 字段,和之前的请求一样。为了扩展查询,我们在 friends 字段上添加了一个子选择。现在,GraphQL 会遍历相关节点及其字段,并返回与 96815 节点的 friends 数组中的邮政编码匹配的节点的 tempC 字段。

这是来自 GraphQL 服务器的响应。我们看到它包含了相关节点的数据:

HTTP/2 200
content-type: application/json ; charset=utf-8
access-control-allow-origin: *

{"data":{"weather":[{"tempC":"25C","friends":
[{"tempC":"20C"},{"tempC":"30C"}]}]}} 

正如你已经发现的,GraphQL 让我们通过调整请求中的数据轻松扩展查询。

练习 6:将 GraphQL API 添加到 Next.js

让我们重新设计天气应用程序的 API 来使用 GraphQL。为此,我们必须首先将 GraphQL 添加到项目中。GraphQL 不是一种模式,而是一个由服务器和查询语言组成的环境,我们必须将它们都添加到 Next.js 中。

我们将安装独立的 Apollo 服务器,它是最流行的 GraphQL 服务器之一,也提供了 Next.js 的集成。打开终端并导航到你在 第五章 中构建的重构应用程序。在目录的顶层,靠近 package.json 文件,执行以下命令:

$ **npm install @apollo/server @as-integrations/next graphql graphql-tag**

这个命令还会安装我们所需的 GraphQL 语言和 GraphQL 标签模块。

创建 Schema

正如我们所讨论的,每个 GraphQL API 都是从 schema 定义开始的。在 Next.js 目录中创建一个名为 graphql 的文件夹,紧挨着 pages 文件夹。这将是我们添加所有 GraphQL 相关文件的地方。

现在创建一个名为 schema.ts 的文件,并粘贴你在 清单 6-2 中写的代码。我们已经定义并讨论了这里使用的类型定义。只需在文件顶部添加一行:

import gql from "graphql-tag";

这一行导入了我们用来定义 schema 的 qql 标记模板字面量。

添加数据

我们希望我们的 API 根据传递给它的查询的参数和属性返回不同的数据。因此,我们需要将数据集添加到项目中。GraphQL 可以查询任何数据库,甚至是静态的 JSON 数据。所以让我们实现一个 JSON 数据集。在 graphql 目录中创建 data.ts 文件,并添加来自 清单 6-6 的代码。

export const db = [
    {
        zip: "96815",
        weather: "sunny",
        tempC: "25C",
        tempF: "70F",
        friends: ["96814", "96826"]
    },
    {
        zip: "96826",
        weather: "sunny",
        tempC: "30C",
        tempF: "86F",
        friends: ["96814", "96814"]
    },
    {
        zip: "96814",
        weather: "sunny",
        tempC: "20C",
        tempF: "68F",
        friends: ["96815", "96826"]
    }
]; 

清单 6-6:用于 GraphQL API 的 graphql/data.ts 文件

这个 JSON 定义了三个天气位置及其属性。消费者将能够查询我们的 API 获取这些数据集。

实现解析器

现在我们可以定义解析器了。将文件 resolvers.ts 添加到 graphql 目录中,并粘贴 Listing 6-7 中的代码。这与我们之前讨论的代码类似,当时我们介绍了解析器,但是这次我们查询的是我们新的数据集,而不是返回相同的静态 JSON 对象给消费者。

import {db} from "./data";

declare interface WeatherInterface {
    zip: string;
    weather: string;
    tempC: string;
    tempF: string;
    friends: string[];
}

export const resolvers = {
    Query: {
        weather: async (_: any, param: WeatherInterface) => {
            return [db.find((item) => item.zip === param.zip)];
        }
    },
    Mutation: {
        weather: async (_: any, param: {data: WeatherInterface}) => {
            return [db.find((item) => item.zip === param.data.zip)];
        }
    }
}; 

Listing 6-7: GraphQL API 的 graphql/resolvers.ts 文件

我们导入之前创建的 JSON 对象数组,并为解析器定义接口。查询解析器通过使用传入的邮政编码查找对象,并将其返回给 Apollo 服务器。突变(mutation)执行相同的操作,只是参数结构稍有不同:它可以通过data属性访问。不幸的是,我们实际上无法通过使用突变来更改数据,因为数据是静态的 JSON 文件。我们在这里实现突变只是为了说明问题。

创建 API 路由

Apollo GraphQL 服务器暴露一个端点,graphql/,我们现在将实现它。创建一个新的文件,graphql.ts,并将 Listing 6-8 中的代码添加到 api 文件夹中。此代码初始化 GraphQL 服务器,并添加 CORS 头,以便我们可以从不同的域访问 API,并稍后使用内置的 GraphQL 沙盒浏览器来玩转 GraphQL。你在之前的 cURL 响应中看到过这个头部。

import {ApolloServer} from "@apollo/server";
import {startServerAndCreateNextHandler} from "@as-integrations/next";
import {resolvers} from "../../graphql/resolvers";
import {typeDefs} from "../../graphql/schema";
import {NextApiHandler, NextApiRequest, NextApiResponse} from "next";

//@ts-ignore
const server = new ApolloServer({
    resolvers,
    typeDefs
});

const handler = startServerAndCreateNextHandler(server);

const allowCors =
    (fn: NextApiHandler) => async (req: NextApiRequest, res: NextApiResponse) => {
        res.setHeader("Allow", "POST");
 res.setHeader("Access-Control-Allow-Origin", "*");
        res.setHeader("Access-Control-Allow-Methods", "POST");
        res.setHeader("Access-Control-Allow-Headers", "*");
        res.setHeader("Access-Control-Allow-Credentials", "true");

        if (req.method === "OPTIONS") {
            res.status(200).end();
        }
        return await fn(req, res);
    };

export default allowCors(handler); 

Listing 6-8: 创建 GraphQL 的 API 入口点的 api/graphql.ts 文件

这些代码就是我们创建 GraphQL 入口点所需的全部内容。首先,我们导入必要的模块,包括我们之前创建的 GraphQL schema 和解析器。然后,我们使用 typedefs 和 resolvers 初始化一个新的 GraphQL 服务器。

我们启动服务器并继续创建 API 处理程序。为此,我们使用 Next.js 集成助手来启动服务器并返回 Next.js 处理程序。集成助手将无服务器 Apollo 实例连接到 Next.js 自定义服务器。在我们将默认导出定义为一个异步函数(该函数以 API 的请求和响应对象作为参数)之前,我们创建了一个包装器,将 CORS 头部添加到请求中。函数中的第一块代码设置了 CORS 头部,并且我们将允许的请求限制为 POST 请求。我们在这里需要 CORS 头部,以使我们的 GraphQL API 对外公开。否则,我们将无法从运行在不同域上的网站连接到 API,甚至无法使用服务器内置的 GraphQL 沙盒。

CORS 设置的一部分是在这里我们立即对任何 OPTIONS 请求返回 200。CORS 模式使用 OPTIONS 请求作为预检检查。在这里,浏览器仅请求头部,然后检查响应的 CORS 头部,以验证从哪个域调用 API 是否被允许访问资源,然后再发起实际请求。

然而,我们的 Apollo 服务器只允许 POST 和 GET 请求,并且会对预检的 OPTIONS 请求返回 405: 方法不允许。因此,我们不会将此请求传递给 Apollo 服务器,而是结束该请求并返回 200 及之前的 CORS 头信息。浏览器应继续按照 CORS 模式处理。最后,我们启动服务器并在所需路径 api/graphql 上创建 API 处理程序。

使用 Apollo 沙盒

使用 npm run dev 启动你的 Next.js 服务器。你应该能看到 Next.js 应用程序正在 http://localhost:3000 上运行。如果你导航到 GraphQL API 地址 http://localhost:3000/api/graphql,你将看到 Apollo 沙盒接口,供查询 API,如 图 6-2 所示。

图 6-2:Apollo 沙盒的 API 查询界面

在左侧的文档面板中,我们看到可用的查询作为我们之前定义的查询对象的字段。如预期所示,我们在这里看到 Weather 查询,点击它后,新的查询会出现在中间的操作面板中。同时,界面发生变化,我们可以看到可用的参数和字段,点击每个字段可以查看更多信息。使用加号 (+) 按钮,我们可以向查询面板添加字段并将其应用于数据。

尝试创建一个返回 zip 和 weather 属性的 Weather 查询。此查询需要一个邮政编码作为参数;通过左侧的用户界面添加该参数,然后将邮政编码 96826 作为字符串添加到下方面板的 variables 部分中的 JSON 对象。现在点击操作面板顶部的 Weather 按钮来运行查询。你应该会在右侧的响应面板中看到该邮政编码的 JSON 结果。将你的屏幕与 图 6-3 进行比较。

图 6-3:从服务器返回的 GraphQL 查询和响应

尝试创建查询、访问属性,并通过无效的参数创建错误,熟悉一下 GraphQL,然后再进入下一章。

总结

本章探讨了 RESTful 和 GraphQL Web API 及其在全栈开发中的作用。虽然在前几章中我们使用了 REST 设计,但你现在应该已经熟悉了无状态服务器的概念,以及在 REST 中执行 CRUD 操作的五种 HTTP 方法。你还练习了使用公共 REST API 读取和更新数据,并评估其请求和响应。

GraphQL API 的实现需要更多的工作,但它们减少了 REST 中常见的过度抓取和抓取不足的问题。你学习了如何通过架构定义 API 契约,并通过解析器实现其功能。然后,你查询了一个 API 并定义了请求中返回的数据集。

最后,你通过将 Apollo 服务器添加到现有的 Next.js 应用程序中,添加了一个 GraphQL API。你现在应该能够创建自己的 GraphQL API 并消费第三方资源。想要了解更多关于 GraphQL 的信息,我推荐https://www.howtographql.com上的教程,以及https://graphql.org/learn/上的官方 GraphQL 介绍。

在下一章中,你将探索 MongoDB 数据库和用于存储数据的对象数据建模库 Mongoose。

第七章:7 MONGODB 和 MONGOOSE

大多数应用程序依赖于数据库管理系统,简称数据库,来组织和授予对数据集集合的访问权限。在本章中,你将使用 MongoDB 非关系型数据库和 Mongoose 作为其附带的对象映射工具。

因为 MongoDB 以 JSON 格式返回数据,并使用 JavaScript 进行数据库查询,它为全栈 JavaScript 开发人员提供了自然的选择。在接下来的章节中,你将学习如何创建一个 Mongoose 模型,通过它你可以查询数据库,简化与 MongoDB 的交互,并编写中间件将前端与后端数据库连接起来。你还将编写服务函数来实现对数据库的四个 CRUD 操作。

在 第七章练习(第 125 页)中,你将为你在第六章中创建的 GraphQL API 添加一个数据库,替换当前的静态数据存储。

应用程序如何使用数据库和对象关系映射器

一个应用程序需要数据库来存储和操作数据。在本书的前面部分,我们的应用程序的 API 仅返回了预定义的数据集,这些数据集存储在文件中,且无法更改。我们使用请求中的参数来添加到数据集中,但不能在不同的 API 调用之间存储数据(这被称为数据持久化)。例如,如果我们想要更新应用程序的天气信息,我们需要一个数据库来持久化数据,以便下一个 API 调用可以读取它。在全栈开发中,我们通常使用数据库来存储与用户相关的数据。另一个数据库的例子是你的电子邮件客户端用来存储你消息的数据库。

为了使用数据库,我们首先需要连接到它并进行身份验证。一旦我们获得了数据访问权限,就可以执行查询来请求特定的数据集。查询返回的结果包含数据,我们的应用程序可以展示这些数据或以其他方式使用它。每一步如何实现,取决于具体使用的数据库。

使用数据库的 API 查询数据往往会显得笨拙,因为它通常需要大量的样板代码,即便只是建立和维护连接。因此,我们通常使用对象关系映射器对象数据建模工具,通过抽象一些细节来简化与数据库的交互。例如,MongoDB 的 Mongoose 对象数据建模工具为我们处理数据库连接,避免了我们在每次交互时都需要检查数据库连接是否开启。

Mongoose 还简化了 MongoDB 在独立数据库服务器上运行的处理方式。使用分布式系统需要进行异步调用,这点你在第二章中已经学过。使用 Mongoose,我们可以通过面向对象的 async/await 接口来访问数据,而不需要使用繁琐的回调函数。

此外,MongoDB 是无模式的;它不要求我们预定义并严格遵守模式。虽然这种灵活性很方便,但它也是常见错误的来源,尤其是在大型应用程序或开发者团队不断变动的项目中。在第三章中,我们讨论了通过使用 TypeScript 为 JavaScript 添加类型的好处。Mongoose 通过类似的方式对 MongoDB 的数据模型进行类型化并验证其完整性,正如你将在“定义 Mongoose 模型”(第 118 页)中发现的那样。

关系型与非关系型数据库

数据库可以以多种方式组织数据,这些方式主要分为两大类:关系型和非关系型。关系型数据库,如 MySQL 和 PostgreSQL,数据存储在一个或多个表中。你可以把这些数据库想象成类似于 Excel 电子表格。与 Excel 类似,每个表都有一个唯一名称,并包含列和行。列定义所有存储在该列中的数据的属性,如数据类型,而行包含实际的数据集,每行都有一个唯一 ID。关系型数据库使用某种变体的结构化查询语言(SQL)来进行数据库操作。

MongoDB 是一个非关系型数据库。与传统的关系型数据库不同,它以 JSON 文档的形式存储数据,而不是以表格形式存储,并且不使用 SQL。非关系型数据库有时被称为NoSQL,它们可以以多种不同格式存储数据。例如,流行的 NoSQL 数据库 Redis 和 Memcached 使用键值存储,这使它们具有高性能和易于扩展的特点。因此,它们常被用作内存缓存。另一个 NoSQL 数据库,Neo4j,是一个图形数据库,它使用图论将数据存储为节点,这个概念我们在第六章中提到过。这些只是非关系型数据库的一些例子。

MongoDB 是最广泛使用的文档数据库;它不是通过表格、行和列来组织数据,而是通过集合、文档和字段。字段是数据库中最小的单位,它定义数据类型和其他属性,并包含实际数据。你可以将其视为 SQL 表中的列的粗略等价物。文档由字段构成,类似于 SQL 表中的行。我们有时称它们为记录,MongoDB 使用 BSON,即 JSON 对象的二进制表示,来存储它们。集合大致等同于 SQL 表,但它不是由行和列组成,而是聚合了文档。

由于非关系型数据库可以以不同格式存储数据,因此每个数据库使用特定的、优化过的查询语言进行 CRUD 操作。这些低级 API 关注的是访问和操作数据,而不一定是开发者体验。相比之下,面向对象的关系映射工具提供了高级抽象,拥有清晰简化的查询语言接口。因此,虽然 MongoDB 有 MongoDB 查询语言(MQL),我们将使用 Mongoose 来访问它。

设置 MongoDB 和 Mongoose

在开始使用 MongoDB 和 Mongoose 之前,必须将它们添加到您的示例项目中。为了简化,我们将使用 MongoDB 的内存实现,而不是在机器上安装和维护真实的数据库服务器。这对于测试本章示例是合适的,但不适用于部署实际的应用程序,因为它在重启时不会持久化数据。当您在第二部分构建食品查找应用时,您将获得设置真实 MongoDB 服务器的经验。第十一章将展示如何使用预构建的 Docker 容器,该容器包含 MongoDB 服务器。

在第六章的重构版 Next.js 应用的根目录下运行此命令:

$ **npm install mongodb-memory-server mongoose**

然后,在根目录中创建两个新文件夹,位于 package.json 文件旁边:一个用于 Mongoose 代码,命名为 mongoose,并在其中创建子文件夹 weather;另一个命名为 middleware,用于存放所需的中间件。

定义 Mongoose 模型

为了验证我们数据的完整性,我们必须创建一个基于架构的 Mongoose 模型,它充当与数据库中 MongoDB 集合的直接接口。所有与数据库的交互将通过该模型进行。然而,在创建模型之前,我们需要先创建架构本身,架构定义了数据库数据的结构,并将 Mongoose 实例映射到集合中的文档。

我们的 Mongoose 架构将与第六章中为 GraphQL API 创建的架构相匹配。这是因为我们将在第 125 页的练习 7 中将 GraphQL API 连接到数据库,从而允许我们用从数据库查询的数据集替换静态 JSON 对象。

接口

在用 TypeScript 编写 Mongoose 模型和架构之前,让我们先声明一个 TypeScript 接口。如果没有匹配的接口,我们将无法为 TSC 类型化模型或架构,代码也无法编译。将列表 7-1 中显示的代码粘贴到 mongoose/weather/interface.ts 文件中。

export declare interface WeatherInterface {
    zip: string;
    weather: string;
    tempC: string;
    tempF: string;
    friends: string[];
}; 

列表 7-1:Mongoose 天气模型的接口

这段代码是一个常规的 TypeScript 接口,属性与 GraphQL 和 Mongoose 架构相匹配。

架构

列表 7-2 展示了 Mongoose 架构。它的顶层属性代表文档中的字段。每个字段都有一个类型和一个标志,指示该字段是否是必需的。字段还可以具有其他可选属性,例如自定义或内建的验证器。这里我们使用了内建的 required 验证器;其他常见的内建验证器包括用于字符串的 minlength 和 maxlength,以及用于数字的 min 和 max。将代码添加到 mongoose/weather/schema.ts 文件中。

import {Schema} from "mongoose";
import {WeatherInterface} from "./interface";

export const WeatherSchema = new Schema<WeatherInterface>({
    zip: {
        type: "String",
        required: true,
    },
    weather: {
        type: "String",
        required: true,
    },
    tempC: {
        type: "String",
        required: true,
    },
    tempF: {
        type: "String",
        required: true,
    },
    friends: {
        type: ["String"],
        required: true,
    },
}); 

列表 7-2:Mongoose 天气模型的架构

我们使用传递给架构构造函数的对象来创建架构,并将 WeatherInterface 设置为其 SchemaType。因此,我们从 mongoose 包中导入 Schema 函数,并导入之前创建的接口。

类似于 TypeScript 为 JavaScript 添加自定义类型,Mongoose 会将每个属性转换为其关联的 SchemaType,该类型提供模型的配置。可用的类型包括内建的 JavaScript 类型,如 Array、Boolean、Date、Number 和 String,以及自定义类型,如 Buffer 和 ObjectId,后者指的是 Mongoose 在创建每个文档时添加的默认唯一 _id 属性。这类似于你可能知道的关系数据库中的主键。

我们在第六章中创建的天气 API 返回了一个包含四个属性的对象:zip、weather、tempC和tempF,每个属性的值都是字符串。此外,friends 属性中包含一个字符串数组。在这个架构中,我们定义了相同的属性,然后导出该架构。

模型

既然我们已经有了一个模式,现在可以创建 Mongoose 模型了。这个模式的封装器将提供对集合中 MongoDB 文档的访问,以执行所有的 CRUD 操作。我们在 mongoose/weather/model.ts 文件中编写模型,其代码位于清单 7-3。请记住,我们还没有将其连接到服务器上的 MongoDB 数据库。

import mongoose, {model} from "mongoose";
import {WeatherInterface} from "./interface";
import {WeatherSchema} from "./schema";

export default mongoose.models.Weather ||
    model<WeatherInterface>("Weather", WeatherSchema); 

清单 7-3:Mongoose 天气模型

首先,我们导入 Mongoose 模块和来自 mongoose 包的模型构造函数,以及我们之前创建的接口和模式。然后,我们设置 Weather 模型,使用 WeatherInterface 来为其指定类型。我们传入两个参数:模型的名称 Weather 和定义模型内部数据结构的模式。Mongoose 会将新创建的模型绑定到我们 MongoDB 实例的集合上。Weathers 集合位于 Weather 数据库中,两个都会由 Mongoose 创建。请注意,在创建新模型之前,我们需要检查 mongoose.models 上是否已存在 Weather 模型;否则,Mongoose 将抛出错误。我们导出该模型,以便在后续的模块中使用它。

数据库连接中间件

本书到目前为止,我们多次提到全栈开发涵盖了应用程序的前端、后端和中间件,后者通常也被称为“应用程序粘合剂”。现在是时候创建我们的第一个专用中间件了。

这个中间件将打开与数据库的连接,然后使用 Mongoose 的异步辅助函数保持该连接。接下来,它将把 Mongoose 的模型映射到 MongoDB 集合,以便我们通过 Mongoose 访问它们。方便的是,连接助手将缓冲操作,并在必要时重新连接到数据库,因此我们不需要自己处理连接问题。将代码从清单 7-4 粘贴到 middleware/db-connect.ts 文件中。

import mongoose from "mongoose";
import {MongoMemoryServer} from "mongodb-memory-server";

async function dbConnect(): Promise<any | String> {
    const mongoServer = await MongoMemoryServer.create();
    const MONGOIO_URI = mongoServer.getUri();
    await mongoose.disconnect();
    await mongoose.connect(MONGOIO_URI, {
        dbName: "Weather"
    });
}

export default dbConnect; 

清单 7-4:Mongoose 中间件

我们导入 mongoose 包和 mongodb-memory-server 数据库。我们定义并导出的异步函数 dbConnect 通过 mongoose.connect 函数管理与数据库服务器的连接。我们创建一个 MongoMemoryServer 实例,将数据保存在内存中,而不是使用真实的数据库服务器,如前所述。然后,我们将连接字符串存储在常量 MONGOIO_URI 中。由于我们使用的是内存服务器,这个字符串是动态的,但对于远程数据库,它将是一个表示数据库服务器地址的静态字符串。接着,我们关闭所有现有的连接,并使用 Mongoose 打开一个新连接。Mongoose 模型已经映射并可用,因此我们已经准备好执行我们的第一个查询。

查询数据库

现在是编写数据库查询的时候了。你应该将这些查询提取为服务,而不是在应用程序代码中随意分散这些查询或直接在 GraphQL 解析器中编写它们。

服务是执行实际 CRUD 操作并返回结果的函数。每个 GraphQL 解析器可以调用一个服务函数,所有的数据库访问都应通过这些函数进行。此外,每个服务应只负责一个特定的 CRUD 操作。Mongoose 会自动排队命令并执行它们,保持连接,并在与数据库建立连接后立即处理队列。

本节介绍了服务功能和基本的 Mongoose 命令。然而,这并不是一个完整的参考。当你开始在自己的项目中使用 Mongoose 时,请查阅 Mongoose 文档以获取所有需要的功能。

创建文档

第一个也是最基本的操作是“创建”操作。它被方便地称为 mongoose.create,幸运的是,我们可以用它来创建和更新数据集。这是因为如果数据条目尚不存在,Mongoose 会自动创建一个新的数据库条目或文档。因此,我们无需先检查数据集是否存在,然后再有条件地创建它再进行更新。

列表 7-5 展示了一个基本的服务函数实现,该函数将数据集存储到数据库中。将代码放入 mongoose/weather/services.ts 文件中。

import WeatherModel from "./model";
import {WeatherInterface} from "./interface";

export async function storeDocument(doc: WeatherInterface): Promise<boolean> {
    try {
        await WeatherModel.create(doc);
    } catch (error) {
        return false;
    }
    return true;
} 

列表 7-5:通过 Mongoose 创建文档

为了存储文档,我们创建并导出异步函数 storeDocument,该函数以数据集作为参数。这里我们将其类型设为 WeatherInterface。然后,我们在模型上调用 create 函数,并将数据集传递给它。该函数将创建并插入文档到 WeatherModel 中,该模型是 MongoDB 实例中的天气集合。最后,它返回一个布尔值,表示操作的状态。

读取文档

为了实现“读取”操作,我们通过 Mongoose 的 findOne 函数查询 MongoDB。它接受一个参数——一个包含要查找属性的对象,并返回第一个匹配项。通过 清单 7-6 中的代码,扩展 mongoose/weather/services.ts 文件。它定义了一个 findByZip 函数,用于查找并返回 Weathers 集合中第一个 zip 属性与传递给函数的 ZIP 代码匹配的文档。

export async function findByZip(
    paramZip: string
): Promise<Array<WeatherInterface> | null> {
    try {
        return await WeatherModel.findOne({zip: paramZip});
    } catch (err) {
        console.log(err);
    }
    return [];
} 

清单 7-6:通过 Mongoose 读取数据

我们向 services.ts 文件中的服务添加并导出异步函数 readByZip。该函数接受一个字符串参数——ZIP 代码,并返回一个包含文档的数组或一个空数组。在新的服务函数内部,我们在模型上调用 Mongoose 的 findOne 函数,并传递一个过滤对象,查找其 zip 字段与参数值匹配的文档。最后,函数返回结果或 null。

更新文档

我们提到过,可以使用 create 函数来更新文档。然而,也有一个专门用于此任务的 API:updateOne。它接受两个参数。第一个是过滤对象,类似于我们在 findOne 中使用的过滤器,第二个是包含新值的对象。你可以将 updateOne 看作是 “find” 和 “create” 函数的结合。通过 清单 7-7 中的代码,扩展 mongoose/weather/services.ts 文件。

export async function updateByZip(
    paramZip: string,
    newData: WeatherInterface
): Promise<boolean> {
    try {
        await WeatherModel.updateOne({zip: paramZip}, newData);
        return true;
    } catch (err) {
        console.log(err);
    }
    return false;
} 

清单 7-7:通过 Mongoose 更新数据

我们添加到服务中的 updateByZip 函数接受两个参数。第一个是字符串 paramZip,它是我们用来查询要更新的文档的邮政编码。第二个参数是新的数据集,我们将其类型定义为 WeatherInterface。我们在模型上调用 Mongoose 的 updateOne 函数,传入一个过滤器对象和最新的数据。该函数应返回一个布尔值,表示操作状态。

删除文档

我们需要实现的最后一个 CRUD 操作是一个删除文档的服务。为此,我们使用 Mongoose 的 deleteOne 函数,并将 Listing 7-8 中的代码添加到 mongoose/weather/services.ts 文件中。它与 findOne 函数类似,不同之处在于它直接删除查询结果。Mongoose 会排队执行这些操作,并在连接建立后自动从数据库中删除文档。

export async function deleteByZip(
        paramZip: string
    ): Promise<boolean> {
    try {
        await WeatherModel.deleteOne({zip: paramZip});
        return true;
    } catch (err) {
        console.log(err);
    }
    return false;
} 

Listing 7-8: 通过 Mongoose 删除数据

异步函数 deleteByZip 接受一个字符串参数 zip。我们使用它查询模型,找到要删除的文档,并将过滤器传递给 Mongoose 的 deleteOne 函数。该函数应返回一个布尔值。

创建一个端到端查询

在全栈开发中,端到端 通常指的是数据能够从应用程序的前端(或它的某个 API)一路传递,通过中间件到达后端,然后再回到它的原始来源。为了练习,让我们使用 REST API 的 /zipcode 端点创建一个简单的端到端示例。

我们将修改 API,以便从 URL 中获取查询参数,查找数据库中对应邮政编码的 weather 对象,然后返回它,实际上是用动态查询结果替换了静态的 JSON 响应。修改文件 pages/api/v1/weather/[zipcode].ts 以匹配 Listing 7-9。

import type {NextApiRequest, NextApiResponse} from "next";
**import {findByZip} from "./../../../../mongoose/weather/services";**
import dbConnect from "./../../../..//middleware/db-connect";
**dbConnect();**

export default async function handler(
    req: NextApiRequest,
    res: NextApiResponse
): Promise<NextApiResponse<WeatherDetailType> | void> {
 **let data** **= await findByZip(req.query.zipcode as string);**
    return res.status(200).json(**data**);
} 

Listing 7-9: 完整的 REST API

注意修改后的 API 处理程序。我们对它做了两个主要修改。首先,我们调用了 dbConnect 来连接数据库。然后,我们使用导入的 findByZip 服务,并将查询参数转换为字符串类型传递给它。与之前使用静态 JSON 对象不同,我们现在返回从服务函数接收到的动态 data。

在我们能够接收 API 调用响应数据之前,还需要执行一步:初始化数据,即向数据库中添加初始数据集。为了简化操作,我们使用 storeDocuments 服务,并直接在 dbConnect 函数中进行初始化。修改 middleware/db-connect.ts 文件,使其与 列表 7-10 中的代码一致,该代码导入了 storeDocument 服务,并在建立数据库连接后添加数据集。

import mongoose from "mongoose";
import {MongoMemoryServer} from "mongodb-memory-server";
**import {storeDocument} from** **"****../mongoose/weather/services****"****;**

async function dbConnect(): Promise<any | String> {
    const mongoServer = await MongoMemoryServer.create();
    const MONGOIO_URI = mongoServer.getUri();
    await mongoose.disconnect();

    let db = await mongoose.connect(MONGOIO_URI, {
        dbName: "Weather"
    });

    **await storeDocument({**
    **zip:** **"****96815****"****,**
    **weather:** **"****sunny****"****,**
    **tempC:** **"****25C****"****,**
      **tempF:** **"****70F****"****,**
    **    friends: [****"****96814****"****,** **"****96826****"****]**
    **});**
    **await storeDocument({**
    **zip:** **"****96814****"****,**
    **weather:** **"****rainy****"****,**
    **tempC:** **"****20C****"****,**
    **tempF:** **"****68F****"****,**
    **    friends: [****"****96815****"****,** **"****96826****"****]**
    **});**
    **await storeDocument({**
        **zip:** **"****96826****"****,**
    **weather:** **"****rainy****"****,**
      **tempC:** **"****30C****"****,**
      **tempF:** **"****86F****"****,**
      **friends: [****"****96815****"****,** **"****96814****"****]**
    **});**

}
export default dbConnect; 

列表 7-10:在 dbConnect 函数中的简单数据初始化

现在我们可以执行端到端请求。在浏览器中访问 REST API 端点 http://localhost:3000/api/v1/weather/96815。你应该能看到来自 MongoDB 数据库的数据集作为 API 响应。尝试在 URL 中调整查询参数为另一个有效的邮政编码。你应该会在响应中获得另一个数据集。

练习 7:将 GraphQL API 连接到数据库

让我们重新设计天气应用的 GraphQL API,使其从数据库读取响应数据,而不是从静态的 JSON 文件中读取。代码看起来会很熟悉,因为我们将使用与前一节 REST API 示例相同的模式。

首先,验证是否已将 MongoDB 内存实现和 Mongoose 添加到你的项目中。如果没有,请按照第 117 页的《设置 MongoDB 和 Mongoose》中的说明进行添加。接下来,检查是否已创建本章中描述的 middlewaremongoose 文件夹中的文件,并确保它们包含从 列表 7-1 到 7-10 的代码。

现在,为了将 GraphQL API 连接到数据库,我们需要做两件事:实现数据库连接,并重构 GraphQL 解析器以使用其数据集。

连接到数据库

要通过 GraphQL API 查询数据库,我们需要连接到数据库。正如你在第六章中所学到的,所有 API 调用都有相同的端点,/graphql。这一点现在对我们来说非常方便;因为所有请求都使用相同的入口点,我们只需要处理一次数据库连接。因此,我们打开文件 api/graphql.ts,并将其修改为与清单 7-11 中的代码相匹配。

import {ApolloServer} from "@apollo/server";
import {startServerAndCreateNextHandler} from "@as-integrations/next";
import {resolvers} from "../../graphql/resolvers";
import {typeDefs} from "../../graphql/schema";
import {NextApiHandler, NextApiRequest, NextApiResponse} from "next";
**import dbConnect from "../../middleware/db-connect";**
//@ts-ignore
const server = new ApolloServer({
    resolvers,
    typeDefs
});

const handler = startServerAndCreateNextHandler(server);

const allowCors = (fn: NextApiHandler) =>
    async (req: NextApiRequest, res: NextApiResponse) => {
        res.setHeader("Allow", "POST");
        res.setHeader("Access-Control-Allow-Origin", "*");
        res.setHeader("Access-Control-Allow-Methods", "POST");
        res.setHeader("Access-Control-Allow-Headers", "*");
        res.setHeader("Access-Control-Allow-Credentials", "true");

        if (req.method === "OPTIONS") {
            res.status(200).end();
        }
        return await fn(req, res);
    };

**const connectDB =** (fn: NextApiHandler) =>
    async (req: NextApiRequest, res: NextApiResponse) => {
    **await dbConnect();**
        return await fn(req, res);
    };

export default **connectDB(**allowCors(handler)**)**; 

清单 7-11:包括数据库连接的 api/graphql.ts 文件

我们对文件进行了三处修改。首先,我们从中间件导入了 dbConnect 函数;然后,我们创建了一个类似于 allowCors 函数的新包装器,并使用它确保每个 API 调用都能连接到 API。我们能够安全地这样做,因为我们实现了 dbConnect 来强制保证每次只有一个数据库连接。最后,我们用新的包装器包装了处理程序,并将其作为默认导出。

将服务添加到 GraphQL 解析器

现在是时候将服务添加到解析器中了。在第六章中,你已经学习到查询解析器实现了数据的读取,而突变解析器则实现了数据的创建、更新和删除。

在这里,我们还定义了两个解析器:一个返回给定 ZIP 代码的天气对象,另一个更新某个位置的天气数据。现在,我们将把在本章中创建的服务 findByZip 和 updateByZip 添加到解析器中。我们不再使用静态数据对象的简单实现,而是修改解析器通过服务查询和更新 MongoDB 文档。

清单 7-12 展示了修改后的 graphql/resolvers.ts 文件,其中我们重构了这两个解析器。

import {WeatherInterface} from "../mongoose/weather/interface";
**import {findByZip, updateByZip} from** **"****../mongoose/weather/services****"****;**

export const resolvers = {
    Query: {
        weather: async (_: any, param: WeatherInterface) => {
            let data = **await findByZip(param.zip)**;
            return [data];
        },
    },
    Mutation: {
        weather: async (_: any, param: {data: WeatherInterface}) => {
            **await updateByZip(param.data.zip, param.data**);
            let data = await findByZip(param.data.zip);
            return [data];
        },
    },
}; 

清单 7-12:使用服务的 graphql/resolvers.ts 文件

我们用适当的服务替换了原本简单的 array.filter 功能。为了查询数据,我们使用了 findByZip 服务,并将请求负载中的 zip 变量传递给它,然后将结果数据包装在数组中返回。对于突变操作,我们使用了 updateByZip 服务。根据类型定义,weather 突变返回更新后的数据集。为此,我们再次使用 findByZip 服务查询修改后的文档,并将结果作为数组项返回。

访问 http://localhost:3000/api/graphql 上的 GraphQL 沙盒,玩转 API 端点以读取和更新 MongoDB 数据库中的文档。

总结

在本章中,你探索了使用非关系型数据库 MongoDB 以及其 Mongoose 对象数据建模工具,Mongoose 让你能够添加和强制执行模式,并对 MongoDB 实例进行 CRUD 操作。我们讲解了关系型数据库和非关系型数据库的区别以及它们存储数据的方式。然后,你创建了一个 Mongoose 模式和一个模型,将 Mongoose 连接到 MongoDB 实例,并编写了服务以在 MongoDB 集合上执行操作。

最后,你将 REST 和 GraphQL APIs 连接到了 MongoDB 数据库。现在,所有的 API 都返回动态文档,而非静态数据集,你可以通过它们进行文档的读取和更新。

MongoDB 和 Mongoose 是功能强大的技术,拥有丰富的功能。如果你想深入了解它们,请查阅官方文档 https://mongoosejs.comhttps://www.geeksforgeeks.org/mongoose-module-introduction/。

下一章将介绍 Jest,这是一个现代的测试框架,用于进行单元测试、快照测试和集成测试。

第八章:8 使用 JEST 框架进行测试

每当你修改代码时,你都面临着可能在应用程序的另一部分引发无法预见的副作用的风险。因此,保证代码库的完整性和稳定性可能具有挑战性。为了做到这一点,开发者遵循两种主要策略。

第一种策略是一种架构模式,我们将代码拆分为小的、自包含的 React 组件。这些组件本质上不会相互干扰。因此,修改其中一个不应该导致任何副作用。第二种策略是进行自动化单元测试,本章将介绍如何使用 Jest 框架进行测试。

在接下来的章节中,我们将讨论自动化单元测试的基本要素以及使用它的好处。你将学习如何在 Jest 中编写测试套件,并利用其报告来改进代码。你还将通过使用代码替身来处理依赖关系。最后,你将探索可能想要在应用程序中运行的其他类型的测试。

测试驱动开发和单元测试

开发者有时使用测试驱动开发(TDD)的技术,在实际编写待测试的代码之前先编写自动化测试。他们首先创建一个测试来评估最小的代码单元是否按预期工作。这样的测试被称为单元测试。接着,他们编写通过测试所需的最少量代码。

这种方法有明显的好处。首先,它让你通过明确地定义代码的功能和边界情况,集中精力于应用程序的需求。因此,你能清楚地了解其期望的行为,并且能更早地发现不明确或缺失的规范。当你在完成功能后编写测试时,它们可能反映的是你实现的行为,而不是你所需要的行为。

其次,限制自己只编写必要的代码可以防止你的函数变得过于复杂,并将你的应用程序拆分成小而易于理解的部分。可测试的代码是可维护的代码。此外,这种技术确保你的测试覆盖了应用程序代码的很大一部分,这个指标称为代码覆盖率,并且通过在开发过程中频繁运行测试,你会立刻识别出新代码行引入的错误。

根据情况,单元测试所针对的单元可以是一个模块、一个函数或一行代码。测试的目的是验证每个单元在独立情况下是否正常工作。每个测试函数中的单行代码就是测试的步骤,整个测试函数被称为一个测试用例。测试套件将多个测试用例聚合成逻辑块。要被认为是可重复的,测试必须在每次运行时返回相同的结果。正如我们在本章中将要探讨的那样,这意味着我们必须在一个控制的环境中运行测试,并使用定义好的数据集。

Facebook 与 React 一起开发了 Jest 测试框架,但我们可以在任何 Node.js 项目中使用它。它有一套定义好的语法来设置和编写测试。其测试运行器执行这些测试,自动替换代码中的任何依赖项,并生成测试覆盖率报告。额外的 npm 模块提供了测试 DOM 或 React 组件的自定义代码,当然,也可以添加 TypeScript 类型。

使用 Jest

要在项目中使用 Jest,我们必须安装所需的包,创建一个用于存放所有测试文件的目录,并添加一个 npm 脚本来运行测试。在 Next.js 应用程序的根目录中执行以下命令,安装框架以及来自 DefinitelyTyped 的类型定义作为开发依赖:

$ **npm install --save-dev jest @types/jest**

然后,创建一个目录来保存你的测试。Jest 默认使用 tests 文件夹,因此在根目录下创建一个。接下来,为了将 npm 脚本 test 添加到你的项目中,打开 package.json 文件,并修改 scripts 对象,使其与 清单 8-1 中的内容匹配。

 "scripts": {
      "dev": "next dev",
      "build": "next build",
      "start": "next start",
      "lint": "next lint"**,**
  **"test": "jest"**
  }, 

清单 8-1:带有新文本命令的 package.json 文件

现在我们可以使用 npm test 命令运行测试。通常,构建服务器在构建过程中默认会执行此命令。最后,为了在 Jest 中启用 TypeScript 支持,请添加 ts-jest 转译器:

$ **npm install --save-dev ts-jest**

还要创建一个 jest.config 文件,通过运行 npx ts-jest config:init 来添加 TypeScript。

创建一个示例模块以进行测试

让我们编写一些示例代码,帮助我们理解单元测试和 TDD。假设我们想在应用程序中创建一个新模块,./helpers/sum.ts。它应该导出一个名为 sum 的函数,返回其参数的总和。为了遵循 TDD 模式,我们将首先为这个模块创建测试用例。

首先,我们需要创建一个函数来运行我们的测试。在默认测试目录中创建一个名为 sum.test.ts 的文件,并添加 清单 8-2 中的代码。

import {sum} from "../helpers/sum";

describe("the sum function", () => {

}); 

清单 8-2:空测试套件

我们导入稍后将编写的 sum 函数,并使用 Jest 的 describe 函数创建一个空的测试套件。当我们使用 npm test 运行(不存在的)测试时,Jest 应该会抱怨在 helpers 目录中没有名为 sum.ts 的文件。现在在你的项目根目录中创建这个文件和文件夹。在文件中编写 Listing 8-3 所示的 sum 函数。

const sum = () => {};
export {sum}; 

Listing 8-3:sum 函数的基础骨架

现在再次使用 npm test 运行测试。由于代码只是导出了一个返回空值的占位符 sum 函数,Jest 测试运行器再次抱怨。这次,它通知我们测试套件需要包含至少一个测试。

让我们来看看一个测试用例的结构,并在这个过程中向 sum.test.ts 文件添加一些测试用例。

测试用例的结构

单元测试有两种类型:基于状态的和基于交互的。基于交互的测试用例验证被评估的代码是否调用了特定的函数,而基于状态的测试用例检查代码的返回值或结果状态。两种类型都遵循相同的三个步骤:安排、执行和断言。

安排

为了编写独立且可复现的测试,我们需要首先安排我们的环境,定义前提条件,例如测试数据。如果我们仅在一个特定的测试用例中需要这些前提条件,我们会在该用例开始时定义它们。否则,我们通过使用 beforeEach 钩子(该钩子在每个测试用例之前执行)或 beforeAll 钩子(该钩子在所有测试运行之前执行)将它们为所有测试在测试套件中设置为全局。

举例来说,如果我们有某种原因需要在每个测试用例中使用相同的全局数据集,并且知道我们的测试步骤会修改数据集,那么我们需要在每次测试前重新创建数据集。beforeEach 钩子是执行此操作的最佳位置。另一方面,如果测试用例只是消费数据,那么我们只需要定义一次数据集,因此可以使用 beforeAll 钩子。

让我们定义两个测试用例,并为每个用例创建输入值。我们的输入参数将针对每个测试用例进行特定定义,因此我们将在测试用例内部声明它们,而不是使用 beforeEachbeforeAll 钩子。使用 Listing 8-4 中的代码更新 sum.test.ts 文件。

import {sum} from "../helpers/sum";

describe("the sum function", () => {
    **test("two plus two is four", () => {**
 **let first =** **2;**
 **let second = 2;**
 **let expectation = 4;**
    });

    **test("minus eight plus four is minus four", () => {**
 **let first = -8;**
 **let second =** **4;**
 **let expectation = -4;**
    });
}); 

Listing 8-4:包含安排步骤的测试套件

describe 函数创建我们的测试套件,其中包含两次调用 test 函数,每次调用都代表一个测试用例。对于这两个用例,第一个参数是我们在测试运行器报告中看到的描述信息。

我们的每个测试都评估 sum 函数的结果。第一个测试检查加法功能,验证 2 加 2 是否返回 4。第二个测试确认该函数也能正确返回负值。它将 4 加到 −8,并期望返回 −4。

你可能还想检查 sum 函数的返回类型。通常,我们会检查返回类型,但由于我们正在使用 TypeScript,因此不需要这个额外的测试用例。相反,我们可以在函数签名中定义返回类型,TSC 会为我们验证它。

操作

一旦测试运行器执行某个用例,测试步骤就会通过使用特定测试用例的数据来调用待测试的代码,代表我们执行相应的操作。每个测试用例应测试系统的一个功能或变体。这个步骤是调用执行函数的代码行。Listing 8-5 将其添加到 sum.test.ts 的测试用例中。

import {sum} from "../helpers/sum";

describe("the sum function", () => {

    test("two plus two is four", () => {
        let first = 2;
        let second = 2;
        let expectation = 4;
 **let result = sum(first, second);**
    });

    test("minus eight plus four is minus four", () => {
        let first = -8;
        let second = 4;
        let expectation = -4;
 **let result = sum(first, second);**
    });

}); 

Listing 8-5:包含操作步骤的测试套件

我们的新代码行调用 sum 函数,并将我们定义的参数值传递给它。我们将返回的值存储在 result 变量中。在编辑器中,TSC 应该会抛出类似 Expected 0 arguments, but got 2 的错误。这是正常的,因为 sum 函数只是一个空占位符,尚未期望任何参数。

断言

我们测试用例的最后一步是断言,即代码满足我们定义的预期。我们通过两个部分创建这个断言:Jest 的expect函数,结合 Jest 的assert库中的matcher函数来定义我们测试的条件。根据单元测试的类别,这个条件可以是特定的返回值、状态变化或调用另一个函数。常见的匹配器检查值是否为数字、字符串等。我们还可以使用它们来断言一个函数返回 true 或 false。

Jest 的assert库为我们提供了一组内置的基本匹配器,我们可以从 npm 仓库中添加额外的匹配器。最常见的断言包之一是testing-library/dom,用于查询 DOM 中特定的节点并断言其特性。例如,我们可以检查类名或属性,或者与原生 DOM 事件一起使用。另一个常见的断言包是testing-library/react,它为 React 提供了实用工具,并让我们在断言中访问render函数和 React hooks。

因为每个测试用例评估一个代码单元中的一个条件,我们将每个测试限制为一个断言。这样,一旦测试运行成功或失败,测试报告生成时,我们可以轻松找出哪个测试假设失败了。清单 8-6 为每个测试用例添加了一个断言。将它粘贴到sum.test.ts文件中。

import {sum} from "../helpers/sum";

describe("the sum function", () => {

    test("two plus two is four", () => {
        let first = 2;
        let second = 2;
        let expectation = 4;
        let result = sum(first, second);
 **expect(result).toBe(expectation);**
    });

    test("minus eight plus four is minus four", () => {
        let first = -8;
        let second = 4;
        let expectation = -4;
        let result = sum(first, second);
 **expect(result).toBe(expectation);**
    });

}); 

清单 8-6:包含断言步骤的测试套件

这些行使用expect断言函数,并与toBe匹配器一起使用,以将预期结果与我们的期望进行比较。我们的测试用例现在已经完成。每个测试用例都遵循arrange, act, assert模式,并验证一个条件。附录 C 列出了其他匹配器。

使用 TDD

我们的测试用例仍未执行,如果你运行npm test,测试运行器应该会立即失败。TSC 检查代码,并且由于缺少对sum函数的参数声明,它会抛出错误:

FAIL  __tests__/sum.test.ts
  • Test suite failed to run
`--snip--`
Test Suites: 2 failed, 2 total
Tests:       0 total
Snapshots:   0 total 

是时候实现这个sum函数了。按照 TDD 的原则,我们将逐步向代码中添加功能,并在每次添加后运行测试套件,直到所有测试通过。首先,我们将添加那些缺失的参数。将sum.ts中的代码替换为清单 8-7 的内容。

const sum = (a: number, b: number) => {};

export {sum}; 

清单 8-7:带有附加参数的sum函数

我们添加了参数并将其类型指定为数字。现在我们重新运行测试用例,正如预期的那样,它们失败了。控制台输出告诉我们 sum 函数没有返回预期的结果。这不应令我们感到惊讶,因为我们的 sum 函数根本没有返回任何值:

FAIL  __tests__/sum.test.ts (5.151 s)
  the sum function
    × two plus two is four (6 ms)
    × minus eight plus four is minus four (1 ms)

  • the sum function › two plus two is four
    Expected: 4
    Received: undefined

  • the sum function › minus eight plus four is minus four
    Expected: -4
    Received: undefined

Test Suites: 1 failed, 1 total
Tests:       2 failed, 2 total
Snapshots:   0 total
Time:        5.328 s, estimated 11 s 

列表 8-8 中的代码将此功能添加到 sum.ts 文件中。我们将函数的返回类型指定为数字,并添加了两个参数。

const sum = (a: number, b: number): number => a + b;

export {sum}; 

列表 8-8:完整的 sum 函数

如果我们重新运行 npm test,Jest 应该报告所有测试用例都通过了:

PASS  __tests__/sum.test.ts (8.045 s)
  the sum function
    ✓ two plus two is four (2 ms)
    ✓ minus eight plus four is minus four (2 ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        8.291 s 

如你所见,一切正常。

重构代码

单元测试在我们需要重构代码时特别有用。例如,我们可以重写 sum 函数,使其接受一个数字数组,而不是两个参数。该函数应返回数组中所有项的和。

我们首先将现有的测试用例重写为更简洁的形式,然后扩展测试套件以验证新行为。将 sum.test.file 中的代码替换为 列表 8-9。

import {sum} from "../helpers/sum";

describe("the sum function", () => {

    test("two plus two is four", () => {
 **expect(sum([2, 2])).toBe(4);**
    });

    test("**minus eight** plus **four** is **minus four**", () => {
        expect(sum([**-8**, **4**])).toBe(**-4**);
    });

    test("**two** plus **two** plus **minus four** is **zero**", () => {
        expect(sum([**2**, **2**, **-4**])).toBe(0);
    });

}); 

列表 8-9:重构后的 sum 函数的测试套件

请注意,我们将测试用例重写为更简洁的形式。虽然将 arrange、act 和 assert 语句拆分到多行可能更易于阅读,但对于像 列表 8-9 中的简单测试用例,我们通常会将其写成一行。我们已将其功能进行了更改,以适应新需求。我们的 sum 函数不再接受两个值,而是接受一个包含数字的数组。再次提醒,TSC 会立即通知我们测试套件中的 sum 函数与实际实现之间的参数不匹配。

一旦编写了测试用例,我们就可以重写代码了。列表 8-10 展示了 helpers/sum.ts 文件的代码。在这里,sum 函数现在接受一个数字数组作为参数,并返回一个数字。

const sum = (data: number[]): number => {
    return data[0] + data[1];
};

export {sum}; 

列表 8-10:在 helpers/sum.ts 文件中重写的 sum 函数

我们将参数更改为一个数字数组。这修复了由 列表 8-9 中的测试套件引起的 TypeScript 错误。但因为我们遵循 TDD 并且每次只做一个功能性更改,我们保持了函数原有的行为,即添加两个值。正如预期的那样,当我们使用 npm test 运行自动化测试时,测试用例中的一个会失败:

FAIL  __tests__/sum.test.ts (7.804 s)
  the sum function
    ✓ two plus two is four (7 ms)
    ✓ minus eight plus four is minus four (1 ms)
    ✕ two plus two plus minus four is zero (9 ms)

  • the sum function › two plus two plus minus four is zero
    Expected: 0
    Received: 4

Test Suites: 1 failed, 1 total
Tests:       1 failed, 2 passed, 3 total
Snapshots:   0 total
Time:        8.057 s, estimated 9 s 

测试新需求的第三个测试用例失败了。我们不仅预期到这个结果,而且希望测试失败;这样我们就能确认测试本身有效。如果在我们实现相应功能之前测试就通过了,那么测试用例就是错误的。

以失败的测试作为基准,现在是时候重构代码以适应新的需求了。将 列表 8-11 中的代码粘贴到 sum.ts 文件中。在这里,我们重构了 sum 函数,使其返回所有数组值的和。

const sum = (data: number[]): number => {
    return data.reduce((a, b) => a + b);
};

export {sum}; 

列表 8-11:修正后的 sum 函数,使用 array.reduce

尽管我们可以使用 for 循环遍历数组,但我们使用现代 JavaScript 的 array.reduce 函数。这个原生数组函数会对每个数组元素运行一个回调函数。回调函数接收上一次迭代的返回值和当前数组项作为参数:这正是我们计算和所需的。

运行测试套件中的所有测试用例,验证它们是否按预期工作:

PASS  __tests__/sum.test.ts (7.422 s)
  the sum function
    ✓ two plus two is four (2 ms)
    ✓ minus eight plus four is minus four
    ✓ two plus two plus minus four is zero

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        7.613 s 

测试运行器应显示代码通过了所有测试。

评估测试覆盖率

为了准确测量我们的测试套件覆盖了哪些代码行,Jest 会生成测试覆盖率报告。我们的测试评估的代码比例越高,测试就越全面,关于应用程序质量和可维护性的信心也越强。作为一般经验法则,您应该争取达到 90% 或以上的代码覆盖率,并且在最关键的部分有较高的覆盖率。当然,测试用例应该通过测试代码的功能来增加价值;单纯为了增加测试覆盖率而添加测试并非我们的目标。但一旦彻底测试了代码库,您就可以重构现有功能并实现新功能,而不必担心引入回归性错误。高代码覆盖率验证了更改没有隐藏的副作用。

修改 package.json 文件中的 npm test 脚本,添加 --coverage 标志,如 列表 8-12 所示。

 "scripts": {
      "dev": "next dev",
      "build": "next build",
      "start": "next start",
      "lint": "next lint"**,**
  **"test": "jest --coverage"**
  }, 

列表 8-12:在 package.json 文件中启用 Jest 的测试覆盖率功能

如果我们重新运行测试套件,Jest 应该会显示我们的单元测试覆盖了代码的百分比。它会生成一个代码覆盖率报告并将其存储在coverage文件夹中。请将你的输出与以下内容进行比较:

PASS  __tests__/sum.test.ts (7.324 s)
  the sum function
    ✓ two plus two is four (2 ms)
    ✓ minus eight plus four is minus four
    ✓ two plus two plus minus four is zero (1 ms)
----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files |     100 |      100 |     100 |     100 |
  sum.ts  |     100 |      100 |     100 |     100 |
----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        7.687 s, estimated 8 s 

报告显示了按语句、分支、函数和行分解的覆盖率。我们看到我们简单的 sum 函数在所有类别中的代码覆盖率为 100%。因此,我们知道我们没有留下任何未测试的代码,并且可以信任测试用例反映了函数的质量。

用伪造、存根和模拟替代依赖关系

我们提到过,我们的测试应该在独立的环境中运行,而不依赖于外部代码。你可能会想,如何处理导入的模块?毕竟,一旦你导入代码,就会为被评估的单元添加一个依赖关系。这些第三方模块可能无法按预期工作,我们不希望我们的代码依赖于假设它们都能正确运行。因此,你应该为每个导入的模块提供一组测试用例来验证其功能。它们也是需要测试的单元。

单独地,我们需要用测试替身替换我们其他代码单元中的模块,而不是导入它们,测试替身返回一组针对测试的静态数据。测试替身替代了一个对象或函数,有效地消除了依赖关系。由于它们返回的是已定义的数据集,因此它们的响应是已知的且可预测的。你可以把它们比作电影中的替身演员。

除了替代对象或函数外,测试替身还有第二个重要目的:它们记录它们的调用并允许我们对其进行监控。因此,我们可以用它们来测试测试替身是否被调用过,调用了多少次,以及接收到哪些参数。测试替身有三种主要类型:伪造、存根和模拟。然而,你有时会听到模拟一词用于指代这三者。

创建一个带有依赖关系的模块

为了在我们的 sum 函数中练习使用测试替身,我们将创建一个新函数,计算斐波那契数列中的指定数量值。斐波那契数列是一个模式,其中每个后续数字是前两个数字的和,这是 sum 模块的一个简单用例。

所有开发者都必须确定他们的测试用例需要多么精细。斐波那契数列就是一个很好的例子,因为尝试测试提交给函数的每一个可能的数字是没有意义的,因为数列是无限的。相反,我们希望验证函数是否正确处理边界情况,并且其底层功能是否正常工作。例如,我们将检查它如何处理长度为 0 的输入;在这种情况下,函数应该返回一个空字符串。然后,我们将测试它如何计算任意长度大于 3 的斐波那契数列。在 tests 文件夹中创建 fibonacci.test.ts 测试套件,然后将示例 8-13 中的代码添加进去。

import {fibonacci} from "../helpers/fibonacci";

describe("the fibonacci sequence", () => {

    test("with a length of 0 is ", () => {
        expect(fibonacci(0)).toBe(" ");
    });

    test("with a length of 5 is '0, 1, 1, 2, 3' ", () => {
        expect(fibonacci(5)).toBe("0, 1, 1, 2, 3");
    });

}); 

示例 8-13:fibonacci 函数的测试套件

我们定义了两个测试用例:一个检查长度为 0 的输入,另一个计算一个包含五个数字的斐波那契数列。两个测试都遵循我们之前使用的紧凑版 arrange, act, assert 模式。

创建完测试用例后,我们可以继续编写斐波那契函数的代码。在 helpers 文件夹中创建 fibonacci.ts 文件,放在 sum.ts 文件旁边,然后将示例 8-14 中的代码添加进去。

import {sum} from "./sum";

const fibonacci = (length: number): string => {
    const sequence: number[] = [];
    for (let i = 0; i < length; i++) {
        if (i < 2) {
            sequence.push(sum([0, i]));
        } else {
            sequence.push(sum([sequence[i - 1], sequence[i - 2]]));
        }
    }
    return sequence.join(", ");
};

export {fibonacci}; 

示例 8-14:fibonacci 函数

我们从本章早些时候创建的模块中导入了 sum 函数。它现在是一个依赖项,稍后我们需要将其替换为测试双重对象。接着,我们实现了 fibonacci 函数,该函数接受要计算的数列长度并返回一个字符串。我们将当前的数列存储在一个数组中,以便能够简单地访问计算下一个数值所需的两个前一个值。请注意,数列中的第一个数字始终是 0,第二个是 1。最后,我们返回一个包含所请求数量值的字符串。如果你保存这段代码并重新运行测试套件,sum.test.jsfibonacci.test.ts 都应该成功通过。

创建一个 Doubles 文件夹

因为我们在斐波那契模块中导入了 sum 函数,所以我们的代码有一个外部依赖。这对测试来说是个问题:如果 sum 函数坏了,斐波那契数列的测试也会失败,即使斐波那契实现的逻辑是正确的。

为了将测试与依赖项解耦,我们将用一个测试替代品替换fibonacci.ts文件中的sum函数。Jest 可以在测试运行期间替换任何模块,只要该模块在与测试文件相邻的mocks子目录中有一个同名的文件。在测试文件旁的helpers文件夹中创建这样的文件夹,并在其中放置一个sum.ts文件。现在先将文件留空。

为了启用测试替代品,我们调用jest.mock函数,并传递测试文件中保存的原始模块的路径。在 Listing 8-15 中,我们将此调用添加到fibonacci.test.ts文件中。

import {fibonacci} from "../helpers/fibonacci";

**jest.mock("../helpers/sum");**

describe("the fibonacci sequence", () => {
    test("with a length of 0 is ", () => {
        expect(fibonacci(0)).toBe(" ");
    });
    test("with a length of 5 is '0, 1, 1, 2, 3' ", () => {
        expect(fibonacci(5)).toBe("0, 1, 1, 2, 3");
    });

}); 

Listing 8-15:带有测试替代品的fibonacci函数的测试套件

这行代码替换了sum模块为测试替代品。现在我们来创建三种基本类型的测试替代品,将它们的代码添加到mocks文件夹中的文件中。

使用存根

存根仅仅是返回一些预定义数据的对象。这使得它们非常容易实现,但使用上有所限制;通常,返回相同的数据不足以模拟依赖项的原始行为。Listing 8-16 展示了sum函数测试替代品的存根实现。将代码粘贴到mocks文件夹中的sum.ts文件中。

const sum = (data: number[]): number => 999;

export {sum}; 

Listing 8-16:sum函数的存根

存根函数具有与原始函数相同的签名。它接受相同的参数——一个数字数组,并返回一个字符串。然而,与原始函数不同的是,这个测试替代品总是返回相同的数字 999,无论它接收到的数据是什么。

要成功运行带有此存根函数的测试套件,我们需要调整对代码行为的预期。它不会返回斐波那契数列中的五个数字,而是会生成字符串999, 999, 999, 999, 999。如果我们看到这样的字符串,我们就知道sum函数被调用了五次。试验这个存根,修改测试套件的预期,以匹配它。然后将匹配器恢复到 Listing 8-15 中显示的状态,这样你就可以在接下来的测试中使用它们。

使用伪造

伪造是最复杂的测试替代品类型。它们是原始功能的工作实现,但与真实实现不同,伪造只提供单元测试所需的功能。它们的实现被简化,通常不会处理边缘情况。

sum 的伪造通过手动添加数组中的第一个和第二个项,而不是使用 array.reduce。这种简化的实现剥夺了 sum 函数对两个以上数据点求和的能力,但对于斐波那契序列来说是足够的。减少的复杂性使其更易于理解,并且不容易出错。将 mocks 文件夹中的 sum.ts 文件内容替换为 Listing 8-17 中的代码。

const sum = (data: number[]): number => {
    return data[0] + data[1];
}
export {sum}; 

Listing 8-17:sum 函数的伪造

我们的伪造使用一个简单的数学加法运算符(+)来添加 data 参数中的第一个和第二个项。它的主要好处是返回的结果类似于实际实现的结果。我们现在可以运行测试套件,它们应该会成功通过,无需调整期望值,返回斐波那契序列。

使用模拟

模拟介于存根和伪造之间。虽然比伪造简单,但它们返回比存根更真实的数据。虽然它们没有模拟依赖项的真实行为,但它们能够响应收到的数据。

例如,我们的简单模拟实现的 sum 函数将从一个硬编码的哈希映射中返回结果。将 Listing 8-18 中的代码替换到 mocks/sum.ts 文件中,该代码检查请求并允许斐波那契计算器使用原始的测试套件。

type resultMap = {
    [key: string]: number;
}

const results : resultMap= {
    "0 + 0": 0,
    "0 + 1": 1,
    "1 + 0": 1,
    "1 + 1": 2,
    "2 + 1": 3
};

const sum = (data: number[]): number => {
    return results[data.join("+")];
}

export {sum}; 

Listing 8-18:sum 函数的模拟

我们创建了一个类型,称为 resultMap,它使用字符串作为键,数字作为值。然后,我们使用新创建的类型来表示一个哈希映射,存储我们期望的响应。接下来,我们定义一个与原始实现具有相同接口的模拟函数。在模拟函数中,我们根据收到的参数计算出要在哈希映射中使用的键。这使我们能够返回正确的数据集,并生成一个实际的斐波那契序列。使用模拟相对于 sum 的主要好处是我们可以控制它的结果,因为它是从已知数据集返回的值。

方便的是,Jest 为我们提供了帮助工具来使用测试替代品。jest.mock函数将导入的模块替换为模拟对象。jest.fn API 创建一个基本的模拟,可以返回任何预定义的数据,而jest.spyOn让我们在不修改函数的情况下记录对其的调用。我们将在第 146 页的练习 8 中使用这些工具。

在典型的开发人员环境中,你不会过多关注存根(stubs)、假对象(fakes)和模拟(mocks)之间的细微差别,通常会把模拟作为测试替代品的统称。不要花太多时间在过度设计模拟上;它们只是帮助你测试代码的工具。

其他类型的测试

本章至此涵盖的测试是你作为全栈开发人员最常遇到的测试类型。本节简要解释了其他类型的测试以及何时使用它们。这些测试并不是要替代单元测试;而是通过覆盖实现中其他无法测试的特定方面来补充单元测试。例如,由于单元测试在隔离环境中运行,它们无法评估模块之间的交互。理论上,如果每个函数和模块都通过了测试,那么整个程序应该按预期工作。实际上,你经常会遇到由于模块文档错误导致的问题。通常,文档会声称某个 API 返回某种特定类型,但实际实现返回的是不同的类型。

功能测试

虽然单元测试从开发人员的角度检查功能的实现,功能测试从用户的角度验证代码是否按用户预期的方式工作。换句话说,这些测试检查给定的输入是否会产生预期的输出。大多数功能测试属于黑箱测试的一种,它忽略模块的内部代码、副作用和中间结果,只测试接口。功能测试不会生成代码覆盖率报告。通常,质量保证经理会在系统测试阶段编写和使用功能测试。相比之下,开发人员在开发过程中编写和使用单元测试。

集成测试

你已经了解了单元测试的目标是检查代码中最小的独立部分。集成测试则完全相反。它验证整个子系统的行为,无论是代码的层次结构,比如应用的数据存储机制,还是由多个模块组成的特定功能。集成测试检查子系统在当前环境中的集成情况。因此,它们永远不会在隔离环境中运行,通常也不使用测试替代品。

集成测试有助于发现三种类型的问题。第一类是与模块间通信相关的问题,即模块之间的通信。常见问题包括内部 API 集成故障和未检测到的副作用,例如某个函数没有在写入新数据到文件系统之前删除旧文件。第二类是与环境相关的问题,指的是代码运行的硬件和软件设置。不同的软件版本或硬件配置可能会给你的代码带来重大问题。全栈开发人员最常遇到的问题是 Node.js 版本的差异以及模块中过时的依赖项。

第三类是与网关通信相关的问题,指的是测试与第三方 API 网关的任何 API 通信。与外部 API 的任何通信都应该通过集成测试进行测试。这是唯一一个可能使用测试替代品的集成测试实例,比如使用外部 API 的虚拟版本,以模拟特定的 API 行为,如超时或成功请求。与功能测试一样,质量保证经理通常编写并使用集成测试,开发人员则较少这样做。

端到端测试

你可以将端到端测试视为功能测试和集成测试的结合。作为另一种黑盒测试,它们检查整个堆栈中的应用程序功能,从前端到后端,在特定环境中运行。这些面向业务的测试应该提供信心,确保整个应用程序仍按预期工作。

端到端测试在特定环境中运行应用程序。通常,许多依赖关系的复杂性增加了不稳定测试的风险,虽然应用程序正常运行,但环境导致测试失败。因此,端到端测试是最耗时的创建和维护测试。由于其复杂性,我们必须谨慎设计它们。在执行过程中,它们通常较慢,容易遇到超时问题,且像几乎所有的黑盒测试一样,无法提供详细的错误报告。因此,它们仅测试最关键的面向业务的场景。通常,质量保证经理编写这些测试。

快照测试

本章前面描述的测试通过一些断言来检查代码。相比之下,快照测试则是将应用程序当前的视觉(或用户界面)状态与其之前的版本进行比较。因此,这些测试也称为视觉回归测试。在每个测试中,我们会创建新的快照,然后与之前存储的快照进行比较,这为测试用户界面组件和完整页面提供了一种低成本的方法。我们不再手动创建和维护描述界面每个属性的测试,比如组件的高度、宽度、位置和颜色,而是可以通过快照来包含所有这些属性。

执行这种类型测试的一种方法是创建并比较截图。通常,一个无头浏览器会渲染组件;测试运行器等待页面渲染完成后再捕捉其图像。不幸的是,这个过程相对较慢,并且无头浏览器存在不稳定的情况。Jest 采用了不同的方法进行快照测试。它不依赖无头浏览器和图像文件,而是将 React 用户界面组件渲染到虚拟 DOM 中,进行序列化并将其保存为纯文本的snap文件,存储在snapshots目录下。因此,Jest 的快照测试具有更高的性能,并且更少出错。你将在第二部分构建的 Food Finder 应用中使用快照测试来验证构建的完整性并测试 React 组件。

练习 8:为天气应用添加测试用例

只要你遵循我们讨论过的基本原则,就没有对错之分来测试你的代码。单元测试、快照测试和端到端测试都是你工具包中的不同工具,你必须在编写测试的时间和每种测试的实用性之间找到平衡。关于测试什么内容,也没有共识。虽然你应该努力达到 90%以上的代码覆盖率,但一般的经验法则是,至少覆盖应用程序中最关键的部分进行单元测试,然后编写一些集成测试,以验证你的应用在每次部署时是否能够正常工作。

对于我们的天气应用,我们希望测试用例覆盖四个核心方面。首先,我们将添加单元测试来评估中间件和服务。即使 REST API 端点和 React 用户界面组件可以在浏览器中直接进行测试,我们也会为它们添加测试用例:一个用于用户界面组件的基本快照测试,以及一个针对 REST API 端点/v1/weather/[zipcode].ts的端到端测试。

出于简便考虑,我们选择测试 REST 端点而不是 GraphQL API,因为每个 REST 端点都有自己的文件,而所有 GraphQL API 共享一个入口点,这使得测试更为复杂。然而,测试这个 GraphQL API 将是一个很好的练习,帮助你在完成本章后探索端到端测试。

使用间谍测试中间件

连接数据库的中间件是应用程序的核心部分,但我们无法直接访问它,因为它没有暴露任何 API。我们只能通过检查数据库或通过 Mongoose、某个服务或 API 端点运行查询来间接测试它。这些方法都能奏效,但如果我们想将数据库连接作为单元测试进行测试,我们需要尽可能地将该组件隔离开来。

为此,我们将使用 Jest 内置的间谍来验证我们的中间件是否成功调用了建立与 MongoDB 内存服务器连接所需的所有函数。导航到你的 tests 文件夹,并在其中创建一个新文件夹 middleware,然后在其中创建一个文件 db-connect.test.ts。然后,将列表 8-19 中的代码复制到该文件中。

/**
 * @jest-environment node
 */

import dbConnect from "../../middleware/db-connect";
import mongoose from "mongoose";
import {MongoMemoryServer} from "mongodb-memory-server";

describe("dbConnect ", () => {

    let connection: any;

    afterEach(async () => {
        jest.clearAllMocks();
        await connection.stop();
        await mongoose.disconnect();
    });

    afterAll(async () => {
        jest.restoreAllMocks();
    });

    test("calls MongoMemoryServer.create()", async () => {
        const spy = jest.spyOn(MongoMemoryServer, "create");
        connection = await dbConnect();
        expect(spy).toHaveBeenCalled();
    });

    test("calls mongoose.disconnect()", async () => {
        const spy = jest.spyOn(mongoose, "disconnect");
        connection = await dbConnect();
        expect(spy).toHaveBeenCalled();
    });

    test("calls mongoose.connect()", async () => {
        const spy = jest.spyOn(mongoose, "connect");
        connection = await dbConnect();
        const MONGO_URI = connection.getUri();
        expect(spy).toHaveBeenCalledWith(MONGO_URI, {dbName: "Weather"});
    });

}); 

列表 8-19:数据库连接的 tests/middleware/db-connect.test.ts 套件

这段代码大部分与本章之前你编写的测试套件相似。但我们现在不是在测试简化的示例代码,而是在测试真实的代码,这要求我们做出一些调整。

首先,我们将 Jest 的测试环境设置为node,该环境模拟了 Node.js 运行时。之后,在编写快照测试时,我们将使用 Jest 的默认环境,称为jsdom,它通过提供一个window对象以及所有常见的 DOM 属性和函数来模拟浏览器。通过始终在文件中设置这些环境,我们避免了因使用错误环境而引发的问题。然后,像往常一样,我们导入所需的包。

现在我们可以开始为dbConnect函数编写测试套件。我们在测试套件的作用域中定义一个connection变量来存储数据库连接,然后我们可以访问 MongoDB 的服务器实例,包括它的方法和属性。例如,我们将使用这些来停止连接并在每次测试后断开与服务器的连接,以确保每个测试用例是独立的。

为了能够存储连接,我们首先需要从文件db-connect.ts中的dbConnect函数返回mongoServer常量。打开文件并在dbConnect函数的闭合大括号(})之前添加一行代码return mongoServer。时不时地,你需要修改你之前写的代码,以适应测试的要求。换句话说,你需要调整代码,使其可以进行测试。

现在我们使用刚刚暴露的连接,并设置afterEach钩子,它在每个测试用例后运行,用于将模拟函数重置为初始模拟状态,从而清除之前收集的所有数据。这是必要的,因为否则间谍会报告在前一次调用中获取的信息,因为它们会在所有测试套件中保留其状态。此外,我们为每个测试用例重新创建数据库连接。因此,在每个测试之后,我们需要停止当前连接并显式断开与数据库的连接。然后,我们设置afterAll钩子,通过restoreAllMocks函数删除所有模拟并恢复原始函数。

我们的测试用例应该都遵循arrange, act, assert模式。在回顾这些用例时,你可能会发现打开middleware文件夹中的db-connect.ts文件并跟着一起操作会很有帮助。第一个测试用例验证了调用MongoMemoryServer上的create函数,因为这是我们在db-connect.ts文件中调用的第一个函数。为了做到这一点,我们使用jest.spyOn方法创建一个间谍。该方法的参数是一个对象的名称以及要监视的对象方法。然后我们对待测试的代码进行操作,并调用dbConnect函数。最后,我们断言该间谍已被调用。

第二个测试用例的工作方式类似,不同的是它监听了另一个方法。我们使用它来检查 mongoose.disconnect 是否在执行 dbConnect 时成功调用。第三个测试用例引入了一个新的匹配器。我们不再仅使用 toHaveBeenCalled 来验证调用本身,而是使用 toHaveBeenCalledWith 来验证调用的参数。在这里,我们直接从连接中获取连接字符串并将其存储在变量 MONGO_URI 中。我们还硬编码了要连接的数据库。然后我们调用匹配器,传递预期的参数并验证它们是否符合我们的预期。

现在运行测试套件,使用 npm test。所有测试应当通过,并且达到 100% 的测试覆盖率。

创建用于测试服务的模拟

虽然我们为中间件编写的测试非常简单,但服务测试稍微复杂一些。如果你打开 mongoose/weather/services.ts 文件,你会发现这些服务依赖于 WeatherModel,它是 Mongoose 访问 MongoDB 集合的网关。每个服务调用模型上的一个方法,而该方法又需要一个数据库连接。我们在这里不重新评估这些数据库连接;相反,这个测试套件的目标是验证服务函数是否调用了正确的 WeatherModel 函数。为此,我们将创建一个模拟的 WeatherModel,它暴露与模拟函数相同的一组 API。

我们首先编写模拟的模型。按照惯例,我们创建了文件 mongoose/weather/mocks/model.ts 并添加了 Listing 8-20 中的代码。

import {WeatherInterface} from "../interface";

type param = {
    [key: string]: string;
};

const WeatherModel = {
    create: jest.fn((newData: WeatherInterface) => Promise.resolve(true)),
    findOne: jest.fn(({zip: paramZip}: param) => Promise.resolve(true)),
    updateOne: jest.fn(({zip: paramZip}: param, newData: WeatherInterface) =>
        Promise.resolve(true)
    ),
    deleteOne: jest.fn(({zip: paramZip}: param) => Promise.resolve(true))
};
export default WeatherModel; 

Listing 8-20: WeatherModel 的模拟

我们实现了 WeatherInterface 并定义了新的 param 类型,这是一个包含键值对的对象,用于给第一个参数指定类型。我们将模拟的 WeatherModel 设置为默认导出,并使用一个实现了实际 WeatherModel 四个方法的对象,每个方法的参数与原始方法相同。它们还采用了原始 Mongoose 模型的方法。因为它们是异步函数,我们返回一个解析为 true 的 Promise。

现在我们可以为服务编写测试套件。它们检查每个服务在成功时返回 true,并调用模拟的 WeatherModel 的正确方法。创建文件 /tests/mongoose/weather/services.test.ts,并将 列表 8-21 中的代码添加到该文件中。

/**
 * @jest-environment node
 */
import {WeatherInterface} from "../../../mongoose/weather/interface";
import {
    findByZip,
    storeDocument,
    updateByZip,
    deleteByZip,
} from "../../../mongoose/weather/services";

import WeatherModel from "../../../mongoose/weather/model";
jest.mock("../../../mongoose/weather/model");

describe("the weather services", () => {

    let doc: WeatherInterface = {
        zip: "test",
        weather: "weather",
        tempC: "00",
        tempF: "01",
        friends: []
    };

 afterEach(async () => {
        jest.clearAllMocks();
    });

    afterAll(async () => {
        jest.restoreAllMocks();
    });

    describe("API storeDocument", () => {
        test("returns true", async () => {
            const result = await storeDocument(doc);
            expect(result).toBeTruthy();
        });
        test("passes the document to Model.create()", async () => {
            const spy = jest.spyOn(WeatherModel, "create");
            await storeDocument(doc);
            expect(spy).toHaveBeenCalledWith(doc);
        });
    });

    describe("API findByZip", () => {
        test("returns true", async () => {
            const result = await findByZip(doc.zip);
            expect(result).toBeTruthy();
        });
        test("passes the zip code to Model.findOne()", async () => {
            const spy = jest.spyOn(WeatherModel, "findOne");
            await findByZip(doc.zip);
            expect(spy).toHaveBeenCalledWith({zip: doc.zip});
        });
    });

    describe("API updateByZip", () => {
        test("returns true", async () => {
            const result = await updateByZip(doc.zip, doc);
            expect(result).toBeTruthy();
        });
        test("passes the zip code and the new data to Model.updateOne()", async () => {
            const spy = jest.spyOn(WeatherModel, "updateOne");
            const result = await updateByZip(doc.zip, doc);
            expect(spy).toHaveBeenCalledWith({zip: doc.zip}, doc);
        });
    });

    describe("API deleteByZip", () => {
        test("returns true", async () => {
            const result = await deleteByZip(doc.zip);
            expect(result).toBeTruthy();
        });
        test("passes the zip code Model.deleteOne()", async () => {
            const spy = jest.spyOn(WeatherModel, "deleteOne");
            const result = await deleteByZip(doc.zip);
            expect(spy).toHaveBeenCalledWith({zip: doc.zip});
        });
    });

}); 

列表 8-21:tests/mongoose/weather/services.test.ts 中更新的测试套件

与之前的测试套件一样,我们首先设置环境并导入模块。我们还导入了 WeatherModel,并使用 jest.mock 调用我们创建的模拟模型路径,从而有效地替换了测试代码中的原始模型。然后,我们创建一个包含一些测试数据的文档。我们将其存储在常量 doc 中,并将其传递给模拟模型的方法。与之前一样,我们使用 afterEach 钩子在每个测试后重置所有模拟,使用 afterAll 钩子在所有测试用例完成后移除模拟并恢复原始函数。

我们为四个服务创建了一个嵌套的测试套件。每个服务都有相同的两个单元测试:一个是使用 toBeTruthy 匹配器验证成功时的返回值,另一个是间谍(spy)监视一个特定的 WeatherModel 模拟函数。代码遵循与前一个测试套件相同的模式,并使用相同的匹配器。

我们在运行 npm test 后收到的代码覆盖率报告显示,我们测试了大约 70% 的服务代码。如果你查看最后一列中列出的未覆盖行,你会看到它们包含了 console.log(err); 输出。这个输出会在异步调用模型方法失败时使用:

PASS  __tests__/mongoose/weather/services.test.ts
PASS  __tests__/middleware/dbconnect.test.ts (7.193 s)

--------------------|---------|----------|---------|---------|-------------------
File                | % Stmts | % Branch | % Funcs | % Lines | Uncovered Lines
--------------------|---------|----------|---------|---------|-------------------
All files           |   83.63 |      100 |   88.23 |   82.35 |
 middleware         |     100 |      100 |     100 |     100 |
  db-connect.test.ts|     100 |      100 |     100 |     100 |
 mongoose/weather.  |   77.41 |      100 |     100 |   75.86 |
  services.test.ts  |   70.83 |      100 |     100 |   70.83 |8,20-22,33-35,43-45
--------------------|---------|----------|---------|---------|------------------- 

本章的目的,我们将保留这些未覆盖的行。否则,我们可以修改模拟的模型,使其抛出一个错误——例如,提供一个无效的文档——然后为每个服务添加一个第三个测试案例,验证错误。

执行 REST API 的端到端测试

高级 API 测试可能会使用专门的 API 测试库,如 SuperTest,它提供了 HTTP 状态码的匹配器,并简化了请求和响应的处理。或者,它们可能会使用像 Postman 这样的 GUI 工具。在本例中,我们将仅通过使用本地的 fetch 方法测试返回的数据是否符合我们的预期。

与之前的测试不同,这次的测试并没有隔离任何单一组件,因为我们的目标是验证系统的所有组件是否按预期协同工作。为了检查当提供输入时,API 是否从数据库返回正确的响应,我们的端到端测试将做出一些假设:所有层级已经独立测试过,数据库包含初始种子数据,并且我们的应用运行在http://localhost:3000/

为了验证我们的第一个假设,打开 API 端点文件pages/api/v1/weather/[zipcode].ts。你会注意到,API 代码导入了两个函数,来自服务模块的findByZip和中间件的dbConnect,这两个我们已经测试过的函数。第二个假设也得到了验证;数据库在每次启动时都会加载初始种子数据。创建文件zipcode.e2e .test.ts,路径为tests/pages/api/v1/weather/,并添加清单 8-22 中的代码。

/**
 * @jest-environment node
 */

describe("The API /v1/weather/[zipcode]", () => {
    test("returns the correct data for the zipcode 96815", async () => {
        const zip = "96815";
        let response = await fetch(`http://localhost:3000/api/v1/weather/${zip}`);
        let body = await response.json();
        expect(body.zip).toEqual(zip);
    });
});

export {}; 

清单 8-22:REST API 的测试套件

我们将环境设置为node,然后定义一个包含一个测试用例的测试套件。在该测试用例中,我们提供一个与初始种子数据集匹配的邮政编码。然后,我们使用自 Node.js 版本 17.5 起可用的原生fetch方法,调用本地的天气 API,并检查返回的邮政编码是否与作为参数传入的邮政编码相同。我们添加一个空的 export 语句,定义此文件为一个 ES6 模块。

测试应该通过,并且代码覆盖率为 100%。现在我们确信应用的核心功能按预期工作,可以开始测试用户界面组件。

使用 fetch 时,你可能会遇到两个常见的错误信息。第一个,ECONNREFUSED,表示 fetch 无法连接到你的应用程序,因为它没有运行。使用 npm run dev 启动应用程序,或者如果你不使用端口 3000,可以调整 fetch 调用中的端口。第二个错误提示测试超出了 5,000 毫秒的超时时间。如果你为了测试启动了应用程序,并且没有使用先前运行的应用程序,Next.js 会在测试消耗 API 路由时立即编译它。根据你的环境,这可能需要比默认超时时间更长。将 jest.setTimeout(20000); 这一行添加到文件顶部的 describe 方法之前,以增加超时时间,将测试等待时间从 5,000 毫秒增加到 20,000 毫秒。

通过快照测试评估用户界面

快照测试验证页面渲染的 HTML 在两次测试运行之间没有变化。为了使用 Jest 实现这一点,我们必须首先准备好环境。将 jsdom 环境、react-testing-library 和 react-test-renderer 添加到项目中:

$ **npm install --save-dev jest-environment-jsdom**
$ **npm install --save-dev @testing-library/react @testing-library/jest-dom**
$ **npm install --save-dev @types/react-test-renderer react-test-renderer** 

我们需要这些工具来模拟浏览器环境,并在测试用例中渲染 React 组件。现在我们将相应地修改根目录下的 jest.config.js 文件。用 Listing 8-23 中的代码替换它的内容。

const nextJest = require("next/jest");
const createJestConfig = nextJest({});

module.exports = createJestConfig(nextJest({})); 

Listing 8-23: 更新后的 jest.config.js 文件

这段代码导入了 next/jest 包,并导出了一个具有 Next.js 项目默认属性的 Jest 配置。这是最简单的 Next.js 兼容 Jest 配置形式。如果你查看官方的 Next.js 设置指南 https://nextjs.org/docs/testing,你会看到它概述了一些基本的配置选项,但我们不需要这些选项。

第一个版本

快照测试渲染一个组件或页面,拍摄其快照并将其作为序列化的 JSON 存储在与测试套件并列的snapshots文件夹中。在每次连续运行时,Jest 会将当前的快照与存储的参考快照进行对比。只要它们相同,快照测试就通过。要生成初始快照,创建一个新的文件夹,tests/pages/components,以及文件weather.snapshot.test.tsx,然后将 Listing 8-24 中的代码添加进去。

/**
 * @jest-environment node
 */

import {act, create} from "react-test-renderer";
import PageComponentWeather from "../../../pages/components/weather";

describe("PageComponentWeather", () => {
    test("renders correctly", async () => {
        let component: any;
        await act(async () => {
            component =
                await create(<PageComponentWeather></PageComponentWeather>);
        });
        expect(component.toJSON()).toMatchSnapshot();
    });
}); 

列表 8-24:PageComponentWeather 的快照测试

我们的快照测试的前几行设置了环境为 jsdom,并导入了测试渲染器的 act 和 create 方法,用于测试 React 组件,我们将在下一行导入它们。

接下来,我们编写模拟的用户行为,并将组件的创建包裹在异步的 act 函数中。正如你可能猜到的,这个函数的名字来源于 arrange, act, assert 模式,确保在继续进行测试用例之前,所有相关的 DOM 更新已经应用。对于所有导致 React 状态更新的语句,它都是必须的,在这里,它延迟了测试执行,直到 useEffect 钩子执行完毕。

然后我们编写一个测试用例,等待 create 函数,该函数渲染 JSX 组件。这让我们可以在模拟的浏览器环境中生成 HTML,并将结果存储在变量中。我们等待组件的渲染,以便在继续测试用例之前,HTML 可以用于我们的后续交互。接着我们将渲染的组件序列化为 JSON 字符串,并使用一个新的匹配器 toMatchSnapshot,它将当前的 JSON 字符串与存储的参考值进行比较。

一次试运行显示所有测试都成功。我们看到两件有趣的事情——测试创建了一个快照,并且我们达到了 81% 的测试覆盖率:

PASS  __tests__/mongoose/weather/services.test.ts
PASS  __tests__/pages/api/v1/weather/zipcode.e2e.test.ts
PASS  __tests__/middleware/dbconnect.test.ts (7.193 s)
PASS  __tests__/pages/components/weather.snapshot.test.tsx

---------------------|---------|----------|---------|---------|-------------------
File                 | % Stmts | % Branch | % Funcs | % Lines | Uncovered Lines
---------------------|---------|----------|---------|---------|-------------------
All files            |   83.63 |      100 |   88.23 |   82.35 |
 middleware          |     100 |      100 |     100 |     100 |
  db-connect.test.ts |     100 |      100 |     100 |     100 |
 mongoose/weather    |   77.41 |      100 |     100 |   75.86 |
  services.test.ts   |   70.83 |      100 |     100 |   70.83 |8,20-22,33-35,43-45
 pages/api/v1/       |         |          |         |         |
  weather            |         |          |         |         |
    [zipcode].ts     |     100 |      100 |     100 |     100 |
 pages/components    |   81.81 |      100 |      60 |      80 |
  weather.tsx        |   81.81 |      100 |      60 |      80 |8,12
---------------------|---------|----------|---------|---------|-------------------
Snapshot Summary
 › 1 snapshot written from 1 test suite. 

你可以通过打开 weather.snapshot.test.tsx.snap 文件,在 snapshots 文件夹中查看创建的快照。它应该与 列表 8-25 中的代码非常相似,你会发现它不过是将渲染的 HTML 保存为多行模板字面量。你的 HTML 可能与这里展示的内容不完全相同;重要的是,在每次测试运行后,当 react-test-renderer 渲染组件时,它应该看起来相同。

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`PageComponentWeather renders correctly 1`] = `
<h1
    data-testid="h1"
    onClick={[Function]}
>
    The weather is
    sunny
    , and the counter shows
    0
</h1>
`; 

列表 8-25:包含序列化 HTML 的 weather.snapshot.test.tsx.snap 文件

我们还看到计数器被设置为 0,这表明 useEffect 钩子在我们创建快照之前并没有运行。如果你打开组件的文件并检查未覆盖的行,你会发现这些行与增加 state 变量的点击处理器相关,正如我们预期的那样,还有 useEffect 钩子。我们也想测试这些核心功能。

第二个版本

我们将修改测试代码,以覆盖之前未测试的功能。将 清单 8-26 中的代码粘贴到快照测试文件中。

/**
 * @jest-environment node
 */

import {act, create} from "react-test-renderer";
import PageComponentWeather from "../../../pages/components/weather";

describe("PageComponentWeather", () => {
    test("renders correctly", async () => {
        let component: any;
        await act(async () => {
            component = await create(<PageComponentWeather></PageComponentWeather>);
        });
        expect(component.toJSON()).toMatchSnapshot();
    });

    test("clicks the h1 element and updates the state", async () => {
        let component: any;
        await act(async () => {
            component = await create(<PageComponentWeather></PageComponentWeather>);
            component.root.findByType("h1").props.onClick();
        });
        expect(component.toJSON()).toMatchSnapshot();
    });

}); 

清单 8-26:更新后的快照测试

在更新后的代码中,我们添加了另一个测试用例,它找到页面上的标题并模拟用户点击它。记住在前面的章节中,这会增加状态变量 counter。再次提醒,我们等待组件的创建,并使用 act 函数。

如果你重新运行测试,应该会看到失败。测试运行器告诉我们快照不匹配:

FAIL  __tests__/pages/components/weather.snapshot.test.tsx
  • PageComponentWeather › renders correctly
`--snip--`
 › 1 snapshot failed.
`--snip--`
Snapshot Summary
 › 1 snapshot failed from 1 test suite.
› Inspect your code changes or run `npm test -- -u` to update them. 

因为我们修改了测试用例,等待 useEffect 钩子,并将状态变量 counter 设置为 1 而不是 0,所以 DOM 也发生了变化。按照测试运行器的建议,使用 npm test -- -u 重新运行测试,创建一个新的更新后的快照。现在测试应该会成功,报告我们组件的测试覆盖率为 100%。

尝试运用你新学到的知识。例如,你能为 pages 目录中的页面路由编写快照测试,或者为 GraphQL API 编写一组端到端测试吗?

总结

现在你应该能够使用 Jest 创建自动化测试,并且更广泛地说,自己设计一个测试计划,以在努力和回报之间找到平衡。我们讨论了 TDD 和单元测试的好处,然后使用 arrange, act, assert 模式,按照测试驱动原则开发了一个简单的 sum 函数。接着,我们使用三种类型的测试替代品,在计算 Fibonacci 数列时替换了 sum 函数。最后,我们向现有的 Next.js 应用程序添加了单元和快照测试,创建了一个 Mongoose 模型的模拟,并使用间谍验证了我们的假设。

要了解更多关于 Jest 和自动化测试的信息,请查阅官方 Jest 文档:https://jestjs.io/docs/getting-started。在下一章中,你将探索授权和认证之间的区别,以及如何在应用程序中利用 OAuth。

第九章:9 使用 OAuth 进行授权

某些应用程序会在登录工作流中存储有关用户的数据。实现此身份验证和授权的方法有很多种,但最简单的方法之一是使用 OAuth2,借用知名公司现有账户进行登录。OAuth2,简称OAuth,是一种开放的访问委托标准,如果你曾使用过某个应用的“用 Facebook、GitHub 或 Google 账户登录”功能,可能已经遇到过它。

OAuth 协议本质上允许我们的 Web 应用访问另一个应用的登录数据,而无需第三方与我们共享用户凭证。为了实现这一点,用户通过创建访问令牌授予我们的应用其第三方账户的访问权限。OAuth 是授权访问委托的公认标准,亚马逊、谷歌、Facebook、微软和 GitHub 都支持 OAuth 工作流。

本章将介绍 OAuth 工作流,然后探讨用于访问委托的持有者令牌的结构,为在第二部分中将 OAuth2 集成到你的“食物查找器”应用中奠定基础。在第 168 页的练习 9 中,我们不会通过 OAuth 流程更新我们的示例 Next.js 应用,而是手动演示 OAuth 授权过程。

OAuth 如何工作

在我们探讨 OAuth 之前,你需要理解身份验证和授权之间的区别。简而言之,我们使用身份验证来验证用户的身份,而授权则指定经过身份验证的用户所拥有的权限,并强制执行这些权限。OAuth 允许将这一过程委托给用户已经拥有账户的第三方,从而简化了用户的登录过程。

身份验证与授权

每次应用收到登录请求时,都会在允许访问之前检查用户的凭证,这个过程叫做身份验证。通常,这些凭证包括用户名和密码,但也可能是硬件令牌,或者涉及生物特征因素,如指纹或面部识别。然后,应用验证凭证是否与数据库中存储的匹配。

最简单的认证方式是单因素认证,只需要一个因素,通常是密码。不幸的是,它也是实施认证的最不安全的方法。更强大且推荐的方式是多因素认证,在这种方式中,用户必须提供至少两个因素。这些因素可能是用户知道的东西,比如密码,或者是用户拥有的东西,比如实体令牌,或是用户具备的东西,比如指纹拥有者。你在登录 PayPal 或 Google 时可能会使用多因素认证,这两个平台都要求你提供密码和一个额外的一次性密码(OTP)。

OTP 是一个基于你与应用程序在注册账户时共享的秘密生成的代码。两个参与方会在短时间间隔内重新生成这一对代码。你的 OTP 可能是通过类似 Google Authenticator 的身份验证器应用生成,或者通过短信接收。你拥有账户的应用程序(例如 PayPal 或 Google)会生成自己的 OTP 代码并将其保存在服务器上。你发送的 OTP 一旦到达服务器,服务器就会加密验证这些代码是否匹配。

我们在验证用户身份后执行授权。广义上说,这涉及查看用户的数据并决定他们是否拥有访问资源所需的权限。一个典型的全栈应用程序要么处理用户数据,要么允许用户在不提供用户数据的情况下登录。后者的做法有其优势,因为处理和存储用户数据可能不方便。它还伴随着额外的责任,比如需要遵守更严格的隐私和数据保留法律,并且要求用户创建另一个账户。

假设你为用户提供通过授权提供者使用现有账户登录的选项,那么你就移除了一个进入门槛。同时,你也无需担心处理他们的数据。如果你需要用户数据——例如,用于向客户收费——你可以使用 OAuth 工作流,并在必要时将从提供者接收到的数据(如用户的支付信息)保存在你自己的数据库中。

OAuth 的作用

每当一个 Web 应用程序允许你通过第三方提供者(如 Facebook、GitHub 或 Google)登录时,它背后使用的是 OAuth 授权码流。OAuth 并不是认证;它是一种授权你使用的 Web 应用程序代你执行操作或访问资源的方式。常见的操作包括发布到你的 Facebook 动态和访问诸如姓名、头像或电子邮件地址等数据。因此,每次你使用基于 OAuth 的登录功能时,应用程序会请求特定的权限,并且只能使用你授予的权限。

要理解 OAuth,你必须了解它的术语。每种 OAuth 流程都使用一组 RESTful API 授权客户端(即应用程序)从资源提供者(如 Facebook、GitHub 或 Google)获取资源(例如用户的个人信息),而这些资源是客户端希望访问的受保护资源。此外,我们将提供 OAuth API 端点的服务器称为授权服务器,将拥有访问权限(因此能够授权应用访问资源)的方称为资源所有者。在大多数情况下,资源所有者是应用的最终用户。

为了获取资源所有者的授权,客户端应用将其客户端凭证(包括 ID、密钥和用户凭证)发送到授权服务器,授权服务器通常是与资源提供者相同系统的一部分。授权服务器验证资源所有者的身份,并处理 OAuth 流程,从而授予其访问令牌,该令牌允许用户访问资源提供者上的受保护资源。授权服务器和资源提供者都是同一系统上的两组 API。

客户端 ID是客户端应用的公共标识符;你可以将其公开并存储在代码中。与客户端 ID 不同,客户端密钥应该保持私密;它是应用程序专用的密码,绝不应该存储在代码中。相反,应该使用 Next.js 的环境文件或服务器的环境变量来处理它。

授权类型

OAuth 流程有多个变体。每种授权类型都覆盖一个特定的用例,但所有类型都最终生成访问令牌。OAuth 定义了四种授权类型:客户端凭证流程、隐式流程、授权码流程和资源所有者密码凭证流程。

客户端凭证流程涵盖机器对机器的通信;当不需要实际的最终用户授权时(例如自动化任务连接到 API 时),我们使用它。在这种情况下,任务本身既是客户端也是资源所有者。它知道资源所有者的凭证、客户端 ID 和客户端密钥,并将这些信息传递给授权服务器以获取访问令牌。

完整栈网页开发中最常见的授权类型是授权码流程。在这种情况下,我们的网页应用是一个客户端,它会分别调用两个不同的 API 端点。第一个是接收授权授权码,第二个是用该授权码交换访问令牌。第 161 页的“授权码流程”深入讲解了这一过程。

最后两种授权类型不应该使用。隐式流程类似于授权代码流程,但不同之处在于,客户端直接接收访问令牌,而不需要分别发送请求以接收授权许可和访问令牌。这个流程跳过了授权步骤,不包含客户端认证,并且已经被弃用。资源所有者密码凭证流程应该避免使用,因为它涉及终端用户将自己的凭据传递给客户端,然后客户端将这些凭据发送给 OAuth 服务器以交换访问令牌。虽然这听起来很直接,但将实际用户凭据发送给远程授权服务器是一个巨大的安全风险。

承载令牌

客户端应用程序启动 OAuth 流程后,会收到一个共享访问令牌,最常见的是容易实现的承载令牌。这个访问令牌替代了用户的凭据;因此,任何拥有该令牌的人都可以访问数据。为了防止因令牌被盗而导致的安全漏洞,承载令牌通常有一个定义的有效期。过期后,令牌只能通过有效的刷新令牌进行刷新。这些是长期有效的令牌,我们用它们来生成新的承载令牌。

刷新令牌可以通过隐式或显式方式进行,并且有多种策略可以防止被盗的刷新令牌危及 OAuth 访问。例如,OAuth 提供者可以要求一个唯一的 ID 或客户端密钥来发放新令牌。每次发放新的承载令牌时,提供者通常会旋转刷新令牌,并且每个刷新令牌只能使用一次。从我们作为 OAuth 客户端的角度来看,刷新令牌的细节并不重要,因为 OAuth 提供者会处理这个令牌。

包含用户会话和认证数据的承载令牌是JSON Web Token(JWT)。JWT 是一个开放标准,用于安全地传输 JSON 对象中的数据。由于 JSON 格式相对紧凑,JWT 可以作为 URL 参数、POST 数据的一部分,甚至嵌入 HTTP 头中发送,且不会影响应用程序的性能。

JWT 令牌既可以签名也可以加密,从而避免应用程序需要额外请求来验证或获取额外数据。加密令牌将其中的数据隐藏起来,防止其他方访问。这种方式在 OAuth 中不常见,因为它带有额外的开销,所以我们现在可以忽略它们。签名令牌保证了其中数据的完整性,因为对令牌的任何修改都会改变其签名。因此,应用程序可以信任其中存储的信息。

签名 JWT 最常用的加密算法是基于哈希的消息认证码(HMAC),并使用 SHA-256 哈希算法。HMAC 是一种消息认证码(MAC)。MAC 的主要特点是,它能够通过从消息中计算校验和来验证消息的真实性。校验和使用数学函数根据初始消息生成唯一且可重现的值或数据字符串。如果消息发生变化,校验和也会发生变化。通过这种方式,我们可以快速验证数据的完整性。对于 JWT 令牌,我们使用两个检查:真实性检查确认实际发送者确实发送了消息,而数据完整性检查则验证消息的内容未发生变化。

与其他类型的 MAC 不同,HMAC 使用加密哈希函数和密钥。你可以自由选择加密哈希函数,但 HMAC 实现的强度取决于所选函数的加密强度。JWT 通常使用 SHA-256 哈希函数,这是 SHA-2 集合中的一种快速且抗碰撞的加密函数,广泛应用于 Debian 软件包认证和比特币交易验证。在加密学中,碰撞指的是两个不同的输入产生相同的输出。当碰撞的可能性较高时,我们就无法信任哈希函数的校验和。如果发生碰撞,我们的消息可能被替换为不同的内容,但哈希函数却可能表明消息没有变化。因此,我们希望使用抗碰撞的加密函数。

授权码流程

为了理解如何使用前面提到的授权码流程进行 OAuth 交互,我们回到我们的虚拟天气服务。假设你希望通过 API 授予气象站向应用程序写入数据的权限,但一个气象站只能修改它自己的邮政编码。你还希望应用程序能够显示气象站的位置及其其他详细信息。此外,你更倾向于不处理用户账户的维护或为每个站点手动设置权限,因此使用 OAuth 是最佳选择。

假设每个气象站已经拥有一个用于发布天气更新的社交媒体账户。这些账户包含典型的用户信息和气象站的邮政编码。我们可以轻松地使用社交媒体提供商作为 OAuth 授权提供者来访问这些数据。气象站会使用社交媒体提供商登录到天气应用,应用则请求访问气象站的用户资料。然后,我们可以检查 OAuth 会话中存储的邮政编码与我们数据集中的邮政编码是否匹配,从而提供适当的写入权限,并检索所需的其他数据。

实现这个授权码流程只需要几个步骤。图 9-1 是这些步骤的简化描述。通常,开发者使用 SDK 或 Node.js 模块来实现这些步骤,只需要提供几个属性,例如客户端 ID、客户端密钥和回调 URL。

图 9-1:简化的 OAuth 授权码流程

为了将我们的应用注册为 OAuth 客户端,我们需要向 GitHub 提供一个回调 URL,该 URL 会在授权请求后将用户重定向到我们的应用程序。我们应用程序中的此端点接收授权码。最近的 OAuth 实现要求回调 URL 使用 HTTPS,以保护令牌免受拦截。

我们的应用程序必须使用资源所有者的凭据和客户端凭据(ID 和密钥)与 GitHub 的授权服务器进行通信。ID 用于标识客户端,密钥用于验证客户端身份。然后,应用程序可以请求授权以访问特定资源,例如天气站的个人资料数据。为此,天气站用户需要登录到 GitHub 的授权服务器。他们将看到一个提示,概述请求的访问资源,例如读取和写入个人资料或流数据。如果用户使用其凭据授权请求,OAuth 客户端将在回调 URL 中以 GET 参数形式接收授权码,并且我们在应用程序中使用的 OAuth SDK 将在流程的下一步中将授权码交换为访问令牌。

在这里,OAuth 客户端使用客户端凭据(客户端 ID 和客户端密钥),并结合先前收到的授权码向 OAuth 提供者的授权服务器请求访问令牌。它是 GitHub 基础设施的一部分,为了完成授权流程,授权服务器会验证身份并确认该授权码对该身份有效。最后,应用程序从此处接收承载令牌并将其存储在用户会话中。

使用从 OAuth 提供者接收到的令牌和用户会话,我们的应用程序现在可以代表用户行动并访问他们的受保护资源,例如来自资源服务器的个人资料数据。为了代表他们行动,我们在 HTTP 请求的Authorization头中添加承载令牌;OAuth 提供者检查我们的授权权限,并通过该令牌验证我们的身份。为了访问用户的数据,我们只需从会话数据中提取并在应用程序的代码中使用它。

对于天气应用程序,我们可以使用第二种选项从数据库中查询特定位置的天气数据。我们需要从用户的会话数据中读取位置属性,并将该值作为 ZIP 代码传递给我们的 API 端点。此外,我们还可以访问其他属性,如描述、姓名或个人资料图片,并将其显示在天气应用程序的每个站点状态页面上。

创建 JWT 令牌

大多数持有者令牌都是 JWT,虽然授权服务器会自动发布它们,但了解其中包含的信息是很有帮助的。本节将引导您通过为天气服务应用创建示例 OAuth JWT 的过程。JWT 是由三部分组成的字符串,这三部分通过句点 (.) 分隔:头部、有效负载和签名。前两部分是 Base64 编码的 JSON 对象,而签名则是前两部分的校验和。

头部

我们创建的第一个字符串是头部,它定义了基本的元数据,如令牌的类型和用于签名算法的签名。列表 9-1 展示了在 JavaScript 中创建一个简单头部的过程,其中包含最基本的元数据。

const headerObject = {
    "typ": "JWT",
    "alg": "HS256"
} 

列表 9-1:OAuth2 天气服务的 JWT 头部

我们将天气服务的令牌类型设置为 JWT,并指定后续使用 HMAC-SHA-256 算法来计算签名。最后,我们将 JSON 对象存储在常量中,以便后续使用。

有效负载

接下来,我们创建第二个字符串,有效负载,它存储令牌的数据。有效负载的每个属性称为声明。在 OAuth 中,声明描述用户对象,通常也描述会话数据。JWT 规范包含三种类型的声明:注册声明、公共声明和私有声明。

注册声明

有七个注册声明,每个声明由三个字母组成。虽然在一般的 JWT 中不必要,但 iss、sub、auth 和 exp 这些注册声明是 OAuth JWT 中必须的。

发行者声明,iss,包含一个唯一的标识符,用于标识发布 JWT 的实体。一个好的值可能是应用程序的 URL,如 列表 9-2 所示。

{
    "iss": "https://www.usemodernfullstack.dev/
} 

列表 9-2:一个注册的发行者声明

主题声明,sub,标识 JWT 所属的主体。对于 OAuth 客户端认证流程,主题声明必须是 OAuth 客户端的客户端 ID;而对于 OAuth 授权授权,主题应该标识资源所有者,或者以化名标识匿名用户。我们在清单 9-3 中创建了一个示例主题声明。

{
    "sub": "`THE_CLIENT_ID`"
} 

清单 9-3:已注册的主题声明

受众声明,aud,标识令牌的接收者。它的值可以是授权服务器上的令牌端点 URL,也可以是其他任何标识接收者的内容,例如应用 ID。请参见清单 9-4 中的示例。

{
    "aud": "api://endpoint"
} 

清单 9-4:已注册的受众声明

过期声明,exp,标识令牌有效的时间窗口。超过该时间段后,授权服务器将拒绝该令牌,您需要请求一个新的令牌。过期声明的值是一个数字,其日期以“自 Unix 纪元以来的秒数”定义,这是一个常见的时间戳格式。它通过计算自 1970 年 1 月 1 日以来经过的秒数来确定。清单 9-5 展示了一个示例。

{
    "exp":  1134156400
} 

清单 9-5:已注册的过期声明

颁发时间声明,iat,是可选的,用于标识授权服务器颁发令牌的时间。您可以从此声明中确定令牌的年龄,它也是自 Unix 纪元以来的秒数,如清单 9-6 所示。

{
    "iat": 1134156200
} 

清单 9-6:已注册的颁发时间声明

不早于声明,nfb,是可选的,用于标识授权服务器应开始接受令牌的时间。授权服务器将拒绝未来带有 nfb 声明的每个令牌。我们将其定义为自 Unix 纪元以来的秒数,正如在清单 9-7 中所示。

{
    "nfb": 1134156100
} 

清单 9-7:已注册的“不早于”声明

JWT 声明,jti,是可选的,为令牌设置一个唯一的 ID(参见清单 9-8)。

{
    "jti": "b5f8f86f-82ab-451e-b391-bf6a07041787"
} 

清单 9-8:已注册的 JWT 声明

授权服务器可能会保留一个最近令牌及其过期日期的列表,以检查令牌是否在 重放攻击 中被重复使用,重放攻击发生在攻击者试图通过重新使用先前颁发的令牌访问数据时。

公共声明

令牌的发布者可以定义公共声明,用于添加特定于应用程序的公共 API。与私有声明不同,这些是为公共访问定义的自定义属性。发布者应将这些声明注册到 JWT 声明注册表中,或使用具有自定义命名空间的防碰撞名称——例如 UUID 或应用程序的名称。此外,由于公共声明是供公众使用的,它们永远不应包含私密或敏感信息。

我们虚构的天气服务的 OAuth JWT 的公共声明可能包括邮政编码,以直接提供每个站点的位置数据。通过将邮政编码作为公共声明,我们无需解析用户对象并手动提取邮政编码。此外,由于位置是社交媒体个人资料上公开的信息,因此它并不敏感。

私有声明

私有声明是自定义声明,它们既不是已注册声明也不是公共声明。我们可以根据需要定义它们,它们可以是特定于我们的应用程序或用例的。尽管它们不需要防止碰撞,但建议使用私有命名空间。与公共声明不同,私有声明包含特定于应用程序的信息,并且仅供内部使用。而公共声明存储的是诸如姓名等通用信息,私有声明则包含应用程序的用户 ID 和角色。例如,我们可以为虚构天气服务的 OAuth JWT 定义一个私有声明,以指定我们使用的服务类型。

现在你已经理解了有效载荷对象可能的属性,你可以创建一个完整的有效载荷,比如在清单 9-9 中展示的那样,它指定了 GitHub 作为服务。

const payloadObject = {
    "exp": 234133423,
    "weather_public_zip": "96815",
    "weather_private_type": "GitHub"
} 

清单 9-9:OAuth 天气服务的 JWT 有效载荷

再次,我们创建一个常量并将对象存储在那里。我们的有效载荷包含三个声明,每个声明的类型不同。由 JWT 令牌的发布者决定包含哪些声明;对于这个例子,我们将令牌的大小限制为每种类型一个声明。已注册的声明exp设置过期日期和时间,zip是一个公共声明,role是一个私有声明。它们都使用自定义命名空间weather来减少碰撞的风险。

签名

在头部和负载就位后,我们使用头部中指定的算法来计算校验和,从而创建 JWT 签名。我们将头部和负载作为 Base64 编码字符串,并传递自定义密钥给校验和函数。作为练习,我们将使用 列表 9-10 中的代码在 TypeScript 中创建签名。你会看到这里的密钥是硬编码的,为了简单起见。生产代码中,这个密钥应存储在环境变量中。

将代码保存为index.ts,放入 TypeScript 项目中,或者使用 npx ts-node index.ts 在本地运行。如果你愿意,也可以使用 TypeScript 沙盒在https://codesandbox.iohttps://stackblitz.com 进行运行。生成一个新的密钥 (www.usemodernfullstack.dev/generate-secret) 并使用它替换列表中的密钥,以查看令牌如何变化。

import {createHmac} from "crypto";

const base64UrlEncode = (data: string): string => {
    return Buffer.from(data, "utf-8").toString("base64");
};

const headerObject = {
    typ: "JWT",
    alg: "HS256"
};

const payloadObject = {
    exp: 234133423,
    weather_public_zip: "96815",
    weather_private_type: "GitHub"
};

const createJWT = () => {
    const base64Header = base64UrlEncode(JSON.stringify(headerObject));
    const base64Payload = base64UrlEncode(JSON.stringify(payloadObject));

    const secret = "59c4b48eac7e9ac37c046ba88964870d";

    const signature: string = createHmac("sha256", secret)
        .update(`${base64Header}.${base64Payload}`)
        .digest("hex");

        return [base64Header, base64Payload, signature].join(".");
};

console.log(createJWT()); 

列表 9-10:一个用于计算 OAuth2 天气服务 JWT 签名的 index.ts 文件

我们使用 Node.js 的标准 crypto 模块,然后创建一个库,通过缓冲区将 JSON 对象转换为 Base64 编码字符串。我们将这些字符串和 secret 传递给 crypto 模块的 createHmac 函数,以 sha256 作为哈希算法来初始化 HMAC 对象。然后,我们将 Base64 编码的头部和负载字符串(用点分隔)传递给 HMAC 对象。最后,我们将结果转换为十六进制格式。

为了获取 JWT 字符串,我们创建一个数组,包含来自头部和负载对象的 Base64 编码字符串,以及 Base64 编码的签名。为了将数组转换为一个使用点(.)分隔各部分的字符串,我们调用 Array.join,并使用点作为分隔符,返回结果的 JWT。

为了生成 JWT,我们运行脚本。最终在控制台中记录的 JWT 令牌应该与 列表 9-11 中的类似。

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjIzNDEzMzQyMywid2VhdGhlcl9wdWJsa
WNfemlwIjoiOTY4MTUiLCJ3ZWF0aGVyX3ByaXZhdGVfdHlwZSI6IkdpdEh1YiJ9.f667c81749886e
e01831376a38fbdba4d7f59a14c14f3a60e1bbee977c993ac9 

列表 9-11:OAuth2 天气服务的最终 JWT 令牌

在下一节中,我们将运用新学的知识,走一遍实际的 OAuth 流程。

练习 9:访问受保护资源

现在你已经了解了 OAuth 的组件和授权码流的理论,让我们通过一个实际的例子来操作。我们将尝试访问由 OAuth 服务器托管的受保护资源,地址为 www.usemodernfullstack.dev/protected/resource。从终端运行本练习的 cURL 命令,跟着操作。

首先,尝试在没有访问令牌的情况下访问受保护的资源,通过发送 GET 请求:

$ **curl -i \**
 **-X GET 'https://www.usemodernfullstack.dev/protected/resource' \**
 **-H 'Accept: text/html'**
`--snip--`
HTTP/2 401
Content-Type: text/html; charset=utf-8
`--snip--`
<h1>Unauthorized request: no authentication given</h1> 

我们使用 -i 标志输出头部信息,当我们在响应中搜索 HTTP 代码时,我们看到 401 状态码,这告诉我们我们没有权限访问该资源,必须获取访问令牌。

要获取访问令牌,我们将通过创建一个用户帐户并将其注册到提供者来设置一个 OAuth 客户端,以接收客户端 ID 和客户端密钥。然后,我们将向 /oauth/authorize 端点发起请求,使用用户凭证登录,并在回调 URL 上接收授权凭证。接下来,我们将在 /oauth/access_token 端点将授权凭证交换为访问令牌。最后,我们将再次发起相同的请求,在头部提供访问令牌。

回调 URL 在这里可以是任何 URL,因为我们不会向其发送任何实际数据。但对于一个真实的授权凭证流程,它需要是你应用中的一个端点。通常,OAuth SDK 会提供这些,因为它处理响应和令牌。

设置客户端

在开始 OAuth 流程之前,我们需要创建一个用户并注册一个 OAuth 客户端。在浏览器中打开 https://www.usemodernfullstack.dev/register。在 图 9-2 中显示的表单上,创建一个用户名和密码自定义的用户帐户。

图 9-2:与 OAuth 提供者创建用户帐户

然后继续注册一个客户端,通过提供回调 URL(图 9-3)。这个回调 URL 指向我们应用中的 OAuth 回调端点。通常,SDK 或 OAuth 提供者会提供如何设置此 URL 的说明。

图 9-3:向 OAuth 服务器注册客户端应用以接收客户端凭证

表单预填充了类似于典型 OAuth 回调结构的回调 URL。通常,你可以在 SDK 的文档中找到这些 URL。不要担心 URL http://localhost:3000/oauth/callback 在你的应用中不存在。对于这次练习,我们不会向其发送任何实际数据;相反,我们将在进行 API 调用时看到它是请求和响应流程的一部分。点击按钮进入下一步,在那里你将创建 OAuth 客户端。确保记下你的用户名、密码、客户端 ID 和客户端密钥。你将在接下来的步骤中需要这些信息。然后点击 注册你的 OAuth 客户端 来完成这个过程。

登录以接收授权凭证

现在,我们注册的用户必须使用他们的凭据登录到 OAuth 提供者,以允许客户端应用程序访问他们的资源。我们调用 OAuth REST API 端点 /oauth/authorize 并(作为资源所有者)使用我们的用户凭据登录,这是流程的第一步。API 响应返回一个重定向到回调 URL,该 URL 参数 code 中包含授权码。

在实际应用中,资源所有者会点击某个“使用 OAuth 登录”按钮并输入他们的凭据,API 调用会在后台进行。但为了本练习的目的,我们将手动执行所有 API 请求。通过使用原始 API 调用,我们将看到 SDK 通常会抽象的操作。直接使用以下 cURL 命令调用 REST 端点:

$ **curl -i \**
 **-X POST 'https://www.usemodernfullstack.dev/oauth/authenticate' \**
 **-H 'Accept: text/html' \**
 **-H 'Content-Type: application/x-www-form-urlencoded' \**
 **-d "response_type****=code\**
**&client_id=<OAUTH_CLIENT_ID>\**
**&state=4nBjkh31\**
**&scope=read\**
**&redirect_uri=http://localhost:3000/oauth/callback\**
**&username=<OAUTH_USER>\**
**&password=<OAUTH_PASSWORD>"**
`--snip--`
HTTP/2 302
Content-Type: text/html; charset=utf-8
location: http://localhost:3000/oauth/callback?code=**<AUTHORIZATION_GRANT>**&state=4nBjkh31 

此 POST 请求用于登录 OAuth 提供者。我们将 URL 设置为 oauth/authenticate 端点,并设置 Accept 头以及适当的 Content-Type 头,application/x-www-form-urlencoded,用于表单数据。

我们使用 -d 标志发送 POST 数据,指示我们正在寻找授权码。为了将 POST 数据拆分为可读的块,我们需要使用双引号 (") 将其括起来,并使用反斜杠 () 进行换行。我们添加了从 OAuth 提供者获得的客户端 ID 和之前讨论的回调 URL。scope 参数指定我们请求的权限,而 state 参数包含一个独特的随机字符串,以缓解跨站请求伪造(CSRF)攻击。OAuth 提供者应该返回此 state 参数以及授权码,以便我们验证其值未发生变化,证明响应来源于正确的 API,而不是第三方。此外,我们发送了之前注册的用户凭据。

响应头告诉我们一切按预期工作。OAuth API 以 302 状态码响应并重定向到我们提供的回调 URL。正如你在 location 头中看到的,重定向到回调 URL 包含 code 参数中的授权码,以及我们发送的 state 参数。与仅被反射的 state 不同,授权码是唯一的,取决于请求数据。

使用授权授权获取访问令牌

接下来,我们使用授权授权从 OAuth 服务器请求访问令牌。复制前一步收到的代码,并使用它通过客户端凭据请求 bearer 访问令牌,API 端点为/oauth/access_token

$ **curl -i \**
 **-X POST 'https://www.usemodernfullstack.dev/oauth/access_token' \**
 **-H 'Accept: text/html, application/json' \**
 **-H 'Content-Type: application/x-www-form-urlencoded' \**
 **-d "code=<AUTHORIZATION_GRANT>\**
**&grant_type=authorization_code\**
**&redirect_uri=http://localhost:3000/oauth/callback\**
**&client_id=<****OAUTH_CLIENT_ID>\**
**&client_secret=<OAUTH_CLIENT_SECRET>"**
`--snip--`
HTTP/2 200 OK
Content-Type: application/json; charset=utf-8

{
    **"access_token":"9bd55e2acf046128a54b76eada1ea6e0f909ca53"**,
    "token_type":"Bearer",
    "expires_in":3599,
    "refresh_token":"79a22d2b37c635a6095f5548ca08ea632deae573",
    "scope":"read"
} 

向 OAuth 服务器发送的 POST 请求使用Accept头部接受 JSON 响应,并将Content-Type头部设置为 POST 表单数据的值。我们通过data-raw标志发送表单数据。数据包含我们在code参数中收到的授权授权、一个grant_type参数,告诉 API 端点预期收到授权授权流程,以及与之前相同的重定向 URL。我们还传递了客户端 ID 和密钥。

响应的 HTTP 状态码为200,这意味着请求成功。在响应体中,我们收到了访问令牌及其他详细信息。复制访问令牌的值以进行下一步操作。

使用访问令牌获取受保护的资源

现在,我们已经从 OAuth 服务器获得了访问令牌,可以用来检索之前无法访问的受保护资源。使用相同的 cURL 命令请求www.usemodernfullstack.dev/protected/resource,并将ACCESS_TOKEN占位符替换为访问令牌:

$ **curl -i \**
 **-X GET 'https://www.usemodernfullstack.dev/protected/resource' \**
 **-H 'Accept: text/html' \**
 **-H 'Authorization: Bearer** **`<ACCESS_TOKEN>`****'**
`--snip--`
**HTTP/2 200 OK**
Content-Type: text/html; charset=utf-8
`--snip--`
<h1>This page is secured.</h1>
`--snip--` 

我们使用包含Authorization头部、带有Bearer关键字以及我们从授权授权流程中收到的访问令牌(位于access_token属性)的 HTTP 请求。当我们查看 HTTP 状态码时,发现收到的状态码是200,而不是401。仔细检查后,我们还发现响应体中包含了安全内容。

我们手动完成了接收有效访问令牌所需的所有步骤。此练习仅适用于教育目的;正如本章前面提到的,我们通常使用 SDK 或库(如next-auth)来实现 OAuth 流程。

总结

身份验证涉及使用凭证来授权访问,而授权则定义并授予访问权限。本章介绍了如何使用 OAuth2 协议实现授权。你已经熟悉了授权授权流,这是在全栈 web 应用程序中最常用的 OAuth 流程,并学习了如何创建 JWT。接着,你实践了手动操作 OAuth,获取和使用 JWT 承载令牌,并从鸟瞰图的角度将 OAuth 流程应用到你的应用程序中。

你可以在https://oauth.net找到更多资源、教程和规范。下一章将介绍 Docker,一个容器化平台,它将你的开发环境与本地机器解耦。

第十章:10 使用 Docker 进行容器化

专业的全栈开发者经常使用 Docker,广义上也使用容器。Docker 作为一个开源容器化平台,解决了三个常见问题。

首先,它使我们能够为每个项目运行某个特定版本的软件,如 Node.js。其次,它将开发环境与本地机器解耦,并创建一种可复现的方式来运行应用程序。第三,与传统的虚拟机不同,Docker 容器运行在共享主机上。因此,它们的体积更小,消耗的内存比传统虚拟机要少,后者需要模拟完整的系统,且通常依赖特定硬件。因此,基于容器的应用程序轻量且易于扩展。这些优势使得 Docker 成为近年来最受欢迎的开发平台。

本章将介绍 Docker 的基础知识。我们首先演示如何通过创建一个运行最新 Node.js 版本并在容器内提供应用程序的 Docker 容器,将我们的 Next.js 应用容器化。接着,我们将探讨微服务架构的概念,并使用 Docker 创建两个微服务。

容器化架构

在日常工作中,开发者必须频繁地在需要不同版本同一库的应用程序之间切换。例如,专注于 JavaScript 的开发者可能需要为每个项目使用不同版本的 Node.js 或 TypeScript。当然,他们可以使用像 nvm 这样的工具,在本地机器上切换已安装的 Node.js 版本,每当需要切换到不同的项目时。但与其采取这种粗糙的方式,他们可以选择一个更优雅的解决方案。

使用 Docker,我们可以将应用程序或其服务分离到独立的容器中,每个容器都提供特定服务的环境。这些容器运行在我们选择的操作系统上(通常是 Debian、Ubuntu 或 Alpine),并且只包含此特定应用程序所需的依赖项。容器之间是隔离的,并通过定义的 API 进行通信。

当我们在开发过程中使用 Docker 容器时,我们便于应用程序的后续部署。毕竟,容器提供了一个与平台无关的版本,这意味着无论在哪个环境中,应用程序都能正常运行。因此,我们已经知道我们的应用程序能够与已安装的依赖项配合使用,不需要解决冲突或执行额外的安装步骤。与其设置一个远程服务器,安装所需的软件,然后再部署和测试我们的应用程序,我们可以直接将 Docker 容器移到服务器上,并在那里启动它。

在需要迁移到不同服务器、扩展应用程序、添加额外数据库服务器或将实例分布到多个位置时,Docker 使我们能够通过相同的简便流程部署应用程序。我们无需管理不同的主机和配置,就能有效地构建一个与平台无关的应用程序,并在任何地方运行相同的容器。

安装 Docker

要检查是否已经安装 Docker,请打开命令行并运行 docker -v。如果看到的版本号高于 20,则应该能够继续跟随本章中的示例。否则,你需要从 Docker Inc. 安装最新版本的 Docker。请访问 https://www.docker.com/products/docker-desktop/。然后选择适合你操作系统的 Docker 桌面安装程序并下载。执行应用程序,并在命令行中检查 Docker 版本号。它应该与你下载的版本一致。

创建 Docker 容器

Docker 有几个组件。运行 Docker 守护进程的物理或虚拟机器称为 主机系统。当你在本地开发应用程序时,主机是你的物理机器,而当你部署容器时,主机是运行应用程序的服务器。

我们在主机系统上使用 Docker 守护进程服务 来与 Docker 平台的所有组件进行交互。守护进程通过 API 提供 Docker 的功能,并且是安装在我们机器上的实际 Docker 应用程序。使用命令行中的 docker 命令访问守护进程。运行 docker --help 以显示所有可能的交互。

我们使用 Docker 容器 来运行容器化的应用程序。这些容器是特定 Docker 镜像的运行实例,镜像是包含应用程序的工件。每个 Docker 镜像都依赖于一个 Dockerfile,该文件定义了 Docker 镜像的配置和内容。

编写 Dockerfile

Dockerfile 是一个文本文件,包含我们设置 Docker 镜像所需的信息。它通常基于一些现有的基础镜像,例如一个基础的 Linux 系统,在此基础上我们安装了额外的软件或预配置的环境。例如,我们可能会使用一个包含 Node.js、MongoDB 和所有相关依赖项的 Linux 镜像。

通常,我们可以基于官方镜像进行构建。例如,清单 10-1 展示了我们用于容器化重构后的 Next.js 应用程序的基本 Dockerfile。Dockerfile 包含关键字和后续命令,我们在这里使用FROM关键字来选择官方的 Node.js Docker 镜像。在项目根目录下(与package.json文件相邻)创建一个名为Dockerfile的文件,并将清单 10-1 中的代码添加到其中。

FROM node:current

WORKDIR /home/node
COPY package.json package-lock.json /home/node/
EXPOSE 3000 

清单 10-1:用于典型 Node.js 应用程序的简单 Dockerfile

我们选择的镜像包含一个运行在 Debian 上的预配置 Node.js 系统。版本标签current提供了最新的 Node.js 版本;或者,我们可以在此处指定特定的版本号。因此,如果需要将应用程序锁定到特定的 Node.js 版本,这是实现的方法。你还可以使用更轻量级的node:current-slim镜像,它是一个精简的 Debian 发行版,仅包含运行 Node.js 所需的软件包。不过,由于我们需要 MongoDB 的内存服务器,因此我们选择了常规镜像。你可以在https://hub.docker.com查看可用的镜像列表。在你的职业生涯中,你可能还会使用其他镜像,如 WordPress、MySQL、Redis、Apache 和 NGINX 的镜像。

最后,我们使用WORKDIR关键字将 Docker 镜像内的工作目录设置为用户的主目录。所有后续命令将会在该目录中执行。我们使用COPY关键字将package .jsonpackage-lock.json文件添加到工作目录中。Node.js 应用程序默认运行在 3000 端口,因此我们使用EXPORT关键字选择 3000 端口用于 TCP 连接。这个连接将允许从容器外部访问应用程序。

构建 Docker 镜像

要从 Dockerfile 创建 Docker 镜像,我们使用docker image build命令。在构建过程中,Docker 守护进程读取 Dockerfile 并执行其中定义的命令,下载和安装软件,复制本地文件到镜像中,并配置环境。运行以下命令来构建镜像:

$ **docker image build --tag nextjs:latest .**
[+] Building 11.9s (10/10) FINISHED
 => [internal] load build definition from **Dockerfile**                   0.1s
 => => transferring dockerfile: 136B                                   0.0s
 => [1/2] FROM docker.io/library/node:current-alpine@sha256:HASH 0.0s
 => [2/2] WORKDIR /home/node                                           0.0s
 => => naming to docker.io/library/ nextjs:latest 

--tag 标志为镜像命名为 nextjs 并将其版本设置为 latest。现在我们可以在后续的操作中轻松引用这个特定的镜像。我们在命令末尾使用一个句点 (.) 来设置构建上下文,将 docker build 命令的文件访问限制在当前目录。输出中,Docker 守护进程表示它已成功构建了标记的镜像。

现在,为了验证我们是否可以访问镜像,运行以下命令。这条命令会列出所有本地可用的 Docker 镜像:

$ **docker image ls**
REPOSITORY    TAG        IMAGE
nextjs        latest     98b28358e19a 

正如预期的那样,我们新创建的镜像有一个随机的 ID (98b28358e19a),并被标记为 nextjs,且版本为 latest。Docker 守护进程可能还会显示额外的信息,比如镜像的大小和创建时间,暂时这些对我们来说并不重要。

Docker 提供了额外的命令来管理本地和远程镜像。你可以通过运行 docker image --help 查看所有可用的命令列表。例如,要从本地机器中删除一个现有的镜像,可以使用 docker image rm:

$ **docker image rm** **`<name:version or ID>`**

一段时间后,你会发现自己收集了许多未使用或过时的镜像版本,使用 docker image prune 删除它们以释放你机器上的空间是一种好习惯。

从 Docker 容器提供应用服务

Docker 容器是 Docker 镜像的运行实例。你可以使用相同的 Docker 镜像启动多个容器,每个容器都有唯一的名称或 ID。一旦容器运行,你可以将本地文件同步到容器中。容器会监听一个暴露的 TCP 或 UDP 端口,你可以通过 SSH 连接到容器并在其中执行命令。

让我们将应用容器化。我们将从镜像启动 Docker 容器,将本地的 Next.js 文件映射到工作目录,暴露端口,最后启动 Next.js 开发服务器。我们可以通过 docker container run 完成这一切:

$ **docker container run \**
**--name nextjs_container \**
**--volume ~/nextjs_refactored/:/home/node/ \**
**--publish-all \**
**nextjs:latest npm run dev**
> refactored-app@0.1.0 dev
> next dev

ready - started server on 0.0.0.0:3000, url: http://localhost:3000
event - compiled client and server successfully in 10.9s (208 modules) 

乍一看,这个命令可能看起来很复杂,但一旦我们仔细看,你就会轻松理解它的作用。我们给它传递了几个标志,第一个是 --name,它为正在运行的容器分配一个唯一的名称。我们稍后会用这个名称来标识容器。

然后我们使用 --volume 标志来创建一个 Docker 卷。是容器之间共享数据的一种简单方式。Docker 本身管理它们,它们让我们将应用程序文件同步到容器内的 home/node/ 目录。我们使用 source:destination 格式来定义卷,并根据你的文件结构,可能需要调整该文件夹的绝对路径。在这个例子中,我们将 /nextjs_refactored/ 从用户的主文件夹映射到容器内。

--publish-all 标志发布所有导出的端口,并将它们分配给主机系统上的随机端口。我们稍后使用 docker container ls 查看我们应用程序的端口。最后两个参数是直观的:nextjs:latest 指向我们希望用于容器的 Docker 镜像,而 npm run dev 启动 Next.js 开发服务器,像往常一样运行。控制台输出显示容器内部的 Node.js 应用程序正在运行并监听端口 3000。

定位暴露的 Docker 端口

不幸的是,一旦我们尝试通过端口 3000 访问 Next.js 应用程序,浏览器会通知我们该端口不可访问;没有应用程序在该端口监听。问题是我们没有将暴露的 Docker 端口 3000 映射到主机的端口 3000。相反,我们使用了 --publish-all 标志,并将暴露的 Docker 端口分配给了一个随机端口。

让我们运行 docker container ls 查看所有运行中 Docker 容器的详细信息:

$ **docker container ls**
CONTAINER ID   IMAGE             PORTS                     NAMES
dff681898013   nextjs:latest     0.0.0.0:55000->3000/tcp   nextjs_container 

搜索我们为容器指定的名称,nextjs_container,并注意主机上的端口 55000 映射到 Docker 端口 3000。因此,我们可以在 http://localhost:55000 访问我们的应用程序。在浏览器中打开此 URL,你应该能看到 Next.js 应用程序。

如果你看一下 URL 地址栏,你会注意到我们用来访问应用程序的端口与前几章中使用的不同,因为它现在运行在 Docker 容器内部。尝试访问我们在之前章节中创建的所有页面和 API,然后再继续下一部分。

与容器交互

你可以通过运行docker container --help查看所有与容器交互的 Docker 命令列表。然而,在大多数情况下,了解其中的一些命令就足够了。例如,使用exec可以在已经运行的 Docker 容器内执行命令。我们可以通过传递-it标志和 shell 路径(例如/bin/sh)来使用exec连接到容器内部的 shell。-i标志是--interactive的简写,而-t则启动一个伪终端。交互选项让我们能够与容器进行交互,而tty伪终端保持 Docker 容器运行,从而使我们能够与其实际互动:

$ **docker container exec -it** **`<container ID or name>`** **/bin/sh**

kill命令停止正在运行的 Docker 容器:

$ **docker container kill** **`<containerid or name>`**

我们可以通过名称或使用在本地运行容器列表中显示的容器 ID 来选择容器。

使用 Docker Compose 创建微服务

Docker 为我们提供了一种将应用程序分解为小而独立的单元——称为微服务的方法。微服务驱动的架构将应用程序拆分为一组自包含的服务,这些服务通过定义良好的 API 进行通信。这是一个相对较新的架构概念,最初在 2000 年代末至 2010 年代初期获得关注,当时 Docker 和其他可以更轻松分割和编排服务器资源的工具开始普及。这些工具构成了微服务架构的技术基础。

微服务有几个优点。首先,每个独立的服务只有一个单一的目的,这减少了其复杂性。因此,它更容易进行测试和维护。我们还可以单独部署微服务,启动同一微服务的多个实例来提高其性能,或完全替换它而不影响整个应用程序。与此对比的是传统的单体应用程序,其用户界面、中间件和数据存储都存在于一个由单一代码库构建的单一程序中。即使单体应用程序采用更模块化的方法,代码库也将它们紧密耦合,你无法轻松地替换其中的元素。

微服务的另一个特点是,专门的团队可以只负责单一服务及其代码库。这意味着他们可以根据每个服务选择适当的工具、框架和编程语言。另一方面,你通常会使用一种核心语言来编写单体应用程序。

现在你已经知道如何从零开始创建一个单一容器,我们将练习创建多个容器;每个容器将服务于应用程序的一个部分。使用微服务的一种方法是为前端创建一个服务,为后端创建另一个服务。我们将在第二部分中创建的 Food Finder 应用程序将使用这种结构。此方法的主要好处是它允许我们使用预配置的 MongoDB 镜像作为数据库。对于本章中的示例,我们将创建第二个服务来监视我们的天气服务,并在文件更改时立即重新运行其测试套件。为此,我们将使用 Docker Compose 接口,并在docker-compose.yml文件中定义我们的微服务架构。

编写 docker-compose.yml 文件

我们在docker-compose.yml中定义所有服务,这是一个 YAML 格式的文本文件。该文件还为每个服务设置属性、依赖关系和卷。大多数属性类似于你在创建 Docker 镜像和容器时指定的命令行标志。创建文件并将清单 10-2 中的代码添加到应用程序的根文件夹中。

version: "3.0"
services:
    application:
        image:
            nextjs:latest
        ports:
            - "3000:3000"
        volumes:
            - ./:/home/node/
        command:
            "npm run dev"
    jest:
        image:
            nextjs:latest
        volumes:
            - ./:/home/node/
        command:
            "npx jest ./__tests__/mongoose/weather/services.test.ts --watchAll" 

清单 10-2:定义应用程序和 Jest 服务的基本 docker-compose.yml 文件

每个docker-compose.yml文件首先通过设置所使用的 Docker Compose 规范的版本来开始。根据版本的不同,我们可以使用不同的属性和值。然后,我们在services下定义每个服务作为单独的属性。如前所述,我们希望有两个服务:我们的 Next.js 应用程序运行在 3000 端口,和 Jest 服务,它监视我们在第八章中创建的services .test.ts文件,并在我们更改文件时立即重新运行测试。我们将 watch 命令限制为仅重新测试 services。这限制了练习的范围,但当然,如果你愿意,也可以重新运行所有测试。

每个服务的结构大致相同。首先,我们定义 Docker Compose 应从哪个镜像创建每个容器。这可以是官方发行版,也可以是本地构建的镜像。我们为两个服务都使用nextjs镜像的latest版本。然后,我们不使用--publishAll标志,而是直接将ports从 3000 映射到 3000。这样,我们就可以从主机的 3000 端口连接到应用程序的 3000 端口。

使用 volumes 属性,我们将主机系统中的文件和路径同步到容器中。这类似于我们在 docker run 命令中使用的映射方式,但与提供绝对路径不同,我们可以对源使用相对路径。在这里,我们将整个本地目录 ./ 映射到容器的工作目录 /home/node。如前所述,我们可以在本地编辑 TypeScript 文件,容器中的应用程序始终使用文件的最新版本。

到目前为止,这些属性与我们在 docker run 命令中使用的命令行参数相匹配。现在我们添加 command 属性,用于指定每个容器在启动时执行的命令。对于应用服务,我们将使用常规的 npm run dev 命令启动 Next.js,而 Jest 服务则应该通过 npx 直接调用 Jest。提供测试文件的路径和 --watchAll 标志会导致 Jest 在源代码变化时重新运行测试。

运行容器

使用 docker compose up 命令启动多容器应用。输出应该类似于这里所示:

$ **docker compose up**
 [+] Running 2/2
 ⠿ Container application-1     Created                       0.0s
 ⠿ Container jest-1  Recreated                               0.4s
Attaching to application-1, jest-1
application-1     |
application-1     | > refactored-app@0.1.0 dev
application-1     | > next dev
application-1     |
application-1     | ready - started server on 0.0.0.0:3000, URL:
application-1     | http://localhost:3000
jest-1            | PASS __tests__/mongoose/weather/services.test.ts
jest-1            |  the weather services
jest-1            |     API storeDocument
jest-1            |       ✓ returns true  (9 ms)
jest-1            |       ✓ passes the document to Model.create()  (6 ms)
jest-1            |     API findByZip
jest-1            |       ✓ returns true  (1 ms)
jest-1            |       ✓ passes the zip code to Model.findOne()  (1 ms)
jest-1            |     API updateByZip
jest-1            |       ✓ returns true  (1 ms)
jest-1            |       ✓ passes the zip code and the new data to
jest-1            |         Model.updateOne()  (1 ms)
jest-1            |     API deleteByZip
jest-1            |       ✓ returns true  (1 ms)
jest-1            |       ✓ passes the zip code Model.deleteOne()  (1 ms)
jest-1            |
jest-1            | Test Suites: 1 passed, 1 total
jest-1            | Tests:       8 passed, 8 total
jest-1            |    0 total
jest-1            | Time:        4.059 s
jest-1            | Ran all test suites matching
jest-1            |    /.\/__tests__\/mongoose\/weather\/services.test.ts/i. 

Docker 守护进程启动所有服务。一旦应用程序准备就绪,我们会看到来自 Express.js 服务器的状态消息,并且可以通过暴露的端口 3000 连接到它。同时,Jest 容器运行天气服务的测试并报告所有测试成功。

重新运行测试

现在我们已经启动了 Docker 环境,接下来验证一下用于查找代码变化并重新运行测试的命令是否按预期工作。为此,我们需要修改源代码来触发 Jest。因此,我们打开 mongoose/weather/service.ts 文件,修改内容,添加一个空行,然后保存文件。Jest 应该会重新运行容器内的测试,正如你可以从 清单 10-3 的输出中看到的那样。

jest-1            | Ran all test suites matching
jest-1            |    /.\/__tests__\/mongoose\/weather\/services.test.ts/i.
jest-1            |
jest-1            | PASS __tests__/mongoose/weather/services.test.ts
jest-1            |   the weather services
jest-1            |     API storeDocument
jest-1            |       ✓ returns true  (9 ms)
jest-1            |       ✓ passes the document to Model.create()  (6 ms)
jest-1            |     API findByZip
jest-1            |       ✓ returns true  (1 ms)
jest-1            |       ✓ passes the zip code to Model.findOne()  (1 ms)
jest-1            |     API updateByZip
jest-1            |       ✓ returns true  (1 ms)
jest-1            |       ✓ passes the zip code and the new data to
jest-1            |         Model.updateOne()  (1 ms)
jest-1            |     API deleteByZip
jest-1            |       ✓ returns true  (1 ms)
jest-1            |       ✓ passes the zip code Model.deleteOne()  (1 ms)
jest-1            |
jest-1            | Test Suites: 1 passed, 1 total
jest-1            | Tests:       8 passed, 8 total
jest-1            |    0 total
jest-1            | Time:        7.089 s
jest-1            | Ran all test suites matching
jest-1            |    /.\/__tests__\/mongoose\/weather\/services.test.ts/i 

清单 10-3:使用 jest --watchAll 重新运行已更改文件的测试

所有测试仍然通过。连接到 http://localhost:3000 并验证你的浏览器是否仍然能够渲染应用程序。

与 Docker Compose 交互

Docker Compose 提供了一个完整的接口,用于管理微服务应用程序。你可以通过运行 docker compose --help 查看可用的命令列表。以下是最重要的命令。

我们使用 docker compose ls 来获取所有本地运行的 Docker 应用程序列表,这些应用程序在 docker-compose.yml 文件中定义。该命令返回应用程序的名称和状态:

$ **docker compose ls**

要关闭当前目录中 docker-compose.yml 文件中定义的所有正在运行的服务,请运行 docker compose kill,该命令会向每个容器中的主进程发送 SIGKILL 命令:

$ **docker compose kill**

要以更优雅的 SIGTERM 命令关闭服务,请使用以下命令:

$ **docker compose down**

与强制关闭不同,这个命令会优雅地移除通过 docker compose up 创建的所有进程、容器、网络和卷。

总结

使用 Docker 容器化平台使得部署应用程序和使用微服务架构变得更加容易。本章介绍了 Docker 生态系统的基本构件:主机、Docker 守护进程、Dockerfile、镜像和容器。通过使用 Docker Compose 和 Docker 卷,你可以将应用程序分割成单个、独立的服务。

要充分释放 Docker 的潜力,请阅读官方教程 https://docs.docker.com/get-started/ 或者 https://docker-curriculum.com。在下一章中,你将开始构建 Food Finder 应用程序。这个全栈 Web 应用程序将建立在你在之前所有章节中获得的知识基础上。

第二部分 全栈应用

第十一章:11 设置 Docker 环境

在本书的这一部分,你将通过运用迄今为止学到的知识,从零开始构建一个全栈应用程序。虽然前面的章节已经解释了部分技术栈,但剩下的章节将更详细地聚焦于代码部分。

本章描述了你将构建的应用程序,并引导你通过使用 Docker 配置环境。虽然我建议在开始编写代码之前阅读前面的章节,但唯一的真正要求是,在继续之前,你必须确保已经安装并运行 Docker。有关安装 Docker 的说明,请参考第十章。

注意

你可以从 <wbr>www<wbr>.usemodernfullstack<wbr>.dev<wbr>/downloads<wbr>/food<wbr>-finder 下载 Food Finder 应用程序的完整源代码,以及从 <wbr>www<wbr>.usemodernfullstack<wbr>.dev<wbr>/downloads<wbr>/assets 下载仅包含所需资源的 ZIP 文件。

Food Finder 应用程序

Food Finder 应用程序展示了一系列餐馆及其位置。用户可以点击这些餐馆,以查看每个位置的更多细节。此外,用户还可以通过 OAuth 使用他们的 GitHub 账户登录该应用,以便维护一个位置的愿望清单。

在幕后,我们将使用 TypeScript 编写这个简单的单页应用程序。在设置本地环境后,我们将使用 Next.js、Mongoose 和 MongoDB 构建后端和中间件,并为其预填充初始数据。然后,我们将添加 GraphQL,以通过 API 层访问用户的愿望清单。为了构建前端,我们将运用对 React 组件、Next.js 页面和路由的知识。我们还将使用 next-auth 添加 OAuth 授权流,以便用户可以通过 GitHub 登录。最后,我们将使用 Jest 编写自动化测试,以验证应用程序的完整性和稳定性。

使用 Docker 构建本地环境

Docker 将开发环境与我们的本地机器解耦。我们将使用它为应用程序的每个部分创建自包含的服务。在 docker-compose 文件中,我们将添加一个服务来提供 MongoDB 数据库的后端,另一个服务来运行托管前端和中间件的 Next.js 应用程序。

要开始开发,创建一个新的空文件夹 code。该文件夹将作为应用程序的根目录,并包含 Food Finder 应用程序的所有代码。在本章后面,我们将使用 create-next-app 辅助命令向其中添加文件。

接下来,在这个根目录下创建一个空的docker-compose.yml文件和一个.docker文件夹。在文件中,我们将定义环境的两个服务,并存储我们创建容器所需的种子数据。

后端容器

后端容器只提供应用的 MongoDB 实例。因此,我们可以使用官方的 MongoDB 镜像,Docker 可以自动从 Docker 注册表下载该镜像,而无需创建自定义的 Dockerfile。

种植数据库

我们希望 MongoDB 以一个预填充的数据库启动,该数据库包含一组有效的初始数据集。这个过程称为数据库的种植,我们可以通过将种植脚本seed-mongodb.js复制到容器的/docker-entrypoint-initdb.d/目录中来自动化这个过程。MongoDB 镜像会在容器的/data/db目录没有数据时,在启动时执行这个文件夹中的脚本,并将其应用到<MONGO_INITDB_DATABASE>环境变量中定义的数据库上。

.docker文件夹中创建一个新的文件夹foodfinder-backend,然后将之前下载的assets.zip文件中的seed-mongodb.js文件复制到新创建的文件夹中。种子文件的内容应该类似于列表 11-1。

db.locations.insert([
    {
        address: "6220 Avenue U",
        zipcode: "NY 11234",
        borough: "Brooklyn",
        cuisine: "Cafe",
        grade: "A",
        name: "The Roasted Bean",
        on_wishlist: [],
        location_id: "56018",
    },
`--snip--`
    {
        address: "405 Lexington Avenue",
        zipcode: "NY 10174",
        borough: "Manhattan",
        cuisine: "American",
        grade: "A",
        name: "The Diner At The Corner",
        on_wishlist: [],
        location_id: "63426",
    }
]); 

列表 11-1:seed-mongodb.js 文件

你可以看到,这个脚本直接与我们将在下一节中设置的 MongoDB 实例中的一个集合进行交互。我们使用 MongoDB 的 insert 方法,将文档填充到数据库的 location 集合中。请注意,我们使用的是原生 MongoDB 驱动程序来插入文档,而不是使用 Mongoose。我们之所以这样做,是因为默认的 MongoDB Docker 镜像中没有安装 Mongoose,而插入文档是一个相对简单的任务。尽管我们没有使用 Mongoose 来种植数据库,但我们插入的文档需要与我们稍后用 Mongoose 定义的架构相匹配。

创建后端服务

现在,我们可以在 Docker 设置中定义后端服务。将列表 11-2 中的代码添加到我们之前创建的空的docker-compose.yml文件中。

version: "3.0"
services:
    backend:
        container_name: foodfinder-backend
        image: mongo:latest
        restart: always
 environment:
            DB_NAME: foodfinder
            MONGO_INITDB_DATABASE: foodfinder
        ports:
            - 27017:27017
        volumes:
            - "./.docker/foodfinder-backend/seed-mongodb.js:
/docker-entrypoint-initdb.d/seed-mongodb.js"
            - mongodb_data_container:/data/db

volumes:
    mongodb_data_container: 

列表 11-2:带有后端服务的 docker-compose.yml 文件

我们首先定义容器的名称,以便后续可以轻松引用它。如前所述,我们使用官方 MongoDB 镜像的最新版本,并指定如果容器停止,它应始终重新启动。接下来,我们使用环境变量来定义我们将与 MongoDB 一起使用的集合。我们定义了两个变量:DB_NAME指向我们将与 Mongoose 一起使用的集合,MONGO_INITDB_DATABASE指向种子脚本。/docker-entrypoint-initdb.d/中的脚本默认使用这个后者集合。

我们希望脚本填充应用程序的数据库,因此我们将两个变量设置为相同的名称foodfinder,从而为我们的 Mongoose 模型提供了一个预填充的数据库。

然后我们将容器的内部端口 27017 映射并暴露到主机的端口 27017,以便 MongoDB 实例可以通过应用程序访问,地址为mongodb://backend:27017/foodfinder。请注意,连接字符串中包含了服务名称、端口和数据库。稍后,我们将这个连接字符串存储在环境变量中,并用它从中间件连接到数据库。最后,我们将种子脚本映射并复制到设置位置,并将数据库数据从/data/db保存到 Docker 卷mongodb_data_container中。因为我们希望将字符串拆分到两行,所以需要根据 YAML 约定将其包裹在双引号中(")。

现在使用docker compose up完成 Docker 设置:

$ **docker compose up**
[+] Running 2/2
 ⠿ Network foodfinder_default                      Created                 0.1s
 ⠿ Container foodfinder-backend                    Created                 0.3s
Attaching to foodfinder-backend

foodfinder-backend  | /usr/local/bin/docker-entrypoint.sh: running /docker
                    /entrypoint-initdb.d/seed-mongodb.js 

输出显示 Docker 守护进程成功创建了foodfinder-backend容器,并且在启动期间执行了种子脚本。我们通过在docker-compose文件中添加几行代码,将 MongoDB 添加到我们的项目中,而不必经历安装和维护 MongoDB 的麻烦,或寻找免费的或低成本的云实例。

使用 CRTL-C 停止容器,并通过docker compose down将其移除:

$ **docker compose down**
[+] Running 2/2
 ⠿ Container foodfinder-backend                     Removed                 0.0s
 ⠿ Network foodfinder_default                       Removed 

现在我们可以添加前端容器了。

前端容器

现在我们将创建前端和中间件的容器化基础设施。我们的做法是使用create-next-app来搭建 Next.js 应用程序,正如我们在第五章中所做的那样,依赖官方的 Node.js Docker 镜像,将应用程序与任何本地 Node.js 安装解耦。

由于我们将所有与 Node.js 相关的命令都在该容器内执行,从技术上讲,我们甚至不需要在本地机器上安装 Node.js;也不必确保我们使用的 Node.js 版本符合 Next.js 的要求。此外,npm 可能会安装优化过的操作系统相关的包,因此通过在容器内使用 npm,我们确保了 npm 安装适用于 Linux 的正确版本。

尽管如此,我们仍然希望 Docker 同步 Node.js modules 文件夹到我们的本地系统。这将允许我们的 IDE 自动使用已安装的依赖项,例如 TypeScript 编译器和 ESLint。让我们从创建一个最小的 Dockerfile 开始。

创建应用程序服务

我们通过将 列表 11-3 中的代码添加到项目的 docker-compose.yml 文件的 services 属性中,将前端和中间件服务结合到我们的 Docker 设置中。

`--snip--`
services:

    application:
        container_name: foodfinder-application
        image: node:lts-alpine
        ports:
            - "3000:3000"
        volumes:
            - ./code:/home/node/code
        working_dir: /home/node/code/
        depends_on:
            - backend
        environment:
            - HOST=0.0.0.0
            - CHOKIDAR_USEPOLLING=true
            - CHOKIDAR_INTERVAL=100
        tty: true
    backend:
`--snip--` 

列表 11-3:带有后端和应用程序服务的 docker-compose.yml 文件

Food Finder 应用程序的服务结构与后端服务的结构相同。首先,我们设置容器的名称。然后,我们定义为该特定服务使用的镜像。虽然后端服务使用了官方的 MongoDB 镜像,但我们现在使用的是官方的 Node.js 镜像,并且运行的是当前 LTS 版本,基于 Alpine Linux 的轻量级 Linux 发行版,这种发行版比基于 Debian 的镜像消耗更少的内存。

然后,我们暴露并映射 3000 端口,使应用程序可以通过 http://localhost:3000 访问,并将本地应用程序的代码目录映射到容器中。接下来,我们将工作目录设置为 code 目录。我们指定容器需要一个正在运行的后端服务,因为 Next.js 应用程序需要与 MongoDB 实例保持有效连接。此外,我们还添加了环境变量。特别地,chokidar 支持 Next.js 代码的热重载。最后,将 tty 属性设置为 true 使容器提供交互式 shell,而不是关闭容器。我们需要这个 shell 来在容器内执行命令。

安装 Next.js

在这两个服务都就绪后,我们现在可以在容器内安装 Next.js。为此,我们需要使用 docker compose up 启动容器:

$ **docker compose up**

[+] Running 3/3
 ⠿ Network foodfinder_default                      Created                 0.1s
 ⠿ Container foodfinder-backend                    Created                 0.3s
 ⠿ Container foodfinder-application                Created                 0.3s
Attaching to foodfinder-application, foodfinder-backend
`--snip--`
foodfinder-application  | Welcome to Node.js ...
`--snip--` 

将这个命令行输出与之前的 docker compose up 输出进行对比。你应该能看到应用程序容器已成功启动,并运行一个 Node.js 交互式 shell。

现在我们可以使用 docker exec 在正在运行的容器内执行命令。这样做有两个主要优点。首先,我们在本地机器上不需要任何特定版本的 Node.js(甚至不需要任何版本)。其次,我们在 Node.js Linux Alpine 镜像中运行 Node.js 应用程序和 npm 命令,这样依赖项就会针对 Alpine 优化,而不是针对我们的主机系统。

要在容器内运行 npm 命令,可以使用 docker exec -it foodfinder-application 后跟要运行的命令。Docker 守护进程会连接到容器内的终端,并在应用程序容器的工作目录 /home/node/code 中执行提供的命令,这个目录是我们之前设置的。让我们使用在 第五章 中讨论的 npx 命令在那里安装 Next.js 应用程序:

/home/node/code# **docker exec -it foodfinder-application \**
**npx create-next-app@latest foodfinder-application \**
**--typescript --use-npm**
Need to install the following packages:
  create-next-app
Ok to proceed? (y)
✔ Would you like to use ESLint with this project? ... No / Yes
Creating a new Next.js app in /home/node/code/foodfinder-application.

Success! Created foodfinder-application at /home/node/code/foodfinder-application 

我们将项目名称设置为 foodfinder-application 并接受默认设置。其余的输出应该对你来说是熟悉的。

一旦脚手架搭建完成,我们可以使用 npm run dev 启动 Next.js 应用程序。如果你在浏览器中访问 http://localhost:3000,应该能看到熟悉的 Next.js 启动画面。foodfinder-application 文件夹应映射到本地的 code 文件夹,这样我们就可以在本地编辑与 Next.js 相关的文件。

调整应用程序服务以支持重启

目前,连接到应用程序容器需要在每次通过 docker compose up 重启后运行 docker exec,然后手动调用 npm run dev。让我们对应用程序服务进行两项小调整,以实现更便捷的设置。修改文件,使其与 示例 11-4 匹配。

`--snip--`
services:
`--snip--`
    **application:**
`--snip--`
        volumes:
            - ./code:/home/node/code
        working_dir: /home/node/code/**foodfinder-application**
`--snip--`
 **command: "npm run dev"**
`--snip--` 

示例 11-4:用于自动启动 Next.js 的 docker-compose.yml 文件

首先,修改 working_dir 属性。因为我们正在处理 Next.js,所以我们将其设置为 Next.js 应用程序的根文件夹 /home/node/code/foodfinder-application,该文件夹包含 package.json 文件。然后,我们添加 command 属性,值为 npm run dev。通过这两个修改,每次调用 docker compose up 时,Next.js 应用程序应立即启动。尝试使用 docker compose up 启动容器;控制台输出应显示 Next.js 正在运行,并且可以通过 http://localhost:3000 访问:

$ **docker compose up**
[+] Running 3/3
 ⠿ Network foodfinder_default                      Created    0.1s
 ⠿ Container foodfinder-backend                    Created    0.3s
 ⠿ Container foodfinder-application                Created    0.3s
Attaching to foodfinder-application, foodfinder-backend
foodfinder-application  |
foodfinder-application  | > foodfinder-application@0.1.0 dev
foodfinder-application  | > next dev
foodfinder-application  |
foodfinder-application  | ready - started server on 0.0.0.0:3000,
foodfinder-application  | url: foodfinder-application  | http://localhost:3000
foodfinder-application  | info  - Loaded env from /home/node/code/foodfinder-
foodfinder-application  | application/.env.local 

如果你在浏览器中访问 http://localhost:3000,你应该会看到 Next.js 启动画面,而不需要手动启动 Next.js 应用程序。

请注意,如果你在 Linux 或 macOS 上使用非管理员或 root 用户,你需要调整应用服务和启动命令。因为 Docker 守护进程默认以 root 用户身份运行,它创建的所有文件都需要 root 权限。你的常规用户没有这些权限,无法访问这些文件。为避免这些问题,请修改设置,使得 Docker 守护进程将所有权转移给你的用户。首先,将 列表 11-5 中的代码添加到 docker-compose 文件中的应用服务。

services:
`--snip--`
    **application:**
`--snip--`
 **user: ${MY_USER}**
`--snip--` 

列表 11-5:带有 user 属性的 docker-compose.yml 文件

我们将 user 属性添加到 application 服务,并使用环境变量 MY_USER 作为该属性的值。然后我们修改 docker compose 命令,使得在启动时将当前用户的用户 ID 和组 ID 添加到该环境变量中。我们使用以下代码,而不是直接调用 docker compose up:

MY_USER=$(id -u):$(id -g) docker compose up

我们使用 id 辅助程序将用户 ID 和组 ID 以 userid:groupid 格式保存到我们的环境变量中,docker-compose 文件随后会读取这个变量。-u 标志返回用户 ID,-g 标志返回组 ID。

总结

我们已经使用 Docker 容器设置好了本地开发环境。通过我们在本章中创建的 docker-compose.yml 文件,我们将应用程序开发与本地主机系统解耦。现在我们可以更换主机系统,并确保 Food Finder 应用始终使用相同的 Node.js 版本。此外,我们还添加了一个运行 MongoDB 服务器的容器,在下一章我们将连接该容器并实现应用程序的中间件。

第十二章:12 构建中间件

中间件是将我们稍后创建的前端与后端容器中现有的 MongoDB 实例连接的“软件胶水”。在本章中,我们将设置 Mongoose,将其连接到我们的数据库,并为应用程序创建一个 Mongoose 模型。在下一章中,我们将通过编写 GraphQL API 来完成中间件的构建。

这个中间件是 Next.js 的一部分,因此我们将与应用程序容器一起工作。但由于 Docker 守护进程确保我们本地应用程序目录中的文件会即时在应用程序容器内的工作目录中可用,我们可以使用本地代码编辑器或 IDE 修改本地机器上的文件。无需连接到容器的 shell,更不需要与 docker compose 交互;你应该可以在 http://localhost:3000 上立即看到所有更改。

配置 Next.js 以使用绝对导入

在我们编写 Next.js 中的第一行代码之前,让我们对 Next.js 配置做一个小的调整。我们希望任何模块的导入路径都是 绝对的,也就是说,它们应该从应用程序的根文件夹开始,而不是从导入它们的文件的位置开始。在 清单 12-1 中的导入,来自我们在 第六章 创建的 pages/api/graphql.ts 文件,是相对导入的例子。

import {resolvers} from "../../graphql/resolvers";
import {typeDefs} from "../../graphql/schema"; 

清单 12-1:pages/api/graphql.ts 中的导入语句

你应该看到它们是从文件所在的位置开始的,然后向上移动两级到根文件夹,最后找到包含 resolversschema TypeScript 文件的 graphql 文件夹。

随着我们的应用程序变得越来越复杂,我们将拥有更多层次的嵌套,而且手动向上遍历目录直到根文件夹会变得越来越不方便。这就是为什么我们希望使用从根文件夹直接开始的绝对导入,如 清单 12-2 所示。

import {resolvers} from "graphql/resolvers";
import {typeDefs} from "graphql/schema"; 

清单 12-2:pages/api/graphql.ts 的绝对导入语句

请注意,我们不需要在导入文件之前先遍历到根目录。为此,请打开 tsconfig.json 文件,该文件由 create-next-app 在应用程序的代码根目录 code/foodfinder-application 下创建,并添加一行代码,将 baseUrl 设置为根文件夹 (清单 12-3)。

{
"compilerOptions": {
 **"baseUrl": ".",**
`--snip--`
}
} 

清单 12-3:使用绝对 URL

使用 docker compose restart foodfinder-application 在新命令行标签页中重启应用程序的容器和 Next.js 应用程序。

连接 Mongoose

现在是时候开始编写中间件了。我们将从将 Mongoose 添加到应用程序开始。连接到应用程序的容器终端:

$ **docker exec -it foodfinder-application npm install mongoose**

在这里,我们使用npm install mongoose来安装包。只要容器正在运行,我们就不需要立即重建前端镜像,因为我们已经将包直接安装到了运行中的容器中。

编写数据库连接

为了将 Next.js 应用程序连接到 MongoDB 实例,我们需要定义环境变量MONGO_URI并为其分配一个连接字符串,该字符串必须与后端暴露的端口和位置匹配。在应用程序的根目录中创建一个新的.env.local文件,位于tsconfig.json文件旁边,并向其中添加以下内容:

MONGO_URI=mongodb://backend:27017/foodfinder

现在我们可以将应用程序连接到 Docker 容器在 27017 端口上暴露的 MongoDB 实例。创建一个名为middleware的文件夹,放置在根文件夹code/foodfinder-application中。我们将在此文件夹中放置所有与中间件相关的 TypeScript 文件。创建一个新的文件,db-connect.ts,并将清单 12-4 中的代码粘贴到该文件中。

import mongoose, {ConnectOptions} from "mongoose";

const MONGO_URI = process.env.MONGO_URI || " ";

if (!MONGO_URI.length) {
    throw new Error(
        "Please define the MONGO_URI environment variable (.env.local)"
    );
}
let cached = global.mongoose;

if (!cached) {
    cached = global.mongoose = {conn: null, promise: null};
}

async function dbConnect(): Promise<any> {

    if (cached.conn) {
        return cached.conn;
    }

    if (!cached.promise) {

        const opts: ConnectOptions = {
            bufferCommands: false,
            maxIdleTimeMS: 10000,
            serverSelectionTimeoutMS: 10000,
            socketTimeoutMS: 20000,
        };

 cached.promise = mongoose
            .connect(MONGO_URI, opts)
            .then((mongoose) => mongoose)
            .catch((err) => {
                throw new Error(String(err));
            });
    }

    try {
        cached.conn = await cached.promise;
    } catch (err) {
        throw new Error(String(err));
    }

    return cached.conn;
}

export default dbConnect; 

清单 12-4:在 db-connect.ts 中将应用程序连接到数据库的 TypeScript 代码

我们导入了mongoose包和ConnectOptions类型,它们都是连接数据库所必需的。然后,我们从环境变量中加载连接字符串,并验证该字符串是否为空。

接下来,我们设置连接缓存。我们使用一个全局变量来保持连接,以便在热重载期间维持连接,并确保多次调用我们的dbConnect函数时始终返回相同的连接。否则,我们的应用程序可能会在每次热重载或每次函数调用时创建新的连接,这样会迅速占满内存。如果没有缓存的连接,我们会使用一个虚拟对象来初始化它。

我们创建了异步函数dbConnect,该函数实际打开并处理连接。由于数据库是远程的,并且不能立即访问,因此我们使用一个异步函数,并将其作为模块的默认函数进行导出。在函数体内,我们首先检查是否已经存在缓存的连接,并直接返回任何已存在的连接。否则,我们创建一个新的连接。因此,我们定义连接选项,然后创建一个新的连接;在这里,我们使用promise模式来提醒我们如何处理异步调用的两种可能方式。最后,我们await连接可用后,返回 Mongoose 实例。

要通过 Mongoose 打开一个缓存的 MongoDB 连接,我们现在可以从middleware/db-connect模块导入dbConnect函数,并等待 Mongoose 连接。

修复 TypeScript 警告

在你的 IDE 中,你应该会立刻看到 TSC 警告我们使用了global.mongoose。对信息的仔细查看,Element implicitly has an 'any' type because type 'typeof globalThis' has no index signature.ts (7017),告诉我们需要将mongoose属性添加到globalThis对象中。

正如我们在第三章中讨论的那样,我们使用custom.d.ts文件来定义自定义全局类型。创建一个新的文件custom.d.ts,并将其放在根目录下的middleware文件夹旁边。一旦将清单 12-5 中的代码粘贴到该文件中,全局命名空间应包含类型为mongoose的mongoose属性,并且 TSC 可以找到它。

import mongoose from "mongoose";

declare global {
    var mongoose: mongoose;
} 

清单 12-5:在 custom.d.ts 文件中定义自定义全局类型mongoose的代码

配置了自定义全局类型定义后,TSC 应该不再抱怨找不到global.mongoose的类型定义。我们可以继续创建适用于我们全栈应用的 Mongoose 模型。

Mongoose 模型

我们的应用程序包含一个数据库,其中包含表示位置数据的文档集合,正如您在 第十一章 的种子脚本中看到的那样。我们将为这个位置集合创建一个 Mongoose 模型。在 第七章 中,您已经学习过,创建模型时需要一个接口来为 TypeScript 类型化文档,一个用于描述文档的模式,一个类型定义,以及一组自定义类型来定义 Mongoose 模型。此外,我们还将创建一组自定义类型来执行应用程序中对位置模型的 CRUD 操作。

在 Next.js 根目录下,创建一个名为 mongoose 的文件夹,并在其中创建一个名为 locations 的子文件夹,放置在 middleware 文件夹旁边。mongoose 文件夹将包含所有与 Mongoose 相关的文件,而 locations 文件夹将包含与位置模型相关的所有文件。

创建模式

在 第七章 中,您已经学到模式描述了数据库文档的结构,并且在创建模式之前,您需要先创建 TypeScript 接口,这样您才能为模式和模型类型化。从技术上讲,在 Mongoose 版本 6.3.1 之后,我们不需要自己定义这个接口。相反,我们可以直接从模式中自动推断出接口类型。请在 mongoose/locations 文件夹中创建一个名为 schema.ts 的文件,并将 列表 12-6 中的代码粘贴到其中。

import {Schema, InferSchemaType} from "mongoose";

export const LocationSchema: Schema = new Schema<LocationType>({
    address: {
        type: "String",
        required: true,
    },
    street: {
        type: "String",
        required: true,
    },
    zipcode: {
        type: "String",
        required: true,
    },
    borough: {
        type: "String",
        required: true,
    },
    cuisine: {
        type: "String",
        required: true,
    },
    grade: {
        type: "String",
        required: true,
    },
    name: {
        type: "String",
        required: true,
    },
    on_wishlist: {
        type: ["String"],
        required: true,
    },
    location_id: {
        type: "String",
        required: true,
    },
});

export declare type LocationType = InferSchemaType<typeof LocationSchema>; 

列表 12-6:mongoose/locations/schema.ts 文件

我们导入了 Schema 构造函数和 InferSchemaType,这是用于推断模式类型的函数,它们都是 Mongoose 模块的一部分。然后我们定义并直接导出模式。该模式本身是直接明了的。位置集合中的文档包含一些自解释的属性,除了 on_wishlist 属性是一个字符串数组外,其他属性都是字符串类型。为了保持应用程序的简洁,我们将直接在位置文档中存储将某个位置添加到愿望清单中的用户 ID,而不是为每个用户的愿望清单创建新的 Mongoose 模型和 MongoDB 文档。这对于真实的应用程序来说不是一个很好的设计,但对我们的目的来说是足够的。最后,我们直接从模式中推断并导出 LocationType,而不是手动创建接口。

创建位置模型

在模式和所需接口完成后,接下来是创建模型。请在 mongoose/location 文件夹中创建一个名为 model.ts 的文件,并将 列表 12-7 中的代码粘贴到其中。

import mongoose, {model} from "mongoose";
import {LocationSchema, LocationType} from "mongoose/locations/schema";

export default mongoose.models.locations ||
    model<LocationType>("locations", LocationSchema); 

列表 12-7:mongoose/locations/model.ts 文件

在导入 Mongoose 包所需的依赖后,我们从之前创建的 schema.ts 文件中导入 LocationSchema 和 LocationType。然后,我们使用这些来创建并导出我们的 locations 模型,除非已经存在一个名为 locations 的模型被初始化并存在。如果是这种情况,我们将返回现有的模型。

到此为止,我们已经成功创建了 Mongoose 模型并将其连接到数据库。现在我们可以访问 MongoDB 实例,并通过 Mongoose 的 API 在 locations 集合中进行创建、读取、更新和删除文档操作。

为了测试一切是否正常工作,尝试创建一个临时的 REST API,初始化与数据库的连接,然后通过模型查询所有文档。你可以在应用程序的pages/api文件夹中创建这个新文件 test-middleware.ts,并将 Listing 12-8 中的代码粘贴进去。

import type {NextApiRequest, NextApiResponse} from "next";

import dbConnect from "middleware/db-connect";
import Locations from "mongoose/locations/model";

export default async function handler(
    req: NextApiRequest, res: NextApiResponse<any>
) {
    await dbConnect();
    const locations = await Locations.find({});
    res.status(200).json(locations);
} 

Listing 12-8: 测试数据库连接的临时 REST API

这个 API 导入了 Next.js 所需的依赖,dbConnect 函数,以及我们之前创建的 Locations 模型。在异步的 API 处理程序中,它调用 dbConnect 函数,并等待 Mongoose 连接到数据库。然后,它在 Locations 模型上调用 Mongoose 的 find API,传入一个空的过滤对象。一旦接收到位置数据,API 处理程序将其发送给客户端。

如果你打开 http://localhost:3000/api/test-middleware,你应该会看到一个包含所有可用位置的 JSON 对象,类似于图 12-1。

图 12-1:测试中间件的 API 返回一个包含数据库中所有位置的 JSON 对象。

你已经成功创建了 Mongoose 模型并执行了第一次数据库查询。

模型的服务

第六章讨论了我们通常如何将数据库的 CRUD 操作抽象为服务调用,以简化后续实现 GraphQL API 的过程。这正是我们接下来要做的,首先,让我们概述所需的功能。

我们需要一个公共服务来查询所有可用位置,以便它们可以显示在应用程序的概览页面中。为了显示位置的详细信息,我们需要另一个公共服务来查找特定位置。我们选择使用位置的 ID 作为服务的参数,然后通过 ID 查找位置。为了处理愿望清单功能,我们需要一个服务来更新用户的愿望清单,以及另一个可以用来判断给定位置是否当前在用户愿望清单中的服务;根据结果,我们将显示“添加到”或“从中移除”按钮。

为了设计查找并返回位置的服务调用,我们将为每个公共 API 创建一个公共函数,并创建一个统一的内部函数 findLocations,该函数调用 Mongoose 的 find 函数。公共 API 构造 Mongoose 用于筛选集合中文档的过滤器对象。换句话说,它创建了数据库查询。同时,它设置了我们将传递给 Mongoose API 的附加选项。这种设计应该能减少我们需要编写的代码量,并防止重复。

创建位置服务的自定义类型

你可能已经注意到,我们需要两个自定义类型作为统一的 findLocations 函数的参数。一个参数定义了与愿望清单相关的 find 操作的属性,另一个是位置的 ID。创建一个 custom.d.ts 文件,在 mongoose/location 文件夹中定义这些类型,如 Listing 12-9 所示。

export declare type FilterLocationType = {
    location_id: string | string[];
};

export declare type FilterWishlistType = {
    on_wishlist: {
        $in: string[];
    };
}; 

Listing 12-9: mongoose/locations/custom.d.ts 文件

我们定义并直接导出这两个自定义类型。FilterLocationType 是直接的,它定义了一个包含位置 ID 的对象,ID 可以是一个字符串或一个字符串数组。我们用它根据 ID 查找位置。第二个类型是 FilterWishlistType,我们将用它来查找所有包含用户 ID 在其 on_wishlist 属性中的位置。我们将 Mongoose 的 $in 操作符的值设置为字符串数组。

创建位置服务

现在我们已经为服务创建了自定义类型,可以开始实现它们。像往常一样,我们在 mongoose/location 文件夹中创建一个 services.ts 文件,并将 Listing 12-10 中的代码添加到其中。

import Locations from "mongoose/locations/model";
import {
    FilterWishlistType,
    FilterLocationType,
} from "mongoose/locations/custom";
import {LocationType} from "mongoose/locations/schema";
import {QueryOptions} from "mongoose";

async function findLocations(
    filter: FilterLocationType | FilterWishlistType | {}
): Promise<LocationType[] | []> {
    try {
    let result: Array<LocationType | undefined> = await Locations.find(
            filter
        );
        return result as LocationType[];
    } catch (err) {
        console.log(err);
    }
    return [];
}

export async function findAllLocations(): Promise<LocationType[] | []> {
    let filter = {};
    return await findLocations(filter);
}

export async function findLocationsById(
    location_ids: string[]
): Promise<LocationType[] | []> {
    let filter = {location_id: location_ids};
    return await findLocations(filter);
}

export async function onUserWishlist(
    user_id: string
): Promise<LocationType[] | []> {
    let filter: FilterWishlistType = {
        on_wishlist: {
            $in: [user_id],
        },
    };
    return await findLocations(filter);
}

export async function updateWishlist(
    location_id: string,
    user_id: string,
    action: string
) : Promise<LocationType | null | {}>
 {
    let filter = {location_id: location_id};
    let options: QueryOptions = {upsert: true, returnDocument: "after"};
    let update = {};

    switch (action) {
        case "add":
            update = {$push: {on_wishlist: user_id}};
            break;
        case "remove":
            update = {$pull: {on_wishlist: user_id}};
            break;
    }

    try {
        let result: LocationType | null = await Locations.findOneAndUpdate(
            filter,
            update,
            options
        );
        return result;
    } catch (err) {
        console.log(err);
    }
    return {};
} 

Listing 12-10: mongoose/locations/services.ts 文件

在导入依赖项后,我们创建了一个实际调用 Mongoose 的 find API 的函数,并等待来自数据库的数据。这个函数将查询数据库中所有使用 find 的公共服务,因此它是所有服务的基础。它的一个参数 filter 对象,可以传递给模型的 find 函数,以检索与过滤器匹配的文档。该过滤器可以是一个空对象,返回所有位置,或者是我们的自定义类型之一,FilterLocationTypeFilterWishlistType。一旦我们从数据库中获得数据,就将其转换为 LocationType,然后返回。如果出现错误,我们会记录错误,并返回一个空数组,以匹配已定义的返回类型:要么是 LocationTypes 数组,要么是空数组。

以下三个功能是公共服务,它们将为其他 TypeScript 模块和用户界面提供数据库访问。这些功能都遵循相同的结构。首先,在 findLocationsById 函数中,我们将 filter 对象设置为特定的参数。然后我们使用此特定服务的 filter 对象调用 findLocations 函数。因为每个服务都调用相同的函数,所以各个服务也有相同的返回签名,并且每个服务返回的是一个位置数组或一个空数组。第一个使用的是一个空对象。因此,它不会过滤任何内容,而是返回集合中的所有文档。函数 findLocationsById 使用 FilterLocationType 并返回与给定位置 ID 匹配的文档。

下一个函数onUserWishlist使用了一个稍微复杂的filter对象。它的类型是FilterWishlistType,我们将其传递给findLocations函数,以获取所有其on_wishlist数组中包含给定用户 ID 的位置。注意,我们在声明时明确指定了filter对象的类型。这与第三章中的建议有所不同,但我们这么做是为了确保 TSC 验证对象的属性,因为在这种情况下它无法从使用方式推断出类型。

最后,我们实现了updateWishlist函数。它与之前的函数稍有不同,但整体结构应该很熟悉。我们再次从第一个参数构建filter对象,使用第二个参数——用户 ID——来更新on_wishlist数组。不过,与之前的函数不同,我们使用另一个参数来指定是否要将用户 ID添加到数组中或移除。在这里使用switch/case语句是一种便捷的方式,能减少暴露的服务数量。根据操作参数,我们用\(push操作符填充更新对象,该操作符将用户 ID 添加到on_wishlist数组中,或者用\)pull操作符将用户 ID 移除。我们将该对象传递给 Mongoose 的findOneAndUpdate API,查找第一个匹配筛选条件的文档,直接更新记录,然后返回更新后的文档或空对象。

测试服务

让我们使用临时的 REST API 来评估这些服务。打开我们之前创建的test-middleware.ts文件,并用列表 12-11 中的代码更新它。

import type {NextApiRequest, NextApiResponse} from "next";
import dbConnect from "middleware/db-connect";

import {findAllLocations} from "mongoose/locations/services";

export default async function handler(
    req: NextApiRequest,
    res: NextApiResponse<any>
) {
    await dbConnect();
    const locations = await findAllLocations();
    res.status(200).json(locations);
} 

列表 12-11:使用服务的 pages/api/test-middleware.ts 文件

我们没有直接导入模型并使用 Mongoose 的 find 方法,而是导入了位置服务,并通过 findAllLocations 服务查询所有位置。如果你在浏览器中打开 http://localhost:3000/api/test-middleware 的 API,你应该再次看到一个包含所有可用位置的 JSON 对象。

总结

我们成功创建了中间件的第一部分。通过本章的代码,我们可以使用 Mongoose 模型在 MongoDB 集合中创建、读取、更新和删除文档。为了执行这些操作,我们设置了将与即将到来的 GraphQL API 连接的服务。在下一章中,我们将删除临时的测试 API 中间件,并用一个正式的 GraphQL API 替换它。

第十三章:13 构建 GraphQL API

在本章中,你将通过定义其模式以及每个查询和突变的解析器,为中间件添加一个 GraphQL API。这些解析器将补充在 第十二章 中创建的 Mongoose 服务。查询将是公开的;然而,我们将通过添加授权层(通过 OAuth)将突变暴露为受保护的 API。

与 第六章 中的 GraphQL API 不同,我们将遵循模块化模式来实现这些模式和解析器。我们将不再将所有内容写在一个大文件中,而是将各个元素分拆到不同的文件中。就像在现代 JavaScript 中使用模块一样,这种方法的好处是将代码拆解为更小的逻辑单元,每个单元都有明确的焦点。这些单元提高了代码的可读性和可维护性。

设置

我们将使用 Apollo 服务器创建 API 的单一入口点 /api/graphql,并通过 @as-integrations/next 包将其集成到 Next.js 中。首先,从 npm 注册表安装 GraphQL 设置所需的包:

$ **docker exec -it foodfinder-application npm install @apollo/server graphql graphql-tag**
**@as-integrations/next \** 

安装完成后,在应用程序根目录下创建 graphql/locations 文件夹,紧挨着 middleware 文件夹。

模式

编写模式的第一步是定义查询和突变的 typedef,以及我们为模式使用的任何自定义类型。为此,我们将在 graphql/locations 文件夹中将模式拆分为三个文件:custom.gql.tsqueries.gql.tsmutations.gql.ts。然后,我们将使用普通的模板字面量将它们合并到最终的模式定义中。

自定义类型和指令

将 清单 13-1 中的代码添加到 custom.gql.ts 文件,以定义 GraphQL 查询的模式。

export default `
    directive @cacheControl(maxAge: Int) on FIELD_DEFINITION | OBJECT
    type Location @cacheControl(maxAge: 86400) {
        address: String
        street: String
        zipcode: String
        borough: String
        cuisine: String
        grade: String
        name: String
        on_wishlist: [String] @cacheControl(maxAge: 60)
        location_id: String
    }
`; 

清单 13-1:graphql/locations/custom.gql.ts 文件

GraphQL API 将从 Mongoose 模式返回位置对象。因此,我们必须定义一个自定义类型来表示这些位置对象。创建一个自定义的 Location 类型。为了指示服务器缓存检索到的值,为整个自定义类型设置 @cacheControl 指令,并为 on_wishlist 属性设置一个更短的缓存指令,因为我们预期该属性会频繁变化。

查询模式

现在将 清单 13-2 中的代码添加到 queries.gql.ts 文件,以定义查询的模式。

export default `
    allLocations: [Location]!
    locationsById(location_ids: [String]!): [Location]!
    onUserWishlist(user_id: String!): [Location]!
`; 

清单 13-2:graphql/locations/queries.gql.ts 文件

我们定义了一个包含三个 GraphQL 查询的模板字面量,所有查询都是我们在第十二章中为 Mongoose 位置模型实现的服务的入口点。这些查询的名称和参数与服务中的类似,并且查询遵循你在第六章中学到的 GraphQL 语法。

变更操作架构

要定义变更操作架构,请将列表 13-3 中的代码粘贴到mutations.gql.ts文件中。

export default `
    addWishlist(location_id: String!, user_id: String!): Location!
    removeWishlist(location_id: String!, user_id: String!): Location!
`; 

列表 13-3:graphql/locations/mutations.gql.ts 文件

我们使用 GraphQL 语法创建了两个变更操作作为模板字面量:一个用于将项目添加到用户的愿望清单中,另一个用于将其移除。两者都会使用我们在位置服务中实现的updateWishlist函数,因此它们需要location_id和user_id作为参数。

将类型定义合并到最终的架构中

我们已将位置架构拆分为两个文件,一个用于查询,一个用于变更操作,并将它们的自定义类型放置在第三个文件中;然而,为了启动 Apollo 服务器,我们需要一个统一的架构。幸运的是,类型定义不过是模板字面量而已,如果我们使用模板字面量占位符,解析器就能将其插入成一个完整的字符串。为此,创建一个新文件schema.ts,放入graphql文件夹,并添加列表 13-4 中的代码。

import gql from "graphql-tag";

import locationTypeDefsCustom from "graphql/locations/custom.gql";
import locationTypeDefsQueries from "graphql/locations/queries.gql";
import locationTypeDefsMutations from "graphql/locations/mutations.gql";

export const typeDefs = gql`

    ${locationTypeDefsCustom}

    type Query {
      ${locationTypeDefsQueries}
    }

    type Mutation {
        ${locationTypeDefsMutations}
    }

`; 

列表 13-4:graphql/schema.ts 文件

我们从graphql-tag包中导入gql标签。虽然在使用 Apollo 服务器时这一步是可选的,但我们仍然在标记模板前保留gql标签,以确保与所有其他 GraphQL 实现的兼容性。这也能在 IDE 中产生正确的语法高亮,IDE 会静态分析类型定义作为 GraphQL 标签。

接下来,我们导入将用于实现统一架构的依赖项和模式片段。最后,我们使用gql函数创建一个标记模板字面量,使用模板字面量占位符将模式片段合并到架构骨架中。我们添加自定义的Location类型,然后将查询的类型定义合并到Query对象中,将变更操作合并到Mutations对象中,并将架构const作为类型定义导出。

GraphQL 解析器

现在我们有了架构,我们将转向解析器。我们将采用类似的开发模式,分别编写查询和变更操作文件,然后将它们合并到 Apollo 服务器所需的单个文件中。首先,在 graphql/locations 文件夹中创建 queries.tsmutations.ts 文件,然后将列表 13-5 中的代码添加到 queries.ts

import {
    findAllLocations,
    findLocationsById,
    onUserWishlist,
} from "mongoose/locations/services";

export const locationQueries = {
    allLocations: async (_: any) => {
        return await findAllLocations();
    },
    locationsById: async (_: any, param: {location_ids: string[]}) => {
        return await findLocationsById(param.location_ids);
    },
    onUserWishlist: async (_: any, param: {user_id: string}) => {
        return await onUserWishlist(param.user_id);
    },
}; 

列表 13-5:graphql/locations/queries.ts 文件

我们从 Mongoose 文件夹导入服务,然后创建并导出位置查询对象。每个查询的结构遵循在第六章中讨论的结构。我们为每个服务创建一个查询,并且它们的参数与服务中的参数相匹配。

对于变更操作,将列表 13-6 中的代码添加到 mutations.ts 文件中。

import {updateWishlist} from "mongoose/locations/services";

interface UpdateWishlistInterface {
    user_id: string;
    location_id: string;
}

export const locationMutations = {
    removeWishlist: async (
        _: any,
        param: UpdateWishlistInterface,
        context: {}
    ) => {
        return await updateWishlist(param.location_id, param.user_id,
            "remove"
        );
    },
    addWishlist: async (_: any, param: UpdateWishlistInterface, context: {}) => {
        return await updateWishlist(param.location_id, param.user_id, "add");
    },
}; 

列表 13-6:graphql/locations/mutations.ts 文件

在这里,我们只从服务中导入 updateWishlist 函数。这是因为我们将其定义为更新文档的单一入口点,并且我们选择使用第三个参数,其值为 addremove,来区分变更操作应该执行的两个动作。我们还创建了 UpdateWishlistInterface,但我们并未导出它。相反,我们将在此文件中使用它,以避免在为函数的 param 参数定义接口时重复代码。

作为变更操作,我们在 locationMutations 对象中创建了两个函数,一个用于从用户的愿望清单中添加项目,另一个用于移除它。两个函数都使用 updateWishlist 服务,并提供对应用户操作的 value 参数。这两个变更操作,removeWishlistaddWishlist,还接受一个名为 context 的第三个对象。目前,它是一个空对象,但在第十五章中,我们将用验证执行操作的用户身份所需的会话信息替换它。

创建最终的解析器文件 resolvers.ts,并将列表 13-7 中的代码添加到该文件中。此代码将合并变更和查询定义。

import {locationQueries} from "graphql/locations/queries";
import {locationMutations} from "graphql/locations/mutations";

export const resolvers = {
    Query: {
...locationQueries,
    },
    Mutation: {
...locationMutations,
    },
}; 

列表 13-7:graphql/resolvers.ts 文件

除了模式外,我们还必须将一个包含所有解析器的对象传递给 Apollo 服务器,正如我们在第六章中讨论的那样。为了做到这一点,我们必须导入查询和变更。然后,我们使用扩展运算符将导入的对象合并到resolvers对象中,并导出它。现在,模式和resolvers对象都可以使用,我们可以创建 API 端点并实例化 Apollo 服务器。

将 API 端点添加到 Next.js

在我们讨论 REST 和 GraphQL API 之间的差异时,我们指出,与每个 REST API 都有自己独立的端点不同,GraphQL 只提供一个端点,通常暴露为/graphql。为了创建这个端点,我们将使用 Apollo 服务器的 Next.js 集成,就像我们在第六章中做的那样。

pages/api文件夹中创建graphql.ts文件,并复制清单 13-8 中的代码,该代码定义了 API 处理程序及其唯一的入口点。

import {ApolloServer, BaseContext} from "@apollo/server";
import {startServerAndCreateNextHandler} from "@as-integrations/next";

import {resolvers} from "graphql/resolvers";
import {typeDefs} from "graphql/schema";
import dbConnect from "middleware/db-connect";

import {NextApiHandler, NextApiRequest, NextApiResponse} from "next";

❶ const server = new ApolloServer<BaseContext>({
    resolvers,
    typeDefs,
});

❷ const handler = startServerAndCreateNextHandler(server, {
    context: async () => {
        const token = {};
        return {token};
    },
});

❸ const allowCors =
    (fn: NextApiHandler) =>
    async (req: NextApiRequest, res: NextApiResponse) => {
        res.setHeader("Allow", "POST");
        res.setHeader("Access-Control-Allow-Origin", "*");
        res.setHeader("Access-Control-Allow-Methods", "POST");
        res.setHeader("Access-Control-Allow-Headers", "*");
        res.setHeader("Access-Control-Allow-Credentials", "true");

        if (req.method === "OPTIONS") {
            res.status(200).end();
        }
        return await fn(req, res);
    };

❹ const connectDB =
    (fn: NextApiHandler) =>
    async (req: NextApiRequest, res: NextApiResponse) => {
        await dbConnect();
        return await fn(req, res);
    };

export default connectDB(allowCors(handler)); 

清单 13-8:pages/api/graphql.ts 文件

我们导入了创建 API 处理程序所需的所有元素:Apollo 服务器、Apollo-Next.js 集成的辅助工具、我们的解析器、GraphQL 模式文件、用于连接数据库的函数以及 Next.js 的 API 辅助工具。

我们使用解析器和模式❶创建一个新的 Apollo 服务器。然后,我们使用 Next.js 集成辅助工具❷来启动 Apollo 服务器并返回一个 Next.js 处理程序。集成辅助工具使用无服务器 Apollo 设置,顺利地与 Next.js 自定义服务器集成,而不是创建它自己的服务器。此外,我们将带有空tokencontext传递给处理程序。这就是我们如何访问在 OAuth 流程中收到的 JWT,并稍后将其传递给解析器的方法。

接下来,我们创建在第六章中讨论的包装函数,以添加 CORS 头❸并确保在每个 API 调用中都有数据库连接❹。我们可以安全地这样做,因为我们已经以返回现有缓存连接的方式设置了数据库连接。最后,我们导出返回的异步包装处理程序。

访问 Apollo 沙箱,地址是http:/localhost:3000/api/graphql,并运行一些查询以测试 GraphQL API,然后再进入下一章。如果你看到的是天气查询和变更而不是 Food Finder 的查询,请清除浏览器缓存并进行强制刷新。

摘要

我们已成功将 GraphQL API 添加到中间件中。通过本章中的代码,我们现在可以使用 Apollo 沙盒来读取和更新数据库中的值。我们还为认证准备了 Apollo 处理程序,并为其提供了一个空的令牌。现在,我们准备使用在 第十五章 中从 OAuth 流程中获得的 JWT 令牌来保护 API 的变更操作。在我们添加认证之前,让我们先构建前端。

第十四章:14 构建前端

在本章中,您将使用 React 组件和 Next.js 页面构建前端,这些内容将在第四章和第五章中讨论。到最后,您将拥有一个初始版本的应用程序,可以在其中添加 OAuth 认证。

用户界面概览

我们的应用程序将由三个 Next.js 页面组成。启动页面将显示从数据库中获取的地点列表。列表中的每个项目将链接到各自的地点详情页面,其 URL 将通过地点的 ID 构建,如:/location/:location_id。第三个页面是用户的愿望清单页面。它与启动页面相似,并遵循与地点详情页面相同的动态 URL 模式,只不过它提供的是用户的 ID,而不是地点的 ID。该页面仅显示已经添加到愿望清单中的地点。

我们还必须考虑每个页面使用哪种渲染策略。由于启动页面的内容永远不会改变,我们将使用静态站点生成(SSG)在构建时渲染 HTML。由于详情页面和愿望清单页面将根据用户的操作发生变化,我们将使用静态站点渲染(SSR)在每次请求时重新生成它们。

最后,所有三个页面应具有包含徽标和指向启动页面链接的头部。当我们在下一章添加 OAuth 数据时,我们将在头部显示用户的姓名、指向用户愿望清单的链接以及登录/注销按钮。

为了实现这一点,我们需要创建以下 React 组件:

  • 位置列表组件,它将使用位置列表项组件来渲染启动页面上的位置列表。稍后,我们将使用这些相同的组件来实现用户愿望清单页面上的位置列表。

  • 整体布局组件、头部组件和徽标组件,它们定义了每个页面的全局布局。

  • 身份验证元素组件,允许用户在头部登录或注销。

  • 一个我们将用于不同任务的通用按钮组件。

让我们从启动页面所需的组件开始。

启动页面

我们将从构建用户界面中最小的部分开始,然后使用这些部分构建更复杂的组件和页面。在启动页面上,我们需要布局组件、位置列表组件和位置列表项组件,位置列表项是最小的构建块,因此我们将从这里开始。

在应用程序的根目录中创建components文件夹,放在middleware文件夹旁边。这是我们将所有 React 组件放在各自文件夹中的地方。

列表项

位置列表项组件表示位置列表中的单个项。创建 locations-list-item 文件夹,并根据我们在 第五章 中讨论的模式添加两个文件,index.tsxindex.module.css。然后,将 Listing 14-1 中的代码添加到 index.module.css 文件中。我们将使用此 CSS 样式来设计该组件。

.root {
    background-color: #fff;
    border-radius: 5px;
    color: #1d1f21;
    cursor: pointer;
    list-style: none;
    margin: 0.5rem 0;
    padding: 0.5rem;
    transition: background-color 0.25s ease-in, color 0.25s ease-in;
    will-change: background-color, color;
}

.root:hover {
    background-color: rgba(0, 118, 255, 0.9);
    color: #fff;
}

.root h2 {
    margin: 0;
    padding: 0;
}

.root small {
    font-weight: 300;
    padding: 0 1rem;
} 

Listing 14-1: The components/locations-list-item/index.module.css 文件

CSS 模块使用白色背景上的深色文字。此外,它添加了一个简单的悬停效果,使得当用户将鼠标悬停时,背景变为蓝色,字体颜色变为白色。我们去除了列表标记并相应地设置了边距和内边距。

现在将 Listing 14-2 中的代码添加到 index.tsx 文件中。

import Link from "next/link";
import styles from "./index.module.css";
import {LocationType} from "mongoose/locations/schema";

interface PropsInterface {
    location: LocationType;
}

const LocationsListItem = (props: PropsInterface): JSX.Element => {
    const location = props.location;
    return (
        <>
            {location && (
                <li className={styles.root}>
                    <Link href={`/location/${location.location_id}`}>
                        <h2>
                            {location.name}
                            <small className={styles.details}>
                                {location.cuisine} in {location.borough}
                            </small>
                        </h2>
                    </Link>
                </li>
            )}
        </>
    );
};

export default LocationsListItem; 

Listing 14-2: The components/locations-list-item/index.tsx 文件

你应该对这个文件的结构已经非常熟悉,来自 第五章。首先,我们导入 next/link 组件,它是我们创建指向详细页面链接所必需的,接着导入我们刚刚添加的样式,以及来自 Mongoose 模式的 LocationType。

然后,我们定义了 PropsInterface,这是一个私有接口,用于组件的属性对象。组件具有常规的 props 参数,其结构定义了 PropsInterface,并返回一个 JSX 元素。这些 props 包含 location 属性中的数据,我们通过其 location 属性将其传递给组件。最后,我们定义了 LocationsListItem 组件,并将其存储在一个常量中,最后将其导出到文件的末尾。

在组件本身,我们有一个包含 Next.js Link 元素的列表项,该元素链接到位置的详细页面。这些链接使用动态 URL 模式,其中包含相应位置的 ID,因此我们创建链接目标以匹配 /location/:location_id。此外,我们将位置的 name、cuisine 和 borough 值渲染到组件中。请记住,直到我们为路由 /location/:location_id 创建页面之前,点击这些链接将会导致 404 错误页面。

位置列表

使用列表项组件,我们将构建地点列表。这个组件会循环遍历地点数组,并在启动页面和愿望清单页面上显示这些地点。创建 components/locations-list 文件夹,并将文件 index.tsxindex.module.css 添加进去。将 列表 14-3 中的代码复制到 index.module.css 文件。

.root {
    margin: 0;
    padding: 0;
} 

列表 14-3:components/locations-list/index.module.css 文件

地点列表组件的样式很简单;我们从组件的根元素中移除边距和内边距。我们在 列表 14-4 中创建了该组件,你应当将其复制到 index.tsx 文件中。

import LocationsListItem from "components/locations-list-item";
import styles from "./index.module.css";
import {LocationType} from "mongoose/locations/schema";

interface PropsInterface {
    locations: LocationType[];
}

const LocationsList = (props: PropsInterface): JSX.Element => {
    return (
        <ul className={styles.root}>
            {props.locations.map((location) => {
                return (
                    <LocationsListItem
                        location={location}
                        key={location.location_id}
                    />
                );
            })}
        </ul>
    );
};

export default LocationsList; 

列表 14-4:components/locations-list/index.tsx 文件

我们导入刚刚实现的 LocationsListItem,以及模块的样式和 Mongoose 模式中的 LocationType。然后,我们定义组件的 PropsInterface 来描述组件的 props 对象。在 LocationsList 组件中,我们使用数组的 map 函数遍历 location 对象,为每个数组项渲染一个 LocationsListItem 组件,并使用 location 属性将地点详情传递给组件。React 要求在循环中渲染的每一项都具有唯一的 ID。我们使用地点 ID 来实现这个目的。

我们现在可以创建启动页面,并将所有可用的地点传递给该组件。稍后,我们将使用相同的组件来显示用户愿望清单页面上的地点。

页面

此时,我们已经具备了启动页面所需的组件,这个页面是一个基本的 Next.js 页面。将该页面的全局样式保存在 styles/globals.css 文件中,代码保存在 pages/index.tsx 文件中。列表 14-5 包含了样式。删除 styles 目录中的其他文件,这些是我们应用程序不需要的默认样式。

html,
body {
    font-family: -apple-system, Segoe UI, Roboto, sans-serif;
    margin: 0;
    padding: 0;
}

* {
    box-sizing: border-box;
}

h1 {
    font-size: 3rem;
}

a {
    color: inherit;
    text-decoration: none;
} 

列表 14-5:styles/globals.css 文件

我们设置了一些全局样式,例如默认字体,并将盒子模型更改为所有元素更直观的 border-box。通过使用 border-box 代替 content-box,元素会采用我们用 width 属性指定的宽度。否则,width 属性只会定义内容的宽度,我们还需要加上边框和内边距,才能计算元素在页面上的实际尺寸。我们将字体设置为每个操作系统的默认值,以确保可读性。

现在,用列表 14-6 中的代码替换现有的 pages/index.tsx 文件内容。

import Head from "next/head";
import type {GetStaticProps, InferGetStaticPropsType, NextPage} from "next";

import LocationsList from "components/locations-list";
import dbConnect from "middleware/db-connect";
import {findAllLocations} from "mongoose/locations/services";
import {LocationType} from "mongoose/locations/schema";

❶ const Home: NextPage = (
    props: InferGetStaticPropsType<typeof getStaticProps>
) => {

  ❷ const locations: LocationType[] = JSON.parse(props.data?.locations);
    let title = `The Food Finder - Home`;

    return (
        <div>
            <Head>
                <title>{title}</title>
                <meta name="description" content="The Food Finder - Home" />
            </Head>

            <h1>Welcome to the Food Finder!</h1>
            <LocationsList locations={locations} />
        </div>
    );
};

❸ export const getStaticProps: GetStaticProps = async () => {
    let locations: LocationType[] | [];
    try {
        await dbConnect();
      ❹ locations = await findAllLocations();
    } catch (err: any) {
        return {notFound: true};
    }
  ❺ return {
        props: {
            data: {locations: JSON.stringify(locations)},
        },
    };
};

export default Home; 

列表 14-6:pages/index.tsx 文件

我们实现了 Next.js 页面,结构类似于第五章中讨论的内容。首先,我们导入所有依赖项;然后,我们创建 NextPage,并将其存储在常量中,最终在文件末尾导出 ❶。

Next.js 页面中的 props 对象,即页面属性,包含我们从 getStaticProps 函数 ❺ 返回的数据,详见第五章。在这个异步函数中,我们连接到数据库 ❸。一旦连接准备就绪,我们调用服务方法来获取所有位置 ❹,然后将它们作为 JSON 字符串传递给 NextPage,并作为 props 对象中的 data.locations 属性。Next.js 在构建时调用 getStaticProps 函数,并只生成一次该页面的 HTML。我们可以使用这种渲染方式,因为可用位置的列表是静态的,永远不会改变。

然后我们从页面属性 ❷ 中获取位置数据,解析 JSON 字符串为数组,并将页面标题存储在一个变量中。我们显式地输入 locations 常量,因为 TSC 无法轻易推断其类型。接着我们构建 JSX。在第一步中,我们使用 next/head 组件设置页面特定的元数据。然后,我们调用之前实现的 LocationList 组件,并将 locations 数组作为 locations 属性传入。通过这样做,LocationList 组件将所有位置呈现为概览列表。

一旦你保存文件,你应该能够在 Docker 命令行中看到 Next.js 正在重新编译应用程序。打开浏览器中的网页应用程序 http://localhost:3000,你会看到类似于 图 14-1 的位置列表。

图 14-1:显示所有可用位置的开始页面

接下来我们将开始为前端进行样式设计,并添加一些基础的全局组件,比如带有 Food Finder 标志的应用程序头部。

全局布局组件

现在是时候创建三个全局组件了。这些组件包括整体布局组件,我们将用它来格式化开始页和愿望清单页的内容,一个 粘性 头部(它始终可见,“粘”在浏览器的顶部边缘),以及放在头部的 Food Finder 标志。我们将从最小的单元开始,然后使用这些单元作为整体组件的构建模块。

标志

最小的组件,标志,只不过是一个 next/image 组件,外面包裹着一个 next/link 元素;当用户点击标志图像时,他们将被重定向到开始页面。添加一个 header 文件夹到 components 文件夹中,然后在 header 文件夹里添加一个 logo 文件夹,并在其中创建两个文件,index.tsxindex.module.css,将 清单 14-7 中的代码粘贴到这两个文件中。

.root {
    display: inline-block;
    height: 35px;
    position: relative;
    width: 119px;
}

@media (min-width: 600px) {
    .root {
        height: 50px;
        width: 169px;
    }
} 

清单 14-7:components/header/logo/index.module.css 文件

这些基本样式用于组件的 根 元素,设置图像的尺寸。我们使用 移动优先设计模式,首先定义用于小屏幕的样式,然后通过标准的 CSS 媒体查询,修改大于 600px 屏幕的样式。我们将在更大的屏幕上使用更大的图像。

现在,让我们创建 logo 组件。在 Next.js public 文件夹中创建一个 assets 子文件夹,并将从 assets.zip 中提取的 logo.svg 文件放入其中。然后将 列表 14-8 中的代码添加到 logo 的 index.tsx 文件中。

import Image from "next/image";
import Link from "next/link";
import logo from "/public/assets/logo.svg";
import styles from "./index.module.css";

const Logo = (): JSX.Element => {
    return (
        <Link href="/" passHref className={styles.root}>
            <Image
                src={logo}
                alt="Logo: Food Finder"
                sizes="100vw"
                fill
                priority
            />
        </Link>
    );
};

export default Logo; 

列表 14-8:components/header/logo/index.tsx 文件

和往常一样,我们导入依赖项,然后创建一个导出的常量,包含 JSX 代码。我们没有通过属性或子元素传递任何数据给它;因此,我们不需要在此处定义组件的 props 对象。

我们在 next/image 中使用了一个基本的 next/link 元素来链接回起始页面,并将 next/image 的属性设置为填充在 CSS 文件中定义的可用空间。

头部

头部组件将包裹我们刚刚创建的 logo 组件。在 header 文件夹中创建 index.tsx 文件和 index.module.css 文件,然后将 列表 14-9 中的代码添加到 CSS 文件中。

.root {
    background: white;
    border-bottom: 1px solid #eaeaea;
    padding: 1rem 0;
    position: sticky;
    top: 0;
    width: 100%;
    z-index: 1;
} 

列表 14-9:components/header/index.module.css 文件

我们使用 CSS 定义 position: sticky 和 top: 0 将头部固定在浏览器的上边缘。现在,即使用户向下滚动页面,头部也会自动停留在那里;页面的内容应该在头部下方滚动,因为我们设置了头部的 z-index,将头部置于其他元素前面。你可以把 z-index 想象成决定一个元素所在楼层的方式。

列表 14-10 显示了头部组件的代码。将它复制到组件的 index.tsx 文件中。

import styles from "./index.module.css";
import Logo from "components/header/logo";

const Header = (): JSX.Element => {
    return (
        <header className={styles.root}>
            <div className="layout-grid">
                <Logo />
            </div>
        </header>
    );
};

export default Header; 

列表 14-10:components/header/index.tsx 文件

我们定义了一个基本组件来显示 logo。然后,我们将导入的 Logo 组件包裹在一个具有全局 layout-grid 类的元素中,接下来我们将在下一节定义这个类。

布局

目前,我们有一个 Next.js 页面(起始页面)和一个头部组件。将头部添加到页面的最简单方法是将其导入到 Next.js 页面并直接放入 JSX 中。然而,我们还将向应用程序中添加两个页面,愿望清单页面和位置详情页面,所以我们希望避免将头部导入三次。

为了简化整个应用程序的设计,Next.js 提供了 layout 的概念,实际上这只是另一个组件,我们可以用它将头部组件添加为页面内容的兄弟元素。让我们创建一个新的布局组件。首先,为了创建该组件的 CSS 文件,将 layout.css 添加到 styles 文件夹,并将 Listing 14-11 中的代码粘贴到其中。

.layout-grid {
    align-items: center;
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    margin: 0 auto;
    max-width: 800px;
    padding: 0 1rem;
    width: 100%;
}

@media (min-width: 600px) {
    .layout-grid {
        flex-direction: row;
        padding: 0 2rem;
    }
} 

Listing 14-11: The styles/layout.css file

我们再次使用移动优先模式来定义一个基本的网格包装器,设置内容区域的全局内边距和最大宽度。我们将包装器的左右 margin 设置为 auto,这样可以使容器居中,因为边距会占用固定宽度的包装器和窗口边缘之间的所有可用空间。

我们使用 flexbox 设置包装器的直接子元素的排列方向为 column,使它们一个接一个地垂直排列。由于 logo 和所有接下来的头部元素都是带有 layout-grid 类的元素的直接子元素,因此它们会受到 flexbox 布局的影响。相反,位置项不是直接的兄弟元素,因此它们在切换屏幕尺寸时不会改变方向。

然后,我们使用媒体查询来调整宽度大于 600px 的屏幕的样式。在这里,我们增加了内边距,并改变了直接子元素的布局顺序。我们不再使用 column,而是将其设置为 row,这样就能立即将元素并排显示。

由于这是一个全局样式文件,而不是 CSS 模块,Next.js 不会自动作用域类名。因此,我们为类名前添加了 layout- 前缀,并且在使用这些样式之前不会将它们导入到组件中。

现在在 components 文件夹内创建一个 layout 文件夹,并添加 index.tsx 文件,将 Listing 14-12 中的组件代码粘贴到其中。

import Header from "components/header";

interface PropsInterface {
    children: React.ReactNode;
}

const Layout = (props: PropsInterface): JSX.Element => {
    return (
        <>
            <Header />
            <main className="layout-grid">
                {props.children}
            </main>
        </>
    );
};
export default Layout; 

Listing 14-12: The components/layout/index.tsx file

在布局组件中,我们定义了一个私有接口和一个具有常规结构的组件。在组件内部,我们添加了 Header 和使用全局布局样式的 main 元素,它作为包装器,包含了我们将在 _app.tsx 文件中传递给该组件的 children 元素。

打开 _app.tsx 文件并按 Listing 14-13 中所示进行修改。

import "../styles/globals.css";
**import "../styles/layout.css";**
import type {AppProps} from "next/app";
**import Layout from "components/layout";**

export default function App({Component, pageProps}: AppProps) {
    return (
 **<Layout>**
            <Component {...pageProps} />
 **<****/Layout>**
    );
} 

Listing 14-13: The pages/_app.tsx file

首先,我们添加 layout.css 作为全局样式。至于布局,我们只有一个 layout 组件,将用于所有页面,并在此处导入。然后,我们将应用程序的页面包裹在布局中,并将当前页面传递给组件的 children 属性。

现在,所有我们的 Next.js 页面都将遵循相同的结构:它们将在 Header 组件旁边,包含页面内容的 main 元素。遵循这种模式的一个好处是,组件的状态将在页面变化和 React 组件重新渲染之间保持不变。

一旦 Next.js 重新编译应用程序,请尝试在浏览器中重新加载应用程序 http://localhost:3000。它应该看起来像 图 14-2。

图 14-2: 带有标题和布局组件的起始页面

现在你应该能看到标题,而新的布局组件使内容居中。

位置详情页面

我们的应用程序现在有一个带有标题的起始页面,并列出所有可用的位置。列表项链接到其特定位置的详情页,因为我们在它们中添加了 next/link 组件,但这些页面还不存在。如果点击其中一个链接,你将遇到 404 错误。为了显示位置详情页面,我们首先需要实现列出特定位置详情的组件,然后创建一个新的 Next.js 页面。

组件

让我们从详情组件开始。在 components 目录中创建 location-details 文件夹,并向其中添加 index.module.cssindex.tsx 文件。然后将 列表 14-14 中的代码添加到 CSS 模块中。

.root {
    margin: 0 0 2rem 0;
    padding: 0;
}
.root li {
    list-style: none;
    margin: 0 0 0.5rem 0;
} 

列表 14-14: components/locations-details/index.module.css 文件

该组件的样式比较基础。我们去掉了默认的边距和填充以及列表样式,然后在每个列表项和根元素的末尾添加了自定义边距。

要实现位置详情组件,将 列表 14-15 中的代码添加到 components/locations-details 文件夹中的 index.tsx 文件。

import {LocationType} from "mongoose/locations/schema";
import styles from "./index.module.css";

interface PropsInterface {
    location: LocationType;
}

const LocationDetail = (props: PropsInterface): JSX.Element => {
    let location = props.location;
    return (
        <div>
            {location && (
                <ul className={styles.root}>
                    <li>
                        <b>Address: </b>
                        {location.address}
                    </li>
                    <li>
                        <b>Zipcode: </b>
                        {location.zipcode}
                    </li>
                    <li>
                        <b>Borough: </b>
                        {location.borough}
                    </li>
                    <li>
                        <b>Cuisine: </b>
                        {location.cuisine}
                    </li>
                    <li>
                        <b>Grade: </b>
                        {location.grade}
                    </li>
                </ul>
            )}
        </div>
    );
};
export default LocationDetail; 

列表 14-15: components/locations-details/index.tsx 文件

位置详情组件在结构上与位置列表项类似。两者都接收一个包含位置数据的对象,并为返回的 JSX 元素添加一组特定的属性。主要的区别在于我们创建的 JSX 结构。除此之外,我们遵循已知的模式,导入所需的样式和类型,使用LocationType定义组件的props接口,然后返回一个包含位置详情的 JSX 元素。

页面

我们在第 215 页的“用户界面概述”中提到,位置的详情页面应位于动态 URLlocation/:location"ePub-I">location文件夹下,位于pages目录中,并添加包含列表 14-16 代码的[locationId].tsx文件。

import Head from "next/head";
import type {
    GetServerSideProps,
    GetServerSidePropsContext,
    InferGetServerSidePropsType,
    PreviewData,
    NextPage,
} from "next";
import LocationDetail from "components/location-details";
import dbConnect from "middleware/db-connect";
import {findLocationsById} from "mongoose/locations/services";
import {LocationType} from "mongoose/locations/schema";
import {ParsedUrlQuery} from "querystring";

const Location: NextPage = (
    props: InferGetServerSidePropsType<typeof getServerSideProps>
) => {
    let location: LocationType = JSON.parse(props.data?.location);
  ❶ let title = `The Food Finder - Details for ${location?.name}`;
    return (
        <div>
            <Head>
                <title>{title}</title>
                <meta
                    name="description"
                    content={`The Food Finder. 
                        Details for ${location?.name}`}
                />
            </Head>
            <h1>{location?.name}</h1>
          ❷ <LocationDetail location={location} />
        </div>
    );
};

❸ export const getServerSideProps: GetServerSideProps = async (
    context: GetServerSidePropsContext<ParsedUrlQuery, PreviewData>
) => {
     let locations: LocationType[] | [];
  ❹ let {locationId} = context.query;
     try {
        await dbConnect();
        locations = await findLocationsById([locationId as string]);
      ❺ if (!locations.length) {
            throw new Error(`Locations ${locationId} not found`);
        }
    } catch (err: any) {
        return {
            notFound: true,
        };
    }
    return {
      ❻ props: {data: {location: JSON.stringify(locations.pop())}},
    };
}; 

列表 14-16:pages/location/[locationId].tsx 文件

起始页面和位置详情页面看起来非常相似。唯一的视觉差异是页面的标题,它是通过位置名称构建的❶,并且我们不再使用LocationsList组件,而是使用带有单个位置对象的LocationDetail组件❷。

然而,从功能角度来看,两个页面并不相似。与使用 SSG 的起始页面不同,位置详情页面使用 SSR,并配合使用getServerSideProp ❸。这是因为一旦我们添加了愿望清单功能并实现了添加/移除按钮,页面的内容应该随着用户的操作而变化。因此,我们需要在每次请求时重新生成 HTML。我们在第五章中深入讨论了 SSR 与 SSG 的区别。

我们使用页面的context及其query属性,从动态 URL 中获取位置 ID❹。然后,我们使用该 ID 从数据库中获取匹配的位置。如前所述,我们直接使用服务,而不是调用公开的 API,因为 Next.js 在服务器端运行get...Prop函数,并且可以直接访问我们应用程序中间件中的服务。

我们还实现了两种退出场景。首先,如果没有结果,我们会抛出一个错误,进入catch块❺,这样会将用户重定向到404 未找到错误页面。否则,我们将从结果中存储第一个位置到 location 属性❻,并将其传递给我们在最后一行导出的 Next.js 页面函数。

总结

我们已经成功构建了 Food Finder 应用程序的前端。到目前为止,你已经实现了一个全栈网页应用,它从 MongoDB 数据库读取数据,并在 Next.js 中以 React 用户界面组件的形式渲染结果。接下来,我们将添加一个 GitHub 的 OAuth 认证流程,以便用户可以使用 GitHub 账户登录并保存个性化的愿望清单。

第十五章:15 添加 OAuth

在本章中,你将为 Food Finder 应用添加 OAuth 身份验证,为用户提供使用其 GitHub 帐户登录的机会。你还将实现愿望清单页面,经过身份验证的用户可以在该页面上添加和移除位置,以及完成此操作所需的按钮组件。最后,你将学习如何保护你的 GraphQL 变更免受未经身份验证用户的访问。

使用 next-auth 添加 OAuth

开发人员通常使用第三方库或 SDK 来实现 OAuth。对于 Food Finder 应用程序,我们将使用来自 Auth.js 的 next-auth 包,该包包含一整套预配置的模板,允许我们轻松连接到 OAuth 服务。这些模板被称为 providers,我们将使用其中一个:GitHub provider,它为我们的应用添加了一个通过 GitHub 登录的按钮。有关 OAuth 身份验证过程的复习,请返回 第九章。

创建 GitHub OAuth 应用

首先,我们需要使用 GitHub 创建一个 OAuth 应用程序。这将为 Food Finder 应用提供连接 GitHub 所需的客户端 ID 和客户端密钥。如果你还没有 GitHub 帐号,可以现在在 https://github.com 创建一个帐号并登录。然后导航至 https://github.com/settings/developers,在 OAuth 应用程序部分创建一个新的 OAuth 应用。在生成的表单中输入 Food Finder 应用的详细信息,表单应类似于 图 15-1。

图 15-1:GitHub 用户界面用于添加新的 OAuth 应用

输入 Food Finder 作为名称,将主页 URL 设置为 http://localhost:3000/,并将授权回调 URL 设置为 http://localhost:3000/api/auth/callback/github。注册应用程序后,GitHub 应该会显示客户端 ID,并让我们生成客户端密钥。

添加客户端凭证

现在将这些凭证复制为 GITHUB_CLIENT_ID 和 GITHUB_CLIENT_SECRET,并粘贴到应用程序代码根目录中的 env.local 文件中。该文件如下所示:

MONGO_URI=mongodb://backend:27017/foodfinder
GITHUB_CLIENT_ID=`ADD_YOUR_CLIENT_ID_HERE`
GITHUB_CLIENT_SECRET=`ADD_YOUR_CLIENT_SECRET_HERE` 

用你的凭证填写占位符。

安装 next-auth

要将 Auth.js 的 OAuth SDK 添加到 next-auth 中并将其配置为连接到 provider,请运行以下命令:

$ **docker exec -it foodfinder_application npm install next-auth**

默认情况下,该 SDK 使用加密的 JWT 来存储并将会话信息附加到 API 请求中。只要我们提供密钥,库将自动处理加密和解密。要添加这样的密钥,请打开 env.local 文件,并在文件末尾添加以下行:

NEXTAUTH_SECRET=78f6cc4bf633b1102f4ca4d72602c60f

使用您喜欢的任何密钥。此处使用的字符串是通过 OpenSSLwww.usemodernfullstack.dev/api/v1/generate-secret 上随机生成的,您应该为每个应用程序使用一个新的密钥。

创建认证回调

现在,我们将开发 api/auth 路由,用于我们在注册 OAuth 应用时提供给 GitHub 的授权回调 URL。在 pages/api 目录下创建 auth 文件夹,并在其中创建文件 [...nextauth].ts。文件名中的 ... 告诉 Next.js 路由器这是一个“捕获所有”路由,意味着它处理所有指向 /auth 下端点的 API 调用;例如,auth/signinauth/callback/github。将 列表 15-1 中的代码添加到该文件中。

import GithubProvider from "next-auth/providers/github";
import {NextApiRequest, NextApiResponse} from "next";
import NextAuth from "next-auth";
import {createHash} from "crypto";

const createUserId = (base: string): string => {
    return createHash("sha256").update(base).digest("hex");
};

export default async function auth(req: NextApiRequest, res: NextApiResponse) {
    return await NextAuth(req, res, {
        providers: [
            GithubProvider({
                clientId: process.env.GITHUB_CLIENT_ID || " ",
                clientSecret: process.env.GITHUB_CLIENT_SECRET || " ",
            }),

        ],
        callbacks: {
            async jwt({token}) {
                if (token?.email && !token.fdlst_private_userId) {
                    token.fdlst_private_userId = createUserId(token.email);
                }
                return token;
            },
 async session({session}) {
                if (
                    session?.user?.email &&
                    !session?.user.fdlst_private_userId
                ) {
                    session.user.fdlst_private_userId = createUserId(
                        session?.user?.email
                    );
                }
                return session;
            },
        },
    });
} 

列表 15-1:pages/api/auth/[...nextauth].ts 文件

我们导入依赖项,包括内置的 GithubProvider 和默认的 crypto 模块。然后,我们创建一个简单的 createUserId 函数,它接受一个字符串作为参数,并调用 crypto 模块的 createHash 函数,以从该字符串返回哈希的用户 ID。

接下来,我们创建并导出默认的异步 auth 函数。为此,我们初始化 NextAuth 模块,并将 GithubProvider 添加到 providers 数组中。我们将其配置为使用我们存储在环境变量中的 clientId 和 clientSecret。

由于我们希望保持应用程序尽可能简单,因此我们将其保持无状态;因此,我们使用 jwt 和 session 回调,每次 next-auth 创建新的会话或 JWT 时都会使用这些回调。在回调中,我们通过 createId 函数计算用户的电子邮件的哈希用户 ID(如果它当前的令牌或会话对象中尚不可用)。最后,我们将其存储在一个私有声明中。

我们刚刚在next-auth会话中的user对象上创建了一个新的属性fdlst_private_userId。正如预期的那样,TSC 警告我们该属性在Session类型中不存在。我们需要通过调整应用程序根目录中的customs.d.ts文件来增强类型接口,使其与清单 15-2 相匹配。

import mongoose from "mongoose";
**import {DefaultSession} from "next-auth";**

declare global {
    var mongoose: mongoose;
}

**declare module "next-auth" {**
 **interface Session {**
 **user: {**
 **fdlst_private_userId: string;**
 **} & DefaultSession["user"];**
 **}**
**}** 

清单 15-2:更新后的 customs.d.ts 文件,其中增强了Session接口

在更新后的代码中,我们导入了next-auth包的DefaultSession,它定义了默认的会话对象,然后创建并重新声明了Session接口的user对象,并为其添加了新的fdlst_private_userId属性。由于 TypeScript 会覆盖现有的user对象,我们显式地从DefaultSession对象中添加它。换句话说,我们将新的fdlst_private_userId属性添加到Session接口中。

跨页面和组件共享会话

设置好回调 URL 后,我们需要确保用户的会话在所有 Next.js 页面和 React 组件中共享。我们可以使用 s 第四章中讨论的next-auth提供的useContext钩子。在pages/_app.tsx文件中,如清单 15-3 所示,将应用程序包装在SessionProvider中。

import "../styles/globals.css";
import "../styles/layout.css";
import type {AppProps} from "next/app";
import Layout from "components/layout";
**import {SessionProvider} from "next-auth/react";**

export default function App({
    Component, **pageProps: {session, ...pageProps}**}: AppProps) {
    return (
 **<SessionProvider session={session}>**
            <Layout>
                <Component {...pageProps} />
            </Layout>
 **</SessionProvider>**
    );
} 

清单 15-3:修改后的 pages/_app.tsx 文件

我们从next-auth包中导入了SessionProvider,并通过session对象增强了pageProps。我们将当前的session存储在提供者的session属性中,使其在整个 Next.js 应用程序中都可用。

在我们能够访问前端和中间件中的会话之前,我们需要添加带有“登录”按钮的auth-element,这样用户才能登录。

通用按钮组件

现在是时候实现我们之前提到的通用按钮组件了。从技术角度来看,这个组件将是一个通用的 div 元素,我们将为其添加样式使其看起来像一个按钮,并具有一些变化。它将作为 auth-element 中的登录/退出按钮,以及位置详情组件中的添加到/从中移除按钮。请在 components 文件夹中创建一个新的文件夹 button,并添加一个名为 index.module.css 的文件,文件内容参见 Listing 15-4,同时创建一个 index.tsx 文件。

.root {
    align-items: center;
    border-radius: 5px;
    color: #1d1f21;
    cursor: pointer;
    display: inline-flex;
    font-weight: 500;
    height: 35px;
    letter-spacing: 0;
    margin: 0;
    overflow: hidden;
    place-content: flex-start;
    position: relative;
    white-space: nowrap;
}

.root > a,
.root > span {
    padding: 0 1rem;
    white-space: nowrap;
}

.root {
    transition: border-color 0.25s ease-in, background-color 0.25s ease-in,
        color 0.25s ease-in;
    will-change: border-color, background-color, color;
}

.root.default,
.root.default:link,
.root.default:visited {
    background-color: transparent;
    border: 1px solid transparent;
    color: #1d1f21;
}

.root.default:hover,
.root.default:active {
    background-color: transparent;
    border: 1px solid #dbd8e3;
    color: #1d1f21;
}

.root.blue,
.root.blue:link,
.root.blue:visited {
    background-color: rgba(0, 118, 255, 0.9);
    border: 1px solid rgba(0, 118, 255, 0.9);
    color: #fff;
    text-decoration: none;
}

.root.blue:hover,
.root.blue:active {
    background-color: transparent;
    border: 1px solid #1d1f21;
    color: #1d1f21;
    text-decoration: none;
}

.root.outline,
.root.outline:link,
.root.outline:visited {
    background-color: transparent;
    border: 1px solid #dbd8e3;
    color: #1d1f21;
    text-decoration: none;
}

.root.outline:hover,
.root.outline:active {
    background-color: transparent;
    border: 1px solid rgba(0, 118, 255, 0.9);
    color: rgba(0, 118, 255, 0.9);
    text-decoration: none;
}

.root.disabled,
.root.disabled:link,
.root.disabled:visited {
    background-color: transparent;
    border: 1px solid #dbd8e3;
    color: #dbd8e3;
    text-decoration: none;
}

.root.disabled:hover,
.root.disabled:active {
    background-color: transparent;
    border: 1px solid #dbd8e3;
    color: #dbd8e3;
    text-decoration: none;
} 

Listing 15-4: components/button/index.module.css 文件

我们为每种希望创建的按钮变体添加样式。所有按钮的高度均为 35 像素,并且具有圆角。我们定义了一个默认样式,一个蓝色背景和白色文字的变体,以及一个背景为白色的轮廓版本。此外,我们还定义了用于禁用按钮的样式。

在样式就绪后,我们可以为组件编写代码。将 Listing 15-5 中的内容复制到组件的 index.tsx 文件中。

import React from "react";
import styles from "./index.module.css";

interface PropsInterface {
    disabled?: boolean;
    children?: React.ReactNode;
    variant?: "blue" | "outline";
    clickHandler?: () => any;
}

const Button = (props: PropsInterface): JSX.Element => {
    const {children, variant, disabled, clickHandler} = props;

    const renderContent = (children: React.ReactNode) => {
        if (disabled) {
            return (
                <span className={styles.span}>
                    {children}
                </span>
            );
        } else {
            return (
                <span className={styles.span} onClick={clickHandler}>
                    {children}
                </span>
            );
        }
    };

    return (
        <div
            className={[
                styles.root,
                disabled ? styles.disabled : " ",
                styles[variant || "default"],
            ].join(" ")}
        >
            {renderContent(children)}
        </div>
    );
};

export default Button; 

Listing 15-5: components/button/index.tsx 文件

在导入依赖项之后,我们定义组件的 prop 参数的接口。我们还将 Button 组件定义为一个返回 JSX 元素的函数,并使用对象解构语法将 props 对象分解为表示对象键值对的常量。我们定义了内部的 renderContent 函数,该函数有一个参数 children,其类型为 ReactNode,并将其包裹在一个 span 元素中进行渲染。根据 disabled 属性的状态,我们还会从 props 对象中添加点击处理程序。该组件本身返回一个我们已经设置了样式使其看起来像按钮的 div 元素。

AuthElement 组件

尽管我们已经将 next-auth 包添加到项目中,创建了 OAuth API 路由并配置了 OAuth 提供者,但我们仍然无法访问会话信息,因为没有登录按钮。让我们创建这个 AuthElement 组件,然后将其添加到页面头部。该组件使用我们默认的按钮组件,一旦用户登录,它会显示用户的全名,并提供指向用户愿望清单的链接。

components/header 目录中创建 auth-element 文件夹,然后添加一个名为 index.module.css 的文件,文件内容参见 Listing 15-6。

.root {
    align-items: center;
    display: flex;
    justify-content: space-between;
    margin: 0;
    padding: 1rem 0;
    width: auto;
}

.root > * {
    margin: 0 0 0 2rem;
}

.name {
    margin: 1rem 0 0 0;
}

@media (min-width: 600px) {
    .name {
        margin: 0 0 0 1rem;
    }
} 

Listing 15-6:components/header/auth-element/index.module.css 文件

我们为组件定义了一组基本样式,使用 flexbox 和边距将它们垂直对齐,并为小屏幕更改它们的布局。

要编写组件本身,请在auth-element文件夹中添加一个index.tsx文件,并将 Listing 15-7 中的代码输入其中。

import Link from "next/link";
import {signIn, signOut, useSession} from "next-auth/react";
import Button from "components/button";
import styles from "./index.module.css";

const AuthElement = (): JSX.Element => {
    const {data: session, status} = useSession();

    return (
        <>
            {status === "authenticated" (
                <span className={styles.name}>
                    Hi <b>{session?.user?.name}</b>
                </span>
            )}

 <nav className={styles.root}>
                {status === "authenticated" && (
                    <>
                        <Button variant="outline">
                            <Link
href={`/list/${session?.user.fdlst_private_userId}`}
                            >
                                Your wish list
                            </Link>
                        </Button>

                        <Button variant="blue" clickHandler={() => signOut()}>
                            Sign out
                        </Button>
                    </>
                )}
                {status == "unauthenticated" && (
                    <>
                        <Button variant="blue" clickHandler={() => signIn()}>
                            Sign in
                        </Button>
                    </>
                )}
            </nav>
        </>
    );
};
export default AuthElement; 

Listing 15-7:components/header/auth-element/index.tsx 文件

最显著的导入项是来自next-auth的signIn和signOut函数以及useSession钩子。后者使我们能够轻松访问会话信息,而这两个函数则触发登录流程或终止会话。

然后我们定义AuthElement组件,并从useSession钩子中获取session data和session status。我们需要这两者来构造我们从组件返回的 JSX 元素。在客户端,我们可以通过useSession钩子直接访问会话信息。然而,在服务器端,我们需要通过 JWT 访问,因为会话信息是 API 请求的 HTTP cookies 的一部分。

当会话状态被认证时,我们从会话数据中呈现用户的姓名,并将“您的愿望清单”和“登出”按钮添加到导航的nav元素中。否则,我们将添加“登录”按钮以启动 OAuth 流程。对于所有这些,我们使用通用按钮组件和从next-auth模块导入的signIn和signOut函数,它们都自动处理 OAuth 流程。

我们使用next/link元素来链接到用户的愿望清单。(这是我们稍后会实现的另一个 Next.js 页面。)愿望清单通过动态路由/list/:userId提供,该路由使用我们通过哈希化用户的电子邮件地址并将其存储在fdlst_private_userId中的用户 ID。

将 AuthElement 组件添加到 Header 中

现在我们必须将新组件添加到头部。打开components/header目录中的index.tsx文件,并进行调整,使其与 Listing 15-8 匹配。

import styles from "./index.module.css";
import Logo from "components/header/logo";
**import AuthElement from "components/header/auth-element";**
const Header = (): JSX.Element => {
    return (
        <header className={styles.root}>
            <div className="**layout-grid**">
                <Logo />
 **<AuthElement />**
            </div>
        </header>
    );
};

export default Header; 

Listing 15-8:修改后的 components/header/index.tsx 文件

更新很简单;我们导入 AuthElement 组件,并将其添加到 Logo 旁边,放在 header 内。

测试 OAuth 工作流程,看看我们的会话管理实践。当你打开 http://localhost:3000 时,登录按钮应该出现在头部,如图 15-2 所示。

图 15-2:登出状态下的头部,显示登录按钮

让我们使用 OAuth 登录。点击 登录 按钮,next-auth 应该会将你重定向到登录屏幕,在那里你可以选择使用配置的 OAuth 提供者登录(见图 15-3)。

图 15-3:OAuth 要求我们选择一个提供者。

点击按钮进行登录。OAuth 应该会将你重定向到应用程序,在那里 AuthElement 根据会话信息渲染你的名字和新的按钮。屏幕应该与图 15-4 相似。

图 15-4:登录状态下的头部,显示会话信息

头部元素已经根据会话状态发生变化。我们显示了从 OAuth 提供者获取的当前用户的名字、指向他们公共愿望清单的链接以及注销按钮。

愿望清单 Next.js 页面

头部中的愿望清单按钮应链接到动态 URL list/:userId 的愿望清单页面。这个常规的 Next.js 页面应该显示所有其 on_wishlist 属性包含在动态 URL 中指定的用户 ID 的位置。它看起来会与起始页非常相似,我们可以利用现有组件构建它。

要创建页面路由,在 pages 目录下创建 list 文件夹,并在其中创建 [userId].tsx 文件。然后将 Listing 15-9 中的代码添加到这个 .tsx 文件中。

import type {
    GetServerSideProps,
    GetServerSidePropsContext,
    NextPage,
    PreviewData,
    InferGetServerSidePropsType,
} from "next";
import Head from "next/head";
import {ParsedUrlQuery} from "querystring";

import dbConnect from "middleware/db-connect";
import {onUserWishlist} from "mongoose/locations/services";
import {LocationType} from "mongoose/locations/schema";
import LocationsList from "components/locations-list";

import {useSession} from "next-auth/react";

const List: NextPage = (
    props: InferGetServerSidePropsType<typeof getServerSideProps>
) => {
    const locations: LocationType[] = JSON.parse(props.data?.locations);
    const userId: string | undefined = props.data?.userId;
    const {data: session} = useSession();
    let title = `The Food Finder- A personal wish list`;
    let isCurrentUsers =
        userId && session?.user.fdlst_private_userId === userId;
    return (
        <div>
            <Head>
                <title>{title}</title>
                content={`The Food Finder. A personal wish list.`}
            </Head>
            <h1>
                {isCurrentUsers ? " Your " : " A "}
                wish list!
            </h1>
            {isCurrentUsers && locations?.length === 0 && (
                <>
                    <h2>Your list is currently empty! :(</h2>
                    <p>Start adding locations to your wish list!</p>
                </>
            )}
            <LocationsList locations={locations} />
        </div>
    );
};

export const getServerSideProps: GetServerSideProps = async (
    context: GetServerSidePropsContext<ParsedUrlQuery, PreviewData>
) => {
    let {userId} = context.query;
    let locations: LocationType[] | [] = [];
    try {
        await dbConnect();
        locations = await onUserWishlist(userId as string);
    } catch (err: any) {}
    return {
        // the props will be received by the page component
        props: {
            data: {locations: JSON.stringify(locations), userId: userId},
        },
    };
};
export default List; 

Listing 15-9: 页面/list/[userId].tsx 文件

虽然我们希望愿望清单页面看起来与起始页相似,但我们使用 SSR,并结合 getServerSideProps,就像我们在位置详情页面中做的那样。愿望清单页面是高度动态的;因此,我们需要在每次请求时重新生成 HTML。

另一种方法是使用客户端渲染,然后通过 GraphQL API 在 useEffect 钩子请求用户的位置。然而,这会导致用户每次打开愿望清单页面时看到加载页面。我们可以通过 SSR 完全避免这种较差的用户体验。

在页面代码的服务器端部分,我们首先从上下文的查询对象中提取 URL 参数userId。我们使用用户的 ID 和onUsersWishlist服务获取用户愿望单中的所有位置。如果发生错误,我们会继续执行,而不是重定向到404错误页面,呈现一个空列表。

然后我们将位置数组和用户 ID 传递给 Next.js 页面,在那里我们像往常一样提取位置以及userId。我们将 URL 中的用户 ID 与当前会话中的用户 ID 进行比较。如果它们匹配,我们就知道当前登录的用户访问了自己的愿望单,并相应地调整用户界面。

将按钮添加到位置详情组件

现在我们可以访问愿望单页面,但它始终为空。我们还没有提供用户将项目添加到其中的方式。为了改变这一点,我们将在位置详情组件中放置一个按钮,允许用户添加或删除特定位置。我们将使用通用按钮组件和会话信息。打开index.ts文件,位于components/location-details.tsx目录中,并修改代码以匹配 Listing 15-10。

import {LocationType} from "mongoose/locations/schema";
import styles from "./index.module.css";

**import {useSession} from "next-auth/react";**
**import {useEffect, useState} from "react";**
**import Button from "components/button";**

interface PropsInterface {
    location: LocationType;
}

**interface WishlistInterface {**
 **locationId: string;**
 **userId: string;**
**}**

const LocationDetail = (props: PropsInterface): JSX.Element => {
    let location: LocationType = props.location;

    **const {data: session} = useSession();**
 **const [onWishlist, setOnWishlist] = useState<Boolean****>(false);**
 **const [loading, setLoading] = useState<Boolean>(false);**

  **useEffect(() => {**
 **let userId = session?.user.fdlst_private_userId;**
 **setOnWishlist(**
 **userId && location.on_wishlist.includes(userId) ? true : false**
 **);**
 **}, [session]);**

    **const wishlistAction = (props: WishlistInterface) => {**

 **const {locationId, userId} = props;**

 **if (loading) {return false;}**
 **setLoading(true);**

 **let action = !onWishlist ? "addWishlist" : "removeWishlist";**

  **fetch("/api/graphql", {**
 **method: "POST",**
 **headers: {**
 **"Content-Type": "application/json",**
 **},**
 **body: JSON.stringify({**
                **query: `mutation wishlist {**
 **${action}(**
 **location_id: "${locationId}",**
 **user_id: "${userId}"**
 **) {**
 **on_wishlist**
 **}**
 **}`,**
 **}),**
 **})**
 **.then((result) => {**
 **if (result.status === 200) {**
 **setOnWishlist(action === "addWishlist" ? true : false);**
 **}**
 **})**
 **.finally(() => {**
 **setLoading(false);**
 **});**
 **};**

    return (
        <div>
            {location && (
                <ul className={styles.root}>
                    <li>
                        <b>Address: </b>
                        {location.address}
                    </li>
                    <li>
                        <b>Zipcode: </b>
                        {location.zipcode}
                    </li>
                    <li>
                        <b>Borough: </b>
                        {location.borough}
                    </li>
                    <li>
                        <b>Cuisine: </b>
                        {location.cuisine}
                    </li>
                    <li>
                        <b>Grade: </b>
                        {location.grade}
                    </li>
                </ul>
            )}

            **{session?.user.fdlst_private_userId && (**
 **<Button**
 **variant={!onWishlist ? "outline" : "blue"}**
 **disabled={loading ? true : false}**
 **clickHandler={() =>**
 **wishlistAction({**
 **locationId: session?.user.fdlst_private_userId,**
 **userId: session?.user?.userId,**
 **})**
 **}**
 **>**
 **{onWishlist && <>Remove from your Wishlist</>}**
 **{!onWishlist && <>Add to your Wishlist</>}**
 **</Button>**
 **)}**

        </div>
    );
};
export default LocationDetail; 

Listing 15-10: 修改后的 components/location-details/index.tsx 文件

首先我们从next-auth导入useSession,从 React 导入useEffect和useState,以及通用的Button组件。然后我们定义WishlistInterface,这是我们稍后将实现的<wishlistAction函数的接口。

在组件内部,我们通过useSession钩子获取session,然后使用useState创建onWishlist和loading状态变量,作为布尔值。我们使用第一个状态变量来指定某个位置当前是否在用户的愿望单中,然后相应地更新用户界面。我们基于位置的on_wishlist属性,在useEffect钩子中计算初始状态。一旦成功将位置添加或移除到愿望单中,我们就会更新状态变量和按钮的文本。

我们实现了 wishlistAction 函数来更新 on_wishlist 属性。首先,我们解构参数对象,然后检查 loading 状态,以查看是否当前有请求在进行。如果有,我们退出函数。否则,我们将加载状态设置为 true,以阻止用户界面,计算 GraphQL mutation 的操作,并用它来调用 wishlist mutation。在成功修改数据库中的文档后,我们更新 onWishlist 状态,并解除用户界面的阻塞。

我们检查当前会话,以查看用户是否已登录。如果是,我们渲染 Button 组件,并根据加载状态设置 disabled 和类名属性,并绑定点击事件。每次点击按钮时,我们调用 wishlistAction 函数,并将当前地点 ID 和用户 ID 作为参数传递。最后,我们根据 onWishlist 状态设置按钮的文本,决定是将当前地点添加到愿望列表中,还是将其移除。

在继续之前,尝试向愿望列表中添加和移除几个地点。检查按钮的文本是否相应变化,并且在愿望列表页面上是否出现与起始页面类似的地点列表。

保护 GraphQL Mutation

还有一件事我们必须做才能完成应用程序:保护 GraphQL API。虽然查询应该是公开可用的,但 mutation 应仅供登录用户访问,且用户只能为 on_wishlist 属性添加或移除自己的用户 ID。

但是如果你使用 curl 命令测试 API,你会看到当前每个人都可以访问该 API。请注意,你必须将提供给 -d 标志的值输入在同一行上,否则服务器可能会返回错误:

$ **curl -v \**
 **-X POST \**
 **-H "Accept: application/json" \**
 **-H "Content-Type: application/json" \**
 **-d '{"query":"mutation wishlist {removeWishlist(location_id: \"12340\",**
 **user_id: \"exampleid\") {on_wishlist}}"}' \**
 **http://localhost:3000/api/graphql**

< HTTP/1.1 200 OK
<
{"data":{"removeWishlist":{"on_wishlist":[]}}} 

作为测试,我们发送一个简单的 mutation 请求,试图将 ID 为 12340 的地点从一个不存在的用户的愿望列表中移除。(mutation 不会生效,这没关系;我们只是想验证 API 是否对公众开放。)该命令收到 200 响应和预期的 JSON,证明 mutation 是公开的。

让我们实现一个 authGuard 来保护我们的变更操作。guard 是一种模式,它检查一个条件,如果条件不满足,则抛出错误,而 auth guard 保护一个路由或 API 防止未经授权的访问。

我们从在 middleware 文件夹中创建 auth-guards.ts 文件开始,并将列表 15-11 中的代码添加到该文件。

import {GraphQLError} from "graphql/error";
import {JWT} from "next-auth/jwt";

interface paramInterface {
    user_id: string;
    location_id: string;
}
interface contextInterface {
    token: JWT;
}

export const authGuard = (
    param: paramInterface,
    context: contextInterface
): boolean | Error => {

 ❶ if (!context || !context.token || !context.token.fdlst_private_userId) {
        return new GraphQLError("User is not authenticated", {
            extensions: {
                http: {status: 500},
                code: "UNAUTHENTICATED",
            },
        });
    }

 ❷ if (context?.token?.fdlst_private_userId !== param.user_id) {
        return new GraphQLError("User is not authorized", {
            extensions: {
                http: {status: 500},
                code: "UNAUTHORIZED",
            },
        });
    }
    return true;
}; 

列表 15-11:middleware/auth-guards.ts 文件

我们还从 next-auth 导入 JWT,并从 graphql 导入 GraphQLError 构造函数。如果身份验证失败,我们将使用后者创建返回给用户的错误对象。接下来,我们为 authGuard 函数的参数定义接口,并导出该函数本身。

我们将在变更解析器中调用认证守卫,并传递两个参数:一个包含用户 ID 和位置 ID 的对象,我们为此定义了 paramInterface,以及包含令牌的上下文对象,contextInterface。认证守卫返回一个布尔值,表示认证是否成功,或者返回一个错误。在 authGuard 函数中,我们验证每次访问变更操作时,是否带有包含私有声明 ❶ 的令牌,并且私有声明中的用户 ID 是否与我们传递给变更操作的用户 ID 匹配 ❷。换句话说,我们验证的是,一个已登录的用户发出了 API 请求,并且他们正在修改自己的愿望清单。

如果检查失败,我们会创建一个带有消息和代码的错误。此外,我们将 HTTP 状态码设置为500。请记住,与依赖精确的 HTTP 状态码列表与调用方进行通信的 REST API 不同,GraphQL API 通常使用200500作为错误的状态码。广义上来说,当 GraphQL 完全无法执行查询时,我们发送500状态码;当查询可以执行时,发送200状态码。在这两种情况下,GraphQL API 都应该包含关于错误发生的详细信息。

现在,我们必须将用户的 OAuth 令牌传递给解析器,然后它们会将令牌传递给认证守卫。为此,我们将使用在 startServerAndCreateNextHandler 函数中实现的上下文函数,该函数位于 pages/api/graphql.ts 文件中。打开该文件,并根据列表 15-12 中的代码进行调整。

import {ApolloServer, BaseContext} from "@apollo/server";
import {startServerAndCreateNextHandler} from "@as-integrations/next";

import {resolvers} from "graphql/resolvers";
import {typeDefs} from "graphql/schema";
import dbConnect from "middleware/db-connect";

import {NextApiHandler, NextApiRequest, NextApiResponse} from "next";

**import {getToken} from "next-auth/jwt";**

const server = new ApolloServer<BaseContext>({
    resolvers,
    typeDefs,
});

const handler = startServerAndCreateNextHandler(server, {
    context: async (**req:** **NextApiRequest**) => {
        **const token = await getToken({req});**
        return {**token**};
    },
});

const allowCors =
    (fn: NextApiHandler) =>
    async (req: NextApiRequest, res: NextApiResponse) => {
        res.setHeader("Allow", "POST");
        res.setHeader("Access-Control-Allow-Origin", "*");
        res.setHeader("Access-Control-Allow-Methods", "POST");
        res.setHeader("Access-Control-Allow-Headers", "*");
        res.setHeader("Access-Control-Allow-Credentials", "true");

        if (req.method === "OPTIONS") {
            res.status(200).end();
        }
        return await fn(req, res);
    };

const connectDB =
    (fn: NextApiHandler) =>
    async (req: NextApiRequest, res: NextApiResponse) => {
        await dbConnect();
        return await fn(req, res);
    };

export default connectDB(allowCors(handler)); 

列表 15-12:修改后的 pages/api/graphql.ts 文件,包含 JWT 令牌

与客户端不同,在客户端我们可以通过 useSession hook 直接访问会话信息,在这里我们需要通过服务器端的 JWT 来访问会话信息。这是因为会话信息作为 API 请求的 HTTP cookies 存储在服务器上,而不是 SessionProvider 的共享会话状态中,我们需要从请求中提取它。为此,我们从 next-auth 的 jwt 模块导入 getToken 函数。然后,我们将从 context 函数接收到的请求对象传递给 getToken 并等待解码后的 JWT。接下来,我们从 context 函数返回该 token,以便在解析器函数中访问该 token。

最后,让我们使用 token 将 authGuard 添加到我们的解析器中,以保护它们免受未经身份验证和未经授权的访问。打开 graphql/locations/mutations.ts 文件,并使用 Listing 15-13 中的代码进行更新。

import {updateWishlist} from "mongoose/locations/services";
**import {authGuard} from "middleware/auth-guard";**
**import {JWT} from "next-auth/jwt";**

interface UpdateWishlistInterface {
    user_id: string;
    location_id: string;
}

**interface contextInterface {**
 **token: JWT;**
**}**

export const locationMutations = {
    removeWishlist: async (
        _: any,
        param: UpdateWishlistInterface,
        **context: contextInterface**
    ) => {

 **const guard = authGuard(param, context);**
 **if (guard !== true) {return guard;}**

        return await updateWishlist(param.location_id, param.user_id, "remove");
    },

    addWishlist: async (
        _: any,
        param: UpdateWishlistInterface,
        **context: contextInterface**
    ) => {

 **const guard = authGuard(param, context);**
 **if (guard !== true) {return guard;}**

        return await updateWishlist(param.location_id, param.user_id, "add");
    },
}; 

Listing 15-13: 添加了 authGuard 的 graphql/locations/mutations.ts 文件

我们为上下文定义了一个新接口,并更新了上下文参数以包含 JWT。接下来,我们将 authGuard 函数添加到我们的 mutations 中,并遵循守卫模式,立即返回错误,而不是继续执行代码。

要测试 authGuard 功能,再次运行 curl。命令行输出应该类似于 Listing 15-14。

$ **curl -v \**
 **-X POST \**
 **-H "Accept: application/json" \**
 **-H "Content-Type: application/json" \**
 **-d '{"query":"mutation wishlist {removeWishlist(location_id: \"12340\",**
 **user_id: \"exampleid\") {on_wishlist}}"}' \**
 **http://localhost:3000/api/graphql**

< HTTP/1.1 500 Internal Server Error
<
{
    "errors":[
        {
            "message":"User is not authenticated",
            "locations": [{"line":1,"column":20}],
            "path": ["removeWishlist"],
            "extensions": {"code":"UNAUTHENTICATED","data":null}
        }
    ]
} 

Listing 15-14: 用于测试我们 API 的 curl 命令

与之前的 curl 调用不同,GraphQL API 现在返回 HTTP/1.1 500 Internal Server Error 和一个详细的错误信息,这个错误信息是在我们创建 GraphQLError 时定义的,位于 auth-guards.ts 文件中。

总结

我们已经成功地在 Food Finder 应用中添加了 OAuth 身份验证流程。现在,用户可以使用他们的 GitHub 账号登录。一旦登录,他们可以维护个人的公开愿望清单。此外,我们还保护了 GraphQL 的变更操作,这意味着它们不再对任何人开放;相反,只有登录用户才能访问这些变更操作。在最后一章,我们将使用 Jest 添加自动化测试来评估该应用。

第十六章:16 在 DOCKER 中运行自动化测试

在这短短的最后一章中,您将编写几个自动化测试来验证 Food Finder 应用程序的状态。然后,您将配置一个 Docker 服务来持续运行这些测试。

我们将重点评估应用程序的头部,使用快照测试并模拟用户会话。我们不会为其他组件、我们的中间件、服务或 API 创建测试。然而,我鼓励您自行构建这些。可以尝试使用基于浏览器的端到端测试,借助像 Cypress 或 Playwright 这样的专用框架来测试整个页面。您可以在 https://nextjs.org/docs/testing 上找到这两个框架的安装说明和示例。

将 Jest 添加到项目中

使用 npm 安装 Jest 库:

$ **docker exec -it foodfinder-application npm install --save-dev jest \**
**jest-environment-jsdom @testing-library/react @testing-library/jest-dom** 

接下来,通过创建一个名为 jest.config.js 的新文件并包含列表 16-1 中的代码,配置 Jest 与我们的 Next.js 设置兼容。将文件保存在应用程序的根文件夹中。

const nextJest = require("next/jest");

const createJestConfig = nextJest({
    dir: "./",
});

const customJestConfig = {
    moduleDirectories: ["node_modules", "<rootDir>/"],
    testEnvironment: "jest-environment-jsdom",
};

module.exports = createJestConfig(customJestConfig); 

列表 16-1:jest.config.js 文件

我们利用内置的 Next.js Jest 配置,因此需要将项目的基本目录配置为加载 config.env 文件到测试环境中。然后设置模块目录的位置和全局测试环境。这里使用全局设置,因为我们的快照测试将需要一个 DOM 环境。

现在我们希望能够使用 npm 命令运行测试。因此,将列表 16-2 中的两个命令添加到项目的 scripts 属性的 package.json 文件中。

 "test": "jest ",
    "testWatch": "jest --watchAll" 

列表 16-2:添加到 package.json 文件的 scripts 属性中的两个命令

第一个命令一次性执行所有可用的测试,第二个命令则持续监视文件更改,并在检测到更改时重新运行测试。

设置 Docker

要使用 Docker 运行测试,请向 docker-compose.yml 中添加另一个使用 Node.js 镜像的服务。在启动时,此服务将运行 npm run testWatch,这是我们刚刚定义的命令。通过这种方式,我们将持续运行测试,并即时获取有关应用程序状态的反馈。修改文件以匹配列表 16-3 中的代码。

version: "3.0"
services:

    backend:
        container_name: foodfinder-backend
        image: mongo:latest
        restart: always
        environment:
            DB_NAME: foodfinder
            MONGO_INITDB_DATABASE: foodfinder
        ports:
            - 27017:27017
        volumes:
            - "./.docker/foodfinder-backend/seed-mongodb.js:
/docker-entrypoint-initdb.d/seed-mongodb.js"
            - mongodb_data_container:/data/db

    application:
        container_name: foodfinder-application
        image: node:lts-alpine
        working_dir: /home/node/code/foodfinder-application
        ports:
            - "3000:3000"
        volumes:
            - ./code:/home/node/code
        depends_on:
            - backend
        environment:
            - HOST=0.0.0.0
            - CHOKIDAR_USEPOLLING=true
            - CHOKIDAR_INTERVAL=100
        tty: true
        command: "npm run dev"

    **jest:**
 **container_name: foodfinder-jest**
 **image: node:lts-alpine**
 **working_dir: /home/node/code/foodfinder-application**
 **volumes:**
 **- ./code:/home/node/code**
 **depends_on:**
 **- backend**
 **- application**
 **environment:**
 **- NODE_ENV=test**
 **tty: true**
 **command: "npm run testWatch"**

volumes:
    mongodb_data_container: 

列表 16-3:包含 jest 服务的修改后的 docker-compose.yml 文件

我们的小服务,名为jest,使用了我们之前用过的官方 Node.js Alpine 镜像。我们设置了工作目录,并使用 volumes 属性将我们的代码也提供给这个容器。与应用程序服务不同,jest 服务将 Node.js 环境设置为 test,并运行 testWatch 命令。

重启 Docker 容器;控制台应显示 Jest 正在监视我们的文件。

为 Header 元素编写快照测试

如同在第八章中一样,在应用程序的根目录中创建 tests 文件夹来存放我们的测试文件。然后添加包含列表 16-4 中代码的 header.snapshot.test.tsx 文件。

import {act, render} from "@testing-library/react";
import {useSession} from "next-auth/react";
import Header from "components/header";

jest.mock("next-auth/react");
describe("The Header component", () => {
    it("renders unchanged when logged out", async () => {
        (useSession as jest.Mock).mockReturnValueOnce({
            data: {user: {}},
            status: "unauthenticated",
        });
        let container: HTMLElement | undefined = undefined;
        await act(async () => {
            container = render(<Header />).container;
        });
        expect(container).toMatchSnapshot();
    });

    it("renders unchanged when logged in", async () => {
        (useSession as jest.Mock).mockReturnValueOnce({
            data: {
                user: {
                    name: "test user",
                    fdlst_private_userId: "rndmusr",
                },
            },
            status: "authenticated",
        });
        let container: HTMLElement | undefined = undefined;
        await act(async () => {
            container = render(<Header />).container;
        });
        expect(container).toMatchSnapshot();
    });
}); 

列表 16-4:tests/header.snapshot.test.tsx 文件

这个测试应该类似于你在第八章中编写的那些。注意,我们从next-auth/react导入了 useSession 钩子,然后使用 jest.mock 在每个测试的安排步骤中替换它。通过用返回状态的模拟会话替换原会话,我们可以验证标题组件在已登录和未登录用户状态下的行为是否符合预期。我们通过使用安排、执行和断言模式来描述 Header 组件的测试套件,并验证渲染的组件是否与存储的快照匹配。

第一个测试用例使用空会话和未经验证的状态来呈现未登录状态下的标题。第二个测试用例使用包含最少数据的会话,并将用户状态设置为已验证。这样我们就可以验证,现有会话显示的用户界面与空会话显示的界面不同。

如果你编写了额外的测试,请确保将它们添加到tests 文件夹中。

总结

你已成功添加了一些简单的快照测试,以验证 Food Finder 应用程序按预期工作。通过添加额外的 Docker 服务,你可以持续验证后续开发不会破坏应用程序。

恭喜!你已经成功地创建了第一个全栈应用程序,使用了 TypeScript、React、Next.js、Mongoose 和 MongoDB。你还使用 Docker 将应用程序容器化,并用 Jest 进行测试。通过本书及其练习,你为自己作为全栈开发者的职业生涯奠定了基础。

第十七章:A 类型脚本编译器选项

将这些选项中的任何一个传递给 tsconfig.json 文件的 compilerOptions 字段,以配置 TSC 将 TypeScript 代码转译为 JavaScript 的过程。有关此过程的更多信息,请参见 第三章。

这里我们介绍了最常见的选项。您可以在官方文档中找到更多信息和完整的选项列表,链接为 https://www.typescriptlang.org/tsconfig

allowJs  一个布尔值,指定项目是否可以导入 JavaScript 文件。

baseUrl  一个字符串,用于定义用于解析模块路径的根目录。例如,如果将其设置为 "./",TypeScript 将从根目录解析文件导入。

esModuleInterop  一个布尔值,指定 TypeScript 是否应无缝导入 CommonJS、AMD 或 UMD 模块,或者是否应将它们与 ES.Next 模块区分开来。通常,如果使用不支持 ES.Next 模块的第三方库,则需要此选项。

forceConsistentCasingInFileNames  一个布尔值,指定文件导入是否区分大小写。这在一些开发者在区分大小写的文件系统上工作,而其他开发者则不区分时尤其重要,以确保文件加载行为对所有人一致。

incremental  一个字符串,用于定义 TypeScript 编译器是否应保存上次编译的项目图,使用增量类型检查,并在连续运行时执行增量更新。这可以加速转译过程。

isolatedModules  一个布尔值,指定 TypeScript 是否应对与第三方转译器(例如 Babel)不兼容的代码发出警告。引发这些警告的最常见原因是代码使用了不是模块的文件;例如,它们没有任何 import 或 export 语句。此值不会改变实际 JavaScript 的行为,它只是警告无法正确转译的代码。

jsx   一个字符串,指定 TypeScript 如何处理 JSX。它仅适用于 .tsx 文件以及 TypeScript 编译器如何输出这些文件。例如,默认值 react 会使用 React .createElement 转换并输出代码,而 preserver 则不会转换组件中的代码,直接输出未修改的代码。

lib   一个数组,通过 polyfill 添加缺失的特性。一般来说,polyfill 是一些代码片段,用来为目标环境不原生支持的特性和功能提供支持。当我们针对不完全兼容的系统时(如旧版本的浏览器或 Node 版本),需要模拟现代 JavaScript 特性。编译器将 lib 数组中定义的 polyfill 添加到生成的代码中。

module   一个字符串,设置转译代码的模块语法。例如,如果将其设置为 commonjs,TSC 将转译该项目,使用传统的 CommonJS 模块语法,采用 require 来导入,使用 module.exports 来导出代码,而设置为 ES2015 时,转译后的代码将使用 import 和 export 关键字。这与 target 属性无关,后者定义了所有可用的语言特性,模块语法除外。

moduleResolution   一个字符串,指定模块解析策略。此策略还定义了 TSC 在编译时如何定位模块的定义文件。改变解析方式可以解决导入和导出模块时的一些边缘问题。

noEmit   一个布尔值,定义了 TSC 是否应该生成文件,或者仅检查项目中的类型。如果你希望像 webpack、Babel.js 或 Parcel 这样的第三方工具来转译代码,而不是 TSC,请将其设置为 false。

resolveJsonModule   一个布尔值,指定 TypeScript 是否导入 JSON 文件。它会根据文件中的 JSON 生成类型定义,并在导入时验证类型。由于 TypeScript 默认不能导入 JSON 文件,因此我们需要手动启用 JSON 导入。

skipLibCheck   一个布尔值,用于定义 TypeScript 编译器是否对所有类型声明文件进行类型检查。将其设置为 false 可以减少编译时间,并且是处理没有类型声明的第三方依赖项的解决办法。

target   一个字符串,用于指定 TypeScript 代码应该转译成的语言特性。例如,如果将其设置为 es6,或等同的 ES2015,TSC 将把该项目转译为与 ES2015 兼容的 JavaScript,例如使用 let 和 const。

第十八章:B NEXT.JS 应用目录

在第 13 版中,Next.js 引入了一种新的路由范式,使用 app 目录替代 pages 目录。本附录讨论了这一新特性,以便您能进一步探索。由于没有计划废弃 pages 目录,您仍然可以继续使用在第五章中学习的路由方法。您甚至可以同时使用这两个目录;只需小心不要在两个目录中添加相同路由的文件夹和文件,因为这可能会导致错误。

app 目录和 pages 目录都使用文件夹和文件来创建路由。然而,app 目录区分了服务器组件和客户端组件。在 pages 文件夹中,一切都是 客户端组件,这意味着所有代码都是 Next.js 发送给客户端的 JavaScript 包的一部分。但是,app 目录中的每个文件默认都是 服务器组件,其代码从未发送到客户端。

本附录将介绍新方法的基本概念,然后使用新结构初始化一个 Next.js 应用程序。

服务器组件与客户端组件

在此上下文中,客户端服务器这两个术语指的是 Next.js 运行时渲染组件的环境。客户端环境是用户的环境(通常是浏览器),而服务器则指 Next.js 服务器,它接收来自客户端的请求,无论它是在本地主机上运行还是在远程位置。

随着服务器组件的引入,Next.js 不再纯粹使用客户端路由。在 以服务器为中心 的路由中,服务器渲染组件并将渲染后的代码发送给客户端。这意味着客户端不需要下载路由映射,从而减少了初始页面大小。此外,用户不必等到所有资源加载完毕后才可以与页面互动。Next.js 服务器组件利用 React 的流式架构,逐步渲染每个组件的内容。在这种模式下,页面在加载完成之前就变得可互动。

服务器组件

Next.js 服务器组件基于自 React 18 版本以来提供的 React 服务器组件。由于这些组件由服务器渲染,它们不会向客户端添加任何 JavaScript,从而减少了整体页面大小并提高了页面性能分数。此外,JavaScript 包是可缓存的,因此客户端不会在我们添加新的额外服务器组件时重新下载它,而只有在我们通过额外的客户端组件添加新的客户端脚本时,才会重新下载。

此外,由于这些组件完全在服务器上渲染,它们可以包含敏感的服务器信息,如访问令牌和 API 密钥。(为了增加额外的保护层,Next.js 的渲染引擎将所有未显式以 NEXT_PUBLIC 前缀的环境变量替换为空字符串。)最后,我们可以使用大规模的依赖项和额外的框架,而不会使客户端脚本臃肿,并可以直接访问后端资源,从而提高应用程序的性能。

列表 B-1 展示了一个服务器组件的基本结构。

export default async function ServerComponent(props: WeatherProps): Promise<JSX.Element> {

    return (
      <h1>The weather is {props.weather}</h1>
    );
} 

列表 B-1:一个基本的服务器组件

在第四章中,你学到 React 组件是一个返回 React 元素的 JavaScript 函数;Next.js 服务器组件遵循相同的结构,不同之处在于它们是异步函数,因此我们可以使用 async/await 模式与 fetch。因此,它返回的不是 React 元素,而是该元素的一个 Promise。 列表 B-1 中的代码应该让你想起前面章节中创建的 WeatherComponent,不过它不包含任何客户端代码。

客户端组件

相比之下,客户端组件是由浏览器而非服务器渲染的组件。你已经知道如何编写客户端组件,因为所有 React 和 Next.js 组件传统上都是客户端组件。

为了渲染这些组件,客户端需要接收所有必需的脚本及其依赖项。每个组件都会增加打包文件的大小,从而降低应用程序的性能。为此,Next.js 提供了优化应用性能的选项,如服务器端渲染(SSR),它在服务器上预渲染页面,然后让客户端为页面添加交互元素。

app 目录中的所有组件默认为服务器组件。然而,客户端组件可以存在于任何地方(例如,我们之前使用的 components 目录中)。列表 B-2 展示了 列表 5-4 中创建的 WeatherComponent,它被重构为一个可以与 app 目录一起使用的客户端组件。

"use client";

import React, {useState, useEffect} from "react";

export default function ClientComponent (props: WeatherProps): JSX.Element {

    const [count, setCount] = useState(0);
    useEffect(() => {setCount(1);}, []);

    return (
        <h1
          onClick={() => setCount(count + 1)} >
          The weather is {props.weather},
          and the counter shows {count}
        </h1>
    );
} 

列表 B-2:一个基本的客户端组件,类似于在 列表 5-4 中创建的 WeatherComponent

我们将组件作为默认函数导出,名称为 ClientComponent。因为我们使用了客户端钩子 useEffect 和 useState,以及 onClick 事件处理器,我们需要在文件顶部用 "use client" 指令声明该组件为客户端组件。否则,Next.js 会抛出错误。

渲染组件

在第五章中,我们使用 getServerSideProps 函数进行了服务器端渲染,并使用 getStaticProps 函数进行了静态站点生成(SSG)。在 app 目录中,这两个函数已被废弃。如果我们希望优化应用程序,可以改为使用 Next.js 内置的 fetch API,它在组件级别控制数据获取和渲染。

获取数据

新的异步 fetch API 扩展了原生的 fetch 网络 API,并返回一个 promise。由于服务器组件只是导出的返回 JSX 元素的函数,我们可以将其声明为异步函数,然后使用 fetch 配合 async/await 模式。

这种模式很有优势,因为它允许我们仅为使用数据的部分获取数据,而不是为整个页面获取数据。这使我们能够利用 React 的特性,自动显示加载状态并优雅地捕获错误,正如在第 269 页的“探索项目结构”中讨论的那样。如果我们遵循这种模式,加载状态只会阻止特定服务器组件及其用户界面的渲染;页面的其余部分将完全功能化并可交互。

注意

客户端组件不应为异步函数,因为 JavaScript 处理异步调用的方式可能会导致多次重新渲染,从而减慢整个应用程序的速度。Next.js 开发者曾讨论过添加一个通用的钩子,使我们能够通过缓存结果在客户端组件中使用异步函数,但这个钩子尚未最终确定。如果您绝对需要客户端数据获取,建议使用像 SWR 这样的专用库,您可以在 <wbr>swr<wbr>.vercel<wbr>.app* 找到。*

你可能会担心,当每个服务器组件加载自己的数据时,最终会产生大量的请求。那么,这些请求的数量会如何影响整体页面性能呢?其实,Next.js 的 fetch 提供了多项优化,帮助加速应用程序。例如,它会自动缓存从服务器组件发送到同一 API 的 GET 请求的响应数据,从而减少请求的数量。

然而,POST 请求通常是不可缓存的,因为它们包含的数据可能会发生变化,因此 fetch 不会自动缓存这些请求。这对我们来说是一个问题,因为 GraphQL 通常使用 POST 请求。幸运的是,React 提供了一个 cache 函数,可以记住它所包装的函数的结果。列表 B-3 展示了如何使用 cache 与 GraphQL API 的示例。

import {cache} from 'react';

export const getUserFromGraphQL = cache(**async** (id:string) => {
    **return await fetch**("/graphql," {method: "POST", body: "query":" "});
}); 

列表 B-3:一个简单的缓存 POST API 调用大纲

我们将 API 调用包装在从 React 导入的 cache 函数中,并返回 API 的响应对象。需要注意的是,缓存的参数只能使用原始值,因为 cache 函数不会对参数进行深度比较。

我们可以实现的另一个优化是利用 fetch 的异步特性,以并行的方式请求服务器组件的数据,而不是按顺序请求。这里,最常见的模式是使用 Promise.all 同时启动所有请求,并阻塞渲染,直到所有请求完成。列表 B-4 展示了这种模式的相关代码。

const userPromiseOne = getUserFromGraphQL ("0001");
const userPromiseTwo = getUserFromGraphQL ("0002");

const [userDataOne, userDataTwo] = await Promise.all([userPromiseOne, userPromiseTwo]); 

列表 B-4:使用 Promise.all 的两个并行 API 调用

我们设置了两个请求,它们都返回一个 promise 用户对象。然后我们等待这两个 promise 的结果,并使用一个包含先前创建的异步 API 调用的数组来调用 Promise.all。当两个 promise 返回数据时,Promise.all 函数会解析,然后服务器组件的代码继续执行。

静态渲染

静态渲染是服务器和客户端组件的默认设置。它类似于静态网站生成,我们在第五章中使用了 getStaticProps。此渲染选项在构建时预渲染服务器环境中的客户端和服务器组件。因此,请求将始终返回相同的 HTML,该 HTML 保持静态且永远不会重新创建。

每种组件类型的渲染方式略有不同。对于客户端组件,服务器会预渲染 HTML 和 JSON 数据;客户端然后接收预渲染的数据,包括客户端脚本,以便为 HTML 添加交互性。对于服务器组件,浏览器仅接收渲染后的有效负载来激活组件。它们既没有客户端 JavaScript,也不使用 JavaScript 来进行激活;因此,它们不会向客户端发送任何 JavaScript,从而避免了打包脚本的膨胀。

列表 B-5 显示了如何静态渲染来自 列表 5-8 的 utils/fetch-names.ts 文件。

export default async function ServerComponentUserList(): Promise<JSX.Element> {
    const url = "https://www.usemodernfullstack.dev/api/v1/users";
    let data: responseItemType[] | [] = [];
    let names: responseItemType[] | [];
    try {
 **const response = await fetch(url, {cache: "force-cache"});**
        data = (await response.json()) as responseItemType[];
 } catch (err) {
        throw new Error("Failed to fetch data");
    }
    names = data.map((item) => {
        return {id: item.id, name: item.name};
    });

    return (
        <ul>
            {names.map((item) => (
                <li key="{item.id}">{item.name}</li>
            ))}
        </ul>
    );
} 

列表 B-5:使用静态渲染的服务器组件

首先,我们将服务器组件定义为一个异步函数,直接返回一个包装在 Promise 中的 JSX.Element。

在第五章中,我们返回了页面的数据,并使用页面的 props 将其传递给 NextPage 函数,在那里我们生成了元素。在这里,设置 url 后,我们使用异步的 fetch 函数从远程 API 获取数据。Next.js 会缓存 API 调用的结果和渲染后的组件,服务器将重用已生成的代码而不重新创建它。

如果你在没有显式缓存设置的情况下使用 fetch,它将使用 force-cache 作为默认设置进行静态渲染。要切换到增量静态再生,只需将 列表 B-5 中的 fetch 调用替换为 列表 B-6 中的调用。

 const response = await fetch(url, {**next: {revalidate: 20}**});

列表 B-6:用于 ISR 风格渲染的修改版 fetch 调用

我们只需添加 revalidate 属性,并将其值设置为 30。服务器将静态渲染组件,但会在首次页面请求后的 30 秒内使当前 HTML 失效,并重新渲染它。

动态渲染

动态渲染取代了 Next.js 传统的服务器端渲染(SSR),我们在第五章中通过导出页面路由中的 getServerSideProps 函数来实现 SSR。由于 Next.js 默认使用静态渲染,我们必须通过两种方式之一来主动选择使用动态渲染:要么在我们的 fetch 请求中禁用缓存,要么使用动态函数。在列表 B-7 中,我们禁用了缓存。

export default async function ServerComponentUserList(): Promise<JSX.Element> {
    const url = "https://www.usemodernfullstack.dev/api/v1/users";
    let data: responseItemType[] | [] = [];
    let names: responseItemType[] | [];
 try {
 **const response = await fetch(url, {cache: "no-cache"});**
        data = (await response.json()) as responseItemType[];
    } catch (err) {
        throw new Error("Failed to fetch data");
    }
    names = data.map((item) => {
        return {id: item.id, name: item.name};
    });

    return (
        <ul>
            {names.map((item) => (
                <li key="{item.id}">{item.name}</li>
            ))}
        </ul>
    );
} 

列表 B-7:通过禁用缓存使用动态渲染的服务器组件

我们显式地将 cache 属性设置为 no-cache。现在,服务器将在每次请求时重新获取该组件的数据。

除了禁用缓存外,我们还可以使用动态函数,包括在服务器组件中使用的 header 函数或 cookies 函数,以及在客户端组件中使用的 useSearchParams 钩子。这些函数使用在构建时未知的动态数据,如请求头、Cookies 和查询参数,这些数据是我们传递给函数的请求对象的一部分。服务器需要为每个请求运行这些函数,因为所需的数据依赖于请求。

请记住,动态渲染会影响整个路由。如果路由中的一个服务器组件选择使用动态渲染,Next.js 将在请求时动态渲染整个路由。

探索项目结构

让我们设置一个新的 Next.js 应用来探索我们已经讨论过的特性。首先,使用 npx create-next-app@latest 命令,并加上 --typescript --use-npm 标志来创建一个示例应用。在回答设置向导的问题时,选择使用 app 目录而不是 pages 目录。

注意

你也可以使用在线 Playground 在 <wbr>codesandbox<wbr>.io<wbr>/s<wbr>/ 上运行本附录中的 Next.js 代码示例。在那里创建新代码沙盒时,搜索官方的 Next.js (App router) 模板。

现在输入 npm run dev 命令以启动应用的开发模式。你应该能在浏览器中看到一个 Next.js 欢迎页面,网址是 http://localhost:3000。与第五章中你看到的欢迎页面不同,后者建议我们编辑 pages/index.tsx 文件,而这里的欢迎页面则指引我们去编辑 app/page.tsx 文件。

查看向导创建的文件和文件夹,并将它们与第五章中的文件进行对比。你应该会看到pagesstyles目录不再是新结构的一部分。相反,路由器将它们替换为app目录。在其中,你应该看不到_app.tsx文件和_document.tsx文件。而是使用根布局文件layout.tsx来定义所有渲染页面的 HTML 包装器,并使用page.tsx文件来渲染根段(主页)。

pages目录仅使用一个文件来创建页面路由的最终内容。相比之下,app目录使用多个文件来创建页面路由并添加额外的行为。

page.tsx文件生成用户界面和路由内容,其父文件夹定义了叶段。如果没有page.tsx文件,URL 路径将无法访问。然后,我们可以向页面的文件夹中添加其他特殊文件。Next.js 会自动将它们应用于该 URL 段及其子段。最重要的特殊文件包括layout.tsx,它创建通用用户界面;loading.tsx,它使用 React 悬挂边界在页面加载时自动创建一个“加载”用户界面;以及error.tsx,它使用 React 错误边界捕获错误,并显示自定义错误界面。

Figure B-1 比较了使用pages目录和app目录时,components/weather页面路由的文件和文件夹。

Figure B-1: 比较页面路由 components/weather 在 pages 和 app 目录结构中的比较

app目录是根文件夹时,其子文件夹仍然对应 URL 段,但现在包含page.tsx文件的文件夹定义了 URL 的最终叶段。旁边的可选特殊文件只会影响components/weather页面的内容。

让我们用app目录重建你在 Listing 5-1 中创建的components/weather页面路由。在app目录下创建components文件夹和weather子文件夹,然后将之前代码练习中的custom.d.ts文件复制到根文件夹。

更新 CSS

首先打开现有的app/globals.css文件,并将其内容替换为 Listing B-8 中的代码。我们需要做一些修改,以便在我们的组件中使用特殊文件。

html,
body {
    background-color: rgb(230, 230, 230);
    font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
        Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
    margin: 0;
    padding: 0;
}

a {
    color: inherit;
    text-decoration: none;
}

* {
    box-sizing: border-box;
}

nav {
    align-items: center;
    background-color: #fff;
    box-shadow: 0 0 10px rgba(0, 0, 0, 0.25);
    display: flex;
    height: 3rem;
    justify-content: space-evenly;
    padding: 0 25%;
}

main {
    display: flex;
    justify-content: center;
}

main .content {
    height: 300px;
    padding-top: 1.5rem;
    width: 400px;
}

main .content li {
    height: 1.25rem;
    margin: 0.25rem;
}

main .loading {
    animation: 1s loading linear infinite;
    background: #ddd linear-gradient(110deg, #eeeeee 0%, #f5f5f5 15%, #eeeeee 30%);
    background-size: 200% 100%;
    min-height: 1.25rem;
    width: 90%;
}

@keyframes loading {
    to {
        background-position-x: -200%;
    }
}
main .error {
    background: #ff5656;
    color: #fff;
}

section {
    background: #fff;
    border: 1px dashed #888;
    box-shadow: 0 0 10px rgba(0, 0, 0, 0.25);
    margin: 2rem;
    padding: 0.5rem;
    position: relative;
}

section .flag {
    background: #888;
    box-shadow: 0 0 10px rgba(0, 0, 0, 0.25);
    color: #fff;
    font-family: monospace;
    left: 0;
    padding: 0.25rem;
    position: absolute;
    top: 0;
    white-space: nowrap;
} 

Listing B-8: 包含基本样式的 app/globals.css 文件,用于我们的代码示例

我们创建一个nav元素用于导航,并在其下方定义一个main内容区域。然后,我们为稍后创建的加载和错误状态添加样式。此外,我们使用section元素来划定文件的边界,并使用flag样式为各部分添加标签。

定义布局

布局是服务器组件,用于定义特定路由段的用户界面。当该路由段处于活动状态时,Next.js 会渲染这个布局。布局是跨所有页面共享的,因此可以相互嵌套,且特定路由及其子路由的所有布局将在该路由段活动时一并渲染。图 B-2 显示了 URL、文件和components/weather路由组件层次之间的关系。

图 B-2:简化的布局组件层次结构

在这个示例中,每个文件夹包含一个layout.tsx文件。Next.js 会以嵌套的方式渲染这些布局,并将页面内容作为最终渲染的组件。

尽管我们可以在布局中获取数据,但无法在父布局和子布局之间共享数据。相反,我们可以利用fetch API 的自动去重功能,在每个子路由段或组件中重用数据。当我们从一个页面导航到另一个页面时,只有变化的布局会重新渲染。共享布局在其子路由段发生变化时不会被重新渲染。

根布局返回页面的骨架结构,包括html和body元素,这是必须的,而我们创建的所有其他布局都是可选的。让我们创建一个根布局。首先,在custom.d.ts文件的末尾添加一个新接口,该文件是我们从上一个练习中复制的。我们将使用LayoutProps接口来为布局的属性对象类型:

interface LayoutProps {
    children: React.ReactNode;
} 

现在打开app/layout.tsx文件,并将其内容替换为 Listing B-9 中的代码。

import "./globals.css";

export const metadata = {
    title: "Appendix C",
    description: "The Example Code",
};

export default function RootLayout(props: LayoutProps): JSX.Element {
    return (
        <html lang="en">
            <body>
                <section>
                    <span className="flag">app/layout(.tsx)</span>
                    {props.children}
                </section>
            </body>
        </html>
    );
} 

Listing B-9:文件 app/layout.tsx 定义了根布局。

我们导入了之前创建的global.css文件,然后通过metadata对象定义默认的 SEO 元数据、页面标题和页面描述。这替代了我们在pages目录中为所有页面使用的next/head组件。

然后我们定义了 RootLayout 组件,它接受一个 LayoutProps 类型的对象,并返回一个 JSX.Element。我们还创建了 JSX.Element,显式地添加了 htmlbody 元素,然后使用 section 和一个带有 CSS 类 flagspan 来勾画页面结构。我们从 LayoutProps 对象中添加了 children 属性,将它们包装在我们的根 HTML 结构中。

现在让我们在 app/componentsapp/components/weather 文件夹中添加可选布局。在每个文件夹中创建一个 layout.tsx 文件,然后将 Listing B-10 中的代码放入 app/components/layout.tsx 文件中。

export default function ComponentsLayout(props: LayoutProps): JSX.Element {
    return (
        <section>
            <span className="flag">app/components/layout(.tsx)</span>
            <nav>Navigation Placeholder</nav>
            <main>{props.children}</main>
        </section>
    );
} 

Listing B-10: 文件 app/components/layout.tsx 定义了段落布局。

该段落布局文件遵循与根布局相同的基本结构。我们定义了一个布局组件,它接收包含 children 属性的 LayoutProps 对象,并返回一个 JSX.Element。与根布局不同,我们只设置了内部结构,即带有导航占位符的 nav 元素,以及 main 内容区,在此区我们渲染来自 LayoutProps 对象的子元素,表示该段的子内容(叶子)。

最后,通过将 Listing B-11 中的代码添加到 app/components/weather/layout.tsx 文件中,创建叶子布局。

export default function WeatherLayout(props: LayoutProps): JSX.Element {
    return (
        <section>
            <span className="flag">app/components/weather/layout(.tsx)</span>
            {props.children}
        </section>
    );
} 

Listing B-11: 文件 app/components/weather/layout.tsx 定义了叶子布局。

叶子的布局类似于 Listing B-10 中的段落布局,但它返回的是一个更简单的 HTML 结构,因为 children 属性不包含另一个布局;而是包含页面的内容(在 page.tsx 中),以及来自 loading.tsxerror.tsx 的 suspense 边界和错误边界。

添加内容和路由

为了暴露页面路由,我们需要创建page.tsx文件;否则,如果我们尝试访问components/weather页面路由,网址为http://localhost:3000/components/weather,我们将看到 Next.js 的默认404错误页面。为了重新创建清单 5-1 中的页面内容,我们将创建两个文件。一个是component.tsx,它包含了WeatherComponent,另一个是page.tsx,它类似于我们在清单 5-1 中使用的NextPage包装器。当然,页面可以包含位于其他文件夹中的额外组件。

我们首先在apps/components/weather文件夹中创建component.tsx文件,并将清单 B-12 中的代码添加到其中。

"use client";

import {useState, useEffect} from "react";

export default function WeatherComponent(props: WeatherProps): JSX.Element {

    const [count, setCount] = useState(0);

    useEffect(() => {
        setCount(1);
    }, []);
    return (
        <h1 onClick={() => {setCount(count + 1)}} >
            The weather is {props.weather}, and the counter shows {count}
        </h1>
    );
} 

清单 B-12:文件 app/components/weather/component.tsx 定义了WeatherComponent。

这段代码与清单 5-1 中定义的WeatherComponent常量的代码相似,唯一不同的是我们添加了"use client"声明,明确将其设置为客户端组件,并将其作为默认函数导出,而不是存储在常量中。该组件本身与之前一样具有相同的功能:我们创建一个标题来显示天气字符串,并且可以通过点击标题来增加计数器。

现在我们添加page.tsx文件,并将清单 B-13 中的代码添加进去,以创建页面路由并将路由暴露给用户。

import WeatherComponent from "./component";

export const metadata = {
    title: "Appendix C - The Weather Component (Weather & Count)",
    description: "The Example Code For The Weather Component (Weather & Count)",
};

export default async function WeatherPage() {
    return (
        <section className="content">
            <span className="flag">app/components/weather/page(.tsx)</span>
            <WeatherComponent weather="sunny" />
        </section>
    );
} 

清单 B-13:文件 app/components/weather/page.tsx 定义了页面路由。

我们导入刚刚创建的WeatherComponent,然后在页面级别设置 SEO 元数据。接着,我们将页面路由作为默认的异步函数进行导出。当我们与清单 5-1 中的类似页面进行比较时,我们发现不再需要导出NextPage;相反,我们使用一个基本函数。app目录简化了代码结构。

现在在浏览器中访问我们的components/weather页面路由,网址为http://localhost:3000/components/weather。你应该看到一个页面,看起来类似于图 B-3。

请注意这里的两件事。首先,你应该能够认出来自第五章的组件,当我们点击标题时,它的计数器会增加。此外,我们在每个.tsx文件中添加的样式和span元素展示了文件之间的关系。我们可以看到,嵌套的布局文件类似于图 B-3 中的简化组件层次结构。

图 B-3:该 components/weather 页面显示嵌套组件

捕获错误

一旦我们向文件夹中添加了 error.tsx 文件,Next.js 会用一个 React 错误边界包装我们页面的内容。图 B-4 展示了添加了 error.tsx 文件的 components/weather 路由的简化组件层次结构。

图 B-4:简化的布局组件层次结构包括了错误边界。

我们看到 error.tsx 文件会自动在页面内容周围创建一个错误边界。通过这样做,Next.js 使我们能够在页面级别捕获错误,并优雅地处理这些错误,而不是冻结整个用户界面或将用户重定向到通用错误页面。可以把它看作是组件级别的 try...catch 块。现在,我们可以显示定制的错误信息,并展示一个按钮,让用户在不重新加载整个应用的情况下重新渲染页面内容到之前的正常状态。

error.tsx 文件导出了一个客户端组件,错误边界使用该组件作为回退界面。换句话说,当代码抛出错误并激活错误边界时,该组件会替换内容。一旦激活,它就会包含错误,确保边界上方的布局保持活动状态并维护其内部状态。错误组件接收 error 对象和 reset 函数作为参数。

让我们在 components/weather 路由中添加一个错误边界。从在 customs.d.ts 文件中添加一个新的 ErrorProps 接口开始,用于为组件的属性类型化:

interface ErrorProps {
    error: Error;
    reset: () => void;
} 

接下来,在 app/components/weather 目录中创建 error.tsx 文件,紧挨着 page.tsx 文件,并添加来自 列表 B-14 的代码。

"use client";

export default function WeatherError(props: ErrorProps): JSX.Element {
    return (
        <section className="content error">
            <span className="flag">app/components/weather/error(.tsx)</span>
            <h2>Something went wrong!</h2>
            <blockquote>{props.error?.toString()}</blockquote>
            <button onClick={() => props.reset()}>Try again (re-render)</button>
        </section>
    );
} 

列表 B-14:文件 app/components/weather/error.tsx 添加了错误边界和回退 UI。

因为我们知道错误组件需要是客户端组件,所以我们在文件顶部添加了 "use client" 指令,然后定义并导出该组件。我们使用刚才创建的 ErrorProps 接口来定义组件的属性类型。接着,我们将 error 属性转换为字符串,并将其显示出来,以便告知用户发生了什么类型的错误。最后,我们渲染了一个按钮,调用组件通过属性对象传递的 reset 函数。用户可以通过点击按钮将组件重新渲染到之前的正常状态。

现在,错误边界已经设置好,我们将修改 component.tsx,使其在计数器达到 4 或更多时抛出错误。打开该文件,并在第一个 useEffect 钩子下添加 列表 B-15 中的代码。

 useEffect(() => {
        if (count && count >= 4) {
            throw new Error("Count >= 4! ");
        }
    }, [count]); 

列表 B-15:app/components/weather/component.tsx 的额外 useEffect 钩子

我们为组件添加的额外 useEffect 钩子非常简单;每当 count 变量发生变化时,我们会验证错误条件,一旦变量值达到 4 或更多,我们就会抛出一个错误,信息为 Count >= 4!,该错误被错误边界捕获,并通过显示 error.tsx 文件导出的回退用户界面优雅地处理。

要测试此功能,请在浏览器中打开 http://localhost:3000/components/weather 并点击标题,直到触发错误。你应该看到错误组件而非天气组件,如同 图 B-5 所示。

图 B-5: components/weather 页面的错误状态

布局标记告诉我们,error.tsx 已经替代了 page.tsx。我们还看到了字符串 Error: Count >=4!,这是我们传递给错误构造函数的内容。当我们点击重新渲染按钮时,page.tsx 应该替代 error.tsx,屏幕会恢复到 图 B-4 的状态。

显示可选加载界面

现在我们将创建loading.tsx文件。通过这个功能,Next.js 会自动将页面内容包装在一个 React 悬挂组件中,创建一个类似于图 B-6 的组件层次结构。

图 B-6:带有加载界面的简化布局组件层次结构

loading.tsx文件是一个基本的服务器组件,返回预渲染的加载用户界面。当我们加载页面或在页面之间导航时,Next.js 会在加载新内容时立即显示此组件。一旦渲染完成,运行时会将加载状态替换为新内容。通过这种方式,我们可以轻松显示有意义的加载状态,例如骨架屏或自定义动画。

让我们通过将清单 B-16 中的代码添加到loading.tsx文件中,向天气组件路由添加一个基本的加载用户界面。

export default function WeatherLoading(): JSX.Element {
    return (
        <section className="content">
            <span className="flag">app/components/weather/loading(.tsx)</span>
            <h1 className="loading"></h1>
        </section>
    );
} 

清单 B-16:文件 app/components/weather/loading.tsx 添加了一个带有加载用户界面的悬挂边界。

我们定义并导出了WeatherLoading组件,该组件返回一个JSX.Element。在 HTML 中,我们添加了一个类似于page.tsx中的标题元素,唯一不同的是,这个标题元素添加了我们在global.css文件中创建的加载类,并显示一个动画占位符。

当我们在浏览器中打开http://localhost:3000/components/weather时,我们应该会看到一个类似于图 B-7 的加载界面。

图 B-7:加载页面内容时的 components/weather 页面

如果你没有看到动画占位符,这意味着 Next.js 已经缓存了你的段内容。

添加一个获取远程数据的服务器组件

现在你已经理解了app目录中的文件夹和文件,让我们添加一个使用fetch API 从远程 API https://www.usemodernfullstack.dev/api/v1/users 获取用户列表并将其渲染到浏览器的服务器组件。我们在第五章中编写了此代码的一个版本。

创建文件夹app/components/server-component,并在其中添加特殊文件component.tsxloading.tsxerror.tsxlayout.tsxpage.tsx。然后,通过将清单 B-17 中的代码添加到component.tsx文件中,设置组件的功能。

export default async function ServerComponentUserList(): Promise<JSX.Element|Error> {
    const url = "https://www.usemodernfullstack.dev/api/v1/users";
    let data: responseItemType[] | [] = [];
    let names: responseItemType[] | [];
    try {
        const response = await fetch(url, {cache: "force-cache"});
        data = (await response.json()) as responseItemType[];
 } catch (err) {
        throw new Error("Failed to fetch data");
    }
    names = data.map((item) => {
        return {id: item.id, name: item.name};
    });

    return (
        <ul>
            {names.map((item) => (
                <li id="{item.id}" key="{item.id}">
                    {item.name}
                </li>
            ))}
        </ul>
    );
} 

清单 B-17:app/components/server-component/component.tsx 文件

在这里,我们创建了一个默认的服务器组件,使用fetch API 来await API 响应。为了实现这一点,我们将其定义为一个异步函数,该函数返回一个JSX.Element或一个Error。接着,我们将 API 端点存储在常量中,并定义后续需要的变量。我们将 API 调用包装在try...catch语句中,以便在 API 请求失败时激活Error Boundary。然后,我们以类似于第五章中处理数据的方式进行数据转换,并返回一个显示用户列表的JSX.Element。

现在,我们添加 Next.js 在等待 API 响应和组件的 JSX 响应时自动显示的加载用户界面。将清单 B-18 中的代码放入loading.tsx文件中。

export default function ServerComponentLoading(): JSX.Element {
    return (
        <section className="content">
            <span className="flag">
                app/components/server-component/loading(.tsx)
            </span>
            <ul id="load">
                {[...new Array(10)].map((item, i) => (
                    <li className="loading"></li>
                ))}
            </ul>
        </section>
    );
} 

清单 B-18:app/components/server-component/loading.tsx 文件

与之前一样,加载组件是一个服务器组件,返回一个JSX.Element。这次,加载骨架是一个包含 10 个项的列表,类似于组件呈现的 HTML 结构。你会发现,这可以给用户一个良好的预期内容印象,并应当改善用户体验。

接下来,我们通过将清单 B-19 中的代码添加到error.tsx文件中来创建错误边界。

"use client"; // Error components must be Client components

export default function ServerComponentError(props: ErrorProps): JSX.Element {
    return (
        <section className="content">
            <span className="flag">app/components/server-component/error(.tsx)</span>
            <h2>Something went wrong!</h2>
            <code>{props.error?.toString()}</code>
            <button onClick={() => props.reset()}>Try again (re-render)</button>
        </section>
    );
} 

清单 B-19:app/components/server-component/error.tsx 文件

除了概述文件结构的标志外,错误边界与我们在天气组件中使用的类似。

然后,我们将清单 B-20 中的代码添加到layout.tsx文件中。

export default function ServerComponentLayout(props: LayoutProps): JSX.Element {
    return (
        <section>
            <span className="flag">app/components/server-component/layout(.tsx)</span>
            {props.children}
        </section>
    );
} 

清单 B-20:app/components/server-component/layout.tsx 文件

再次强调,代码与我们在天气组件中使用的代码类似。我们只调整了概述组件层次结构的标志。

最后,所有部分就位后,我们将清单 B-21 中的代码添加到page.tsx文件中,以暴露页面路由。

import ServerComponentUserList from "./component";

export const metadata = {
    title: "Appendix C - Server Side Component (User API)",
    description: "The Example Code For A Server Side Component (User API)",
};

export default async function ServerComponentUserListPage(): JSX.Element {
    return (
        <section className="content">
            <span className="flag">app/components/server-component/page(.tsx)</span>
            {/* @ts-expect-error Async Server Component */}
 <ServerComponentUserList />
        </section>
    );
} 

清单 B-21:app/components/server-component/page.tsx 文件

完成带有导航的应用程序

在应用程序中有了两个页面后,我们现在可以使用next/link组件来替换

posted @ 2025-11-30 19:38  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报