React 现代全栈项目(四)
原文:
zh.annas-archive.org/md5/698b69ebe010bfc0cb8e00bb1fbda841译者:飞龙
第十一章:使用 GraphQL API 构建后端
到目前为止,我们只与 REST API 进行交互。对于具有深层嵌套对象的更复杂 API,我们可以使用 GraphQL 来允许对大型对象的部分进行选择性访问。在本章中,我们首先将学习什么是 GraphQL 以及它在何时有用。然后,我们将尝试制作 GraphQL 查询和突变。之后,我们将实现后端的 GraphQL。最后,我们将简要介绍高级 GraphQL 概念。
在本章中,我们将涵盖以下主要主题:
-
什么是 GraphQL?
-
在后端实现 GraphQL API
-
实现 GraphQL 身份验证和突变
-
高级 GraphQL 概念概述
技术要求
在我们开始之前,请从第一章**,准备全栈开发和第二章**,了解 Node.js和MongoDB中安装所有要求。
那些章节中列出的版本是书中使用的版本。虽然安装较新版本不应有问题,但请注意,某些步骤在较新版本上可能有所不同。如果您在这本书提供的代码和步骤中遇到问题,请尝试使用第一章和第二章中提到的版本。
您可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch11。
如果您克隆了本书的完整仓库,Husky 在运行npm install时可能找不到.git目录。在这种情况下,只需在相应章节文件夹的根目录中运行git init。
本章的 CiA 视频可以在:youtu.be/6gP0uM-XaVo找到。
什么是 GraphQL?
在我们学习如何使用 GraphQL 之前,让我们首先关注什么是 GraphQL。像 REST 一样,它是一种查询 API 的方式。然而,它远不止于此。GraphQL 包括一个服务器端运行时来执行查询,以及一个类型系统来定义你的数据。它与许多数据库引擎兼容,并且可以集成到现有的后端中。
GraphQL 服务是通过定义类型(如User类型)、类型上的字段(如username字段)以及解析字段值的函数来创建的。假设我们已定义以下带有获取用户名的函数的User类型:
type User {
username: String
}
function User_username(user) {
return user.getUsername()
}
我们可以定义一个Query类型和一个获取当前用户的函数:
type Query {
currentUser: User
}
function Query_currentUser(req) {
return req.auth.user
}
信息
查询类型是一个特殊类型,它定义了 GraphQL 模式的“入口点”。它允许我们定义哪些字段可以使用 GraphQL API 进行查询。
现在我们已经定义了带有字段和解析这些字段的功能的类型,我们可以进行一个 GraphQL 查询来获取当前用户的用户名。GraphQL 查询看起来像 JavaScript 对象,但它们只列出你想要查询的字段名。然后,GraphQL API 将返回一个具有与查询相同结构的 JavaScript 对象,但填充了值。让我们看看获取当前用户用户名的查询将是什么样子:
{
currentUser {
username
}
}
那个查询将返回一个类似以下的 JSON 结果:
{
"data": {
"currentUser": {
"username": "dan"
}
}
}
如您所见,结果与查询具有相同的形状。这是 GraphQL 的一个基本概念:客户端可以具体请求它需要的字段,服务器将返回确切的这些字段。如果我们需要更多关于用户的数据,我们只需向类型中添加新的字段并查询即可。
GraphQL 会验证查询和结果是否符合定义的类型。这确保了我们不会破坏客户端和服务器之间的契约。GraphQL 类型充当客户端和服务器之间的契约。在验证查询后,它由 GraphQL 服务器执行,然后返回一个与查询请求的形状完全相同的结果。每个请求的字段在服务器上执行一个函数。这些函数被称为解析器。
图 11.1 – GraphQL 客户端与服务器之间的交互
类型和方法也可以深层嵌套。例如,一个用户可能有一个字段,返回该用户是作者的帖子。然后我们可以对那些帖子对象中的字段进行子选择。这对于对象中的对象以及对象中的对象数组的嵌套级别也是适用的。GraphQL 将继续解析字段,直到只剩下简单的值(标量),如字符串和数字。例如,以下查询可以获取当前用户创建的所有帖子的 ID 和标题:
{
currentUser {
username
posts {
id
title
}
}
}
此外,GraphQL 允许我们为字段定义参数,这些参数将被传递到解析我们字段的函数中。我们可以使用参数,例如,获取所有带有特定标签的帖子。在 GraphQL 中,我们可以向任何字段传递参数,即使它们是深层嵌套的。参数甚至可以传递给单个值字段,例如,用于转换值。例如,以下查询将根据 ID 获取帖子并返回帖子标题:
{
postById(id: "1234") {
title
}
}
如果你自己构建后端或者有这个想法,GraphQL 特别有用,因为它可以允许查询深层嵌套和相互关联的数据。然而,如果你无法控制现有的 REST 后端,通常不值得添加 GraphQL 作为单独的、独立的层,因为它的基于模式的限制。
在了解了查询之后,让我们继续学习突变。
突变
在 REST 中,任何请求都可能导致副作用(例如将数据写入数据库)。但是,正如我们所学的,GET 请求应该只返回数据,而不应引起此类副作用。只有 POST/PUT/PATCH/DELETE 请求应该导致数据库中的数据发生变化。在 GraphQL 中,有一个类似的概念:理论上,任何字段函数都可能改变数据库状态。然而,在 GraphQL 中,我们定义一个突变而不是查询来明确表示我们想要改变数据库状态。除了用 mutation 关键字定义外,突变与查询具有相同的结构。尽管如此,有一个区别:查询并行获取字段,而突变按顺序运行,首先执行第一个字段函数,然后是下一个,依此类推。这种行为确保我们在突变中不会出现竞争条件。
信息
除了内置的 Query 类型外,还有一个 Mutation 类型来定义允许的突变字段。
现在我们已经了解了 GraphQL 是什么以及它是如何工作的基础知识,让我们开始在实际的博客应用程序后端中实现 GraphQL!
在后端实现 GraphQL API
现在我们将设置 GraphQL 在我们现有的博客应用程序后端,除了 REST API 之外。这样做将允许我们看到 GraphQL 与 REST API 的比较和差异。按照以下步骤开始设置后端上的 GraphQL:
-
将现有的 ch10 文件夹复制到一个新的 ch11 文件夹中,如下所示:
$ cp -R ch10 ch11 -
在 VS Code 中打开 ch11 文件夹。
-
首先,让我们安装一个 VS Code 扩展来添加 GraphQL 语言支持。转到 Extensions 选项卡,搜索由 GraphQL 基金会开发的 GraphQL.vscode-graphql 扩展。安装扩展。
-
接下来,使用以下命令在后端安装 graphql 和 @apollo/server 库:
$ cd backend/ $ npm install graphql@16.8.1 @apollo/server@4.10.0Apollo Server 是一个生产就绪的 GraphQL 服务器实现,支持多个后端 Web 框架,包括 Express。
-
创建一个新的 backend/src/graphql/ 文件夹。在其内部,创建一个 backend/src/graphql/query.js 文件,在其中我们定义一个 Query 模式,这是我们的 GraphQL API 的入口点(列出后端支持的所有查询),如下所示:
export const querySchema = `#graphql type Query { test: String } `在模板字符串的开头添加一个
#graphql指令是很重要的,这样字符串就会被识别为 GraphQL 语法,并在代码编辑器中正确高亮显示。在我们的模式中,我们定义了一个test字段,现在我们为它定义一个解析器。 -
定义一个包含将 test 字段解析为静态字符串的函数的 queryResolver 对象:
export const queryResolver = { Query: { test: () => { return 'Hello World from GraphQL!' }, }, } -
创建一个新的 backend/src/graphql/index.js 文件,并在其中导入 querySchema 和 queryResolver:
import { querySchema, queryResolver } from './query.js' -
然后,导出一个名为 typeDefs 的数组,它包含所有模式(目前只包含查询模式),以及一个名为 resolvers 的数组,它包含所有解析器(目前只包含查询解析器):
export const typeDefs = [querySchema] export const resolvers = [queryResolver] -
编辑 backend/src/app.js 并从 @apollo/server 库导入 ApolloServer 和 expressMiddleware:
import { ApolloServer } from '@apollo/server' import { expressMiddleware } from '@apollo/server/express4' -
然后,导入 typeDefs 和 resolvers:
import { typeDefs, resolvers } from './graphql/index.js' -
在所有其他中间件和路由定义之前,使用模式类型定义和定义的解析器创建一个新的 Apollo 服务器:
const apolloServer = new ApolloServer({ typeDefs, resolvers, }) -
然后,在服务器准备就绪后,将 expressMiddleware 挂载到 /graphql 路由上,如下所示:
apolloServer .start() .then(() => app.use('/graphql', expressMiddleware(apolloServer))) -
通过运行以下命令以开发模式启动后端:
$ npm run dev -
在您的浏览器中转到 **http://localhost:3001/graphql**;您应该看到左侧的 Apollo 接口,可以输入查询,以及右侧的结果。
-
从左侧编辑器的所有注释中删除并输入以下 GraphQL 查询:
query ExampleQuery { test } -
按下 Play 按钮,运行查询,您将看到以下结果:
图 11.2 – 我们第一次 GraphQL 查询的成功执行!
如您所见,我们对 test 字段的查询返回了我们之前定义的静态字符串!
在实现基本字段之后,让我们实现一些访问我们的服务函数并从 MongoDB 获取数据的字段。
实现查询帖子的字段
按照以下步骤实现查询帖子的字段:
-
编辑 backend/src/graphql/query.js 并导入相关的服务函数:
import { getPostById, listAllPosts, listPostsByAuthor, listPostsByTag, } from '../services/posts.js' -
调整模式以包含一个 posts 字段,它返回一个帖子数组:
export const querySchema = `#graphql type Query { test: String [Type] syntax means that something is an array of Type. We will define the Post type later. Type! is the non-null modifier and means that a type is not null (required), so [Type!] means that each element is a Type, and not null (the array can still be empty, though). [Type!]! means that the array will always exist and never be null (but the array can still be empty). -
此外,定义用于通过 author 和 tag 查询帖子的字段,这两个字段都接受一个必需的参数:
postsByAuthor(username: String!): [Post!]! postsByTag(tag: String!): [Post!]! -
最后,定义一个通过 id 查询帖子的字段:
postById(id: ID!): Post } ` -
现在我们已经定义了模式,我们仍然需要为所有这些字段提供解析器。多亏了我们的服务函数,这相当简单:我们可以在 async 函数中简单地调用我们的服务函数,并使用相关参数,如下所示:
export const queryResolver = { Query: { test: () => { return 'Hello World from GraphQL!' }, posts: async () => { return await listAllPosts() }, postsByAuthor: async (parent, { username }) => { return await listPostsByAuthor(username) }, postsByTag: async (parent, { tag }) => { return await listPostsByTag(tag) }, postById: async (parent, { id }) => { return await getPostById(id) }, }, }解析器函数始终将
parent对象作为第一个参数,将所有参数作为第二个参数的对象接收。
现在我们已经成功定义了查询帖子的字段。然而,Post 类型尚未定义,因此我们的 GraphQL 查询目前还不能工作。让我们接下来做这件事。
定义 Post 类型
在定义 Query 类型之后,我们继续定义 Post 类型,如下所示:
-
创建一个新的 backend/src/graphql/post.js 文件,其中我们导入 getUserInfoById 函数以稍后解析帖子的作者:
import { getUserInfoById } from '../services/users.js' -
然后,定义 postSchema。注意,Post 由 id、title、author、contents、tags 以及 createdAt 和 updatedAt 时间戳组成:
export const postSchema = `#graphql type Post { id: ID! title: String! author: User contents: String tags: [String!] createdAt: Float updatedAt: Float } `在这种情况下,我们使用
[String!]作为标签,而不是[String!]!,因为tags字段也可以不存在/null。createdAt和updatedAt时间戳太大,无法放入 32 位有符号整数中,因此它们的类型需要是Float而不是Int。 -
接下来,定义一个用于获取用户的服务函数的 author 字段的解析器:
export const postResolver = { Post: { author: async (post) => { return await getUserInfoById(post.author) }, }, }获取帖子的解析器已经是
Query模式的组成部分,所以我们不需要在这里定义如何获取帖子。GraphQL 知道查询字段返回Post数组,然后允许我们进一步解析帖子上的字段。 -
编辑 backend/src/graphql/index.js 并添加 postSchema 和 postResolver:
import { querySchema, queryResolver } from './query.js' import { postSchema, postResolver } from './post.js' export const typeDefs = [querySchema, postSchema] export const resolvers = [queryResolver, postResolver]
在定义了 Post 类型之后,让我们继续定义 User 类型。
定义用户类型
当定义 Post 类型时,我们使用了 User 类型来定义帖子的作者。然而,我们尚未定义 User 类型。现在让我们来做这件事:
-
创建一个新的 backend/src/graphql/user.js 文件,并将 listPostsByAuthor 函数导入到这里,因为我们将要添加一种解析用户对象时获取用户帖子的方式,以展示 GraphQL 如何处理深度嵌套的关系:
import { listPostsByAuthor } from '../services/posts.js' -
定义 userSchema。在我们的 GraphQL 模式中,每个 User 都有 username 和一个 posts 字段,我们将解析用户所写的所有帖子:
export const userSchema = `#graphql type User { username: String! posts: [Post!]! } `
信息
我们在这里没有指定任何其他属性,因为我们只在我们 getUserInfoById 服务函数中返回用户名。如果我们还想在这里获取用户 ID,我们就必须从该函数中返回它。我们不是返回完整的用户对象,因为这可能是一个潜在的安全漏洞,暴露内部数据,如密码(或某些应用程序中的账单信息)。
-
接下来,定义 userResolver,它获取当前用户的所有帖子:
export const userResolver = { User: { posts: async (user) => { return await listPostsByAuthor(user.username) }, }, } -
编辑 backend/src/graphql/index.js 并添加 userSchema 和 userResolver:
import { querySchema, queryResolver } from './query.js' import { postSchema, postResolver } from './post.js' import { userSchema, userResolver } from './user.js' export const typeDefs = [querySchema, postSchema, userSchema] export const resolvers = [queryResolver, postResolver, userResolver]
在定义了 User 类型之后,让我们尝试一些深度嵌套的查询!
尝试深度嵌套的查询
现在我们已经成功定义了我们的 GraphQL 模式和解析器,我们可以开始使用 GraphQL 查询我们的数据库了!
例如,我们现在可以获取所有帖子的完整列表,包括它们的 ID、标题和作者的用户名,如下所示:
query GetPostsOverview {
posts {
id
title
author {
username
}
}
}
在 Apollo 接口中执行前面的查询。正如我们所看到的,查询获取所有帖子,为每个帖子选择 id、title 和 author,然后为每个 author 实例解析 username。这个查询允许我们在单个请求中获取我们需要的所有数据,我们不再需要单独的请求来解析作者的用户名了!
信息
我们没有在 User 类型中指定 密码 字段,所以 GraphQL 不会允许我们访问它,即使解析器函数返回一个包含密码的用户对象。
现在,让我们尝试一个通过 ID 获取帖子并然后找到相同作者的其他帖子的查询。这可以用来,例如,在某人阅读完帖子后推荐同一作者的其他文章查看:
-
我们可以通过清空操作文本框的内容,然后在左侧的文档侧边栏中选择根类型下的查询,在 Apollo 接口中自动生成一个查询。现在点击左侧postById字段旁边的**+**按钮,它会自动为我们定义一个查询变量,看起来如下所示:
query PostById($postByIdId: ID!) { postById(id: $postByIdId) {
图 11.3 – 使用 Apollo 接口自动生成查询
-
在帖子内部,我们现在可以获取帖子的标题、内容和作者值:
title contents author { -
在作者字段内部,我们获取用户名以及他们的帖子 ID 和标题:
username posts { id title } } } } -
在 Apollo 接口的底部,有一个变量部分,我们需要在其中填写数据库中存在的 ID:
{ "postByIdId": "<ENTER ID FROM DATABASE>" } -
运行查询,你会看到帖子及其作者被解析,并且该作者所写的所有帖子也被正确列出,如下面的截图所示:
图 11.4 – 在 GraphQL 中运行深度嵌套查询
接下来,让我们学习如何通过定义输入类型为字段提供参数。
实现输入类型
我们已经学习了如何在 GraphQL 中定义常规类型,但如果我们有一个通用的方式为字段提供参数呢?例如,查询帖子的选项总是相同的(sortBy 和 sortOrder)。我们不能使用常规类型,相反,我们需要定义一个输入类型。按照以下步骤在 GraphQL 中实现查询选项:
-
编辑 backend/src/graphql/query.js 并在模式中定义一个输入类型:
export const querySchema = `#graphql input PostsOptions { sortBy: String sortOrder: String } -
然后,使用输入类型作为字段的参数,如下所示:
type Query { test: String posts(options: PostsOptions): [Post!]! postsByAuthor(username: String!, options: PostsOptions): [Post!]! postsByTag(tag: String!, options: PostsOptions): [Post!]! postById(id: ID!, options: PostsOptions): Post } ` -
现在,编辑解析器以传递选项到服务函数:
posts: async (parent, { options }) => { return await listAllPosts(options) }, postsByAuthor: async (parent, { username, options }) => { return await listPostsByAuthor(username, options) }, postsByTag: async (parent, { tag, options }) => { return await listPostsByTag(tag, options) }, -
尝试以下查询以查看帖子是否按正确顺序排序:
query SortedPosts($options: PostsOptions) { posts(options: $options) { id title createdAt updatedAt } } -
设置以下变量:
{ "options": { "sortBy": "updatedAt", "sortOrder": "ascending" } } -
通过按下播放按钮运行查询,你应该会看到响应按updatedAt时间戳升序排序!
现在我们已经成功实现了使用 GraphQL 查询数据库的功能,接下来让我们继续实现使用 GraphQL 创建新帖子的方法。
实现 GraphQL 认证和突变
我们现在将实现使用 GraphQL 创建新帖子的方法。为了定义改变数据库状态的字段,我们需要在 mutation 类型下创建它们。然而,在我们这样做之前,我们首先需要在 GraphQL 中实现认证,这样我们就可以在创建帖子时访问当前登录的用户。
将认证添加到 GraphQL
因为我们在使用 Express 与 GraphQL,所以我们可以使用任何 Express 中间件与 GraphQL,并将其传递给我们的解析器作为 context。因此,我们可以使用现有的 express-jwt 中间件来解析 JWT。现在让我们开始为 GraphQL 添加认证:
-
我们当前的requireAuth中间件配置确保用户已登录,如果他们未登录则抛出错误。然而,当将auth上下文传递给 GraphQL 时,这是一个问题,因为并非所有查询都需要身份验证。我们现在将创建一个新的optionalAuth中间件,它不需要凭证来处理请求。编辑backend/src/middleware/jwt.js并定义以下新中间件:
export const optionalAuth = expressjwt({ secret: () => process.env.JWT_SECRET, algorithms: ['HS256'], credentialsRequired: false, }) -
现在,编辑backend/src/app.js,并在其中导入optionalAuth中间件:
import { optionalAuth } from './middleware/jwt.js' -
编辑我们定义**/graphql路由的app.use()调用,并向其中添加optionalAuth**中间件,类似于我们对路由所做的那样:
apolloServer.start().then(() => app.use( '/graphql', optionalAuth, -
然后,向 Apollo expressMiddleware添加第二个参数,定义一个context函数,该函数将req.auth作为上下文提供给 GraphQL 解析器:
expressMiddleware(apolloServer, { context: async ({ req }) => { return { auth: req.auth } }, }), ), )
接下来,让我们继续在 GraphQL 中实现突变。
实现突变
现在我们已经为 GraphQL 添加了身份验证,我们可以定义我们的突变。按照以下步骤创建注册、登录和创建帖子的突变:
-
创建一个新的backend/src/graphql/mutation.js文件,并导入GraphQLError(在用户未登录时抛出UNAUTHORIZED错误),以及createUser、loginUser和createPost函数:
import { GraphQLError } from 'graphql' import { createUser, loginUser } from '../services/users.js' import { createPost } from '../services/posts.js' -
定义mutationSchema,在其中我们首先定义注册和登录用户的字段。signupUser字段返回一个用户对象,而loginUser字段返回一个 JWT:
export const mutationSchema = `#graphql type Mutation { signupUser(username: String!, password: String!): User loginUser(username: String!, password: String!): String -
然后,定义一个字段,用于从给定的标题、内容(可选)和标签(可选)创建一个新的帖子。它返回一个新创建的帖子:
createPost(title: String!, contents: String, tags: [String]): Post } ` -
定义解析器,在其中我们首先定义signupUser和loginUser字段,它们相当直接:
export const mutationResolver = { Mutation: { signupUser: async (parent, { username, password }) => { return await createUser({ username, password }) }, loginUser: async (parent, { username, password }) => { return await loginUser({ username, password }) }, -
接下来,我们定义createPost字段。在这里,我们首先访问传递给字段的参数,并且作为解析函数的第三个参数,我们得到之前创建的上下文:
createPost: async (parent, { title, contents, tags }, { auth }) => { -
如果用户未登录,auth上下文将为null。在这种情况下,我们会抛出一个错误,并且不会创建新的帖子:
if (!auth) { throw new GraphQLError( 'You need to be authenticated to perform this action.', { extensions: { code: 'UNAUTHORIZED', }, }, ) } -
否则,我们使用auth.sub(其中包含用户 ID)和提供的参数来创建一个新的帖子:
return await createPost(auth.sub, { title, contents, tags }) }, }, } -
编辑backend/src/graphql/index.js,并添加mutationSchema和mutationResolver:
import { querySchema, queryResolver } from './query.js' import { postSchema, postResolver } from './post.js' import { userSchema, userResolver } from './user.js' import { mutationSchema, mutationResolver } from './mutation.js' export const typeDefs = [querySchema, postSchema, userSchema, mutationSchema] export const resolvers = [ queryResolver, postResolver, userResolver, mutationResolver, ]
在实现突变之后,让我们学习如何使用它们。
使用突变
在定义可能的突变之后,我们可以在 Apollo 界面中运行它们。按照以下步骤首先注册一个用户,然后登录,最后创建一个帖子——所有这些操作都使用 GraphQL:
-
前往**http://localhost:3001/graphql**查看 Apollo 界面。定义一个新的突变,用于使用给定的用户名和密码注册用户,并在注册成功时返回用户名:
mutation SignupUser($username: String!, $password: String!) { signupUser(username: $username, password: $password) { username } }
小贴士
你可以通过回到 Root Types,点击 Mutation,然后点击 signupUser 旁边的 + 图标来使用左侧的 Documentation 部分。然后,点击 username 字段旁边的 + 图标。这将自动创建前面的代码。
-
编辑底部的变量并输入用户名和密码:
{ "username": "graphql", "password": "gql" } -
通过按播放按钮执行 SignupUser 突变。
-
接下来,创建一个新的突变来登录用户:
mutation LoginUser($username: String!, $password: String!) { loginUser(username: $username, password: $password) } -
输入与之前相同的变量并按播放按钮,响应包含 JWT。复制并存储 JWT 以供以后使用。
-
定义一个新的突变来创建帖子。这个突变返回 Post,因此我们可以获取 id、title 和 author 的 username 值:
mutation CreatePost($title: String!, $contents: String, $tags: [String]) { createPost(title: $title, contents: $contents, tags: $tags) { id title author { username } } }这就是 GraphQL 真正发光的地方。在创建帖子后,我们可以解析作者的用户名,以查看它是否真的是由正确的用户创建的,因为我们可以访问为
Post定义的解析器,即使在突变中也可以!正如你所看到的,GraphQL 非常灵活。 -
输入以下变量:
{ "title": "GraphQL Post", "contents": "This is posted from GraphQL!" } -
选择 Headers 选项卡,点击 New header 按钮,输入 Authorization 作为 header key,并将 Bearer <粘贴之前复制的 JWT> 作为 value。然后点击 Play 按钮提交突变。
图 11.5 – 在 Apollo 接口中添加授权头
- 在响应中,你可以看到帖子已成功创建,作者已正确设置和解析!
在为我们的博客应用程序实现了 GraphQL 查询和突变之后,让我们通过概述高级 GraphQL 概念来结束本章。
高级 GraphQL 概念概述
默认情况下,GraphQL 附带一组标量类型:
-
Int:有符号的 32 位整数
-
Float:有符号的双精度浮点值
-
String:UTF-8 编码的字符序列
-
Boolean:可以是 true 或 false
-
ID:一个唯一的标识符,序列化为 String,但表示它不是人类可读的
GraphQL 还允许定义枚举,枚举是一种特殊的标量类型。它们被限制在特定的值范围内。例如,我们可以定义以下枚举来区分不同类型的帖子:
enum PostType {
UNPUBLISHED,
UNLISTED,
PUBLIC
}
在 Apollo 中,枚举将被处理为只能具有特定值的字符串,但在其他 GraphQL 实现中可能会有所不同。
许多 GraphQL 实现也允许定义自定义标量类型。例如,Apollo 支持自定义标量类型的定义。
片段
当同类型的字段经常被访问时,我们可以创建一个片段来简化并标准化对这些字段的访问。例如,如果我们经常解析用户,并且用户有 username、profilePicture、fullName 和 biography 等字段,我们可以创建以下片段:
fragment UserInfo on User {
username
profilePicture
fullName
biography
}
这个片段可以在查询中使用。例如,看看这个片段:
{
posts {
author {
...UserInfo
}
}
}
当相同的字段结构在同一个查询中多次使用时,片段特别有用。例如,如果一个作者有 followedBy 和 follows 字段,我们可以这样解析所有用户:
{
posts {
author {
...UserInfo
followedBy {
...UserInfo
}
follows {
...UserInfo
}
}
}
}
查询反射
查询反射使我们能够查询定义的架构本身,以了解服务器为我们提供的数据。本质上,这是查询由 GraphQL 服务器定义的架构。我们可以使用 __schema 字段来获取所有架构。架构由 types 组成,这些 types 有 name 值。
例如,我们可以使用以下查询来获取我们服务器上定义的所有类型:
{
__schema {
types {
name
}
}
}
如果你在我们服务器上执行此查询,你将获得(包括其他类型)我们定义的 Query、Post、User 和 Mutation 类型。
查询反射非常强大,你可以从中获取有关可能查询和突变的大量信息。实际上,Apollo 接口使用反射来渲染 文档 侧边栏,并为我们自动完成字段!
摘要
在本章中,我们学习了 GraphQL 是什么以及它如何比 REST 更灵活,同时需要更少的样板代码,尤其是在查询深度嵌套对象时。然后,我们在后端实现了 GraphQL 并创建了各种类型、查询和突变。我们还学习了如何在 GraphQL 中集成 JWT 身份验证。最后,我们通过学习高级概念,如类型系统、片段和查询反射来结束本章。
在下一章,第十二章“使用 Apollo 客户端在前端与 GraphQL 交互”,我们将学习如何使用 React 和 Apollo 客户端库访问和集成 GraphQL。
第十二章:使用 Apollo 客户端在前端与 GraphQL 交互
在上一章成功实现使用 Apollo Server 的 GraphQL 后端之后,我们现在将使用 Apollo 客户端在前端与新的 GraphQL API 进行交互。Apollo 客户端是一个库,使得与 GraphQL API 交互变得更加容易和方便。我们将首先用 GraphQL 查询替换获取帖子列表的操作,然后无需额外的查询即可解析作者用户名,展示 GraphQL 的强大功能。接下来,我们将向查询中添加变量以允许设置过滤和排序选项。最后,我们将学习如何在前端使用突变。
在本章中,我们将涵盖以下主要主题:
-
设置 Apollo 客户端并执行我们的第一个查询
-
在 GraphQL 查询中使用变量
-
在前端使用突变
技术要求
在我们开始之前,请从 第一章 为全栈开发做准备 和 第二章 了解 Node.js 和 MongoDB 中安装所有要求。
那些章节中列出的版本是书中使用的版本。虽然安装较新版本通常不会有问题,但请注意,某些步骤在较新版本上可能有所不同。如果本书中提供的代码和步骤存在问题,请尝试使用第 1 章和 2 章中提到的版本。
你可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch12。
本章的 CiA 视频可以在以下网址找到:youtu.be/Gl_5i9DR_xA。
如果你克隆了本书的完整仓库,Husky 在运行 npm install 时可能找不到 .git 目录。在这种情况下,只需在相应章节文件夹的根目录中运行 git init。
设置 Apollo 客户端并执行我们的第一个查询
在我们开始在前端进行 GraphQL 查询之前,我们首先需要设置 Apollo 客户端。POST 请求到 /graphql 端点),Apollo 客户端使得与 GraphQL 交互变得更加容易和方便。它还包括一些额外的功能,如开箱即用的缓存。
按照以下步骤设置 Apollo 客户端:
-
将现有的 ch11 文件夹复制到新的 ch12 文件夹,如下所示:
$ cp -R ch11 ch12 -
在 VS Code 中打开 ch12 文件夹。
-
安装 @apollo/client 和 graphql 依赖项:
$ npm install @apollo/client@3.9.5 graphql@16.8.1 -
编辑 .env 并添加一个新的环境变量,指向我们的 GraphQL 服务器端点:
VITE_GRAPHQL_URL="http://localhost:3001/graphql" -
编辑 src/App.jsx 并从 @apollo/client 包中导入 ApolloClient、InMemoryCache 和 ApolloProvider:
import { ApolloProvider } from '@apollo/client/react/index.js' import { ApolloClient, InMemoryCache } from '@apollo/client/core/index.js'在撰写本文时,Apollo 客户端中存在 ESM 导入问题,因此我们需要直接从
index.js文件中导入。 -
创建一个指向 GraphQL 端点并使用 InMemoryCache 的新实例的 Apollo Client:
const apolloClient = new ApolloClient({ uri: import.meta.env.VITE_GRAPHQL_URL, cache: new InMemoryCache(), }) -
调整 App 组件以添加 ApolloProvider,为我们的整个应用提供 Apollo Client 上下文:
export function App({ children }) { return ( <HelmetProvider> <ApolloProvider client={apolloClient}> <QueryClientProvider client={queryClient}> <AuthContextProvider>{children}</AuthContextProvider> </QueryClientProvider> </ApolloProvider> </HelmetProvider> ) } -
我们现在还将创建一个 GraphQL 配置文件,以便 VS Code GraphQL 扩展可以为我们自动完成和验证查询。在项目的根目录中创建一个新的 graphql.config.json 文件,内容如下:
{ "schema": "http://localhost:3001/graphql", "documents": "src/api/graphql/**/*.{js,jsx}" }schema定义了 GraphQL 端点的 URL,而documents定义了包含 GraphQL 查询的文件的位置。我们稍后将在src/api/graphql/文件夹中放置 GraphQL 查询。 -
确保 Docker 和数据库容器正在运行,然后按照以下方式启动后端:
$ cd backend/ $ npm run dev在本章中保持后端运行,以便 GraphQL 扩展可以访问 GraphQL 端点。
-
重新启动 VS Code GraphQL 扩展。您可以通过访问 VS Code 命令面板(在 Windows/Linux 上为 Ctrl + Shift + P,在 macOS 上为 Cmd + Shift + P)并输入 GraphQL: Manual Restart 来这样做。
使用 GraphQL 从前端查询帖子
现在 Apollo Client 已设置并准备好使用,让我们定义我们的第一个 GraphQL 查询:一个简单的查询来获取所有帖子。
按照以下步骤定义查询并在我们的应用中使用它:
-
在 src/api/graphql/ 文件夹中创建一个新的文件夹,我们将在这里放置我们的 GraphQL 查询。
-
在此文件夹内,创建一个新的 src/api/graphql/posts.js 文件。
-
在 src/api/graphql/posts.js 文件中,从 @apollo/client 导入 gql 函数:
import { gql } from '@apollo/client/core/index.js' -
定义一个新的 GET_POSTS 查询,它检索帖子的所有相关属性(除了作者,稍后添加):
export const GET_POSTS = gql` query getPosts { posts { id title contents tags updatedAt createdAt } } `你应该会看到 GraphQL 扩展为我们提供了我们定义在后端的类型的自动完成选项!如果我们输入错误的字段名,它也会警告我们该字段在类型上不存在。
-
编辑 src/pages/Blog.jsx 并从 @apollo/client 导入 useQuery 钩子:
import { useQuery as useGraphQLQuery } from '@apollo/client/react/index.js'我们将 Apollo Client 的
useQuery钩子重命名为useGraphQLQuery以避免与 TanStack React Query 的useQuery钩子混淆。 -
导入之前定义的 GET_POSTS 查询:
import { GET_POSTS } from '../api/graphql/posts.js' -
移除 用于 useQuery 和 getPosts 的导入:
import { useQuery } from '@tanstack/react-query' import { getPosts } from '../api/posts.js' -
移除 现有的 useQuery 钩子:
const postsQuery = useQuery({ queryKey: ['posts', { author, sortBy, sortOrder }], queryFn: () => getPosts({ author, sortBy, sortOrder }), }) const posts = postsQuery.data ?? [] -
替换 为以下钩子:
const postsQuery = useGraphQLQuery(GET_POSTS) const posts = postsQuery.data?.posts ?? [] -
确保您位于项目的根目录中,然后按照以下方式运行前端:
$ npm run dev
现在,在 http://localhost:5173/ 上打开前端,你会看到帖子标题被正确显示。然而,帖子链接不起作用,控制台中有错误。GraphQL 和 REST API 的结果略有不同:REST API 将帖子的 ID 作为 _id 属性返回,而 GraphQL 将它们作为 id 属性返回。
让我们调整我们的代码以适应这个变化:
-
编辑 src/components/Post.jsx 并将 _id 属性更改为 id:
export function Post({ title, contents, author, id, -
同时,更新使用的地方的变量名:
<Link to={`/posts/${id}/${slug(title)}`}> -
确保更新 propTypes:
Post.propTypes = { title: PropTypes.string.isRequired, contents: PropTypes.string, author: PropTypes.string, id: PropTypes.string.isRequired, -
现在属性已更改,编辑 src/pages/ViewPost.jsx 并按照以下方式传递新属性:
{post ? ( <Post {...post} id={postId} fullPost /> ) : ( `Post with id ${postId} not found.` )}
保存所有文件后,前端应该刷新并正确渲染所有帖子列表,并带有正常工作的链接。现在要恢复原始功能,只剩下显示作者用户名。
在单个查询中解析作者用户名
由于 GraphQL 的强大功能,我们现在可以一次性在单个查询中获取所有作者的用户名,而不是分别解析每个作者的用户名!让我们利用这个功能来重构我们的代码,使其更简单并提高性能:
-
首先,编辑 src/api/graphql/posts.js 中的 GraphQL 查询,添加 author.username 字段,如下所示:
export const GET_POSTS = gql` query getPosts { posts { author { username } -
然后,编辑 src/components/User.jsx 组件。替换整个组件为以下更简单的组件:
import PropTypes from 'prop-types' export function User({ username }) { return <b>{username}</b> } User.propTypes = { username: PropTypes.string.isRequired, }现在在这里获取用户信息不再必要,因为我们可以直接从 GraphQL 响应中显示用户名。
-
接下来,编辑 src/components/Post.jsx 并按照以下方式将整个 author 对象传递给 User 组件:
Written by <User {...author} /> -
我们还需要调整 propTypes 以接受 Post 组件的完整 author 对象,而不是用户 ID:
author: PropTypes.shape(User.propTypes), -
编辑 src/pages/ViewPost.jsx 并将整个 author 对象传递给 Post 组件:
<Post {...post} id={postId} src/components/Header.jsx and import the useQuery hook and the getUserInfo API function:导入
{ useQuery }从@tanstack/react-query导入
{ getUserInfo }从../api/users.js -
然后,调整组件以从令牌(JWT 的 sub 字段)中获取用户 ID 并对用户信息进行查询:
export function Header() { const [token, setToken] = useAuth() const { sub } = token ? jwtDecode(token) : {} const userInfoQuery = useQuery({ queryKey: ['users', sub], queryFn: () => getUserInfo(sub), enabled: Boolean(sub), }) const userInfo = userInfoQuery.data -
最后,我们检查是否能够解析用户信息查询(而不是仅仅检查 token)。如果是这样,我们将用户信息传递给 User 组件:
if (token && userInfo) { return ( <nav> Logged in as <User {...userInfo} />我们还像之前一样移除了令牌解码。
现在我们正在使用 GraphQL 来获取帖子列表并在单个请求中解析作者用户名!然而,过滤和排序不再工作,因为我们还没有将此信息传递给 GraphQL 查询。
在下一节中,我们将介绍用于过滤和排序 GraphQL 查询的变量。
在 GraphQL 查询中使用变量
要添加对过滤和排序的支持,我们需要在我们的 GraphQL 查询中添加变量。然后,在执行查询时我们可以填写这些变量。
按照以下步骤向查询中添加变量:
-
编辑 src/api/graphql/posts.js 并调整查询以接受一个 $options 变量:
export const GET_POSTS = gql` query getPosts($options: PostsOptions) { -
然后,将 $options 变量传递给 posts 解析器,因为我们已经在上一章中实现了 options 参数:
posts(options: $options) { -
现在,我们只需在执行查询时传递这些选项。编辑 src/pages/Blog.jsx 并按照以下方式传递变量:
const postsQuery = useGraphQLQuery(GET_POSTS, { variables: { options: { sortBy, sortOrder } }, }) -
前往博客前端并将排序顺序更改为升序,以查看变量的实际效果!
使用片段重用查询的部分
现在排序功能已经正常工作,我们只需要添加按作者过滤的功能。为此,我们需要为 postsByAuthor 添加第二个查询。正如你所想象的那样,这个查询应该返回与 posts 查询相同的字段。我们可以利用片段来重用这两个查询的字段,如下所示:
-
编辑 src/api/graphql/posts.js 并在 GraphQL 中定义一个新的片段,其中包含我们从帖子中需要的所有字段:
export const POST_FIELDS = gql` fragment PostFields on Post { id title contents tags updatedAt createdAt author { username } } `该片段通过给它一个名称(
PostFields)并指定它可以用于哪种类型(on Post)来定义。然后,可以在片段中查询指定类型的所有字段。 -
要使用片段,我们首先必须将其定义包含在 GET_POSTS 查询中:
export const GET_POSTS = gql` ${POST_FIELDS} query getPosts($options: PostsOptions) { -
现在,我们不再需要手动列出所有字段,我们可以使用片段:
posts(options: $options) { ...PostFields } } `使用片段的语法类似于 JavaScript 中的对象解构,其中对象中定义的所有属性都会扩展到另一个对象中。
注意
有时需要重新启动 VS Code GraphQL 扩展才能正确检测片段。您可以通过访问 VS Code 命令面板(在 Windows/Linux 上为 Ctrl + Shift + P,在 macOS 上为 Cmd + Shift + P)并输入 GraphQL: Manual Restart 来这样做。
-
接下来,我们定义第二个查询,通过作者查询帖子,并使用片段获取所有必要的字段:
export const GET_POSTS_BY_AUTHOR = gql` ${POST_FIELDS} query getPostsByAuthor($author: String!, $options: PostsOptions) { postsByAuthor(username: $author, options: $options) { ...PostFields } } `我们将
$author变量定义为该查询所必需的(通过在类型后使用感叹号)。我们需要这样做,因为postsByAuthor字段也要求设置第一个参数(username)。 -
编辑 src/pages/Blog.jsx 并导入新定义的查询:
import { GET_POSTS, GET_POSTS_BY_AUTHOR } from '../api/graphql/posts.js' -
然后,调整钩子以使用 GET_POSTS_BY_AUTHOR 查询,如果 author 已定义:
const postsQuery = useGraphQLQuery(author ? GET_POSTS_BY_AUTHOR : GET_POSTS, { -
将 author 变量传递给查询:
variables: { author, options: { sortBy, sortOrder } }, }) -
最后,我们需要调整选择结果的方式,因为 GET_POSTS_BY_AUTHOR 查询中的 postsByAuthor 字段将结果返回在 data.postsByAuthor 中,而 GET_POSTS 查询使用 posts 字段,结果返回在 data.posts 中。由于没有同时返回这两个字段的情况,我们可以简单地这样做:
const posts = postsQuery.data?.postsByAuthor ?? postsQuery.data?.posts ?? [] -
前往前端尝试按作者过滤。现在过滤器又正常工作了!
如我们所见,片段对于重复使用相同字段进行多个查询非常有用!现在我们的帖子列表已经完全重构为使用 GraphQL,让我们继续在前端使用突变,这样我们就可以将注册、登录和创建帖子功能迁移到 GraphQL。
在前端使用突变
如我们在上一章所学,GraphQL 中的突变用于更改后端的状态(类似于 REST 中的 POST 请求)。我们现在将实现注册和登录的突变。
按照以下步骤操作:
-
创建一个新的 src/api/graphql/users.js 文件并导入 gql:
import { gql } from '@apollo/client/core/index.js' -
然后,定义一个新的 SIGNUP_USER 突变,它接受用户名和密码并调用 signupUser 突变字段:
export const SIGNUP_USER = gql` mutation signupUser($username: String!, $password: String!) { signupUser(username: $username, password: $password) { username } } ` -
编辑 src/pages/Signup.jsx 并将当前来自 TanStack React Query 的 useMutation hook 替换为来自 Apollo Client 的一个。正如我们之前为 useQuery 所做的那样,我们也将把这个 hook 重命名为 useGraphQLMutation 以避免混淆:
import { useMutation as useGraphQLMutation } from '@apollo/client/react/index.js' -
此外,替换 signup 函数的导入为 SIGNUP_USER mutation 的导入:
import { SIGNUP_USER } from '../api/graphql/users.js' -
替换 现有的 mutation hook 为以下内容:
const [signupUser, { loading }] = useGraphQLMutation(SIGNUP_USER, { variables: { username, password }, onCompleted: () => navigate('/login'), onError: () => alert('failed to sign up!'), })如所见,Apollo Client 的 mutation hook 与 TanStack React Query 的 mutation hook 有略微不同的 API。它返回一个包含调用 mutation 的函数以及包含加载状态、错误状态和数据的对象的数组。类似于
useGraphQLQueryhook,它也接受 mutation 作为第一个参数,以及包含变量的对象作为第二个参数。此外,Apollo Client 中的onSuccess函数被命名为onCompleted。 -
按如下方式更改 handleSubmit 函数:
const handleSubmit = (e) => { e.preventDefault() signupUser() } -
最后,按如下方式更改提交按钮:
<input type='submit' value={loading ? 'Signing up...' : 'Sign Up'} disabled={!username || !password || loading} />
现在注册功能已成功迁移到 GraphQL。接下来,让我们迁移登录功能。
将登录迁移到 GraphQL
将登录功能重构为 GraphQL 与注册功能非常相似,所以让我们快速浏览一下步骤:
-
编辑 src/api/graphql/users.js 并为登录定义一个 mutation:
export const LOGIN_USER = gql` mutation loginUser($username: String!, $password: String!) { loginUser(username: $username, password: $password) } ` -
编辑 src/pages/Login.jsx 并将导入 TanStack React Query 和 login 函数替换为以下内容:
import { useMutation as useGraphQLMutation } from '@apollo/client/react/index.js' import { LOGIN_USER } from '../api/graphql/users.js' -
更新 hook:
const [loginUser, { loading }] = useGraphQLMutation(LOGIN_USER, { variables: { username, password }, onCompleted: (data) => { setToken(data.loginUser) navigate('/') }, onError: () => alert('failed to login!'), }) -
更新 handleSubmit 函数:
const handleSubmit = (e) => { e.preventDefault() loginUser() } -
最后,更新提交按钮:
<input type='submit' value={loading ? 'Logging in...' : 'Log In'} disabled={!username || !password || loading} />
现在,注册和登录都使用 GraphQL mutation,让我们继续迁移创建帖子功能到 GraphQL。
将创建帖子迁移到 GraphQL
创建帖子功能实现起来有点复杂,因为它要求我们登录(这意味着我们需要发送 JWT 标头),并使帖子列表查询失效,以便在创建新帖子后更新列表。
现在让我们开始使用 Apollo Client 来实现这个功能:
-
首先,让我们定义 mutation。编辑 src/api/graphql/posts.js 并添加以下代码:
export const CREATE_POST = gql` mutation createPost($title: String!, $contents: String, $tags: [String!]) { createPost(title: $title, contents: $contents, tags: $tags) { id title } } `对于这个 mutation,我们将使用响应来获取创建的帖子的
id和title。我们将利用这些数据在成功创建后显示帖子的链接。 -
然后,编辑 src/components/CreatePost.jsx 并将 TanStack React Query 的导入替换为 mutation hook 的导入:
import { useMutation as useGraphQLMutation } from '@apollo/client/react/index.js' -
此外,导入 Link 组件和 slug 函数以显示创建的帖子链接:
import { Link } from 'react-router-dom' import slug from 'slug' -
替换 createPost 函数的导入为 CREATE_POST mutation 和 GET_POSTS 以及 GET_POSTS_BY_AUTHOR 查询的导入。我们将使用这些查询定义让 Apollo Client 在稍后为我们重新获取它们:
import { CREATE_POST, GET_POSTS, GET_POSTS_BY_AUTHOR, } from '../api/graphql/posts.js' -
替换 现有的查询客户端和 mutation hook 为以下 GraphQL mutation,其中我们传递 title 和 contents 变量:
const [createPost, { loading, data }] = useGraphQLMutation(CREATE_POST, { variables: { title, contents }, -
接下来,我们将 JWT 标头作为 context 传递给 mutation:
context: { headers: { Authorization: `Bearer ${token}` } }, -
然后,我们将 refetchQueries 选项提供给突变,告诉 Apollo Client 在调用突变后重新获取某些查询:
refetchQueries: [GET_POSTS, GET_POSTS_BY_AUTHOR], })
注意
由于突变后的重新获取是一个常见的操作,Apollo Client 提供了一种简单的方法在突变钩子中执行此操作。只需将所有应重新获取的查询传递到那里,Apollo Client 将负责处理。
-
调整 handleSubmit 函数:
const handleSubmit = (e) => { e.preventDefault() createPost() } -
调整提交按钮:
<input type='submit' value={loading ? 'Creating...' : 'Create'} disabled={!title || loading} /> -
最后,我们将更改成功消息,显示创建的帖子的链接:
{data?.createPost ? ( <> <br /> Post{' '} <Link to={`/posts/${data.createPost.id}/${slug(data.createPost.title)}`} > {data.createPost.title} </Link>{' '} created successfully! </> ) : null}由于 GraphQL 中类型和解析器的工作方式,它使我们能够轻松地访问突变结果的字段,就像我们正在获取单个帖子一样。例如,我们甚至可以告诉 GraphQL 获取创建的帖子的作者的用户名!
-
尝试创建一个新的帖子,你会看到成功消息现在显示了创建的帖子的链接,帖子列表也会自动为我们重新获取!
以下截图显示了一个新帖子成功创建,成功消息中显示了新帖子的链接,以及帖子列表中的新帖子(由 Apollo Client 自动重新获取):
图 12.1:使用 GraphQL 突变创建帖子,并重新获取帖子列表
现在我们已经成功实现了使用 GraphQL 创建帖子,我们的博客应用已经完全连接到我们的 GraphQL 服务器。
在这本书中,我们还没有涵盖 GraphQL 的许多更高级的概念,例如高级重新获取、订阅(从 GraphQL 服务器获取实时更新)、错误处理、suspense、分页和缓存。本书中的 GraphQL 章节仅作为 GraphQL 的入门介绍。
如果你希望了解更多关于 GraphQL 和 Apollo 的信息,我建议查看广泛的 Apollo 文档(www.apollographql.com/docs/),其中包含有关使用 Apollo Server 和 Apollo Client 的详细信息和实践示例。
摘要
在本章中,我们使用 Apollo Client 将之前创建的 GraphQL 后端连接到前端。我们首先设置 Apollo Client 并执行一个 GraphQL 查询以获取所有帖子。然后,我们通过在单个请求中获取作者用户名来提高帖子列表的性能,利用 GraphQL 的强大功能。
接下来,我们在查询中引入了变量,并重新实现了按作者排序和过滤。我们还引入了查询中的片段以重用相同的字段。最后,我们在前端实现了 GraphQL 突变以注册、登录和创建帖子。我们还沿途了解了 Apollo Client 中的查询重新获取,并简要介绍了 GraphQL 和 Apollo 的高级概念。
在下一章,第十三章,使用 Express 和 Socket.IO 构建基于事件驱动的后端,我们将从传统的全栈架构中跳出来,并使用一种特殊类型的全栈架构:基于事件的程序来构建一个新应用。
第四部分:探索基于事件的完整栈架构
在本书的这一部分,我们将摆脱传统的全栈架构,探索一种特殊类型的全栈架构:基于事件的架构。基于事件的架构的例子包括处理实时数据的软件,例如协作应用(例如,Google Docs、在线白板等)或金融应用(例如,Kraken 加密货币交易所)。我们首先将使用Express和Socket.IO开发一个基于事件的后端。然后,我们将创建一个前端来消费和发送事件。最后,我们将使用MongoDB为我们的应用添加持久性和功能,以便回放事件。
本部分包括以下章节:
-
第十三章,使用 Express 和 Socket.IO 构建基于事件的后端
-
第十四章,创建一个前端以消费和发送事件
-
第十五章,使用 MongoDB 为 Socket.IO 添加持久性
第十三章:使用 Express 和 Socket.IO 构建事件驱动后端
在本章中,我们将学习关于事件驱动应用程序以及使用这种架构与更传统架构相比的权衡。然后,我们将学习关于 WebSockets 以及它们是如何工作的。之后,我们将使用 Socket.IO 和 Express 实现后端。最后,我们将学习如何通过使用 JWT 与 Socket.IO 集成来实现身份验证。
在本章中,我们将涵盖以下主要主题:
-
什么是事件驱动应用程序?
-
设置 Socket.IO
-
使用 Socket.IO 创建聊天应用的后端
-
通过将 JWT 与 Socket.IO 集成来添加身份验证
技术要求
在我们开始之前,请从第一章,为全栈开发做准备,以及第二章,了解 Node.js 和 MongoDB中安装所有要求。
那些章节中列出的版本是本书中使用的版本。虽然安装较新版本不应有问题,但请注意,某些步骤可能有所不同。如果你在使用本书中提供的代码和步骤时遇到问题,请尝试使用 第一章 和 第二章 中提到的版本。
你可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch13。
如果你克隆了本书的完整仓库,在运行 npm install 时 Husky 可能找不到 .git 目录。在这种情况下,只需在相应章节文件夹的根目录下运行 git init。
本章的 CiA 视频可以在以下位置找到:youtu.be/kHGvkopIHf4。
什么是事件驱动应用程序?
与传统的基于请求-响应模式的 Web 应用程序相比,在事件驱动应用程序中,我们处理的是事件。服务器和客户端保持连接,每一方都可以发送事件,另一方监听并做出反应。
以下图表显示了在请求-响应模式与事件驱动模式之间实现聊天应用的区别:
图 13.1 – 使用请求-响应和事件驱动模式的聊天应用实现
例如,在请求-响应模式中实现聊天应用,我们需要定期向GET /chat/messages端点发送请求以刷新聊天室中发送的消息列表。这种定期发送请求的过程称为短轮询。要发送聊天消息,我们会向POST /chat/messages发送请求。在事件驱动模式中,我们可以从客户端向服务器发送chat.message事件,然后服务器将chat.message事件发送给所有已连接的用户。然后客户端监听chat.message事件,并在消息到来时显示它们;不需要定期请求!
当然,每种模式都有其优缺点:
-
REST/请求-响应:
-
当数据不经常变化时很有用
-
响应可以轻松缓存
-
请求是无状态的,这使得扩展后端变得容易
-
在实时更新方面表现不佳(需要定期轮询)
-
每个请求的开销更大(在发送许多短响应时不好)
-
-
WebSocket/事件驱动:
-
对于需要频繁更新的应用很有用
-
更高效,因为客户端和服务器之间的持久连接被重复用于多个请求
-
每个请求的开销较小
-
可能会与(企业)代理存在连接问题
-
它是有状态的,这可能会使扩展应用更困难
-
如我们所见,对于获取不经常变化(并且可以缓存)的数据(如博客文章),请求-响应模式更合适。对于数据频繁变化的应用(如聊天室),事件驱动模式更合适。
什么是 WebSockets?
WebSocket API 是一个浏览器功能,允许 Web 应用程序在客户端和服务器之间创建一个开放的连接,类似于 Unix 风格的套接字。使用 WebSockets,通信可以同时双向进行。这与 HTTP 请求形成对比,在 HTTP 请求中,双方可以通信,但不能同时进行。
WebSockets 使用 HTTP 在客户端和服务器之间建立连接,然后将协议从 HTTP 升级到 WebSocket 协议。虽然 HTTP 和 WebSockets 都依赖于传输控制协议(TCP),但它们是开放系统互联(OSI)模型应用层(第 7 层)上的不同协议。
通过发送带有Upgrade: websocket头和其他参数的 HTTP 请求来建立 WebSocket 连接。然后服务器响应以HTTP 101 Switching Protocols响应代码和建立连接的信息。然后,客户端和服务器继续在 WebSocket 协议上进行通信。
什么是 Socket.IO?
Socket.IO 是一个基于事件的客户端和服务器库的实现。在大多数情况下,它使用 WebSocket 连接到服务器。如果 WebSocket 连接不可行(由于浏览器支持不足或防火墙设置),Socket.IO 也可以回退到 HTTP 长轮询。然而,Socket.IO 并不是一个纯 WebSocket 实现,因为它为每个数据包添加了额外的元数据。它仅在内部使用 WebSocket 传输数据。
除了提供客户端和服务器之间发送事件的方式之外,Socket.IO 还在普通 WebSocket 之上提供了以下功能:
-
回退到 HTTP 长轮询:如果 WebSocket 连接无法建立,则会发生这种情况。这对于使用代理或防火墙且阻止 WebSocket 连接的公司来说特别有用。
-
自动重连:如果 WebSocket 连接中断。
-
缓冲数据包:当客户端断开连接时,数据包可以在重新连接时自动重新发送。
-
确认:在请求-响应模式中发送事件的一种便捷方式,这在某些情况下甚至在基于事件的程序中也可能很有用。
-
广播:向所有(或所有连接客户端的子集)发送事件。
-
多路复用:Socket.IO 实现了命名空间,可以用来创建“频道”,只有特定用户可以发送事件并接收事件,例如“仅管理员频道”。
现在我们已经学习了 Socket.IO 的基本知识,让我们深入了解连接以及发送/接收事件的工作原理。
连接到 Socket.IO
以下图示展示了如何使用 Socket.IO 建立连接:
图 13.2 – 使用 Socket.IO 建立连接
首先,Socket.IO 从客户端(前端)向服务器(后端)发送一个握手信号,这个握手信号可以包含用于与服务器进行身份验证的信息,或者查询参数,以便在建立连接时提供额外的信息。
如果无法通过 WebSocket 建立连接,Socket.IO 将通过 HTTP 长轮询连接到服务器,这意味着向服务器发送一个保持活跃的请求,直到发生事件,此时服务器向请求发送响应。这允许等待事件,而无需定期发送请求以查看是否有新事件。当然,这不如 WebSocket 性能好,但它是当 WebSocket 不可用时的一个很好的回退方案。
发送和接收事件
一旦连接到 Socket.IO,我们就可以开始 发射(发送)和接收事件。事件通过注册事件处理函数来处理,当客户端或服务器接收到某种类型的事件时,这些函数会被调用。客户端和服务器都可以发射和接收事件。此外,事件还可以从服务器广播到多个客户端。以下图示展示了在聊天应用程序中事件是如何发射和接收的:
图 13.3 – 使用 Socket.IO 发射和接收事件
正如我们所见,用户 1 发送了一条 大家好 的消息,服务器(后端)随后将其广播给所有其他客户端(前端)。在这种情况下,消息被广播回 用户 1,以及 用户 2。
如果我们想限制接收特定事件的客户端,Socket.IO 允许创建 rooms。客户端可以加入一个房间,在服务器上,我们也可以向特定房间广播事件。这个概念可以用于聊天室,也可以用于在特定项目上协作(例如实时共同编辑文档)。
除了异步地发射和接收事件外,Socket.IO 还提供了一种通过 user.info 事件发送期望响应的事件的方式,并同步等待服务器响应(确认)。我们可以在前面的图中看到这一点,其中 用户 2 请求有关某个用户的信息,然后收到包含用户信息的响应。
现在我们已经了解了基于事件的程序、WebSockets 和 Socket.IO,让我们将这个理论付诸实践并设置 Socket.IO。
设置 Socket.IO
要设置 Socket.IO 服务器,我们将基于我们在 第六章 中所学的代码,使用 JWT 添加身份验证和角色,因为它已经包含了一些后端和前端 JWT 身份验证的样板代码。在本章的 通过将 JWT 与 Socket.IO 集成来添加身份验证 部分,我们将使用 JWT 为 Socket.IO 添加身份验证:
-
将现有的 ch6 文件夹复制到新的 ch13 文件夹中,如下所示:
$ cp -R ch6 ch13 -
在 VS Code 中打开 ch13 文件夹。
-
现在,我们可以开始设置 Socket.IO。首先,通过运行以下命令在后端文件夹中安装 socket.io 包:
$ cd backend/ $ npm install socket.io@4.7.2 -
编辑 backend/.env 并更改 DATABASE_URL,使其指向一个新的 chat 数据库:
DATABASE_URL=mongodb://localhost:27017/chat -
编辑 backend/src/app.js 并从 node:http 导入 createServer 函数,从 socket.io 导入 Server 函数:
import { createServer } from 'node:http' import { Server } from 'socket.io'我们需要创建一个
node:http服务器,因为我们不能直接将 Socket.IO 连接到 Express。相反,Socket.IO 附加到node:http服务器上。 -
幸运的是,Express 也很容易附加到 node:http 服务器上。编辑 backend/src/app.js 并在 app 导出之前,从 Express 应用程序创建一个新的 node:http 服务器,如下所示:
const server = createServer(app) -
现在,从 node:http 服务器创建一个新的 Socket.IO 服务器:
const io = new Server(server, { cors: { origin: '*', }, })
警告
设置原点为 ***** 使得钓鱼网站可以模仿你的网站并向你的后端发送请求。在生产环境中,原点应设置为前端部署的 URL。
-
我们可以使用 Socket.IO 服务器来监听来自客户端的连接并打印一条消息:
io.on('connection', (socket) => { console.log('user connected:', socket.id) -
可以通过使用 socket 对象来跟踪活跃的客户端连接。例如,我们可以像这样监听来自客户端的断开事件:
socket.on('disconnect', () => { console.log('user disconnected:', socket.id) }) }) -
最后,更改导出,使其使用 node:http 服务器而不是直接使用 Express 应用:
export { server as app } -
通过运行以下命令来启动后端:
$ cd backend/ $ npm run dev在启动后端之前,别忘了启动 Docker 和数据库容器。保持后端在本章的剩余部分运行。
现在我们已经设置了一个简单的 Socket.IO 服务器,让我们继续设置客户端。
设置简单的 Socket.IO 客户端
我们现在将使用现有的前端。在下一章,第十四章,创建一个用于消费和发送事件的客户端前端,我们将移除博客组件并为我们的聊天应用创建一个新的 React 前端。让我们开始设置一个简单的 Socket.IO 客户端:
-
在项目的根目录中,通过运行以下命令为前端安装 socket.io-client 包:
backend folder anymore! -
编辑 src/App.jsx 并从 socket.io-client 中导入 io 函数:
import { io } from 'socket.io-client' -
通过使用 io 函数并传递主机名和端口号来定义一个新的 Socket.IO 客户端实例:
const socket = io(import.meta.env.VITE_SOCKET_HOST)在这里,我们将通过环境变量传递
localhost:3001。我们无法在这里传递 HTTP URL,因为 Socket.IO 将尝试使用 WebSockets 连接到主机名和端口号。 -
监听 connect 事件,并在成功连接到 Socket.IO 服务器时打印一条消息:
socket.on('connect', () => { console.log('connected to socket.io as', socket.id) }) -
此外,监听 connect_error 事件,并在连接到 Socket.IO 服务器失败时记录错误消息:
socket.on('connect_error', (err) => { console.error('socket.io connect error:', err) }) -
编辑 .env 并添加以下环境变量:
VITE_SOCKET_HOST="localhost:3001" -
按照以下方式运行前端:
$ npm run dev -
现在,通过访问 http://localhost:5173/ 在浏览器中打开前端。保持前端在本章的剩余部分运行。
你将在浏览器控制台中看到一个表示 已连接到 socket.io 的消息。在服务器输出中,你会看到客户端已成功连接。尝试刷新页面以查看它断开连接并再次连接(使用新的 socket ID):
图 13.4 – 观察 Socket.IO 客户端连接到并断开我们的服务器
现在我们已经成功设置了 Socket.IO 服务器,让我们继续创建一个使用 Socket.IO 的聊天应用的后端。
使用 Socket.IO 为聊天应用创建后端
我们现在可以开始使用 Socket.IO 实现聊天应用了。我们将为我们的聊天应用开发以下功能:
-
从客户端向服务器发射 聊天消息的事件
-
从服务器向所有客户端广播 聊天消息
-
加入 房间 以发送消息
-
使用 确认 获取关于用户的信息
让我们开始吧!
从客户端向服务器发送聊天消息的事件发射
我们将从客户端到服务器的 chat.message 事件开始。目前,我们将在这个连接后立即发射此事件。稍后,我们将将其集成到前端。按照以下步骤从客户端发送聊天消息并在服务器上接收:
-
编辑 backend/src/app.js 并 剪切/删除 以下代码:
io.on('connection', (socket) => { console.log('user connected:', socket.id) socket.on('disconnect', () => { console.log('user disconnected:', socket.id) }) }) -
创建一个新的 backend/src/socket.js 文件,在那里定义一个 handleSocket 函数,并在其中粘贴以下代码:
export function handleSocket(io) { io.on('connection', (socket) => { console.log('user connected:', socket.id) socket.on('disconnect', () => { console.log('user disconnected:', socket.id) }) -
现在添加一个新的监听器,它监听 chat.message 事件并记录客户端发送的消息:
socket.on('chat.message', (message) => { console.log(`${socket.id}: ${message}`) }) }) } -
编辑 backend/src/app.js 并导入 handleSocket 函数:
import { handleSocket } from './socket.js' -
一旦创建好 Socket.IO 服务器,调用 handleSocket 函数:
const io = new Server(server, { cors: { origin: '*', }, }) handleSocket(io) -
编辑 src/App.jsx 并发射一个包含一些文本的 chat.message 事件,如下所示:
socket.on('connect', () => { console.log('connected to socket.io as', socket.id) socket.emit('chat.message', 'hello from client') })
信息
Socket.IO 允许我们在事件中发送任何可序列化的数据结构,而不仅仅是字符串!例如,可以发送对象和数组。
后端和前端应该自动刷新,服务器将记录以下消息:
XXmWHjA_5zew70VIAAAM: hello from client
如果没有,请确保你(重新)启动后端和前端,并手动刷新页面。
如您所见,使用 Socket.IO 异步实时发送和接收事件相当简单。
从服务器向所有客户端广播聊天消息
现在后端服务器可以接收来自客户端的消息,我们需要将这些消息 广播 给所有其他客户端,以便其他人可以看到发送的聊天消息。让我们现在就来做这件事:
-
编辑 backend/src/socket.js 并扩展 chat.message 事件监听器,使其调用 io.emit 并将聊天消息发送给所有人:
socket.on('chat.message', (message) => { console.log(`${socket.id}: ${message}`) io.emit('chat.message', { username: socket.id, message, }) })
注意
或者,你可以使用 socket.broadcast.emit 向除了发送消息的那个客户端以外的所有客户端发送事件。
-
我们还需要在客户端添加一个用于聊天消息的监听器。这与服务器上的方式相同。编辑 src/App.jsx 并添加以下事件监听器:
socket.on('chat.message', (msg) => { console.log(`${msg.username}: ${msg.message}`) }) -
现在,你应该能在服务器和客户端看到消息被记录。尝试打开第二个窗口;你将在浏览器中看到来自两个客户端的消息!
图 13.5 – 从另一个客户端接收消息
加入房间以发送消息
虽然有一个可以传递消息给所有人的工作聊天室是件好事,但通常我们不想将我们的消息广播给所有人。相反,我们可能只想将消息发送给特定的一组人。为了实现这一点,Socket.IO 提供了 rooms。房间可以用来将客户端分组,以便只将事件发送到房间中的所有其他客户端。这个功能可以用来创建聊天室,也可以用来共同协作完成项目(通过为每个项目创建一个新的房间)。让我们学习如何在 Socket.IO 中使用房间:
-
Socket.IO 允许我们在握手过程中传递查询字符串。我们可以访问这个查询字符串来获取客户端想要加入的房间。编辑 backend/src/socket.js 并从握手查询中获取房间:
io.on('connection', (socket) => { console.log('user connected:', socket.id) const room = socket.handshake.query?.room ?? 'public' -
现在,使用 socket.join 将客户端加入所选房间:
socket.join(room) console.log(socket.id, 'joined room:', room) -
然后,在 chat.message 处理程序内部,使用 .to(room) 确保来自该客户端的聊天消息只发送到特定的房间:
io.to(room).emit('chat.message', { username: socket.id, message, }) -
在客户端,我们需要传递一个查询字符串来告诉服务器我们想要加入哪个房间。编辑 src/App.jsx,如下所示:
const socket = io(import.meta.env.VITE_SOCKET_HOST, { query: window.location.search.substring(1), ? at the beginning of the string). -
在两个不同的浏览器窗口中打开 http://localhost:5173/ 和 **http://localhost:5173/?room=test**,并从两个窗口发送消息。你会看到第二个窗口的消息没有发送到第一个窗口。然而,如果你打开另一个带有 ?room=test 查询字符串的窗口并发送消息,你会看到消息被转发到第二个窗口(但不是第一个窗口)。
如我们所见,我们可以使用房间来对哪些客户端接收特定事件有更细粒度的控制。因为服务器控制客户端加入哪些房间,我们也可以在允许客户端加入房间之前添加权限检查。
使用确认信息来获取用户信息
正如我们所见,事件是发送异步消息的好方法。然而,有时我们想要一个更传统的同步请求-响应 API,就像我们之前在 REST 中所做的那样。在 Socket.IO 中,我们可以通过使用 acknowledgments 来实现同步事件。我们可以使用确认信息,例如,获取当前聊天室中用户的更多信息。目前,我们只将返回用户所在的房间。稍后,当我们添加身份验证时,我们将在这里从数据库中获取用户对象。让我们开始实现确认信息:
-
编辑 backend/src/socket.js 并定义一个新的事件监听器:
socket.on('user.info', async (socketId, callback) => {注意我们是如何将回调函数作为最后一个参数传递的。这就是使事件成为确认信息的原因。
-
在这个事件监听器中,我们将获取具有我们 socket ID 的房间中的所有 socket:
const sockets = await io.in(socketId).fetchSockets()内部,Socket.IO 为每个连接的 socket 创建一个房间,以便能够向单个 socket 发送事件。
注意
我们可以直接访问当前实例的 socket,但当我们把我们的服务扩展到集群中的多个实例时,这就不起作用了。为了使其即使在集群中也能工作,我们需要使用房间功能通过 ID 获取 socket。
-
现在,我们必须检查是否找到了具有给定 ID 的 socket。如果没有找到,我们返回 null:
if (sockets.length === 0) return callback(null) -
否则,我们返回 socket ID 和用户所在的房间列表:
const socket = sockets[0] const userInfo = { socketId, rooms: Array.from(socket.rooms), } return callback(userInfo) }) -
现在,我们可以在客户端上发出 user.info 事件。编辑 src/App.jsx 并首先将 connect 事件监听器改为 async 函数:
socket.on('connect', async () => { console.log('connected to socket.io as', socket.id) socket.emit('chat.message', 'hello from client') -
要使用确认发出事件,我们可以使用 emitWithAck 函数,它返回一个可以 await 的 Promise:
const userInfo = await socket.emitWithAck('user.info', socket.id) console.log('user info', userInfo) }) -
保存代码后,转到浏览器窗口;你将看到用户信息在控制台中记录下来:
图 13.6 – 使用确认获取用户信息
现在我们已经学习了如何发送各种类型的事件,让我们进入一个更高级的主题:使用 Socket.IO 进行身份验证。
通过将 JWT 与 Socket.IO 集成添加身份验证
到目前为止,所有聊天消息都是使用 socket ID 作为“用户名”发送的。这不是在聊天室中识别用户的好方法。为了解决这个问题,我们将通过使用 JWT 验证 socket 来引入用户账户。按照以下步骤在 Socket.IO 中实现 JWT:
-
编辑 backend/src/socket.js 并从 jsonwebtoken 包中导入 jwt,以及从我们的服务函数中导入 getUserInfoById:
import jwt from 'jsonwebtoken' import { getUserInfoById } from './services/users.js' -
在 handleSocket 函数内部,使用 io.use() 定义一个新的 Socket.IO 中间件。Socket.IO 中的中间件与 Express 中的中间件类似 – 我们定义一个在请求处理之前运行的函数,如下所示:
export function handleSocket(io) { io.use((socket, next) => { -
在这个函数内部,我们检查令牌是否通过 auth 对象发送(类似于我们之前如何通过查询字符串发送 room)。如果没有传递令牌,我们将错误传递给 next() 函数并导致连接失败:
if (!socket.handshake.auth?.token) { return next(new Error('Authentication failed: no token provided')) }
注意
重要的是不要通过查询字符串传递 JWT,因为这是 URL 的一部分。它在浏览器地址栏中暴露,因此可能被潜在攻击者存储在浏览器历史记录中。相反,auth 对象在握手过程中通过请求有效载荷发送,这不会在地址栏中暴露。
-
否则,我们调用 jwt.verify 通过现有的 JWT_SECRET 环境变量来验证令牌:
jwt.verify( socket.handshake.auth.token, process.env.JWT_SECRET, -
如果令牌无效,我们再次在 next() 函数中返回一个错误:
async (err, decodedToken) => { if (err) { return next(new Error('Authentication failed: invalid token')) } -
否则,我们将解码的令牌保存到 socket.auth:
socket.auth = decodedToken -
此外,我们从数据库中获取用户信息,为了方便,将其存储在 socket.user:
socket.user = await getUserInfoById(socket.auth.sub) return next() }, ) })
注意
确保在 Socket.IO 中间件中始终调用 next()。否则,Socket.IO 将保持连接打开,直到在给定超时后关闭。
-
user对象包含一个username值。现在,我们可以替换聊天消息中的 socket ID 为用户名:
socket.on('chat.message', (message) => { console.log(`${socket.id}: ${message}`) io.to(room).emit('chat.message', { username: socket.user.username, message, }) }) -
我们还可以从user.info事件返回用户信息:
const userInfo = { socketId, rooms: Array.from(socket.rooms), user: socket.user, } -
我们仍然需要从客户端发送身份验证对象,编辑src/App.jsx,并从localStorage中获取令牌,如下所示:
const socket = io(import.meta.env.VITE_SOCKET_HOST, { query: window.location.search.substring(1), auth: { token: window.localStorage.getItem('token'), }, })
注意
为了简单起见,我们在这个例子中将 JWT 存储和读取到localStorage中。然而,在生产环境中将 JWT 以这种方式存储并不是一个好主意,因为如果攻击者找到了注入 JavaScript 的方法,localStorage可能会被读取。存储 JWT 的更好方式是使用具有Secure、HttpOnly和**SameSite="Strict"**属性的 cookie。
- 现在服务器端已设置好,我们可以在客户端尝试登录。最初,我们会看到一个错误消息:
图 13.7 – 由于未提供 JWT 而从 Socket.IO 发出的错误消息
- 要获取令牌,我们可以使用现有的博客前端正常注册和登录。然后,我们可以检查检查器的网络选项卡,以找到响应中包含令牌的**/login**请求:
图 13.8 – 从网络选项卡复制 JWT
- 将此令牌复制并添加到localStorage中,通过在浏览器控制台中运行localStorage.setItem('token', '')(将****替换为复制的令牌)。刷新页面后,它应该可以工作!正如我们所见,当我们用两个不同的用户登录时,我们可以看到他们带有各自用户名的消息:
图 13.9 – 从不同用户接收消息
我们聊天应用的后端现在完全功能正常!在下一章中,我们将创建一个前端来完善我们的聊天应用。
摘要
在本章中,我们学习了基于事件的应用程序、WebSockets 和 Socket.IO。然后,我们在后端(服务器)和前端(客户端)上设置了 Socket.IO。之后,我们学习了如何在服务器和客户端之间发送消息,如何加入房间,以及如何广播消息。我们还使用确认来获取有关请求-响应模式中用户的信息。最后,我们在 Socket.IO 中实现了使用 JWT 的身份验证,完成了我们的聊天应用后端。
在下一章第十四章,创建用于消费和发送事件的客户端,我们将创建我们的聊天应用的客户端,它将与我们本章创建的后端交互。
第十四章:创建一个用于消费和发送事件的前端
在上一章成功创建 Socket.IO 后端,并进行了第一次 Socket.IO 客户端实验后,现在让我们专注于实现一个前端来连接后端并消费和发送事件。
我们首先将清理我们的项目,通过从之前创建的博客应用中删除文件。然后,我们将实现一个 React Context 来初始化和存储我们的 Socket.IO 实例,利用现有的 AuthProvider 为与后端进行身份验证提供令牌。之后,我们将实现一个用于我们的聊天应用的接口,以及发送聊天消息和显示接收到的聊天消息的方法。最后,我们将实现带有确认的聊天命令,以显示我们当前所在的房间。
在本章中,我们将涵盖以下主要主题:
-
将 Socket.IO 客户端集成到 React 中
-
实现聊天功能
-
实现带有确认的聊天命令
技术要求
在我们开始之前,请安装来自 第一章 为全栈开发做准备 和 第二章 了解 Node.js 和 MongoDB 的所有要求。
那些章节中列出的版本是书中使用的版本。虽然安装较新版本可能不会出现问题,但请注意,某些步骤在较新版本上可能有所不同。如果您在这本书提供的代码和步骤中遇到问题,请尝试使用 第一章 和 第二章 中列出的版本。
您可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch14。
如果您克隆了本书的完整仓库,Husky 在运行 npm install 时可能找不到 .git 目录。在这种情况下,只需在相应章节文件夹的根目录中运行 git init。
本章的 CiA 视频可以在:youtu.be/d_TZK6S_XDU 找到。
将 Socket.IO 客户端集成到 React 中
让我们先清理项目,删除从博客应用中复制过来的所有旧文件。然后,我们将设置一个 Socket.IO 上下文,以便在 React 组件中更容易地初始化和使用 Socket.IO。最后,我们将创建第一个利用此上下文来显示我们的 Socket.IO 连接状态的组件。
清理项目
让我们先删除我们之前创建的博客应用中的文件夹和文件:
-
将现有的 ch13 文件夹复制到新的 ch14 文件夹中,如下所示:
$ cp -R ch13 ch14 -
在 VS Code 中打开 ch14 文件夹。
-
删除 以下文件夹和文件,因为它们仅适用于博客应用的后端:
-
backend/src/tests/
-
backend/src/example.js
-
backend/src/db/models/post.js
-
backend/src/routes/posts.js
-
backend/src/services/posts.js
-
-
在 backend/src/app.js 中,移除 以下导入:
import postRoutes from './routes/posts.js' -
此外,移除 postRoutes:
postRoutes(app) -
删除 以下文件夹和文件,因为它们仅用于博客应用的前端:
-
src/api/posts.js
-
src/components/CreatePost.jsx
-
src/components/Post.jsx
-
src/components/PostFilter.jsx
-
src/components/PostList.jsx
-
src/components/PostSorting.jsx
-
src/pages/Blog.jsx
-
现在我们已经清理了我们的项目,让我们开始实现我们新聊天应用的 Socket.IO 上下文。
创建 Socket.IO 上下文
到目前为止,我们一直在 src/App.jsx 组件中初始化 Socket.IO 客户端实例。然而,这样做有一些缺点:
-
要在其他组件中访问套接字,我们需要通过属性传递它。
-
我们在整个应用中只能有一个套接字连接。
-
从 AuthContext 中动态获取令牌是不可能的,这迫使我们将其存储在本地存储中。
-
我们的应用需要完全刷新才能加载新的令牌并与之连接。
-
我们仍然尝试连接,并在未登录时获取错误。
为了解决这些问题,我们可以创建一个 Socket.IO 上下文。然后我们可以使用提供者组件执行以下操作:
-
只有在 AuthContext 中有可用令牌时才连接到 Socket.IO。
-
存储 Socket.IO 连接的状态,并在组件中使用它,例如,仅在登录时显示聊天界面。
-
存储错误对象并在用户界面中显示错误。
以下图表显示了我们的连接状态将如何被跟踪:
图 14.1 – 连接的不同状态
如所见,套接字连接最初正在等待用户登录。一旦可用令牌,我们尝试建立套接字连接。如果成功,状态变为connected,否则变为error。如果套接字断开连接(例如,当互联网连接丢失时),状态设置为disconnected。
现在,让我们开始创建 Socket.IO 上下文:
-
创建一个新的 src/contexts/SocketIOContext.jsx 文件。
-
在此文件中,从 react、socket.io-client 和 prop-types 中导入以下函数:
import { createContext, useState, useContext, useEffect } from 'react' import { io } from 'socket.io-client' import PropTypes from 'prop-types' -
此外,从 AuthContext 中导入 useAuth 钩子以获取当前令牌:
import { useAuth } from './AuthContext.jsx' -
现在,定义一个带有一些初始值(socket、status 和 error)的 React 上下文:
export const SocketIOContext = createContext({ socket: null, status: 'waiting', error: null, }) -
接下来,定义一个提供者组件,在其中我们首先为上下文的不同值创建状态钩子:
export const SocketIOContextProvider = ({ children }) => { const [socket, setSocket] = useState(null) const [status, setStatus] = useState('waiting') const [error, setError] = useState(null) -
然后,使用 useAuth 钩子获取 JWT(如果可用):
const [token] = useAuth() -
创建一个效果钩子,检查令牌是否可用,如果可用,则尝试连接到 Socket.IO 后端:
useEffect(() => { if (token) { const socket = io(import.meta.env.VITE_SOCKET_HOST, { query: window.location.search.substring(1), auth: { token }, })就像之前一样,我们传递主机、
query字符串和auth对象。然而,现在我们从useAuth钩子而不是本地存储中获取令牌。 -
为connect、connect_error和disconnect事件创建处理程序,并分别设置status字符串和error对象:
socket.on('connect', () => { setStatus('connected') setError(null) }) socket.on('connect_error', (err) => { setStatus('error') setError(err) }) socket.on('disconnect', () => setStatus('disconnected')) -
设置socket对象并列出 effect 钩子所需的所有必要依赖项:
setSocket(socket) } }, [token, setSocket, setStatus, setError]) -
现在,我们可以返回提供者,将状态钩子中的所有值传递给它:
return ( <SocketIOContext.Provider value={{ socket, status, error }}> {children} </SocketIOContext.Provider> ) } -
最后,我们为上下文提供者组件设置PropTypes,并定义一个将简单地返回整个上下文的useSocket钩子:
SocketIOContextProvider.propTypes = { children: PropTypes.element.isRequired, } export function useSocket() { return useContext(SocketIOContext) }
现在我们有一个上下文来初始化我们的 Socket.IO 客户端,让我们将其连接并显示套接字连接的状态。
连接上下文并显示状态
我们现在可以从App组件中删除连接到 Socket.IO 的代码,并使用提供者,如下所示:
-
编辑src/App.jsx并删除以下导入:
import { io } from 'socket.io-client' -
向SocketIOContextProvider添加导入:
import { SocketIOContextProvider } from './contexts/SocketIOContext.jsx' -
然后,删除与 Socket.IO 连接相关的以下代码:
const socket = io(import.meta.env.VITE_SOCKET_HOST, { query: window.location.search.substring(1), auth: { token: window.localStorage.getItem('token'), }, }) socket.on('connect', async () => { console.log('connected to socket.io as', socket.id) socket.emit('chat.message', 'hello from client') const userInfo = await socket.emitWithAck('user.info', socket.id) console.log('user info', userInfo) }) socket.on('connect_error', (err) => { console.error('socket.io connect error:', err) }) socket.on('chat.message', (message) => { console.log(message) }) -
在App组件内部,渲染上下文提供者:
export function App() { return ( <QueryClientProvider client={queryClient}> <AuthContextProvider> <SocketIOContextProvider> <RouterProvider router={router} /> </SocketIOContextProvider> </AuthContextProvider> </QueryClientProvider> ) }
在连接 Socket.IO 上下文之后,让我们继续创建一个用于显示状态的Status组件。
创建一个状态组件
现在,让我们创建一个Status组件来显示套接字当前的状态:
-
创建一个新的src/components/Status.jsx文件。
-
在其中,从我们的SocketIOContext导入useSocket钩子:
import { useSocket } from '../contexts/SocketIOContext.jsx' -
定义一个Status组件,其中我们从钩子中获取status字符串和error对象:
export function Status() { const { status, error } = useSocket() -
渲染套接字状态:
return ( <div> Socket status: <b>{status}</b> -
如果我们有一个error对象,我们现在还可以显示错误信息:
{error && <i> - {error.message}</i>} </div> ) }
现在我们有一个Status组件,让我们创建一个Chat页面组件,在其中渲染Header和Status组件。
创建一个聊天页面组件
我们之前在我们的博客应用中有一个Blog页面,我们在本章早期删除了它。现在,让我们为我们的聊天应用创建一个新的Chat页面组件:
-
创建一个新的src/pages/Chat.jsx文件。
-
在其中,导入Header组件(我们将从Blog应用中重用)和Status组件:
import { Header } from '../components/Header.jsx' import { Status } from '../components/Status.jsx' -
渲染一个Chat组件,在其中显示Header和Status组件:
export function Chat() { return ( <div style={{ padding: 8 }}> <Header /> <br /> <hr /> <br /> <Status /> </div> ) } -
编辑src/App.jsx并定位到以下导入:
import { Blog } from './pages/Blog.jsx'替换为对
Chat组件的导入:import { Chat } from './pages/Chat.jsx' -
最后,替换主路径中的**组件为**组件:
const router = createBrowserRouter( { path: '/', element: <Chat />, },
启动和测试我们的聊天应用前端
我们现在可以启动并测试我们的聊天应用前端:
-
按照以下方式运行前端:
$ npm run dev -
按照以下方式运行后端(确保 Docker 和数据库容器正在运行!):
$ cd backend/ $ npm run dev
![图 14.2 – 套接字连接等待用户登录图 14.2 – 套接字连接等待用户登录 1. 登录(如果您还没有,请创建一个新用户),套接字应该成功连接:
图 14.3 – 用户登录后套接字已连接
注销时断开套接字
你可能已经注意到,当按下 注销 时,套接字仍然保持连接。现在,让我们修复这个问题,通过在注销时断开套接字。
-
编辑 src/components/Header.jsx 并导入 useSocket 钩子:
import { useSocket } from '../contexts/SocketIOContext.jsx' -
从 useSocket 钩子中获取套接字,如下所示:
export function Header() { const [token, setToken] = useAuth() const { socket } = useSocket() -
定义一个新的 handleLogout 函数,它断开套接字并重置令牌:
const handleLogout = () => { socket.disconnect() setToken(null) } -
最后,将 onClick 处理器设置为 handleLogout 函数:
<button onClick={handleLogout}>Logout</button>
现在,当你注销时,套接字将会断开连接,如下面的截图所示:
图 14.4 – 注销后套接字已断开
现在 Socket.IO 客户端已成功集成到我们的 React 前端,我们可以继续在前端实现聊天功能。
实现 chat 功能
我们现在将实现发送和接收消息的功能。首先,我们将实现所有需要的组件。然后,我们将创建一个 useChat 钩子来实现与套接字连接的接口并提供发送/接收消息的函数。最后,我们将通过创建聊天室来将这些功能组合在一起。
实现 chat 组件
我们将实现以下聊天组件:
-
ChatMessage:用于显示聊天消息
-
EnterMessage:一个输入新消息的字段和一个发送它们的按钮
实现 ChatMessage 组件
让我们先实现 ChatMessage 组件:
-
创建一个新的 src/components/ChatMessage.jsx 文件,它将渲染聊天消息。
-
导入 PropTypes 并定义一个新的函数,带有 username 和 message 属性:
import PropTypes from 'prop-types' export function ChatMessage({ username, message }) { -
以粗体形式渲染用户名,并在其旁边显示消息:
return ( <div> <b>{username}</b>: {message} </div> ) } -
定义属性类型,如下所示:
ChatMessage.propTypes = { username: PropTypes.string.isRequired, message: PropTypes.string.isRequired, }
实现 EnterMessage 组件
现在,让我们创建 EnterMessage 组件,它将允许用户发送新的聊天消息:
-
创建一个新的 src/components/EnterMessage.jsx 文件。
-
导入 useState 钩子和 PropTypes:
import { useState } from 'react' import PropTypes from 'prop-types' -
定义一个新的 EnterMessage 组件,它接收一个 onSend 函数作为属性:
export function EnterMessage({ onSend }) { -
我们存储输入的消息的当前状态:
const [message, setMessage] = useState('') -
然后,我们定义一个函数来处理发送请求并在之后清除字段:
function handleSend(e) { e.preventDefault() onSend(message) setMessage('') }
提醒
因为我们是使用 submit 按钮提交表单,所以我们需要调用 e.preventDefault() 来防止表单刷新页面。
-
渲染一个表单,包含一个输入字段来输入消息和一个按钮来发送它:
return ( <form onSubmit={handleSend}> <input type='text' value={message} onChange={(e) => setMessage(e.target.value)} /> <input type='submit' value='Send' /> </form> ) } -
定义属性类型,如下所示:
EnterMessage.propTypes = { onSend: PropTypes.func.isRequired, }
实现 useChat 钩子
为了将所有逻辑组合在一起,我们将实现一个 useChat 钩子,它将处理发送和接收消息,以及将所有当前消息存储在状态钩子中。按照以下步骤实现它:
-
创建一个新的 src/hooks/ 文件夹。在其内部,创建一个新的 src/hooks/useChat.js 文件。
-
从 React 中导入 useState 和 useEffect 钩子:
import { useState, useEffect } from 'react' -
从我们的上下文中导入 useSocket 钩子:
import { useSocket } from '../contexts/SocketIOContext.jsx' -
定义一个新的 useChat 函数,其中我们从 useSocket 钩子获取套接字,并定义一个状态钩子来存储消息数组:
export function useChat() { const { socket } = useSocket() const [messages, setMessages] = useState([]) -
接下来,定义一个 receiveMessage 函数,该函数将新消息追加到数组中:
function receiveMessage(message) { setMessages((messages) => [...messages, message]) } -
现在,创建一个效果钩子,在其中我们使用 socket.on 创建一个监听器:
useEffect(() => { socket.on('chat.message', receiveMessage) -
我们需要确保在效果钩子卸载时再次使用 socket.off 移除监听器,否则在组件重新渲染或卸载时我们可能会得到多个监听器:
return () => socket.off('chat.message', receiveMessage) }, []) -
现在,接收消息应该可以正常工作。让我们继续发送消息。为此,我们创建一个 sendMessage 函数,该函数使用 socket.emit 来发送消息:
function sendMessage(message) { socket.emit('chat.message', message) } -
最后,返回 messages 数组和 sendMessage 函数,以便我们可以在我们的组件中使用它们:
return { messages, sendMessage } }
现在我们已经成功实现了 useChat 钩子,让我们使用它!
实现 ChatRoom 组件
最后,我们可以把它们全部放在一起,并实现一个 ChatRoom 组件。按照以下步骤开始:
-
创建一个新的 src/components/ChatRoom.jsx 文件。
-
导入 useChat 钩子和 EnterMessage 以及 ChatMessage 组件:
import { useChat } from '../hooks/useChat.js' import { EnterMessage } from './EnterMessage.jsx' import { ChatMessage } from './ChatMessage.jsx' -
定义一个新的组件,该组件从 useChat 钩子获取 messages 数组和 sendMessage 函数:
export function ChatRoom() { const { messages, sendMessage } = useChat() -
然后,将消息列表渲染为 ChatMessage 组件:
return ( <div> {messages.map((message, index) => ( <ChatMessage key={index} {...message} /> ))} -
接下来,渲染 EnterMessage 组件,并将 sendMessage 函数作为 onSend 属性传递:
<EnterMessage onSend={sendMessage} /> </div> ) } -
编辑 src/pages/Chat.jsx 并导入 ChatRoom 组件和 useSocket 钩子:
import { ChatRoom } from '../components/ChatRoom.jsx' import { useSocket } from '../contexts/SocketIOContext.jsx' -
从 Chat 页面组件中的 useSocket 钩子获取状态:
export function Chat() { const { status } = useSocket() -
如果状态是 已连接,我们显示 ChatRoom 组件:
return ( <div style={{ padding: 8 }}> <Header /> <br /> <hr /> <br /> <Status /> <br /> <hr /> <br /> {status === 'connected' && <ChatRoom />} -
现在,在您的浏览器中转到 http://localhost:5173/ 并使用用户名和密码登录。套接字连接并渲染聊天室。输入一条聊天消息,并通过按 Return/Enter 或点击 发送 按钮发送它。您将看到消息被接收并显示出来!
-
打开第二个浏览器窗口并使用第二个用户登录。在那里发送另一条消息。您将看到消息被两个用户接收,如下面的截图所示:
图 14.5 – 从不同用户发送和接收消息
现在我们有一个基本的聊天应用正在运行,让我们探索如何使用确认来实现聊天命令。
使用确认实现聊天命令
除了发送和接收消息外,聊天应用通常还提供了一种向客户端和/或服务器发送命令的方式。例如,我们可以发送一个 /clear 命令来清除我们的本地消息列表。或者,我们可以发送一个 /rooms 命令来获取我们所在的房间列表。按照以下步骤实现聊天命令:
-
编辑src/hooks/useChat.js并调整其中的sendMessage函数。首先,让我们将其改为async函数:
async function sendMessage(message) { -
替换函数的内容如下。我们首先检查消息是否以斜杠(/)开头。如果是,那么我们通过删除斜杠来获取命令,并使用switch语句:
if (message.startsWith('/')) { const command = message.substring(1) switch (command) { -
对于clear命令,我们只需将消息数组设置为空数组:
case 'clear': setMessages([]) break -
对于rooms命令,我们通过使用socket.emitWithAck和我们的socket.id来获取用户信息:
case 'rooms': { const userInfo = await socket.emitWithAck('user.info', socket.id) -
然后,我们获取房间列表,过滤掉我们自动加入的带有我们socket.id名称的房间:
const rooms = userInfo.rooms.filter((room) => room !== socket.id) -
我们重用receiveMessage函数从服务器发送消息,告诉我们我们所在的房间:
receiveMessage({ message: `You are in: ${rooms.join(', ')}`, }) break }注意,这里我们没有发送用户名,只是发送消息。我们稍后必须调整
ChatMessage组件以适应这一点。 -
如果我们收到任何其他命令,我们将显示一个错误消息:
default: receiveMessage({ message: `Unknown command: ${command}`, }) break } -
否则(如果消息没有以斜杠开头),我们就像之前一样简单地发出聊天消息:
} else { socket.emit('chat.message', message) } } -
最后,编辑src/components/ChatMessage.jsx并调整组件以在未提供用户名时渲染系统消息:
export function ChatMessage({ username, message }) { return ( <div> {username ? ( <span> <b>{username}</b>: {message} </span> ) : ( <i>{message}</i> )} </div> ) } -
不要忘记调整PropTypes以使用户名可选(通过从username属性中移除**.isRequired**):
ChatMessage.propTypes = { username: PropTypes.string,
图 14.6 – 发送/rooms 命令
注意
由于登录后查询参数被清除,目前无法加入不同的房间。在下一章中,我们将重构聊天应用并实现**/join**命令以加入不同的房间。
摘要
在本章中,我们为我们的聊天应用后端实现了一个前端。我们首先通过创建一个上下文和自定义钩子来集成 Socket.IO 客户端和 React。然后,我们使用AuthProvider获取令牌以在连接到 socket 时验证用户。之后,我们显示了我们的 socket 状态。然后,我们实现了聊天应用界面以发送和接收消息。最后,我们通过使用确认来获取我们所在的房间实现了聊天命令。
在下一章,第十五章,使用 MongoDB 为 Socket.IO 添加持久性,我们将学习如何使用 MongoDB 和 Socket.IO 存储和回放之前发送的消息。