React-全栈项目第二版-全-
React 全栈项目第二版(全)
原文:
zh.annas-archive.org/md5/35c59f78351aeb34721c43c78c53c92a译者:飞龙
前言
本书通过结合 React 的强大功能和经过行业验证的服务端技术(如 Node.js、Express 和 MongoDB)来探讨全栈 JavaScript Web 应用的开发。JavaScript 领域已经迅速发展了一段时间。在有关全栈 JavaScript Web 应用方面,有大量的选项和资源可用,当你需要从这些经常变化的实体中选择、了解它们并使它们协同工作以构建自己的 Web 应用时,很容易感到迷茫。为了解决这个痛点,本书采用了一种实用方法,帮助你使用流行的 MERN 框架搭建和构建各种工作应用。
本书面向的对象
本书面向的是可能使用过 React 但全栈开发(涉及 Node.js、Express 和 MongoDB)经验有限的 JavaScript 开发者。
本书涵盖的内容
第一章,利用 MERN 释放 React 应用的力量,介绍了 MERN 框架技术以及本书将要开发的应用。我们将讨论使用 React、Node.js、Express 和 MongoDB 开发 Web 应用。
第二章,准备开发环境,帮助你设置 MERN 框架技术以进行开发。我们将探讨基本开发工具;安装 Node.js、MongoDB、Express、React 以及任何其他必需的库;然后运行代码以检查设置。
第三章,使用 MongoDB、Express 和 Node 构建后端,实现了 MERN 框架应用的骨架后端。我们将构建一个使用 MongoDB、Express 和 Node.js 的独立服务器端应用,存储用户详细信息,并提供用户认证和 CRUD 操作的 API。
第四章,添加 React 前端以完善 MERN,通过集成 React 前端来完善 MERN 框架应用。我们将实现一个具有 React 视图的前端,用于与服务器上的用户 CRUD 操作和认证 API 交互。
第五章,将骨架扩展为社交媒体应用,通过扩展骨架应用来构建一个社交媒体应用。我们将通过实现社交媒体功能(如帖子分享、点赞、评论、关注朋友和聚合新闻源)来探索 MERN 框架的能力。
第六章,构建基于 Web 的课堂应用,专注于通过扩展 MERN 框架骨架应用来构建一个简单的在线课堂应用。这个课堂应用将支持多个用户角色,添加课程内容和课程,学生注册,进度跟踪以及课程注册统计。
第七章,使用在线市场锻炼 MERN 技能,利用 MERN 框架技术开发在线市场应用的基本功能。我们将实现买卖相关功能,包括卖家账户、产品列表和按类别搜索产品。
第八章,扩展市场以支持订单和支付,专注于通过实现买家添加产品到购物车、结账和下订单的能力,以及卖家管理这些订单和从市场应用中处理支付的能力来扩展我们在上一章中构建的在线市场。我们还将集成 Stripe 以收集和处理支付。
第九章,为市场添加实时竞价功能,专注于教你如何使用 MERN 框架技术以及 Socket.IO,轻松集成全栈应用中的实时行为。我们将通过在 MERN 市场应用中集成具有实时竞价功能的拍卖功能来实现这一点。
第十章,将数据可视化集成到支出跟踪应用中,专注于使用 MERN 框架技术以及 Victory——一个用于 React 的图表库,以轻松集成全栈应用中的数据可视化功能。我们将扩展 MERN 框架基础应用以构建一个支出跟踪应用,该应用将包含用户随时间记录的支出数据处理和可视化功能。
第十一章,构建媒体流应用,专注于扩展 MERN 框架基础应用以构建一个使用 MongoDB GridFS 的媒体上传和流应用。我们将从构建一个基本的媒体流应用开始,允许注册用户上传视频文件,这些文件将被存储在 MongoDB 中并通过流回放,以便观众可以在简单的 React 媒体播放器中播放每个视频。
第十二章,定制媒体播放器和优化 SEO,通过定制媒体播放器和自动播放媒体列表升级我们的媒体应用观看能力。我们将实现自定义控制默认 React 媒体播放器,添加一个可以自动播放的播放列表,并通过添加仅针对媒体详情视图的数据的选区服务器端渲染来优化媒体详情的 SEO。
第十三章,开发基于 Web 的 VR 游戏,使用 React 360 开发一个三维虚拟现实(VR)游戏。我们将探索 React 360 的三维和 VR 功能,并构建一个简单的基于 Web 的 VR 游戏。
第十四章,使用 MERN 使 VR 游戏动态化,您将通过扩展 MERN 骨架应用程序并集成 React 360 来构建一个动态的 VR 游戏应用程序。我们将实现一个游戏数据模型,使用户能够创建自己的 VR 游戏,并将动态游戏数据与使用 React 360 开发的游戏相结合。
第十五章,遵循最佳实践并进一步开发 MERN,回顾了前几章学到的经验教训,并提出了进一步基于 MERN 的应用程序开发的改进建议。我们将扩展一些已经应用的最佳实践,例如应用程序结构中的模块化,其他应该应用的做法,例如编写测试代码,以及可能的改进,例如优化包大小。
为了充分利用这本书
本书假设您熟悉基本的网络技术,了解 JavaScript 中的编程结构,并对 React 应用程序的工作原理有一个大致的了解。随着您阅读本书,您将发现这些概念如何在构建使用 React 16.13.1、Node.js 13.12.0、Express 4.17.1 和 MongoDB 4.2.5 的完整功能网络应用程序时结合在一起。
为了在阅读章节时最大限度地提高您的学习体验,建议您并行运行相关应用程序代码,保持指定的包版本,并使用每章中提供的相关说明。
如果您使用的是这本书的数字版,我们建议您自己输入代码或通过 GitHub 仓库(下一节中提供链接)访问代码。这样做将帮助您避免与代码复制粘贴相关的任何潜在错误。
下载示例代码文件
您可以从www.packt.com的账户下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在www.packt.com登录或注册。
-
选择“支持”标签。
-
点击“代码下载”。
-
在搜索框中输入书名,并遵循屏幕上的说明。
文件下载完成后,请确保使用最新版本的以下软件解压或提取文件夹:
-
Windows 版的 WinRAR/7-Zip
-
Mac 版的 Zipeg/iZip/UnRarX
-
Linux 版的 7-Zip/PeaZip
该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Full-Stack-React-Projects-Second-Edition。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富的书籍和视频目录的代码包,可在 github.com/PacktPublishing/ 上找到。查看它们吧!
使用的约定
在本书中使用了多种文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 处理。以下是一个示例:“将下载的 WebStorm-10*.dmg 磁盘映像文件作为系统中的另一个磁盘挂载。”
代码块按以下方式设置:
addItem(item, cb) {
let cart = []
if (typeof window !== "undefined") {
if (localStorage.getItem('cart')) {
cart = JSON.parse(localStorage.getItem('cart'))
}
cart.push({
product: item,
quantity: 1,
shop: item.shop._id
})
localStorage.setItem('cart', JSON.stringify(cart))
cb()
}
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
<Grid container spacing={24}>
<Grid item xs={6} sm={6}>
<CartItems checkout={checkout}
setCheckout={showCheckout}/>
</Grid>
{checkout &&
<Grid item xs={6} sm={6}>
<Checkout/>
</Grid>}
</Grid>
任何命令行输入或输出都按以下方式编写:
yarn add --dev @babel/preset-react
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“从管理面板中选择系统信息。”
警告或重要提示看起来像这样。
小技巧和技巧看起来像这样。
联系我们
我们欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并通过 customercare@packtpub.com 邮箱联系我们。
勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,我们将不胜感激。请访问 www.packtpub.com/support/errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上以任何形式发现我们作品的非法副本,我们将不胜感激,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packt.com 联系我们,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com。
评论
请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 公司可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
有关 Packt 的更多信息,请访问 packt.com。
第一章:使用 MERN 入门
在这部分,我们将对 MERN 进行介绍,并概述其不同组件。此外,在您开始使用这些技术开发完整的 Web 应用程序之前,您将了解如何正确配置您的开发环境。
本节包括以下章节:
-
第一章,利用 MERN 解放 React 应用程序
-
第二章,准备开发环境
第二章:使用 MERN 激活 React 应用程序
React 可能已经为前端网络开发开辟了新的领域,并改变了我们编写 JavaScript 用户界面的方式,但我们需要一个坚实的后端来构建完整的网络应用程序。虽然在选择后端技术时有很多选择,但使用全 JavaScript 栈的好处和吸引力是无可否认的,尤其是在有像 Node、Express 和 MongoDB 这样强大且广泛采用的后端技术时。将 React 的潜力与这些经过行业检验的服务端技术相结合,在开发实际网络应用程序时创造了多样化的可能性。本书将引导您通过设置基于 MERN(即 MongoDB, Express.js, React, 和 Node.js)的 Web 开发,到构建不同复杂度的实际网络应用程序。
在深入开发这些网络应用程序之前,我们将在本章中回答以下问题,以便您能够有效地使用这本书来获取全栈开发技能,并了解选择 MERN 栈构建应用程序背后的背景:
-
第二版有哪些新内容?
-
这本书是如何组织来帮助掌握 MERN 的?
-
MERN 栈是什么?
-
为什么 MERN 在今天仍然相关?
-
在什么情况下 MERN 是开发网络应用程序的好选择?
第二版有哪些新内容?
MERN 栈技术以及整个全栈开发生态系统正在随着行业采用和使用的增加而持续增长和改进。在本版中,我们考虑了这些新发展,并更新了第一版中的所有应用程序和相应的代码库。
我们使用每个技术、库和模块的最新版本和约定,这些技术、库和模块对于开发相关的设置和功能实现都是必需的。此外,我们还强调了使用这些技术升级中的新功能,例如 React Hooks 和 JavaScript 中的 async/await。
为了展示 MERN 栈更多的可能性,我们更新了现有的市场应用程序,添加了更高级的功能,例如实时竞标。我们还添加了两个新项目,一个基于网络的教室应用程序和一个具有数据可视化功能的支出跟踪应用程序。
为了更好地理解本书涵盖的内容和概念,我们扩展了解释并提供了一些最新资源的线索,这些资源可能有助于您更深入地掌握并提高您的学习体验。
除了涵盖 MERN 技术的最新更新并提供详细的解释外,本书中的概念和项目组织旨在帮助您从简单到高级主题灵活学习。在下一节中,我们将讨论本书的结构以及您如何根据自己的偏好和经验水平来利用它。
书的结构
本书旨在帮助那些对 MERN 堆栈有一定经验或无经验的 JavaScript 开发者设置并开始开发不同复杂度的 Web 应用程序。它包括构建和运行不同应用程序的指南,辅以代码片段和关键概念的说明。
本书分为五个部分,从基础知识到高级主题逐步深入,带您从零开始构建 MERN,然后使用它开发具有简单到复杂功能的不同应用程序,同时展示如何根据应用程序的需求扩展 MERN 堆栈的功能。
开始使用 MERN
第一章,利用 MERN 解放 React 应用程序,以及 第二章,准备开发环境,为在 MERN 堆栈中开发 Web 应用程序设定了背景,并指导您设置开发环境。
从零开始构建 MERN 应用程序
第三章,使用 MongoDB、Express 和 Node 构建 Backend,和 第四章,添加 React 前端以完成 MERN,向您展示如何将 MERN 堆栈技术结合起来,形成一个具有最小和基本功能的骨架 Web 应用程序。第五章将骨架扩展成社交媒体应用程序,展示了这个骨架 MERN 应用程序如何作为一个基础,并容易被扩展来构建一个简单的社交媒体平台。这种扩展和自定义基础应用程序的能力将在本书其余部分开发的其他应用程序中得到应用。
使用 MERN 开发 Web 应用程序
在本部分,您将通过构建两个真实世界的应用程序来更加熟悉 MERN 堆栈 Web 应用程序的核心属性——一个基于 Web 的课堂应用程序在 第六章构建基于 Web 的课堂应用程序,以及一个功能丰富的在线市场在 第七章使用在线市场锻炼 MERN 技能,第八章扩展市场以支持订单和支付,和 第九章向市场添加实时竞标功能。
向复杂 MERN 应用程序迈进
第十章,将数据可视化集成到支出跟踪应用中,第十一章,构建媒体流应用,第十二章,定制媒体播放器和改进 SEO,第十三章,开发基于 Web 的 VR 游戏,和第十四章,使用 MERN 使 VR 游戏动态化,展示了如何使用 React 360 利用这个堆栈开发具有更复杂和沉浸式功能的应用,如数据可视化、媒体流和虚拟现实(VR)。
MERN 的进一步发展
最后,第十五章,遵循最佳实践并进一步开发 MERN,通过扩展遵循以成功构建 MERN 应用的最佳实践,提出改进和进一步发展的建议。
您可以根据自己的经验水平和喜好选择不按规定的顺序使用这本书。对于刚开始接触 MERN 的开发者,可以遵循书中的路径。对于经验更丰富的 JavaScript 开发者,从零开始构建 MERN部分中的章节将是设置基础应用的好起点,然后可以选择任何六个应用进行构建和扩展。
这种结构是为了使具有不同背景的开发者能够进行实践学习。为了最大限度地实现这一目标,我们建议采用一种实用的方法来跟随书中的材料,如下一节更详细地描述。
充分利用本书
本书的内容以实用为导向,涵盖了构建每个 MERN 应用相关的实现步骤、代码和概念。然而,大多数代码解释将引用可能包含更多行代码的文件中的特定代码片段,这些片段构成了完整且可工作的应用代码。
简单来说,强烈建议您不要只是阅读章节,而应该并行运行相关的代码,并在阅读书中的解释的同时浏览应用的功能。
讨论代码实现的章节将指向包含完整代码的 GitHub 仓库,并提供了如何运行代码的说明。您可以在阅读章节之前拉取代码、安装它并运行它:

您应该考虑这里概述的推荐步骤来遵循本书中的实现:
-
在深入讨论章节中讨论的实现细节之前,从相关的 GitHub 仓库中拉取代码。
-
按照代码中的说明安装并运行应用。
-
在阅读相关章节中的功能描述时,浏览运行中应用程序的功能。
-
当代码以开发模式运行并在编辑器中打开时,参考书中的步骤和解释,以获得对实现的更深入理解。
本书旨在为每个应用程序提供快速入门,并提供工作代码。您可以按需进行实验、改进和扩展此代码。为了获得积极的 学习体验,鼓励您在遵循本书的同时重构和修改代码。在某些示例中,本书选择使用冗长的代码而非简洁且更干净的代码,因为对于初学者来说,冗长的代码更容易理解。在另一些实现中,本书坚持使用更广泛使用的传统约定,而不是现代和即将到来的 JavaScript 约定。这样做是为了最小化在您自己研究讨论的技术和概念时,参考在线资源和文档时的差异。这些代码可以更新的实例,是探索和提升超出本书涵盖范围技能的好机会。
现在,您应该对本书的预期内容以及如何充分利用其内容和结构有一个整体的认识,随着我们继续讨论 MERN 栈的细节,并开始揭示其潜力。
MERN 栈
MongoDB、Express、React 和 Node 一起用于构建 Web 应用程序,并组成 MERN 栈。在这个阵容中,Node 和 Express 将 Web 后端绑定在一起,MongoDB 作为 NoSQL 数据库,React 创建用户看到的交互式前端。
所有这四种技术都是免费的、开源的、跨平台的,基于 JavaScript,拥有广泛的社区和行业支持。每种技术都有一套独特的属性,当它们集成在一起时,就构成了一个简单但有效的全 JavaScript 栈,适用于 Web 开发。
由于这些是独立的技术,因此识别这些作为项目中需要配置、组合和扩展以满足项目特定要求的动态部分也很重要。即使您不是这个栈中所有技术的专家,您也需要熟悉每个技术,并理解它们如何协同工作。
Node
Node 是在 Chrome 的 V8 JavaScript 引擎上开发的 JavaScript 运行时环境。Node 使得在服务器端使用 JavaScript 成为可能,从而可以构建各种工具和应用程序,而之前的用例仅限于在浏览器内使用。
Node 具有事件驱动的架构,能够进行异步、非阻塞的I/O(简称输入/输出)。其独特的非阻塞 I/O 模型消除了等待处理请求的方法。这使得您可以构建可扩展且轻量级的实时 Web 应用程序,能够高效地处理大量请求。
Node 的默认包管理系统,Node 包管理器或npm,与 Node 安装捆绑在一起。npm为您提供了访问由全球开发者构建的数十万个可重用 Node 包的权限,并声称它是目前世界上最大的开源库生态系统。
在nodejs.org/en/了解 Node,并浏览可用的npm注册表www.npmjs.com/。
然而,npm并不是您可用的唯一包管理系统。Yarn 是由 Facebook 开发的一个较新的包管理器,近年来越来越受欢迎。它可以作为npm的替代品使用,可以访问npm注册表中的所有相同模块,以及npm尚未提供的更多功能。
在yarnpkg.com了解 Yarn 及其功能。
Node 将使我们能够构建和运行完整的全栈 JavaScript 应用程序。然而,为了实现具有 API 路由等特定于 Web 应用程序的功能的可扩展服务器端应用程序,我们将在 Node 之上使用 Express 模块。
Express
Express 是一个简单的服务器端 Web 框架,用于使用 Node 构建 Web 应用程序。它通过提供 HTTP 实用方法和中间件功能,为 Node 添加了一层基本的 Web 应用程序功能。
从一般意义上讲,任何应用程序中的中间件功能使不同的组件能够添加在一起协同工作。在服务器端 Web 应用程序框架的特定上下文中,中间件函数可以访问 HTTP 请求-响应管道,这意味着可以访问请求-响应对象以及 Web 应用程序请求-响应周期中的下一个中间件函数。
在任何使用 Node 开发的 Web 应用程序中,Express 可以用作 API 路由和中间件 Web 框架。几乎可以将任何兼容的中间件插入到请求处理链中,几乎可以以任何顺序,这使得 Express 非常灵活。
在expressjs.com了解 Express.js 能实现什么。
在我们将要开发的基于 MERN 的应用程序中,Express 可以用于在服务器端处理 API 路由,向客户端提供静态文件,通过认证集成限制对资源的访问,实现错误处理,以及本质上添加任何中间件包,以根据需要扩展 Web 应用程序的功能。
在任何完整的 Web 应用程序中,数据存储系统都是一个关键功能。Express 模块没有定义要求或对将数据库集成到 Node-Express Web 应用程序施加限制。因此,这为您提供了选择任何数据库选项的灵活性,无论是关系型数据库(如 PostgreSQL)还是 NoSQL 数据库(如 MongoDB)。
MongoDB
在为任何应用程序选择 NoSQL 数据库时,MongoDB 是首选。它是一个面向文档的数据库,以灵活的、类似 JSON 的文档存储数据。这意味着字段可以因文档而异,数据模型可以根据不断变化的应用程序需求随时间演变。
重视可用性和可伸缩性的应用程序可以从 MongoDB 的分布式架构特性中受益。它内置了对高可用性的支持,使用分片进行水平扩展,以及跨地理分布的多数据中心可伸缩性。
MongoDB 拥有强大的查询语言,能够实现即席查询、快速查找的索引以及实时聚合,这些功能提供了强大的数据访问和分析方式,即使在数据量呈指数级增长的情况下也能保持性能。
在www.mongodb.com/探索 MongoDB 的功能和服务。
选择 MongoDB 作为 Node 和 Express Web 应用程序的数据库,将使您拥有一个完全基于 JavaScript 的独立服务器端应用程序。这将为您留下集成客户端界面的选择,该界面可能使用兼容的前端库(如 React)构建,以完成全栈应用程序。
React
React 是一个用于构建用户界面的声明式和组件化 JavaScript 库。其声明性和模块化特性使得开发者能够轻松创建和维护可重用、交互式和复杂用户界面。
如果使用 React 构建,大量显示动态数据的大型应用程序可以快速且响应灵敏,因为它会高效地更新和渲染仅在特定数据发生变化时所需的用户界面组件。React 通过其虚拟 DOM 的显著实现进行这种高效的渲染,这使得 React 与其他直接在浏览器 DOM 中进行昂贵操作处理页面更新的其他 Web 用户界面库区分开来。
使用 React 开发用户界面也迫使前端程序员编写逻辑清晰、模块化的代码,这些代码可重用且易于调试、测试和扩展。
查看reactjs.org/上的 React 资源。
由于所有四种技术都是基于 JavaScript 的,这些技术本身就是为了集成而优化的。然而,在实际应用中,如何将这些技术组合起来形成 MERN 栈,这取决于应用需求和开发者的偏好,这使得 MERN 栈可以根据特定需求进行定制和扩展。这个栈是否适合你的下一个全栈 Web 项目,不仅取决于它能否满足你的需求,还取决于它在行业中的表现以及这些技术的发展方向。
MERN 的相关性
自从 JavaScript 诞生以来,它已经走得很远,并且一直在不断发展。MERN 栈技术挑战了现状,并为 JavaScript 的可能性开辟了新的天地。但是,当涉及到开发需要可持续性的实际应用时,它是一个值得的选择吗?以下几节简要概述了选择 MERN 作为下一个 Web 应用程序的理由。
技术栈的一致性
由于 JavaScript 被广泛使用,开发者不需要频繁地学习和转换以与非常不同的技术一起工作。这也使得在负责 Web 应用程序不同部分的团队之间进行更好的沟通和理解成为可能。
学习、开发、部署和扩展所需的时间更少
整个技术栈的一致性也使得学习和使用 MERN 变得容易,减少了采用新栈的 overhead 和开发工作产品的耗时。一旦 MERN 应用程序的工作基础建立并确立了工作流程,复制、进一步开发或扩展任何应用程序所需的努力就会减少。
在行业中广泛采用
所有规模的组织都在根据他们的需求采用这个栈中的技术,因为它们可以更快地构建应用程序,处理高度多样化的需求,并在规模上更有效地管理应用程序。
社区支持和增长
围绕非常流行的 MERN 栈技术的开发者社区非常多样化,并且正在不断增长。由于很多人持续使用、修复、更新并愿意帮助这些技术发展,可预见的未来支持系统将保持强大。这些技术将继续得到维护,并且很可能在文档、附加库和技术支持方面提供资源。
使用这些技术的便捷性和好处已经得到了广泛的认可。由于继续采用和适应这些技术的知名公司,以及不断有更多的人为代码库做出贡献、提供支持和创建资源,MERN 栈中的技术将继续在未来一段时间内保持相关性。
为了确定这个广泛采用的堆栈是否满足您项目的特定要求,您可以探索使用这一组技术可能实现的功能范围。在下一节中,我们将突出介绍这个堆栈的一些方面,以及本书示例应用程序的几个功能,以展示这些技术所提供的多样化选项。
MERN 应用程序的范围
考虑到每个技术所具有的独特特性,以及通过整合其他技术轻松扩展此堆栈功能性的便利性,使用此堆栈可以构建的应用程序范围实际上非常广泛。
现在,Web 应用程序默认预期是丰富的客户端应用程序,它们具有沉浸式、交互式,并且在性能或可用性方面不会有所欠缺。MERN 技术的优势组合使其非常适合开发满足这些特定方面和需求的应用程序。
此外,一些这些技术的创新和即将推出的特性,例如 Node 的底层操作操作、MongoDB GridFS 的大文件流能力,以及使用 React 360 在 Web 上实现 VR 功能,使得使用 MERN 构建更加复杂和独特的应用程序成为可能。
可能有人会挑选 MERN 技术中的特定功能,并争论为什么它们不适合某些应用程序。然而,鉴于 MERN 堆栈的灵活性和可扩展性,这些担忧可以在 MERN 中逐个案例解决。在本书中,我们将演示如何面对构建应用程序中的特定要求和需求时进行此类考虑。
本书将介绍在 MERN 框架下开发的应用程序
为了展示 MERN 的广泛可能性以及如何轻松地构建具有不同功能的 Web 应用程序,本书将展示日常使用的 Web 应用程序以及复杂和罕见的 Web 体验。
社交媒体平台
对于第一个 MERN 应用程序,我们将构建一个受 Twitter 和 Facebook 启发的基本社交媒体应用程序,如下所示:

这个社交媒体平台将实现简单的功能,如帖子分享、点赞和评论、关注朋友以及聚合新闻源。
基于 Web 的教室应用程序
远程或在线学习现在是常见的做法,讲师和学生都利用互联网连接在在线平台上进行教学和学习。我们将使用 MERN 实现一个简单的基于 Web 的教室应用程序,其外观如下所示:

这个教室将具备一些功能,允许讲师添加包含课程的课程,同时学生可以报名参加这些课程并跟踪他们的进度。
在线市场
互联网上充满了各种电子商务 Web 应用程序,它们不会很快过时。使用 MERN,我们将构建一个具有从基础到高级电子商务功能的综合在线市场应用程序。以下截图显示了市场完成的首页,包括产品列表:

这个市场应用的功能将涵盖支持卖家账户、产品列表、客户购物车、支付处理、订单管理和实时竞标能力等方面。
支出跟踪应用
将数据可视化添加到任何数据密集型应用可以显著提高其价值。我们将通过扩展 MERN 来添加支出跟踪应用,以展示你如何在全栈 MERN 应用程序中集成数据可视化功能,包括图表和图形。以下截图显示了支出跟踪应用完成的首页,概述了用户的当前支出:

使用这个应用程序,用户将能够跟踪他们的日常支出。应用程序将随着时间的推移添加支出。然后,应用程序将提取数据模式,为用户提供一个视觉表示,展示他们的支出习惯随时间如何发展。
媒体流应用
为了测试一些高级 MERN 功能,一个更沉浸式的应用,如媒体流应用,是下一个选择。以下截图显示了包含添加到该平台的热门视频列表的首页视图,这些功能灵感来自 Netflix 和 YouTube:

在这个媒体流应用中,我们将实现内容上传和查看功能,为内容提供者提供媒体内容上传功能,并为观众提供实时内容流。
VR 网页游戏
通过使用建立在 React 之上的框架,如 React 360,可以将 Web VR 和 3D 功能应用于 React 的用户界面。我们将探讨如何在 MERN 中使用 React 360 创建独特的 Web 体验,通过构建一个基本的 VR 游戏应用来展示,如下面的截图所示:

用户将能够玩 VR 游戏,并使用这个基于 Web 的应用程序制作自己的游戏。每个游戏都将有动画 VR 对象放置在 360 度的世界中,玩家将需要找到并收集这些对象以完成游戏。
随着书中这些多样化应用的实现,你将学会如何结合、扩展和使用 MERN 堆栈技术来构建全栈 Web 应用程序,并揭示你自己的全栈项目多样化的选择。
摘要
在本章中,我们了解了在 MERN 技术栈中开发 Web 应用的背景,以及这本书将如何帮助您使用这个技术栈进行开发。MERN 技术栈项目集成了 MongoDB、Express、React 和 Node 来构建 Web 应用。这个技术栈中的每一种技术都在 Web 开发领域取得了相关的发展。这些技术被广泛采用,并在不断增长的社区支持下持续改进。使用具有不同需求的 MERN 应用开发是可能的,从日常使用的应用到更复杂的 Web 体验。这本书的实用导向方法可用于从基础到高级提升 MERN 技能,或者直接用于构建更复杂的应用。
在下一章中,我们将开始为 MERN 应用开发做准备,通过学习如何使用每种 MERN 技术栈来设置开发环境,并编写一个 MERN 入门应用的代码,以确保您的系统设置正确。
第三章:准备开发环境
在使用 MERN 堆栈构建应用程序之前,我们首先需要为每种技术以及辅助开发和调试的工具准备开发环境。使用此堆栈需要您使不同的技术和工具协同工作,鉴于有关此主题的许多选项和资源,弄清楚这一切如何结合在一起可能是一项艰巨的任务。本章将指导您了解工作空间选项、基本开发工具、如何在您的工空间中设置 MERN 技术,以及如何通过实际代码检查此设置。
我们将涵盖以下主题:
-
选择开发工具
-
设置 MERN 堆栈技术
-
检查您的开发设置
选择开发工具
在选择基本开发工具时,如文本编辑器或 IDE、版本控制软件,甚至开发工作空间本身时,有很多选项可供选择。在本节中,我们将讨论与 MERN 堆栈相关的选项和建议,以便您可以根据个人偏好做出明智的选择。
工作空间选项
在本地机器上进行开发是程序员中的一种常见做法,但随着良好的云端和远程开发服务的出现,例如 AWS Cloud9 (aws.amazon.com/cloud9/?origin=c9io) 和 Visual Studio Code 的远程开发扩展 (code.visualstudio.com/docs/remote),您可以使用 MERN 技术设置您的本地工作空间(本书余下部分将假设这种情况),但您也可以选择在为 Node 开发配备的云端服务中运行和开发代码。
本地与云端开发
您可以选择同时使用这两种类型的工作空间,以享受在本地工作的好处,无需担心带宽/互联网问题,并在您没有物理上拥有您最喜欢的本地机器时远程工作。为此,您可以使用 Git 进行版本控制您的代码,将最新的代码存储在远程 Git 托管服务(如 GitHub 或 BitBucket)上,然后在所有工作空间中共享相同的代码。在您的工空间中,您可以从许多可用的选项中选择您喜欢的 IDE 来编写代码,其中一些将在下文中讨论。
IDE 或文本编辑器
大多数云端开发环境都会集成源代码编辑器,但针对您的本地工作空间,您可以根据自己的编程偏好选择任何一种,然后对其进行定制以适应 MERN 开发。例如,以下流行的选项可以根据需要定制:
-
Visual Studio Code (
code.visualstudio.com/):Microsoft 开发的特性丰富的源代码编辑器,广泛支持现代网络应用程序开发工作流程,包括对 MERN 堆栈技术的支持 -
Atom (
atom.io/):一个免费的、开源的文本编辑器,由 GitHub 提供,有许多其他开发者提供的与 MERN 堆栈相关的包 -
SublimeText (
www.sublimetext.com/):一个专有、跨平台的文本编辑器,也提供了许多与 MERN 堆栈相关的包,以及 JavaScript 开发支持 -
WebStorm (
www.jetbrains.com/webstorm/):JetBrains 开发的完整 JavaScript IDE,支持 MERN 堆栈开发
您还有其他可用的编辑器,但除了关注每个编辑器能提供什么之外,选择一个适合您、能够实现高效工作流程并且与其他必要的网络应用程序开发工具良好集成的编辑器同样重要。
Chrome 开发者工具
加载、查看和调试前端是网络开发过程中的一个非常重要的部分。Chrome DevTools(developers.google.com/web/tools/chrome-devtools),它是 Chrome 浏览器的一部分,拥有许多出色的功能,允许调试、测试和实验前端代码以及 UI 的外观、感觉、响应性和性能。此外,React Developer Tools 扩展作为 Chrome 插件提供,它为 Chrome DevTools 添加了 React 调试工具。
在您的开发工作流程中利用此类工具可以帮助您更好地理解代码,并有效地构建应用程序。同样,将代码版本控制与 Git 等工具集成可以提高您作为开发者的生产力和效率。
Git
任何开发工作流程如果没有一个能够跟踪代码更改、代码共享和协作的版本控制系统都是不完整的。多年来,Git 已成为许多开发者的首选版本控制系统,并且是最广泛使用的分布式源代码管理工具。在本书的代码开发中,Git 将主要帮助我们跟踪在构建每个应用程序的步骤中的进度。
安装
要开始使用 Git,首先根据您的系统规格在本地机器或云开发环境中安装它。有关下载和安装 Git 最新版本的说明以及使用 Git 命令的文档,可以在git-scm.com/downloads找到。
远程 Git 托管服务
基于云的 Git 仓库托管服务,如 GitHub 和 BitBucket,有助于在工作和部署环境中共享您的最新代码,并备份您的代码。这些服务包含了许多有用的功能,以帮助进行代码管理和开发工作流程。要开始使用,您可以创建一个账户并为您的代码库设置远程仓库。
所有这些基本工具都将帮助丰富您的 Web 开发工作流程并提高生产力。一旦您根据下一节中的讨论在您的空间中完成必要的设置,我们将继续前进并开始构建 MERN 应用程序。
设置 MERN 技术栈
随着本书的编写,MERN 技术栈正在开发和升级,因此对于本书中展示的工作,我们使用撰写本文时的最新稳定版本。这些技术的大多数安装指南取决于您的工作空间系统环境,因此本节指向所有相关的安装资源,并作为设置一个完全运行的 MERN 技术栈的指南。
MongoDB
在将任何数据库功能添加到 MERN 应用程序之前,MongoDB 必须设置好、运行良好,并且对您的开发环境可访问。在撰写本文时,MongoDB 的当前稳定版本是 4.2.0,本书中的应用程序开发使用的是这个版本的 MongoDB 社区版。本节其余部分提供了有关如何安装和运行 MongoDB 的资源。
安装
在您可以使用 MongoDB 进行开发之前,您需要在您的空间中安装并启动 MongoDB。MongoDB 的安装和启动过程取决于您的工作空间规格:
-
云开发服务将有自己的安装和设置 MongoDB 的说明。例如,AWS Cloud9 的如何操作步骤可以在
docs.c9.io/docs/setup-a-database#mongodb找到。 -
您可以在
docs.mongodb.com/manual/administration/install-community找到 MongoDB 在本地机器上的安装指南。
在您成功安装 MongoDB 到您的空间并使其运行后,您可以使用mongo shell与之交互。
运行 mongo shell
MongoDB shell 是 MongoDB 安装的一部分,是一个交互式工具,用于开始熟悉 MongoDB 操作。一旦 MongoDB 安装并运行,你就可以在命令行上运行 mongo shell。在 mongo shell 中,你可以使用命令查询和更新数据,并执行管理操作。
你也可以跳过 MongoDB 的本地安装,而是使用 MongoDB Atlas 在云中部署 MongoDB 数据库(www.mongodb.com/cloud/atlas)。它是一个全球云数据库服务,可以用于向现代应用程序添加完全管理的 MongoDB 数据库。
MERN 开发的下一个核心组件是 Node.js,这是完成剩余的 MERN 设置所必需的。
Node.js
MERN 应用程序的后端服务器实现依赖于 Node.js。在撰写本文时,13.12.0 是可用的最新稳定版 Node.js,书中代码也已在最新夜间构建的 14.0.0 版本上进行了测试。你选择的 Node.js 版本将捆绑 npm 作为包管理器。根据你选择 npm 或 Yarn 作为 MERN 项目的包管理器,你可以带或不带 npm 安装 Node.js。
安装
Node.js 可以通过直接下载、安装程序或 Node 版本管理器进行安装:
-
你可以通过直接下载源代码或针对你的工作空间平台预构建的安装程序来安装 Node.js。下载可在nodejs.org/en/download找到。
-
云开发服务可能预装了 Node.js,例如 AWS Cloud9 就是这样,或者会有添加和更新它的具体说明。
要测试安装是否成功,你可以打开命令行并运行node -v以查看它是否正确返回版本号。
使用 nvm 进行 Node 版本管理
如果你需要为不同的项目维护多个版本的 Node.js 和 npm,nvm 是一个有用的命令行工具,可以在同一工作空间上安装和管理不同版本。你必须单独安装 nvm。设置说明可以在github.com/creationix/nvm找到。
在你的系统上设置 Node.js 后,你可以使用 npm 或 Yarn 等包管理器开始集成 MERN 堆栈的其余部分。
Yarn 包管理器
Yarn 是一个相对较新的 JavaScript 依赖项包管理器,可以用作 npm 的替代品。它是一个快速、可靠且安全的依赖项管理器,提供了一系列额外的功能,包括离线模式,可以在没有互联网连接的情况下重新安装包,并支持多个包注册表,如 npmjs.com 和 Bower。
我们将使用 Yarn(v1.22.4)来管理本书中项目的 Node 模块和包。在使用 Yarn 之前,您需要在您的开发空间中安装它。根据您的操作系统及其版本,安装 Yarn 有多种方法。
要了解更多关于在您的开发空间中安装 Yarn 的选项,请访问 yarnpkg.com/lang/en/docs/install。
安装 Yarn 后,它可以用来添加其他必要的模块,包括 Express 和 React。
MERN 模块
剩余的 MERN 堆栈技术都可作为 Node.js 包模块提供,并可以使用 Yarn 添加到每个项目中。这包括运行每个 MERN 应用程序所需的关键模块,如 React 和 Express,以及开发期间必要的模块。在本节中,我们列出并讨论这些模块,然后在下一节中查看如何在实际项目中使用这些模块。
关键模块
要集成 MERN 堆栈技术并运行您的应用程序,我们需要以下模块:
-
React:要开始使用 React,我们需要两个模块:
-
react -
react-dom
-
-
Express:要在您的代码中使用 Express,您需要
express模块。 -
MongoDB:要直接在 Node 应用程序中使用 MongoDB,您需要添加驱动程序,该驱动程序作为名为
mongodb的 Node 模块提供。
这些关键模块将生成全栈 Web 应用程序,但我们需要一些额外的模块来帮助开发和应用代码的生成。
开发依赖模块
为了在整个 MERN 应用程序开发过程中保持一致性,我们将在客户端和服务器端实现中使用 ES6 和更高版本的新的 JavaScript 语法。因此,为了帮助开发过程,我们将使用以下附加模块来编译和打包代码,并在开发过程中代码更新时自动重新加载服务器和浏览器应用程序:
-
Babel 模块需要将 ES6 和 JSX 转换为适用于所有浏览器的合适 JavaScript。要使 Babel 工作所需的模块如下:
-
@babel/core -
babel-loader -
用于使用 Webpack 转换 JavaScript 文件
-
@babel/preset-env和@babel/preset-react以提供对 React 和最新 JavaScript 特性的支持
-
-
Webpack 模块将帮助打包编译后的 JavaScript,包括客户端和服务器端代码。要使 Webpack 工作所需的模块如下:
-
-
webpack。 -
webpack-cli用于运行 Webpack 命令。 -
webpack-node-externals用于在 Webpack 打包时忽略外部 Node.js 模块文件。 -
webpack-dev-middleware用于在代码开发期间通过连接的服务器提供 Webpack 输出的文件。 -
webpack-hot-middleware通过将浏览器客户端连接到 Webpack 服务器并在开发过程中接收代码更改时的更新,将热模块重载添加到现有服务器中。 -
nodemon用于在开发期间监视服务器端更改,以便服务器可以重新加载以使更改生效。 -
react-hot-loader用于客户端上的快速开发。每当 React 前端中的文件发生变化时,react-hot-loader都会启用浏览器应用程序更新,而无需重新捆绑整个前端代码。 -
@hot-loader/react-dom用于启用对 React hooks 的热重载支持。它本质上替换了相同版本的react-dom包,但增加了额外的补丁以支持热重载。
-
虽然 react-hot-loader 旨在帮助开发流程,但将其作为常规依赖项而不是开发依赖项安装是安全的。它将自动确保在生产环境中禁用热重载,并且占用空间最小。
在安装并准备好使用必要的 MERN 堆栈技术和相关工具后,在下一节中,我们将使用此工具集编写代码以确认您的开发空间是否正确设置以开始开发基于 MERN 的 Web 应用程序。
检查您的开发设置
在本节中,我们将通过开发工作流程,逐步编写代码以确保环境正确设置以开始开发和运行 MERN 应用程序。
我们将在以下文件夹结构中生成这些项目文件以运行一个简单的设置项目:
| mern-simplesetup/
| -- client/
| --- HelloWorld.js
| --- main.js
| -- server/
| --- devBundle.js
| --- server.js
| -- .babelrc
| -- nodemon.json
| -- package.json
| -- template.js
| -- webpack.config.client.js
| -- webpack.config.client.production.js
| -- webpack.config.server.js
本节中讨论的代码可在 GitHub 上的以下存储库中找到:github.com/PacktPublishing/Full-Stack-React-Projects-Second-Edition/tree/master/Chapter02/mern-simplesetup。您可以将此代码克隆并运行,同时您将通读本章其余部分的代码解释。
我们将配置文件留在根目录中,并将应用程序代码组织到客户端和服务器端文件夹中。client 文件夹将包含前端代码,而 server 文件夹将包含后端代码。在本节的其余部分,我们将生成这些文件,并实现前端和后端代码以构建一个可工作的全栈 Web 应用程序。
初始化 package.json 和安装 Node.js 模块
我们将首先使用 Yarn 安装所有必需的模块。在项目文件夹中添加 package.json 文件是一种最佳实践,以维护、记录和共享在 MERN 应用程序中使用的 Node.js 模块。package.json 文件将包含有关应用程序的元信息,并列出模块依赖项。
执行以下要点中概述的步骤以生成 package.json 文件,修改它,并使用它来安装模块:
-
yarn init:从命令行进入您的项目文件夹,并运行yarn init。您将回答一系列问题以收集有关项目的元信息,例如名称、许可证和作者。然后,将自动生成一个包含您答案的package.json文件。 -
dependencies:在编辑器中打开package.json,并修改JSON对象,添加键模块和react-hot-loader作为常规依赖项:
在代码块之前提到的文件路径表示代码在项目目录中的位置。本书中一直保持此约定,以便在跟随代码时提供更好的上下文和指导。
mern-simplesetup/package.json:
"dependencies": {
"@hot-loader/react-dom": "16.13.0",
"express": "4.17.1",
"mongodb": "3.5.5",
"react": "16.13.1",
"react-dom": "16.13.1",
"react-hot-loader": "4.12.20"
}
devDependencies:进一步修改package.json,将开发期间所需的以下 Node 模块作为devDependencies添加:
mern-simplesetup/package.json:
"devDependencies": {
"@babel/core": "7.9.0",
"@babel/preset-env": "7.9.0",
"@babel/preset-react": "7.9.4",
"babel-loader": "8.1.0",
"nodemon": "2.0.2",
"webpack": "4.42.1",
"webpack-cli": "3.3.11",
"webpack-dev-middleware": "3.7.2",
"webpack-hot-middleware": "2.25.0",
"webpack-node-externals": "1.7.2"
}
- yarn:保存
package.json,然后从命令行运行yarn命令以获取并添加所有这些模块到你的项目中。
在安装并添加所有必要的模块后,接下来我们将添加配置以编译和运行应用程序代码。
配置 Babel、Webpack 和 Nodemon
在我们开始编写网络应用程序代码之前,我们需要配置 Babel、Webpack 和 Nodemon 以在开发期间编译、捆绑和自动重新加载代码更改。
Babel
在你的项目文件夹中创建一个 .babelrc 文件,并添加以下带有 presets 和 plugins 的 JSON,指定如下:
mern-simplesetup/.babelrc:
{
"presets": [
["@babel/preset-env",
{
"targets": {
"node": "current"
}
}
],
"@babel/preset-react"
],
"plugins": [
"react-hot-loader/babel"
]
}
在此配置中,我们指定需要 Babel 将最新的 JavaScript 语法进行转译,支持 Node.js 环境中的代码以及 React/JSX 代码。react-hot-loader 模块需要 react-hot-loader/babel 插件来编译 React 组件。
Webpack
我们将不得不配置 Webpack 以捆绑客户端和服务器代码,并为生产环境单独捆绑客户端代码。在你的项目文件夹中创建 webpack.config.client.js、webpack.config.server.js 和 webpack.config.client.production.js 文件。所有三个文件都将具有以下代码结构,从导入开始,然后是 config 对象的定义,最后是定义的 config 对象的导出:
const path = require('path')
const webpack = require('webpack')
const CURRENT_WORKING_DIR = process.cwd()
const config = { ... }
module.exports = config
config JSON 对象将根据客户端或服务器端代码以及开发与生产代码的特定值而有所不同。在以下各节中,我们将突出显示每个配置实例中的相关属性。
或者,你也可以使用交互式门户 Generate Custom Webpack Configuration 在 generatewebpackconfig.netlify.com/ 或使用命令行的 Webpack-cli 的 init 命令生成 Webpack 配置。
开发时的客户端 Webpack 配置
在你的 webpack.config.client.js 文件中更新 config 对象,以便配置 Webpack 在开发期间捆绑和热加载 React 代码:
mern-simplesetup/webpack.config.client.js:
const config = {
name: "browser",
mode: "development",
devtool: 'eval-source-map',
entry: [
'webpack-hot-middleware/client?reload=true',
path.join(CURRENT_WORKING_DIR, 'client/main.js')
],
output: {
path: path.join(CURRENT_WORKING_DIR , '/dist'),
filename: 'bundle.js',
publicPath: '/dist/'
},
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /node_modules/,
use: ['babel-loader']
}
]
},
plugins: [
new webpack.HotModuleReplacementPlugin(),
new webpack.NoEmitOnErrorsPlugin()
],
resolve: {
alias: {
'react-dom': '@hot-loader/react-dom'
}
}
}
在 config 对象中突出显示的键和值将确定 Webpack 如何捆绑代码以及捆绑代码将放置的位置:
-
mode将process.env.NODE_ENV设置为给定的值,并告诉 Webpack 相应地使用其内置优化。如果没有明确设置,则默认为"production"值。也可以通过命令行传递 CLI 参数来设置。 -
devtool指定了源映射的生成方式,如果有的话。通常,源映射提供了一种将压缩文件内的代码映射回源文件原始位置的方法,以帮助调试。 -
entry指定了 Webpack 开始捆绑的入口文件,在本例中是client文件夹中的main.js文件。 -
output指定了捆绑代码的输出路径,在本例中设置为dist/bundle.js。 -
publicPath允许指定应用程序中所有资产的基准路径。 -
module设置用于转译的文件扩展名的正则表达式规则,以及要排除的文件夹。这里要使用的转译工具是babel-loader。 -
HotModuleReplacementPlugin启用了react-hot-loader的热模块替换功能。 -
NoEmitOnErrorsPlugin允许在编译错误时跳过输出。 -
我们还添加了一个 Webpack 别名,将
react-dom引用指向@hot-loader/react-dom版本。
应用程序的客户端代码将从bundle.js中的捆绑代码在浏览器中加载。
Webpack 还提供了其他配置选项,可以根据你的代码和捆绑规范按需使用,正如我们将在探索服务器端特定捆绑时看到的。
服务器端 Webpack 配置
修改代码以要求nodeExternals,并在你的webpack.config.server.js文件中使用以下代码更新config对象,以配置 Webpack 进行服务器端代码的捆绑:
mern-simplesetup/webpack.config.server.js:
const nodeExternals = require('webpack-node-externals')
const config = {
name: "server",
entry: [ path.join(CURRENT_WORKING_DIR , './server/server.js') ],
target: "node",
output: {
path: path.join(CURRENT_WORKING_DIR , '/dist/'),
filename: "server.generated.js",
publicPath: '/dist/',
libraryTarget: "commonjs2"
},
externals: [nodeExternals()],
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: [ 'babel-loader' ]
}
]
}
}
mode选项在此处未明确设置,但在运行 Webpack 命令时,根据是开发运行还是生产构建,将按需传递。
Webpack 从server.js所在的server文件夹开始捆绑,然后在dist文件夹中的server.generated.js中输出捆绑代码。在捆绑过程中,将假设 CommonJS 环境,因为我们已在libraryTarget中指定了commonjs2,因此输出将被分配给module.exports。
我们将使用生成的server.generated.js捆绑来运行服务器端代码。
生产环境下的客户端 Webpack 配置
为了准备客户端代码以用于生产,在你的webpack.config.client.production.js文件中使用以下代码更新config对象:
mern-simplesetup/webpack.config.client.production.js:
const config = {
mode: "production",
entry: [
path.join(CURRENT_WORKING_DIR, 'client/main.js')
],
output: {
path: path.join(CURRENT_WORKING_DIR , '/dist'),
filename: 'bundle.js',
publicPath: "/dist/"
},
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /node_modules/,
use: [
'babel-loader'
]
}
]
}
}
这将配置 Webpack 捆绑用于生产模式的 React 代码。这里的配置与开发模式的客户端配置类似,但没有热重载插件和调试配置,因为在生产中不需要这些。
在设置好打包配置后,我们可以添加配置,以便在开发期间使用 Nodemon 自动运行这些生成的包,以响应代码更新。
Nodemon
在你的项目文件夹中创建一个 nodemon.json 文件,并添加以下配置:
mern-simplesetup/nodemon.json:
{
"verbose": false,
"watch": [ "./server" ],
"exec": "webpack --mode=development --config
webpack.config.server.js
&& node ./dist/server.generated.js"
}
此配置将设置 nodemon 在开发期间监视服务器文件中的更改,然后根据需要执行编译和构建命令。我们可以开始编写一个简单的全栈 Web 应用程序的代码,以查看这些配置的实际效果。
使用 React 的前端视图
为了开始开发前端,首先在项目文件夹中创建一个名为 template.js 的根模板文件,该文件将渲染带有 React 组件的 HTML:
mern-simplesetup/template.js:
export default () => {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>MERN Kickstart</title>
</head>
<body>
<div id="root"></div>
<script type="text/javascript" src="img/bundle.js">
</script>
</body>
</html>`
}
当服务器接收到对根 URL 的请求时,这个 HTML 模板将在浏览器中渲染,并且具有 ID "root" 的 div 元素将包含我们的 React 组件。
接下来,创建一个 client 文件夹,我们将在此文件夹中添加两个 React 文件,main.js 和 HelloWorld.js。
main.js 文件简单地渲染 HTML 文档中的 div 元素顶层的入口 React 组件:
mern-simplesetup/client/main.js:
import React from 'react'
import { render } from 'react-dom'
import HelloWorld from './HelloWorld'
render(<HelloWorld/>, document.getElementById('root'))
在这种情况下,入口 React 组件是从 HelloWorld.js 导入的 HelloWorld 组件:
mern-simplesetup/client/HelloWorld.js:
import React from 'react'
import { hot } from 'react-hot-loader'
const HelloWorld = () => {
return (
<div>
<h1>Hello World!</h1>
</div>
)
}
export default hot(module)(HelloWorld)
HelloWorld.js 包含一个基本的 HelloWorld React 组件,它通过 react-hot-loader 在开发期间启用热重载进行热导出。
要在服务器接收到对根 URL 的请求时在浏览器中看到渲染的 React 组件,我们需要使用 Webpack 和 Babel 设置来编译和打包此代码,并添加响应根路由请求的打包代码的服务器端代码。我们将在下一节中实现此服务器端代码。
使用 Express 和 Node 的服务器
在项目文件夹中,创建一个名为 server 的文件夹,并添加一个名为 server.js 的文件,该文件将设置服务器。然后,添加另一个名为 devBundle.js 的文件,该文件将帮助在开发模式下使用 Webpack 配置编译 React 代码。在接下来的章节中,我们将实现 Node-Express 应用程序,该应用程序启动客户端代码打包,启动服务器,设置路径以向客户端提供静态资产,并在对根路由发起 GET 请求时在模板中渲染 React 视图。
Express 应用程序
在 server.js 中,我们首先将添加代码以导入 express 模块,以便初始化一个 Express 应用程序:
mern-simplesetup/server/server.js:
import express from 'express'
const app = express()
然后,我们将使用这个 Express 应用程序来构建 Node 服务器应用程序的其余部分。
在开发期间打包 React 应用程序
为了保持开发流程简单,我们将初始化 Webpack,在服务器运行时编译客户端代码。在devBundle.js中,我们将设置一个compile方法,该方法接受 Express 应用并配置它使用 Webpack 中间件来编译、打包和提供代码,以及在开发模式下启用热重载:
mern-simplesetup/server/devBundle.js:
import webpack from 'webpack'
import webpackMiddleware from 'webpack-dev-middleware'
import webpackHotMiddleware from 'webpack-hot-middleware'
import webpackConfig from './../webpack.config.client.js'
const compile = (app) => {
if(process.env.NODE_ENV == "development"){
const compiler = webpack(webpackConfig)
const middleware = webpackMiddleware(compiler, {
publicPath: webpackConfig.output.publicPath
})
app.use(middleware)
app.use(webpackHotMiddleware(compiler))
}
}
export default {
compile
}
我们将在开发模式下通过添加以下行在server.js中调用此编译方法:
mern-simplesetup/server/server.js:
import devBundle from './devBundle'
const app = express()
devBundle.compile(app)
这两条高亮的行仅用于开发模式,当构建用于生产的应用程序代码时应将其注释掉。在开发模式下,当这些行执行时,Webpack 将编译并打包 React 代码,将其放置在dist/bundle.js中。
从 dist 文件夹提供静态文件
Webpack 将在开发和生产模式下编译客户端代码,然后将打包的文件放置在dist文件夹中。为了使这些静态文件对客户端请求可用,我们将在server.js中添加以下代码,以从dist文件夹中提供静态文件:
mern-simplesetup/server/server.js:
import path from 'path'
const CURRENT_WORKING_DIR = process.cwd()
app.use('/dist', express.static(path.join(CURRENT_WORKING_DIR, 'dist')))
这将配置 Express 应用,当请求的路由以/dist开头时,从dist文件夹返回静态文件。
在根目录渲染模板
当服务器在根 URL / 接收到请求时,我们将在浏览器中渲染template.js。在server.js中,向 Express 应用添加以下路由处理代码以接收/的GET请求:
mern-simplesetup/server/server.js:
import template from './../template'
app.get('/', (req, res) => {
res.status(200).send(template())
})
最后,配置 Express 应用以启动一个监听指定端口的请求的服务器:
mern-simplesetup/server/server.js:
let port = process.env.PORT || 3000
app.listen(port, function onStart(err) {
if (err) {
console.log(err)
}
console.info('Server started on port %s.', port)
})
使用此代码,当服务器运行时,它将能够接受根路由的请求并在浏览器中渲染带有“Hello World”文本的 React 视图。这个全栈实现中唯一缺少的部分是与数据库的连接,我们将在下一节中添加。
将服务器连接到 MongoDB
为了将你的 Node 服务器连接到 MongoDB,请将以下代码添加到server.js中,并确保你在工作区中运行 MongoDB 或者你有云 MongoDB 数据库实例的 URL:
mern-simplesetup/server/server.js:
import { MongoClient } from 'mongodb'
const url = process.env.MONGODB_URI ||
'mongodb://localhost:27017/mernSimpleSetup'
MongoClient.connect(url, (err, db)=>{
console.log("Connected successfully to mongodb server")
db.close()
})
在此代码示例中,MongoClient是使用其 URL 连接到运行中的 MongoDB 实例的驱动程序。它允许我们在后端实现数据库相关代码。这完成了使用 MERN 设置的这个简单 Web 应用程序的全栈集成,最终我们可以运行此代码以查看应用程序实时工作。
运行脚本
为了运行应用程序,我们将更新package.json文件,为开发和生产添加以下运行脚本:
mern-simplesetup/package.json:
"scripts": {
"development": "nodemon",
"build": "webpack --config webpack.config.client.production.js
&& webpack --mode=production --config
webpack.config.server.js",
"start": "NODE_ENV=production node ./dist/server.generated.js"
}
让我们看看代码:
-
yarn development:此命令将启动 Nodemon、Webpack 和服务器以进行开发。 -
yarn build:这将生成生产模式下的客户端和服务器代码包(在运行此脚本之前,请确保从server.js中删除devBundle.compile代码)。 -
yarn start:此命令将在生产中运行打包后的代码。
您可以使用以下命令来运行应用程序,无论是您在开发应用程序时进行调试,还是当应用程序准备在生产环境中上线时。
实时开发和调试
要运行到目前为止开发的代码,并确保一切正常工作,您可以按照以下步骤进行:
-
从命令行运行应用程序:
yarn development。 -
在浏览器中加载:在浏览器中打开根 URL,如果您使用的是本地机器,则为
http://localhost:3000。您应该看到一个标题为 MERN Kickstart 的页面,该页面仅显示 Hello World!。 -
实时开发代码和调试:将
HelloWorld.js组件的文本从"Hello World!"更改为仅"hello"。保存更改后,您可以在浏览器中看到即时更新,并且还可以检查命令行输出以确认bundle.js没有被重新创建。同样,当您更改服务器端代码时,您也可以看到即时更新,这可以在开发过程中提高生产力。
如果您已经走到这一步,恭喜您!您已经准备好开始开发令人兴奋的 MERN 应用程序了。
摘要
在本章中,我们讨论了开发工具选项以及如何安装 MERN 技术,然后我们编写了代码来检查开发环境是否正确设置。
我们首先查看适合 Web 开发的推荐工作区、IDE、版本控制软件和浏览器选项。您可以根据作为开发者的个人喜好从中选择。
接下来,我们首先安装 MongoDB、Node 和 Yarn,然后使用 Yarn 添加剩余所需库,从而设置 MERN 堆栈技术。
在编写代码以检查此设置之前,我们已配置 Webpack 和 Babel 在开发期间编译和打包代码,并构建生产就绪代码。我们了解到,在将应用程序打开到浏览器之前,有必要编译用于开发 MERN 应用程序的 ES6 和 JSX 代码。
此外,我们还通过包括 React Hot Loader 用于前端开发、配置 Nodemon 用于后端开发,以及在开发期间运行服务器时使用一条命令编译客户端和服务器代码,使开发流程更高效。
在下一章中,我们将使用这个设置开始构建一个作为全功能应用程序基础的骨架 MERN 应用程序。
第四章:从零开始构建 MERN
在本部分,我们从零开始构建一个全栈 MERN 应用程序,并展示如何轻松扩展以开发第一个示例应用程序。
本节包含以下章节:
-
第三章,使用 MongoDB、Express 和 Node 构建后端
-
第四章,添加 React 前端以完成 MERN
-
第五章,将骨架扩展成社交媒体应用程序
第五章:使用 MongoDB、Express 和 Node 构建后端
在开发不同的网络应用程序时,你会发现过程中有常见的任务、基本功能和重复的实现代码。对于本书中将开发的 MERN 应用程序也是如此。考虑到这些相似之处,我们首先将建立一个可轻松修改和扩展以实现各种 MERN 应用程序的框架 MERN 应用程序的基础。
在本章中,我们将涵盖以下主题,并从使用 Node、Express 和 MongoDB 实现的 MERN 框架的后端实现开始:
-
框架应用程序概述
-
后端代码设置
-
使用 Mongoose 的用户模型
-
使用 Express 的用户 CRUD API 端点
-
使用 JSON Web Tokens 进行用户认证
-
运行后端代码并检查 API
框架应用程序概述
框架应用程序将封装基本功能和大多数 MERN 应用程序中重复的工作流程。我们将构建一个基本但功能齐全的 MERN 网络应用程序,具有用户创建、读取、更新、删除(CRUD)和认证-授权(auth)功能;这还将展示如何使用此堆栈开发、组织和运行通用网络应用程序的代码。目标是使框架尽可能简单,以便易于扩展,并可作为开发不同 MERN 应用程序的基础应用程序。
功能分解
在框架应用程序中,我们将添加以下用例,包括用户 CRUD 和 auth 功能实现:
-
注册:用户可以通过使用电子邮件地址创建新账户进行注册。
-
用户列表:任何访客都可以看到所有注册用户的列表。
-
认证:注册用户可以登录和登出。
-
受保护的用户资料:只有注册用户在登录后才能查看个人用户详情。
-
授权用户编辑和删除:只有注册并认证的用户可以编辑或删除自己的用户账户详情。
通过这些功能,我们将拥有一个简单的运行中的网络应用程序,支持用户账户。我们将从后端实现开始构建这个基本网络应用程序,然后集成 React 前端以完成全栈。
定义后端组件
在本章中,我们将专注于使用 Node、Express 和 MongoDB 构建一个工作后端框架,该后端将是一个独立的服务器端应用程序,可以处理创建用户、列出所有用户、在数据库中查看、更新或删除用户等 HTTP 请求,同时考虑用户认证和授权。
用户模型
用户模型将定义要存储在 MongoDB 数据库中的用户详情,并处理与用户相关的业务逻辑,如密码加密和用户数据验证。此版本的用户模型将是基本的,支持以下属性:
| 字段名称 | 类型 | 描述 |
|---|---|---|
name |
字符串 | 存储用户名称的必填字段。 |
email |
字符串 | 必需的唯一字段,用于存储用户的电子邮件并识别每个账户(每个唯一电子邮件地址仅允许一个账户)。 |
password |
字符串 | 认证所需的字段。出于安全目的,数据库将存储加密后的密码,而不是实际字符串。 |
created |
日期 | 创建新用户账户时自动生成的时间戳。 |
updated |
日期 | 更新现有用户详细信息时自动生成的时间戳。 |
当我们通过扩展此骨架构建应用程序时,我们可以根据需要添加更多字段。但以这些字段开始将足以识别唯一的用户账户,并且还可以实现与用户 CRUD 操作相关的功能。
用户 CRUD 的 API 端点
为了在用户数据库上启用和处理用户 CRUD 操作,后端将实现并公开前端可以在视图中使用的 API 端点,如下所示:
| 操作 | API 路由 | HTTP 方法 |
|---|---|---|
| 创建用户 | /api/users |
POST |
| 列出所有用户 | /api/users |
GET |
| 获取用户 | /api/users/:userId |
GET |
| 更新用户 | /api/users/:userId |
PUT |
| 删除用户 | /api/users/:userId |
DELETE |
| 用户登录 | /auth/signin |
POST |
| 用户登出(可选) | /auth/signout |
GET |
这些用户 CRUD 操作中的一些将具有受保护访问权限,这要求请求客户端进行认证、授权或两者兼而有之,具体取决于功能规范。表中最后两个路由用于认证,将允许用户登录和登出。对于本书中开发的应用程序,我们将使用 JWT 机制来实现这些认证功能,如下一节中更详细地讨论。
使用 JSON Web Tokens 进行认证
为了根据骨架功能限制和保护对用户 API 端点的访问,后端需要整合认证和授权机制。在实现 Web 应用程序的用户认证方面有许多选择。最常见且经过时间考验的选项是在客户端和服务器端使用会话来存储用户状态。但一种较新的方法是使用 JSON Web Token(JWT)作为无状态认证机制,它不需要在服务器端存储用户状态。
这两种方法在相关的现实世界用例中都有其优势。然而,为了保持本书中的代码简单,并且因为它与 MERN 栈和我们的示例应用程序配合良好,我们将使用 JWT 进行认证实现。
JWT 的工作原理
在深入到 MERN 栈中 JWT 认证的实现之前,我们将查看这种机制通常如何在客户端-服务器应用程序中工作,如下面的图所示:

初始时,当用户使用其凭据登录时,服务器端生成一个带有秘密密钥和唯一用户详情的 JWT。然后,此令牌将返回给请求客户端以在本地保存,无论是 localStorage、sessionStorage 还是浏览器中的 cookie,本质上是将维护用户状态的责任交给了客户端。
对于在成功登录后发出的 HTTP 请求,特别是对受保护和有限访问的 API 端点的请求,客户端必须将此令牌附加到请求中。更具体地说,JSON Web Token 必须包含在请求的 Authorization 标头中,作为 Bearer:
Authorization: Bearer <JSON Web Token>
当服务器收到对受保护 API 端点的请求时,它会检查请求的 Authorization 标头中的有效 JWT,然后验证签名以识别发送者并确保请求数据未被损坏。如果令牌有效,请求客户端将被授予访问相关操作或资源的权限;否则,将返回授权错误。
在骨架应用程序中,当用户使用他们的电子邮件和密码登录时,后端将生成一个带有用户 ID 和仅在服务器上可用的秘密密钥的已签名 JWT。然后,当用户尝试查看任何用户资料、更新他们的账户详情或删除他们的用户账户时,此令牌将用于验证。
实现用户模型以存储和验证用户数据,然后将其与 API 集成以执行基于 JWT 的 CRUD 操作,这将产生一个功能独立的后端。在本章的其余部分,我们将探讨如何在 MERN 框架中实现这一点并设置。
设置后端骨架
要开始开发 MERN 骨架的后端部分,我们将设置项目文件夹,安装和配置必要的 Node 模块,然后准备运行脚本以帮助开发和运行代码。然后,我们将逐步分析代码以实现一个工作的 Express 服务器、带有 Mongoose 的用户模型、Express 路由器上的 API 端点以及基于 JWT 的身份验证,以满足我们之前定义的用户功能规格。
本章将讨论的代码以及完整的骨架应用程序可在 GitHub 上找到 https://github.com/PacktPublishing/Full-Stack-React-Projects-Second-Edition/tree/master/Chapter03%20and%2004/mern-skeleton 。仅后端代码可在同一存储库的 mern2-skeleton-backend 分支中找到。您可以将此代码克隆并运行,在阅读本章其余部分的代码解释时进行操作。
文件夹和文件结构
在本章的其余部分,我们将通过设置和实现,最终得到以下文件夹结构,其中包含与 MERN 骨架后端相关的文件。有了这些文件,我们将拥有一个功能齐全的、独立的后端服务器应用程序:
| mern_skeleton/
| -- config/
| --- config.js
| -- server/
| --- controllers/
| ---- auth.controller.js
| ---- user.controller.js
| --- helpers/
| ---- dbErrorHandler.js
| --- models/
| ---- user.model.js
| --- routes/
| ---- auth.routes.js
| ---- user.routes.js
| --- express.js
| --- server.js
| -- .babelrc
| -- nodemon.json
| -- package.json
| -- template.js
| -- webpack.config.server.js
| -- yarn.lock
我们将把配置文件放在根目录下,把与后端相关的代码放在 server 文件夹中。在 server 文件夹内,我们将后端代码划分为包含模型、控制器、路由、辅助函数和通用服务器端代码的模块。这个文件夹结构将在下一章中进一步扩展,届时我们将通过添加 React 前端来完成骨架应用程序。
初始化项目
如果你的开发环境已经设置好,你可以初始化 MERN 项目以开始开发后端。首先,我们将从项目文件夹中初始化 package.json,配置和安装任何开发依赖项,设置代码中使用的配置变量,并在 package.json 中更新运行脚本以帮助开发和运行代码。
添加 package.json
我们需要一个 package.json 文件来存储关于项目的元信息,列出带有版本号的模块依赖项,并定义运行脚本。要在项目文件夹中初始化 package.json 文件,从命令行进入项目文件夹,运行 yarn init,然后按照指示添加必要的详细信息。创建 package.json 后,我们可以继续设置和开发,并在代码实现过程中需要更多模块时更新该文件。
开发依赖项
为了开始开发过程并运行后端服务器代码,我们将配置和安装 Babel、Webpack 和 Nodemon,如第二章所述,准备开发环境,并对后端进行一些小的调整。
Babel
由于我们将在后端代码中使用 ES6+ 和最新的 JS 特性,我们将安装和配置 Babel 模块将 ES6+ 转换为较旧的 JS 版本,以便与正在使用的 Node 版本兼容。
首先,我们将在 .babelrc 文件中配置 Babel,使用最新的 JS 特性预设,并指定当前版本的 Node 作为目标环境。
mern-skeleton/.babelrc:
{
"presets": [
["@babel/preset-env",
{
"targets": {
"node": "current"
}
}
]
]
}
将 targets.node 设置为 current 指示 Babel 编译针对当前版本的 Node,并允许我们在后端代码中使用 async/await 等表达式。
接下来,我们需要从命令行安装 Babel 模块作为 devDependencies:
yarn add --dev @babel/core babel-loader @babel/preset-env
一旦完成模块安装,你将注意到 package.json 文件中的 devDependencies 列表已经更新。
Webpack
我们需要 Webpack 来使用 Babel 编译和打包服务器端代码。对于配置,我们可以使用第二章中讨论的相同的 webpack.config.server.js。
从命令行运行以下命令以安装webpack、webpack-cli和webpack-node-externals模块:
yarn add --dev webpack webpack-cli webpack-node-externals
这将安装 Webpack 模块并更新package.json文件。
Nodemon
在我们更新代码期间自动重启 Node 服务器,我们将使用 Nodemon 来监控服务器代码的更改。我们可以使用我们在第二章“准备开发环境”中讨论的相同安装和配置指南。
在我们添加运行脚本以开始开发和运行后端代码之前,我们将定义跨后端实现使用的值配置变量。
配置变量
在config/config.js文件中,我们将定义一些服务器端配置相关的变量,这些变量将在代码中使用,但应作为最佳实践避免硬编码,以及出于安全考虑。
mern-skeleton/config/config.js:
const config = {
env: process.env.NODE_ENV || 'development',
port: process.env.PORT || 3000,
jwtSecret: process.env.JWT_SECRET || "YOUR_secret_key",
mongoUri: process.env.MONGODB_URI ||
process.env.MONGO_HOST ||
'mongodb://' + (process.env.IP || 'localhost') + ':' +
(process.env.MONGO_PORT || '27017') +
'/mernproject'
}
export default config
定义好的配置变量如下:
-
env:用于区分开发和生产模式 -
port:用于定义服务器的监听端口 -
jwtSecret:用于签名 JWT 的秘密密钥 -
mongoUri:项目 MongoDB 数据库实例的位置
这些变量将使我们能够从单个文件更改值并在后端代码中使用它。接下来,我们将添加运行脚本,这将允许我们运行和调试后端实现。
运行脚本
要在开发后端代码时运行服务器,我们可以从package.json文件中的yarn development脚本开始。对于完整的骨架应用程序,我们将使用我们在第二章“准备开发环境”中定义的相同运行脚本。
mern-skeleton/package.json:
"scripts": {
"development": "nodemon"
}
添加此脚本后,从项目文件夹中运行命令行中的yarn development将基本上根据nodemon.json中的配置启动 Nodemon。配置指示 Nodemon 监控服务器文件以进行更新,并在更新时重新构建文件,然后重启服务器,以便更改立即可用。我们将从实现具有此配置的工作服务器开始。
准备服务器
在本节中,我们将在开始实现用户特定功能之前,将 Express、Node 和 MongoDB 集成在一起,以运行一个完全配置的服务器。
配置 Express
要使用 Express,我们将安装它,然后在server/express.js文件中添加和配置它。
从命令行运行以下命令以安装express模块并自动更新package.json文件:
yarn add express
Express 安装后,我们可以将其导入到express.js文件中,按需配置它,并使其对整个应用程序可用。
mern-skeleton/server/express.js:
import express from 'express'
const app = express()
/*... configure express ... */
export default app
为了正确处理 HTTP 请求并提供服务,我们将使用以下模块来配置 Express:
-
body-parser: 请求体解析中间件,用于处理解析可流式请求对象的复杂性,以便我们可以通过在请求体中交换 JSON 来简化浏览器-服务器通信。要安装此模块,请在命令行中运行yarn add body-parser。然后,使用bodyParser.json()和bodyParser.urlencoded({ extended: true })配置 Express 应用。 -
cookie-parser: 解析和设置请求对象中的 Cookie 的解析中间件。要安装cookie-parser模块,请在命令行中运行yarn add cookie-parser。 -
compression: 尝试压缩所有通过中间件传输的请求响应体的压缩中间件。要安装compression模块,请在命令行中运行yarn add compression。 -
helmet: 通过设置各种 HTTP 头来帮助保护 Express 应用的中间件集合。要安装helmet模块,请在命令行中运行yarn add helmet。 -
cors: 允许跨源资源共享(CORS)的中间件。要安装cors模块,请在命令行中运行yarn add cors。
在安装了前面的模块之后,我们可以更新 express.js 以导入这些模块,并在将其导出以供服务器代码的其余部分使用之前配置 Express 应用。
更新的 mern-skeleton/server/express.js 代码应如下所示:
import express from 'express'
import bodyParser from 'body-parser'
import cookieParser from 'cookie-parser'
import compress from 'compression'
import cors from 'cors'
import helmet from 'helmet'
const app = express()
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: true }))
app.use(cookieParser())
app.use(compress())
app.use(helmet())
app.use(cors())
export default app
Express 应用现在可以接受和处理来自传入 HTTP 请求的信息,为此我们首先需要使用此应用启动一个服务器。
启动服务器
配置 Express 应用以接受 HTTP 请求后,我们可以继续使用它来实现一个可以监听传入请求的服务器。
在 mern-skeleton/server/server.js 文件中,添加以下代码以实现服务器:
import config from './../config/config'
import app from './express'
app.listen(config.port, (err) => {
if (err) {
console.log(err)
}
console.info('Server started on port %s.', config.port)
})
首先,我们导入配置变量以设置服务器将监听的端口号,然后导入配置好的 Express 应用以启动服务器。要运行此代码并继续开发,我们可以在命令行中运行 yarn development。如果代码没有错误,服务器应该会启动运行,Nodemon 会监控代码更改。接下来,我们将更新此服务器代码以集成数据库连接。
设置 Mongoose 并连接到 MongoDB
我们将使用 mongoose 模块来实现此骨架中的用户模型,以及所有未来 MERN 应用的数据模型。在这里,我们将首先配置 Mongoose 并利用它来定义与 MongoDB 数据库的连接。
首先,要安装 mongoose 模块,请运行以下命令:
yarn add mongoose
然后,更新 server.js 文件以导入 mongoose 模块,配置它以使用原生 ES6 promises,并最终使用它来处理项目与 MongoDB 数据库的连接。
mern-skeleton/server/server.js:
import mongoose from 'mongoose'
mongoose.Promise = global.Promise
mongoose.connect(config.mongoUri, { useNewUrlParser: true,
useCreateIndex: true,
useUnifiedTopology: true } )
mongoose.connection.on('error', () => {
throw new Error(`unable to connect to database: ${mongoUri}`)
})
如果你正在开发环境中运行代码,并且 MongoDB 也正在运行,保存此更新应该可以成功重启服务器,现在该服务器已集成 Mongoose 和 MongoDB。
Mongoose 是一个 MongoDB 对象建模工具,它提供了一个基于模式的解决方案来建模应用程序数据。它包括内置的类型转换、验证、查询构建和业务逻辑钩子。使用 Mongoose 与此后端堆栈一起提供 MongoDB 之上的更高层,具有更多功能,包括将对象模型映射到数据库文档。这使得使用 Node 和 MongoDB 后端进行开发更加简单和高效。要了解更多关于 Mongoose 的信息,请访问 mongoosejs.com。
在配置了 Express 应用程序、数据库集成 Mongoose 以及准备就绪的监听服务器后,我们可以添加代码以从后端加载 HTML 视图。
在根 URL 上提供 HTML 模板
现在有一个启用了 Node-Express-和 MongoDB 服务器正在运行,我们可以扩展它,使其能够响应根 URL / 的传入请求来提供 HTML 模板。
在 template.js 文件中,添加一个 JS 函数,该函数返回一个简单的 HTML 文档,将在浏览器屏幕上渲染Hello World。
mern-skeleton/template.js:
export default () => {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>MERN Skeleton</title>
</head>
<body>
<div id="root">Hello World</div>
</body>
</html>`
}
要在根 URL 上提供此模板,更新 express.js 文件以导入此模板,并在对 '/' 路由的 GET 请求中将其发送作为响应。
mern-skeleton/server/express.js:
import Template from './../template'
...
app.get('/', (req, res) => {
res.status(200).send(Template())
})
...
使用此更新,在浏览器中打开根 URL 应该会显示在页面上渲染的“Hello World”。如果你在本地机器上运行代码,根 URL 将是 http://localhost:3000/。
到目前为止,我们可以构建在基于 Node-Express-和 MongoDB 的后端服务器上,以添加特定于用户的功能。
实现用户模型
我们将在 server/models/user.model.js 文件中实现用户模型,并使用 Mongoose 定义包含必要用户数据字段的模式。我们这样做是为了可以对字段添加内置验证,并集成如密码加密、身份验证和自定义验证等业务逻辑。
我们将首先导入 mongoose 模块,并使用它来生成一个 UserSchema,它将包含模式定义和与用户相关的业务逻辑,以构成用户模型。这个用户模型将被导出,以便其余的后端代码可以使用它。
mern-skeleton/server/models/user.model.js:
import mongoose from 'mongoose'
const UserSchema = new mongoose.Schema({ … })
export default mongoose.model('User', UserSchema)
mongoose.Schema() 函数接受一个模式定义对象作为参数,以生成一个新的 Mongoose 模式对象,该对象将指定集合中每个文档的属性或结构。在我们添加任何业务逻辑代码以完成用户模型之前,我们将讨论用户集合的模式定义。
用户模式定义
需要生成新 Mongoose 模式的用户模式定义对象将声明所有用户数据字段和相关属性。该模式将记录与用户相关的信息,包括名称、电子邮件、创建时间和最后更新时间戳、哈希密码以及相关的唯一密码盐。我们将在下一节中详细说明这些属性,展示每个字段如何在用户模式代码中定义。
名称
名称字段是String类型的必填字段。
mern-skeleton/server/models/user.model.js:
name: {
type: String,
trim: true,
required: 'Name is required'
},
此字段将存储用户的名称。
邮箱
电子邮件字段是String类型的必填字段。
mern-skeleton/server/models/user.model.js:
email: {
type: String,
trim: true,
unique: 'Email already exists',
match: [/.+\@.+\..+/, 'Please fill a valid email address'],
required: 'Email is required'
},
要存储在此电子邮件字段中的值必须具有有效的电子邮件格式,并且必须在用户集合中是唯一的。
创建和更新时间戳
created和updated字段是Date值。
mern-skeleton/server/models/user.model.js:
created: {
type: Date,
default: Date.now
},
updated: Date,
这些Date值将程序化生成,以记录用户创建和用户数据更新的时间戳。
哈希密码和盐
hashed_password和salt字段代表用于认证的加密用户密码。
mern-skeleton/server/models/user.model.js:
hashed_password: {
type: String,
required: "Password is required"
},
salt: String
为了安全起见,实际的密码字符串不会直接存储在数据库中,并且将单独处理,如下一节所述。
用于认证的密码
密码字段对于在任何应用程序中提供安全用户身份验证至关重要,每个用户密码都需要作为用户模型的一部分进行加密、验证和安全的认证。
将密码字符串作为虚拟字段处理
用户提供的密码字符串不会直接存储在用户文档中。相反,它被处理为一个虚拟字段。
mern-skeleton/server/models/user.model.js:
UserSchema
.virtual('password')
.set(function(password) {
this._password = password
this.salt = this.makeSalt()
this.hashed_password = this.encryptPassword(password)
})
.get(function() {
return this._password
})
当在用户创建或更新时接收到密码值时,它将被加密成一个新的哈希值,并设置到hashed_password字段,同时与salt字段中的唯一盐值一起。
加密和认证
用于生成表示密码值的hashed_password和salt值的加密逻辑和盐生成逻辑被定义为UserSchema方法。
mern-skeleton/server/models/user.model.js:
UserSchema.methods = {
authenticate: function(plainText) {
return this.encryptPassword(plainText) === this.hashed_password
},
encryptPassword: function(password) {
if (!password) return ''
try {
return crypto
.createHmac('sha1', this.salt)
.update(password)
.digest('hex')
} catch (err) {
return ''
}
},
makeSalt: function() {
return Math.round((new Date().valueOf() * Math.random())) + ''
}
}
UserSchema方法可用于提供以下功能:
-
authenticate:此方法被调用以验证登录尝试,通过将用户提供的密码文本与数据库中特定用户的hashed_password进行匹配。 -
encryptPassword:此方法用于使用 Node 的crypto模块从纯文本密码和唯一的salt值生成加密哈希。 -
makeSalt:此方法使用执行时的当前时间戳和Math.random()生成一个唯一且随机的盐值。
crypto模块提供了各种加密功能,包括一些标准的加密哈希算法。在我们的代码中,我们使用 SHA1 哈希算法和crypto中的createHmac来从密码文本和salt对生成加密的 HMAC 哈希。
哈希算法为相同的输入值生成相同的哈希。但为了确保两个用户不会因为偶然使用相同的密码文本而得到相同的哈希密码,我们在为每个用户生成哈希密码之前,将每个密码与一个唯一的salt值配对。这也会使猜测所使用的哈希算法变得困难,因为看似相同的用户输入生成了不同的哈希。
这些UserSchema方法用于将用户提供的密码字符串加密成带有随机生成的salt值的hashed_password。当用户详细信息在创建或更新时保存到数据库中时,hashed_password和salt存储在用户文档中。为了匹配和验证用户在登录期间提供的密码字符串,需要这两个值。我们还应该确保用户一开始就选择一个强大的密码字符串,这可以通过添加自定义验证到护照字段来实现。
密码字段验证
为了向最终用户选择的实际密码字符串添加验证约束,我们需要添加自定义验证逻辑并将其与模式中的hashed_password字段关联。
mern-skeleton/server/models/user.model.js:
UserSchema.path('hashed_password').validate(function(v) {
if (this._password && this._password.length < 6) {
this.invalidate('password', 'Password must be at least 6 characters.')
}
if (this.isNew && !this._password) {
this.invalidate('password', 'Password is required')
}
}, null)
在我们的应用程序中,我们将保持密码验证标准简单,并确保在创建新用户或更新现有密码时提供密码值,并且其长度至少为六个字符。我们通过在 Mongoose 尝试存储hashed_password值之前添加自定义验证来实现这一点。如果验证失败,逻辑将返回相关的错误信息。
定义好的UserSchema以及所有与密码相关的业务逻辑,完成了用户模型实现。现在,我们可以导入并使用这个用户模型在其他后端代码的部分。但在我们开始使用这个模型来扩展后端功能之前,我们将添加一个辅助模块,这样我们就可以解析可读的 Mongoose 错误消息,这些错误消息是在对模式验证进行操作时抛出的。
Mongoose 错误处理
当用户数据保存到数据库中时,如果违反了添加到用户模式字段中的验证约束,将抛出错误消息。为了处理这些验证错误以及数据库在查询时可能抛出的其他错误,我们将定义一个辅助方法,该方法将返回可以在请求-响应周期中适当传播的相关错误消息。
我们将在 server/helpers/dbErrorHandler.js 文件中添加 getErrorMessage 辅助方法。此方法将解析并返回与特定验证错误或其他在查询 MongoDB 时可能发生的错误相关的错误信息。
mern-skeleton/server/helpers/dbErrorHandler.js:
const getErrorMessage = (err) => {
let message = ''
if (err.code) {
switch (err.code) {
case 11000:
case 11001:
message = getUniqueErrorMessage(err)
break
default:
message = 'Something went wrong'
}
} else {
for (let errName in err.errors) {
if (err.errors[errName].message)
message = err.errors[errName].message
}
}
return message
}
export default {getErrorMessage}
由于 Mongoose 验证器违规而未抛出的错误将包含一个相关的错误 code。在某些情况下,这些错误需要不同的处理方式。例如,由于违反 unique 约束而引起的错误将返回一个与 Mongoose 验证错误不同的错误对象。unique 选项不是一个验证器,而是构建 MongoDB unique 索引的方便助手,因此我们将添加另一个 getUniqueErrorMessage 方法来解析与 unique 约束相关的错误对象并构建适当的错误信息。
mern-skeleton/server/helpers/dbErrorHandler.js:
const getUniqueErrorMessage = (err) => {
let output
try {
let fieldName =
err.message.substring(err.message.lastIndexOf('.$') + 2,
err.message.lastIndexOf('_1'))
output = fieldName.charAt(0).toUpperCase() + fieldName.slice(1) +
' already exists'
} catch (ex) {
output = 'Unique field already exists'
}
return output
}
通过使用从该辅助文件导出的 getErrorMessage 函数,我们可以在处理由 Mongoose 操作抛出的错误时添加有意义的错误信息。
用户模型完成后,我们可以执行与使用我们将在下一节中开发的用户 API 实现用户 CRUD 功能相关的 Mongoose 操作。
添加用户 CRUD API
由 Express 应用程序公开的用户 API 端点将允许前端根据用户模型生成文档执行 CRUD 操作。为了实现这些工作端点,我们将编写 Express 路由和相应的控制器回调函数,这些函数应在针对这些声明的路由传入 HTTP 请求时执行。在本节中,我们将查看这些端点在没有任何认证限制的情况下是如何工作的。
我们将使用 Express 路由器在 server/routes/user.routes.js 中声明用户 API 路由,然后将其挂载到我们在 server/express.js 中配置的 Express 应用程序上。
mern-skeleton/server/express.js:
import userRoutes from './routes/user.routes'
...
app.use('/', userRoutes)
...
所有路由和 API 端点,例如我们接下来将声明的特定用户路由,都需要挂载到 Express 应用程序上,以便从客户端访问。
用户路由
在 user.routes.js 文件中定义的用户路由将使用 express.Router() 来定义与相关 HTTP 方法相关的路由路径,并将相应的控制器函数分配给当这些请求被服务器接收时应调用的函数。
我们将通过以下方式保持用户路由的简单性:
-
/api/users用于以下操作:-
使用 GET 列出用户
-
使用 POST 创建新用户
-
-
/api/users/:userId用于以下操作:-
使用 GET 获取用户
-
使用 PUT 更新用户
-
使用 DELETE 删除用户
-
生成的 user.routes.js 代码将如下所示(不包括需要添加到受保护路由的认证考虑因素)。
mern-skeleton/server/routes/user.routes.js:
import express from 'express'
import userCtrl from '../controllers/user.controller'
const router = express.Router()
router.route('/api/users')
.get(userCtrl.list)
.post(userCtrl.create)
router.route('/api/users/:userId')
.get(userCtrl.read)
.put(userCtrl.update)
.delete(userCtrl.remove)
router.param('userId', userCtrl.userByID)
export default router
除了声明与用户 CRUD 操作对应的 API 端点外,我们还将配置 Express 路由器,以便它通过执行 userByID 控制器函数来处理请求路由中的 userId 参数。
当服务器接收到这些定义的路由中的每个请求时,相应的控制器函数会被调用。我们将在下一小节中定义这些控制器方法的功能,并将其从 user.controller.js 文件中导出。
用户控制器
server/controllers/user.controller.js 文件将包含用于先前用户路由声明的控制器方法的定义,这些方法作为回调在服务器接收到路由请求时执行。
user.controller.js 文件将具有以下结构:
import User from '../models/user.model'
import extend from 'lodash/extend'
import errorHandler from './error.controller'
const create = (req, res, next) => { … }
const list = (req, res) => { … }
const userByID = (req, res, next, id) => { … }
const read = (req, res) => { … }
const update = (req, res, next) => { … }
const remove = (req, res, next) => { … }
export default { create, userByID, read, list, remove, update }
此控制器将使用 errorHandler 辅助函数来响应路由请求,并在发生 Mongoose 错误时返回有意义的消息。在更新具有更改值的现有用户时,它还将使用名为 lodash 的模块。
lodash 是一个 JavaScript 库,它提供了用于常见编程任务的实用函数,包括数组和对象的操作。要安装 lodash,请在命令行中运行 yarn add lodash。
我们定义的每个控制器函数都与一个路由请求相关联,并将根据每个 API 用例进行详细说明。
创建新用户
创建新用户的 API 端点在以下路由中声明。
mern-skeleton/server/routes/user.routes.js:
router.route('/api/users').post(userCtrl.create)
当 Express 应用接收到针对 '/api/users' 的 POST 请求时,它会调用我们在控制器中定义的 create 函数。
mern-skeleton/server/controllers/user.controller.js:
const create = async (req, res) => {
const user = new User(req.body)
try {
await user.save()
return res.status(200).json({
message: "Successfully signed up!"
})
} catch (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
此函数使用从前端 POST 请求中接收到的用户 JSON 对象在 req.body 内创建新用户。调用 user.save 尝试在 Mongoose 对数据进行验证检查后,将新用户保存到数据库中。因此,会向请求客户端返回错误或成功响应。
create 函数被定义为使用 **async** 关键字的一个异步函数,允许我们使用 **await** 与 user.save() 一起,它返回一个 Promise。在 **async** 函数内部使用 **await** 关键字会导致此函数在执行下一行代码之前等待返回的 Promise 解决。如果 Promise 拒绝,则会抛出错误并在 catch 块中捕获。
Async/await 是 ES8 的一个新增功能,它允许我们以看似顺序或同步的方式编写异步 JavaScript 代码。对于处理异步行为,如访问数据库的控制器函数,我们将使用 async/await 语法来实现它们。
类似地,在下一节中,我们将使用 async/await 来实现控制器函数,以便在查询数据库后列出所有用户。
列出所有用户
获取所有用户的 API 端点在以下路由中声明。
mern-skeleton/server/routes/user.routes.js:
router.route('/api/users').get(userCtrl.list)
当 Express 应用程序接收到对 '/api/users' 的 GET 请求时,它执行 list 控制器函数。
mern-skeleton/server/controllers/user.controller.js:
const list = async (req, res) => {
try {
let users = await User.find().select('name email updated created')
res.json(users)
} catch (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
list 控制器函数从数据库中查找所有用户,仅在结果用户列表中填充 name、email、created 和 updated 字段,然后将此用户列表作为 JSON 对象数组返回给请求客户端。
剩余的 CRUD 操作,包括读取、更新和删除单个用户,需要我们首先通过 ID 获取特定的用户。在下文中,我们将实现控制器函数,以便从数据库中检索单个用户,以响应相应的请求,返回用户、更新用户或删除用户。
通过 ID 加载用户以读取、更新或删除
读取、更新和删除的所有三个 API 端点都需要根据被访问用户的用户 ID 从数据库中加载用户。我们将编程 Express 路由器首先执行此操作,然后再响应特定的读取、更新或删除请求。
加载
当 Express 应用程序收到一个请求,该请求与包含 :userId 参数的路径匹配时,应用程序将执行 userByID 控制器函数,该函数获取并加载用户到 Express 请求对象中,然后再将其传播到特定于请求的 next 函数。
mern-skeleton/server/routes/user.routes.js:
router.param('userId', userCtrl.userByID)
userByID 控制器函数使用 :userId 参数中的值通过 _id 查询数据库并加载匹配用户的详细信息。
mern-skeleton/server/controllers/user.controller.js:
const userByID = async (req, res, next, id) => {
try {
let user = await User.findById(id)
if (!user)
return res.status('400').json({
error: "User not found"
})
req.profile = user
next()
} catch (err) {
return res.status('400').json({
error: "Could not retrieve user"
})
}
}
如果在数据库中找到匹配的用户,则将用户对象附加到请求对象的 profile 键中。然后,使用 next() 中间件将控制权传递给下一个相关的控制器函数。例如,如果原始请求是读取用户资料,则 userByID 中的 next() 调用将转到 read 控制器函数,这将在下文中讨论。
读取
读取单个用户数据的 API 端点在以下路由中声明。
mern-skeleton/server/routes/user.routes.js:
router.route('/api/users/:userId').get(userCtrl.read)
当 Express 应用程序接收到对 '/api/users/:userId' 的 GET 请求时,它执行 userByID 控制器函数,通过 userId 值加载用户,然后执行 read 控制器函数。
mern-skeleton/server/controllers/user.controller.js:
const read = (req, res) => {
req.profile.hashed_password = undefined
req.profile.salt = undefined
return res.json(req.profile)
}
read 函数从 req.profile 中检索用户详细信息,并在将用户对象发送到请求客户端的响应中之前删除敏感信息,例如 hashed_password 和 salt 值。在实现更新用户的控制器函数时也遵循此规则,如下所示。
更新
更新单个用户的 API 端点在以下路由中声明。
mern-skeleton/server/routes/user.routes.js:
router.route('/api/users/:userId').put(userCtrl.update)
当 Express 应用接收到'/api/users/:userId'的 PUT 请求时,类似于读取,它会加载具有:userId参数值的用户,然后在执行update控制器函数之前。
mern-skeleton/server/controllers/user.controller.js:
const update = async (req, res) => {
try {
let user = req.profile
user = extend(user, req.body)
user.updated = Date.now()
await user.save()
user.hashed_password = undefined
user.salt = undefined
res.json(user)
} catch (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
update函数从req.profile中检索用户详细信息,然后使用lodash模块扩展并合并请求体中传入的更改以更新用户数据。在将此更新后的用户保存到数据库之前,updated字段被填充为当前日期,以反映最后更新的时间戳。在成功保存此更新后,更新后的用户对象通过删除敏感数据(如hashed_password和salt)来清理,然后在响应中发送给请求客户端。删除用户的最终用户控制器函数的实现与update函数类似,具体细节将在下一节中说明。
删除
删除用户的 API 端点在以下路由中声明。
mern-skeleton/server/routes/user.routes.js:
router.route('/api/users/:userId').delete(userCtrl.remove)
当 Express 应用接收到'/api/users/:userId'的 DELETE 请求时,类似于读取和更新,它会根据 ID 加载用户,然后执行remove控制器函数。
mern-skeleton/server/controllers/user.controller.js:
const remove = async (req, res) => {
try {
let user = req.profile
let deletedUser = await user.remove()
deletedUser.hashed_password = undefined
deletedUser.salt = undefined
res.json(deletedUser)
} catch (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
remove函数从req.profile中检索用户,并使用remove()查询从数据库中删除用户。在成功删除后,请求客户端会收到响应中的删除用户对象。
到目前为止,API 端点的实现允许任何客户端对用户模型执行 CRUD 操作。然而,我们希望通过身份验证和授权来限制对这些操作的一些访问。我们将在下一节中探讨这个问题。
集成用户认证和保护路由
为了限制对用户操作(如查看用户资料、更新用户和删除用户)的访问,我们首先实现使用 JWT 的登录认证,然后使用它来保护并授权读取、更新和删除路由。
登录和注销相关的认证 API 端点将在server/routes/auth.routes.js中声明,然后将其挂载到server/express.js中的 Express 应用上。
mern-skeleton/server/express.js:
import authRoutes from './routes/auth.routes'
...
app.use('/', authRoutes)
...
这将使我们在auth.routes.js中定义的路由从客户端可访问。
认证路由
两个认证 API 在auth.routes.js文件中定义,使用express.Router()声明路由路径和相关 HTTP 方法。它们还被分配了相应的控制器函数,当接收到这些路由的请求时应该调用这些函数。
认证路由如下:
-
'/auth/signin':使用电子邮件和密码进行用户认证的 POST 请求 -
'/auth/signout':登录后,在响应对象上设置的 JWT 包含的 cookie 的 GET 请求用于清除
生成的mern-skeleton/server/routes/auth.routes.js文件将如下所示:
import express from 'express'
import authCtrl from '../controllers/auth.controller'
const router = express.Router()
router.route('/auth/signin')
.post(authCtrl.signin)
router.route('/auth/signout')
.get(authCtrl.signout)
export default router
对 signin 路由的 POST 请求和对 signout 路由的 GET 请求将调用在 auth.controller.js 文件中定义的相应控制器函数,如下一节所述。
认证控制器
server/controllers/auth.controller.js 中的认证控制器函数不仅会处理对 signin 和 signout 路由的请求,还会提供 JWT 和 express-jwt 功能,以启用受保护用户 API 端点的认证和授权。
mern-skeleton/server/controllers/auth.controller.js 文件将具有以下结构:
import User from '../models/user.model'
import jwt from 'jsonwebtoken'
import expressJwt from 'express-jwt'
import config from './../../config/config'
const signin = (req, res) => { … }
const signout = (req, res) => { … }
const requireSignin = …
const hasAuthorization = (req, res) => { … }
export default { signin, signout, requireSignin, hasAuthorization }
在以下几节中详细说明了四个控制器函数,以展示后端如何使用 JSON Web Tokens 实现用户认证。我们将从下一节的 signin 控制器函数开始。
登录
在以下路由中声明了用于登录用户的 API 端点。
mern-skeleton/server/routes/auth.routes.js:
router.route('/auth/signin').post(authCtrl.signin)
当 Express 应用程序接收到对 '/auth/signin' 的 POST 请求时,它将执行 signin 控制器函数。
mern-skeleton/server/controllers/auth.controller.js:
const signin = async (req, res) => {
try {
let user = await User.findOne({ "email": req.body.email })
if (!user)
return res.status('401').json({ error: "User not found" })
if (!user.authenticate(req.body.password)) {
return res.status('401').send({ error: "Email and
password don't match." })
}
const token = jwt.sign({ _id: user._id }, config.jwtSecret)
res.cookie('t', token, { expire: new Date() + 9999 })
return res.json({
token,
user: {
_id: user._id,
name: user.name,
email: user.email
}
})
} catch (err) {
return res.status('401').json({ error: "Could not sign in" })
}
}
POST 请求对象在 req.body 中接收电子邮件和密码。这个电子邮件用于从数据库中检索匹配的用户。然后,使用在 UserSchema 中定义的密码认证方法来验证从客户端接收到的 req.body 中的密码。
如果密码验证成功,JWT 模块将使用密钥和用户 _id 值生成一个签名的 JWT。
通过在命令行中运行 yarn add jsonwebtoken 来安装 jsonwebtoken 模块,使其可用于此控制器。
然后,将签名的 JWT 返回给认证的客户端,并附带用户的详细信息。可选地,我们还可以将令牌设置为响应对象中的 cookie,以便在客户端(如果选择 cookie 作为 JWT 存储形式)可用。在客户端,此令牌必须在请求受保护路由时附加为 Authorization 标头。要注销用户,客户端可以简单地根据其存储方式删除此令牌。在下一节中,我们将学习如何使用 signout API 端点清除包含令牌的 cookie。
注销
在以下路由中声明了用于注销用户的 API 端点。
mern-skeleton/server/routes/auth.routes.js:
router.route('/auth/signout').get(authCtrl.signout)
当 Express 应用程序接收到对 '/auth/signout' 的 GET 请求时,它将执行 signout 控制器函数。
mern-skeleton/server/controllers/auth.controller.js:
const signout = (req, res) => {
res.clearCookie("t")
return res.status('200').json({
message: "signed out"
})
}
signout 函数清除包含签名的 JWT 的响应 cookie。这是一个可选端点,如果前端完全不使用 cookie,那么对于认证目的来说并不是必需的。
使用 JWT,用户状态存储是客户端的责任,除了 cookies 之外,客户端还有多种存储选项。在注销时,客户端需要在客户端删除令牌,以确认用户不再经过身份验证。在服务器端,我们可以使用和验证在登录时生成的令牌来保护不应未经有效身份验证而访问的路由。在下一节中,我们将学习如何使用 JWT 实现这些受保护的路由。
使用 express-jwt 保护路由
为了保护对读取、更新和删除路由的访问,服务器需要检查请求客户端是否是经过身份验证和授权的用户。
当访问受保护的路由时,为了检查请求用户是否已登录并且具有有效的 JWT,我们将使用express-jwt模块。
express-jwt模块是一个中间件,用于验证 JSON Web Tokens。通过命令行运行yarn add express-jwt来安装express-jwt。
保护用户路由
我们将定义两个认证控制器方法,分别称为requireSignin和hasAuthorization,这两个方法将被添加到需要通过身份验证和授权进行保护的用户路由声明中。
user.routes.js中的读取、更新和删除路由需要按以下方式更新。
mern-skeleton/server/routes/user.routes.js:
import authCtrl from '../controllers/auth.controller'
...
router.route('/api/users/:userId')
.get(authCtrl.requireSignin, userCtrl.read)
.put(authCtrl.requireSignin, authCtrl.hasAuthorization,
userCtrl.update)
.delete(authCtrl.requireSignin, authCtrl.hasAuthorization,
userCtrl.remove)
...
读取用户信息的路由只需要进行身份验证验证,而更新和删除路由在执行这些 CRUD 操作之前应该检查身份验证和授权。我们将在下一节中探讨requireSignin方法的实现,该方法用于检查身份验证,以及hasAuthorization方法的实现,该方法用于检查授权。
要求登录
auth.controller.js中的requireSignin方法使用express-jwt来验证传入的请求是否在Authorization头中包含有效的 JWT。如果令牌有效,它将在请求对象中添加一个带有'auth'键的已验证用户 ID;否则,它将抛出一个身份验证错误。
mern-skeleton/server/controllers/auth.controller.js:
const requireSignin = expressJwt({
secret: config.jwtSecret,
userProperty: 'auth'
})
我们可以将requireSignin添加到任何需要保护免受未经身份验证访问的路由。
授权已登录用户
对于一些受保护的路由,例如更新和删除,除了检查身份验证之外,我们还想确保请求用户只更新或删除他们自己的用户信息。
为了实现这一点,auth.controller.js中定义的hasAuthorization函数将在允许相应的 CRUD 控制器函数继续之前,检查经过身份验证的用户是否与正在更新或删除的用户相同。
mern-skeleton/server/controllers/auth.controller.js:
const hasAuthorization = (req, res, next) => {
const authorized = req.profile && req.auth
&& req.profile._id == req.auth._id
if (!(authorized)) {
return res.status('403').json({
error: "User is not authorized"
})
}
next()
}
在requireSignin认证验证后,express-jwt填充了req.auth对象,而req.profile则由user.controller.js中的userByID函数填充。我们将向需要认证和授权的路由添加hasAuthorization函数。
express-jwt的认证错误处理
为了处理express-jwt在尝试验证传入请求中的 JWT 令牌时抛出的认证相关错误,我们需要在mern-skeleton/server/express.js中的 Express 应用程序配置中添加以下错误捕获代码,在代码末尾,在路由挂载之后和应用程序导出之前:
app.use((err, req, res, next) => {
if (err.name === 'UnauthorizedError') {
res.status(401).json({"error" : err.name + ": " + err.message})
}else if (err) {
res.status(400).json({"error" : err.name + ": " + err.message})
console.log(err)
}
})
当由于某种原因令牌无法验证时,express-jwt会抛出一个名为UnauthorizedError的错误。我们在这里捕获这个错误,向请求客户端返回401状态。如果在这里生成并捕获其他服务器端错误,我们也会添加一个要发送的响应。
在为保护路由实现用户认证后,我们已经涵盖了我们的 MERN 应用程序骨架所需的所有工作后端功能。在下一节中,我们将探讨如何检查这个独立后端是否按预期工作,而不需要实现前端。
检查独立后端
在选择用于检查后端 API 的工具时,有多种选择,从命令行工具 curl(github.com/curl/curl)到高级 REST 客户端(ARC)(chrome.google.com/webstore/detail/advanced-rest-client/hgmloofddffdnphfgcellkdfbfbjeloo),这是一个具有交互式用户界面的 Chrome 扩展应用程序。
要检查本章中实现的 API,首先,从命令行启动服务器,并使用这些工具之一请求路由。如果您在本地机器上运行代码,根 URL 是http://localhost:3000/。
使用 ARC,我们将展示五个用例的预期行为,以便我们可以检查实现的 API 端点。
创建新用户
首先,我们将通过/api/users POST 请求创建一个新用户,并在请求体中传递名称、电子邮件和密码值。当用户在数据库中成功创建且没有验证错误时,我们将看到如下截图所示的 200 OK 成功消息:

您也可以尝试发送具有无效名称、电子邮件和密码值的相同请求,以检查后端是否返回了相关的错误消息。接下来,我们将通过调用列表用户 API 来检查用户是否已成功创建并存储在数据库中。
获取用户列表
我们可以通过向/api/users发送一个获取所有用户的GET请求来查看新用户是否在数据库中。响应应包含存储在数据库中的所有用户对象的数组:

注意返回的用户对象只显示了_id、name、email和created字段的值,而没有显示salt或hashed_password值,这些值也存在于数据库中实际存储的文档中。请求只检索我们在列表控制器方法中指定的 Mongoose find查询中指定的所选字段。在获取单个用户时也存在这种省略。
尝试获取单个用户
接下来,我们将尝试在不先登录的情况下访问受保护的 API。读取任何用户的GET请求将返回 401 未授权错误,如下例所示。在这里,向/api/users/5a1c7ead1a692aa19c3e7b33发送的GET请求返回 401 错误:

为了使这个请求返回包含用户详情的成功响应,需要在请求头中提供一个有效的授权令牌。我们可以通过成功调用登录请求来生成一个有效的令牌。
登录
为了能够访问受保护的路由,我们将使用第一个示例中创建的用户凭据进行登录。为了登录,发送一个 POST 请求到/auth/signin,请求体中包含电子邮件和密码,如下面的截图所示:

登录成功后,服务器返回一个签名过的 JWT 和用户详情。我们需要这个令牌来访问用于获取单个用户的受保护路由。
成功获取单个用户
使用登录后收到的令牌,我们现在可以访问之前失败的受保护路由。在向/api/users/5a1c7ead1a692aa19c3e7b33发送 GET 请求时,令牌被设置为Authorization头部的 Bearer 方案中。这次,成功返回了用户对象:

如本节所示,使用 ARC,您还可以检查其他 API 端点的更新和删除用户实现。所有这些 API 端点按预期工作,我们就有了一个完整的 MERN 应用程序后端。
摘要
在本章中,我们使用 Node、Express 和 MongoDB 开发了一个完全功能独立的后端应用程序,并涵盖了 MERN 框架应用程序骨架的第一部分。在后端,我们实现了用于存储用户数据的用户模型,使用 Mongoose 实现;实现了执行 CRUD 操作的 API 端点,使用 Express 实现;以及实现了用于受保护路由的用户认证,使用 JWT 和express-jwt实现。
我们还通过配置 Webpack 来设置开发流程,以便使用 Babel 编译 ES6+代码。我们还配置了 Nodemon,以便在代码更改时重启服务器。最后,我们使用 Chrome 的高级 Rest API 客户端扩展应用程序检查了 API 的实现。
现在,我们已经准备好扩展这个后端应用程序代码并添加 React 前端,这将完成 MERN 框架的应用程序。我们将在下一章中这样做。
第六章:将 React 前端添加到完成 MERN
一个 Web 应用程序没有前端是不完整的。这是用户与之交互的部分,对于任何 Web 体验都至关重要。在本章中,我们将使用 React 为 MERN 骨架应用程序后端已实现的基本用户和认证功能添加交互式用户界面,我们在上一章中开始构建该应用程序。这个功能性的前端将添加连接到后端 API 的 React 组件,并允许用户根据授权在应用程序中无缝导航。到本章结束时,你将学会如何轻松地将 React 客户端与 Node-Express-MongoDB 服务器端集成,以创建全栈 Web 应用程序。
在本章中,我们将涵盖以下主题:
-
骨架的前端特性
-
使用 React、React Router 和 Material-UI 设置开发环境
-
使用 React 渲染主页
-
后端用户 API 集成
-
限制访问的认证集成
-
用户列表、个人资料、编辑、删除、注册和登录 UI 以完成用户前端
-
基本服务器端渲染
定义骨架应用程序的前端
为了完全实现我们在第三章“使用 MongoDB、Express 和 Node 构建后端”的“功能分解”部分中讨论的骨架应用程序功能,我们将向我们的基础应用程序添加以下用户界面组件:
-
主页:在根 URL 上渲染的视图,用于欢迎用户访问 Web 应用程序。
-
注册页面:一个带有用户注册表单的视图,允许新用户创建用户账户,并在成功创建后将其重定向到登录页面。
-
登录页面:一个带有登录表单的视图,允许现有用户登录,以便他们可以访问受保护的视图和操作。
-
用户列表页面:一个视图,用于从数据库中检索并显示所有用户的列表,并且还链接到单个用户个人资料。
-
个人资料页面:一个组件,用于检索并显示单个用户的信息。这仅对已登录用户可用,并且还包含编辑和删除选项,这些选项仅在已登录用户查看自己的个人资料时可见。
-
编辑个人资料页面:一个表单,用于检索用户信息以预填充表单字段。这允许用户编辑信息,并且此表单仅对尝试编辑自己个人资料的已登录用户可用。
-
删除用户组件:一个选项,允许已登录用户在确认其意图后删除自己的个人资料。
-
菜单导航栏:一个组件,列出所有对用户可用和相关的视图,并有助于指示用户在应用程序中的当前位置。
下面的 React 组件树图显示了我们将开发的所有 React 组件,以构建此基础应用程序的视图:

MainRouter将是主要的 React 组件。它包含应用程序中的所有其他自定义 React 视图。首页、注册、登录、用户、个人资料和编辑个人资料将在 React Router 声明的单独路由上渲染,而菜单组件将在所有这些视图中渲染。删除用户将是个人资料视图的一部分。
本章讨论的代码以及完整的骨架,可在 GitHub 上找到:github.com/PacktPublishing/Full-Stack-React-Projects-Second-Edition/tree/master/Chapter03%20and%2004/mern-skeleton。您可以克隆此代码,并在阅读本章其余部分的代码解释时运行应用程序。
为了实现这些前端 React 视图,我们不得不扩展现有的项目代码,这些代码包含 MERN 骨架的独立服务器应用程序。接下来,我们将简要查看构成这个前端并完成全栈骨架应用程序代码所需的文件。
文件夹和文件结构
以下文件夹结构显示了需要添加到我们在上一章开始实现的基础项目中的新文件夹和文件,以便使用 React 前端来完成它:
| mern_skeleton/
| -- client/
| --- assets/
| ---- images/
| --- auth/
| ---- api-auth.js
| ---- auth-helper.js
| ---- PrivateRoute.js
| ---- Signin.js
| --- core/
| ---- Home.js
| ---- Menu.js
| --- user/
| ---- api-user.js
| ---- DeleteUser.js
| ---- EditProfile.js
| ---- Profile.js
| ---- Signup.js
| ---- Users.js
| --- App.js
| --- main.js
| --- MainRouter.js
| --- theme.js
| -- server/
| --- devBundle.js
| -- webpack.config.client.js
| -- webpack.config.client.production.js
client文件夹将包含 React 组件、辅助工具和前端资产,如图片和 CSS。除了这个文件夹和用于编译和打包客户端代码的 Webpack 配置文件之外,我们还将修改一些其他现有的文件,以完成本章中完整骨架应用程序的集成。
在我们开始实现具体的前端功能之前,我们需要通过安装必要的模块并添加编译、打包和加载 React 视图的配置来为 React 开发做好准备。我们将在下一节中介绍这些设置步骤。
设置 React 开发环境
在我们可以在现有的骨架代码库中使用 React 进行开发之前,我们需要添加配置以编译和打包前端代码,添加构建交互式界面所需的 React 相关依赖项,并在 MERN 开发流程中将这一切结合起来。
为了实现这一点,我们将添加前端配置以编译、打包和热重载代码的 Babel、Webpack 和 React Hot Loader。接下来,我们将修改服务器代码,以便在一条命令中启动前端和后端的代码打包,使开发流程简单化。然后,我们将进一步更新代码,以便在应用程序在浏览器中运行时从服务器提供打包后的代码。最后,我们将通过安装启动前端实现所需的 React 依赖项来完成设置。
配置 Babel 和 Webpack
在代码可以在浏览器中运行之前,我们需要编译和捆绑我们将编写的 React 代码以实现前端。为了在开发期间运行并捆绑客户端代码,以及为生产捆绑,我们将更新 Babel 和 Webpack 的配置。然后,我们将配置 Express 应用程序以一个命令来启动前端和后端代码捆绑,这样在开发期间只需启动服务器就可以使整个堆栈准备好运行和测试。
Babel
要编译 React,首先,通过在命令行中运行以下命令将 Babel React 预设模块作为开发依赖项安装:
yarn add --dev @babel/preset-react
然后,使用以下代码更新.babelrc。这将包括模块,并配置react-hot-loader Babel 插件,这是react-hot-loader模块所需的。
mern-skeleton/.babelrc:
{
"presets": [
["@babel/preset-env",
{
"targets": {
"node": "current"
}
}
],
"@babel/preset-react"
],
"plugins": [
"react-hot-loader/babel"
]
}
要使用此更新的 Babel 配置,我们需要更新 Webpack 配置,我们将在下一节中查看。
Webpack
在使用 Babel 编译客户端代码后捆绑它,以及启用react-hot-loader以加快开发速度,通过在命令行中运行以下命令安装以下模块:
yarn add -dev webpack-dev-middleware webpack-hot-middleware file-loader
yarn add react-hot-loader @hot-loader/react-dom
然后,为了配置 Webpack 以进行前端开发并构建生产包,我们将添加一个webpack.config.client.js文件和一个webpack.config.client.production.js文件,其中包含我们在第二章中描述的相同配置代码,准备开发环境。
在配置好 Webpack 并准备好捆绑前端 React 代码后,接下来,我们将添加一些可以在我们的开发流程中使用的代码。这将使全栈开发过程无缝。
加载开发 Webpack 中间件
在开发期间,当我们运行服务器时,Express 应用程序还应加载与前端相关的 Webpack 中间件,这与为客户端代码设置的配置相匹配,以便前端和后端开发工作流程集成。为此,我们将使用我们在第二章中讨论的devBundle.js文件,准备开发环境,以设置一个接受 Express 应用程序并配置它使用 Webpack 中间件的compile方法。server文件夹中的devBundle.js文件将如下所示。
mern-skeleton/server/devBundle.js:
import config from './../config/config'
import webpack from 'webpack'
import webpackMiddleware from 'webpack-dev-middleware'
import webpackHotMiddleware from 'webpack-hot-middleware'
import webpackConfig from './../webpack.config.client.js'
const compile = (app) => {
if(config.env === "development"){
const compiler = webpack(webpackConfig)
const middleware = webpackMiddleware(compiler, {
publicPath: webpackConfig.output.publicPath
})
app.use(middleware)
app.use(webpackHotMiddleware(compiler))
}
}
export default {
compile
}
在此方法中,Webpack 中间件使用在webpack.config.client.js中设置的值,并使用 Webpack Hot Middleware 从服务器端启用热重载。
最后,我们需要在express.js中导入并调用这个compile方法,通过添加以下突出显示的行,但仅在开发期间。
mern-skeleton/server/express.js:
import devBundle from './devBundle'
const app = express()
devBundle.compile(app)
这两条加粗的行仅用于开发模式,在构建生产代码时应将其注释掉。当 Express 应用以开发模式运行时,添加此代码将导入中间件以及客户端 Webpack 配置。然后,它将启动 Webpack 来编译和打包客户端代码,并启用热重载。
打包的代码将被放置在dist文件夹中。这些代码将用于渲染视图。接下来,我们将配置 Express 服务器应用,使其从dist文件夹提供静态文件。这将确保打包的 React 代码可以在浏览器中加载。
加载打包的前端代码
我们将在浏览器中看到的渲染的前端视图将加载自dist文件夹中的打包文件。为了将这些打包文件添加到包含我们前端代码的 HTML 视图中,我们需要配置 Express 应用,使其提供静态文件,这些文件不是由服务器端代码动态生成的。
使用 Express 提供静态文件
为了确保 Express 服务器正确处理对静态文件(如 CSS 文件、图像或打包的客户端 JS)的请求,我们将配置它,通过在express.js中添加以下配置,从dist文件夹提供静态文件。
mern-skeleton/server/express.js:
import path from 'path'
const CURRENT_WORKING_DIR = process.cwd()
app.use('/dist', express.static(path.join(CURRENT_WORKING_DIR, 'dist')))
在此配置到位后,当 Express 应用接收到以/dist开头的路由请求时,它将知道在返回资源之前在dist文件夹中查找请求的静态资源。现在,我们可以在前端加载dist文件夹中的打包文件。
更新模板以加载打包的脚本
为了将打包的前端代码添加到 HTML 中以便渲染我们的 React 前端,我们将更新template.js文件,使其将dist文件夹中的脚本文件添加到<body>标签的末尾。
mern-skeleton/template.js:
...
<body>
<div id="root"></div>
<script type="text/javascript" src="img/bundle.js"></script>
</body>
当我们在服务器运行时访问根 URL '/' 时,此脚本标签将在浏览器中加载我们的 React 前端代码。我们已经准备好看到这一效果,并可以开始安装将添加 React 视图的依赖项。
添加 React 依赖项
我们骨架应用的前端视图将主要使用 React 来实现。此外,为了启用客户端路由,我们将使用 React Router,并且为了通过简洁的外观和感觉提升用户体验,我们将使用 Material-UI。为了添加这些库,我们将在本节中安装以下模块:
-
核心 React 模块:
react和react-dom -
React Router 模块:
react-router和react-router-dom -
Material-UI 模块:
@material-ui/core和@material-ui/icons
React
在整本书中,我们将使用 React 来编写前端代码。为了开始编写 React 组件代码,我们需要将以下模块作为常规依赖项安装:
yarn add react react-dom
这些是实施基于 React 的 Web 前端所必需的核心 React 库模块。通过添加其他附加模块,我们将在 React 之上添加更多功能。
React Router
React Router 提供了一组导航组件,这些组件使 React 应用程序在前端实现路由功能。我们将添加以下 React Router 模块:
yarn add react-router react-router-dom
这些模块将使我们能够利用声明式路由,并在前端拥有可书签的 URL 路由。
Material-UI
为了保持我们的 MERN 应用程序的 UI 简洁,而不深入 UI 设计和实现,我们将利用 Material-UI 库。它提供了现成可使用且可定制的 React 组件,实现了谷歌的材质设计。为了开始使用 Material-UI 组件来构建前端,我们需要安装以下模块:
yarn add @material-ui/core @material-ui/icons
在撰写本文时,Material-UI 的最新版本是4.9.8。建议您安装此确切版本,以确保示例项目的代码不会出错。
为了添加 Material-UI 推荐的Roboto字体并使用Material-UI图标,我们将把相关的样式链接添加到template.js文件中,在 HTML 文档的<head>部分:
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:100,300,400">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
在开发配置全部设置完成,并将必要的 React 模块添加到代码库之后,我们现在可以开始实现自定义的 React 组件,从主页开始。这个页面应该作为完整应用程序的第一个视图加载。
渲染主页视图
为了展示如何实现这个 MERN 框架的前端功能,我们将首先详细说明如何在应用程序的根路由上渲染一个简单的首页,然后再介绍后端 API 集成、用户认证集成,以及在本章的其余部分实现其他视图组件。
在根路由上实现和渲染一个工作的Home组件的过程,也将揭示前端代码骨架的基本结构。我们将从包含整个 React 应用的顶级入口组件开始,该组件渲染主路由组件,它将应用程序中的所有 React 组件链接起来。
在接下来的章节中,我们将开始实施 React 前端。首先,我们将添加根 React 组件,该组件与 React Router 和 Material-UI 集成,并配置为热重载。我们还将学习如何自定义 Material-UI 主题,并使主题对所有组件可用。最后,我们将实现并加载代表主页的 React 组件,从而展示如何在应用程序中添加和渲染 React 视图。
入口点在 main.js
客户端文件夹中的client/main.js文件将是渲染完整 React 应用程序的入口点,如客户端 Webpack 配置对象中已指示的那样。在client/main.js中,我们导入包含整个前端的最顶层或最高级 React 组件,并将其渲染到 HTML 文档中template.js指定的具有'root' ID 的div元素中。
mern-skeleton/client/main.js:
import React from 'react'
import { render } from 'react-dom'
import App from './App'
render(<App/>, document.getElementById('root'))
在这里,最高级的根 React 组件是App组件,它正在 HTML 中被渲染。App组件在client/App.js中定义,如下一小节所述。
根 React 组件
将包含应用程序前端所有组件的最高级 React 组件定义在client/App.js文件中。在这个文件中,我们配置 React 应用程序,使其使用定制的 Material-UI 主题渲染视图组件,启用前端路由,并确保 React Hot Loader 可以在我们开发组件时即时加载更改。
在以下章节中,我们将添加代码来自定义主题,使此主题和 React Router 功能可供我们的 React 组件使用,并配置用于热加载的根组件。
自定义 Material-UI 主题
可以使用ThemeProvider组件轻松地自定义 Material-UI 主题。它还可以用于在createMuiTheme()中配置主题变量的自定义值。我们将使用createMuiTheme在client/theme.js中为骨架应用程序定义一个自定义主题,然后将其导出,以便在App组件中使用。
mern-skeleton/client/theme.js:
import { createMuiTheme } from '@material-ui/core/styles'
import { pink } from '@material-ui/core/colors'
const theme = createMuiTheme({
typography: {
useNextVariants: true,
},
palette: {
primary: {
light: '#5c67a3',
main: '#3f4771',
dark: '#2e355b',
contrastText: '#fff',
},
secondary: {
light: '#ff79b0',
main: '#ff4081',
dark: '#c60055',
contrastText: '#000',
},
openTitle: '#3f4771',
protectedTitle: pink['400'],
type: 'light'
}
})
export default theme
对于骨架,我们只通过设置一些用于 UI 的颜色值进行最小化定制。这里生成的主题变量将被传递到,并在我们构建的所有组件中使用。
使用 ThemeProvider 和 BrowserRouter 包装根组件
我们将创建以构成用户界面的自定义 React 组件将通过MainRouter组件中指定的前端路由来访问。本质上,此组件包含了为应用程序开发的所有自定义视图,需要提供主题值和路由功能。此组件将是根App组件中的核心组件,该组件在以下代码中定义。
mern-skeleton/client/App.js:
import React from 'react'
import MainRouter from './MainRouter'
import {BrowserRouter} from 'react-router-dom'
import { ThemeProvider } from '@material-ui/styles'
import theme from './theme'
const App = () => {
return (
<BrowserRouter>
<ThemeProvider theme={theme}>
<MainRouter/>
</ThemeProvider>
</BrowserRouter>
)}
在App.js中定义此根组件时,我们将MainRouter组件包裹在ThemeProvider中,使其能够访问 Material-UI 主题,以及BrowserRouter,它通过 React Router 启用前端路由。我们之前定义的自定义主题变量作为属性传递给ThemeProvider,使主题可在所有自定义 React 组件中使用。最后,在App.js文件中,我们需要导出此App组件,以便可以在main.js中导入和使用。
标记根组件为热导出
App.js中的最后一行代码,用于导出App组件,使用了react-hot-loader的高阶组件(HOC)hot模块来标记根组件为hot。
mern-skeleton/client/App.js:
import { hot } from 'react-hot-loader'
const App = () => { ... }
export default hot(module)(App)
以这种方式标记App组件为hot实际上在开发期间启用了我们的 React 组件的实时重新加载。
在这一点之后,对于我们的 MERN 应用,我们不需要太多地更改main.js和App.js代码,我们可以通过将新组件注入到MainRouter组件中继续构建 React 应用的其余部分,这正是我们将在下一节中做的。
向 MainRouter 添加主页路由
MainRouter.js代码将帮助我们根据应用中的路由或位置渲染自定义 React 组件。在这个第一个版本中,我们只会添加根路由以渲染Home组件。
mern-skeleton/client/MainRouter.js:
import React from 'react'
import {Route, Switch} from 'react-router-dom'
import Home from './core/Home'
const MainRouter = () => {
return ( <div>
<Switch>
<Route exact path="/" component={Home}/>
</Switch>
</div>
)
}
export default MainRouter
随着我们开发更多的视图组件,我们将更新MainRouter并在Switch组件内添加新组件的路由。
React Router 中的Switch组件渲染一个特定的路由。换句话说,它只渲染与请求的路由路径匹配的第一个子组件。另一方面,如果没有嵌套在Switch中,当有路径匹配时,每个Route组件都会全面渲染;例如,对'/'的请求也匹配'/contact'路由。
我们在MainRouter中添加此路由的Home组件需要被定义和导出,我们将在下一节中这样做。
Home组件
Home组件将是包含骨架应用主页视图的 React 组件。当用户访问根路由时,它将在浏览器中渲染,我们将使用 Material-UI 组件来组合它。
以下截图显示了Home组件,以及将在本章后面作为单独组件实现的Menu组件,该组件将提供跨应用导航:

将在浏览器中渲染并供用户交互的Home组件和其他视图组件将遵循一个常见的代码结构,包含以下部分(按顺序):
-
导入构建组件所需的库、模块和文件
-
样式声明,用于定义组件元素的特定 CSS 样式
-
定义 React 组件的函数
在本书中,随着我们开发代表前端视图的新 React 组件,我们将主要关注 React 组件定义部分。但为了我们的第一次实现,我们将详细阐述所有这些部分以介绍必要的结构。
导入
对于每个 React 组件实现,我们需要导入实现代码中使用的库、模块和文件。组件文件将首先从 React、Material-UI、React Router 模块、图片、CSS、API 获取以及我们代码中的认证助手导入,具体取决于特定组件的需求。例如,对于Home.js中的Home组件代码,我们使用以下导入。
mern-skeleton/client/core/Home.js:
import React from 'react'
import { makeStyles } from '@material-ui/core/styles'
import Card from '@material-ui/core/Card'
import CardContent from '@material-ui/core/CardContent'
import CardMedia from '@material-ui/core/CardMedia'
import Typography from '@material-ui/core/Typography'
import unicornbikeImg from './../assets/images/unicornbike.jpg'
图片文件保存在client/assets/images/文件夹中,并导入以便将其添加到Home组件中。这些导入将帮助我们构建组件并定义组件中使用的样式。
样式声明
在导入之后,我们将通过利用Material-UI主题变量和makeStyles(这是由Material-UI提供的自定义 React Hook API)来定义所需的 CSS 样式,以通过Material-UI主题变量和makeStyles来设置组件中的元素样式。
Hooks 是 React 的新特性。Hooks 是函数,使得在函数组件中可以使用 React 状态和生命周期特性,而无需编写一个类来定义组件。React 提供了一些内置的 Hooks,但根据需要我们也可以构建自定义 Hooks 以在不同组件间重用有状态的行为。要了解更多关于 React Hooks 的信息,请访问reactjs.org/docs/hooks-intro.html。
对于Home.js中的Home组件,我们有以下样式。
mern-skeleton/client/core/Home.js:
const useStyles = makeStyles(theme => ({
card: {
maxWidth: 600,
margin: 'auto',
marginTop: theme.spacing(5)
},
title: {
padding:`${theme.spacing(3)}px ${theme.spacing(2.5)}px ${theme.spacing(2)}px`,
color: theme.palette.openTitle
},
media: {
minHeight: 400
}
}))
在这里定义的 JSS 样式对象将通过makeStyles Hook 返回的 Hook 注入到组件中。makeStyles Hook API 接受一个函数作为参数,并提供访问我们自定义主题变量的权限,我们可以在定义样式时使用这些变量。
Material-UI 使用 JSS,这是一个用于向组件添加样式的 CSS-in-JS 样式解决方案。JSS 使用 JavaScript 作为描述样式的语言。本书不会详细涵盖 CSS 和样式实现。它将主要依赖于 Material-UI 组件的默认外观和感觉。要了解更多关于 JSS 的信息,请访问cssinjs.org/?v=v9.8.1。有关如何自定义Material-UI组件样式的示例,请查看 Material-UI 文档material-ui.com/。
我们可以使用这些生成的样式来设置组件中的元素样式,如下面的Home组件定义所示。
组件定义
在编写定义组件的函数时,我们将组合组件的内容和行为。Home组件将包含一个带有标题、图片和说明的 Material-UI Card,所有这些都将使用我们之前定义并调用useStyles() Hook 返回的样式进行样式化。
mern-skeleton/client/core/Home.js:
export default function Home(){
const classes = useStyles()
return (
<Card className={classes.card}>
<Typography variant="h6" className={classes.title}>
Home Page
</Typography>
<CardMedia className={classes.media}
image={unicornbikeImg} title="Unicorn Bicycle"/>
<CardContent>
<Typography variant="body2" component="p">
Welcome to the MERN Skeleton home page.
</Typography>
</CardContent>
</Card>
)
}
在前面的代码中,我们定义并导出了一个名为Home的函数组件。现在,这个导出的组件可以在其他组件内部进行组合。正如我们之前讨论的那样,我们已经在MainRouter组件中的一个路由中导入了此Home组件。
在本书的整个过程中,我们将定义所有我们的 React 组件为函数组件。我们将利用 React Hooks,这是 React 的新增功能,来添加状态和生命周期特性,而不是使用类定义来实现相同的功能。
我们将在我们的 MERN 应用程序中实现的其他视图组件将遵循相同的结构。在本书的剩余部分,我们将主要关注组件定义,突出实现组件的独特方面。
我们几乎准备好运行此代码以在前端渲染主页组件。但在那之前,我们需要更新 Webpack 配置,以便我们可以捆绑和显示图像。
捆绑图像资源
我们导入到Home组件视图中的静态图像文件也必须包含在与其他编译 JS 代码捆绑的捆绑包中,以便代码可以访问和加载它。为了启用此功能,我们需要更新 Webpack 配置文件,并添加一个模块规则来加载、捆绑并将图像文件输出到dist目录,该目录包含编译的前端和后端代码。
更新webpack.config.client.js、webpack.config.server.js和webpack.config.client.production.js文件,以便在babel-loader使用后添加以下模块规则:
[ …
{
test: /\.(ttf|eot|svg|gif|jpg|png)(\?[\s\S]+)?$/,
use: 'file-loader'
}
]
此模块规则使用 Webpack 的file-loader节点模块,需要将其作为开发依赖项安装,如下所示:
yarn add --dev file-loader
添加了此图像捆绑配置后,当运行应用程序时,主页组件应成功渲染图像。
在浏览器中运行和打开
到目前为止的客户端代码可以运行,这样我们就可以在根 URL 中查看Home组件。要运行应用程序,请使用以下命令:
yarn development
然后,在浏览器中打开根 URL(http://localhost:3000)以查看Home组件。
我们在本节中开发的Home组件是一个基本的视图组件,没有交互功能,并且不需要使用后端 API 进行用户 CRUD 或认证。然而,我们前端骨架的剩余视图组件将需要后端 API 和认证,因此我们将探讨如何在下一节中集成这些功能。
集成后端 API
用户应该能够使用前端视图根据认证和授权从数据库中检索和修改用户数据。为了实现这些功能,React 组件将使用 Fetch API 访问后端公开的 API 端点。
Fetch API 是一个较新的标准,它使网络请求类似于XMLHttpRequest(XHR),但使用 promise 而不是回调,从而实现了一个更简单、更干净的 API。要了解更多关于 Fetch API 的信息,请访问developer.mozilla.org/en-US/docs/Web/API/Fetch_API。
用户 CRUD 的获取
在client/user/api-user.js文件中,我们将添加访问每个用户 CRUD API 端点的方法,React 组件可以使用这些方法根据需要与服务器和数据库交换用户数据。在接下来的章节中,我们将探讨这些方法的实现以及它们如何对应到每个 CRUD 端点。
创建用户
create方法将从视图组件获取用户数据,我们将在那里调用此方法。然后,它将使用fetch在创建 API 路由'/api/users'上发起一个POST调用,以在后端使用提供的数据创建一个新用户。
mern-skeleton/client/user/api-user.js:
const create = async (user) => {
try {
let response = await fetch('/api/users/', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(user)
})
return await response.json()
} catch(err) {
console.log(err)
}
}
最后,在这个方法中,我们作为 promise 返回来自服务器的响应。因此,调用此方法的组件可以使用这个 promise 来适当地处理响应,具体取决于从服务器返回的内容。同样,我们将在下一节中实现list方法。
列出用户
list方法将使用fetch发起一个GET调用,以检索数据库中的所有用户,然后作为 promise 将服务器的响应返回给组件。
mern-skeleton/client/user/api-user.js:
const list = async (signal) => {
try {
let response = await fetch('/api/users/', {
method: 'GET',
signal: signal,
})
return await response.json()
} catch(err) {
console.log(err)
}
}
如果返回的 promise 成功解析,将给组件提供一个包含从数据库检索到的用户对象的数组。在单个用户读取的情况下,我们将处理单个用户对象,如下所示。
阅读用户资料
read方法将使用fetch发起一个GET调用,通过 ID 检索特定用户。由于这是一个受保护的路由,除了传递用户 ID 作为参数外,请求的组件还必须提供有效的凭据,在这种情况下,将是一个在成功登录后收到的有效 JWT。
mern-skeleton/client/user/api-user.js:
const read = async (params, credentials, signal) => {
try {
let response = await fetch('/api/users/' + params.userId, {
method: 'GET',
signal: signal,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
}
})
return await response.json()
} catch(err) {
console.log(err)
}
}
JWT 通过Bearer方案附加到GET fetch 调用中的Authorization头,然后服务器响应作为 promise 返回给组件。当这个 promise 解析时,它将要么给组件提供特定用户的用户详情,要么通知访问权限仅限于认证用户。同样,更新的用户 API 方法也需要为 fetch 调用传递有效的 JWT 凭据,如下一节所示。
更新用户数据
update方法将获取特定用户的更改后的用户数据,然后使用fetch发起一个PUT调用,以更新后端中现有的用户。这也是一个受保护的路由,将需要有效的 JWT 作为凭据。
mern-skeleton/client/user/api-user.js:
const update = async (params, credentials, user) => {
try {
let response = await fetch('/api/users/' + params.userId, {
method: 'PUT',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
},
body: JSON.stringify(user)
})
return await response.json()
} catch(err) {
console.log(err)
}
}
正如我们看到的其他 fetch 调用一样,此方法也将返回一个包含服务器对用户更新请求响应的承诺。在最后一个方法中,我们将学习如何调用用户删除 API。
删除用户
remove 方法将允许视图组件从数据库中删除特定用户,并使用 fetch 发起一个 DELETE 请求。这同样是一个受保护的路线,需要有效的 JWT 作为凭证,类似于 read 和 update 方法。
mern-skeleton/client/user/api-user.js:
const remove = async (params, credentials) => {
try {
let response = await fetch('/api/users/' + params.userId, {
method: 'DELETE',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
}
})
return await response.json()
} catch(err) {
console.log(err)
}
}
服务器对删除请求的响应将以承诺的形式返回到组件中,就像其他方法一样。
在这五个辅助方法中,我们已经涵盖了所有与用户 CRUD 相关的后端实现的 API 端点调用。最后,我们可以如下从 api-user.js 文件导出这些方法。
mern-skeleton/client/user/api-user.js:
export { create, list, read, update, remove }
这些用户 CRUD 方法现在可以根据需要由 React 组件导入和使用。接下来,我们将实现类似的辅助方法以集成与认证相关的 API 端点。
为认证 API 获取
为了将服务器端的认证 API 端点与前端 React 组件集成,我们将在 client/auth/api-auth.js 文件中添加获取登录和注销 API 端点的方法。让我们来看看它们。
登录
signin 方法将从视图组件获取用户登录数据,然后使用 fetch 发起一个 POST 请求以验证用户与后端。
mern-skeleton/client/auth/api-auth.js:
const signin = async (user) => {
try {
let response = await fetch('/auth/signin/', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(user)
})
return await response.json()
} catch(err) {
console.log(err)
}
}
服务器响应将以承诺的形式返回到组件中,如果登录成功,可能会提供 JWT。调用此方法的组件需要适当地处理响应,例如将接收到的 JWT 本地存储,以便在从前端调用其他受保护 API 路由时使用。我们将在本章后面实现 登录 视图时查看此实现。
用户成功登录后,我们还想在用户注销时调用注销 API。接下来将讨论注销 API 的调用。
注销
我们将在 api-auth.js 中添加一个 signout 方法,它将使用 fetch 发起一个 GET 请求到服务器的注销 API 端点。
mern-skeleton/client/auth/api-auth.js:
const signout = async () => {
try {
let response = await fetch('/auth/signout/', { method: 'GET' })
return await response.json()
} catch(err) {
console.log(err)
}
}
此方法也将返回一个承诺,以通知组件 API 请求是否成功。
在 api-auth.js 文件的末尾,我们将导出 signin 和 signout 方法。
mern-skeleton/client/auth/api-auth.js:
export { signin, signout }
现在,这些方法可以被导入到相关的 React 组件中,以便我们可以实现用户登录和注销功能。
在添加了这些 API 获取方法之后,React 前端可以完全访问我们在后端提供的端点。在我们开始在 React 组件中使用这些方法之前,我们将探讨如何在前端维护用户认证状态。
在前端添加认证
正如我们在上一章中讨论的,使用 JWT 实现认证将管理用户认证状态的责任转移给了客户端。为此,我们需要编写代码,允许客户端在成功登录后存储从服务器接收到的 JWT,在访问受保护的路由时使其可用,当用户登出时删除或使令牌无效,并且根据用户认证状态限制对视图和组件的前端访问。
使用 React Router 文档中的认证工作流程示例,在以下章节中,我们将编写辅助方法来管理组件间的认证状态,并使用自定义的 PrivateRoute 组件将受保护的路由添加到 MERN 骨架应用程序的前端。
管理认证状态
为了管理应用程序前端中的认证状态,前端需要能够存储、检索和删除在用户成功登录时从服务器接收到的认证凭据。在我们的 MERN 应用程序中,我们将使用浏览器的 sessionStorage 作为存储选项来存储 JWT 认证凭据。
或者,您可以使用 localStorage 而不是 sessionStorage 来存储 JWT 凭据。使用 sessionStorage,用户认证状态将仅在当前窗口标签中记住。使用 localStorage,用户认证状态将在浏览器的标签间记住。
在 client/auth/auth-helper.js 中,我们将定义以下章节中讨论的辅助方法,以从客户端的 sessionStorage 中存储和检索 JWT 凭据,并在用户登出时清除 sessionStorage。
保存凭据
为了在成功登录后保存从服务器接收到的 JWT 凭据,我们使用 authenticate 方法,该方法定义如下。
mern-skeleton/client/auth/auth-helper.js:
authenticate(jwt, cb) {
if(typeof window !== "undefined")
sessionStorage.setItem('jwt', JSON.stringify(jwt))
cb()
}
authenticate 方法接受 JWT 凭据 jwt 和一个回调函数 cb 作为参数。在确保 window 已定义后,即在确保此代码在浏览器中运行并因此可以访问 sessionStorage 后,它将凭据存储在 sessionStorage 中。然后,它执行传入的回调函数。此回调将允许组件——在我们的例子中,是调用登录的组件——定义在成功登录并存储凭据后应执行的操作。接下来,我们将讨论让我们访问这些存储凭据的方法。
检索凭据
在我们的前端组件中,我们需要检索存储的凭据以检查当前用户是否已登录。在 isAuthenticated() 方法中,我们可以从 sessionStorage 中检索这些凭据。
mern-skeleton/client/auth/auth-helper.js:
isAuthenticated() {
if (typeof window == "undefined")
return false
if (sessionStorage.getItem('jwt'))
return JSON.parse(sessionStorage.getItem('jwt'))
else
return false
}
对isAuthenticated()的调用将返回存储的凭据或false,这取决于是否在sessionStorage中找到了凭据。在存储中找到凭据意味着用户已登录,而没有找到凭据则意味着用户未登录。我们还将添加一个方法,允许我们在登录用户从应用程序注销时从存储中删除凭据。
删除凭据
当用户成功从应用程序注销时,我们希望从sessionStorage中清除存储的 JWT 凭据。这可以通过调用以下代码中定义的clearJWT方法来实现。
mern-skeleton/client/auth/auth-helper.js:
clearJWT(cb) {
if(typeof window !== "undefined")
sessionStorage.removeItem('jwt')
cb()
signout().then((data) => {
document.cookie = "t=; expires=Thu, 01 Jan 1970 00:00:00
UTC; path=/;"
})
}
这个clearJWT方法接受一个回调函数作为参数,并从sessionStorage中移除 JWT 凭据。传入的cb()函数允许启动signout功能的组件决定在成功注销后应该发生什么。
clearJWT方法还使用了我们在api-auth.js中定义的signout方法来调用后端中的注销 API。如果我们使用cookies而不是sessionStorage来存储凭据,那么对这个 API 调用的响应将是我们清除 cookie 的地方,如前面的代码所示。使用注销 API 调用是可选的,因为这取决于是否使用 cookie 作为凭据存储机制。
使用这三个方法,我们现在有了在客户端存储、检索和删除 JWT 凭据的方法。使用这些方法,我们为前端构建的 React 组件将能够检查和管理用户认证状态,以限制前端访问,如下一节中自定义PrivateRoute组件所示。
PrivateRoute组件
文件中的代码定义了PrivateRoute组件,如reacttraining.com/react-router/web/example/auth-workflow中的认证流程示例所示,该示例可在 React Router 文档中找到。它将允许我们为前端声明受保护的路线,根据用户认证来限制视图访问。
mern-skeleton/client/auth/PrivateRoute.js:
import React, { Component } from 'react'
import { Route, Redirect } from 'react-router-dom'
import auth from './auth-helper'
const PrivateRoute = ({ component: Component, ...rest }) => (
<Route {...rest} render={props => (
auth.isAuthenticated() ? (
<Component {...props}/>
) : (
<Redirect to={{
pathname: '/signin',
state: { from: props.location }
}}/>
)
)}/>
)
export default PrivateRoute
在此PrivateRoute中要渲染的组件只有在用户认证时才会加载,这是通过调用isAuthenticated方法确定的;否则,用户将被重定向到Signin组件。我们将在PrivateRoute中加载应有限制访问的组件,例如用户配置文件组件,这将确保只有认证用户才能查看用户配置文件页面。
在将后端 API 集成并准备好在组件中使用认证管理辅助方法后,我们现在可以开始构建剩余的视图组件,这些组件利用这些方法并完成前端。
完成用户前端
本节中将要描述的 React 组件通过允许用户根据认证限制查看、创建和修改存储在数据库中的用户数据,从而完成我们为骨架定义的交互式功能。我们将实现的组件如下:
-
Users: 从数据库获取并显示所有用户 -
Signup: 显示一个表单,允许新用户注册 -
Signin: 显示一个表单,允许现有用户登录 -
Profile: 在从数据库检索后显示特定用户的详细信息 -
EditProfile: 显示特定用户的详细信息,并允许授权用户更新这些信息 -
DeleteUser: 允许授权用户从应用程序中删除他们的账户 -
Menu: 为应用程序中的每个视图添加一个通用的导航栏
对于这些组件中的每一个,我们将讨论它们的独特之处,以及如何在MainRouter中添加它们。
用户组件
client/user/Users.js中的Users组件显示了从数据库中检索到的所有用户的名称,并将每个名称链接到用户个人资料。以下组件可以被应用程序的任何访客查看,并在'/users'路由上渲染:

在组件定义中,类似于我们实现Home组件的方式,我们定义并导出一个函数组件。在这个组件中,我们首先使用一个空的用户数组初始化状态。
mern-skeleton/client/user/Users.js:
export default function Users() {
...
const [users, setUsers] = useState([])
...
}
我们使用内置的 React 钩子useState给这个函数组件添加状态。通过调用这个钩子,我们实际上声明了一个名为users的状态变量,可以通过调用setUsers来更新它,并将users的初始值设置为[]。
使用内置的useState钩子允许我们在 React 中给函数组件添加状态行为。调用它将声明一个状态变量,类似于在类组件定义中使用this.state。传递给useState的参数是这个变量的初始值——换句话说,初始状态。调用useState返回当前状态和一个更新状态值的函数,这类似于类定义中的this.setState。
在初始化users状态后,接下来我们将使用另一个内置的 React 钩子useEffect从后端获取用户列表并更新状态中的users值。
useEffect钩子用于替代我们本应在 React 类中使用的componentDidMount、componentDidUpdate和componentWillUnmount生命周期方法。在函数组件中使用此钩子允许我们执行副作用,例如从后端获取数据。默认情况下,React 在每次渲染后(包括第一次渲染)都会运行使用useEffect定义的效果。但我们可以指示效果仅在状态发生变化时重新运行。可选地,我们还可以定义在效果之后如何清理,例如,在组件卸载时执行取消 fetch 信号等操作,以避免内存泄漏。
在我们的Users组件中,我们使用useEffect来调用api-user.js辅助方法中的list方法。这将从后端获取用户列表,并通过更新状态将用户数据加载到组件中。
mern-skeleton/client/user/Users.js:
useEffect(() => {
const abortController = new AbortController()
const signal = abortController.signal
list(signal).then((data) => {
if (data && data.error) {
console.log(data.error)
} else {
setUsers(data)
}
})
return function cleanup(){
abortController.abort()
}
}, [])
在此效果中,我们还添加了一个清理函数,在组件卸载时取消 fetch 调用。为了将信号与 fetch 调用关联起来,我们使用 AbortController Web API,这允许我们根据需要取消 DOM 请求。
在此useEffect钩子的第二个参数中,我们传递一个空数组,以便此效果清理只在组件挂载和卸载时运行一次,而不是在每次渲染后。
最后,在Users函数组件的返回值中,我们添加实际视图内容。视图由 Material-UI 组件组成,如Paper、List和ListItem。这些元素使用makeStyles钩子定义和提供的 CSS 进行样式化,与Home组件中的方式相同。
mern-skeleton/client/user/Users.js:
return (
<Paper className={classes.root} elevation={4}>
<Typography variant="h6" className={classes.title}>
All Users
</Typography>
<List dense>
{users.map((item, i) => {
return <Link to={"/user/" + item._id} key={i}>
<ListItem button>
<ListItemAvatar>
<Avatar>
<Person/>
</Avatar>
</ListItemAvatar>
<ListItemText primary={item.name}/>
<ListItemSecondaryAction>
<IconButton>
<ArrowForward/>
</IconButton>
</ListItemSecondaryAction>
</ListItem>
</Link>
})
}
</List>
</Paper>
)
在这种观点下,为了生成每个列表项,我们使用map函数遍历状态中的用户数组。每个列表项都会渲染从用户数组中访问的每个项目的单个用户名称。
要将此Users组件添加到 React 应用程序中,我们需要更新MainRouter组件,添加一个在'/users'路径上渲染此组件的Route。在Home路由之后,将Route添加到Switch组件内部。
mern-skeleton/client/MainRouter.js:
<Route path="/users" component={Users}/>
要在浏览器中看到此视图的渲染效果,您可以暂时将Link组件添加到Home组件中,以便能够路由到Users组件:
<Link to="/users">Users</Link>
在浏览器中渲染根路由的首页视图后,点击此链接将显示我们在本节中实现的Users组件。我们将在下一节中类似地实现其他 React 组件,从Signup组件开始。
注册组件
client/user/Signup.js中的Signup组件向用户展示一个包含姓名、电子邮件和密码字段的表单,用于在'/signup'路径上注册,如下截图所示:

在组件定义中,我们使用 useState 钩子初始化状态,使用空的输入字段值、空的错误消息,并将对话框打开变量设置为 false。
mern-skeleton/client/user/Signup.js:
export default function Signup() {
...
const [values, setValues] = useState({
name: '',
password: '',
email: '',
open: false,
error: ''
})
...
}
我们还定义了两个处理函数,用于在输入值更改或点击提交按钮时调用。handleChange 函数接受输入字段中输入的新值,并将其设置为状态。
mern-skeleton/client/user/Signup.js:
const handleChange = name => event => {
setValues({ ...values, [name]: event.target.value })
}
当表单提交时,会调用 clickSubmit 函数。该函数从状态中获取输入值,并调用 create fetch 方法在后端注册用户。然后,根据服务器的响应,要么显示错误消息,要么显示成功对话框。
mern-skeleton/client/user/Signup.js:
const clickSubmit = () => {
const user = {
name: values.name || undefined,
email: values.email || undefined,
password: values.password || undefined
}
create(user).then((data) => {
if (data.error) {
setValues({ ...values, error: data.error})
} else {
setValues({ ...values, error: '', open: true})
}
})
}
在 return 函数中,我们使用 Material-UI 的 TextField 等组件组合和样式化注册视图中的表单组件。
mern-skeleton/client/user/Signup.js:
return (
<div>
<Card className={classes.card}>
<CardContent>
<Typography variant="h6" className={classes.title}>
Sign Up
</Typography>
<TextField id="name" label="Name"
className={classes.textField}
value={values.name} onChange={handleChange('name')}
margin="normal"/>
<br/>
<TextField id="email" type="email" label="Email"
className={classes.textField}
value={values.email} onChange={handleChange('email')}
margin="normal"/>
<br/>
<TextField id="password" type="password" label="Password"
className={classes.textField} value={values.password}
onChange={handleChange('password')} margin="normal"/>
<br/>
{
values.error && (<Typography component="p" color="error">
<Icon color="error" className={classes.error}>error</Icon>
{values.error}</Typography>)
}
</CardContent>
<CardActions>
<Button color="primary" variant="contained" onClick={clickSubmit}
className={classes.submit}>Submit</Button>
</CardActions>
</Card>
</div>
)
此返回值还包含一个错误消息块,以及一个根据服务器注册响应条件渲染的 Dialog 组件。如果服务器返回错误,则添加到表单下方的错误块(我们在前面的代码中实现),将在视图中渲染相应的错误消息。如果服务器返回成功响应,则将渲染一个 Dialog 组件。
Signup.js 中的 Dialog 组件如下组成。
mern-skeleton/client/user/Signup.js:
<Dialog open={values.open} disableBackdropClick={true}>
<DialogTitle>New Account</DialogTitle>
<DialogContent>
<DialogContentText>
New account successfully created.
</DialogContentText>
</DialogContent>
<DialogActions>
<Link to="/signin">
<Button color="primary" autoFocus="autoFocus"
variant="contained">
Sign In
</Button>
</Link>
</DialogActions>
</Dialog>
在成功创建账户后,用户会收到确认信息,并被要求使用此 Dialog 组件进行登录,该组件链接到 Signin 组件,如下截图所示:

要将 Signup 组件添加到应用中,请将以下 Route 添加到 Switch 组件中的 MainRouter。
mern-skeleton/client/MainRouter.js:
<Route path="/signup" component={Signup}/>
这将在 '/signup' 路径上渲染 Signup 视图。同样,我们将接下来实现 Signin 组件。
登录组件
client/auth/Signin.js 中的 Signin 组件也是一个仅包含电子邮件和密码字段的登录表单。该组件与 Signup 组件非常相似,将在 '/signin' 路径上渲染。关键区别在于成功登录后的重定向实现以及存储接收到的 JWT 凭证。渲染的 Signin 组件如下截图所示:

对于重定向,我们将使用 React Router 的 Redirect 组件。首先,在状态中将 redirectToReferrer 值初始化为 false,与其他字段一起:
mern-skeleton/client/auth/Signin.js:
export default function Signin(props) {
const [values, setValues] = useState({
email: '',
password: '',
error: '',
redirectToReferrer: false
})
}
Signin函数将接受包含 React Router 变量的 props 作为参数。我们将使用这些变量进行重定向。当用户在提交表单后成功登录,并且收到的 JWT 存储在sessionStorage中时,redirectToReferrer应设置为true。为了存储 JWT 并在之后进行重定向,我们将调用在auth-helper.js中定义的authenticate()方法。这种实现将放在clickSubmit()函数中,以便在表单提交时调用。
mern-skeleton/client/auth/Signin.js:
const clickSubmit = () => {
const user = {
email: values.email || undefined,
password: values.password || undefined
}
signin(user).then((data) => {
if (data.error) {
setValues({ ...values, error: data.error})
} else {
auth.authenticate(data, () => {
setValues({ ...values, error: '',redirectToReferrer: true})
})
}
})
}
重定向将根据redirectToReferrer的值条件性地发生,使用 React Router 中的Redirect组件。我们将在函数的返回块之前添加重定向代码,如下所示。
mern-skeleton/client/auth/Signin.js:
const {from} = props.location.state || {
from: {
pathname: '/'
}
}
const {redirectToReferrer} = values
if (redirectToReferrer) {
return (<Redirect to={from}/>)
}
如果渲染了Redirect组件,它将带应用程序到在 props 中接收到的最后一个位置,或者到根目录的Home组件。
函数返回的代码在此处未显示,因为它与Signup中的代码非常相似。它将包含相同的表单元素,只是email和password字段,一个条件性错误消息,以及submit按钮。
要将Signin组件添加到应用程序中,请将以下 Route 添加到MainRouter中的Switch组件。
mern-skeleton/client/MainRouter.js:
<Route path="/signin" component={Signin}/>
这将在"/signin"处渲染Signin组件,并且可以在 Home 组件中链接,类似于Signup组件,以便在浏览器中查看。接下来,我们将实现个人资料视图以显示单个用户的详细信息。
Profile组件
client/user/Profile.js中的Profile组件在'/user/:userId'路径的视图中显示单个用户的信息,其中userId参数代表特定用户的 ID。完成的Profile将显示用户详细信息,并条件性地显示编辑/删除选项。以下截图显示了当当前浏览的用户正在查看其他用户的个人资料而不是自己的个人资料时,Profile是如何渲染的:

如果用户已登录,则可以从服务器获取此配置文件信息。为了验证这一点,组件必须向read获取调用提供 JWT 凭证;否则,用户应重定向到登录视图。
在Profile组件定义中,我们需要使用空用户初始化状态,并将redirectToSignin设置为false。
mern-skeleton/client/user/Profile.js:
export default function Profile({ match }) {
...
const [user, setUser] = useState({})
const [redirectToSignin, setRedirectToSignin] = useState(false)
...
}
我们还需要获取由Route组件传递的match props 的访问权限,它将包含一个:userId参数值。这可以通过match.params.userId访问。
Profile组件应获取用户信息并使用这些详细信息渲染视图。为了实现这一点,我们将使用useEffect钩子,就像我们在Users组件中所做的那样。
mern-skeleton/client/user/Profile.js:
useEffect(() => {
const abortController = new AbortController()
const signal = abortController.signal
const jwt = auth.isAuthenticated()
read({
userId: match.params.userId
}, {t: jwt.token}, signal).then((data) => {
if (data && data.error) {
setRedirectToSignin(true)
} else {
setUser(data)
}
})
return function cleanup(){
abortController.abort()
}
}, [match.params.userId])
此效果使用match.params.userId值并调用read用户获取方法。由于此方法还需要凭据来授权已登录的用户,因此使用auth-helper.js中的isAuthenticated方法从sessionStorage检索 JWT,并将其传递给read调用。
一旦服务器响应,要么将状态更新为用户信息,要么如果当前用户未认证,将视图重定向到登录视图。我们还在此效果钩子中添加了一个清理函数,以便在组件卸载时中止获取信号。
此效果仅在路由中的userId参数更改时需要重新运行,例如,当应用程序从一个个人资料视图切换到另一个视图时。为了确保在userId值更新时此效果重新运行,我们将在useEffect的第二个参数中添加[match.params.userId]。
如果当前用户未认证,我们将设置条件重定向到登录视图。
mern-skeleton/client/user/Profile.js
if (redirectToSignin) {
return <Redirect to='/signin'/>
}
如果当前登录的用户正在查看另一个用户的个人资料,该函数将返回包含以下元素的Profile视图。
mern-skeleton/client/user/Profile.js:
return (
<Paper className={classes.root} elevation={4}>
<Typography variant="h6" className={classes.title}>
Profile
</Typography>
<List dense>
<ListItem>
<ListItemAvatar>
<Avatar>
<Person/>
</Avatar>
</ListItemAvatar>
<ListItemText primary={user.name} secondary={user.email}/>
</ListItem>
<Divider/>
<ListItem>
<ListItemText primary={"Joined: " + (
new Date(user.created)).toDateString()}/>
</ListItem>
</List>
</Paper>
)
然而,如果当前登录的用户正在查看自己的个人资料,他们将在Profile组件中看到编辑和删除选项,如下面的截图所示:

要实现此功能,在Profile中的第一个ListItem组件中添加一个包含Edit按钮和DeleteUser组件的ListItemSecondaryAction组件,该组件将根据当前用户是否查看自己的个人资料有条件地渲染。
mern-skeleton/client/user/Profile.js:
{ auth.isAuthenticated().user && auth.isAuthenticated().user._id == user._id &&
(<ListItemSecondaryAction>
<Link to={"/user/edit/" + user._id}>
<IconButton aria-label="Edit" color="primary">
<Edit/>
</IconButton>
</Link>
<DeleteUser userId={user._id}/>
</ListItemSecondaryAction>)
}
Edit按钮将路由到EditProfile组件,而自定义的DeleteUser组件将处理删除操作,并将userId作为 prop 传递给它。
要将Profile组件添加到应用程序中,请将Route添加到Switch组件中的MainRouter。
mern-skeleton/client/MainRouter.js:
<Route path="/user/:userId" component={Profile}/>
要在浏览器中访问此路由并渲染包含用户详情的Profile,链接中应包含有效的用户 ID。在下一节中,我们将使用相同的方法检索单个用户详情并在组件中渲染它来实现编辑个人资料视图。
EditProfile 组件
client/user/EditProfile.js中的EditProfile组件在其实现上与Signup和Profile组件有相似之处。它允许授权用户以与注册表单类似的形式编辑自己的个人资料信息,如下面的截图所示:

在'/user/edit/:userId'加载时,组件将在验证 JWT 进行身份验证后,使用其 ID 获取用户信息,然后加载带有接收到的用户信息的表单。表单将允许用户编辑并提交仅更改的信息到update获取调用,并且在更新成功后,将用户重定向到带有更新信息的Profile视图。
EditProfile将以与Profile组件相同的方式加载数据,即通过在useEffect中使用match.params中的userId参数进行read获取。它将从auth.isAuthenticated中收集凭证。表单视图将包含与Signup组件相同的元素,当输入值发生变化时,它们将在状态中更新。
在表单提交时,组件将调用带有userId、JWT 和更新后的用户数据的update获取方法。
mern-skeleton/client/user/EditProfile.js:
const clickSubmit = () => {
const jwt = auth.isAuthenticated()
const user = {
name: values.name || undefined,
email: values.email || undefined,
password: values.password || undefined
}
update({
userId: match.params.userId
}, {
t: jwt.token
}, user).then((data) => {
if (data && data.error) {
setValues({...values, error: data.error})
} else {
setValues({...values, userId: data._id, redirectToProfile: true})
}
})
}
根据服务器的响应,用户将看到错误消息或使用Redirect组件重定向到更新后的个人资料页面,如下所示。
mern-skeleton/client/user/EditProfile.js:
if (values.redirectToProfile) {
return (<Redirect to={'/user/' + values.userId}/>)
}
要将EditProfile组件添加到应用程序中,我们将使用PrivateRoute,这将阻止未登录的用户加载组件。在MainRouter中的放置顺序也将很重要。
mern-skeleton/client/MainRouter.js:
<Switch>
... <PrivateRoute path="/user/edit/:userId" component={EditProfile}/>
<Route path="/user/:userId" component={Profile}/>
</Switch>
带有'/user/edit/:userId'路径的路由需要放在带有'/user/:userId'路径的路由之前,这样在请求此路由时,Switch 组件将首先唯一匹配编辑路径,而不会与Profile路由混淆。
在添加了此个人资料编辑视图后,我们只剩下用户删除 UI 实现需要完成用户相关的前端。
DeleteUser 组件
client/user/DeleteUser.js中的DeleteUser组件基本上是一个按钮,我们将将其添加到个人资料视图中,当点击时,将打开一个Dialog组件,提示用户确认delete操作,如下面的截图所示:

此组件将Dialog组件的open状态初始化为false,以及将redirect设置为false,以便它不会首先渲染。
mern-skeleton/client/user/DeleteUser.js:
export default function DeleteUser(props) {
...
const [open, setOpen] = useState(false)
const [redirect, setRedirect] = useState(false)
...
}
DeleteUser组件也将从父组件接收属性。在这种情况下,属性将包含从Profile组件发送的userId。
接下来,我们需要一些处理方法来打开和关闭dialog按钮。当用户点击delete按钮时,对话框将被打开。
mern-skeleton/client/user/DeleteUser.js:
const clickButton = () => {
setOpen(true)
}
当用户在对话框中点击cancel时,对话框将被关闭。
mern-skeleton/client/user/DeleteUser.js:
const handleRequestClose = () => {
setOpen(false)
}
组件将能够访问从 Profile 组件作为属性传递的 userId,这是调用 remove 获取方法所需的,以及用户在对话框中确认删除操作后的 JWT 凭据。
mern-skeleton/client/user/DeleteUser.js:
const deleteAccount = () => {
const jwt = auth.isAuthenticated()
remove({
userId: props.userId
}, {t: jwt.token}).then((data) => {
if (data && data.error) {
console.log(data.error)
} else {
auth.clearJWT(() => console.log('deleted'))
setRedirect(true)
}
})
}
在确认后,deleteAccount 函数调用带有从属性中获取的 userId 和来自 isAuthenticated 的 JWT 的 remove 获取方法。在删除成功后,用户将被注销并重定向到主页视图。React Router 的 Redirect 组件用于将当前用户重定向到主页视图,如下所示:
if (redirect) {
return <Redirect to='/'/>
}
组件函数返回 DeleteUser 组件元素,包括一个 DeleteIcon 按钮和确认 Dialog。
mern-skeleton/client/user/DeleteUser.js:
return (<span>
<IconButton aria-label="Delete"
onClick={clickButton} color="secondary">
<DeleteIcon/>
</IconButton>
<Dialog open={open} onClose={handleRequestClose}>
<DialogTitle>{"Delete Account"}</DialogTitle>
<DialogContent>
<DialogContentText>
Confirm to delete your account.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleRequestClose} color="primary">
Cancel
</Button>
<Button onClick={deleteAccount}
color="secondary" autoFocus="autoFocus">
Confirm
</Button>
</DialogActions>
</Dialog>
</span>)
DeleteUser 接收 userId 作为属性,用于在 delete 获取调用中使用,因此我们需要为这个 React 组件添加一个必需的属性验证检查。我们将在下一步进行此操作。
使用 PropTypes 验证属性
为了验证将 userId 作为属性注入到组件中的必要性,我们将在定义的组件中添加 PropTypes 验证器。
mern-skeleton/client/user/DeleteUser.js:
DeleteUser.propTypes = {
userId: PropTypes.string.isRequired
}
由于我们在 Profile 组件中使用了 DeleteUser 组件,因此当在 MainRouter 中添加 Profile 时,它会被添加到应用程序视图中。
在添加了删除用户界面之后,我们现在有一个包含所有 React 组件视图的前端,以便完成骨架应用程序的功能。但是,我们仍然需要一个公共导航 UI 来将这些视图连接起来,并使前端用户能够轻松访问每个视图。在下一节中,我们将实现这个导航菜单组件。
菜单组件
Menu 组件将通过提供对所有可用视图的链接以及指示用户在应用程序中的当前位置,作为前端应用程序中的导航栏工作。
为了实现这些导航栏功能,我们将使用 React Router 的 HOC withRouter 来获取对 history 对象属性的访问权限。以下 Menu 组件中的代码仅添加了标题、链接到根路由的“主页”图标以及链接到 '/users' 路由的“用户”按钮。
mern-skeleton/client/core/Menu.js:
const Menu = withRouter(({history}) => (
<AppBar position="static">
<Toolbar>
<Typography variant="h6" color="inherit">
MERN Skeleton
</Typography>
<Link to="/">
<IconButton aria-label="Home" style={isActive(history, "/")}>
<HomeIcon/>
</IconButton>
</Link>
<Link to="/users">
<Button style={isActive(history, "/users")}>Users</Button>
</Link>
</Toolbar>
</AppBar>))
为了在 Menu 上指示应用程序的当前位置,我们将通过条件性地更改颜色来突出显示与当前位置路径匹配的链接。
mern-skeleton/client/core/Menu.js:
const isActive = (history, path) => {
if (history.location.pathname == path)
return {color: '#ff4081'}
else
return {color: '#ffffff'}
}
isActive 函数用于将颜色应用到 Menu 中的按钮上,如下所示:
style={isActive(history, "/users")}
根据用户是否已登录,以下链接(如“登录”、“注册”、“我的资料”和“注销”)将显示在 Menu 中。以下截图显示了用户未登录时菜单的渲染方式:

例如,只有当用户未登录时,注册和登录的链接才应出现在菜单中。因此,我们需要在Users按钮之后添加它到Menu组件,并使用条件。
mern-skeleton/client/core/Menu.js:
{
!auth.isAuthenticated() && (<span>
<Link to="/signup">
<Button style={isActive(history, "/signup")}> Sign Up </Button>
</Link>
<Link to="/signin">
<Button style={isActive(history, "/signin")}> Sign In </Button>
</Link>
</span>)
}
同样,只有当用户已登录时,MY PROFILE链接和SIGN OUT按钮才应出现在菜单中,并且应使用以下条件检查添加到Menu组件中。
mern-skeleton/client/core/Menu.js:
{
auth.isAuthenticated() && (<span>
<Link to={"/user/" + auth.isAuthenticated().user._id}>
<Button style={isActive(history, "/user/"
+ auth.isAuthenticated().user._id)}>
My Profile
</Button>
</Link>
<Button color="inherit"
onClick={() => { auth.clearJWT(() => history.push('/')) }}>
Sign out
</Button>
</span>)
}
例如,MY PROFILE 按钮使用已登录用户的信息链接到用户的个人资料,而 SIGN OUT 按钮在点击时调用auth.clearJWT()方法。当用户已登录时,Menu将如下所示:

要使Menu导航栏在所有视图中都显示,我们需要在所有其他路由之前,并在Switch组件外部将其添加到MainRouter。
mern-skeleton/client/MainRouter.js:
<Menu/>
<Switch>
…
</Switch>
这将使Menu组件在访问相应路由时渲染在其他所有组件之上。
前端骨架现在已完整,并包含所有必要的组件,允许用户在考虑身份验证和授权限制的情况下,在后台注册、查看和修改用户数据。然而,目前还不能直接在浏览器地址栏中访问前端路由;这些路由只能在前端视图中链接时访问。为了在骨架应用程序中启用此功能,我们需要实现基本的服务器端渲染。
实现基本的服务器端渲染
目前,当 React Router 路由或路径名直接输入到浏览器地址栏中,或者当非根路径的视图刷新时,URL 不起作用。这是因为服务器无法识别我们在前端定义的 React Router 路由。我们必须在后台实现基本的服务器端渲染,以便服务器在接收到对前端路由的请求时能够响应。
为了在服务器接收到对前端路由的请求时正确渲染相关 React 组件,我们需要在客户端 JS 准备好接管渲染之前,首先在服务器端根据 React Router 和 Material-UI 组件生成 React 组件。
服务器端渲染 React 应用程序的基本思想是使用react-dom中的renderToString方法将根 React 组件转换为标记字符串。然后,我们可以将其附加到服务器在接收到请求时渲染的模板上。
在express.js中,我们将替换响应'/'的GET请求返回template.js的代码,用接收任何传入的 GET 请求时生成一些服务器端渲染的标记和相应 React 组件树的 CSS 的代码替换。此更新后的代码将实现以下功能:
app.get('*', (req, res) => {
// 1\. Generate CSS styles using Material-UI's ServerStyleSheets
// 2\. Use renderToString to generate markup which renders
components specific to the route requested
// 3\. Return template with markup and CSS styles in the response
})
在接下来的章节中,我们将查看前面代码块中概述的步骤的实现,并讨论如何准备前端以便它接受和处理此服务器端渲染的代码。
服务器端渲染模块
要实现基本的服务器端渲染,我们需要将以下 React、React Router 和 Material-UI 特定模块导入到服务器代码中。在我们的代码结构中,以下模块将被导入到server/express.js中:
- React 模块:以下模块是渲染 React 组件和使用
renderToString所必需的:
import React from 'react'
import ReactDOMServer from 'react-dom/server'
- 路由模块:
StaticRouter是一个无状态的路由器,它接受请求的 URL 以匹配在MainRouter组件中声明的前端路由。MainRouter是我们前端中的根组件。
import StaticRouter from 'react-router-dom/StaticRouter'
import MainRouter from './../client/MainRouter'
- Material-UI 模块和自定义主题:以下模块将帮助根据前端使用的样式和 Material-UI 主题生成前端组件的 CSS 样式:
import { ServerStyleSheets, ThemeProvider } from '@material-ui/styles'
import theme from './../client/theme'
使用这些模块,我们可以准备、生成并返回服务器端渲染的前端代码,正如我们接下来将要讨论的。
生成 CSS 和标记
要在服务器端生成表示 React 前端视图的 CSS 和标记,我们将使用 Material-UI 的ServerStyleSheets和 React 的renderToString。
在 Express 应用接收到的每个请求中,我们将创建一个新的ServerStyleSheets实例。然后,我们将使用renderToString调用中的服务器端收集器渲染相关的 React 树,最终返回要向用户显示的 React 视图的关联标记或 HTML 字符串版本。
以下代码将在 Express 应用接收到的每个 GET 请求上执行。
mern-skeleton/server/express.js:
const sheets = new ServerStyleSheets()
const context = {}
const markup = ReactDOMServer.renderToString(
sheets.collect(
<StaticRouter location={req.url} context={context}>
<ThemeProvider theme={theme}>
<MainRouter />
</ThemeProvider>
</StaticRouter>
)
)
在渲染 React 树时,客户端应用的根组件MainRouter被 Material-UI 的ThemeProvider包装,以提供MainRouter子组件所需的样式 props。在这里使用无状态的StaticRouter而不是客户端上使用的BrowserRouter,是为了包装MainRouter并提供用于实现客户端组件的路由 props。
基于这些值,例如作为 props 传递给包装组件的请求的location路由和theme,renderToString将返回包含相关视图的标记。
发送带有标记和 CSS 的模板
一旦生成了标记,我们需要检查组件中是否生成了要发送在标记中的redirect。如果没有生成重定向,那么我们使用sheets.toString从sheets获取 CSS 字符串,并在响应中发送带有注入的标记和 CSS 的Template,如下面的代码所示。
mern-skeleton/server/express.js:
if (context.url) {
return res.redirect(303, context.url)
}
const css = sheets.toString()
res.status(200).send(Template({
markup: markup,
css: css
}))
当我们尝试通过服务器端渲染访问 PrivateRoute 时,会渲染重定向的例子。由于服务器端无法从浏览器的 sessionStorage 中访问认证令牌,PrivateRoute 中的重定向将会渲染。在这种情况下,context.url 的值将是 '/signin' 路由,因此,而不是尝试渲染 PrivateRoute 组件,它将重定向到 '/signin' 路由。
这完成了我们需要添加到服务器端的代码,以启用 React 视图的简单服务器端渲染。接下来,我们需要更新前端,使其能够集成并渲染由服务器生成的代码。
更新 template.js
我们在服务器上生成的标记和 CSS 必须添加到 template.js HTML 代码中,以便在服务器渲染模板时加载。
mern-skeleton/template.js:
export default ({markup, css}) => {
return `...
<div id="root">${markup}</div>
<style id="jss-server-side">${css}</style>
...`
}
这将在前端脚本准备好接管之前在浏览器中加载服务器生成的代码。在下一节中,我们将学习前端脚本需要如何处理从服务器端渲染代码的接管。
更新 App.js
一旦服务器端渲染的代码到达浏览器,并且前端脚本接管,我们需要在根 React 组件挂载时移除服务器端注入的 CSS,使用 useEffect 钩子。
mern-skeleton/client/App.js:
React.useEffect(() => {
const jssStyles = document.querySelector('#jss-server-side')
if (jssStyles) {
jssStyles.parentNode.removeChild(jssStyles)
}
}, [])
这将使客户端能够完全控制 React 应用的渲染。为了确保这种转移高效进行,我们需要更新 ReactDOM 渲染视图的方式。
替换渲染为激活
现在,由于 React 组件将在服务器端渲染,我们可以更新 main.js 代码,使其使用 ReactDOM.hydrate() 而不是 ReactDOM.render():
import React from 'react'
import { hydrate } from 'react-dom'
import App from './App'
hydrate(<App/>, document.getElementById('root'))
hydrate 函数用于将已经由 ReactDOMServer 渲染的 HTML 内容的容器进行激活。这意味着服务器端渲染的标记被保留,当 React 在浏览器中接管时,仅附加事件处理器,从而使得初始加载性能更佳。
实现基本的服务器端渲染后,现在浏览器地址栏直接请求前端路由可以由服务器正确处理,这使得可以保存 React 前端视图的快捷方式。
本章中我们开发的骨架 MERN 应用程序现在是一个完全功能性的 MERN 网络应用程序,具有基本用户功能。我们可以扩展这个骨架中的代码,为不同的应用程序添加各种功能。
摘要
在本章中,我们通过添加一个可工作的 React 前端,包括前端路由和基本的 React 视图服务器端渲染,完成了 MERN 骨架应用程序。
我们首先更新了开发流程,使其包括用于 React 视图的客户端代码打包。我们更新了 Webpack 和 Babel 的配置,以编译 React 代码,并讨论了如何在开发过程中从一个地方加载配置的 Webpack 中间件,以启动服务器端和客户端代码的编译。
在更新开发流程并构建前端之前,我们添加了相关的 React 依赖项,包括用于前端路由的 React Router 和 Material-UI,以便在骨架应用的用户界面中使用它们现有的组件。
然后,我们实现了顶级根 React 组件,并集成了 React Router,这使得我们能够添加客户端路由以进行导航。使用这些路由,我们加载了我们使用 Material-UI 组件开发的自定义 React 组件,以构成骨架应用的用户界面。
为了使这些 React 视图能够动态地与从后端获取的数据进行交互,我们使用了 Fetch API 来连接到后端用户 API。然后,我们在前端视图中集成了认证和授权。我们使用 sessionStorage 来存储用户特定的详细信息,以及从服务器成功登录后获取的 JWT,以及通过使用 PrivateRoute 组件限制对某些视图的访问。
最后,我们修改了服务器代码,以便我们可以实现基本的服务器端渲染,这允许我们在服务器识别到传入的请求实际上是为 React 路由时,直接在浏览器中加载前端路由的标记。
现在,你应该能够实现并集成一个基于 React 的前端,该前端结合了客户端路由和独立服务器应用程序的认证管理。
在下一章中,我们将使用本章学到的概念来扩展骨架应用程序代码,以便我们可以构建一个功能齐全的社交媒体应用程序。
第七章:将骨架扩展成社交媒体应用
社交媒体现在是网络的一个基本组成部分,我们构建的许多以用户为中心的 Web 应用最终都需要一个社交组件来推动用户参与。
对于我们的第一个真实世界 MERN 应用,我们将修改我们在 第三章,使用 MongoDB、Express 和 Node 构建后端,和 第四章,添加 React 前端以完成 MERN,中开发的 MERN 骨架应用,在本章中构建一个简单的社交媒体应用。在这个过程中,你将学习如何扩展 MERN 栈技术的集成并添加新功能以扩展你自己的全栈 Web 应用。
在本章中,我们将讨论以下主题:
-
介绍 MERN 社交
-
更新用户资料
-
在 MERN 社交中关注用户
-
发布带照片的消息
-
在帖子中实现交互
介绍 MERN 社交
MERN 社交是一个具有基本功能的社交媒体应用,灵感来源于现有的社交媒体平台,如 Facebook 和 Twitter。此应用的主要目的是展示如何使用 MERN 栈技术实现允许用户相互连接或关注的特性,并在共享内容上进行交互。在本章构建 MERN 社交的过程中,我们将讨论以下具有社交媒体特色的特性实现:
-
包含描述和照片的用户资料
-
用户相互关注
-
谁应该关注建议
-
发布带照片的消息
-
展示关注用户发布的新闻源
-
按用户列出帖子
-
点赞帖子
-
在帖子上发表评论
你可以根据需要进一步扩展这些实现,以实现更复杂的功能。MERN 社交主页如下所示:

完整的 MERN 社交应用代码可在 GitHub 上找到,网址为 github.com/PacktPublishing/Full-Stack-React-Projects-Second-Edition/tree/master/Chapter05/mern-social。你可以克隆此代码,并在浏览本章其余部分的代码解释时运行应用。
MERN 社交应用所需的视图将通过扩展和修改 MERN 骨架应用中现有的 React 组件来开发。以下组件树显示了构成 MERN 社交前端的所有自定义 React 组件,同时也暴露了我们将在本章其余部分构建视图所使用的组合结构:

除了更新现有组件外,我们还将添加新的自定义组件来组合视图,包括一个新闻源视图,用户可以在其中创建新的帖子,也可以浏览他们关注的 MERN Social 上所有人的所有帖子。在下一节中,我们将首先更新用户资料,以展示如何上传个人照片并为平台上的每个用户添加简短的个人简介。
更新用户资料
现有的骨架应用程序仅支持用户名、电子邮件和密码。但在 MERN Social 中,我们将允许用户在注册后编辑个人资料时添加关于自己的描述,以及上传个人照片,如下面的截图所示:

为了实现此功能更新,我们需要修改用户后端和前端。在以下章节中,我们将学习如何更新后端中的用户模型和用户更新 API,然后是前端中的用户资料和用户资料编辑表单视图,以在 MERN Social 中为用户添加简短描述和个人照片。
添加关于描述
为了存储用户在about字段中输入的简短描述,我们需要在server/models/user.model.js中的用户模型中添加一个about字段:
about: {
type: String,
trim: true
}
然后,为了从用户那里获取描述作为输入,我们需要在EditProfile表单中添加一个多行TextField,并像处理用户名输入一样处理值变化。
mern-social/client/user/EditProfile.js:
<TextField
id="multiline-flexible"
label="About"
multiline
rows="2"
value={values.about}
onChange={handleChange('about')}
/>
最后,为了在用户资料页面上显示添加到about字段的描述文本,我们可以将其添加到现有的个人资料视图中。
mern-social/client/user/Profile.js:
<ListItem> <ListItemText primary={this.state.user.about}/> </ListItem>
通过对 MERN 骨架代码中用户功能的此修改,用户现在可以添加和更新关于自己的描述,并在个人资料中显示。接下来,我们将添加上传照片的功能,以完成用户资料的完善。
上传个人照片
允许用户上传个人照片将需要我们存储上传的图像文件,并在请求时检索它以在视图中加载。在考虑不同的文件存储选项时,有多种实现此上传功能的方法:
-
服务器文件系统:将文件上传和保存到服务器文件系统,并将 URL 存储在 MongoDB 中。
-
外部文件存储:将文件保存到外部存储,例如 Amazon S3,并将 URL 存储在 MongoDB 中。
-
存储为 MongoDB 中的数据:将小于 16 MB 大小的文件作为 Buffer 类型的数据存储在 MongoDB 中。
对于 MERN Social,我们将假设用户上传的图片文件大小较小,并演示如何存储这些文件以实现个人照片上传功能。在第八章“扩展订单和支付的市场”,我们将讨论如何使用 GridFS 在 MongoDB 中存储更大的文件。
为了实现此照片上传功能,在以下章节中,我们将执行以下操作:
-
更新用户模型以存储照片。
-
集成更新的前端视图以从客户端上传照片。
-
修改后端中的用户更新控制器以处理上传的图片。
更新用户模型以在 MongoDB 中存储照片
为了直接在数据库中存储上传的个人资料照片,我们将更新用户模型以添加一个photo字段,该字段以Buffer类型的数据存储文件,并包含文件的contentType。
mern-social/server/models/user.model.js:
photo: {
data: Buffer,
contentType: String
}
用户从客户端上传的图像文件将被转换为二进制数据并存储在 MongoDB 中用户集合的文档的photo字段中。接下来,我们将看看如何从前端上传文件。
从编辑表单上传照片
当用户编辑个人资料时,他们将能够从本地文件上传图像文件。为了实现这种交互,我们将更新client/user/EditProfile.js中的EditProfile组件,添加上传照片选项,并将用户选择的文件附加到提交给服务器的表单数据中。我们将在以下章节中讨论这一点。
使用 Material-UI 的文件输入
我们将利用 HTML5 文件输入类型让用户从本地文件中选择一个图像。当用户选择一个文件时,文件输入会在改变事件中返回文件名。我们将按照以下方式将文件输入元素添加到编辑个人资料表单中:
mern-social/client/user/EditProfile.js:
<input accept="image/*" type="file"
onChange={handleChange('photo')}
style={{display:'none'}}
id="icon-button-file" />
为了将此input元素与 Material-UI 组件集成,我们应用display:none来隐藏视图中的input元素,然后在标签内添加一个 Material-UI 按钮用于此文件输入。这样,视图显示的是 Material-UI 按钮而不是 HTML5 文件输入元素。标签的添加方式如下:
mern-social/client/user/EditProfile.js:
<label htmlFor="icon-button-file">
<Button variant="contained" color="default" component="span">
Upload <FileUpload/>
</Button>
</label>
当按钮的component属性设置为span时,Button组件在label元素内渲染为一个span元素。点击Upload的span或label会被具有相同 ID 的文件输入注册,因此文件选择对话框被打开。一旦用户选择了一个文件,我们就可以在handleChange(...)的调用中将它设置为状态,并在视图中显示其名称,如下面的代码所示。
mern-social/client/user/EditProfile.js:
<span className={classes.filename}>
{values.photo ? values.photo.name : ''}
</span>
这样,用户将看到他们试图上传的文件名作为个人资料照片。在选择上传的文件后,接下来我们必须将此文件附加到请求中并发送到服务器,以更新数据库中的用户信息。
附加文件提交的表单
使用表单上传文件到服务器需要多部分表单提交。这与我们在之前的 fetch 实现中发送的字符串化对象形成对比。我们将修改EditProfile组件,使其使用FormData API 以multipart/form-data类型所需的格式存储表单数据。
您可以在developer.mozilla.org/en-US/docs/Web/API/FormData了解更多关于 FormData API 的信息。
首先,我们将更新输入handleChange函数,以便我们可以存储文本字段和文件输入的输入值,如下面的代码所示。
mern-social/client/user/EditProfile.js:
const handleChange = name => event => {
const value = name === 'photo'
? event.target.files[0]
: event.target.value
setValues({...values, [name]: value })
}
然后,在表单提交时,我们需要初始化FormData并附加已更新的字段值,如下所示。
mern-social/client/user/EditProfile.js:
const clickSubmit = () => {
let userData = new FormData()
values.name && userData.append('name', values.name)
values.email && userData.append('email', values.email)
values.passoword && userData.append('passoword', values.passoword)
values.about && userData.append('about', values.about)
values.photo && userData.append('photo', values.photo)
...
}
在将所有字段和值附加到它之后,使用 fetch API 调用发送userData以更新用户,如下面的代码所示。
mern-social/client/user/EditProfile.js:
update({
userId: match.params.userId
}, {
t: jwt.token
}, userData).then((data) => {
if (data && data.error) {
setValues({...values, error: data.error})
} else {
setValues({...values, 'redirectToProfile': true})
}
})
由于发送到服务器的数据的内容类型不再是'application/json',我们还需要修改api-user.js中的update fetch 方法,以从fetch调用中的头中删除Content-Type,如下所示。
mern-social/client/user/api-user.js:
const update = async (params, credentials, user) => {
try {
let response = await fetch('/api/users/' + params.userId, {
method: 'PUT',
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer ' + credentials.t
},
body: user
})
return await response.json()
} catch(err) {
console.log(err)
}}
现在,如果用户在编辑个人资料时选择上传个人照片,服务器将接收到一个带有附件的请求,其中包含其他字段值。接下来,我们需要修改服务器端代码以能够处理此请求。
处理包含文件上传的请求
在服务器上,为了处理可能包含文件的更新 API 请求,我们将使用formidable Node 模块。从命令行运行以下命令以安装formidable:
yarn add formidable
formidable将允许服务器读取multipart表单数据,并让我们访问字段和文件(如果有)。如果有文件,formidable将暂时将其存储在文件系统中。我们将使用fs模块从文件系统读取它,这将检索文件类型和数据,并将其存储在用户模型的photo字段中。formidable代码将放在user.controller.js中的update控制器中,如下所示。
mern-social/server/controllers/user.controller.js:
import formidable from 'formidable'
import fs from 'fs'
const update = async (req, res) => {
let form = new formidable.IncomingForm()
form.keepExtensions = true
form.parse(req, async (err, fields, files) => {
if (err) {
return res.status(400).json({
error: "Photo could not be uploaded"
})
}
let user = req.profile
user = extend(user, fields)
user.updated = Date.now()
if(files.photo){
user.photo.data = fs.readFileSync(files.photo.path)
user.photo.contentType = files.photo.type
}
try {
await user.save()
user.hashed_password = undefined
user.salt = undefined
res.json(user)
} catch (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
})
}
这将把上传的文件作为数据存储在数据库中。接下来,我们将设置文件检索,以便我们可以在前端视图中访问和显示用户上传的照片。
检索个人照片
要检索数据库中存储的图片并在视图中显示,最简单的方法是设置一个路由,该路由将获取数据并将其作为图像文件返回给请求客户端。在本节中,我们将学习如何设置此路由以公开照片 URL,以及如何使用此 URL 在前端视图中显示照片。
个人照片 URL
我们将为每个用户在数据库中存储的照片设置一个路由,并添加另一个路由,如果指定的用户没有上传个人照片,它将获取默认照片。这些路由将如下定义。
mern-social/server/routes/user.routes.js:
router.route('/api/users/photo/:userId')
.get(userCtrl.photo, userCtrl.defaultPhoto)
router.route('/api/users/defaultphoto')
.get(userCtrl.defaultPhoto)
我们将在photo控制器方法中查找照片,如果找到,将在照片路由的请求响应中发送它;否则,我们将调用next()以返回默认照片,如下面的代码所示。
mern-social/server/controllers/user.controller.js:
const photo = (req, res, next) => {
if(req.profile.photo.data){
res.set("Content-Type", req.profile.photo.contentType)
return res.send(req.profile.photo.data)
}
next()
}
默认照片是从服务器的文件系统中检索并发送的,如下所示。
mern-social/server/controllers/user.controller.js:
import profileImage from './../../client/assets/images/profile-pic.png'
const defaultPhoto = (req, res) => {
return res.sendFile(process.cwd()+profileImage)
}
我们可以使用这里定义的路由来在视图中显示照片,如下一节所述。
在视图中显示照片
通过设置用于检索照片的 URL 路由,我们可以在img元素的src属性中简单地使用这些路由来加载视图中的照片。例如,在Profile组件中,我们使用状态中的values中的用户 ID 来构造照片 URL,如下面的代码所示。
mern-social/client/user/Profile.js:
const photoUrl = values.user._id
? `/api/users/photo/${values.user._id}?${new Date().getTime()}`
: '/api/users/defaultphoto'
为了确保在照片更新后img元素在Profile视图中重新加载,我们必须向照片 URL 添加一个时间值以绕过浏览器默认的图像缓存行为。
然后,我们可以将photoUrl设置为 Material-UI 的Avatar组件,该组件在视图中渲染链接的图像:
<Avatar src={photoUrl}/>
MERN Social 中更新的用户个人资料现在可以显示用户上传的个人照片和about描述,如下面的截图所示:

我们已成功更新了 MERN 骨架应用程序代码,允许用户上传个人照片并在他们的个人资料中添加简短的简介。在下一节中,我们将进一步更新并实现允许用户相互关注的社交媒体风格功能。
MERN Social 中的关注用户
在 MERN Social 中,用户将能够相互关注。每个用户将有一个关注者列表和一个他们关注的列表。用户还可以查看他们可以关注的用户列表;换句话说,就是他们在 MERN Social 中尚未关注的用户。在以下章节中,我们将学习如何更新全栈代码以实现这些功能。
关注和取消关注
为了跟踪哪个用户在关注哪些其他用户,我们将为每个用户维护两个列表。当一个用户关注或取消关注另一个用户时,我们将更新一个用户的following列表和另一个用户的followers列表。首先,我们将更新后端以存储和更新这些列表,然后修改前端视图以允许用户执行关注和取消关注操作。
更新用户模型
要在数据库中存储following和followers的列表,我们需要更新用户模型,添加两个用户引用数组,如下面的代码所示。
mern-social/server/models/user.model.js:
following: [{type: mongoose.Schema.ObjectId, ref: 'User'}],
followers: [{type: mongoose.Schema.ObjectId, ref: 'User'}]
这些引用将指向被给定用户关注或正在关注该用户的集合中的用户。接下来,我们将更新用户控制器以确保在客户端请求的响应中返回这些列表中引用的用户详情。
更新userByID控制器方法
当从后端检索单个用户时,我们希望user对象包括following和followers数组中引用的用户的名字和 ID。为了检索这些详情,我们需要更新userByID控制器方法,使其填充返回的用户对象,如下面高亮显示的代码所示。
mern-social/server/controllers/user.controller.js:
const userByID = async (req, res, next, id) => {
try {
let user = await User.findById(id)
.populate('following', '_id name')
.populate('followers', '_id name')
.exec()
if (!user)
return res.status('400').json({
error: "User not found"
})
req.profile = user
next()
} catch (err) {
return res.status('400').json({
error: "Could not retrieve user"
})
}
}
我们使用 Mongoose 的populate方法来指定从查询返回的用户对象应包含following和followers列表中引用的用户的名字和 ID。这样,当我们通过读取 API 调用获取用户时,我们将获得followers和following列表中用户引用的名字和 ID。
在更新用户模型后,我们准备好添加 API 端点,以更新这些列表,要么添加要么从列表中删除用户,如下一节所述。
添加关注和取消关注的 API
当用户从视图中关注或取消关注另一个用户时,数据库中这两个用户的记录将根据follow或unfollow请求进行更新。
在user.routes.js中设置follow和unfollow路由如下。
mern-social/server/routes/user.routes.js:
router.route('/api/users/follow')
.put(authCtrl.requireSignin,
userCtrl.addFollowing,
userCtrl.addFollower)
router.route('/api/users/unfollow')
.put(authCtrl.requireSignin,
userCtrl.removeFollowing,
userCtrl.removeFollower)
用户控制器中的addFollowing控制器方法将通过将关注用户的引用推入数组来更新当前用户的following数组,如下面的代码所示。
mern-social/server/controllers/user.controller.js:
const addFollowing = async (req, res, next) => {
try{
await User.findByIdAndUpdate(req.body.userId,
{$push: {following: req.body.followId}})
next()
}catch(err){
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
在成功更新following数组后,调用next(),随后执行addFollower方法,将当前用户的引用添加到被关注用户的followers数组中。addFollower方法定义如下。
mern-social/server/controllers/user.controller.js:
const addFollower = async (req, res) => {
try{
let result = await User.findByIdAndUpdate(req.body.followId,
{$push: {followers: req.body.userId}},
{new: true})
.populate('following', '_id name')
.populate('followers', '_id name')
.exec()
result.hashed_password = undefined
result.salt = undefined
res.json(result)
}catch(err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
对于取消关注,实现方式类似。removeFollowing和removeFollower控制器方法通过使用$pull而不是$push来移除用户引用来更新相应的'following'和'followers'数组。removeFollowing和removeFollower将如下所示。
mern-social/server/controllers/user.controller.js:
const removeFollowing = async (req, res, next) => {
try{
await User.findByIdAndUpdate(req.body.userId,
{$pull: {following: req.body.unfollowId}})
next()
}catch(err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
const removeFollower = async (req, res) => {
try{
let result = await User.findByIdAndUpdate(req.body.unfollowId,
{$pull: {followers: req.body.userId}},
{new: true})
.populate('following', '_id name')
.populate('followers', '_id name')
.exec()
result.hashed_password = undefined
result.salt = undefined
res.json(result)
}catch(err){
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
服务器端的用户后端已准备好关注和取消关注功能。接下来,我们将更新前端以利用这些新的后端 API 并完成此功能。
在视图中访问关注和取消关注的 API
为了在视图中访问这些 API 调用,我们将更新api-user.js以包含follow和unfollow获取方法。follow和unfollow方法将类似,调用相应的路由,使用当前用户的 ID 和凭据,以及被关注或取消关注的用户的 ID。follow方法如下。
mern-social/client/user/api-user.js:
const follow = async (params, credentials, followId) => {
try {
let response = await fetch('/api/users/follow/', {
method: 'PUT',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
},
body: JSON.stringify({userId:params.userId, followId: followId})
})
return await response.json()
} catch(err) {
console.log(err)
}
}
unfollow获取方法类似;它接受取消关注的用户 ID 并调用unfollow API,如下面的代码所示。
mern-social/client/user/api-user.js:
const unfollow = async (params, credentials, unfollowId) => {
try {
let response = await fetch('/api/users/unfollow/', {
method: 'PUT',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
},
body: JSON.stringify({userId:params.userId, unfollowId: unfollowId})
})
return await response.json()
} catch(err) {
console.log(err)
}
}
实现了 API 获取代码后,我们可以使用这两种方法在视图中集成后端更新,如下一节所述,这将允许用户在应用程序中关注或取消关注另一个用户。
关注和取消关注按钮
允许用户关注或取消关注另一个用户的按钮将根据当前用户是否已经关注了该用户而条件性地显示,如下面的截图所示:

在以下章节中,我们将把这个按钮添加到一个独立的 React 组件中,将其与现有的用户资料视图集成,并连接到关注和取消关注的获取方法。
FollowProfileButton 组件
我们将创建一个名为FollowProfileButton的单独组件,该组件将被添加到Profile组件中。这个组件将根据当前用户是否已经是资料中用户的关注者来显示Follow或Unfollow按钮。FollowProfileButton组件如下所示。
mern-social/client/user/FollowProfileButton.js:
export default function FollowProfileButton (props) {
const followClick = () => {
props.onButtonClick(follow)
}
const unfollowClick = () => {
props.onButtonClick(unfollow)
}
return (<div>
{ props.following
? (<Button variant="contained" color="secondary"
onClick={unfollowClick}>Unfollow</Button>)
: (<Button variant="contained" color="primary"
onClick={followClick}>Follow</Button>)
}
</div>)
}
FollowProfileButton.propTypes = {
following: PropTypes.bool.isRequired,
onButtonClick: PropTypes.func.isRequired
}
当FollowProfileButton添加到资料中时,following值将由Profile组件确定并发送,作为属性传递给FollowProfileButton,同时传递一个点击处理函数,该函数作为参数调用特定的follow或unfollow获取 API。生成的资料视图将如下所示:

为了将这个FollowProfileButton组件与资料视图集成,我们需要更新现有的Profile组件,如以下所述。
更新资料组件
在Profile视图中,FollowProfileButton应该只在用户查看其他用户的资料时显示,因此我们需要修改查看资料时显示Edit和Delete按钮的条件,如下所示:
{auth.isAuthenticated().user &&
auth.isAuthenticated().user._id == values.user._id
? (edit and delete buttons)
: (follow button)
}
在Profile组件中,在useEffect中成功获取用户数据后,我们将检查已登录用户是否已经在关注资料中的用户,并将following值设置为相应的状态,如下面的代码所示。
mern-social/client/user/Profile.js:
let following = checkFollow(data)
setValues({...values, user: data, following: following})
为了确定在 following 中设置的值,checkFollow 方法将检查已登录用户是否存在于获取的用户 followers 列表中,如果找到则返回 match;如果没有找到匹配项,则返回 undefined。checkFollow 方法定义如下。
mern-social/client/user/Profile.js:
const checkFollow = (user) => {
const match = user.followers.some((follower)=> {
return follower._id == jwt.user._id
})
return match
}
Profile 组件还将定义 FollowProfileButton 的点击处理程序,以便在关注或取消关注操作完成后更新 Profile 的状态,如下面的代码所示。
mern-social/client/user/Profile.js:
const clickFollowButton = (callApi) => {
callApi({
userId: jwt.user._id
}, {
t: jwt.token
}, values.user._id).then((data) => {
if (data.error) {
setValues({...values, error: data.error})
} else {
setValues({...values, user: data, following: !values.following})
}
})
}
点击处理程序定义接受获取 API 调用作为参数,并将其作为属性传递给 FollowProfileButton,当它添加到 Profile 视图中时,还传递 following 值,如下所示。
mern-social/client/user/Profile.js:
<FollowProfileButton following={this.state.following} onButtonClick={this.clickFollowButton}/>
这将加载 FollowProfileButton 到个人资料视图中,考虑到所有必要的条件,并为当前用户提供在 MERN 社交应用程序中关注或取消关注其他用户的选项。接下来,我们将扩展此功能,允许用户在用户个人资料视图中查看关注者或粉丝列表。
列出关注者和粉丝
为了让用户在 MERN 社交平台上轻松访问他们关注的用户和他们被关注的用户,我们将将这些列表添加到他们的个人资料视图中。在每个用户的个人资料中,我们将添加一个包含他们的粉丝和关注者的列表,如下面的截图所示:

在 following 和 followers 列表中引用的用户详细信息已经在加载个人资料时使用 read API 获取的用户对象中。为了渲染这些独立的粉丝和关注者列表,我们将创建一个新的组件,称为 FollowGrid。
创建 FollowGrid 组件
FollowGrid 组件将接受一个用户列表作为属性,显示用户的头像和名字,并将它们链接到每个用户的个人资料。我们可以将此组件添加到 Profile 视图中以显示 关注者 或 粉丝。FollowGrid 组件的定义如下。
mern-social/client/user/FollowGrid.js:
export default function FollowGrid (props) {
const classes = useStyles()
return (<div className={classes.root}>
<GridList cellHeight={160} className={classes.gridList} cols={4}>
{props.people.map((person, i) => {
return <GridListTile style={{'height':120}} key={i}>
<Link to={"/user/" + person._id}>
<Avatar src={'/api/users/photo/'+person._id}
className={classes.bigAvatar}/>
<Typography className={classes.tileText}>
{person.name}
</Typography>
</Link>
</GridListTile>
})}
</GridList>
</div>)
}
FollowGrid.propTypes = {
people: PropTypes.array.isRequired
}
要将 FollowGrid 组件添加到 Profile 视图中,我们可以将其放置在视图中所需的位置,并将 followers 或 followings 列表作为 people 属性传递:
<FollowGrid people={props.user.followers}/>
<FollowGrid people={props.user.following}/>
如前所述,在 MERN 社交平台中,我们选择在“个人资料”组件的标签页中显示 FollowGrid 组件。我们使用 Material-UI 标签组件创建了一个单独的 ProfileTabs 组件,并将其添加到 Profile 组件中。这个 ProfileTabs 组件包含两个 FollowGrid 组件,其中包含关注者和粉丝列表,以及一个显示用户发布的帖子的 PostList 组件。
在本章后面将讨论 PostList 组件。在下一节中,我们将添加一个功能,允许用户发现平台上他们尚未关注的其他用户。
寻找关注的人
“谁值得关注”功能将向登录用户显示 MERN Social 中他们尚未关注的用户列表,从而给他们提供关注他们或查看他们个人资料的选择,如下截图所示:

为了实现这个功能,我们需要添加一个后端 API,该 API 返回当前登录用户未关注的用户列表,然后通过添加一个加载并显示此用户列表的组件来更新前端。
获取未关注用户
我们将在服务器上实现一个新的 API 来查询数据库并获取当前用户未关注的用户列表。此路由将按如下定义。
mern-social/server/routes/user.routes.js:
router.route('/api/users/findpeople/:userId')
.get(authCtrl.requireSignin, userCtrl.findPeople)
在 findPeople 控制器方法中,我们将查询数据库中的 User 集合以找到不在当前用户 following 列表中的用户。
mern-social/server/controllers/user.controller.js:
const findPeople = async (req, res) => {
let following = req.profile.following
following.push(req.profile._id)
try {
let users = await User.find({ _id:{ $nin : following }})
.select('name')
res.json(users)
}catch(err){
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
此查询将返回一个数组,包含当前用户未关注的用户。为了在前端使用此用户列表,我们将更新 api-user.js 文件并添加对该 API 的获取。findPeople 获取方法定义如下。
mern-social/client/user/api-user.js:
const findPeople = async (params, credentials, signal) => {
try {
let response = await fetch('/api/users/findpeople/' + params.userId, {
method: 'GET',
signal: signal,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
}
})
return await response.json()
} catch(err) {
console.log(err)
}
}
我们可以在组件中使用这个 findPeople 获取方法来显示用户列表。在下一节中,我们将创建 FindPeople 组件来完成这个目的。
FindPeople 组件
为了显示“谁值得关注”功能,我们将创建一个名为 FindPeople 的组件,该组件可以被添加到任何视图或独立渲染。在这个组件中,我们将通过在 useEffect 中调用 findPeople 方法来获取未关注的用户。如下代码所示。
mern-social/client/user/FindPeople.js:
useEffect(() => {
const abortController = new AbortController()
const signal = abortController.signal
findPeople({
userId: jwt.user._id
}, {
t: jwt.token
}, signal).then((data) => {
if (data && data.error) {
console.log(data.error)
} else {
setValues({...values, users:data})
}
})
return function cleanup(){
abortController.abort()
}
}, [])
获取的用户列表将被迭代并在 Material-UI List 组件中渲染,每个列表项包含用户的头像、姓名、到个人主页的链接以及一个 Follow 按钮,如下代码所示。
mern-social/client/user/FindPeople.js:
<List>
{values.users.map((item, i) => {
return <span key={i}>
<ListItem>
<ListItemAvatar className={classes.avatar}>
<Avatar src={'/api/users/photo/'+item._id}/>
</ListItemAvatar>
<ListItemText primary={item.name}/>
<ListItemSecondaryAction className={classes.follow}>
<Link to={"/user/" + item._id}>
<IconButton variant="contained" color="secondary"
className={classes.viewButton}>
<ViewIcon/>
</IconButton>
</Link>
<Button aria-label="Follow" variant="contained"
color="primary"
onClick={()=> {clickFollow(item, i)}}>
Follow
</Button>
</ListItemSecondaryAction>
</ListItem>
</span>
})
}
</List>
点击 Follow 按钮将调用关注 API 并通过剪切掉新关注的用户来更新要关注的用户列表。clickFollow 方法如下实现此行为。
mern-social/client/user/FindPeople.js:
const clickFollow = (user, index) => {
follow({
userId: jwt.user._id
}, {
t: jwt.token
}, user._id).then((data) => {
if (data.error) {
console.log(data.error)
} else {
let toFollow = values.users
toFollow.splice(index, 1)
setValues({...values, users: toFollow, open: true,
followMessage: `Following ${user.name}!`})
}
})
}
我们还将添加一个 Material-UI Snackbar 组件,当用户成功关注新用户时,它会临时打开以告知用户他们开始关注这位新用户。Snackbar 将按如下方式添加到视图代码中。
mern-social/client/user/FindPeople.js:
<Snackbar
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
open={values.open}
onClose={handleRequestClose}
autoHideDuration={6000}
message={<span className={classes.snack}>{values.followMessage}</span>}
/>
如下截图所示,Snackbar 将在页面右下角显示包含被关注用户名的 message,并在设置的时间后自动隐藏:

MERN Social 用户现在可以相互关注,查看每个用户的关注者和被关注者列表,还可以看到他们可以关注的人的列表。在 MERN Social 中关注另一个用户的主要目的是查看和互动他们的共享帖子。在下一节中,我们将查看帖子功能的实现。
在 MERN Social 上发布
MERN Social 中的帖子功能将允许用户在 MERN Social 应用程序平台上分享内容,并通过评论或点赞帖子与他人互动,如下面的截图所示:

对于这个功能,我们将实现一个包含帖子后端和前端的完整全栈切片。帖子后端将包括一个新的 Mongoose 模型,用于结构化要存储在数据库中的帖子数据,而帖子 CRUD API 端点将允许前端与数据库中的帖子集合进行交互。帖子前端将包括与帖子相关的 React 组件,允许用户查看帖子、添加新帖子、与帖子互动以及删除自己的帖子。在接下来的章节中,我们将定义帖子模式中帖子数据的结构,然后根据我们正在实现的特定帖子相关功能,逐步添加帖子后端 API 和前端组件。
Mongoose 的帖子模式模型
为了定义存储每个帖子详细信息的结构,并将每个帖子作为文档存储在 MongoDB 的集合中,我们将在 server/models/post.model.js 中定义帖子模式的 Mongoose 模型。帖子模式将存储帖子的文本内容、照片、发布帖子的用户引用、创建时间、用户对帖子的点赞以及用户对帖子的评论。该模式将按照以下字段存储这些详细信息,每个字段都按照相应的代码定义。
- 帖子文本:
text将是一个必填字段,需要在创建新帖子时从视图中提供:
text: {
type: String,
required: 'Text is required'
}
- 帖子照片:
photo将在帖子创建时从用户的本地文件上传并存储在 MongoDB 中,类似于用户个人资料照片上传功能。每个帖子中的照片是可选的:
photo: {
data: Buffer,
contentType: String
}
- 帖子作者:创建帖子需要用户先登录,这样我们就可以在
postedBy字段中存储发布帖子的用户引用:
postedBy: {type: mongoose.Schema.ObjectId, ref: 'User'}
- 创建时间:
created时间将在数据库中创建帖子时自动生成:
created: { type: Date, default: Date.now }
- 点赞:对特定帖子点赞的用户引用将存储在
likes数组中:
likes: [{type: mongoose.Schema.ObjectId, ref: 'User'}]
- 评论:每个帖子的评论将包含文本内容、创建时间以及评论用户的引用。每个帖子将有一个包含
comments的数组:
comments: [{
text: String,
created: { type: Date, default: Date.now },
postedBy: { type: mongoose.Schema.ObjectId, ref: 'User'}
}]
这个模式定义将使我们能够在 MERN Social 中实现所有与帖子相关的功能。接下来,我们将从新闻源功能开始讨论,了解如何编写前端 React 组件。
新闻源组件
在 MERN Social 上,每个用户将看到他们关注的用户分享的帖子,以及他们自己分享的帖子,所有这些帖子都汇总在新闻源视图中。在进一步探讨 MERN Social 中帖子相关功能的实现之前,我们将查看这个新闻源视图的组成,以展示如何设计嵌套 UI 组件并共享状态的基本示例。Newsfeed 组件将包含两个主要的子组件——一个新帖子表单和来自关注用户的帖子列表,如下面的截图所示:

Newsfeed 组件的基本结构如下,其中包含 NewPost 组件和 PostList 组件。
mern-social/client/post/Newsfeed.js:
<Card>
<Typography type="title"> Newsfeed </Typography>
<Divider/>
<NewPost addUpdate={addPost}/>
<Divider/>
<PostList removeUpdate={removePost} posts={posts}/>
</Card>
作为父组件,Newsfeed 将控制在子组件中渲染的帖子数据的状态。当在子组件中修改帖子数据时,例如在 NewPost 组件中添加新帖子或在 PostList 组件中删除帖子,它将提供一种方法来更新组件间的帖子状态。
在这里具体来说,在 Newsfeed 组件中,我们最初调用服务器以获取当前登录用户关注的用户的帖子列表。然后我们将这个帖子列表设置到状态中,以便在 PostList 组件中渲染。Newsfeed 组件为 NewPost 和 PostList 提供了 addPost 和 removePost 函数,这些函数将在创建新帖子或删除现有帖子时使用,以更新 Newsfeed 状态中的帖子列表,并最终在 PostList 中反映出来。
在 Newsfeed 组件中定义的 addPost 函数将获取在 NewPost 组件中创建的新帖子,并将其添加到状态中的帖子中。addPost 函数将如下所示。
mern-social/client/post/Newsfeed.js:
const addPost = (post) => {
const updatedPosts = [...posts]
updatedPosts.unshift(post)
setPosts(updatedPosts)
}
在 Newsfeed 组件中定义的 removePost 函数将从 PostList 中的 Post 组件中获取已删除的帖子,并将其从状态中的帖子中删除。removePost 函数将如下所示。
mern-social/client/post/Newsfeed.js:
const removePost = (post) => {
const updatedPosts = [...posts]
const index = updatedPosts.indexOf(post)
updatedPosts.splice(index, 1)
setPosts(updatedPosts)
}
通过这种方式在 Newsfeed 的状态中更新帖子,PostList 将将更改后的帖子列表渲染给观众。这种从父组件到子组件以及返回的状态更新机制将应用于其他功能,例如帖子中的评论更新以及当在 Profile 组件中为单个用户渲染 PostList 时。
要开始完整实现 Newsfeed,我们需要能够从服务器获取帖子列表并在 PostList 中显示它。在下一节中,我们将为前端创建这个 PostList 组件,并将 PostList API 端点添加到后端。
列出帖子
在 MERN 社交中,我们在 Newsfeed 和每个用户的个人资料中列出帖子。我们将创建一个通用的 PostList 组件,该组件可以渲染提供的任何帖子列表,我们可以在 Newsfeed 和 Profile 组件中使用它。PostList 组件定义如下。
mern-social/client/post/PostList.js:
export default function PostList (props) {
return (
<div style={{marginTop: '24px'}}>
{props.posts.map((item, i) => {
return <Post post={item} key={i}
onRemove={props.removeUpdate}/>
})
}
</div>
)
}
PostList.propTypes = {
posts: PropTypes.array.isRequired,
removeUpdate: PropTypes.func.isRequired
}
PostList 组件将遍历从 Newsfeed 或 Profile 传递给它的作为 props 的帖子列表,并将每个帖子的数据传递给一个将渲染帖子详细信息的 Post 组件。PostList 还会将从父组件发送的 removeUpdate 函数传递给 Post 组件,以便在删除单个帖子时更新状态。接下来,我们将在从后端获取相关帖子后完成新闻源视图中的帖子列表。
在新闻源中列出帖子
我们将在服务器上设置一个 API,该 API 查询 Post 集合并返回一个列表,其中包含指定用户关注的用户的帖子。然后,为了填充新闻源视图,这些帖子将通过调用此 API 在前端检索,并在 Newsfeed 中的 PostList 中显示。
帖子新闻源 API
要实现针对新闻源的特定 API,我们需要添加一个路由端点,该端点将接收新闻源帖子请求并相应地响应用户端请求。
在后端,我们需要定义一个路由路径,该路径将接收检索特定用户新闻源帖子的请求,如下所示。
server/routes/post.routes.js
router.route('/api/posts/feed/:userId')
.get(authCtrl.requireSignin, postCtrl.listNewsFeed)
我们在这个路由中使用 :userID 参数来指定当前登录的用户。我们将利用 userByID 控制器方法在 user.controller 中获取用户详细信息,就像我们之前所做的那样,并将这些信息附加到在 listNewsFeed 帖子控制器方法中访问的请求对象中。将以下内容添加到 mern-social/server/routes/post.routes.js:
router.param('userId', userCtrl.userByID)
post.routes.js 文件将与 user.routes.js 文件非常相似。为了在 Express 应用程序中加载这些新路由,我们需要在 express.js 中挂载帖子路由,就像我们为 auth 和用户路由所做的那样。帖子相关路由的挂载方式如下。
mern-social/server/express.js:
app.use('/', postRoutes)
post.controller.js 中的 listNewsFeed 控制器方法将查询数据库中的 Post 集合以获取匹配的帖子。listNewsFeed 控制器方法定义如下。
mern-social/server/controllers/post.controller.js:
const listNewsFeed = async (req, res) => {
let following = req.profile.following
following.push(req.profile._id)
try {
let posts = await Post.find({postedBy:{ $in : req.profile.following }})
.populate('comments.postedBy', '_id name')
.populate('postedBy', '_id name')
.sort('-created')
.exec()
res.json(posts)
} catch(err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
在对 Post 集合的查询中,我们找到所有具有 postedBy 用户引用与当前用户的关注者和当前用户匹配的帖子。返回的帖子将按 created 时间戳排序,最新的帖子排在第一位。每个帖子还将包含创建帖子的用户和评论帖子的用户的 id 和 name。接下来,我们将在前端 Newsfeed 组件中获取此 API 并渲染列表详情。
在视图中获取新闻源帖子
我们将在前端使用新闻源 API 来获取相关帖子并在新闻源视图中显示这些帖子。首先,我们将添加一个获取方法来向 API 发送请求,如下面的代码所示。
client/post/api-post.js:
const listNewsFeed = async (params, credentials, signal) => {
try {
let response = await fetch('/api/posts/feed/'+ params.userId, {
method: 'GET',
signal: signal,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
}
})
return await response.json()
} catch(err) {
console.log(err)
}
}
这是将加载在 PostList 中渲染的帖子的一种获取方法,PostList 被添加为 Newsfeed 组件的子组件。因此,这个获取操作需要在 Newsfeed 组件的 useEffect 钩子中调用,如下面的代码所示。
mern-social/client/post/Newsfeed.js:
useEffect(() => {
const abortController = new AbortController()
const signal = abortController.signal
listNewsFeed({
userId: jwt.user._id
}, {
t: jwt.token
}, signal).then((data) => {
if (data.error) {
console.log(data.error)
} else {
setPosts(data)
}
})
return function cleanup(){
abortController.abort()
}
}, [])
这将从后端检索帖子列表并将其设置到 Newsfeed 组件的状态中,以最初加载在 PostList 组件中渲染的帖子,如下面的截图所示:

在本章后面将讨论如何渲染列表中的单个帖子细节。在下一节中,我们将为 Profile 组件渲染相同的 PostList 并显示特定用户分享的帖子。
在个人资料中列出用户帖子
获取特定用户创建的帖子列表并在 Profile 中显示的实现将与我们在上一节中讨论的关于在新闻源中列出帖子的实现类似。首先,我们将在服务器上设置一个 API,该 API 查询帖子集合并返回特定用户的帖子到 Profile 视图。
用户帖子 API
为了检索特定用户分享的帖子,我们需要添加一个路由端点来接收对这些帖子的请求并相应地响应请求客户端。
在后端,我们将定义另一个与帖子相关的路由,该路由将接收查询以返回特定用户的帖子,如下所示。
mern-social/server/routes/post.routes.js:
router.route('/api/posts/by/:userId')
.get(authCtrl.requireSignin, postCtrl.listByUser)
post.controller.js 中的 listByUser 控制器方法将查询帖子集合以找到在 postedBy 字段中与路由中指定的 userId 参数匹配的引用。listByUser 控制器方法将如下所示。
mern-social/server/controllers/post.controller.js:
const listByUser = async (req, res) => {
try {
let posts = await Post.find({postedBy: req.profile._id})
.populate('comments.postedBy', '_id name')
.populate('postedBy', '_id name')
.sort('-created')
.exec()
res.json(posts)
} catch(err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
这个查询将返回由特定用户创建的帖子列表。我们需要从前端调用这个 API,我们将在下一节中这样做。
在视图中获取用户帖子
我们将在前端使用 list-posts-by-user API 来获取相关帖子并在个人资料视图中显示这些帖子。为了使用此 API,我们将在前端添加一个获取方法,如下所示。
mern-social/client/post/api-post.js:
const listByUser = async (req, res) => {
try {
let posts = await Post.find({postedBy: req.profile._id})
.populate('comments.postedBy', '_id name')
.populate('postedBy', '_id name')
.sort('-created')
.exec()
res.json(posts)
} catch(err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
这个 fetch 方法将加载 PostList 所需的帖子,PostList 被添加到 Profile 视图中。我们将更新 Profile 组件,使其定义一个 loadPosts 方法,该方法调用 listByUser 获取方法。loadPosts 方法将如下所示。
mern-social/client/user/Profile.js:
const loadPosts = (user) => {
listByUser({
userId: user
}, {
t: jwt.token
}).then((data) => {
if (data.error) {
console.log(data.error)
} else {
setPosts(data)
}
})
}
在 Profile 组件中,loadPosts 方法将在从服务器在 useEffect() 钩子函数中获取用户详情之后,使用正在加载的用户 ID 被调用。为特定用户加载的帖子被设置到状态中,并在添加到 Profile 组件的 PostList 组件中渲染。Profile 组件还提供了一个类似于 Newsfeed 组件的 removePost 函数,作为属性传递给 PostList 组件,以便在帖子被删除时更新帖子列表。Profile 组件中的结果 PostList 将渲染成如下截图所示:

列出在 MERN Social 上已分享的帖子的功能现在已经完成。但在测试这些功能之前,我们需要实现允许用户创建新帖子的功能。我们将在下一节中这样做。
创建新帖子
创建新帖子功能将允许已登录用户发布消息,并且可以选择通过从本地文件上传来添加图片到帖子中。为了实现这个功能,在接下来的章节中,我们将向后端添加一个创建帖子 API 端点,允许上传图像文件,并且在前端添加一个 NewPost 组件,该组件将利用此端点让用户创建新帖子。
创建帖子 API
在服务器上,我们将定义一个 API 来在数据库中创建帖子,首先在 mern-social/server/routes/post.routes.js 中声明一个接受 POST 请求的路由 /api/posts/new/:userId:
router.route('/api/posts/new/:userId')
.post(authCtrl.requireSignin, postCtrl.create)
post.controller.js 中的 create 方法将使用 formidable 模块来访问字段和图像文件(如果有),就像我们为用户个人资料图片更新所做的那样。create 控制器方法将如下所示。
mern-social/server/controllers/post.controller.js:
const create = (req, res, next) => {
let form = new formidable.IncomingForm()
form.keepExtensions = true
form.parse(req, async (err, fields, files) => {
if (err) {
return res.status(400).json({
error: "Image could not be uploaded"
})
}
let post = new Post(fields)
post.postedBy= req.profile
if(files.photo){
post.photo.data = fs.readFileSync(files.photo.path)
post.photo.contentType = files.photo.type
}
try {
let result = await post.save()
res.json(result)
} catch (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
})
}
与个人资料图片上传类似,与新帖子一起上传的图片将以二进制格式存储在 Post 文档中。我们需要添加一个路由来检索并返回此图片到前端,我们将在下一步中这样做。
检索帖子的图片
为了检索上传的图片,我们还将设置一个 photo 路由端点,在请求时,将返回与特定帖子关联的图片。图片 URL 路由将与其他与帖子相关的路由一起定义,如下所示。
mern-social/server/routes/post.routes.js:
router.route('/api/posts/photo/:postId').get(postCtrl.photo)
photo 控制器将返回存储在 MongoDB 中的 photo 数据,以图像文件的形式。这定义如下。
mern-social/server/controllers/post.controller.js:
const photo = (req, res, next) => {
res.set("Content-Type", req.post.photo.contentType)
return res.send(req.post.photo.data)
}
由于图片路由使用 :postID 参数,我们将设置一个 postByID 控制器方法来通过其 ID 获取特定帖子,然后再将其返回给图片请求。我们将在 post.routes.js 中添加 param 调用,如下所示代码所示。
mern-social/server/routes/post.routes.js:
router.param('postId', postCtrl.postByID)
postByID将与userByID方法类似,并将从数据库检索到的帖子附加到请求对象中,以便可以通过next方法访问。postByID方法定义如下。
mern-social/server/controllers/post.controller.js:
const postByID = async (req, res, next, id) => {
try{
let post = await Post.findById(id)
.populate('postedBy', '_id name')
.exec()
if (!post)
return res.status('400').json({
error: "Post not found"
})
req.post = post
next()
}catch(err){
return res.status('400').json({
error: "Could not retrieve use post"
})
}
}
在此实现中,附加的帖子数据还将包含postedBy用户引用的 ID 和名称,因为我们调用了populate()。在下一节中,我们将在前端添加一个 fetch 方法来访问此 API 端点。
在视图中获取创建帖子 API
我们将通过添加一个create方法来更新api-post.js,以便对创建 API 进行fetch调用。create fetch 方法将如下所示。
mern-social/client/post/api-post.js:
const create = async (params, credentials, post) => {
try {
let response = await fetch('/api/posts/new/'+ params.userId, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer ' + credentials.t
},
body: post
})
return await response.json()
} catch(err) {
console.log(err)
}
}
此方法与用户edit fetch 方法类似,将使用FormData对象发送多部分表单提交,该对象将包含文本字段和图像文件。最后,我们准备好将此创建新帖子的功能集成到后端,与允许用户编写帖子并将其提交到后端的客户端组件。
创建 NewPost 组件
我们在Newsfeed组件中添加的NewPost组件将允许用户编写包含文本消息和可选图像的新帖子,如下面的截图所示:

NewPost组件将是一个标准表单,包含 Material-UI 的TextField和一个文件上传按钮,如EditProfile中实现的那样,它将值设置在FormData对象中,以便在提交帖子时传递给create fetch 方法的调用。帖子提交将调用以下clickPost方法。
mern-social/client/post/NewPost.js:
const clickPost = () => {
let postData = new FormData()
postData.append('text', values.text)
postData.append('photo', values.photo)
create({
userId: jwt.user._id
}, {
t: jwt.token
}, postData).then((data) => {
if (data.error) {
setValues({...values, error: data.error})
} else {
setValues({...values, text:'', photo: ''})
props.addUpdate(data)
}
})
}
如前所述,NewPost组件被添加为Newsfeed的子组件,并作为属性传递了addUpdate方法。在成功创建帖子后,表单视图将被清空,并执行addUpdate,以便将新帖子更新到Newsfeed中的帖子列表。在下一节中,我们将添加Post组件,以便显示每个帖子和其详细信息。
Post 组件
每个帖子中的帖子详情将在Post组件中渲染,该组件将从PostList组件接收帖子数据作为 props,以及onRemove属性,如果删除帖子则需要应用。在以下章节中,我们将查看 Post 接口的不同部分以及如何实现每个部分。
布局
Post组件的布局将包含一个显示发布者详情的标题,帖子的内容,一个包含点赞和评论计数的操作栏,以及一个评论部分,如下面的截图所示:

接下来,我们将探讨此 Post 组件的标题、内容、操作和评论部分的实现细节。
标题
标题将包含有关发布用户的信息,如姓名、头像和链接到用户个人资料,以及帖子创建的日期。在标题部分显示这些详情的代码将如下所示。
mern-social/client/post/Post.js:
<CardHeader
avatar={
<Avatar src={'/api/users/photo/'+props.post.postedBy._id}/>
}
action={ props.post.postedBy._id === auth.isAuthenticated().user._id &&
<IconButton onClick={deletePost}>
<DeleteIcon />
</IconButton>
}
title={<Link to={"/user/" + props.post.postedBy._id}>{props.post.postedBy.name}</Link>}
subheader={(new Date(props.post.created)).toDateString()}
className={classes.cardHeader}
/>
如果已登录用户正在查看自己的帖子,标题还将条件性地显示一个delete按钮。这个标题部分将位于主要内容部分之上,我们将在下一节讨论。
内容
内容部分将显示帖子的文本和图片(如果帖子包含图片)。在内容部分显示这些详情的代码将如下所示。
mern-social/client/post/Post.js:
<CardContent className={classes.cardContent}>
<Typography component="p" className={classes.text}>
{props.post.text}
</Typography>
{props.post.photo &&
(<div className={classes.photo}>
<img className={classes.media}
src={'/api/posts/photo/'+ props.post._id}/>
</div>)
}
</CardContent>
如果给定的帖子包含照片,将通过在img标签的src属性中添加照片 API 来加载图片。紧随此内容部分之后是行动部分。
行动
行动部分将包含一个交互式的“点赞”选项,显示帖子的总点赞数,以及一个评论图标,显示帖子的总评论数。显示这些行动的代码将如下所示。
mern-social/client/post/Post.js:
<CardActions>
{ values.like
? <IconButton onClick={clickLike} className={classes.button}
aria-label="Like" color="secondary">
<FavoriteIcon />
</IconButton>
: <IconButton onClick={clickLike} className={classes.button}
aria-label="Unlike" color="secondary">
<FavoriteBorderIcon />
</IconButton> } <span>{values.likes}</span>
<IconButton className={classes.button}
aria-label="Comment" color="secondary">
<CommentIcon/>
</IconButton> <span>{values.comments.length}</span>
</CardActions>
我们将在本章后面讨论“点赞”按钮的实现。每个帖子的点赞详情是通过接收在 props 中的post对象检索的。
在Post组件中,最后一部分将显示在给定帖子上的评论。我们将在下一节讨论这个问题。
评论
评论部分将包含Comments组件中的所有与评论相关的元素,并将获取props,如postId和comments数据,以及一个更新状态的方法,该方法可以在Comments组件中添加或删除评论时调用。
评论部分将通过以下代码在视图中渲染。
mern-social/client/post/Post.js:
<Comments postId={props.post._id}
comments={values.comments}
updateComments={updateComments}/>
这个Comments组件的实现将在本章后面详细说明。这四个部分构成了我们在Post组件中实现的单个帖子视图,该组件在PostList组件中渲染。每个帖子的标题还显示了创建者的删除按钮。我们将在下一节实现这个删除帖子功能。
删除帖子
如果已登录用户和特定帖子的postedBy用户相同,则delete按钮才会可见。为了从数据库中删除帖子,我们将在后端设置一个删除帖子 API,该 API 在前端也将有一个在点击delete时应用的方法。删除帖子 API 端点的路由将如下所示。
mern-social/server/routes/post.routes.js:
router.route('/api/posts/:postId')
.delete(authCtrl.requireSignin,
postCtrl.isPoster,
postCtrl.remove)
删除路由将在调用帖子上的remove之前检查授权,确保认证用户和postedBy用户是同一用户。以下代码中实现的isPoster方法在执行next方法之前检查已登录用户是否是帖子的原始创建者。
mern-social/server/controllers/post.controller.js:
const isPoster = (req, res, next) => {
let isPoster = req.post && req.auth &&
req.post.postedBy._id == req.auth._id
if(!isPoster){
return res.status('403').json({
error: "User is not authorized"
})
}
next()
}
删除 API 的其余实现(包括 remove 控制器方法和前端 fetch 方法)与其他 API 实现相同。这里的重要区别在于,在删除帖子功能中,当删除成功时,会调用 Post 组件中的 onRemove 更新方法。onRemove 方法作为 prop 从 Newsfeed 或 Profile 发送,以在删除成功时更新状态中的帖子列表。
当在帖子中点击 delete 按钮时,将调用定义在 Post 组件中的以下 deletePost 方法。
mern-social/client/post/Post.js:
const deletePost = () => {
remove({
postId: props.post._id
}, {
t: jwt.token
}).then((data) => {
if (data.error) {
console.log(data.error)
} else {
props.onRemove(props.post)
}
})
}
此方法向删除帖子 API 发起 fetch 调用,并在成功后,通过执行从父组件接收的 onRemove 方法来更新状态中的帖子列表。
这部分完成了后端和前端中 Post CRUD 特性的实现。然而,我们还没有完成允许 MERN Social 用户与这些帖子互动的功能。在下一节中,我们将添加点赞帖子以及评论帖子的功能。
与帖子互动
任何社交媒体平台的核心功能都是用户能够与共享内容互动。对于在 MERN Social 应用程序中创建的帖子,我们将添加点赞和为单个帖子留下评论的选项。
要完成此功能的实现,首先,我们必须修改后端,以便我们可以添加更新现有帖子详情(包括点赞该帖子的用户详情和评论详情)的 API 端点。
然后,在前端,我们必须修改 UI,以便用户可以在帖子上点赞和留下评论。
点赞
Post 组件动作栏部分的点赞选项将允许用户点赞或取消点赞帖子,并显示帖子的总点赞数。为了记录“点赞”,我们必须设置点赞和取消点赞 API,以便在用户与渲染在每个帖子中的动作栏交互时在视图中调用。
点赞 API
点赞 API 将是一个 PUT 请求,它将更新 Post 文档中的 likes 数组。请求将在定义如下 api/posts/like 路由处接收。
mern-social/server/routes/post.routes.js:
router.route('/api/posts/like')
.put(authCtrl.requireSignin, postCtrl.like)
在 like 控制器方法中,请求体中接收到的帖子 ID 将用于查找特定的 Post 文档,并通过将当前用户的 ID 推送到 likes 数组来更新它,如下面的代码所示。
mern-social/server/controllers/post.controller.js:
const like = async (req, res) => {
try {
let result = await Post.findByIdAndUpdate(req.body.postId,
{$push: {likes: req.body.userId}},
{new: true})
res.json(result)
} catch(err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
要使用此 API,将在 api-post.js 中添加一个名为 like 的 fetch 方法,该方法将在用户点击 like 按钮时使用。like fetch 的定义如下。
mern-social/client/post/api-post.js:
const like = async (params, credentials, postId) => {
try {
let response = await fetch('/api/posts/like/', {
method: 'PUT',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
},
body: JSON.stringify({userId:params.userId, postId: postId})
})
return await response.json()
} catch(err) {
console.log(err)
}
}
类似地,在下一节中,我们还将实现一个取消点赞 API 端点,以便用户可以取消之前点赞的帖子。
取消点赞 API
unlike API 将类似于like API 实现,有自己的路由。这将被声明如下。
mern-social/server/routes/post.routes.js:
router.route('/api/posts/unlike')
.put(authCtrl.requireSignin, postCtrl.unlike)
控制器中的unlike方法将通过其 ID 查找帖子,并通过使用$pull而不是$push来移除当前用户的 ID 来更新likes数组。unlike控制器方法将如下所示。
mern-social/server/controllers/post.controller.js:
const unlike = async (req, res) => {
try {
let result = await Post.findByIdAndUpdate(req.body.postId,
{$pull: {likes: req.body.userId}},
{new: true})
res.json(result)
} catch(err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
unlike API 也将有一个相应的 fetch 方法,类似于api-post.js中的like方法。
这些 API 将在用户与视图中的点赞按钮交互时被调用。但是,首先需要确定点赞按钮是否应该允许点赞或取消点赞操作。我们将在下一节中探讨这个问题。
检查帖子是否被点赞和统计点赞数
当Post组件被渲染时,我们需要检查当前登录的用户是否已经点赞了这篇帖子,以便显示适当的like选项。下面的checkLike方法检查当前登录的用户是否在帖子的likes数组中被引用。
mern-social/client/post/Post.js:
const checkLike = (likes) => {
let match = likes.indexOf(jwt.user._id) !== -1
return match
}
这个checkLike函数可以在设置like状态变量的初始值时被调用,该变量跟踪当前用户是否喜欢了给定的帖子。以下截图显示了帖子没有被点赞和当前用户点赞时的点赞按钮的渲染方式:

使用checkLike方法在状态中设置的like值可以用来渲染心形轮廓按钮或完整的心形按钮。如果用户没有点赞帖子,将渲染心形轮廓按钮;点击它将调用like API,显示完整的心形按钮,并增加likes计数。完整的心形按钮将表示当前用户已经点赞了这篇帖子;点击这个按钮将调用unlike API,渲染心形轮廓按钮,并减少likes计数。
当Post组件挂载并接收到属性时,likes计数也会通过将likes值设置为状态的props.post.likes.length来初始化,如下面的代码所示。
mern-social/client/post/Post.js:
const [values, setValues] = useState({
like: checkLike(props.post.likes),
likes: props.post.likes.length,
comments: props.post.comments
})
当发生“点赞”或“取消点赞”操作时,与点赞相关的值会再次更新,并且更新后的帖子数据会从 API 调用中返回。接下来,我们将看看如何处理点赞按钮上的点击。
处理点赞点击
为了处理like和unlike按钮的点击,我们将设置一个clickLike方法,该方法将根据是“点赞”还是“取消点赞”操作调用适当的 fetch 方法,然后更新帖子的like和likes计数的状态。这个clickLike方法将定义如下。
mern-social/client/post/Post.js:
const clickLike = () => {
let callApi = values.like ? unlike : like
const jwt = auth.isAuthenticated()
callApi({
userId: jwt.user._id
}, {
t: jwt.token
}, props.post._id).then((data) => {
if (data.error) {
console.log(data.error)
} else {
setValues({...values, like: !values.like,
likes: data.likes.length})
}
})
}
在点击时将调用哪个点赞或取消点赞 API 端点取决于状态中like变量的值。一旦成功调用所选 API 端点,状态中的值将更新,以便它们可以在视图中反映出来。
这完成了点赞功能的实现,包括与前端集成的后端 API,以实现对特定帖子的点赞和取消点赞。接下来,我们将添加评论功能,以完成我们为 MERN Social 设定的社交媒体应用程序功能。
评论
每个帖子中的评论部分将允许已登录用户添加评论、查看评论列表以及删除自己的评论。对评论列表的任何更改,如新增或删除,都将更新评论以及Post组件动作栏部分的评论计数。以下截图显示了结果评论部分:

要实现一个功能性的评论部分,我们将更新后端以包含相应的评论和取消评论 API 端点,并创建此Comments组件以便与后端更新集成。
添加评论
当用户添加评论时,数据库中的Post文档将使用新的评论进行更新。首先,我们需要实现一个 API,该 API 从客户端接收评论详情并更新Post文档。然后,我们需要在前端创建 UI,使我们能够编写新的评论并将其提交到后端 API。
评论 API
要实现添加评论 API,我们将设置如下PUT路由来更新帖子。
mern-social/server/routes/post.routes.js:
router.route('/api/posts/comment')
.put(authCtrl.requireSignin, postCtrl.comment)
在以下代码中定义的comment控制器方法将根据其 ID 找到要更新的相关帖子,并将请求体中接收到的评论对象推送到帖子的comments数组中。
mern-social/server/controllers/post.controller.js:
const comment = async (req, res) => {
let comment = req.body.comment
comment.postedBy = req.body.userId
try {
let result = await Post.findByIdAndUpdate(req.body.postId,
{$push: {comments: comment}},
{new: true})
.populate('comments.postedBy', '_id name')
.populate('postedBy', '_id name')
.exec()
res.json(result)
} catch(err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
在响应中,将发送回更新后的帖子对象,其中包含在帖子和评论中填充的postedBy用户的详细信息。
要在视图中使用此 API,我们将在api-post.js中设置一个获取方法,该方法接受当前用户的 ID、帖子 ID 和视图中的comment对象,并将其与添加评论请求一起发送。评论获取方法如下所示。
mern-social/client/post/api-post.js:
const comment = async (params, credentials, postId, comment) => {
try {
let response = await fetch('/api/posts/comment/', {
method: 'PUT',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
},
body: JSON.stringify({userId:params.userId, postId: postId,
comment: comment})
})
return await response.json()
} catch(err) {
console.log(err)
}
}
我们可以在用户提交新评论时在 UI 中使用此获取方法,正如下一节所讨论的。
在视图中编写内容
Comments组件中的“添加评论”部分将允许已登录用户输入评论文本:

这将包含显示用户照片的头像和一个文本字段,当用户按下Enter键时将添加评论。此添加评论部分将以以下代码在视图中渲染。
mern-social/client/post/Comments.js:
<CardHeader
avatar={
<Avatar className={classes.smallAvatar}
src= {'/api/users/photo/'
+auth.isAuthenticated().user._id}/>
}
title={ <TextField
onKeyDown={addComment}
multiline
value={text}
onChange={handleChange}
placeholder="Write something ..."
className={classes.commentField}
margin="normal"
/>
}
className={classes.cardHeader}
/>
文本将在值更改时存储在状态中,在 onKeyDown 事件中,如果按下 Enter 键,addComment 方法将调用 comment fetch 方法。Enter 键对应于 keyCode 13,如下面的代码所示。
mern-social/client/post/Comments.js:
const addComment = (event) => {
if(event.keyCode == 13 && event.target.value){
event.preventDefault()
comment({
userId: jwt.user._id
}, {
t: jwt.token
}, props.postId, {text: text}).then((data) => {
if (data.error) {
console.log(data.error)
} else {
setText('')
props.updateComments(data.comments)
}
})
}
}
Comments 组件从 Post 组件接收 updateComments 方法(如前文所述)作为属性。这将在新评论添加时执行,以更新评论列表和 Post 视图中的评论计数。Comments 中列出所有评论的部分将在下一节中添加。
列出评论
Comments 组件从 Post 组件接收特定帖子的评论列表作为属性。然后,它遍历单个评论以渲染评论者的细节和评论内容。此视图的实现代码如下。
mern-social/client/post/Comments.js:
{ props.comments.map((item, i) => {
return <CardHeader
avatar={
<Avatar className={classes.smallAvatar}
src={'/api/users/photo/'+item.postedBy._id}/>
}
title={commentBody(item)}
className={classes.cardHeader}
key={i}/>
})
}
commentBody 渲染内容,包括评论者的名字(链接到其个人资料)、评论文本和评论创建日期。commentBody 定义如下。
mern-social/client/post/Comments.js:
const commentBody = item => {
return (
<p className={classes.commentText}>
<Link to={"/user/" + item.postedBy._id}>
{item.postedBy.name} </Link><br/>
{item.text}
<span className={classes.commentDate}>
{ (new Date(item.created)).toDateString()} |
{ auth.isAuthenticated().user._id === item.postedBy._id &&
<Icon onClick={deleteComment(item)}
className={classes.commentDelete}>delete</Icon> }
</span>
</p>
)
}
如果评论的 postedBy 引用与当前登录用户匹配,commentBody 将为该评论渲染一个删除选项。我们将在下一节中查看此评论删除选项的实现。
删除评论
点击评论中的删除按钮将通过从相应帖子的 comments 数组中删除评论来更新数据库中的帖子。删除按钮可以在以下屏幕截图所示的评论下方看到:

要实现此删除评论功能,我们需要在后端添加一个 uncomment API,然后在前端使用它。
不评论 API
我们将在以下 PUT 路由中实现 uncomment API。
mern-social/server/routes/post.routes.js:
router.route('/api/posts/uncomment')
.put(authCtrl.requireSignin, postCtrl.uncomment)
uncomment 控制器方法将通过 ID 查找相关帖子,并从帖子的 comments 数组中拉取具有被删除评论 ID 的评论,如下面的代码所示。
mern-social/server/controllers/post.controller.js:
const uncomment = async (req, res) => {
let comment = req.body.comment
try{
let result = await Post.findByIdAndUpdate(req.body.postId,
{$pull: {comments: {_id: comment._id}}},
{new: true})
.populate('comments.postedBy', '_id name')
.populate('postedBy', '_id name')
.exec()
res.json(result)
} catch(err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
响应中将返回更新后的帖子,类似于评论 API。
要在视图中使用此 API,我们还需要在 api-post.js 中设置一个 fetch 方法,类似于 addComment fetch 方法,它接受当前用户的 ID、帖子 ID 和要随 uncomment 请求一起发送的已删除 comment 对象。接下来,我们将学习如何在点击删除按钮时使用此 fetch 方法。
从视图中删除评论
当评论者点击评论的删除按钮时,Comments 组件将调用 deleteComment 方法来获取 uncomment API 并更新评论,以及评论计数,当评论成功从服务器移除时。deleteComment 方法定义如下。
mern-social/client/post/Comments.js:
const deleteComment = comment => event => {
uncomment({
userId: jwt.user._id
}, {
t: jwt.token
}, props.postId, comment).then((data) => {
if (data.error) {
console.log(data.error)
} else {
props.updateComments(data.comments)
}
})
}
在成功从后端移除评论后,从 Post 组件的 props 中发送的 updateComments 方法将被调用。这将更新 Post 组件的状态以更新视图。这将在下一节中讨论。
评论计数更新
updateComments 方法,它将允许在添加或删除评论时更新 comments 和评论计数,定义在 Post 组件中,并作为 prop 传递给 Comments 组件。updateComments 方法将如下所示:
mern-social/client/post/Post.js:
const updateComments = (comments) => {
setValues({...values, comments: comments})
}
此方法接受更新的评论列表作为参数,并更新包含在视图中渲染的评论列表的状态。Post 组件中的评论初始状态是在组件挂载并接收帖子数据作为 props 时设置的。这里设置的评论作为 props 发送到 Comments 组件,并用于在帖子布局的动作栏中渲染点赞动作旁边的评论计数,如下所示。
mern-social/client/post/Post.js:
<IconButton aria-label="Comment" color="secondary">
<CommentIcon/>
</IconButton> <span>{values.comments.length}</span>
Post 组件中的评论计数与在 Comments 组件中渲染和更新的评论之间的关系,简单展示了如何在 React 中通过嵌套组件之间共享数据变化来创建动态和交互式的用户界面。
MERN 社交应用现在包含了我们之前为该应用定义的功能集。用户可以使用照片和描述更新他们的个人资料,在应用中相互关注,并创建带有照片和文本的帖子,以及点赞和评论帖子。这里展示的实现可以被调整和进一步扩展,以添加更多功能,以便利用与 MERN 栈一起工作的揭示机制。
摘要
本章中我们开发的 MERN 社交应用展示了如何将 MERN 栈技术结合使用,以构建一个功能齐全且功能正常的具有社交媒体功能的完整 Web 应用程序。
我们首先更新了骨架应用中的用户功能,允许任何在 MERN 社交上有账户的人添加关于自己的描述,以及从本地文件上传个人照片。在上传个人照片的实现中,我们探讨了如何从客户端上传多部分表单数据,然后在服务器上接收它,直接将文件数据存储在 MongoDB 数据库中,然后能够检索它以供查看。
接下来,我们进一步更新了用户功能,允许用户在 MERN 社交平台上相互关注。在用户模型中,我们增加了维护用户引用数组的能力,以表示每个用户的关注者和被关注者列表。扩展这一能力,我们在视图中加入了关注和取消关注选项,并显示了关注者、被关注者以及尚未关注的用户列表。
然后,我们增加了允许用户发布内容并通过点赞或评论与内容互动的功能。在后台,我们设置了帖子模型和相应的 API,这些 API 能够存储可能包含或不包含图片的帖子内容,以及维护任何用户对帖子产生的点赞和评论记录。
最后,在实现发布、点赞和评论功能的视图时,我们探讨了如何使用组件组合以及如何在组件之间共享变化的状态值来创建复杂和交互式的视图。
通过完成这个 MERN 社交应用程序的实现,我们学习了如何扩展和修改基本应用程序代码,使其根据我们所需的功能发展成为完整的网络应用程序。您可以将类似的策略应用于将基本应用程序扩展为任何您选择的现实世界应用程序。
在下一章中,我们将进一步扩展 MERN 堆栈中的这些功能,并在开发在线教室应用程序时通过扩展基本应用程序来解锁新的可能性。
第八章:使用 MERN 开发 Web 应用程序
在本部分,我们使用上一节中的 MERN 框架应用程序开发两个不同的 Web 应用程序,展示了如何实现和添加从基本到复杂的功能,以及这些功能如何添加到不断增长的 MERN 应用程序中。
本节包含以下章节:
-
第六章,构建基于 Web 的课堂应用
-
第七章,使用在线市场练习 MERN 技能
-
第八章,扩展市场以支持订单和支付
-
第九章,向市场添加实时竞价功能
第九章:构建基于 Web 的教室应用程序
随着世界向互联网发展,我们在不同学科领域学习和获取知识的方式也在改变。目前,在网络上,有大量的在线平台为教师和学生提供了远程教学和学习的选项,无需在教室中物理上聚集。
在本章中,我们将通过扩展 MERN 堆栈骨架应用程序来构建一个简单的在线教室应用程序。这个教室应用程序将支持多个用户角色,课程内容和课程的添加,学生报名,进度跟踪和课程报名统计。在构建这个应用程序的过程中,我们将揭示这个堆栈的更多功能,例如如何实现基于角色的资源访问和操作,如何组合多个模式,以及如何运行不同的查询操作以收集统计数据。到本章结束时,您将熟悉在基于 MERN 的任何应用程序中轻松集成新全栈功能的所需技术。
我们将在本章中涵盖以下主题,以构建在线教室应用程序:
-
介绍 MERN 教室
-
为用户添加教育者角色
-
将课程添加到教室
-
更新课程中的课程
-
发布课程
-
报名参加课程
-
跟踪进度和报名统计
介绍 MERN 教室
MERN 教室是一个简单的在线教室应用程序,允许教育者添加由各种课程组成的课程,而学生可以报名参加这些课程。此外,该应用程序将允许学生跟踪他们在课程中的进度,而讲师可以监控有多少学生报名参加了某个课程,以及有多少学生完成了每个课程。包含所有这些功能的完成应用程序将最终拥有以下截图所示的首页:

完整的 MERN 教室应用程序的代码可在 GitHub 上的github.com/PacktPublishing/Full-Stack-React-Projects-Second-Edition/tree/master/Chapter06/mern-classroom仓库中找到。您可以将此代码克隆并运行,以在阅读本章其余部分的代码解释时运行应用程序。
MERN 教室应用程序所需的视图将通过扩展和修改 MERN 骨架应用程序中的现有 React 组件来开发。以下图表中的组件树展示了构成 MERN 教室前端的所有自定义 React 组件,并揭示了我们将用于构建本章其余部分视图的组成结构:

我们将添加与课程、课程和注册相关的新 React 组件;我们还将修改现有的组件,如 EditProfile、Menu 和 Home 组件,正如我们在本章的其余部分构建 MERN Classroom 应用程序的不同功能时一样。在 Classroom 应用程序中的大多数这些功能都将取决于用户成为教育者的能力。在下一节中,我们将通过更新用户来开始实现 MERN Classroom 应用程序,以便他们可以选择教育者角色。
更新用户的教育者角色
在 MERN Classroom 应用程序注册的用户将有机会通过在EditProfile表单组件中选择此选项来成为平台上的教育者。此选项在表单中的外观如下——显示当用户不是教育者时,以及当他们选择成为教育者时:

当用户选择成为教育者时,与普通用户相比,他们将被允许创建和管理自己的课程。如下面的截图所示,MERN Classroom 将为教育者显示导航菜单中的 TEACH 选项,即它不会显示给普通用户:

在接下来的几节中,我们将通过首先更新用户模型,然后是EditProfile视图,最后是将仅对教育者可见的 TEACH 链接添加到菜单中,来添加此教育者功能。
向用户模型添加角色
MERN 骨架应用程序中的现有用户模型需要一个表示教育者的值,默认设置为false以表示普通用户,但可以设置为true以表示同时也是教育者的用户。为了将此新字段添加到用户模式中,我们将添加以下代码。
mern-classroom/server/models/user.model.js:
educator: {
type: Boolean,
default: false
}
此educator值必须发送到前端,一旦用户成功登录,就会收到用户详情,以便视图可以根据显示与教育者相关的信息相应地渲染。为了进行此更改,我们需要更新在signin控制器方法中发送回的响应,如下面的代码所示:
mern-classroom/server/controllers/auth.controller.js
...
l
token,
user: {
_id: user._id,
name: user.name,
email: user.email,
educator: user.educator
}
})
...
}
通过在响应中发送此educator字段值,我们可以根据角色特定的授权考虑因素渲染前端视图。
在到达这些条件渲染视图之前,我们首先需要在EditProfile视图中实现选择教育者角色的选项,正如下一节所讨论的。
更新 EditProfile 视图
要成为 MERN Classroom 应用程序中的教育者,已登录的用户需要更新他们的个人资料。他们将在编辑个人资料视图中看到一个切换按钮,该按钮可以激活或停用教育者功能。为了实现这一点,首先,我们将更新EditProfile组件,以便在FormControlLabel中添加一个 Material-UI Switch组件,如下面的代码所示。
mern-classroom/client/user/EditProfile.js:
<Typography variant="subtitle1" className={classes.subheading}>
I am an Educator
</Typography>
<FormControlLabel
control={
<Switch classes={{
checked: classes.checked,
bar: classes.bar,
}}
checked={values.educator}
onChange={handleCheck}
/>}
label={values.educator? 'Yes' : 'No'}
/>
任何对开关的更改都将通过调用以下代码中定义的handleCheck方法,将值设置为状态中的educator变量。
mern-classroom/client/user/EditProfile.js:
const handleCheck = (event, checked) => {
setValues({...values, 'educator': checked})
}
handleCheck方法接收一个表示开关是否被选中的checked布尔值,并将此值设置为educator。
在表单提交时,educator值被添加到发送到服务器的更新详细信息中,如下面的代码所示。
mern-classroom/client/user/EditProfile.js:
clickSubmit = () => {
const jwt = auth.isAuthenticated()
const user = {
name: this.state.name || undefined,
email: this.state.email || undefined,
password: this.state.password || undefined,
educator: values.educator || undefined
}
update({
userId: this.match.params.userId
}, {
t: jwt.token
}, user).then((data) => {
if (data.error) {
this.setState({error: data.error})
} else {
auth.updateUser(data, ()=> {
setValues({...values, userId: data._id, redirectToProfile: true})
})
}
})
}
一旦成功更新了编辑个人资料视图,存储在sessionStorage中以供身份验证目的的用户详细信息也应更新。通过调用auth.updateUser方法来完成此sessionStorage更新。它与其他auth-helper.js方法一起定义,并传递更新的用户数据和更新视图的回调函数。此updateUser方法定义如下。
mern-classroom/client/auth/auth-helper.js:
updateUser(user, cb) {
if(typeof window !== "undefined"){
if(sessionStorage.getItem('jwt')){
let auth = JSON.parse(sessionStorage.getItem('jwt'))
auth.user = user
sessionStorage.setItem('jwt', JSON.stringify(auth))
cb()
}
}
}
一旦更新的教育者角色在前端可用,我们就可以用它来相应地渲染前端。在下一节中,我们将看到如何根据教育者或普通用户查看应用程序来不同地渲染菜单。
渲染一个教学选项
在教室应用程序的前端,我们可以根据教育者是否正在浏览应用程序来渲染不同的选项。在本节中,我们将添加代码以有条件地显示导航栏上的TEACH链接,该链接仅对已登录且也是教育者的用户可见。
我们将在之前仅对已登录用户渲染的代码中更新Menu组件,如下所示。
mern-classroom/client/core/Menu.js:
{auth.isAuthenticated() && (<span>
{auth.isAuthenticated().user.educator &&
(<Link to="/teach/courses">
<Button style={isPartActive(history, "/teach/")}>
<Library/> Teach </Button>
</Link>)
}
...
}
这个链接仅对教育者可见,将他们带到教育者仪表板视图,在那里他们可以管理他们正在教授的课程。
本节教会了我们如何在应用程序中将用户角色更新为教育者角色,现在我们可以开始集成允许教育者向教室添加课程的功能。
向教室添加课程
MERN Classroom 中的教育工作者可以创建课程并为每个课程添加课程。在本节中,我们将介绍与课程相关的功能实现,例如添加新课程、按特定讲师列出课程以及显示单个课程的详细信息。为了存储课程数据并启用课程管理,我们首先将实现一个用于课程的 Mongoose 模式,然后是创建和列出课程的后端 API,以及为授权的教育工作者和与应用程序中的课程交互的普通用户的前端视图。
定义课程模型
定义课程模式——在server/models/course.model.js中定义——将具有简单的字段来存储课程详情,包括图片、类别、课程是否已发布以及创建课程的用户的引用。定义课程字段的代码如下,并附有说明:
- 课程名称和描述:
name和description字段将具有字符串类型,其中name为必填字段:
name: {
type: String,
trim: true,
required: 'Name is required'
},
description: {
type: String,
trim: true
},
- 课程图片:
image字段将存储用户上传的课程图片文件,以二进制数据形式存储在 MongoDB 数据库中:
image: {
data: Buffer,
contentType: String
},
- 课程类别:
category字段将存储课程类别值作为字符串,并且它是一个必填字段:
category: {
type: String,
required: 'Category is required'
},
- 课程发布状态:
published字段将是一个布尔值,表示课程是否已发布:
published: {
type: Boolean,
default: false
},
- 课程讲师:
instructor字段将引用创建课程的用户:
instructor: {
type: mongoose.Schema.ObjectId,
ref: 'User'
}
- 创建和更新时间:
created和updated字段将具有Date类型,其中created在添加新课程时生成,而updated在修改任何课程详细信息时更改:
updated: Date,
created: {
type: Date,
default: Date.now
},
此模式定义中的字段将使我们能够在 MERN Classroom 中实现与课程相关的功能。为了开始这些功能,在下一节中,我们将实现一个全栈切片,允许教育工作者创建新课程。
创建新课程
在 MERN Classroom 中,一个已登录的用户——同时也是教育工作者——将能够创建新课程。为了实现此功能,在以下章节中,我们将添加一个创建课程 API 到后台,以及在前端获取此 API 的方法,以及一个创建新课程表单视图,该视图接受用户输入的课程字段。
创建课程 API
为了在后台开始创建课程 API 的实现,我们将添加一个POST路由,该路由验证当前用户是否为教育工作者,然后使用请求体中传递的课程数据创建一个新的课程。该路由定义如下:
mern-classroom/server/routes/course.routes.js:
router.route('/api/courses/by/:userId')
.post(authCtrl.requireSignin, authCtrl.hasAuthorization,
userCtrl.isEducator,
courseCtrl.create)
course.routes.js文件将与user.routes文件非常相似,为了将这些新路由加载到 Express 应用程序中,我们需要在express.js中挂载课程路由,就像我们为 auth 和用户路由所做的那样,如下面的代码所示:
mern-classroom/server/express.js:
app.use('/', courseRoutes)
接下来,我们将更新用户控制器以在创建新课程之前添加isEducator方法——这将确保当前用户实际上是一名教育工作者。isEducator方法定义如下:
mern-classroom/server/controllers/user.controller.js:
const isEducator = (req, res, next) => {
const isEducator = req.profile && req.profile.educator
if (!isEducator) {
return res.status('403').json({
error: "User is not an educator"
})
}
next()
}
课程控制器中的create方法使用formidableNode 模块来解析可能包含用户上传的课程图片的文件的多部分请求。如果有文件,formidable将暂时将其存储在文件系统中,然后我们使用fs模块读取它以检索文件类型和数据,然后将其存储在课程文档的image字段中。create控制器方法将如下所示:
mern-classroom/server/controllers/course.controller.js:
const create = (req, res) => {
let form = new formidable.IncomingForm()
form.keepExtensions = true
form.parse(req, async (err, fields, files) => {
if (err) {
return res.status(400).json({
error: "Image could not be uploaded"
})
}
let course = new Course(fields)
course.instructor= req.profile
if(files.image){
course.image.data = fs.readFileSync(files.image.path)
course.image.contentType = files.image.type
}
try {
let result = await course.save()
res.json(result)
}catch (err){
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
})
}
如果用户上传了课程图片,该图片文件将作为数据存储在 MongoDB 中。然后,为了在视图中显示,它将从数据库中作为单独的GETAPI 路由中的图片文件检索。GETAPI 被设置为 Express 路由在/api/courses/photo/:courseId,它从 MongoDB 获取图像数据并将其作为文件发送响应。文件上传、存储和检索的实现步骤在第五章“从简单的社交媒体应用程序开始”的上传个人照片部分中详细说明。
服务器上的创建课程 API 端点准备就绪后,接下来,我们可以在前端添加一个fetch方法来利用它。
在视图中获取创建 API
为了在前端使用创建 API,我们将在客户端设置一个fetch方法,通过传递多部分表单数据,向创建 API 发送POST请求,如下所示:
mern-classroom/client/course/api-course.js
const create = async (params, credentials, course) => {
try {
let response = await fetch('/api/courses/by/'+ params.userId, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer ' + credentials.t
},
body: course
})
return response.json()
} catch(err) {
console.log(err)
}
}
此方法将在新课程表单视图中用于将用户输入的课程详细信息提交到后端以在数据库中创建新课程。在下一节中,我们将实现这个新课程表单视图的 React 组件。
新课程组件
为了让教育工作者能够创建新的课程,我们将在应用程序的前端添加一个包含表单的 React 组件。这个表单视图将看起来如下所示:

表单将包含一个上传课程图片的选项,输入字段用于输入课程名称、描述和类别;以及提交按钮,该按钮将保存已输入的详细信息到数据库中。
我们将定义NewCourseReact 组件来实现这个表单。如下所示,我们首先使用useState钩子初始化状态;使用空输入字段值、空错误消息和一个初始化为false的redirect变量。
mern-classroom/client/course/NewCourse.js:
export default function NewCourse() {
...
const [values, setValues] = useState({
name: '',
description: '',
image: '',
category: '',
redirect: false,
error: ''
})
...
}
在表单视图中,我们首先给用户一个上传课程图片文件的选项。为了呈现这个选项,我们将在NewCourse的返回函数中使用 Material-UI 按钮和 HTML5 文件输入元素添加文件上传元素,如下面的代码所示。
mern-classroom/client/course/NewCourse.js:
<input accept="image/*" onChange={handleChange('image')}
type="file" style={display:'none'} />
<label htmlFor="icon-button-file">
<Button variant="contained" color="secondary" component="span">
Upload Photo <FileUpload/>
</Button>
</label>
<span>{values.image ? values.image.name : ''}</span>
然后,我们使用 Material-UI 的TextField组件添加name、description和category表单字段。
mern-classroom/client/course/NewCourse.js:
<TextField
id="name"
label="Name"
value={values.name} onChange={handleChange('name')}/> <br/>
<TextField
id="multiline-flexible"
label="Description"
multiline
rows="2"
value={values.description}
onChange={handleChange('description')}/> <br/>
<TextField
id="category"
label="Category"
value={values.category}
onChange={handleChange('category')}/>
我们将在NewCourse中定义一个处理函数,以便我们可以跟踪表单视图中这些字段的变化。handleChange函数将定义如下:
mern-classroom/client/course/NewCourse.js
const handleChange = name => event => {
const value = name === 'image'
? event.target.files[0]
: event.target.value
setValues({...values, [name]: value })
}
这个handleChange函数接受输入字段中输入的新值,并将其设置为状态,包括如果用户上传了文件,则包括文件名。
最后,在视图中,你可以添加提交按钮,当点击时,应该调用一个点击处理函数。我们将在NewCourse中定义一个函数来完成这个目的,如下所示。
mern-classroom/client/course/NewCourse.js:
const clickSubmit = () => {
let courseData = new FormData()
values.name && courseData.append('name', values.name)
values.description && courseData.append('description',
values.description)
values.image && courseData.append('image', values.image)
values.category && courseData.append('category', values.category)
create({
userId: jwt.user._id
}, {
t: jwt.token
}, courseData).then((data) => {
if (data.error) {
setValues({...values, error: data.error})
} else {
setValues({...values, error: '', redirect: true})
}
})
}
当表单提交时,将调用此clickSubmit函数。它首先从状态中获取输入值并将其设置为FormData对象。这确保了数据以正确的格式存储,这是发送包含文件上传请求所需的multipart/form-data编码类型。然后,调用createfetch 方法在后端创建一个新的课程。最后,根据服务器的响应,要么显示错误消息,要么使用以下代码将用户重定向到MyCourses视图。
mern-classroom/client/course/NewCourse.js:
if (values.redirect) {
return (<Redirect to={'/teach/courses'}/>)
}
NewCourse组件只能由已登录的也是讲师的用户查看。因此,我们将向MainRouter组件添加一个PrivateRoute,这将只为授权用户在/teach/course/new渲染此表单。
mern-classroom/client/MainRouter.js:
<PrivateRoute path="/teach/course/new" component={NewCourse}/>
此链接可以添加到任何可能由教育者访问的视图组件中,例如将在下一节中实现的MyCourses视图,以列出由教育者创建的课程。
按教育者列出课程
授权的教育者将能够看到他们在平台上创建的课程列表。为了实现此功能,在以下章节中,我们将添加一个后端 API 来检索特定讲师的课程列表,然后我们将在前端调用此 API 以在 React 组件中渲染这些数据。
列出课程 API
为了实现返回由特定讲师创建的课程列表的 API,首先,我们将在后端添加一个路由来检索当服务器在/api/courses/by/:userId接收GET请求时由给定用户创建的所有课程。此路由将声明如下。
mern-classroom/server/routes/course.routes.js:
router.route('/api/courses/by/:userId')
.get(authCtrl.requireSignin,
authCtrl.hasAuthorization,
courseCtrl.listByInstructor)
为了处理路由中的:userId参数并从数据库检索关联的用户,我们将在用户控制器中使用userByID方法。我们将在course.routes.js中的课程路由中添加以下代码,以便用户在request对象中作为profile可用。
mern-classroom/server/routes/course.routes.js:
router.param('userId', userCtrl.userByID)
在course.controller.js中的listByInstructor控制器方法将查询数据库中的Course集合以获取匹配的课程,如下所示。
mern-classroom/server/controllers/course.controller.js:
const listByInstructor = (req, res) => {
Course.find({instructor: req.profile._id}, (err, courses) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
res.json(courses)
}).populate('instructor', '_id name')
}
在查询课程集合时,我们找到所有具有与用户指定的userId参数匹配的instructor字段的课程。然后,将这些课程作为响应发送给客户端。在下一节中,我们将看到如何从前端调用此 API。
在视图中获取列表 API
为了在前端使用列表 API,我们将定义一个fetch方法,该方法可以被 React 组件用来加载这些课程列表。需要用来通过特定讲师检索课程列表的fetch方法将定义如下。
mern-classroom/client/course/api-course.js
const listByInstructor = async (params, credentials, signal) => {
try {
let response = await fetch('/api/courses/by/'+params.userId, {
method: 'GET',
signal: signal,
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer ' + credentials.t
}
})
return response.json()
} catch(err) {
console.log(err)
}
}
此listByInstructor方法将获取userId值以生成要调用的 API 路由,并将接收与提供的userId值关联的用户创建的课程列表。在教室应用程序中,我们将在下一节讨论的MyCourses组件中使用此方法。
MyCourses 组件
在MyCourses组件中,我们将使用listByInstructor API 从服务器获取数据后,在 Material-UI List中渲染课程列表。如图所示,此组件将作为教育者的仪表板,其中列出他们的课程,并有一个选项添加新课程:

为了实现此组件,我们首先需要获取和渲染课程列表。我们将在useEffect钩子中执行获取 API 调用,并将接收到的课程数组设置在状态中,如下所示。
mern-classroom/client/course/MyCourses.js
export default function MyCourses(){
const [courses, setCourses] = useState([])
const [redirectToSignin, setRedirectToSignin] = useState(false)
const jwt = auth.isAuthenticated()
useEffect(() => {
const abortController = new AbortController()
const signal = abortController.signal
listByInstructor({
userId: jwt.user._id
}, {t: jwt.token}, signal).then((data) => {
if (data.error) {
setRedirectToSignin(true)
} else {
setCourses(data)
}
})
return function cleanup(){
abortController.abort()
}
}, [])
if (redirectToSignin) {
return <Redirect to='/signin'/>
}
...
}
当listByInstructor API 被获取时,我们将传递当前登录用户的认证令牌以在服务器端检查授权。用户应该只能看到他们自己的课程,如果当前用户没有权限进行此获取调用,视图将被重定向到登录页面。否则,将返回并显示在视图中的课程列表。
在此MyCourses组件的视图中,我们将通过使用map迭代检索到的课程数组,在视图中渲染每个课程数据,每个ListItem都将链接到单个课程视图,如下所示:
mern-classroom/client/course/MyCourses.js
{courses.map((course, i) => {
return <Link to={"/teach/course/"+course._id} key={i}>
<ListItem button>
<ListItemAvatar>
<Avatar src={'/api/courses/photo/'+course._id+"?" +
new Date().getTime()}/>
</ListItemAvatar>
<ListItemText primary={course.name}
secondary={course.description}/>
</ListItem>
<Divider/>
</Link>}
)
}
MyCourses组件只能由已登录且也是教育者的用户查看。因此,我们将在MainRouter组件中添加一个PrivateRoute,这样只有授权用户才能在/seller/courses渲染此组件。
mern-classroom/client/MainRouter.js:
<PrivateRoute path="/seller/courses" component={MyCourses}/>
我们在菜单上的TEACH
链接中使用这个前端路由,该链接将已登录的教育者导向此MyCourses视图。在这个视图中,用户可以点击列表中的每一门课程,并转到显示特定课程详情的页面。在下一节中,我们将实现渲染单个课程的功能。
显示课程
MERN Classroom 应用程序的用户,包括访客、已登录的学生和教育者,都将能够浏览课程页面,并具有与其授权级别相关的交互。在接下来的几节中,我们将通过在后台添加读取课程 API、从前端调用此 API 的方法以及将容纳课程详情视图的 React 组件来实现单个课程视图功能。
一个读取课程 API
为了在后台实现一个读取课程 API,我们首先声明GET路由和参数处理触发器,如下面的代码所示。
mern-classroom/server/routes/course.routes.js:
router.route('/api/courses/:courseId')
.get(courseCtrl.read)
router.param('courseId', courseCtrl.courseByID)
我们将添加这个GET路由来查询具有 ID 的Course集合,并在响应中返回相应的课程。路由 URL 中的:courseId参数将调用courseByID控制器方法,该方法与userByID控制器方法类似。它从数据库中检索课程,并将其附加到用于next方法的请求对象中,如下面的代码所示。
mern-classroom/server/controllers/course.controller.js:
const courseByID = async (req, res, next, id) => {
try {
let course = await Course.findById(id)
.populate('instructor', '_id name')
if (!course)
return res.status('400').json({
error: "Course not found"
})
req.course = course
next()
} catch (err) {
return res.status('400').json({
error: "Could not retrieve course"
})
}
}
从数据库查询的课程对象还将包含讲师的名称和 ID 详情,正如我们在populate()方法中指定的。在将此课程对象附加到请求对象后调用next()将调用read控制器方法。然后read控制器方法将此course对象作为响应返回给客户端,如下面的代码所示。
mern-classroom/server/controllers/course.controller.js:
const read = (req, res) => {
req.course.image = undefined
return res.json(req.course)
}
在发送响应之前,我们将移除图片字段,因为图片将通过单独的路由作为文件检索。随着后端这个 API 的准备好,你现在可以添加实现,以便在前端调用它,通过在api-course.js中添加一个fetch方法,该方法与其他已经添加的fetch方法类似。我们将使用fetch方法来调用将在渲染课程详情的 React 组件中读取课程 API,正如下一节所讨论的。
课程组件
Course组件将渲染单个课程特定的细节和用户交互,如下面的截图所示:

完成的 Course 组件将包含以下部分:
-
一个显示课程详情的部分,对所有访问者可见。我们将在这个部分实现这一部分。
-
一个
课程
部分,包含课程列表,对所有访问者可见,以及添加新课程的选项,这个选项只对这门课程的讲师可见。我们将在下一节实现课程部分。 -
编辑、删除和发布选项,只有讲师可见。这部分将在本章后面讨论。
-
一个未在上一张图片中显示的
报名
选项,只有在课程被讲师发布后才会可见。这部分将在本章后面实现。
要开始实现此 Course 组件,我们首先将使用 useEffect 钩子通过读取 API 的 fetch 调用检索课程详情,然后我们将设置接收到的值到状态中,如下面的代码所示。
mern-classroom/client/course/Course.js
export default function Course ({match}) {
const [course, setCourse] = useState({instructor:{}})
const [values, setValues] = useState({
error: ''
})
useEffect(() => {
const abortController = new AbortController()
const signal = abortController.signal
read({courseId: match.params.courseId}, signal).then((data) => {
if (data.error) {
setValues({...values, error: data.error})
} else {
setCourse(data)
}
})
return function cleanup(){
abortController.abort()
}
}, [match.params.courseId])
...
}
useEffect 只会在路由参数中的 courseId 发生变化时运行。
在视图中,我们将渲染接收到的详情,例如课程名称、描述、类别、图片以及链接到讲师用户资料的 Material-UI Card 组件,如下面的代码所示:
mern-classroom/client/course/Course.js
<Card>
<CardHeader
title={course.name}
subheader={<div>
<Link to={"/user/"+course.instructor._id}>
By {course.instructor.name}
</Link>
<span>{course.category}</span>
</div>
}
/>
<CardMedia image={imageUrl} title={course.name} />
<div>
<Typography variant="body1">
{course.description}
</Typography>
</div>
</Card>
imageUrl 包含检索课程图片作为文件响应的路由,并且它被构建如下:
mern-classroom/client/course/Course.js
const imageUrl = course._id
? `/api/courses/photo/${course._id}?${new Date().getTime()}`
: '/api/courses/defaultphoto'
当课程讲师登录并查看课程页面时,我们将在 Course 组件中渲染编辑和其他课程数据修改选项。目前,我们只关注如何有条件地将 edit 选项添加到视图代码中:
mern-classroom/client/course/Course.js
{auth.isAuthenticated().user && auth.isAuthenticated().user._id == course.instructor._id &&
(<span><Link to={"/teach/course/edit/" + course._id}>
<IconButton aria-label="Edit" color="secondary">
<Edit/>
</IconButton>
</Link>
</span>)
}
如果当前用户已登录,并且他们的 ID 与课程讲师的 ID 匹配,那么只有在这种情况下才会渲染 编辑
选项。这部分将在接下来的章节中进一步编辑,以便展示发布和删除选项。
为了在前端加载此 Course 组件,我们将在 MainRouter 中添加一个路由,如下所示:
<Route path="/course/:courseId" component={Course}/>
此路由 URL (/course/:courseId) 现在可以添加到任何组件中,以链接到特定的课程,其中 :courseId 参数将被课程 ID 值替换。点击链接将用户带到相应的课程视图。
我们现在已经将相关的后端模型和 API 端点与前端视图集成,这意味着我们已经实现了新课程创建、讲师课程列表和单课程显示功能的运行实现。我们现在可以继续扩展这些实现,让讲师能够为每个课程添加课程,并根据需要更新课程,然后再发布。
更新带有课程的课程
MERN Classroom 中的每个课程都将包含一个课程内容的课程列表,以及学生在注册时需要覆盖的内容。我们将保持课程结构简单,在这个应用程序中,我们将更多关注课程管理实现,并允许学生按顺序完成课程。在接下来的几节中,我们将专注于课程管理的实现,我们还将探讨如何更新现有课程——无论是编辑详情还是删除课程。首先,我们将探讨如何存储课程详情,然后我们将实现全栈功能,允许讲师添加课程、更新课程、更新课程详情以及删除课程。
存储课程
在我们能够存储和检索每个课程的课程详情之前,我们需要定义课程数据结构并将其与课程数据结构关联。
我们将首先定义课程模型,其中包含标题、内容和资源 URL 字段,这些字段都是字符串类型,如下面的代码所示。
mern-classroom/server/models/course.model.js
const LessonSchema = new mongoose.Schema({
title: String,
content: String,
resource_url: String
})
const Lesson = mongoose.model('Lesson', LessonSchema)
这些架构将允许教育者为他们自己的课程创建和存储基本的课程。为了将课程与课程结构集成,我们将在课程模型中添加一个名为 lessons 的字段,该字段将存储一个课程文档数组,如下面的代码所示。
mern-classroom/server/models/course.model.js
lessons: [LessonSchema]
使用这个更新的课程架构和模型,我们现在可以继续实施允许教育者向他们的课程添加课程的实现,如下一节所述。
添加新课程
MERN Classroom 应用程序上的教育者将能够向他们仍在构建且尚未发布的课程中添加新课程。在接下来的几节中,我们将实现这一功能,首先通过实现一个添加课程到现有课程的后端 API,然后创建一个前端表单视图来输入和发送新课程详情,最后在课程页面上显示新添加的课程。
添加课程 API
为了实现一个允许我们为特定课程添加和存储新课程的后端 API,我们首先需要声明如下所示的 PUT 路由:
mern-classroom/server/routes/course.routes.js:
router.route('/api/courses/:courseId/lesson/new')
.put(authCtrl.requireSignin,
courseCtrl.isInstructor,
courseCtrl.newLesson)
当这个路由接收到一个包含课程 ID 的 URL 的 PUT 请求时,我们首先将使用 isInstructor 方法检查当前用户是否是该课程的讲师,然后我们将使用 newLesson 方法将课程保存在数据库中。isInstructor 控制器方法定义如下:
mern-classroom/server/controllers/course.controller.js:
const isInstructor = (req, res, next) => {
const isInstructor = req.course && req.auth &&
req.course.instructor._id == req.auth._id
if(!isInstructor){
return res.status('403').json({
error: "User is not authorized"
})
}
next()
}
使用 isInstructor 方法,我们首先检查已登录用户是否有与给定课程讲师相同的用户 ID。如果用户未授权,则响应中返回错误,否则调用 next() 中间件以执行 newLesson 方法。此 newLesson 控制器方法定义如下:
mern-classroom/server/controllers/course.controller.js:
const newLesson = async (req, res) => {
try {
let lesson = req.body.lesson
let result = await Course.findByIdAndUpdate(req.course._id,
{$push: {lessons: lesson},
updated: Date.now()},
{new: true})
.populate('instructor', '_id name')
.exec()
res.json(result)
} catch (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
在此 newLesson 控制器方法中,我们使用 MongoDB 的 findByIdAndUpdate 来查找相应的课程文档,并通过将请求体中接收的新课程对象推送到其 lessons 数组字段来更新它。
为了在前端访问此 API 以添加新课程,您还需要添加相应的 fetch 方法,就像我们为其他 API 实现所做的那样。
此 API 将用于基于表单的组件,该组件将接收用户对每门新课程的输入,并将其发送到后端。我们将在下一节实现此基于表单的组件。
新课程组件
在每门课程中,当课程尚未发布时,讲师可以通过填写表格来添加课程。为了实现添加新课程的表单视图,我们将创建一个名为 NewLesson 的 React 组件,并将其添加到 Course 组件中。此组件将在课程页面上的对话框中渲染以下表单:

在定义 NewLesson 组件时,我们首先使用 useState 钩子初始化状态中的表单值。此组件还将从 Course 组件接收 props,如下所示。
mern-classroom/client/course/NewLesson.js
export default function NewLesson(props) {
const [open, setOpen] = useState(false)
const [values, setValues] = useState({
title: '',
content: '',
resource_url: ''
})
...
}
NewLesson.propTypes = {
courseId: PropTypes.string.isRequired,
addLesson: PropTypes.func.isRequired
}
NewLesson 组件将从要添加的父组件(在这种情况下为 Course 组件)接收 courseId 值和 addLesson 函数作为 props。我们通过向 NewLesson 添加 PropTypes 验证来使这些 props 成为必需。在表单提交时,这些 props 将在本组件中使用。
接下来,我们将添加一个按钮来切换包含表单的对话框,如下所示。
mern-classroom/client/course/NewLesson.js
<Button aria-label="Add Lesson" color="primary" variant="contained"
onClick={handleClickOpen}>
<Add/> New Lesson
</Button>
<Dialog open={open} onClose={handleClose} aria-labelledby="form-dialog- title">
<div className={classes.form}>
<DialogTitle id="form-dialog-title">Add New Lesson</DialogTitle>
...
<DialogActions>
<Button onClick={handleClose}
color="primary" variant="contained">
Cancel
</Button>
<Button onClick={clickSubmit}
color="secondary" variant="contained">
Add
</Button>
</DialogActions>
</div>
</Dialog>
根据状态变量 open 的状态,Material-UI 的 Dialog 组件保持打开或关闭。我们在以下函数中更新 open 值,这些函数在对话框打开和关闭操作时被调用。
mern-classroom/client/course/NewLesson.js
const handleClickOpen = () => {
setOpen(true)
}
const handleClose = () => {
setOpen(false)
}
在 Dialog 组件内部使用 TextFields 在 DialogContent 中添加了输入新课程标题、内容和资源 URL 值的表单字段,如下所示。
mern-classroom/client/course/NewLesson.js
<DialogContent>
<TextField label="Title" type="text" fullWidth
value={values.title} onChange={handleChange('title')} />
<br/>
<TextField label="Content" type="text" multiline rows="5" fullWidth
value={values.content} onChange={handleChange('content')}/>
<br/>
<TextField label="Resource link" type="text" fullWidth
value={values.resource_url}
onChange={handleChange('resource_url')} />
<br/>
</DialogContent>
输入字段中输入的值将通过以下定义的 handleChange 函数进行捕获:
mern-classroom/client/course/NewLesson.js
const handleChange = name => event => {
setValues({ ...values, [name]: event.target.value })
}
最后,当表单提交时,我们将在clickSubmit函数中将新的课程详情发送到服务器,如下面的代码所示。
mern-classroom/client/course/NewLesson.js
const clickSubmit = () => {
const jwt = auth.isAuthenticated()
const lesson = {
title: values.title || undefined,
content: values.content || undefined,
resource_url: values.resource_url || undefined
}
newLesson({
courseId: props.courseId
}, {
t: jwt.token
}, lesson).then((data) => {
if (data && data.error) {
setValues({...values, error: data.error})
} else {
props.addLesson(data)
setValues({...values, title: '',
content: '',
resource_url: ''})
setOpen(false)
}
})
}
课程详情将通过带有从课程组件接收的课程 ID 作为属性的请求发送到添加课程 API。在服务器成功更新响应后,除了清空表单字段外,还执行了作为属性传递的addLesson更新函数,以在课程组件中渲染最新的课程。从Course组件传递的addLesson函数定义如下:
mern-classroom/client/course/Course.js
const addLesson = (course) => {
setCourse(course)
}
添加到课程组件中的NewLesson组件应该仅在当前用户是课程的讲师且课程尚未发布时才渲染。为了执行此检查和条件渲染NewLesson组件,我们可以在课程组件中添加以下代码:
mern-classroom/client/course/Course.js
{ auth.isAuthenticated().user &&
auth.isAuthenticated().user._id == course.instructor._id &&
!course.published &&
(<NewLesson courseId={course._id} addLesson={addLesson}/>)
}
这将允许应用程序上的教育工作者向他们的课程添加课程。接下来,我们将添加代码以在课程页面上渲染这些课程。
显示课程
特定课程的课程将在课程页面下方以列表形式呈现,包括课程总数,如下面的截图所示:

为了渲染这个课程列表,我们将更新Course组件,使用map函数遍历课程数组,每个课程将在 Material-UI 的ListItem组件中显示,如下面的代码所示。
mern-classroom/client/course/Course.js
<List>
{course.lessons && course.lessons.map((lesson, index) => {
return(<span key={index}>
<ListItem>
<ListItemAvatar>
<Avatar> {index+1} </Avatar>
</ListItemAvatar>
<ListItemText primary={lesson.title} />
</ListItem>
<Divider variant="inset" component="li" />
</span>)
})}
</List>
每个列表项旁边的数字是使用数组的当前索引值计算的。也可以通过访问course.lessons.length来显示课程总数。
既然讲师可以添加和查看每门课程的课程,那么在下一节中,我们将实现更新这些添加的课程的能力,同时修改其他课程详情。
编辑课程
一旦教育工作者添加了课程并且有更多更新要合并,教育工作者将能够以讲师的身份编辑课程的详情。编辑课程包括更新其课程的能力。为了在应用程序中实现这一功能,首先,我们必须创建一个后端 API,允许对特定课程执行更新操作。
然后,需要在前端使用课程及其课程更改后的详细信息来访问这个更新的 API。在接下来的章节中,我们将构建这个后端 API 和EditCourse React 组件,这将允许讲师更改课程详情和课程。
更新课程 API
在后端,我们需要一个 API,允许如果请求用户是给定课程的授权讲师,则更新现有课程。我们首先声明接受客户端更新请求的 PUT 路由,如下所示:
mern-classroom/server/routes/course.routes.js:
router.route('/api/courses/:courseId')
.put(authCtrl.requireSignin, courseCtrl.isInstructor,
courseCtrl.update)
接收到 /api/courses/:courseId 路由的 PUT 请求首先检查已登录用户是否是 URL 中提供的 courseId 关联的课程讲师。如果发现用户是授权的,则调用 update 控制器。课程控制器中的 update 方法定义如下所示。
mern-classroom/server/controllers/course.controller.js:
const update = (req, res) => {
let form = new formidable.IncomingForm()
form.keepExtensions = true
form.parse(req, async (err, fields, files) => {
if (err) {
return res.status(400).json({
error: "Photo could not be uploaded"
})
}
let course = req.course
course = extend(course, fields)
if(fields.lessons){
course.lessons = JSON.parse(fields.lessons)
}
course.updated = Date.now()
if(files.image){
course.image.data = fs.readFileSync(files.image.path)
course.image.contentType = files.image.type
}
try {
await course.save()
res.json(course)
} catch (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
})
}
由于请求体可能包含文件上传,我们在这里使用 formidable 来解析多部分数据。课程数组是一个嵌套对象的数组,我们需要在保存之前将课程数组特别解析并分配给课程。正如我们将在下一节中看到的那样,从前端发送的课程数组在发送之前将被字符串化,因此在这个控制器中,我们需要额外检查是否收到了 lessons 字段,并在解析后单独分配它。
要在前端使用此 API,您需要定义一个 fetch 方法,该方法接受课程 ID、用户认证凭据和更新的课程详情,以便对更新课程 API 进行 fetch 调用——就像我们对其他 API 实现所做的那样。
现在我们有一个可以在前端使用的课程更新 API,我们可以使用它来更新课程的详细信息。我们将在 EditCourse 组件中使用它,接下来将讨论该组件。
The EditCourse component
在前端,我们将添加一个用于编辑课程的视图,它将包含两个部分。第一部分将允许用户更改课程详情,包括名称、类别、描述和图片;第二部分将允许修改课程的课程。以下截图显示了课程的第一个部分:

为了实现此视图,我们将定义一个名为 EditCourse 的 React 组件。此组件将首先通过在 useEffect 钩子中调用 read fetch 方法来加载课程详情,如下面的代码所示。
mern-classroom/client/course/EditCourse.js
useEffect(() => {
const abortController = new AbortController()
const signal = abortController.signal
read({courseId: match.params.courseId}, signal).then((data) => {
if (data.error) {
setValues({...values, error: data.error})
} else {
setCourse(data)
}
})
return function cleanup(){
abortController.abort()
}
}, [match.params.courseId])
在成功接收到响应中的课程数据后,将通过调用 setCourse 将其设置为状态中的 course 变量,并将其用于填充视图。视图的第一部分将渲染与课程视图类似但使用 TextFields 的课程详情,并提供上传新图片和保存按钮以进行更新调用,如下面的代码所示。
mern-classroom/client/course/EditCourse.js
<CardHeader title={<TextField label="Title" type="text" fullWidth
value={course.name} onChange={handleChange('name')}/>}
subheader={<div><Link to={"/user/"+course.instructor._id}>
By {course.instructor.name}
</Link>
{<TextField label="Category" type="text" fullWidth
value={course.category}
onChange={handleChange('category')}/>}
</div>}
action={<Button variant="contained" color="secondary"
onClick={updateCourse}>Save</Button>}
/>
<div className={classes.flex}>
<CardMedia image={imageUrl} title={course.name}/>
<div className={classes.details}>
<TextField multiline rows="5" label="Description" type="text"
value={course.description}
onChange={handleChange('description')} /><br/>
<input accept="image/*"
onChange={handleChange('image')} type="file" />
<label htmlFor="icon-button-file">
<Button variant="outlined" color="secondary" component="span">
Change Photo
<FileUpload/>
</Button>
</label> <span>{course.image ? course.image.name : ''}</span><br/>
</div>
</div>
输入字段的更改将通过 handleChange 方法来处理,该方法定义如下。
mern-classroom/client/course/EditCourse.js
const handleChange = name => event => {
const value = name === 'image'
? event.target.files[0]
: event.target.value
setCourse({ ...course, [name]: value })
}
当点击保存按钮时,我们将获取所有课程详情并将其设置为FormData,然后使用课程更新 API 以多部分格式发送到后端。在保存时调用的clickSubmit函数将定义如下:
mern-classroom/client/course/EditCourse.js
const clickSubmit = () => {
let courseData = new FormData()
course.name && courseData.append('name', course.name)
course.description && courseData.append('description'
, course.description)
course.image && courseData.append('image', course.image)
course.category && courseData.append('category', course.category)
courseData.append('lessons', JSON.stringify(course.lessons))
update({
courseId: match.params.courseId
}, {
t: jwt.token
}, courseData).then((data) => {
if (data && data.error) {
console.log(data.error)
setValues({...values, error: data.error})
} else {
setValues({...values, redirect: true})
}
})
}
课程课程也通过这个FormData发送,但由于课程是以嵌套对象的数组形式存储的,而FormData只接受简单的键值对,所以在分配之前我们需要将lessons值进行字符串化。
为了在前端加载EditCourse,我们需要为其声明一个前端路由。此组件只能由已登录且也是课程讲师的用户查看。因此,我们将在MainRouter组件中添加一个PrivateRoute,这将只为授权用户在/teach/course/edit/:courseId渲染此视图。
mern-marketplace/client/MainRouter.js:
<PrivateRoute path="/teach/course/edit/:courseId" component={EditCourse}/>
此链接添加到课程视图中,以便允许访问EditCourse页面。
我们已经探讨了如何在保存时更新并发送课程详情以及所有课程到后端,但我们还剩下编辑课程课程的接口。在接下来的章节中,我们将通过查看更新课程课程的实现来完成EditCourse组件。
更新课程
为了允许讲师更新他们添加到课程中的课程,我们将在EditCourse组件中添加以下部分,这将允许用户编辑课程详情、重新排列课程的顺序以及删除课程:

这些课程更新功能的实现主要依赖于数组操作技术。在接下来的章节中,我们将添加列表中单个课程的接口,并讨论如何实现编辑、移动和删除功能。
编辑课程详情
用户将能够在EditCourse组件中编辑每个字段的课程详情。在视图中,课程列表中的每个项目将包含三个TextFields,用于课程中的每个字段。这些字段将预先填充现有值,如下面的代码所示。
mern-classroom/client/course/EditCourse.js
<ListItemText
primary={<><TextField label="Title" type="text" fullWidth
value={lesson.title}
onChange={handleLessonChange('title', index)} />
<br/>
<TextField multiline rows="5" label="Content" type="text"
fullWidth value={lesson.content}
onChange={handleLessonChange('content', index)}/>
<br/>
<TextField label="Resource link" type="text" fullWidth
value={lesson.resource_url}
onChange={handleLessonChange('resource_url', index)}/>
<br/>
</>}
/>
为了处理每个字段中值的变化,我们将定义一个handleLessonChange方法,它将接受字段名称和数组中相应课程的索引。handleLessonChange方法将定义如下:
mern-classroom/client/course/EditCourse.js
const handleLessonChange = (name, index) => event => {
const lessons = course.lessons
lessons[index][name] = event.target.value
setCourse({ ...course, lessons: lessons })
}
在课程中,课程数组在设置指定字段中提供的索引的值后更新状态。当用户在EditCourse视图中点击保存时,这个经过修改的课程将包含修改后的课程并保存到数据库。接下来,我们将看看我们如何允许用户重新排列课程的顺序。
移动课程以重新排列顺序
在更新课程时,用户还将能够重新排列列表中的每个课程。除了第一个课程外,每个课程都将有一个向上箭头按钮。此按钮将按以下方式添加到视图中的每个课程项:
mern-classroom/client/course/EditCourse.js
{ index != 0 &&
<IconButton color="primary" onClick={moveUp(index)}>
<ArrowUp />
</IconButton>
}
当用户点击此按钮时,当前索引的课程将被向上移动,而上面的课程将移动到数组中的该位置。moveUp函数将按以下方式实现此行为:
mern-classroom/client/course/EditCourse.js
const moveUp = index => event => {
const lessons = course.lessons
const moveUp = lessons[index]
lessons[index] = lessons[index-1]
lessons[index-1] = moveUp
setCourse({ ...course, lessons: lessons })
}
重新排列的课程数组随后更新到状态中,当用户在EditCourse页面保存更改时,它将被保存到数据库中。接下来,我们将实现从列表中删除课程的功能。
删除课程
在EditCourse页面,渲染在课程列表中的每个项目都将有一个删除选项。删除按钮将按以下方式添加到视图中的每个列表项:
mern-classroom/client/course/EditCourse.js
<ListItemSecondaryAction>
<IconButton edge="end" aria-label="up" color="primary"
onClick={deleteLesson(index)}>
<DeleteIcon />
</IconButton>
</ListItemSecondaryAction>}
当点击删除按钮时,我们将获取正在被删除的课程索引,并将其从lessons数组中移除。当按钮被点击时调用的deleteLesson函数定义如下:
mern-classroom/client/course/EditCourse.js
const deleteLesson = index => event => {
const lessons = course.lessons
lessons.splice(index, 1)
setCourse({...course, lessons:lessons})
}
在此函数中,我们正在通过从给定索引中删除课程来切割数组,然后将更新后的数组添加到状态中的课程中。当用户在EditCourse页面点击保存按钮时,这个新的课程数组将与课程对象一起发送到数据库。
这总结了讲师可以改变其课程的三种不同方式。通过使用与 React 组件特性集成的数组操作技术,用户现在可以编辑细节、重新排列顺序以及删除课程。在下一节中,我们将讨论修改课程所剩下的唯一功能,即从数据库中删除课程的能力。
删除课程
在 MERN Classroom 应用程序中,如果课程尚未发布,讲师将能够永久删除课程。为了允许讲师删除课程,首先,我们将定义一个从数据库中删除课程的后端 API,然后实现一个 React 组件,当用户与前端交互以执行此删除时,将使用此 API。
删除课程 API
为了实现一个后端 API,该 API 接受从数据库中删除指定课程的请求,我们首先定义一个如下所示的 DELETE 路由。
mern-classroom/server/routes/course.routes.js:
router.route('/api/courses/:courseId')
.delete(authCtrl.requireSignin, courseCtrl.isInstructor,
courseCtrl.remove)
此 DELETE 路由接受课程 ID 作为 URL 参数,并在继续到以下代码中定义的remove控制器方法之前,检查当前用户是否已登录并有权执行此删除操作。
mern-classroom/server/controllers/course.controller.js:
const remove = async (req, res) => {
try {
let course = req.course
let deleteCourse = await course.remove()
res.json(deleteCourse)
} catch (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
remove方法简单地从数据库中对应提供的 ID 的课程集合中删除课程文档。要在前端访问此后端 API,你还需要一个具有此路由的 fetch 方法;类似于其他 API 实现。fetch 方法需要接受课程 ID 和当前用户的认证凭据,然后使用这些值调用删除 API。
当用户通过界面上的按钮点击执行删除操作时,将使用 fetch 方法。在下一节中,我们将讨论一个名为DeleteCourse的 React 组件,这是此交互发生的地方。
DeleteCourse组件
当讲师登录并查看未发布的课程时,他们将在课程页面上看到一个删除选项。此删除选项将在名为DeleteCourse的独立 React 组件中实现,并将此组件添加到Course组件中。DeleteCourse组件基本上是一个按钮,当点击时,会打开一个Dialog组件,提示用户确认删除操作,如下面的截图所示:

DeleteCourse组件的实现与第四章中讨论的DeleteUser组件类似,即添加 React 前端以完成 MERN。当DeleteCourse组件添加到Course组件中时,它将接受课程 ID 和从Course组件中获取的onRemove函数定义作为 props,而不是用户 ID,如下面的代码所示:
mern-classroom/client/course/Course.js
<DeleteCourse course={course} onRemove={removeCourse}/>
使用此实现,课程讲师将能够从平台上删除课程。
在本节中,我们通过扩展课程模型并实现课程模型,增加了向课程添加课程的能力。然后,我们添加了必要的后端 API 和用户界面更新,以便添加课程、修改课程详情和课程,以及删除课程和课程。现在课程模块已经准备好,我们可以实现发布课程并使其在应用程序中可供报名的能力。我们将在下一节讨论此发布功能。
发布课程
在 MERN Classroom 中,只有已发布的课程才可供平台上的其他用户报名。一旦讲师创建了课程并更新了课程内容,他们将有发布课程的选择。发布的课程将列在主页上,所有访客都可以查看。在本节的其余部分,我们将探讨允许讲师发布课程并在前端列出这些发布课程的实施。
实现发布选项
每个课程的讲师在至少向课程添加一个课时后,将有权发布他们的课程。发布课程还意味着课程将不能再被删除,无法添加新课时,也无法删除现有课时。因此,当讲师选择发布时,他们将被要求确认此操作。在本节中,我们将探讨如何使用和扩展现有的课程模块以集成此发布功能。
发布按钮状态
在课程视图中,当讲师登录时,他们将根据课程是否可以发布以及是否已经发布,看到“发布”按钮的三个状态,如下所示截图:

此按钮的状态主要取决于课程文档的published属性是否设置为true或false,以及lessons数组的长度。按钮将被添加到Course组件中,如下所示代码:
mern-classroom/client/course/Course.js
{ !course.published ?
(<> <Button color="secondary" variant="outlined"
onClick={clickPublish}>
{ course.lessons.length == 0 ?
"Add atleast 1 lesson to publish"
: "Publish" }
</Button>
<DeleteCourse course={course} onRemove={removeCourse}/>
</>) : (
<Button color="primary"
variant="outlined">Published</Button>
)
}
删除选项只有在课程尚未发布时才会渲染。当点击“发布”按钮时,我们将打开一个对话框,要求用户确认。当按钮被点击时,将调用clickPublish函数,定义如下:
mern-classroom/client/course/Course.js
const clickPublish = () => {
if(course.lessons.length > 0){
setOpen(true)
}
}
clickPublish 函数只有在课程数组长度大于零时才会打开对话框;防止讲师在没有课程的情况下发布课程。接下来,我们将添加对话框,让讲师在确认后发布课程。
确认发布
当讲师点击“发布”按钮时,他们将看到一个对话框,告知他们此操作的结果,并给他们提供发布课程或取消操作的选择。对话框将如下所示:

为了实现此对话框,我们将使用 Material-UI 的Dialog组件,包括标题和内容文本,以及发布和取消按钮,如下所示代码。
mern-classroom/client/course/Course.js
<Dialog open={open} onClose={handleClose} aria-labelledby="form-dialog-title">
<DialogTitle id="form-dialog-title">Publish Course</DialogTitle>
<DialogContent>
<Typography variant="body1">
Publishing your course will make it live to students
for enrollment.
</Typography>
<Typography variant="body1">
Make sure all lessons are added and ready for publishing.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} color="primary" variant="contained">
Cancel
</Button>
<Button onClick={publish} color="secondary" variant="contained">
Publish
</Button>
</DialogActions>
</Dialog>
当用户点击对话框中的“发布”按钮作为确认发布课程时,我们将向后端发出更新 API 调用,将课程的published属性设置为true。定义此更新的publish函数如下:
mern-classroom/client/course/Course.js
const publish = () => {
let courseData = new FormData()
courseData.append('published', true)
update({
courseId: match.params.courseId
}, {
t: jwt.token
}, courseData).then((data) => {
if (data && data.error) {
setValues({...values, error: data.error})
} else {
setCourse({...course, published: true})
setOpen(false)
}
})
}
在此函数中,我们使用的是已经定义并用于从“编辑课程”视图保存其他课程细节修改的相同更新 API。一旦后端成功更新了published值,它也会在Course组件的状态中更新。
在课程中,这个published属性可以用来在Course和EditCourse组件中条件性地隐藏添加新课程、删除课程和删除课程的选项,以防止讲师在课程发布后执行这些操作。由于课程是由讲师发布的,因此这些课程将在平台上的所有用户视图中被列出,如以下章节所述。
列出已发布课程
访问 MERN Classroom 应用程序的所有访客都将能够访问已发布的课程。为了展示这些已发布的课程,我们将添加一个功能来从数据库中检索所有已发布的课程,并在主页上以列表形式显示课程。在接下来的章节中,我们将通过首先定义后端 API 来实现这个功能,该 API 将接收请求并返回已发布课程的列表。然后,我们将实现前端组件,该组件将获取此 API 并渲染课程。
已发布课程 API
为了从数据库中检索已发布课程的列表,我们将在后端实现一个 API,首先声明一个接收 GET 请求的路径'/api/courses/published',如下面的代码所示:
mern-classroom/server/routes/course.routes.js:
router.route('/api/courses/published')
.get(courseCtrl.listPublished)
向此路由发送 GET 请求将调用listPublished控制器方法,该方法启动对具有published属性值为true的课程的查询。然后,结果课程在响应中返回。listPublished控制器方法定义如下:
mern-classroom/server/controllers/course.controller.js:
const listPublished = (req, res) => {
Course.find({published: true}, (err, courses) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
res.json(courses)
}).populate('instructor', '_id name')
}
为了在前端使用这个列表 API,我们还需要在客户端定义一个 fetch 方法,就像我们为所有其他 API 调用所做的那样。然后,这个 fetch 方法将在组件中使用,用于检索并显示已发布的课程。在下一节中,我们将探讨在 React 组件中渲染检索到的课程列表的实现。
课程组件
为了显示已发布课程的列表,我们将设计一个组件,该组件从它所添加的父组件接收课程数组作为 props。在 MERN Classroom 应用程序中,我们将在主页上渲染已发布的课程,如下一张截图所示:

在Home组件中,我们将使用useEffect钩子从后端检索已发布课程的列表,如下面的代码所示:
mern-classroom/client/core/Home.js
useEffect(() => {
const abortController = new AbortController()
const signal = abortController.signal
listPublished(signal).then((data) => {
if (data.error) {
console.log(data.error)
} else {
setCourses(data)
}
})
return function cleanup(){
abortController.abort()
}
}, [])
一旦收到课程列表,它就被设置到状态中的courses变量。当它被添加到Home组件时,我们将这个courses数组作为 props 传递给Courses组件,如下所示:
mern-classroom/client/core/Home.js
<Courses courses={courses} />
此Courses组件将接受这些属性并遍历数组,使用 Material-UI 的GridList组件渲染每个课程。Courses组件的定义如下所示:
mern-classroom/client/course/Courses.js
export default function Courses(props){
return (
<GridList cellHeight={220} cols={2}>
{props.courses.map((course, i) => {
return (
<GridListTile key={i} style={{padding:0}}>
<Link to={"/course/"+course._id}>
<img src={'/api/courses/photo/'+course._id}
alt={course.name} />
</Link>
<GridListTileBar
title={<Link to={"/course/"+course._id}>
{course.name}</Link>}
subtitle={<span>{course.category}</span>}
actionIcon={auth.isAuthenticated() ?
<Enroll courseId={course._id}/> :
<Link to="/signin">
Sign in to Enroll</Link>
}
/>
</GridListTile>)
})}
</GridList>
)
}
Courses.propTypes = {
courses: PropTypes.array.isRequired
}
列表中的每个课程将显示其名称、类别和图片,并将链接到单个课程页面。将实现一个独立的组件来显示每个课程的注册选项,但仅对已登录并浏览主页的用户显示。
由于现在讲师可以发布课程,所有应用程序的访客都可以查看,我们可以开始实施课程注册功能。
注册课程
MERN Classroom 应用程序的所有访客都有登录并注册任何已发布课程的选项。注册课程将使他们能够访问课程详情,并允许他们系统地学习课程。为了实现此功能,在本节中,我们首先定义一个注册模型来在数据库中存储注册详情。然后,我们将添加后端 API,以便当最终用户与将要添加到前端的前端Enroll组件交互时创建新的注册。最后,我们将实现一个视图,使学生能够查看和与其注册的课程内容进行交互。
定义注册模型
我们将定义一个注册模式(schema)和模型来存储应用程序中每个注册的详情。它将包含存储正在注册的课程引用和作为学生注册的用户引用的字段。它还将存储与相关课程中的课程相对应的数组,该数组将存储每个课程对该学生的完成状态。此外,我们还将存储三个时间戳值;第一个值将表示学生何时注册,第二个值将表示他们上次完成课程或更新注册的时间,最后,当他们完成课程时。此注册模型将在server/models/enrollment.model.js中定义,以下列表中给出了定义注册字段的代码及其说明:
- 课程引用:
course字段将存储与此次注册关联的课程文档的引用:
course: {
type: mongoose.Schema.ObjectId,
ref: 'Course'
}
- 学生引用:
student字段将存储创建此注册的用户引用,该用户通过选择注册课程来完成注册:
student: {
type: mongoose.Schema.ObjectId,
ref: 'User'
}
- 课程状态:
lessonStatus字段将存储一个数组,其中包含对存储在相关课程lessons数组中每个课程的引用。对于lessonStatus数组中的每个对象,我们将添加一个complete字段,该字段将存储一个布尔值,表示相应的课程是否已完成:
lessonStatus: [{
lesson: {type: mongoose.Schema.ObjectId, ref: 'Lesson'},
complete: Boolean
}]
- 注册时间:
enrolled字段将是一个表示注册创建时间的Date值;换句话说,当学生注册课程时:
enrolled: {
type: Date,
default: Date.now
}
- 更新时间:
updated字段将是另一个Date值,每次完成一个课时都会更新,指示用户上次在课程课时上工作的日期:
updated: Date
- 完成时间:
completed字段也将是Date类型,它只会在课程中的所有课程都完成时设置:
completed: Date
在此模式定义中的字段将使我们能够实现 MERN Classroom 中的所有注册相关功能。在下一节中,我们将实现用户注册课程的 功能,并使用此注册模型存储注册的详细信息。
创建注册 API
当用户选择注册课程时,我们将创建一个新的注册并将其存储在后端。为了实现此功能,我们需要在服务器上定义一个创建注册的 API,首先声明一个接受 POST 请求的路线 '/api/enrollment/new/:courseId',如下面的代码所示:
mern-classroom/server/routes/enrollment.routes.js:
router.route('/api/enrollment/new/:courseId')
.get(authCtrl.requireSignin, enrollmentCtrl.findEnrollment, enrollmentCtrl.create)
router.param('courseId', courseCtrl.courseByID)
此路由在 URL 中接受课程 ID 作为参数。因此,我们还添加了来自课程控制器的 courseByID 控制器方法,以处理此参数并从数据库中检索相应的课程。从客户端请求中发起请求的用户通过请求中发送的用户身份验证凭据进行识别。在此路由接收到的 POST 请求将首先检查用户是否已认证,然后检查他们是否已经注册了此课程,在为该用户在此课程中创建新的注册之前。
findEnrollment 控制器方法将查询数据库中的 Enrollments 集合,以检查是否存在具有给定课程 ID 和用户 ID 的注册。findEnrollment 方法定义如下。
mern-classroom/server/controllers/enrollment.controller.js:
const findEnrollment = async (req, res, next) => {
try {
let enrollments = await Enrollment.find({course:req.course._id,
student: req.auth._id})
if(enrollments.length == 0){
next()
}else{
res.json(enrollments[0])
}
} catch (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
如果查询返回匹配的结果,则将返回的结果注册发送回响应,否则将调用 create 控制器方法来创建一个新的注册。
create 控制器方法从给定的课程引用、用户引用和课程中的课程数组生成要保存到数据库中的新注册对象。create 方法定义如下。
mern-classroom/server/controllers/enrollment.controller.js:
const create = async (req, res) => {
let newEnrollment = {
course: req.course,
student: req.auth,
}
newEnrollment.lessonStatus = req.course.lessons.map((lesson)=>{
return {lesson: lesson, complete:false}
})
const enrollment = new Enrollment(newEnrollment)
try {
let result = await enrollment.save()
return res.status(200).json(result)
} catch (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
在 course 中的 lessons 数组被迭代以生成新注册文档的 lessonStatus 对象数组。lessonStatus 数组中的每个对象都将 complete 值初始化为 false。基于这些值成功保存新注册文档后,新文档将发送回响应。
为注册 API 定义的 所有路由,例如此创建 API,都在 enrollment.routes.js 文件中声明,并且它将类似于我们应用程序中已经创建的其他路由文件。与其他路由一样,我们需要通过在 express.js 中挂载注册路由来将这些新路由加载到 Express 应用中。注册相关路由的挂载方式如下。
mern-social/server/express.js:
app.use('/', enrollmentRoutes)
要在前端访问创建 API,您还需要定义一个类似于应用程序中已定义的其他 fetch 方法的 fetch 方法。使用此 fetch 方法,下一节中讨论的 Enroll 组件将能够调用此创建注册 API。
注册组件
Enroll 组件将简单地包含一个按钮,该按钮将启动对后端的注册调用,并在服务器成功返回新的注册文档 ID 时重定向用户。此组件从添加它的父组件接收相关课程的 ID 作为属性。此属性将在创建注册 API 调用时使用。Enroll 组件的定义如下所示。
mern-classroom/client/enrollment/Enroll.js:
export default function Enroll(props) {
const [values, setValues] = useState({
enrollmentId: '',
error: '',
redirect: false
})
if(values.redirect){
return (<Redirect to={'/learn/'+values.enrollmentId}/>)
}
return (
<Button variant="contained" color="secondary"
onClick={clickEnroll}> Enroll </Button>
)
当点击 ENROLL 按钮时,将使用提供的课程 ID 获取创建注册 API,以检索现有注册或创建新的注册并在响应中接收它。当按钮被点击时将调用的 clickEnroll 函数定义如下:
mern-classroom/client/enrollment/Enroll.js:
const clickEnroll = () => {
const jwt = auth.isAuthenticated()
create({
courseId: props.courseId
}, {
t: jwt.token
}).then((data) => {
console.log(data)
if (data && data.error) {
setValues({...values, error: data.error})
} else {
setValues({...values, enrollmentId: data._id, redirect: true})
}
})
}
当服务器成功返回注册时,用户将被重定向到将显示特定注册详情的视图。
由于 Enroll 组件从父组件接收课程 ID 作为属性,因此我们还在组件中添加了 PropType 验证(如下所示),因为其功能和实现依赖于传递此属性。
mern-classroom/client/enrollment/Enroll.js:
Enroll.propTypes = {
courseId: PropTypes.string.isRequired
}
当服务器在 API 调用中成功响应时,用户将被重定向到已注册课程视图,在那里他们可以浏览课程内容。我们将在下一节中实现此视图。
已注册课程视图
对于用户注册的每门课程,他们都会看到一个视图,列出课程的详细信息以及课程中的每个课程;并可以选择完成每个课程。在以下章节中,我们将通过首先添加返回给定注册详情的后端 API 来实现此视图,然后在前端使用此 API 构建已注册课程视图。
读取注册 API
将返回数据库中注册详情的后端 API 将被定义为接受请求在 '/api/enrollment/:enrollmentId' 的 GET 路由,并如下声明:
mern-classroom/server/routes/enrollment.routes.js
router.route('/api/enrollment/:enrollmentId')
.get(authCtrl.requireSignin, enrollmentCtrl.isStudent,
enrollmentCtrl.read)
router.param('enrollmentId', enrollmentCtrl.enrollmentByID)
在此路由上的 GET 请求将首先调用enrollmentByID方法,因为它在 URL 声明中包含enrollmentId参数。enrolmentByID方法将根据提供的 ID 查询Enrollments集合,如果找到匹配的注册文档,我们将使用 Mongoose 的populate方法确保引用的课程、嵌套的课程讲师和引用的学生详情也被填充。enrollmentByID控制器方法定义如下所示:
mern-classroom/server/controllers/enrollment.controller.js:
const enrollmentByID = async (req, res, next, id) => {
try {
let enrollment = await Enrollment.findById(id)
.populate({path: 'course', populate:{
path: 'instructor'}})
.populate('student', '_id name')
if (!enrollment)
return res.status('400').json({
error: "Enrollment not found"
})
req.enrollment = enrollment
next()
} catch (err) {
return res.status('400').json({
error: "Could not retrieve enrollment"
})
}
}
结果的注册对象附加到请求对象,并传递给下一个控制器方法。在将此注册对象返回给客户端之前,我们将在isStudent方法中检查当前登录的用户是否与该特定注册关联的学生,正如以下代码中定义的那样。
mern-classroom/server/controllers/enrollment.controller.js:
const isStudent = (req, res, next) => {
const isStudent = req.auth && req.auth._id ==
req.enrollment.student._id
if (!isStudent) {
return res.status('403').json({
error: "User is not enrolled"
})
}
next()
}
isStudent方法检查通过请求中发送的认证凭据识别的用户是否与注册中引用的学生匹配。如果两个用户不匹配,则返回带有错误信息的 403 状态,否则,将调用下一个控制器方法以返回注册对象。下一个控制器方法是read方法,其定义如下:
mern-classroom/server/controllers/enrollment.controller.js:
const read = (req, res) => {
return res.json(req.enrollment)
}
要在前端使用此读取注册 API,您还需要定义一个相应的 fetch 方法,如在本应用程序中实现的所有其他 API 一样。然后,此 fetch 方法将用于检索要在学生交互的 React 组件中渲染的注册详情。我们将在下一节中实现此Enrollment组件。
注册组件
Enrollment组件将加载从读取注册 API 接收到的课程和课程详情。在这个视图中,学生将能够遍历课程中的每一课,并标记为完成。课程标题将列在抽屉中,给学生一个关于课程包含的内容以及他们已经进展到哪里的整体概念。抽屉中的每一项都会展开以显示课程的详细信息,如下面的截图所示:

要实现此视图,首先,我们需要在useEffect钩子中调用读取注册 API 的 fetch 调用,以检索注册详情并将其设置到状态中,如下面的代码所示。
mern-classroom/client/enrollment/Enrollment.js:
export default function Enrollment ({match}) {
const [enrollment, setEnrollment] = useState({course:{instructor:[]},
lessonStatus: []})
const [values, setValues] = useState({
redirect: false,
error: '',
drawer: -1
})
const jwt = auth.isAuthenticated()
useEffect(() => {
const abortController = new AbortController()
const signal = abortController.signal
read({enrollmentId: match.params.enrollmentId},
{t: jwt.token}, signal).then((data) => {
if (data.error) {
setValues({...values, error: data.error})
} else {
setEnrollment(data)
}
})
return function cleanup(){
abortController.abort()
}
}, [match.params.enrollmentId])
....
我们将使用 Material-UI 的Drawer组件来实现抽屉布局。在抽屉中,我们保留第一个项目为课程概览,这将使用户了解课程详情,类似于单个课程页面。当用户进入此报名视图时,他们将首先看到课程概览。
在以下代码中,在添加此第一个抽屉项目后,我们为课程创建了一个单独的章节,其中遍历lessonStatus数组以在抽屉中列出课程标题。
mern-classroom/client/enrollment/Enrollment.js:
<Drawer variant="permanent">
<div className={classes.toolbar} />
<List>
<ListItem button onClick={selectDrawer(-1)}
className={values.drawer == -1 ?
classes.selectedDrawer : classes.unselected}>
<ListItemIcon><Info /></ListItemIcon>
<ListItemText primary={"Course Overview"} />
</ListItem>
</List>
<Divider />
<List>
<ListSubheader component="div">
Lessons
</ListSubheader>
{enrollment.lessonStatus.map((lesson, index) => (
<ListItem button key={index} onClick={selectDrawer(index)}
className={values.drawer == index ?
classes.selectedDrawer : classes.unselected}>
<ListItemAvatar>
<Avatar> {index+1} </Avatar>
</ListItemAvatar>
<ListItemText
primary={enrollment.course.lessons[index].title} />
<ListItemSecondaryAction> { lesson.complete ?
<CheckCircle/> : <RadioButtonUncheckedIcon />}
</ListItemSecondaryAction>
</ListItem>
))}
</List>
<Divider />
</Drawer>
抽屉中“课程”部分的每个项目也将使用户能够直观地了解课程是否已完成,或者是否尚未完成。这些勾选或未勾选的图标将根据lessonStatus数组中每个项目的complete字段的布尔值进行渲染。
为了确定当前选中的抽屉,我们将使用初始化的drawer值到状态中,值为-1。-1 值将与课程概览抽屉项目和视图相关联,而lessonStatus中每个项目的索引将确定从抽屉中选择时显示哪个课程。当点击抽屉项目时,我们将调用selectDrawer方法,将其-1 或被点击课程的索引作为其参数。selectDrawer方法定义如下:
mern-classroom/client/enrollment/Enrollment.js:
const selectDrawer = (index) => event => {
setValues({...values, drawer:index})
}
此selectDrawer方法根据抽屉中点击的项目设置状态中的drawer值。实际的内容视图也将根据以下结构有条件地渲染:
{ values.drawer == - 1 && (Overview of course) }
{ values.drawer != - 1 && (Individual lesson content based on the index value represented in drawer) }
课程概览部分可以根据课程页面进行设计和实现。为了渲染单个课程的详细信息,我们可以使用以下Card组件:
mern-classroom/client/enrollment/Enrollment.js:
{values.drawer != -1 && (<>
<Typography variant="h5">{enrollment.course.name}</Typography>
<Card> <CardHeader
title={enrollment.course.lessons[values.drawer].title}
/>
<CardContent>
<Typography variant="body1">
{enrollment.course.lessons[values.drawer].content}
</Typography>
</CardContent>
<CardActions>
<a href={enrollment.course.lessons[values.drawer].resource_url}>
<Button variant="contained" color="primary">
Resource Link</Button>
</a>
</CardActions>
</Card>
</>
)}
这将渲染所选课程的详细信息,包括标题、内容和资源 URL 值。通过这种实现,我们现在有了一种让用户报名课程并查看他们报名详情的方法。这种报名数据最初是从课程详情创建的,但也会存储特定于报名学生的详细信息,以及他们在课程和课程整体中的进度。为了能够记录和跟踪这种进度,并向学生和教师显示相关的统计信息,我们将在下一节进一步更新此实现以添加这些功能。
跟踪进度和报名统计
在 MERN Classroom 这样的教室应用程序中,让学生可视化他们在报名课程中的进度,并让教师看到有多少学生报名并完成了他们的课程,这可能非常有价值。
在这个应用中,一旦学生报名参加一门课程,他们就能逐个完成课程中的每一课,并标记为完成,直到所有课程都完成,整个课程才算完成。应用会提供视觉提示,让学生知道他们在课程中的报名状态。对于讲师来说,一旦他们发布了一门课程,我们会显示报名该课程的学生总数,以及完成该课程的学生总数。
在以下章节中,我们将实现这些功能,从让用户完成课程并跟踪他们在课程中的进度开始,然后列出他们的报名,带有哪些已完成和哪些正在进行中的指示,最后显示每个发布的课程的报名统计数据。
完成课程
我们必须扩展报名 API 和报名视图实现,以便学生首先完成课程,然后完成整个课程。我们将在后端添加一个课程完成 API,并在前端使用这个 API 在用户执行此操作时标记课程为完成。在以下章节中,我们将添加这个 API,然后修改Enrollment组件以使用这个 API,并视觉上指示哪些课程已完成。
完成的课程 API
我们将在后端为报名添加一个complete API 端点,该端点将标记指定的课程为完成,当所有课程都完成时,也会标记报名的课程为完成。为了实现这个 API,我们将首先声明一个 PUT 路由,如下面的代码所示:
mern-classroom/server/routes/enrollment.routes.js
router.route('/api/enrollment/complete/:enrollmentId')
.put(authCtrl.requireSignin,
enrollmentCtrl.isStudent,
enrollmentCtrl.complete)
当接收到'/api/enrollment/complete/:enrollmentId' URL 的 PUT 请求时,我们首先确保已登录的用户是与这个报名记录关联的学生,然后我们将调用complete报名控制器方法。complete方法定义如下:
mern-classroom/server/controllers/enrollment.controller.js
const complete = async (req, res) => {
let updatedData = {}
updatedData['lessonStatus.$.complete']= req.body.complete
updatedData.updated = Date.now()
if(req.body.courseCompleted)
updatedData.completed = req.body.courseCompleted
try {
let enrollment = await
Enrollment.updateOne({'lessonStatus._id':
req.body.lessonStatusId},
{'$set': updatedData})
res.json(enrollment)
} catch (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
在这个complete方法中,我们使用 MongoDB 的updateOne操作来更新包含对应于请求中提供的lessonStatusId值的lessonStatus对象的报名文档。
在生成的报名文档中,我们更新lessonStatus数组中特定对象的complete字段和报名文档的updated字段。如果请求中发送了courseCompleted值,我们也会更新报名文档中的completed字段。一旦报名文档更新成功,它就会作为响应发送回去。
要在前端使用这个 complete API 端点,你还需要定义一个相应的获取方法,就像我们为其他 API 实现所做的那样。这个获取方法应该向完成注册路由发送一个 PUT 请求,并将相关值作为请求发送。如下一节所述,我们将在这个 Enrollment 组件中使用这个实现的 API,以便允许学生完成课程。
视图中的完成课程
在 Enrollment 组件中,我们在抽屉视图中渲染每个课程的详细信息,我们将给学生提供标记课程为完成的选项。这个选项将根据给定的课程是否已经完成而条件性地渲染。这个选项将被添加到 CardHeader 的 action 属性中,如下面的代码所示:
mern-classroom/client/enrollment/Enrollment.js:
action={<Button
onClick={markComplete}
variant={enrollment.lessonStatus[values.drawer].complete ?
'contained' : 'outlined'} color="secondary">
{enrollment.lessonStatus[values.drawer].complete ?
"Completed" : "Mark as complete"}
</Button>}
如果给定的 lessonStatus 对象中的 complete 属性设置为 true,则渲染一个填充的按钮,上面写着“已完成”,否则渲染一个带有“标记为完成”文本的轮廓按钮。点击此按钮将调用 markComplete 函数,该函数将发出 API 调用来更新数据库中的注册。这个 markComplete 函数定义如下:
mern-classroom/client/enrollment/Enrollment.js:
const markComplete = () => {
if(!enrollment.lessonStatus[values.drawer].complete){
const lessonStatus = enrollment.lessonStatus
lessonStatus[values.drawer].complete = true
let count = totalCompleted(lessonStatus)
let updatedData = {}
updatedData.lessonStatusId = lessonStatus[values.drawer]._id
updatedData.complete = true
if(count == lessonStatus.length){
updatedData.courseCompleted = Date.now()
}
complete({
enrollmentId: match.params.enrollmentId
}, {
t: jwt.token
}, updatedData).then((data) => {
if (data && data.error) {
setValues({...values, error: data.error})
} else {
setEnrollment({...enrollment, lessonStatus: lessonStatus})
}
})
}
}
在这个函数中,在向后端发出 API 调用之前,我们在 updatedData 对象中准备要随请求发送的值。我们发送 lessonStatus 的详细信息,包括用户完成的课程的 ID 值和设置为 true 的 complete 值。我们还计算完成课程的总数是否等于课程总数,这样我们就可以在请求中设置并发送 courseCompleted 值。
完成课程的总数是通过 totalCompleted 函数计算的,该函数定义如下:
mern-classroom/client/enrollment/Enrollment.js:
const totalCompleted = (lessons) => {
let count = lessons.reduce((total, lessonStatus) => {
return total + (lessonStatus.complete ? 1 : 0)}, 0)
setTotalComplete(count)
return count
}
我们使用数组 reduce 函数来查找并统计 lessonStatus 数组中完成课程的计数。这个计数值也存储在状态中,以便可以在抽屉底部的视图中渲染,如下面的截图所示:

学生的课程旁边将有一个勾选图标,作为指示哪些课程已完成或未完成。我们还给学生一个总数,显示完成课程的数量。当所有课程都完成时,课程被认为是完成的。这让学生对自己的课程进度有一个概念。接下来,我们将添加一个功能,允许用户查看他们注册的所有课程的状况。
列出用户的全部注册
一旦学生登录到 MERN Classroom,他们就能在主页上查看他们所有注册的列表。为了实现这个功能,我们首先定义一个后端 API,它返回给定用户的注册列表,然后在前端使用它来向用户渲染注册列表。
注册列表 API
注册列表 API 将接收一个 GET 请求并查询 Enrollments 集合,以找到具有与学生参考匹配的当前登录用户的注册。为了实现这个 API,我们首先声明 '/api/enrollment/enrolled' 的 GET 路由,如下面的代码所示:
mern-classroom/server/routes/enrollment.routes.js
router.route('/api/enrollment/enrolled')
.get(authCtrl.requireSignin, enrollmentCtrl.listEnrolled)
对这个路由的 GET 请求将调用 listEnrolled 控制器方法,该方法将查询数据库并将结果作为响应返回给客户端。listEnrolled 方法定义如下:
mern-classroom/server/controllers/enrollment.controller.js
const listEnrolled = async (req, res) => {
try {
let enrollments = await Enrollment.find({student: req.auth._id})
.sort({'completed': 1})
.populate('course', '_id name category')
res.json(enrollments)
} catch (err) {
console.log(err)
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
对 Enrollments 集合的查询将找到所有与学生参考匹配的用户 ID 相匹配的注册。结果注册将被填充有引用课程的名称和类别值,并且列表将被排序,以便完成的注册放在未完成的注册之后。
通过在客户端为这个 API 定义相应的获取方法,我们可以在将渲染这些注册的 React 组件中调用它。我们将在下一节中查看这个组件的实现。
注册组件
Enrollments 组件将在主页上渲染,并且它将从 Home 组件接收注册列表作为属性。接收到的注册列表将在这个组件中按顺序渲染,以便向用户展示他们已注册的课程。我们还将使用每个状态的代表性图标来指示列表中的注册课程是否已完成,或正在进行中,如下面的截图所示:

这个列出注册的视图将非常类似于 Courses 组件,该组件列出已发布的课程。在 Enrollments 中,而不是课程,将迭代从 Home 组件接收到的注册以渲染每个注册,如下面的代码所示:
mern-classroom/client/enrollment/Enrollments.js
{props.enrollments.map((course, i) => (
<GridListTile key={i}>
<Link to={"/learn/"+course._id}>
<img src={'/api/courses/photo/'+course.course._id}
alt= {course.course.name} />
</Link>
<GridListTileBar
title={<Link to={"/learn/"+course._id}>{course.course.name}</Link>}
actionIcon={<div> {course.completed ?
(<CompletedIcon color="secondary"/>)
: (<InProgressIcon/>)
}
</div>}
/>
</GridListTile>
))}
根据单个注册是否已经有一个 complete 日期值,我们将有条件地渲染图标。这将使用户了解他们已经完成的注册课程,以及他们尚未完成的课程。
既然我们已经实现了允许应用程序中的学生注册课程、完成课程和跟踪进度的功能,我们也可以通过扩展这些实现来提供关于课程的注册统计信息,正如我们接下来将要看到的。
报名统计信息
一旦讲师发布课程,并且应用程序中的其他用户开始报名并完成课程中的课程,我们将显示课程的报名总数和课程完成数作为简单的报名统计信息。为了实现此功能,在以下章节中,我们首先实现一个返回报名统计信息的 API,然后展示这些统计信息在视图中的显示。
报名统计 API
为了实现一个将查询数据库中的Enrollments集合以计算特定课程统计信息的后端 API,我们首先需要在'/api/enrollment/stats/:courseId'上声明一个 GET 路由,如下所示。
mern-classroom/server/routes/enrollment.routes.js
router.route('/api/enrollment/stats/:courseId')
.get(enrollmentCtrl.enrollmentStats)
在此 URL 上的 GET 请求将返回一个stats对象,其中包含课程的全部报名和全部完成数,这些是通过 URL 参数中提供的courseId确定的。此实现定义在enrollmentStats控制器方法中,如下所示。
mern-classroom/server/controllers/enrollment.controller.js
const enrollmentStats = async (req, res) => {
try {
let stats = {}
stats.totalEnrolled = await Enrollment.find({course:req.course._id})
.countDocuments()
stats.totalCompleted = await Enrollment.find({course:req.course._id})
.exists('completed', true)
.countDocuments()
res.json(stats)
} catch (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
在这个enrollmentStats方法中,我们使用请求中提供的课程 ID 对Enrollments集合执行两个查询。在第一个查询中,我们简单地找到给定课程的全部报名,并使用 MongoDB 的countDocuments()对这些结果进行计数。在第二个查询中,我们找到给定课程的全部报名,并检查这些报名中是否存在completed字段。然后我们最终得到这些结果的计数。这些数字将作为响应发送回客户端。
与其他 API 实现类似,您还需要在客户端定义相应的 fetch 方法,该方法将向此路由发出 GET 请求。使用此 fetch 方法,我们将检索并显示每个已发布课程的这些统计信息,如下一节所述。
显示已发布课程的报名统计信息
报名统计信息可以从后端检索并在课程视图中渲染,如下所示:

为了检索这些报名统计信息,我们将在Course组件中添加第二个useEffect钩子,以便对报名统计 API 进行 fetch 调用,如下所示:
mern-classroom/client/course/Course.js
useEffect(() => {
const abortController = new AbortController()
const signal = abortController.signal
enrollmentStats({courseId: match.params.courseId},
{t:jwt.token}, signal).then((data) => {
if (data.error) {
setValues({...values, error: data.error})
} else {
setStats(data)
}
})
return function cleanup(){
abortController.abort()
}
}, [match.params.courseId])
这将接收给定课程的报名统计信息,并将其设置到状态中的stats变量,我们可以在视图中渲染它,如下所示:
mern-classroom/client/course/Course.js
{course.published &&
(<div> <span> <PeopleIcon /> {stats.totalEnrolled} enrolled </span>
<span> <CompletedIcon/> {stats.totalCompleted} completed </span>
</div>)
}
在将此功能添加到课程组件后,任何正在浏览 MERN Classroom 应用程序中课程的访客,应用程序中的已发布课程将看起来如下所示:

这张课程页面截图,包含了课程详情、报名选项和报名统计信息,成功地捕捉了我们为了实现这一视图而在本章中实现的所有功能。一个注册了教室应用的用户成为了教育者,创建并发布了这个课程,其中包含课程内容。然后,其他用户报名参加了课程并完成了课程内容,从而生成了报名统计信息。我们只是简单地扩展了 MERN 框架应用,添加了更多模型、API 和 React 前端组件,这些组件检索并渲染了接收到的数据,以构建一个完整的教室应用。
摘要
在本章中,我们通过扩展框架应用开发了名为 MERN Classroom 的简单在线教室应用。我们集成了允许用户拥有多个角色的功能,包括教育者和学生;作为讲师添加和发布包含课程内容的课程;作为学生报名课程并完成课程内容;以及跟踪课程完成进度和报名统计信息。
在实现这些功能的过程中,我们练习了如何扩展构成前端-后端同步应用的全栈组件切片。我们通过实现数据架构和模型、添加新的后端 API 以及将这些 API 与前端的新 React 组件集成来添加新功能,从而完成全栈切片。通过逐步构建这个应用,从较小的实现单元到复杂和组合功能,你现在应该对如何结合 MERN 基础的全栈应用的不同部分有了更好的理解。
为了学习如何整合更加复杂的功能,并找到在使用此堆栈开发高级现实世界应用时可能遇到的棘手问题的解决方案,我们将在下一章开始构建一个基于 MERN 的、功能丰富的在线市场应用。
第十章:通过在线市场锻炼 MERN 技能
随着越来越多的业务在网上进行,在线市场环境中买卖的能力已成为许多网络平台的核心需求。在本章和接下来的两章中,我们将利用 MERN 技术栈开发一个具有用户买卖功能的在线市场应用程序。
我们将为这个应用程序构建从简单到高级的所有功能,从本章开始,我们将重复前几章中学到的全栈开发经验,为市场平台打下基础。我们将通过支持卖家账户和带有产品的商店来扩展 MERN 框架应用程序,逐步集成市场功能,如产品搜索和建议。到本章结束时,你将更好地掌握如何扩展、集成和组合全栈实现的各个方面,以向你的应用程序添加复杂功能。
在本章中,我们将通过以下主题开始构建在线市场:
-
介绍 MERN 市场应用程序
-
拥有卖家账户的用户
-
在市场上添加商店
-
向商店添加产品
-
通过名称和类别搜索产品
介绍 MERN 市场应用程序
MERN 市场应用程序将允许用户成为卖家,他们可以管理多个商店并在每个商店中添加他们想要出售的产品。访问 MERN 市场的用户将能够搜索和浏览他们想要购买的产品,并将产品添加到购物车中下订单。最终的市场应用程序将如以下截图所示:

完整的 MERN 市场应用程序的代码可在 GitHub 上找到,网址为github.com/PacktPublishing/Full-Stack-React-Projects-Second-Edition/tree/master/Chapter07%20and%2008/mern-marketplace。本章和下一章中讨论的实现可以在存储库的 shop-cart-order-pay 分支中访问。你可以在阅读本章剩余部分的代码解释时克隆此代码并运行应用程序。
在本章中,我们将扩展 MERN 框架以构建一个简单的在线市场版本,从以下功能开始:
-
拥有卖家账户的用户
-
店铺管理
-
产品管理
-
通过名称和类别搜索产品
与卖家账户、商店和产品相关的功能所需视图将通过扩展和修改 MERN 框架应用程序中现有的 React 组件来开发。下面所示的组件树展示了本章开发的 MERN 市场前端的所有自定义 React 组件:

我们将添加新的 React 组件来实现管理商店和产品以及浏览和搜索产品的视图。我们还将修改现有的组件,如 EditProfile、Menu 和 Home 组件,将骨架代码开发成市场应用程序,正如我们在本章的其余部分构建不同功能时那样。这些市场功能将取决于用户将他们的账户更新为卖家账户的能力。在下一节中,我们将通过更新现有的用户实现来启用卖家账户功能,开始构建 MERN 市场应用程序。
允许用户成为卖家
任何在 MERN 市场应用程序上有账户的用户都有将他们的账户更新为卖家账户的选项,通过更改他们的个人资料来实现。我们将在编辑个人资料
页面添加此选项以转换为卖家账户,如下面的截图所示:

拥有活跃卖家账户的用户将被允许创建和管理他们自己的商店,在那里他们可以管理产品。普通用户将无法访问卖家仪表板,而拥有活跃卖家账户的用户将在菜单上看到一个指向他们仪表板的链接,显示为“我的商店”。以下截图显示了普通用户与拥有活跃卖家账户的用户在菜单上的区别:

要添加此卖家账户功能,我们需要更新用户模型、编辑个人资料视图,并在菜单中添加一个仅对卖家可见的“我的商店”链接,如以下各节所述。
更新用户模型
我们需要存储有关每个用户的额外详细信息,以确定用户是否是活跃的卖家。我们将更新我们在第三章,《使用 MongoDB、Express 和 Node 构建后端》中开发的用户模型,以添加一个默认设置为false的seller值来表示普通用户,并且可以额外设置为true来表示也是卖家的用户。我们将更新现有的用户模式以添加此seller字段,如下面的代码所示:
mern-marketplace/server/models/user.model.js:
seller: {
type: Boolean,
default: false
}
对于每个用户的此seller值必须在成功登录后发送给客户端,以便视图可以根据显示与卖家相关的信息进行渲染。我们将在signin控制器方法中更新返回的响应,以添加此详细信息,如下面的代码所示:
mern-marketplace/server/controllers/auth.controller.js:
...
return res.json({
token,
user: {
_id: user._id,
name: user.name,
email: user.email,
seller: user.seller
}
})
...
}
使用此seller字段值,我们可以根据仅允许卖家账户的授权来渲染前端。在根据卖家授权渲染视图之前,我们首先需要在EditProfile视图中实现激活卖家账户功能的选项,如下一节所述。
更新编辑个人资料视图
已登录用户将在编辑个人资料视图中看到一个切换按钮,允许他们激活或停用卖家功能。我们将更新EditProfile组件,在FormControlLabel中添加一个Material-UI Switch组件,如下面的代码所示:
mern-marketplace/client/user/EditProfile.js:
<Typography variant="subtitle1" className={classes.subheading}>
Seller Account
</Typography>
<FormControlLabel
control={<Switch
checked={values.seller}
onChange={handleCheck}
/>}
label={values.seller? 'Active' : 'Inactive'}
/>
任何对开关的更改将通过调用handleCheck方法设置为状态中seller的值。handleCheck方法的实现如下所示:
mern-marketplace/client/user/EditProfile.js:
const handleCheck = (event, checked) => {
setValues({...values, 'seller': checked})
}
当提交编辑个人资料详情的表单时,seller值也将添加到发送给服务器的更新详情中,如下面的代码所示:
mern-marketplace/client/user/EditProfile.js:
const clickSubmit = () => {
const jwt = auth.isAuthenticated()
const user = {
name: values.name || undefined,
email: values.email || undefined,
password: values.password || undefined,
seller: values.seller || undefined
}
update({
userId: match.params.userId
}, {
t: jwt.token
}, user).then((data) => {
if (data && data.error) {
setValues({...values, error: data.error})
} else {
auth.updateUser(data, ()=>{
setValues({...values, userId: data._id, redirectToProfile: true})
})
}
})
}
在成功更新后,用于认证目的存储在sessionStorage中的用户详情也应更新。通过调用auth.updateUser方法来完成此sessionStorage更新。auth.updateUser方法的实现已在第六章的更新编辑个人资料视图部分中讨论,构建基于 Web 的课堂应用。
一旦在前端获得更新的seller值,我们可以使用它来相应地渲染界面。在下一节中,我们将看到如何根据查看应用的用户的卖家账户是否活跃来不同地渲染菜单。
更新菜单
在市场应用的前端,我们可以根据当前浏览应用的用户的卖家账户是否活跃来渲染不同的选项。在本节中,我们将添加代码以条件性地在导航栏上显示到我的商店的链接,该链接仅对已登录且拥有活跃卖家账户的用户可见。
我们将更新前一段代码中的Menu组件,使其仅在用户登录时渲染,如下所示:
mern-marketplace/client/core/Menu.js:
{auth.isAuthenticated().user.seller &&
(<Link to="/seller/shops">
<Button color = {isPartActive(history, "/seller/")}> My Shops </Button>
</Link>)
}
导航栏上的此我的商店链接将带活跃卖家账户的用户带到卖家仪表板视图,在那里他们可以管理他们在市场上的商店。
通过对用户实现的这些更新,现在市场中的用户可以将他们的普通账户更新为卖家账户,我们可以开始整合允许这些卖家向市场添加商店的功能。我们将在下一节中看到如何实现这一点。
在市场中添加商店
MERN 市场中的卖家可以创建商店并向每个商店添加产品。为了存储商店数据和启用商店管理,我们将实现一个用于商店的 Mongoose 模式,后端 API 以访问和修改商店数据,以及面向商店所有者和浏览市场的买家的前端视图。
在接下来的章节中,我们将通过首先定义用于在数据库中存储商店数据的商店模型,然后实现商店相关功能的后端 API 和前端视图(包括创建新商店、列出所有商店、按所有者列出商店、显示单个商店、编辑商店和从应用程序中删除商店)来构建应用程序中的商店模块。
定义商店模型
我们将实现一个 Mongoose 模型来定义一个用于存储每个商店详情的商店模型。此模型将在server/models/shop.model.js中定义,其实现将与之前章节中覆盖的其他 Mongoose 模型实现类似,如第六章中定义的 Course 模型,构建基于 Web 的课堂应用程序。此模型中的商店模式将包含简单的字段以存储商店详情,包括标志图像以及指向拥有商店的用户引用。定义商店字段的代码块及其说明如下所示:
- 商店名称和描述:
name和description字段将是字符串类型,其中name为必填字段:
name: {
type: String,
trim: true,
required: 'Name is required'
},
description: {
type: String,
trim: true
},
- 商店标志图像:
image字段将存储用户上传到 MongoDB 数据库中的标志图像文件:
image: {
data: Buffer,
contentType: String
},
- 商店所有者:
owner字段将引用创建商店的用户:
owner: {
type: mongoose.Schema.ObjectId,
ref: 'User'
}
- 创建时间和更新时间:
created和updated字段将是Date类型,created在添加新商店时生成,而updated在修改任何商店详情时更改:
updated: Date,
created: {
type: Date,
default: Date.now
},
在此模式定义中添加的字段将使我们能够实现 MERN Marketplace 中的商店相关功能。在下一节中,我们将通过实现允许卖家创建新商店的全栈切片来开始开发这些功能。
创建新商店
在 MERN Marketplace 中,一个已登录并拥有活跃卖家账户的用户将能够创建新的商店。为了实现这个功能,在接下来的章节中,我们将在后端添加创建商店 API,以及在前端获取此 API 的方法,以及一个用于输入商店字段的创建新商店表单视图。
创建商店 API
为了实现允许在数据库中创建新商店的创建商店 API,我们首先添加一个POST路由,如下面的代码所示:
mern-marketplace/server/routes/shop.routes.js:
router.route('/api/shops/by/:userId')
.post(authCtrl.requireSignin, authCtrl.hasAuthorization,
userCtrl.isSeller, shopCtrl.create)
对/api/shops/by/:userId此路由的POST请求将首先确保请求的用户已登录并且也是授权的所有者,换句话说,它是与路由参数中指定的:userId关联的同一用户。
为了处理:userId参数并从数据库中检索关联的用户,我们将利用用户控制器中的userByID方法。我们将在shop.routes.js中的Shop路由中添加以下内容,以便用户在request对象中作为profile可用:
mern-marketplace/server/routes/shop.routes.js:
router.param('userId', userCtrl.userByID)
包含商店路由的shop.routes.js文件将与user.routes文件非常相似。为了在 Express 应用中加载这些新的商店路由,我们需要在express.js中挂载商店路由,如下所示,就像我们为认证和用户路由所做的那样:
mern-marketplace/server/express.js:
app.use('/', shopRoutes)
在创建商店路由的请求中,也会验证当前用户是否为卖家,然后再使用请求中传递的商店数据创建一个新的商店。我们将更新用户控制器以添加isSeller方法,以确保当前用户实际上是一个卖家。isSeller方法定义如下:
mern-marketplace/server/controllers/user.controller.js:
const isSeller = (req, res, next) => {
const isSeller = req.profile && req.profile.seller
if (!isSeller) {
return res.status('403').json({
error: "User is not a seller"
})
}
next()
}
在商店控制器中,当卖家验证后,会调用create方法,该方法使用formidable节点模块解析可能包含用户上传的商店标志图像文件的 multipart 请求。如果有文件,formidable将暂时将其存储在文件系统中,我们将使用fs模块读取它,以检索文件类型和数据并将其存储在商店文档的image字段中。create控制器方法将如下所示:
mern-marketplace/server/controllers/shop.controller.js:
const create = (req, res, next) => {
let form = new formidable.IncomingForm()
form.keepExtensions = true
form.parse(req, (err, fields, files) => {
if (err) {
res.status(400).json({
message: "Image could not be uploaded"
})
}
let shop = new Shop(fields)
shop.owner= req.profile
if(files.image){
shop.image.data = fs.readFileSync(files.image.path)
shop.image.contentType = files.image.type
}
shop.save((err, result) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
res.status(200).json(result)
})
})
}
用户上传的商店标志图像文件存储在 MongoDB 中作为数据。然后,为了在视图中显示,它作为单独的GET API 从数据库中检索出来,作为一个图像文件。GET API 设置为 Express 路由/api/shops/logo/:shopId,从 MongoDB 获取图像数据并将其作为文件发送在响应中。文件上传、存储和检索的实现步骤在第五章“从简单的社交媒体应用开始”的“上传个人照片”部分中详细说明。
现在可以在前端使用这个创建商店 API 端点来发起POST请求。接下来,我们将在客户端添加一个fetch方法,以便从应用程序的客户端界面发起这个请求。
在视图中获取创建 API
在前端,为了向这个创建 API 发起请求,我们将在客户端设置一个fetch方法,向 API 路由发送一个POST请求,并传递包含新商店详细信息的 multipart 表单数据。这个fetch方法定义如下:
mern-marketplace/client/shop/api-shop.js:
const create = (params, credentials, shop) => {
return fetch('/api/shops/by/'+ params.userId, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer ' + credentials.t
},
body: shop
})
.then((response) => {
return response.json()
}).catch((err) => console.log(err))
}
我们将在下一节实现的创建新商店表单视图中使用这个方法,将用户输入的商店详细信息发送到后端。
新商店组件
在市场应用中的卖家将通过表单视图输入新商店的详细信息并创建新商店。我们将在这个NewShop组件中渲染这个表单,允许卖家通过输入名称和描述,并从本地文件系统中上传标志图像文件来创建商店,如下面的截图所示:

我们将在名为 NewShop 的 React 组件中实现此表单。对于视图,我们首先使用 Material-UI 按钮和 HTML5 文件输入元素添加文件上传元素,如下面的代码所示:
mern-marketplace/client/shop/NewShop.js:
<input accept="image/*" onChange={handleChange('image')}
id="icon-button-file"
style={display:'none'} type="file" />
<label htmlFor="icon-button-file">
<Button variant="contained" color="secondary" component="span">
Upload Logo <FileUpload/>
</Button>
</label>
<span>{values.image ? values.image.name : ''}</span>
然后,我们添加名称和描述表单字段,使用 TextField 组件,如下所示:
mern-marketplace/client/shop/NewShop.js:
<TextField
id="name"
label="Name"
value={values.name}
onChange={handleChange('name')}/> <br/>
<TextField
id="multiline-flexible"
label="Description"
multiline rows="2"
value={values.description}
onChange={handleChange('description')}/>
这些表单字段的变化将通过 handleChange 方法进行跟踪,当用户与输入字段交互输入值时。handleChange 函数将定义如下所示:
mern-marketplace/client/shop/NewShop.js:
const handleChange = name => event => {
const value = name === 'image'
? event.target.files[0]
: event.target.value
setValues({ ...values, [name]: value })
}
handleChange 方法会更新状态,包括用户上传的图像文件名(如果有的话)。
最后,您可以通过添加一个提交按钮来完成此表单视图,当用户点击时,应将表单数据发送到服务器。我们将定义一个 clickSubmit 方法,如下所示,当用户点击提交按钮时将被调用:
mern-marketplace/client/shop/NewShop.js:
const clickSubmit = () => {
const jwt = auth.isAuthenticated()
let shopData = new FormData()
values.name && shopData.append('name', values.name)
values.description && shopData.append('description', values.description)
values.image && shopData.append('image', values.image)
create({
userId: jwt.user._id
}, {
t: jwt.token
}, shopData).then((data) => {
if (data.error) {
setValues({...values, error: data.error})
} else {
setValues({...values, error: '', redirect: true})
}
})
}
此 clickSubmit 函数将获取输入值并填充 shopData,这是一个 FormData 对象,确保数据以正确的格式存储,适用于 multipart/form-data 编码类型。然后调用 create fetch 方法,使用此表单数据在后端创建新的商店。在成功创建商店后,用户将被重定向回 MyShops 视图,如下面的代码所示:
mern-marketplace/client/shop/NewShop.js:
if (values.redirect) {
return (<Redirect to={'/seller/shops'}/>)
}
NewShop 组件只能由已登录且也是卖家的用户查看。因此,我们将在 MainRouter 组件中添加一个 PrivateRoute,如下面的代码块所示,它只为在 /seller/shop/new 的认证用户提供此表单:
mern-marketplace/client/MainRouter.js:
<PrivateRoute path="/seller/shop/new" component={NewShop}/>
此链接可以添加到卖家可能访问的任何视图组件中,例如在卖家管理市场中的商店的视图中。现在,在市场上添加新商店成为可能,在下一节中,我们将讨论从后端数据库到前端应用视图检索和列出这些商店的实现。
列出商店
在 MERN Marketplace 中,普通用户将能够浏览平台上的所有商店列表,而每位店主将管理他们自己的商店列表。在以下章节中,我们将实现全栈切片,用于检索和显示两种不同的商店列表——所有商店的列表和特定用户拥有的商店列表。
列出所有商店
浏览市场中的任何用户都将能够看到市场上所有商店的列表。为了实现此功能,我们必须查询 shops 集合以检索数据库中的所有商店,并将其显示给最终用户。我们通过添加以下全栈切片来实现这一点:
-
一个用于检索商店列表的后端 API
-
前端的一个
fetch方法用于向 API 发送请求 -
一个用于显示商店列表的 React 组件
商店列表 API
在后端,我们将定义一个 API 来从数据库检索所有商店,以便在前端列出市场中的商店。此 API 将接受来自客户端的请求以查询 shops 集合,并在响应中返回结果商店文档。首先,当服务器在 '/api/shops' 接收到 GET 请求时,我们将添加一个路由来检索存储在数据库中的所有商店。此路由声明如下所示:
mern-marketplace/server/routes/shop.routes.js
router.route('/api/shops')
.get(shopCtrl.list)
在此路由接收到的 GET 请求将调用 list 控制器方法,该方法将查询数据库中的 shops 集合以返回所有商店。list 方法定义如下:
mern-marketplace/server/controllers/shop.controller.js:
const list = async (req, res) => {
try {
let shops = await Shop.find()
res.json(shops)
} catch (err){
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
此方法将返回数据库中的所有商店以响应请求的客户端。接下来,我们将看到如何从客户端向此商店列表 API 发送请求。
获取所有商店以供查看
为了在前端使用商店列表 API,我们将定义一个 fetch 方法,该方法可以被 React 组件用来加载此商店列表。客户端的 list 方法将使用 fetch 向 API 发送 GET 请求,如下所示:
mern-marketplace/client/shop/api-shop.js:
const list = async (signal) => {
try {
let response = await fetch('/api/shops', {
method: 'GET',
signal: signal
})
return response.json()
}catch(err) {
console.log(err)
}
}
如我们将在下一节中看到的,此 list 方法可以在 React 组件中用来显示商店列表。
Shops 组件
在 Shops 组件中,在从服务器获取数据并将数据设置在状态中以供显示后,我们将使用 Material-UI List 渲染商店列表,如下所示:

为了实现此组件,我们首先需要获取并渲染商店列表。我们将在 useEffect 钩子中调用 fetch API 调用,并将接收到的 shops 数组设置在状态中,如下所示:
mern-marketplace/client/shop/Shops.js:
export default function Shops(){
const [shops, setShops] = useState([])
useEffect(() => {
const abortController = new AbortController()
const signal = abortController.signal
list(signal).then((data) => {
if (!data.error) {
setShops(data)
}
})
return function cleanup(){
abortController.abort()
}
}, [])
...
}
在 Shops 组件视图中,使用 map 迭代检索到的 shops 数组,每个商店的数据在视图中以 Material-UI ListItem 的形式渲染,并且每个 ListItem 也链接到单个商店的视图,如下所示:
mern-marketplace/client/shop/Shops.js:
{shops.map((shop, i) => {
return <Link to={"/shops/"+shop._id} key={i}>
<Divider/>
<ListItem button>
<ListItemAvatar>
<Avatar src={'/api/shops/logo/'+shop._id+"?" + new Date().getTime()}/>
</ListItemAvatar>
<div className={classes.details}>
<Typography type="headline"
component="h2" color="primary">
{shop.name}
</Typography>
<Typography type="subheading" component="h4">
{shop.description}
</Typography>
</div>
</ListItem>
<Divider/>
</Link>
})}
Shops 组件将由最终用户在 /shops/all 路径下访问,该路径通过 React Router 设置,并在 MainRouter.js 中声明如下:
mern-marketplace/client/MainRouter.js:
<Route path="/shops/all" component={Shops}/>
将此链接添加到应用程序中的任何视图中,将用户重定向到显示市场内所有商店的视图。接下来,我们将类似地实现列出特定用户拥有的商店的功能。
按所有者列出商店
市场上的授权卖家将看到他们创建的商店列表,他们可以通过编辑或删除列表中的任何商店来管理这些商店。为了实现这个功能,我们必须查询商店集合以检索所有具有相同所有者的商店,并仅向授权的所有者显示。我们通过添加以下全栈切片来实现这一点:
-
一个后端 API,确保请求用户已授权并检索相关的商店列表
-
前端的一个
fetch方法来请求这个 API -
一个 React 组件用于向授权用户显示商店列表
按所有者分组的商店 API
我们将在后端实现一个 API 来返回特定所有者的商店列表,以便在前端渲染给最终用户。我们将从在服务器接收到对/api/shops/by/:userId的GET请求时检索给定用户创建的所有商店的后端路由开始。此路由声明如下所示:
mern-marketplace/server/routes/shop.routes.js:
router.route('/api/shops/by/:userId')
.get(authCtrl.requireSignin, authCtrl.hasAuthorization, shopCtrl.listByOwner)
对这个路由的GET请求将首先确保请求用户已登录并且也是授权的所有者,然后调用shop.controller.js中的listByOwner控制器方法。此方法将在数据库中查询Shop集合以获取匹配的商店。listByOwner方法定义如下:
mern-marketplace/server/controllers/shop.controller.js:
const listByOwner = async (req, res) => {
try {
let shops = await Shop.find({owner: req.profile._id}).populate('owner', '_id name')
res.json(shops)
} catch (err){
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
在查询 Shop 集合时,我们找到所有owner字段与用户指定的userId参数匹配的商店,然后在owner字段中填充引用的用户 ID 和名称,并将结果商店以数组形式返回给客户端。接下来,我们将看到如何从客户端发起对这个 API 的请求。
获取用于视图的用户拥有的所有商店
在前端,为了使用按所有者分组的 API 获取特定用户的商店,我们将添加一个fetch方法,该方法接受已登录用户的凭据,并通过将特定用户 ID 传递到 URL 中,向 API 路由发起GET请求。此fetch方法定义如下:
mern-marketplace/client/shop/api-shop.js:
const listByOwner = async (params, credentials, signal) => {
try {
let response = await fetch('/api/shops/by/'+params.userId, {
method: 'GET',
signal: signal,
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer ' + credentials.t
}
})
return response.json()
} catch(err){
console.log(err)
}
}
使用此方法从服务器返回的响应中的商店可以在 React 组件中渲染,以向授权用户显示商店,如下一节所述。
MyShops 组件
MyShops组件与Shops组件类似。它获取当前用户拥有的商店列表,并如图所示在ListItem中渲染每个商店:

此外,每个商店都有一个编辑和删除选项,与Shops中的项目列表不同。MyShops组件的实现与Shops相同,除了以下添加的编辑和删除按钮:
mern-marketplace/client/shop/MyShops.js:
<ListItemSecondaryAction>
<Link to={"/seller/shop/edit/" + shop._id}>
<IconButton aria-label="Edit" color="primary">
<Edit/>
</IconButton>
</Link>
<DeleteShop shop={shop} onRemove={removeShop}/>
</ListItemSecondaryAction>
编辑按钮链接到<q>编辑商店</q>视图,而DeleteShop组件(将在本章后面讨论),处理删除操作。DeleteShop组件通过调用从MyShops传递的removeShop方法来更新列表。这个removeShop方法允许我们使用当前用户的修改后的商店列表更新状态,并在MyShops组件中定义,如下所示:
mern-marketplace/client/shop/MyShops.js:
const removeShop = (shop) => {
const updatedShops = [...shops]
const index = updatedShops.indexOf(shop)
updatedShops.splice(index, 1)
setShops(updatedShops)
}
MyShops组件只能由已登录的卖家查看。因此,我们将在MainRouter组件中添加一个PrivateRoute,它只为认证用户在/seller/shops上渲染此组件,如下所示代码所示:
mern-marketplace/client/MainRouter.js:
<PrivateRoute path="/seller/shops" component={MyShops}/>
在市场应用程序中,我们在导航菜单中添加此链接,以便将已登录的卖家重定向到他们可以编辑或删除商店以管理他们拥有的商店的视图。在添加编辑或删除商店的能力之前,我们接下来将探讨如何从后端检索单个商店并将其显示给最终用户。
显示商店
访问 MERN 市场的任何用户都将能够浏览每个单独的商店。在以下章节中,我们将通过向后端添加读取商店 API、从前端调用此 API 的方法以及将在视图中显示商店详情的 React 组件来实现单个商店视图。
读取商店 API
为了在后端实现读取商店 API,我们将首先添加一个GET路由,该路由通过 ID 查询Shop集合,并在响应中返回商店。该路由与路由参数处理程序一起声明,如下所示:
mern-marketplace/server/routes/shop.routes.js:
router.route('/api/shop/:shopId')
.get(shopCtrl.read)
router.param('shopId', shopCtrl.shopByID)
路由 URL 中的:shopId参数将调用shopByID控制器方法,该方法类似于userByID控制器方法。它从数据库中检索商店并将其附加到请求对象中,以便在next方法中使用。shopByID方法定义如下:
mern-marketplace/server/controllers/shop.controller.js:
const shopByID = async (req, res, next, id) => {
try {
let shop = await Shop.findById(id).populate('owner', '_id name').exec()
if (!shop)
return res.status('400').json({
error: "Shop not found"
})
req.shop = shop
next()
} catch (err) {
return res.status('400').json({
error: "Could not retrieve shop"
})
}
}
从数据库查询到的商店对象也将包含所有者的名称和 ID 详情,正如我们在populate()方法中指定的。然后read控制器方法将这个商店对象作为对客户端的响应返回。read控制器方法定义如下所示:
mern-marketplace/server/controllers/shop.controller.js:
const read = (req, res) => {
req.shop.image = undefined
return res.json(req.shop)
}
在发送响应之前,我们正在删除图像字段,因为图像将通过单独的路由作为文件检索。有了这个后端 API 就绪,你现在可以添加在api-shop.js中调用它的实现,类似于已为其他 API 实现添加的其他fetch方法。我们将使用fetch方法在将渲染商店详情的 React 组件中调用读取商店 API,如下一节所述。
商店组件
Shop 组件将渲染商店详情,并使用产品列表组件列出指定商店中的产品,这将在 产品 部分进行讨论。完成的单个 Shop 视图将如图所示:

为了实现这个 Shop 组件,我们首先会在 useEffect 钩子中使用 fetch 调用读取 API 来检索商店详情,并将接收到的值设置到状态中,如下面的代码所示:
mern-marketplace/client/shop/Shop.js:
export default function Shop({match}) {
const [shop, setShop] = useState('')
const [error, setError] = useState('')
useEffect(() => {
const abortController = new AbortController()
const signal = abortController.signal
read({
shopId: match.params.shopId
}, signal).then((data) => {
if (data.error) {
setError(data.error)
} else {
setShop(data)
}
})
return function cleanup(){
abortController.abort()
}
}, [match.params.shopId])
...
}
这个 useEffect 钩子仅在路由参数中的 shopId 发生变化时运行。
获取到的商店数据被设置到状态中,并在视图中渲染以显示商店的名称、标志和描述,如下面的代码所示:
mern-marketplace/client/shop/Shop.js:
<CardContent>
<Typography type="headline" component="h2">
{shop.name}
</Typography><br/>
<Avatar src={logoUrl}/><br/>
<Typography type="subheading" component="h2">
{shop.description}
</Typography><br/>
</CardContent>
logoUrl 指向从数据库中检索标志图像的路由(如果图像存在),其定义如下:
mern-marketplace/client/shop/Shop.js:
const logoUrl = shop._id
? `/api/shops/logo/${shop._id}?${new Date().getTime()}`
: '/api/shops/defaultphoto'
Shop 组件将通过浏览器中的 /shops/:shopId 路由进行访问,该路由在 MainRouter 中定义如下:
mern-marketplace/client/MainRouter.js:
<Route path="/shops/:shopId" component={Shop}/>
这个路由可以在任何组件中使用,以链接到特定的商店,并且这个链接将用户带到加载了商店详细信息的相应 Shop 视图。在下一节中,我们将添加允许商店所有者编辑这些商店详细信息的功能。
编辑商店
应用程序中的授权卖家将能够更新他们已经添加到市场中的商店。为了实现这一功能,我们需要创建一个后端 API,允许在确认请求用户已认证并授权后对特定商店进行更新操作。然后需要从前端调用这个更新后的 API,并传入商店更改的详细信息。在接下来的章节中,我们将构建这个后端 API 和 React 组件,以便卖家可以更改他们的商店信息。
商店编辑 API
在后端,我们需要一个 API,允许如果请求用户是给定商店的授权卖家,则更新数据库中的现有商店。我们首先声明接受客户端更新请求的 PUT 路由如下:
mern-marketplace/server/routes/shop.routes.js:
router.route('/api/shops/:shopId')
.put(authCtrl.requireSignin, shopCtrl.isOwner, shopCtrl.update)
在 /api/shops/:shopId 路由接收到的 PUT 请求首先检查已登录用户是否是 URL 中提供的 shopId 相关商店的所有者,使用 isOwner 控制器方法,该方法定义如下:
mern-marketplace/server/controllers/shop.controller.js:
const isOwner = (req, res, next) => {
const isOwner = req.shop && req.auth
&& req.shop.owner._id == req.auth._id
if(!isOwner){
return res.status('403').json({
error: "User is not authorized"
})
}
next()
}
在这个方法中,如果发现用户是授权的,则通过调用 next() 来调用 update 控制器。
update控制器方法将使用与前面讨论的create控制器方法中相同的formidable和fs模块来解析表单数据并更新数据库中的现有商店。商店控制器中的update方法定义如下所示:
mern-marketplace/server/controllers/shop.controller.js:
const update = (req, res) => {
let form = new formidable.IncomingForm()
form.keepExtensions = true
form.parse(req, async (err, fields, files) => {
if (err) {
res.status(400).json({
message: "Photo could not be uploaded"
})
}
let shop = req.shop
shop = extend(shop, fields)
shop.updated = Date.now()
if(files.image){
shop.image.data = fs.readFileSync(files.image.path)
shop.image.contentType = files.image.type
}
try {
let result = await shop.save()
res.json(result)
} catch (err){
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
})
}
要在前端使用此更新 API,您需要定义一个fetch方法,该方法接受商店 ID、用户认证凭据和更新的商店详情,以对该更新商店 API 进行 fetch 调用,就像我们在创建新商店部分中为其他 API 实现所做的那样。
现在我们有一个可以用于前端更新商店详情的商店更新 API。我们将在下一节讨论的EditShop组件中使用此 API。
编辑商店组件
EditShop组件将显示一个类似于创建新商店表单的表单,预先填充了现有商店的详情。此组件还将显示此商店的产品列表,将在产品部分讨论。完成的编辑商店视图如图所示:

此视图的表单部分用于编辑商店详情,与NewShop组件中的表单类似,具有相同的表单字段和一个formData对象,该对象包含要随update fetch方法发送的多部分表单数据。与NewShop组件相比,在此组件中,我们需要利用读取商店 API 在useEffect钩子中获取指定商店的详情并预先填充表单字段。您可以将针对NewShop组件和Shop组件讨论的实现结合起来,以完成EditShop组件。
EditShop组件只能由授权的商店所有者访问。因此,我们将在MainRouter组件中添加一个PrivateRoute,如以下所示,它将只为经过认证的用户在/seller/shop/edit/:shopId渲染此组件:
mern-marketplace/client/MainRouter.js:
<PrivateRoute path="/seller/shop/edit/:shopId" component={EditShop}/>
在MyShops组件中,为每个商店添加了一个编辑图标,允许卖家访问他们每个商店的编辑页面。在MyShops视图中,卖家还可以删除他们的商店,如下一节所述。
删除商店
作为管理他们拥有的商店的一部分,授权卖家将有权删除他们自己的任何商店。为了允许卖家从市场移除商店,在以下章节中,我们首先将定义一个从数据库中删除商店的后端 API,然后实现一个 React 组件,当用户与前端交互以执行此删除时,该组件将使用此 API。
删除商店的 API
为了从数据库中删除商店,我们将在后端实现一个删除商店 API,该 API 将接受客户端在/api/shops/:shopId上的 DELETE 请求。我们将为这个 API 添加以下代码所示的DELETE路由,这将允许授权卖家删除他们自己的商店之一:
mern-marketplace/server/routes/shop.routes.js:
router.route('/api/shops/:shopId')
.delete(authCtrl.requireSignin, shopCtrl.isOwner, shopCtrl.remove)
当接收到此路由的 DELETE 请求时,如果isOwner方法确认已登录的用户是该商店的所有者,那么remove控制器方法将删除由参数中的shopId指定的商店。remove方法定义如下:
mern-marketplace/server/controllers/shop.controller.js:
const remove = async (req, res) => {
try {
let shop = req.shop
let deletedShop = shop.remove()
res.json(deletedShop)
} catch (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
这个remove方法简单地从数据库中的Shops集合中删除与提供的 ID 相对应的商店文档。为了在前端访问这个后端 API,你还需要一个具有此路由的fetch方法,类似于其他 API 实现。fetch方法需要获取商店 ID 和当前用户的认证凭证,然后使用这些值调用删除商店 API。
当用户在前端界面中点击按钮执行删除操作时,将使用fetch方法。在下一节中,我们将讨论一个名为DeleteShop的 React 组件,用户将通过该组件执行删除商店操作。
删除商店组件
DeleteShop组件被添加到列表中的每个商店的MyShops组件中。它从MyShops接收shop对象和onRemove方法作为 props。此组件基本上是一个按钮,当点击时,会打开一个Dialog组件,提示用户确认删除操作,如下面的截图所示:

DeleteShop组件的实现类似于在第四章中讨论的DeleteUser组件,即添加 React 前端以完成 MERN。当它添加到MyShops时,DeleteShop组件将从MyShops组件接收shop对象和onRemove函数定义作为 props,如下所示:
mern-marketplace/client/shop/MyShops.js:
<DeleteShop shop={shop} onRemove={removeShop}/>
通过这种实现,授权卖家将能够从市场上删除他们拥有的商店。
我们通过首先定义用于存储商店数据的 Shop 模型,然后集成后端 API 和前端视图,以便能够在应用程序中执行对商店的 CRUD 操作,从而实现了市场中的商店模块。这些商店功能,包括创建新商店、显示商店、编辑和删除商店的能力,将允许买家和卖家与市场中的商店进行交互。商店还将拥有以下讨论的产品,所有者将负责管理,买家将能够浏览,并可以选择将产品添加到购物车中。
将产品添加到商店
产品是市场应用中最关键的部分。在 MERN 市场中,卖家可以管理他们店铺中的产品,访客可以搜索和浏览产品。虽然我们将实现允许授权卖家添加、修改和删除他们店铺中产品的功能,但我们还将整合对最终用户有意义的列出产品的方式。在应用中,我们将通过特定店铺、与给定产品相关联的产品以及最新添加到市场中的产品来检索和显示产品。在接下来的章节中,我们将通过首先定义用于在数据库中存储产品数据的 product 模型,然后实现与产品相关功能的后端 API 和前端视图来构建产品模块,包括向店铺添加新产品、渲染不同的产品列表、显示单个产品、编辑产品和删除产品。
定义产品模型
产品将存储在数据库中的产品集合中。为了实现这一点,我们将添加一个 Mongoose 模型来定义一个 Product 模型,用于存储每个产品的详细信息。此模型将在 server/models/product.model.js 中定义,其实现将与之前章节中覆盖的其他 Mongoose 模型实现类似,如第六章构建基于 Web 的课堂应用中定义的课程模型。
对于 MERN 市场来说,我们将保持产品架构简单,支持 name、description、image、category、quantity、price、created at、updated at 以及对店铺的引用等字段。定义产品架构中产品字段的代码如下,以及相应的解释:
- 产品名称和描述:
name和description字段将被定义为String类型,其中name是一个required字段:
name: {
type: String,
trim: true,
required: 'Name is required'
},
description: {
type: String,
trim: true
},
- 产品图片:
image字段将存储用户上传到 MongoDB 数据库中的图片文件:
image: {
data: Buffer,
contentType: String
},
- 产品类别:
category值将允许将相同类型的产品分组在一起:
category: {
type: String
},
- 产品数量:
quantity字段将表示店铺中可供销售的产品的数量:
quantity: {
type: Number,
required: "Quantity is required"
},
- 产品价格:
price字段将保存此产品将花费买家的单价:
price: {
type: Number,
required: "Price is required"
},
- 产品店铺:
shop字段将引用添加产品的店铺:
shop: {
type: mongoose.Schema.ObjectId,
ref: 'Shop'
}
- 创建和更新时间:
created和updated字段将被定义为Date类型,created字段在添加新产品时生成,而updated时间在修改产品详情时改变:
updated: Date,
created: {
type: Date,
default: Date.now
},
此架构定义中的字段将使我们能够在 MERN 市场中实现产品相关功能。为了开始这些功能的实现,在下一节中,我们将实现一个全栈切片,允许卖家向他们在市场中的现有店铺添加新产品。
创建新产品
MERN 市场中的卖家将能够向他们在平台上拥有的商店添加新产品。为了实现此功能,在接下来的章节中,我们将在后台添加创建产品 API,以及在前端获取此 API 的方法,还有一个用于收集用户输入的产品字段的新产品表单视图。
创建产品 API
我们将添加一个后端 API,允许授权的店主通过客户端的POST请求将新产品保存到数据库中。为了在后台实现这个创建产品 API,我们首先将在/api/products/by/:shopId路径下添加一个路由,该路由接受包含产品数据的POST请求。向此路由发送请求将创建一个与:shopId参数指定的商店相关联的新产品。此创建产品 API 路由的声明如下所示:
mern-marketplace/server/routes/product.routes.js:
router.route('/api/products/by/:shopId')
.post(authCtrl.requireSignin, shopCtrl.isOwner, productCtrl.create)
router.param('shopId', shopCtrl.shopByID)
包含此路由声明的product.routes.js文件将与shop.routes.js文件非常相似,为了在 Express 应用中加载这些新路由,我们需要在express.js中挂载产品路由,如下所示:
mern-marketplace/server/express.js:
app.use('/', productRoutes)
处理创建产品 API 路由请求的代码将首先检查当前用户是否为新产品将被添加的商店的所有者,然后在数据库中创建新产品。此 API 利用来自商店控制器的shopByID和isOwner方法来处理:shopId参数,并在调用create控制器方法之前验证当前用户是否是商店所有者。create方法定义如下:
mern-marketplace/server/controllers/product.controller.js:
const create = (req, res, next) => {
let form = new formidable.IncomingForm()
form.keepExtensions = true
form.parse(req, async (err, fields, files) => {
if (err) {
return res.status(400).json({
message: "Image could not be uploaded"
})
}
let product = new Product(fields)
product.shop= req.shop
if(files.image){
product.image.data = fs.readFileSync(files.image.path)
product.image.contentType = files.image.type
}
try {
let result = await product.save()
res.json(result)
} catch (err){
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
})
}
在产品控制器中,此create方法使用formidable节点模块来解析可能包含用户上传的图像文件以及产品字段的 multipart 请求。然后,解析后的数据被保存到产品集合中作为新产品。
在前端,为了使用此创建产品 API,你还需要在client/product/api-product.js中设置一个fetch方法,通过传递从视图中的 multipart 表单数据来向创建 API 发送POST请求。然后,此fetch方法可以在 React 组件中使用,该组件从用户那里获取产品详情并发送请求以创建新产品。基于此表单的 React 组件创建新产品的实现将在下一节中讨论。
新产品组件
在市场平台上已经创建店铺的授权卖家将看到一个用于添加新产品的表单视图。我们将在这个名为 NewProduct 的 React 组件中实现这个表单视图。NewProduct 组件将与 NewShop 组件类似。它将包含一个表单,允许卖家通过输入名称、描述、类别、数量和价格来创建产品,并从他们的本地文件系统中上传产品图片文件,如图下截图所示:

NewProduct 组件可以几乎与 NewShop 组件完全相同地实现,唯一的区别是从渲染 NewProduct 组件的前端路由 URL 中检索店铺 ID。此组件将在与特定店铺关联的路由上加载,因此只有登录的卖家才能向他们拥有的店铺添加产品。为了定义此路由,我们在 MainRouter 组件中添加了一个 PrivateRoute,如下所示,它将只为授权用户在 URL '/seller/:shopId/products/new' 上渲染此表单:
mern-marketplace/client/MainRouter.js:
<PrivateRoute path="/seller/:shopId/products/new" component={NewProduct}/>
在前端视图中的任何地方添加此特定店铺的链接将渲染 NewProduct 组件供登录用户使用。在这个视图中,用户将能够填写表单中的新产品详细信息,然后如果他们是给定店铺的授权所有者,将产品保存到后端数据库中。接下来,我们将探讨检索和在不同列表中显示这些产品的实现方法。
列出产品
在 MERN 市场平台上,产品将以多种方式向用户展示。两个主要区别在于产品对于卖家和买家的列出方式。在以下章节中,我们将了解如何为卖家和买家列出店铺中的产品,然后还将讨论如何列出为买家提供的产品建议,包括与特定产品相关的产品以及最新添加到市场平台的产品。
按店铺列出
市场平台的访客将浏览每个店铺中的产品,卖家将管理他们每个店铺中的产品列表。这两个功能将共享相同的后端 API,该 API 将检索特定店铺的所有产品,但将为两种类型的用户以不同的方式渲染。在以下章节中,首先,我们将实现用于检索特定店铺中产品的后端 API。然后,我们将使用该 API 在两个不同的 React 组件中渲染产品列表,一个用于店铺的卖家,另一个用于买家。
店铺产品 API
为了实现从数据库中检索特定店铺产品的后端 API,我们将在 /api/products/by/:shopId 上设置一个 GET 路由,如下面的代码所示:
mern-marketplace/server/routes/product.routes.js:
router.route('/api/products/by/:shopId')
.get(productCtrl.listByShop)
对此请求执行listByShop控制器方法将查询产品集合以返回与给定商店引用匹配的产品。listByShop方法定义如下代码所示:
mern-marketplace/server/controllers/product.controller.js:
const listByShop = async (req, res) => {
try {
let products = await Product.find({shop: req.shop._id})
.populate('shop', '_id name').select('-image')
res.json(products)
} catch (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
结果产品数组中的每个产品都将包含相关商店的名称和 ID 详情,我们将省略image字段,因为可以通过单独的 API 路由检索图像。
在前端,为了使用按商店列表的 API 获取特定商店的产品,我们还需要在api-product.js中添加一个fetch方法,类似于我们的其他 API 实现。然后,可以在任何 React 组件中调用fetch方法来渲染产品,例如,在下一节中讨论的显示给所有买家的商店中的产品。
买家产品组件
我们将构建一个Products组件,主要用于向可能购买产品的访客展示产品。我们可以在整个应用程序中重用此组件以渲染与买家相关的不同产品列表。它将从显示产品列表的父组件接收产品列表作为 props。渲染后的产品视图可能看起来如下截图所示:

在市场应用程序中,商店中的产品列表将以单独的Shop视图的形式显示给用户。因此,此Products组件被添加到Shop组件中,并提供了相关产品的列表作为 props,如下所示:
mern-marketplace/client/shop/Shop.js:
<Products products={products} searched={false}/></Card>
searched prop 传递了此列表是否是产品搜索的结果,因此可以渲染适当的消息。
在Shop组件中,我们需要在useEffect钩子中添加对listByShop fetch 方法的调用以检索相关产品并将其设置到状态中,如下所示代码所示:
mern-marketplace/client/shop/Shop.js:
useEffect(() => {
const abortController = new AbortController()
const signal = abortController.signal
listByShop({
shopId: match.params.shopId
}, signal).then((data)=>{
if (data.error) {
setError(data.error)
} else {
setProducts(data)
}
})
return function cleanup(){
abortController.abort()
}
}, [match.params.shopId])
在Products组件中,如果通过 props 传入的产品列表包含产品,则会遍历列表,并在 Material-UI 的GridListTile中渲染每个产品的相关详情,包括指向单个产品视图的链接以及AddToCart组件(其实现将在第八章[7514f26d-29e1-46e2-ac46-7515b2c3a6d0.xhtml]中讨论,扩展订单和支付的市场。以下是添加渲染产品列表的代码:
mern-marketplace/client/product/Products.js:
{props.products.length > 0 ?
(<div>
<GridList cellHeight={200} cols={3}>
{props.products.map((product, i) => (
<GridListTile key={i}>
<Link to={"/product/"+product._id}>
<img src={'/api/product/image/'+product._id}
alt={product.name} />
</Link>
<GridListTileBar
title={<Link to={"/product/"+product._id}>
{product.name}</Link>}
subtitle={<span>$ {product.price} </span>}
actionIcon={
<AddToCart item={product}/>
}
/>
</GridListTile>))
}
</GridList>
</div>) : props.searched && (<Typography component="h4">
No products found! :(</Typography>)}
如果在 props 中发送的products数组被发现为空,并且这是用户搜索操作的结果,我们将渲染一条适当的消息来通知用户没有找到产品。
这个 Products 组件可以用来渲染不同类型的买家产品列表,包括商店中的产品、按类别划分的产品以及搜索结果中的产品。在下一节中,我们将实现一个 MyProducts 组件,它将只为商店老板渲染产品列表,为他们提供一组不同的交互选项。
为商店老板的 MyProducts 组件
与 Products 组件不同,client/product/MyProducts.js 中的 MyProducts 组件仅用于向卖家展示产品,以便他们可以管理他们拥有的每个商店中的产品,并且将如图所示显示给最终用户:

如下代码所示,将 MyProducts 组件添加到 EditShop 视图中,以便卖家可以在一个地方管理商店及其内容。它通过属性提供商店的 ID,以便可以获取相关产品:
mern-marketplace/client/shop/EditShop.js:
<MyProducts shopId={match.params.shopId}/>
在 MyProducts 中,相关产品首先使用 listByShop 获取方法,通过 useEffect 钩子加载到一个状态,如下面的代码所示:
mern-marketplace/client/product/MyProducts.js:
export default function MyProducts (props){
const [products, setProducts] = useState([])
useEffect(() => {
const abortController = new AbortController()
const signal = abortController.signal
listByShop({
shopId: props.shopId
}, signal).then((data)=>{
if (data.error) {
console.log(data.error)
} else {
setProducts(data)
}
})
return function cleanup(){
abortController.abort()
}
}, [])
...
}
此产品列表随后被迭代,每个产品都在 ListItem 组件中渲染,并附带编辑和删除选项,类似于 MyShops 列表视图。编辑按钮链接到编辑产品视图。DeleteProduct 组件处理删除操作,并通过调用从 MyProducts 传递的 onRemove 方法来重新加载列表,以更新当前商店的产品列表状态。
在 MyProducts 中定义的 removeProduct 方法作为 onRemove 属性传递给 DeleteProduct 组件。removeProduct 方法定义如下:
mern-marketplace/client/product/MyProducts.js:
const removeProduct = (product) => {
const updatedProducts = [...products]
const index = updatedProducts.indexOf(product)
updatedProducts.splice(index, 1)
setProducts(updatedProducts)
}
然后当它被添加到 MyProducts 中时,作为属性传递给 DeleteProduct 组件,如下所示:
mern-marketplace/client/product/MyProducts.js:
<DeleteProduct
product={product}
shopId={props.shopId}
onRemove={removeProduct}/>
以这种方式实现一个单独的 MyProducts 组件,使商店老板能够查看他们商店中的产品列表,并可选择编辑和删除每个产品。在下一节中,我们将完成从后端检索不同类型产品列表的实现,并在前端将它们作为买家产品建议渲染。
列出产品建议
访问 MERN 市场的访客将看到产品建议,例如最新添加到市场中的产品以及与他们当前查看的产品相关的产品。在接下来的几节中,我们将首先查看获取最新产品和给定产品相关产品列表的后端 API 实现,然后实现一个名为 Suggestions 的 React 组件来渲染这些产品列表。
最新产品
在 MERN Marketplace 的主页上,我们将显示最近添加到市场的五个最新产品。为了获取最新产品,我们将设置一个后端 API,该 API 将在/api/products/latest接收GET请求,如下所示:
mern-marketplace/server/routes/product.routes.js:
router.route('/api/products/latest')
.get(productCtrl.listLatest)
在此路由接收到的GET请求将调用listLatest控制器方法。此方法将找到所有产品,按数据库中产品的created日期字段从新到旧排序产品列表,并在响应中返回排序列表中的前五个。此listLatest控制器方法定义如下:
mern-marketplace/server/controllers/product.controller.js:
const listLatest = async (req, res) => {
try {
let products = await Product.find({}).sort('-created')
.limit(5).populate('shop', '_id name').exec()
res.json(products)
} catch (err){
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
要在前端使用此 API,您还需要在api-product.js中设置相应的fetch方法,用于此最新产品 API,类似于其他 API 实现。然后,检索到的列表将被渲染在Suggestions组件中,以添加到主页。接下来,我们将讨论用于检索相关产品列表的类似 API。
相关产品
在每个单个产品视图中,我们将展示五个相关产品作为建议。为了检索这些相关产品,我们将设置一个后端 API,该 API 在/api/products/related接收请求,如下所示。
mern-marketplace/server/routes/product.routes.js:
router.route('/api/products/related/:productId')
.get(productCtrl.listRelated)
router.param('productId', productCtrl.productByID)
路由 URL 中的:productId参数将调用productByID控制器方法,该方法类似于shopByID控制器方法,从数据库中检索产品并将其附加到请求对象中,以便在next方法中使用。productByID控制器方法定义如下:
mern-marketplace/server/controllers/product.controller.js:
const productByID = async (req, res, next, id) => {
try {
let product = await Product.findById(id)
.populate('shop', '_id name').exec()
if (!product)
return res.status('400').json({
error: "Product not found"
})
req.product = product
next()
} catch (err) {
return res.status('400').json({
error: "Could not retrieve product"
})
}
}
一旦检索到产品,就会调用listRelated控制器方法。此方法查询数据库中的Product集合,以找到与给定产品具有相同类别的其他产品(不包括给定产品),并返回结果列表中的前五个产品。此listRelated控制器方法定义如下:
mern-marketplace/server/controllers/product.controller.js:
const listRelated = async (req, res) => {
try{
let products = await Product.find({ "_id": { "$ne": req.product },
"category": req.product.category})
.limit(5).populate('shop', '_id name').exec()
res.json(products)
} catch (err){
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
为了在前端利用此相关产品 API,我们将在api-product.js中设置相应的fetch方法。fetch方法将在Product组件中使用产品 ID 调用,以填充产品视图中渲染的Suggestions组件。我们将在下一节查看此Suggestions组件的实现。
建议组件
Suggestions组件将在主页和单个产品页面上渲染,分别显示最新产品和相关产品。一旦渲染,Suggestions组件可能看起来如下所示:

此组件将从父组件接收相关的产品列表作为 props,以及列表的标题:
<Suggestions products={suggestions} title={suggestionTitle}/>
在 Suggestions 组件中,遍历接收到的列表,并使用相关详细信息、单个产品页面链接和 AddToCart 组件渲染单个产品,如下所示。
mern-marketplace/client/product/Suggestions.js:
<Typography type="title"> {props.title} </Typography>
{props.products.map((item, i) => {
return <span key={i}>
<Card>
<CardMedia image={'/api/product/image/'+item._id}
title={item.name}/>
<CardContent>
<Link to={'/product/'+item._id}>
<Typography type="title" component="h3">
{item.name}</Typography>
</Link>
<Link to={'/shops/'+item.shop._id}>
<Typography type="subheading">
<Icon>shopping_basket</Icon> {item.shop.name}
</Typography>
</Link>
<Typography component="p">
Added on {(new
Date(item.created)).toDateString()}
</Typography>
</CardContent>
<Typography type="subheading" component="h3">$
{item.price}</Typography>
<Link to={'/product/'+item._id}>
<IconButton color="secondary" dense="dense">
<ViewIcon className={classes.iconButton}/>
</IconButton>
</Link>
<AddToCart item={item}/>
</Card>
</span>})}
这个 Suggestions 组件可以被重用来向买家渲染任何产品列表。在本节中,我们讨论了如何检索和显示两个不同的产品列表。列表中的每个产品都链接到一个视图,该视图将渲染单个产品的详细信息。在下一节中,我们将查看读取和向最终用户显示单个产品的实现。
显示产品
访问 MERN 市场的访客将能够查看每个产品的更多详细信息。在以下章节中,我们将实现一个后端 API 来从数据库中检索单个产品,然后在前端使用它来在 React 组件中渲染单个产品。
读取产品 API
在后端,我们将添加一个带有 GET 路由的 API,该路由通过 ID 查询产品集合并返回响应中的产品。该路由的声明如下所示:
mern-marketplace/server/routes/product.routes.js:
router.route('/api/products/:productId')
.get(productCtrl.read)
URL 中的 :productId 参数调用 productByID 控制器方法,该方法从数据库中检索产品并将其附加到请求对象中。请求对象中的产品被 read 控制器方法用于响应 GET 请求。read 控制器方法定义如下:
mern-marketplace/server/controllers/product.controller.js:
const read = (req, res) => {
req.product.image = undefined
return res.json(req.product)
}
要在前端使用这个读取产品 API,我们需要在 client/product/api-product.js 中添加一个 fetch 方法,类似于其他 API 实现。然后这个 fetch 方法可以在 React 组件中使用,该组件将渲染单个产品详细信息,如下一节所述。
产品组件
我们将添加一个名为 Product 的 React 组件来渲染单个产品的详细信息,并提供添加到购物车的选项。在这个单个产品视图中,我们还将显示相关产品的列表,如图所示:

Product 组件可以通过 /product/:productID 路由在浏览器中访问,该路由在 MainRouter 中定义如下:
mern-marketplace/client/MainRouter.js:
<Route path="/product/:productId" component={Product}/>
通过使用 useEffect 钩子调用相关 API 并使用路由参数中指定的 productId 来获取产品详情和相关产品列表数据,如下所示:
mern-marketplace/client/product/Product.js:
export default function Product ({match}) {
const [product, setProduct] = useState({shop:{}})
const [suggestions, setSuggestions] = useState([])
const [error, setError] = useState('')
useEffect(() => {
const abortController = new AbortController()
const signal = abortController.signal
read({productId: match.params.productId}, signal).then((data) => {
if (data.error) {
setError(data.error)
} else {
setProduct(data)
}
})
return function cleanup(){
abortController.abort()
}
}, [match.params.productId])
useEffect(() => {
const abortController = new AbortController()
const signal = abortController.signal
listRelated({
productId: match.params.productId}, signal).then((data) => {
if (data.error) {
setError(data.error)
} else {
setSuggestions(data)
}
})
return function cleanup(){
abortController.abort()
}
}, [match.params.productId])
在第一个 useEffect 钩子中,我们调用 read API 来检索指定的产品并将其设置到状态中。在第二个钩子中,我们调用 listRelated API 来获取相关产品的列表并将其设置到要作为属性传递给在产品视图中添加的 Suggestions 组件的状态中。
组件的产品详细信息部分显示有关产品的相关信息以及一个 Material-UI Card组件中的AddToCart组件,如下面的代码所示:
mern-marketplace/client/product/Product.js:
<Card>
<CardHeader
action={<AddToCart cartStyle={classes.addCart}
item={product}/>}
title={product.name}
subheader={product.quantity > 0? 'In Stock': 'Out of
Stock'}
/>
<CardMedia image={imageUrl} title={product.name}/>
<Typography component="p" variant="subtitle1">
{product.description}<br/>
$ {product.price}
<Link to={'/shops/'+product.shop._id}>
<Icon>shopping_basket</Icon> {product.shop.name}
</Link>
</Typography>
</Card>
在“产品”视图中添加了建议组件,该组件通过属性传递相关列表数据,如下所示:
mern-marketplace/client/product/Product.js:
<Suggestions products={suggestions} title='Related Products'/>
完成此视图后,市场应用程序的访客将能够了解更多关于特定产品的信息,以及探索其他类似的产品。在下一节中,我们将讨论如何为店主添加编辑和删除他们添加到市场中的产品的能力。
编辑和删除产品
在应用程序中编辑和删除产品的方法与编辑和删除商店的方法类似,如前几节所述,编辑商店和删除商店。这些功能将需要在后端使用相应的 API、在前端使用获取方法,以及带有表单和操作的 React 组件视图。在以下章节中,我们将突出显示编辑和从市场删除产品的前端视图、路由和后端 API 端点。
编辑
编辑功能与我们之前实现的创建产品功能非常相似。可以实现的EditProduct表单组件,可以渲染一个允许修改产品详细信息的表单,也仅对经过验证的卖家在/seller/:shopId/:productId/edit处可访问。
要限制对此视图的访问,我们可以在MainRouter中添加一个PrivateRoute来声明指向EditProduct视图的路由,如下所示:
mern-marketplace/client/MainRouter.js:
<PrivateRoute path="/seller/:shopId/:productId/edit" component={EditProduct}/>
EditProduct组件包含与NewProduct相同的表单,但使用读取产品 API 检索的产品值进行填充。在表单提交时,它使用fetch方法通过 PUT 请求将多部分表单数据发送到后端在/api/products/by/:shopId处的编辑产品 API。此编辑产品 API 的后端路由声明如下:
mern-marketplace/server/routes/product.routes.js:
router.route('/api/product/:shopId/:productId')
.put(authCtrl.requireSignin, shopCtrl.isOwner, productCtrl.update)
当授权用户向此 API 发送 PUT 请求时,将调用update控制器方法。它与产品create方法和商店update方法类似。它使用formidable处理多部分表单数据,并将产品详细信息扩展以保存到数据库中的更新。
此编辑产品表单视图的实现与后端更新 API 集成,将允许店主修改他们商店中产品的详细信息。接下来,我们将探讨将产品删除功能集成到应用程序中的重点。
删除
为了实现删除产品功能,我们可以实现一个类似于DeleteShop组件的DeleteProduct组件,并将其添加到MyProducts组件中,为列表中的每个产品。它可以从MyProducts组件中作为属性接收product对象、shopID和onRemove方法,如为店主提供的MyProducts组件部分所述。
组件将像DeleteShop一样工作,在按钮点击时打开一个确认对话框,然后,当用户确认删除意图后,调用用于删除的fetch方法,该方法向服务器在/api/product/:shopId/:productId处发出 DELETE 请求。此从数据库删除产品的后端 API 将如下声明,与其他产品路由一起声明:
mern-marketplace/server/routes/product.routes.js:
router.route('/api/product/:shopId/:productId')
.delete(authCtrl.requireSignin, shopCtrl.isOwner, productCtrl.remove)
如果授权用户向此 API 发出 DELETE 请求,则将调用remove控制器方法,并从数据库中删除指定的产品,就像为商店的remove控制器方法一样。
我们在本节中开始实现市场产品相关功能,首先定义一个用于存储产品详情的模式,然后讨论创建、列出、读取、更新和删除应用程序中产品的全栈切片。在下一节中,我们将探讨如何允许市场中的用户以不同的方式搜索产品,以便他们可以轻松找到他们想要的产品。
通过名称和类别搜索产品
在 MERN 市场,访客将能够通过名称和特定类别搜索特定产品。在接下来的章节中,我们将讨论如何通过首先查看从产品集合中检索独特类别的后端 API,并对存储的产品执行搜索查询来添加此搜索功能。然后,我们将讨论利用这些 API 的不同情况,例如执行搜索操作的视图和按类别显示产品的视图。
类别 API
为了允许用户选择一个特定的类别进行搜索,我们首先设置一个 API,该 API 从数据库中产品集合检索所有独特的类别。对/api/products/categories的GET请求将返回一个唯一类别的数组,并且此路由如以下所示声明:
mern-marketplace/server/routes/product.routes.js:
router.route('/api/products/categories')
.get(productCtrl.listCategories)
listCategories控制器方法使用以下代码对Products集合执行针对category字段的distinct调用:
mern-marketplace/server/controllers/product.controller.js:
const listCategories = async (req, res) => {
try {
let products = await Product.distinct('category',{})
res.json(products)
} catch (err){
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
此类别 API 可以在前端使用相应的fetch方法检索独特类别的数组并在视图中显示。这可以与搜索 API 配对,允许用户在特定类别中通过其名称搜索产品。在下一节中,我们将讨论此搜索 API。
搜索产品 API
我们可以定义一个搜索产品的 API,该 API 将接受一个GET请求,URL 为/api/products?search=value&category=value,其中 URL 中的查询参数用于查询包含提供的搜索文本和类别值的 Products 集合。此搜索 API 的路由定义如下:
mern-marketplace/server/routes/product.routes.js:
router.route('/api/products')
.get(productCtrl.list)
list控制器方法首先处理请求中的查询参数,然后在给定类别中查找具有与提供的搜索文本部分匹配的名称的产品(如果有的话)。此list方法定义如下:
mern-marketplace/server/controllers/product.controller.js:
const list = async (req, res) => {
const query = {}
if(req.query.search)
query.name = {'$regex': req.query.search, '$options': "i"}
if(req.query.category && req.query.category != 'All')
query.category = req.query.category
try {
let products = await Product.find(query)
.populate('shop', '_id name')
.select('-image').exec()
res.json(products)
} catch (err){
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
根据请求中提供的查询参数返回的结果产品将填充商店详情,并通过删除图像字段值进行缩小,然后作为响应发送回。为了在前端使用此 API 执行产品搜索,我们需要一个可以构造请求 URL 中查询参数的fetch方法,如下一节所述。
获取视图的搜索结果
为了在前端使用此搜索 API,我们将设置一个方法来构造带有查询参数的 URL,并调用fetch来向搜索产品 API 发起请求。此fetch方法定义如下。
mern-marketplace/client/product/api-product.js:
import queryString from 'query-string'
const list = (params) => {
const query = queryString.stringify(params)
return fetch('/api/products?'+query, {
method: 'GET',
}).then(response => {
return response.json()
}).catch((err) => console.log(err))
}
为了以正确的格式构造查询参数,我们将使用query-string节点模块,它将帮助将 params 对象转换为可以附加到请求路由 URL 的查询字符串。此 params 对象中的键和值将由调用此list方法的 React 组件定义。接下来,我们将查看Search组件,该组件将利用此方法使最终用户能够在市场上搜索产品。
搜索组件
将类别 API 和搜索 API 结合使用以执行搜索操作的第一个用例是在Search组件中。一旦实现并功能化,该组件将如图所示:

此Search组件为用户提供了一个简单的表单,包含一个搜索输入文本字段和一个来自父组件的类别选项下拉菜单,该父组件将使用不同的类别 API 检索列表。渲染此搜索表单视图的代码如下:
mern-marketplace/client/product/Search.js:
<TextField id="select-category" select label="Select category" value={category}
onChange={handleChange('category')}
selectProps={{ MenuProps: { className: classes.menu, } }}>
<MenuItem value="All"> All </MenuItem>
{props.categories.map(option => (
<MenuItem key={option} value={option}> {option} </MenuItem>
))}
</TextField>
<TextField id="search" label="Search products" type="search" onKeyDown={enterKey}
onChange={handleChange('search')}
/>
<Button raised onClick={search}> Search </Button>
当用户输入搜索文本并按下Enter键时,我们将调用search方法。为了检测是否按下了Enter键,我们使用TextField上的onKeyDown属性,并定义如下enterKey处理方法:
mern-marketplace/client/product/Search.js:
const enterKey = (event) => {
if(event.keyCode == 13){
event.preventDefault()
search()
}
}
“搜索”方法使用list获取方法调用搜索 API,并向它提供必要的搜索查询参数和值。此“搜索”方法定义如下所示:
mern-marketplace/client/product/Search.js:
const search = () => {
if(values.search){
list({
search: values.search || undefined, category: values.category
}).then((data) => {
if (data.error) {
console.log(data.error)
} else {
setValues({...values, results: data, searched:true})
}
})
}
}
在这个方法中,提供给list方法的查询参数是搜索文本值(如果有)和所选类别值。然后,从后端接收到的结果数组被设置为状态中的值,并作为属性传递给Products组件,如下所示,以在搜索表单下方渲染匹配的产品:
mern-marketplace/client/product/Search.js:
<Products products={results} searched={searched}/>
这个搜索视图为访客提供了一个有用的工具,可以在可能存储在完整市场数据库中的许多产品中查找他们想要的具体产品。在下一节中,我们将探讨在前端利用类别和搜索 API 的另一个简单用例。
类别组件
“类别”组件是独特类别和搜索 API 的第二个用例。对于这个组件,我们首先在父组件中获取类别列表,并将其作为属性发送以向用户显示类别,如下面的截图所示:

当用户在显示列表中选择一个类别时,会调用带有仅类别值的搜索 API,后端返回所选类别的所有产品。然后,这些返回的产品将在“产品”组件中渲染。这可以是一种简单的方法来组合这些 API,并向浏览市场的买家展示有意义的商品。
在这个 MERN 市场的第一个版本中,用户可以成为卖家来创建商店并添加产品,访客可以浏览商店并搜索产品,同时应用程序还会向访客推荐产品。
摘要
在本章中,我们开始使用 MERN 堆栈构建在线市场应用程序。MERN 骨架被扩展,以便用户可以拥有活跃的卖家账户,这样他们就可以创建商店并向每个商店添加产品,目的是向其他用户销售。我们还探讨了如何利用堆栈来实现产品浏览、搜索和为对购买感兴趣的普通用户提供建议等功能。
在浏览本章的实现过程中,我们探讨了如何通过全栈实现来奠定基础,以便能够组合和扩展诸如搜索和建议等有趣的功能。您可以在构建可能需要这些功能的其他全栈应用程序时应用这些相同的方法。
即使包含了这些功能,一个市场应用如果没有购物车、订单管理和支付处理功能,仍然是不完整的。在下一章中,我们将扩展我们的市场应用,添加这些高级功能,并了解如何使用 MERN 堆栈来实现电子商务应用的核心方面。
第十一章:扩展 MERN Marketplace 以支持订单和支付
当客户下单时处理客户支付,并允许卖家管理这些订单是电子商务应用的关键方面。在本章中,我们将通过实现买家将产品添加到购物车、结账和下单的能力,以及卖家管理这些订单和通过市场应用处理支付的能力,扩展我们在上一章中构建的在线市场。一旦你完成本章并添加了这些功能,除了扩展市场应用的高级功能外,你还将能够利用浏览器存储、使用 Stripe 处理支付,并将其他技术集成到这个堆栈中。
在本章中,我们将通过以下主题扩展在线市场:
-
在 MERN Marketplace 中引入购物车、支付和订单
-
实现购物车
-
使用 Stripe 进行支付
-
集成结账流程
-
创建新订单
-
列出每个商店的订单
-
查看单个订单详情
在 MERN Marketplace 中引入购物车、支付和订单
我们在第七章,《使用在线市场锻炼 MERN 技能》中开发的 MERN Marketplace 应用具有非常简单的功能,缺少核心的电子商务功能。在本章中,我们将扩展这个市场应用,使其包括买家的购物车功能、处理信用卡支付的 Stripe 集成以及卖家的基本订单管理流程。以下实现保持简单,作为开发您自己应用中这些功能的更复杂版本的起点。
完整的 MERN Marketplace 应用程序代码可在 GitHub 上找到:github.com/PacktPublishing/Full-Stack-React-Projects-Second-Edition/tree/master/Chapter07%20and%2008/mern-marketplace。你可以克隆此代码,并在阅读本章其余部分的代码解释时运行应用程序。要使 Stripe 支付代码工作,你需要创建自己的 Stripe 账户,并将config/config.js文件中的 Stripe API 密钥、密钥和 Stripe Connect 客户端 ID 更新为你的测试值。
下面的组件树图显示了构成 MERN Marketplace 前端的自定义组件,包括本章其余部分将实现的购物车、支付和与订单相关的功能:

本章将讨论的功能将修改一些现有组件,例如Profile、MyShops、Products和Suggestions,并添加新的组件,例如AddToCart、MyOrders、Cart和ShopOrders。在下一节中,我们将开始通过实现购物车来扩展在线市场。
实现购物车
访问 MERN 市场的访客可以通过点击每个产品的“添加到购物车”按钮将他们想要购买的产品添加到购物车中。菜单上的购物车图标将指示用户在浏览市场时已添加到购物车中的产品数量。他们还可以通过打开购物车视图来更新购物车的内容并开始结账过程。但为了完成结账过程并下订单,用户将需要登录。
购物车主要是前端功能,因此购物车详情将存储在客户端本地,直到用户在结账时下订单。为了实现购物车功能,我们将在client/cart/cart-helper.js中设置辅助方法,这将帮助从相关的 React 组件中操作购物车详情。
在以下几节中,我们将探讨如何将产品添加到购物车,更新菜单以显示购物车的状态,并实现购物车视图,用户可以在结账前查看和修改已添加到购物车中的所有项目。
添加到购物车
当在市场上浏览产品时,用户将看到在每个产品上添加到他们购物车的选项。这个选项将通过名为AddToCart的 React 组件实现。这个AddToCart组件在client/Cart/AddToCart.js中从它添加到的父组件接收product对象和 CSS 样式对象作为 props。例如,在 MERN 市场中,它被添加到产品视图如下:
<AddToCart cartStyle={classes.addCart} item={product}/>
当AddToCart组件渲染时,会根据传递的项目是否有库存显示购物车图标按钮,如下面的截图所示:

例如,如果项目数量大于0,则显示AddCartIcon;否则,渲染DisabledCartIcon。图标的显示取决于传递给 props 的 CSS 样式对象。渲染AddToCart按钮这些变体的代码如下。
mern-marketplace/client/cart/AddToCart.js:
{ props.item.quantity >= 0 ?
<IconButton color="secondary" dense="dense" onClick={addToCart}>
<AddCartIcon className={props.cartStyle || classes.iconButton}/>
</IconButton> :
<IconButton disabled={true} color="secondary" dense="dense">
<DisabledCartIcon className={props.cartStyle || classes.disabledIconButton}/>
</IconButton>
}
当点击AddCartIcon按钮时,会调用addToCart方法。addToCart方法定义如下。
mern-marketplace/client/cart/AddToCart.js:
const addToCart = () => {
cart.addItem(props.item, () => {
setRedirect({redirect:true})
})
}
addToCart方法调用在cart-helper.js中定义的addItem辅助方法。这个addItem方法接受product项目和状态更新callback函数作为参数,并将更新后的购物车详情存储在localStorage中,并执行传递的回调,如下面的代码所示。
mern-marketplace/client/cart/cart-helper.js:
addItem(item, cb) {
let cart = []
if (typeof window !== "undefined") {
if (localStorage.getItem('cart')) {
cart = JSON.parse(localStorage.getItem('cart'))
}
cart.push({
product: item,
quantity: 1,
shop: item.shop._id
})
localStorage.setItem('cart', JSON.stringify(cart))
cb()
}
}
存储在localStorage中的购物车数据包含一个购物车项目对象数组,每个对象包含产品详情、添加到购物车中的产品数量(默认设置为1)以及产品所属商店的 ID。当产品被添加到购物车并存储在localStorage中时,我们还将如下一节所述在导航菜单上显示更新的项目计数。
菜单中的购物车图标
在菜单中,我们将添加一个链接到购物车视图,以及一个显示存储在localStorage中的购物车数组长度的徽章,以便视觉上告知用户当前购物车中有多少个项目。渲染的链接和徽章将如下所示:

购物车链接将与菜单中的其他链接类似,但有一个例外,即 Material-UI Badge组件,它显示购物车长度。它将按如下方式添加:
mern-marketplace/client/core/Menu.js:
<Link to="/cart">
<Button color={isActive(history, "/cart")}>
Cart
<Badge invisible={false} color="secondary"
badgeContent= {cart.itemTotal()}>
<CartIcon />
</Badge>
</Button>
</Link>
购物车长度由cart-helper.js中的itemTotal辅助方法返回,该方法读取存储在localStorage中的cart数组并返回数组的长度。itemTotal方法定义如下。
mern-marketplace/client/cart/cart-helper.js:
itemTotal() {
if (typeof window !== "undefined") {
if (localStorage.getItem('cart')) {
return JSON.parse(localStorage.getItem('cart')).length
}
}
return 0
}
点击此购物车链接,菜单上显示项目总数,将用户带到购物车视图并显示已添加到购物车的项目详情。在下一节中,我们将讨论此购物车视图的实现。
购物车视图
购物车视图将包含购物车项目和结账详情。但最初,只有购物车详情将被显示,直到用户准备好结账。渲染此购物车视图的代码将按如下方式添加。
mern-marketplace/client/cart/Cart.js:
<Grid container spacing={24}>
<Grid item xs={6} sm={6}>
<CartItems checkout={checkout}
setCheckout={showCheckout}/>
</Grid>
{checkout &&
<Grid item xs={6} sm={6}>
<Checkout/>
</Grid>}
</Grid>
显示购物车项目的CartItems组件,它传递一个checkout布尔值和用于此结账值的州更新方法,以便根据用户交互有条件地渲染Checkout组件及其选项。
用于更新checkout值的showCheckout方法定义如下。
mern-marketplace/client/cart/Cart.js:
const showCheckout = val => {
setCheckout(val)
}
Cart组件将在/cart路由下访问,因此我们需要将Route添加到MainRouter组件中,如下所示。
mern-marketplace/client/MainRouter.js:
<Route path="/cart" component={Cart}/>
这是我们在菜单中使用的链接,用于将用户重定向到包含购物车详情的购物车视图。在下一节中,我们将查看CartItems组件的实现,该组件将渲染购物车中每个项目的详情并允许修改。
CartItems组件
CartItems组件将允许用户查看和更新他们购物车中的项目。它还将给他们提供选项,如果他们已登录,则可以开始结账过程,如下面的截图所示:

如果购物车包含项目,CartItems 组件将遍历项目并在购物车中渲染产品。如果没有添加任何项目,购物车视图将只显示一条消息,说明购物车为空。此实现的代码如下。
mern-marketplace/client/cart/CartItems.js:
{cartItems.length > 0 ? <span>
{cartItems.map((item, i) => {
...
… Display product details
… Edit quantity
… Remove product option
...
})
}
… Show total price and Checkout options …
</span> :
<Typography variant="subtitle1" component="h3" color="primary">
No items added to your cart.
</Typography>
}
对于每个产品项目,我们显示产品的详情和一个可编辑的数量文本字段,以及一个移除项目的选项。最后,我们显示购物车中项目的总价以及开始结账操作的选项。在接下来的章节中,我们将探讨这些购物车项目显示和修改选项的实现。
检索购物车详情
在显示购物车项目详情之前,我们需要检索存储在 localStorage 中的购物车详情。为此,我们在 cart-helper.js 中实现了 getCart 辅助方法,该方法从 localStorage 中检索并返回购物车详情,如下面的代码所示。
mern-marketplace/client/cart/cart-helper.js:
getCart() {
if (typeof window !== "undefined") {
if (localStorage.getItem('cart')) {
return JSON.parse(localStorage.getItem('cart'))
}
}
return []
}
在 CartItems 组件中,我们将使用 getCart 辅助方法检索购物车项目,并将其设置为 cartItems 的初始状态,如下面的代码所示。
mern-marketplace/client/cart/CartItems.js:
const [cartItems, setCartItems] = useState(cart.getCart())
然后,使用 map 函数遍历从 localStorage 中检索到的 cartItems 数组,以显示每个项目的详情,如下面的代码所示。
mern-marketplace/client/cart/CartItems.js:
<span key={i}>
<Card>
<CardMedia image={'/api/product/image/'+item.product._id}
title={item.product.name}/>
<CardContent>
<Link to={'/product/'+item.product._id}>
<Typography type="title" component="h3"
color="primary">
{item.product.name}</Typography>
</Link>
<Typography type="subheading" component="h3"
color="primary">
$ {item.product.price}
</Typography>
<span>${item.product.price * item.quantity}</span>
<span>Shop: {item.product.shop.name}</span>
</CardContent>
<div>
… Editable quantity …
… Remove item option ...
</div>
</Card>
<Divider/>
</span>
对于每个渲染的购物车项目,我们还将提供用户更改数量的选项,如下一节所述。
修改数量
在购物车视图中显示的每个购物车项目都将包含一个可编辑的 TextField,允许用户更新他们购买的每个产品的数量,最小允许值为 1,如下面的代码所示。
mern-marketplace/client/cart/CartItems.js:
Quantity: <TextField
value={item.quantity}
onChange={handleChange(i)}
type="number"
inputProps={{ min:1 }}
InputLabelProps={{
shrink: true,
}}
/>
当用户更新此值时,将调用 handleChange 方法以执行最小值验证,更新状态中的 cartItems,并使用辅助方法更新 localStorage 中的购物车。handleChange 方法定义如下。
mern-marketplace/client/cart/CartItems.js:
const handleChange = index => event => {
let updatedCartItems = cartItems
if(event.target.value == 0){
updatedCartItems[index].quantity = 1
}else{
updatedCartItems[index].quantity = event.target.value
}
setCartItems([...updatedCartItems])
cart.updateCart(index, event.target.value)
}
updateCart 辅助方法接受购物车数组中正在更新的产品的索引和新数量值作为参数,并更新存储在 localStorage 中的详情。此 updateCart 辅助方法定义如下。
mern-marketplace/client/cart/cart-helper.js:
updateCart(itemIndex, quantity) {
let cart = []
if (typeof window !== "undefined") {
if (localStorage.getItem('cart')) {
cart = JSON.parse(localStorage.getItem('cart'))
}
cart[itemIndex].quantity = quantity
localStorage.setItem('cart', JSON.stringify(cart))
}
}
除了在购物车中更新项目数量外,用户还可以选择从购物车中移除项目,如下一节所述。
移除项目
购物车中的每个项目旁边都将有一个移除选项。这个移除项目选项是一个按钮,当点击时,将传递项目的数组索引到 removeItem 方法,以便可以从数组中移除。此按钮的渲染代码如下。
mern-marketplace/client/cart/CartItems.js:
<Button color="primary" onClick={removeItem(i)}>x Remove</Button>
removeItem点击处理方法使用removeItem辅助方法从localStorage中的购物车移除项目,然后更新状态中的cartItems。此方法还检查购物车是否已清空,以便可以使用从Cart组件传递的属性作为setCheckout函数隐藏结账。removeItem点击处理方法定义如下。
mern-marketplace/client/cart/CartItems.js:
const removeItem = index => event =>{
let updatedCartItems = cart.removeItem(index)
if(updatedCartItems.length == 0){
props.setCheckout(false)
}
setCartItems(updatedCartItems)
}
cart-helper.js中的removeItem辅助方法接受要从中移除的产品索引,将其从数组中移除,并在返回更新后的cart数组之前更新localStorage。此removeItem辅助方法定义如下。
mern-marketplace/client/cart/cart-helper.js:
removeItem(itemIndex) {
let cart = []
if (typeof window !== "undefined") {
if (localStorage.getItem('cart')) {
cart = JSON.parse(localStorage.getItem('cart'))
}
cart.splice(itemIndex, 1)
localStorage.setItem('cart', JSON.stringify(cart))
}
return cart
}
当用户通过更改数量或移除项目来修改购物车中的项目时,他们也将看到当前购物车中所有项目的更新总价,如下一节所述。
显示总价
在CartItems组件的底部,我们将显示购物车中项目的总价。它将使用以下代码渲染。
mern-marketplace/client/cart/CartItems.js:
<span className={classes.total}>Total: ${getTotal()}</span>
getTotal方法将在考虑cartItems数组中每个项目的单价和数量时计算总价。此方法定义如下。
mern-marketplace/client/cart/CartItems.js:
const getTotal = () => {
return cartItems.reduce((a, b) => {
return a + (b.quantity*b.product.price)
}, 0)
}
通过这种方式,用户在准备结账并下订单之前,将能够了解他们要购买的内容及其费用概览。在下一节中,我们将探讨如何根据购物车状态和用户是否已登录有条件地渲染结账选项。
结账选项
用户将根据他们是否已登录以及结账是否已被打开,看到执行结账的选项,如以下代码所示。
mern-marketplace/client/cart/CartItems.js:
{!props.checkout && (auth.isAuthenticated() ?
<Button onClick={openCheckout}>
Checkout
</Button> :
<Link to="/signin">
<Button>Sign in to checkout</Button>
</Link>)
}
当点击结账按钮时,openCheckout方法将使用作为属性传递的setCheckout方法将Cart组件中的结账值设置为true。openCheckout方法定义如下。
mern-marketplace/client/cart/CartItems.js:
const openCheckout = () => {
props.setCheckout(true)
}
一旦在购物车视图中将结账值设置为true,Checkout组件将被渲染,以允许用户输入结账详情并下订单。
这将为用户完成购买过程,现在他们能够将项目添加到购物车并修改每个项目,直到他们准备结账。但在讨论结账功能的实现之前,该功能将涉及收集和处理支付信息,在下一节中,我们将讨论如何在我们的应用程序中使用 Stripe 添加预期的支付相关功能。
使用 Stripe 进行支付
在结账、订单创建和订单管理流程的实现中都需要支付处理。这还涉及到对买家和卖家用户数据的更新。在我们深入探讨结账和订单功能的实现之前,我们将简要讨论使用 Stripe 的支付处理选项和考虑因素,并学习如何在 MERN 市场中集成它。
Stripe 提供了一套广泛的工具,这些工具对于在任何 Web 应用程序中集成支付都是必要的。这些工具可以根据具体的应用类型和正在实施的支付用例以不同的方式选择和使用。
在 MERN 市场设置的情况下,应用程序本身将在 Stripe 上拥有一个平台,并期望卖家在平台上连接 Stripe 账户,以便应用程序可以代表卖家向在结账时输入信用卡详情的用户收费。在 MERN 市场中,用户可以将不同商店的产品添加到购物车中,这样只有在卖家处理订单时,应用程序才会为所订购的具体产品创建卡片上的费用。此外,卖家将能够通过自己的 Stripe 仪表板完全控制代表他们创建的费用。我们将演示如何使用 Stripe 提供的工具来使这个支付设置生效。
Stripe 为每个工具提供了一套完整的文档和指南,同时也为在 Stripe 上设置的账户和平台提供了测试数据。为了在 MERN 市场中实现支付,我们将使用测试密钥,并将其留给您来扩展实现以支持实时支付。
在以下章节中,我们将讨论如何为每个卖家连接 Stripe 账户,使用 Stripe 卡元素从用户那里收集信用卡详情,使用 Stripe 客户记录用户的支付信息以安全的方式,以及使用 Stripe 创建费用以处理支付。
为每个卖家提供 Stripe 连接的账户
为了代表卖家创建费用,应用程序将允许一个卖家用户将他们的 Stripe 账户连接到他们的 MERN 市场用户账户。在以下章节中,我们将通过更新用户模型以存储 Stripe 凭证,添加视图组件以允许用户连接到 Stripe,以及添加后端 API 以在更新数据库之前完成 Stripe OAuth 来实现这一功能。
更新用户模型
当卖家将他们的 Stripe 账户连接到市场时,我们需要将他们的 Stripe 凭证与他们其他用户详情一起存储,以便他们在销售产品时可以用于支付处理。为了在用户成功连接他们的 Stripe 账户后存储 Stripe OAuth 凭证,我们将更新我们在第三章,使用 MongoDB、Express 和 Node 构建后端中开发的用户模型,如下所示的字段。
mern-marketplace/server/models/user.model.js:
stripe_seller: {}
这个stripe_seller字段将存储从 Stripe 在认证过程中收到的卖家 Stripe 账户凭证。当需要通过 Stripe 处理他们从商店销售的产品时的费用时,将使用这些凭证。接下来,我们将查看前端组件,该组件将允许用户从我们的应用程序连接到 Stripe。
连接到 Stripe 的按钮
在卖家的用户资料页面中,如果用户尚未连接他们的 Stripe 账户,我们将显示一个按钮,该按钮将用户带到 Stripe 进行认证并连接他们的 Stripe 账户。连接到 Stripe 的按钮将在“资料”视图中如下渲染:

如果用户已经成功连接了他们的 Stripe 账户,我们将显示一个禁用的 STRIPE CONNECTED 按钮,如下面的截图所示:

添加到Profile组件中的代码将检查用户是否为卖家,然后再渲染与 Stripe 相关的按钮。然后,第二个检查将确认给定用户的stripe_seller字段中是否已经存在 Stripe 凭证。如果用户已经存在 Stripe 凭证,则显示禁用的STRIPE CONNECTED按钮;否则,将显示一个使用他们的 OAuth 链接连接到 Stripe 的链接,如以下代码所示。
mern-marketplace/client/user/Profile.js:
{user.seller && (user.stripe_seller ?
(<Button variant="contained" disabled className={classes.stripe_connected}>
Stripe connected
</Button>)
: (<a href={"https://connect.stripe.com/oauth/authorize? response_type=code&client_id=" +config.stripe_connect_test_client_id+"&scope=read_write"}
className={classes.stripe_connect}>
<img src={stripeButton}/>
</a>)
)
}
OAuth 链接包含平台的客户端 ID,我们将将其设置在config变量中,以及其他选项值作为查询参数。此链接将用户带到 Stripe,并允许用户连接现有的 Stripe 账户或创建一个新的账户。一旦 Stripe 的认证过程完成,它将使用在 Stripe 仪表板上的平台连接设置中设置的重定向 URL 返回到我们的应用程序。Stripe 将认证代码或错误消息附加为查询参数到重定向 URL。
MERN Marketplace 的重定向 URI 设置为/seller/stripe/connect,这将渲染StripeConnect组件。我们将如下声明此路由。
mern-marketplace/client/MainRouter.js:
<Route path="/seller/stripe/connect" component={StripeConnect}/>
当 Stripe 将用户重定向到这个 URL 时,我们将渲染StripeConnect组件,以便它处理 Stripe 对认证的响应,如下一节所述。
StripeConnect 组件
StripeConnect 组件将基本上完成与 Stripe 的剩余授权流程步骤,并根据 Stripe 连接是否成功渲染相关消息,如下面的截图所示:

当 StripeConnect 组件加载时,我们将使用 useEffect 钩子解析从 Stripe 重定向附加到 URL 的查询参数,如下面的代码所示。
mern-marketplace/client/user/StripeConnect.js:
useEffect(() => {
const abortController = new AbortController()
const signal = abortController.signal
const jwt = auth.isAuthenticated()
const parsed = queryString.parse(props.location.search)
if(parsed.error){
setValues({...values, error: true})
}
if(parsed.code){
setValues({...values, connecting: true, error: false})
//post call to stripe, get credentials and update user data
stripeUpdate({
userId: jwt.user._id
}, {
t: jwt.token
}, parsed.code, signal).then((data) => {
if (data.error) {
setValues({...values, error: true, connected: false,
connecting: false})
} else {
setValues({...values, connected: true,
connecting: false, error: false})
}
})
}
return function cleanup(){
abortController.abort()
}
}, [])
对于解析,我们使用之前用于实现产品搜索的相同 query-string 节点模块。然后,如果 URL 的 query 参数包含授权 code 而不是 error,我们将通过 stripeUpdate 获取方法在我们的服务器上发起 API 调用来完成 Stripe OAuth。
stripeUpdate 获取方法在 api-user.js 中定义,并将从 Stripe 检索到的授权码传递到我们将在服务器上设置的 '/api/stripe_auth/:userId' 的 API。此 stripeUpdate 获取方法定义如下。
mern-marketplace/client/user/api-user.js:
const stripeUpdate = async (params, credentials, auth_code, signal) => {
try {
let response = await fetch ('/api/stripe_auth/'+params.userId, {
method: 'PUT',
signal: signal,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
},
body: JSON.stringify({stripe: auth_code})
})
return await response.json()
} catch(err) {
console.log(err)
}
}
此获取方法正在调用我们必须在我们的服务器上添加的后端 API,以完成 OAuth 流程并将检索到的凭证保存到数据库中。我们将在下一节中实现此 API。
Stripe 授权更新 API
一旦连接了 Stripe 账户,为了完成 OAuth 流程,我们需要从我们的服务器向 Stripe OAuth 发起 POST API 调用。我们需要通过 POST API 调用将之前检索到的授权码发送给 Stripe OAuth,并接收要存储在卖家用户账户中以处理费用的凭证。我们将通过在后端实现更新 API 来实现此 Stripe 授权更新。此 Stripe 授权更新 API 将在 /api/stripe_auth/:userId 接收 PUT 请求并启动 POST API 调用来从 Stripe 获取凭证。
此 Stripe 授权更新 API 的路由将在服务器上的用户路由中声明,如下所示。
mern-marketplace/server/routes/user.routes.js:
router.route('/api/stripe_auth/:userId')
.put(authCtrl.requireSignin, authCtrl.hasAuthorization,
userCtrl.stripe_auth, userCtrl.update)
对此路由的请求使用 stripe_auth 控制器方法从 Stripe 获取凭证,并将其传递给现有的用户更新方法,以便它可以存储在数据库中。
为了从我们的服务器向 Stripe API 发起 POST 请求,我们将使用 request 节点模块,需要从命令行使用以下命令安装:
yarn add request
用户控制器中的 stripe_auth 控制器方法将定义如下。
mern-marketplace/server/controllers/user.controller.js:
const stripe_auth = (req, res, next) => {
request({
url: "https://connect.stripe.com/oauth/token",
method: "POST",
json: true,
body: { client_secret:config.stripe_test_secret_key,
code:req.body.stripe,
grant_type:'authorization_code'}
}, (error, response, body) => {
if(body.error){
return res.status('400').json({
error: body.error_description
})
}
req.body.stripe_seller = body
next()
})
}
使用 Stripe 的 POST API 调用需要平台的密钥和检索到的授权码来完成授权。然后,它将在 body 中返回连接账户的凭证,这些凭证随后被附加到请求体中,以便在 next() 调用 update 控制器方法时更新用户的详细信息。
从 Stripe 获取的这些认证凭据可以在我们的应用程序中使用,代表卖家在他们的商店销售产品时,在客户信用卡上创建费用。在下一节中,我们将学习如何使用 Stripe 在结账过程中收集客户信用卡详情。
Stripe Card Elements 用于结账
在结账过程中,为了从用户那里收集信用卡详情,我们将使用 Stripe 的 Card Elements 将信用卡字段添加到结账表单中。为了将 Card Elements 集成到我们的 React 接口中,我们将利用 react-stripe-elements 节点模块,可以通过在命令行中运行以下命令来安装:
yarn add react-stripe-elements
我们还需要将 Stripe.js 代码注入到 template.js 中,以便在前端代码中访问 Stripe,如下所示。
mern-marketplace/template.js:
<script id="stripe-js" src="img/"></script>
对于 MERN Marketplace,Stripe 将在购物车视图中需要,其中 Checkout 组件需要它来渲染 Card Elements 并处理卡详情输入。我们将使用来自 react-stripe-elements 的 StripeProvider 组件包装我们在 Cart.js 中添加的 Checkout 组件,以便 Checkout 中的 Elements 组件可以访问 Stripe 实例。
mern-marketplace/client/cart/Cart.js:
<StripeProvider apiKey={config.stripe_test_api_key}>
<Checkout/>
</StripeProvider>
然后,在 Checkout 组件中,我们将使用 Stripe 的 Elements 组件。使用 Stripe 的 Card Elements 将使应用程序能够收集用户的信用卡详情,并使用 Stripe 实例来标记卡信息,而不是在我们的服务器上处理。在结账过程中收集卡详情和生成卡令牌的实现细节将在 整合结账过程 和 创建新订单 部分中讨论。在下一节中,我们将讨论如何使用 Stripe 安全地记录从用户那里通过 Card Elements 收到的卡详情。
Stripe 客户用于记录卡详情
当在结账过程的最后放置订单时,生成的卡令牌将被用来创建或更新代表我们的用户的 Stripe 客户(stripe.com/docs/api#customers)。这是将信用卡信息(stripe.com/docs/saving-cards)存储在 Stripe 中以供进一步使用的好方法,例如,当卖家从他们的商店处理已订购的产品时,在购物车中对特定产品创建费用。这消除了需要在自己的服务器上安全存储用户信用卡详情的复杂性。为了将 Stripe 客户集成到我们的应用程序中,在接下来的章节中,我们将更新用户模型以便它存储 Stripe 客户详情,并更新用户控制器方法,以便我们可以使用后端的 Stripe 节点模块创建或更新 Stripe 客户信息。
更新用户模型
为了使用 Stripe 客户端安全地存储每个用户的信用卡信息并在应用中按需处理支付,我们需要存储与每个用户关联的 Stripe 客户端详情。为了跟踪我们数据库中用户的相应 Stripe 客户端信息,我们将更新用户模型,如下所示的字段:
stripe_customer: {},
此字段将存储一个 Stripe 客户端对象,这将允许我们创建周期性费用并跟踪与我们平台中同一用户关联的多个费用。为了能够创建或更新 Stripe 客户端,我们需要利用 Stripe 的客户 API。在下一节中,我们将更新用户控制器,以便我们可以集成和使用来自 Stripe 的此客户 API。
更新用户控制器
当用户在输入信用卡详情后下订单时,我们将创建一个新的或更新现有的 Stripe 客户端。为了实现这一点,我们将更新用户控制器,以便在服务器接收到创建订单 API 请求(如“创建新订单”部分所述)之前调用 stripeCustomer 方法。
在 stripeCustomer 控制器方法中,我们需要使用 stripe 节点模块,可以使用以下命令安装:
yarn add stripe
在安装了 stripe 模块后,需要将其导入到用户控制器文件中。然后,需要使用应用程序的 Stripe 秘密密钥初始化 stripe 实例。
mern-marketplace/server/controllers/user.controller.js:
import stripe from 'stripe'
const myStripe = stripe(config.stripe_test_secret_key)
stripeCustomer 控制器方法将检查当前用户是否已经在数据库中存储了相应的 Stripe 客户端,然后使用从前端接收到的卡令牌来创建一个新的 Stripe 客户端或更新现有的一个,如下文所述。
创建新的 Stripe 客户端
如果当前用户没有对应的 Stripe 客户端 - 也就是说,stripe_customer 字段没有存储值 - 我们将使用 Stripe 的创建客户 API (stripe.com/docs/api#create_customer),如下所示。
mern-marketplace/server/controllers/user.controller.js:
myStripe.customers.create({
email: req.profile.email,
source: req.body.token
}).then((customer) => {
User.update({'_id':req.profile._id},
{'$set': { 'stripe_customer': customer.id }},
(err, order) => {
if (err) {
return res.status(400).send({
error: errorHandler.getErrorMessage(err)
})
}
req.body.order.payment_id = customer.id
next()
})
})
如果 Stripe 客户端创建成功,我们将通过在 stripe_customer 字段中存储 Stripe 客户端 ID 引用来更新当前用户的数据。我们还将把此客户 ID 添加到正在下订单中,以便更容易创建与订单相关的费用。一旦创建了 Stripe 客户端,我们就可以在用户为新的订单输入信用卡详情时更新 Stripe 客户端,如下一节所述。
更新现有的 Stripe 客户端
对于现有的 Stripe 客户端 - 也就是说,当前用户已经在 stripe_customer 字段中存储了值 - 我们将使用 Stripe API 来更新一个 Stripe 客户端,如下所示。
mern-marketplace/server/controllers/user.controller.js:
myStripe.customers.update(req.profile.stripe_customer, {
source: req.body.token
},
(err, customer) => {
if(err){
return res.status(400).send({
error: "Could not update charge details"
})
}
req.body.order.payment_id = customer.id
next()
})
一旦成功更新 Stripe 客户,我们将在next()调用中将客户 ID 添加到正在创建的订单中。虽然这里没有涉及,但 Stripe 客户功能可以用来允许用户从应用程序中存储和更新他们的信用卡信息。随着用户的支付信息被安全存储并可供访问,我们可以探讨如何使用这些信息在卖家处理订购产品时处理支付。
为每个处理的产品创建费用
当卖家通过处理他们在商店中订购的产品来更新订单时,应用程序将代表卖家在客户的信用卡上创建一个费用,费用为订购产品的成本。
为了实现这一点,我们将更新user.controller.js文件,添加一个createCharge控制器方法,该方法将使用 Stripe 的创建费用 API,并需要卖家的 Stripe 账户 ID 以及买家的 Stripe 客户 ID。createCharge控制器方法将定义如下。
mern-marketplace/server/controllers/user.controller.js:
const createCharge = (req, res, next) => {
if(!req.profile.stripe_seller){
return res.status('400').json({
error: "Please connect your Stripe account"
})
}
myStripe.tokens.create({
customer: req.order.payment_id,
}, {
stripeAccount: req.profile.stripe_seller.stripe_user_id,
}).then((token) => {
myStripe.charges.create({
amount: req.body.amount * 100, //amount in cents
currency: "usd",
source: token.id,
}, {
stripeAccount: req.profile.stripe_seller.stripe_user_id,
}).then((charge) => {
next()
})
})
}
如果卖家尚未连接他们的 Stripe 账户,createCharge方法将返回一个 400 错误响应,以指示需要连接的 Stripe 账户。
为了能够代表卖家的 Stripe 账户向 Stripe 客户收费,我们需要使用客户 ID 和卖家的 Stripe 账户 ID 生成一个 Stripe 令牌,然后使用该令牌创建费用。
当服务器接收到更新订单的请求,并将产品状态更改为处理中时(此订单更新请求的 API 实现将在按商店列出订单部分讨论),将调用createCharge控制器方法。
这涵盖了所有与 MERN Marketplace 特定用例的支付处理相关的 Stripe 相关概念。现在,我们将继续我们的实现,以便允许用户完成结账过程并从购物车中下单。
集成结账过程
已登录并已将商品添加到购物车的用户将能够开始结账过程。我们将添加一个结账表单来收集客户详情、送货地址信息和信用卡信息,如下面的截图所示:

此结账视图将包括两部分,第一部分用于收集买家的详细信息,包括姓名、电子邮件和送货地址,第二部分用于输入信用卡详情并下单。在以下章节中,我们将通过初始化结账表单详情并添加收集买家详情的字段来完成结账过程的实现。然后,我们将收集买家的信用卡详情,以便他们可以下单并完成结账过程。
初始化结账详情
在本节中,我们将创建结算视图,该视图包含表单字段和“下单”选项,位于“结算”组件中。在这个组件中,我们将在从表单收集详细信息之前在状态中初始化checkoutDetails对象。我们将根据当前用户的详细信息预先填充客户信息,并将当前购物车商品添加到checkoutDetails中,如下面的代码所示。
mern-marketplace/client/cart/Checkout.js:
const user = auth.isAuthenticated().user
const [values, setValues] = useState({
checkoutDetails: {
products: cart.getCart(),
customer_name: user.name,
customer_email:user.email,
delivery_address: { street: '', city: '', state: '',
zipcode: '', country:''}
},
error: ''
})
这些客户信息值,在checkoutDetails中初始化,将在用户与表单字段交互时更新。在以下章节中,我们将添加收集在此结算视图中要收集的客户信息和送货地址详细信息的表单字段和更改处理函数。
客户信息
在结算表单中,我们将有收集客户姓名和电子邮件地址的字段。为了将这些文本字段添加到Checkout组件中,我们将使用以下代码。
mern-marketplace/client/cart/Checkout.js:
<TextField id="name" label="Name" value={values.checkoutDetails.customer_name} onChange={handleCustomerChange('customer_name')}/>
<TextField id="email" type="email" label="Email" value={values.checkoutDetails.customer_email} onChange={handleCustomerChange('customer_email')}/><br/>
当用户更新这两个字段的值时,handleCustomerChange方法将更新状态中的相关详细信息。handleCustomerChange方法定义如下。
mern-marketplace/client/cart/Checkout.js:
const handleCustomerChange = name => event => {
let checkoutDetails = values.checkoutDetails
checkoutDetails[name] = event.target.value || undefined
setValues({...values, checkoutDetails: checkoutDetails})
}
这将使用户能够更新与该订单关联的客户的姓名和电子邮件。接下来,我们将查看收集此订单送货地址详细信息实现的示例。
送货地址
为了从用户那里收集送货地址,我们将在结算表单中添加收集地址详细信息(如街道地址、城市、州、邮政编码和国家名称)的字段。我们将使用以下代码添加文本字段,以便用户输入这些地址详细信息。
mern-marketplace/client/cart/Checkout.js:
<TextField id="street" label="Street Address" value= {values.checkoutDetails.delivery_address.street} onChange={handleAddressChange('street')}/>
<TextField id="city" label="City" value={values.checkoutDetails.delivery_address.city} onChange={handleAddressChange('city')}/>
<TextField id="state" label="State" value={values.checkoutDetails.delivery_address.state} onChange={handleAddressChange('state')}/>
<TextField id="zipcode" label="Zip Code" value={values.checkoutDetails.delivery_address.zipcode} onChange={handleAddressChange('zipcode')}/>
<TextField id="country" label="Country" value={values.checkoutDetails.delivery_address.country} onChange={handleAddressChange('country')}/>
当用户更新这些地址字段时,handleAddressChange方法将更新状态中的相关详细信息,如下所示。
mern-marketplace/client/cart/Checkout.js:
const handleAddressChange = name => event => {
let checkoutDetails = values.checkoutDetails
checkoutDetails.delivery_address[name] =
event.target.value || undefined
setValues({...values, checkoutDetails: checkoutDetails})
}
在这些文本字段和处理更改函数就绪后,状态中的checkoutDetails对象将包含用户输入的客户信息和送货地址。在下一节中,我们将从买家那里收集支付信息,并将其与其他结算详细信息一起使用,以完成结算过程并下单。
下单
结账过程的剩余步骤将涉及安全地收集用户的信用卡详细信息,从而使用户能够下订单,从存储中清空购物车,并将用户重定向到包含订单详情的视图。我们将通过构建一个 PlaceOrder 组件来实现这些步骤,该组件由结账视图中的剩余元素组成,即信用卡字段和下订单按钮。在接下来的章节中,当我们开发这个组件时,我们将使用 Stripe 卡元素来收集信用卡详细信息,为用户添加一个完成结账过程的下订单按钮,利用购物车辅助方法清空购物车,并将用户重定向到订单视图。
使用 Stripe 卡元素
为了使用来自 react-stripe-elements 的 Stripe 的 CardElement 组件将信用卡字段添加到 PlaceOrder 组件中,我们需要使用 Stripe 的 injectStripe 高阶组件(HOC)来包装 PlaceOrder 组件。
这是因为 CardElement 组件需要成为由 injectStripe 构建,并用 Elements 组件包装的支付表单组件的一部分。因此,当我们创建一个名为 PlaceOrder 的组件时,我们将在导出之前用 injectStripe 包装它,如下面的代码所示。
mern-marketplace/client/cart/PlaceOrder.js:
const PlaceOrder = (props) => { … }
PlaceOrder.propTypes = {
checkoutDetails: PropTypes.object.isRequired
}
export default injectStripe(PlaceOrder)
然后,我们将此 PlaceOrder 组件添加到结账表单中,将其 checkoutDetails 对象作为属性传递,并用来自 react-stripe-elements 的 Elements 组件包装,如下所示。
mern-marketplace/client/cart/Checkout.js:
<Elements> <PlaceOrder checkoutDetails={values.checkoutDetails} /> </Elements>
injectStripe HOC 提供了 props.stripe 属性,该属性管理 Elements 组。这将允许我们在 PlaceOrder 中调用 props.stripe.createToken,将卡详细信息提交给 Stripe 并获取卡令牌。接下来,我们将学习如何使用 Stripe CardElement 组件在 PlaceOrder 组件内部收集信用卡详细信息。
The CardElement component
Stripe 的 CardElement 是自包含的,因此我们只需将其添加到 PlaceOrder 组件中,然后根据需要添加样式,信用卡详细信息输入将由它处理。我们将按照以下方式将 CardElement 组件添加到 PlaceOrder 中。
mern-marketplace/client/cart/PlaceOrder.js:
<CardElement className={classes.StripeElement}
{...{style: {
base: {
color: '#424770',
letterSpacing: '0.025em',
'::placeholder': {
color: '#aab7c4',
},
},
invalid: {
color: '#9e2146',
},
}}}/>
这将在结账表单视图中渲染信用卡详细信息字段。在下一节中,我们将学习如何安全地验证和存储用户在点击按钮下订单并完成结账过程时在此字段中输入的信用卡详细信息。
添加一个下订单按钮
在结账视图中的最后一个元素是“下订单”按钮,如果所有详细信息都正确输入,它将完成结账过程。我们将在 CardElement 之后将此按钮添加到 PlaceOrder 组件中,如下面的代码所示。
mern-marketplace/client/cart/PlaceOrder.js:
<Button color="secondary" variant="raised" onClick={placeOrder}>Place Order</Button>
点击“下单”按钮将调用placeOrder方法,该方法将尝试使用stripe.createToken对卡详情进行标记化。如果这失败,用户将被告知错误,但如果成功,则结账详情和生成的卡标记将被发送到我们的服务器创建订单 API(下一节将介绍)。placeOrder方法定义如下。
mern-marketplace/client/cart/PlaceOrder.js:
const placeOrder = ()=>{
props.stripe.createToken().then(payload => {
if(payload.error){
setValues({...values, error: payload.error.message})
}else{
const jwt = auth.isAuthenticated()
create({userId:jwt.user._id}, {
t: jwt.token
}, props.checkoutDetails, payload.token.id).then((data) => {
if (data.error) {
setValues({...values, error: data.error})
} else {
cart.emptyCart(()=> {
setValues({...values, 'orderId':data._id,'redirect': true})
})
}
})
}
})
}
我们在这里调用的create fetch 方法用于向后端创建订单 API 发起 POST 请求,定义在client/order/api-order.js中。它接受结账详情、卡标记和用户凭证作为参数,并将它们发送到 API,如前述 API 实现中所示。当新订单成功创建时,我们将在localStorage中清空购物车,如下一节所述。
清空购物车
如果向创建订单 API 的请求成功,我们将清空localStorage中的购物车,以便用户可以添加新项目到购物车,并在需要的情况下下单。为了在浏览器存储中清空购物车,我们将使用cart-helper.js中的emptyCart辅助方法,其定义如下。
mern-marketplace/client/cart/cart-helper.js:
emptyCart(cb) {
if(typeof window !== "undefined"){
localStorage.removeItem('cart')
cb()
}
}
emptyCart方法从localStorage中移除购物车对象,并通过执行从placeOrder方法传递给它的回调来更新视图的状态,其中它被调用。在结账过程完成后,我们现在可以将用户从购物车和结账视图中重定向出去,如下一节所述。
重定向到订单视图
下单完成后,购物车清空,我们可以将用户重定向到订单查看页面,该页面将显示他们刚刚下单的订单详情。为了实现这个重定向,我们可以使用 React Router 中的 Redirect 组件,如下面的代码所示。
mern-marketplace/client/cart/PlaceOrder.js:
if (values.redirect) {
return (<Redirect to={'/order/' + values.orderId}/>)
}
这种重定向也作为用户结账过程完成的指示。完成的结账过程还会在应用程序的后端创建一个新的订单。在下一节中,我们将探讨创建和存储这些新订单到数据库的实现。
创建新订单
当用户下单时,结账时确认的订单详情将被用于在数据库中创建一个新的订单记录,更新或为用户创建 Stripe 客户,并减少已订购产品的库存数量。在接下来的几节中,我们将添加一个订单模型来定义要存储在数据库中的订单详情,并讨论从前端调用以创建新订单记录的后端 API 的实现。
定义订单模型
为了在后端存储订单,我们将为订单模型定义一个架构,该架构将记录订单详情,包括客户详情、支付信息以及所订购产品的数组。这个数组中每个产品的结构将在一个单独的子架构 CartItemSchema 中定义。在接下来的章节中,我们将定义这些架构,以便我们可以在数据库中存储订单和购物车项目。
Order 架构
在 server/models/course.model.js 中定义的 Order 架构将包含用于存储客户姓名和电子邮件、用户账户引用、送货地址信息、支付引用、创建和更新时间戳以及所订购产品数组的字段。定义订单字段的相关代码如下:
- 客户姓名和电子邮件:为了记录订单的目标客户的详细信息,我们将向
Order架构添加customer_name和customer_email字段:
customer_name: { type: String, trim: true, required: 'Name is required' },
customer_email: { type: String, trim: true,
match: [/.+\@.+\..+/, 'Please fill a valid email address'],
required: 'Email is required' }
- 下单用户:为了引用下单的已登录用户,我们将添加一个
ordered_by字段:
ordered_by: {type: mongoose.Schema.ObjectId, ref: 'User'}
- 送货地址:订单的送货地址信息将存储在具有
street、city、state、zipcode和country字段的送货地址子文档中:
delivery_address: {
street: {type: String, required: 'Street is required'},
city: {type: String, required: 'City is required'},
state: {type: String},
zipcode: {type: String, required: 'Zip Code is required'},
country: {type: String, required: 'Country is required'}
},
- 支付引用:当订单更新并且卖家处理完所订购的产品后需要创建费用时,支付信息将是相关的。我们将记录与信用卡详情相关的 Stripe 客户 ID,并将其作为
payment_id字段中的引用,以记录此订单的支付信息:
payment_id: {},
- 所订购的产品:订单的主要内容将是所订购产品的列表,以及如每个产品的数量等详细信息。我们将在
Order架构中的products字段中记录此列表。每个产品的结构将在CartItemSchema中单独定义。
mern-marketplace/server/models/order.model.js:
products: [CartItemSchema],
该架构定义中的字段将使我们能够存储每个订单所需的详细信息。用于记录所订购的每个产品详情的 CartItemSchema 将在下一节中讨论。
CartItem 架构
当下单时,CartItem 架构将代表所订购的每个产品。它将包含对产品的引用、用户所订购的产品数量、对产品所属商店的引用以及其状态,如下面的代码所示。
mern-marketplace/server/models/order.model.js:
const CartItemSchema = new mongoose.Schema({
product: {type: mongoose.Schema.ObjectId, ref: 'Product'},
quantity: Number,
shop: {type: mongoose.Schema.ObjectId, ref: 'Shop'},
status: {type: String,
default: 'Not processed',
enum: ['Not processed' , 'Processing', 'Shipped', 'Delivered',
'Cancelled']}
})
const CartItem = mongoose.model('CartItem', CartItemSchema)
产品的 status 只能具有在 enums 中定义的值,默认值设置为 "未处理"。这代表产品订单的当前状态,由卖家更新。
这里定义的 Order 架构和 CartItem 架构将允许我们记录有关客户和已订购产品的详细信息,以便完成用户购买产品的购买步骤。接下来,我们将讨论允许前端在数据库的 Orders 集合中创建订单文档的后端 API 实现。
创建订单 API
后端创建订单 API 将从前端接收 POST 请求以在数据库中创建订单。API 路由将在 server/routes/order.routes.js 中声明,以及其他订单路由。这些订单路由将与用户路由非常相似。为了在 Express 应用中加载订单路由,我们需要在 express.js 中挂载路由,就像我们为 auth 和用户路由所做的那样。
mern-marketplace/server/express.js:
app.use('/', orderRoutes)
当创建订单 API 在 /api/orders/:userId 接收到 POST 请求时,以下一系列动作发生:
-
确保当前用户已登录。
-
Stripe
Customer是通过我们之前在 Stripe Customer to record card details 部分讨论过的stripeCustomer用户控制器方法创建或更新的。 -
使用
decreaseQuanity产品控制器方法更新所有已订购产品的库存数量。 -
使用
create订单控制器方法在订单集合中创建订单。
此创建订单 API 的路由定义如下。
mern-marketplace/server/routes/order.routes.js:
router.route('/api/orders/:userId')
.post(authCtrl.requireSignin, userCtrl.stripeCustomer,
productCtrl.decreaseQuantity, orderCtrl.create)
要检索与路由中的 :userId 参数关联的用户,我们将使用 userByID 用户控制器方法。我们将编写处理此参数的代码,包括其他订单路由声明。
mern-marketplace/server/routes/order.routes.js:
router.param('userId', userCtrl.userByID)
userByID 方法从 User 集合中获取用户并将其附加到请求对象中,以便后续几个方法可以访问。当此 API 收到请求时,将调用包括产品控制器方法以减少库存数量和订单控制器方法将新订单保存到数据库在内的几个后续方法。我们将在以下几节中讨论这两个方法的实现。
减少产品库存数量
当下单时,我们将根据用户订购的数量减少每个产品的库存数量。订单下单后,这将自动反映相关商店中产品的更新数量。我们将在 decreaseQuantity 控制器方法中实现此产品数量减少更新,该方法将与其他产品控制器方法一起添加,如下所示。
mern-marketplace/server/controllers/product.controller.js:
const decreaseQuantity = async (req, res, next) => {
let bulkOps = req.body.order.products.map((item) => {
return {
"updateOne": {
"filter": { "_id": item.product._id } ,
"update": { "$inc": {"quantity": -item.quantity} }
}
}
})
try {
await Product.bulkWrite(bulkOps, {})
next()
} catch (err){
return res.status(400).json({
error: "Could not update product"
})
}
}
由于在此情况下,更新操作涉及在匹配到已订购的产品数组后对集合中的多个产品进行批量更新,因此我们使用 MongoDB 的bulkWrite方法通过一个命令向 MongoDB 服务器发送多个updateOne操作。所需的多个updateOne操作使用map函数列在bulkOps中。这将比发送多个独立的保存或更新操作更快,因为使用bulkWrite(),只有一个往返 MongoDB。
此方法更新产品数量后,将调用下一个方法以将新订单保存到数据库中。在下一节中,我们将看到此方法的实现,它创建这个新订单。
创建控制器方法
在订单控制器中定义的create控制器方法是在创建订单 API 收到请求时调用的最后一个方法。此方法接受订单详情,创建一个新订单并将其保存到 MongoDB 的订单集合中。create控制器方法实现如下。
mern-marketplace/server/controllers/order.controller.js:
const create = async (req, res) => {
try {
req.body.order.user = req.profile
let order = new Order(req.body.order)
let result = await order.save()
res.status(200).json(result)
} catch (err){
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
实现这一点后,任何在 MERN Marketplace 上注册的用户都可以在后台创建和存储订单。现在,我们可以设置 API 来获取用户订单列表、商店订单列表或读取单个订单,并将获取的数据显示在前端视图中。在下一节中,我们将学习如何按商店列出订单,以便店主可以处理和管理他们收到的产品订单。
按商店列出订单
市场的一个重要功能是允许卖家查看和更新他们商店中收到的产品订单的状态。为了实现这一点,我们将设置后端 API 来按商店列出订单并更新订单,当卖家更改已购买产品的状态时。然后,我们将添加一些前端视图来显示订单并允许卖家与每个订单进行交互。
按商店列表 API
在本节中,我们将实现一个 API 来获取特定商店的订单,以便认证的卖家可以在一个地方查看他们每个商店的订单。这个 API 的请求将在/api/orders/shop/:shopId接收,路由在order.routes.js中定义,如下所示。
mern-marketplace/server/routes/order.routes.js:
router.route('/api/orders/shop/:shopId')
.get(authCtrl.requireSignin, shopCtrl.isOwner, orderCtrl.listByShop)
router.param('shopId', shopCtrl.shopByID)
为了检索与路由中的:shopId参数关联的商店,我们将使用shopByID商店控制器方法,该方法从商店集合中获取商店并将其附加到请求对象中,以便后续方法可以访问。
listByShop控制器方法将检索使用匹配的商店 ID 购买的产品订单,然后为每个产品填充 ID、名称和价格字段,按日期从最近到最远排序订单。listByShop控制器方法定义如下。
mern-marketplace/server/controllers/order.controller.js:
const listByShop = async (req, res) => {
try {
let orders = await Order.find({"products.shop": req.shop._id})
.populate({path: 'products.product', select: '_id name price'})
.sort('-created')
.exec()
res.json(orders)
} catch (err){
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
为了在前端获取此 API,我们将在api-order.js中添加相应的listByShop方法,类似于我们的其他 API 实现。此获取方法将在ShopOrders组件中使用,以显示每个商店的订单。我们将在下一节中查看ShopOrders组件的实现。
ShopOrders 组件
ShopOrders组件将是卖家可以看到给定商店收到的订单列表的视图。在此视图中,每个订单将仅显示与商店相关的购买产品,并允许卖家使用可能的状

为了在前端路由中渲染此视图,我们将使用PrivateRoute更新MainRouter,以便在/seller/orders/:shop/:shopId路由中加载ShopOrders组件,如下面的代码所示。
mern-marketplace/client/MainRouter.js:
<PrivateRoute path="/seller/orders/:shop/:shopId" component={ShopOrders}/>
访问此链接将加载视图中的ShopOrders组件。在ShopOrders组件中,我们将获取并列出给定商店的订单,并为每个订单,我们将使用名为ProductOrderEdit的 React 组件渲染订单详情以及已订购的产品列表。在接下来的章节中,我们将学习如何加载订单列表并讨论ProductOrderEdit组件的实现。
列出订单
当ShopOrders组件在视图中挂载时,我们将从数据库中检索提供的商店 ID 的订单列表,并将其设置为要在视图中渲染的状态。我们将通过使用listByShop获取方法向后端 API 请求按商店列出订单,并在useEffect钩子中将检索到的订单设置到状态中,如下面的代码所示。
mern-marketplace/client/order/ShopOrders.js:
useEffect(() => {
const jwt = auth.isAuthenticated()
const abortController = new AbortController()
const signal = abortController.signal
listByShop({
shopId: match.params.shopId
}, {t: jwt.token}, signal).then((data) => {
if (data.error) {
console.log(data)
} else {
setOrders(data)
}
})
return function cleanup(){
abortController.abort()
}
}, [])
在视图中,我们将遍历订单列表,并将每个订单渲染为Material-UI的可折叠列表,点击时展开。此视图的代码将如下添加。
mern-marketplace/client/order/ShopOrders.js:
<Typography type="title"> Orders in {match.params.shop} </Typography>
<List dense> {orders.map((order, index) => { return
<span key={index}>
<ListItem button onClick={handleClick(index)}>
<ListItemText primary={'Order # '+order._id}
secondary={(new Date(order.created)).toDateString()}/>
{open == index ? <ExpandLess /> : <ExpandMore />}
</ListItem>
<Collapse component="li" in={open == index}
timeout="auto" unmountOnExit>
<ProductOrderEdit shopId={match.params.shopId}
order={order} orderIndex={index}
updateOrders={updateOrders}/>
<Typography type="subheading"> Deliver to:</Typography>
<Typography type="subheading" color="primary">
{order.customer_name} ({order.customer_email})
</Typography>
<Typography type="subheading" color="primary">
{order.delivery_address.street}</Typography>
<Typography type="subheading" color="primary">
{order.delivery_address.city},
{order.delivery_address.state}
{order.delivery_address.zipcode}</Typography>
<Typography type="subheading" color="primary">
{order.delivery_address.country}</Typography>
</Collapse>
</span>})}
</List>
每个展开的订单将显示订单详情和ProductOrderEdit组件。ProductOrderEdit组件将显示购买的产品,并允许卖家编辑每个产品的状态。updateOrders方法作为 prop 传递给ProductOrderEdit组件,以便在产品状态更改时更新状态。updateOrders方法定义如下。
mern-marketplace/client/order/ShopOrders.js:
const updateOrders = (index, updatedOrder) => {
let updatedOrders = orders
updatedOrders[index] = updatedOrder
setOrders([...updatedOrders])
}
在ProductOrderEdit组件中,当卖家与ProductOrderEdit组件中将要渲染的任何产品的状态更新下拉菜单交互时,我们将调用此updateOrders方法。在下一节中,我们将探讨ProductOrderEdit组件的实现。
ProductOrderEdit 组件
在本节中,我们将实现一个ProductOrderEdit组件来渲染订单中的所有产品,并带有编辑状态选项。这个ProductOrderEdit组件将接受一个订单对象作为属性,遍历订单的products数组以显示仅从当前商店购买的产品,以及一个下拉菜单来更改每个产品的状态值。渲染每个订单产品的代码如下所示。
mern-marketplace/client/order/ProductOrderEdit.js:
{props.order.products.map((item, index) => { return <span key={index}>
{ item.shop == props.shopId &&
<ListItem button>
<ListItemText primary={ <div>
<img src=
{'/api/product/image/'+item.product._id}/>
{item.product.name}
<p>{"Quantity: "+item.quantity}</p>
</div>}/>
<TextField id="select-status" select
label="Update Status" value={item.status}
onChange={handleStatusChange(index)}
SelectProps={{
MenuProps: { className: classes.menu },
}}>
{statusValues.map(option => (
<MenuItem key={option} value={option}>
{option}
</MenuItem>
))}
</TextField>
</ListItem>}
为了能够在下拉菜单选项中列出有效的状态值以更新已订购产品的状态,我们将在ProductOrderEdit组件的useEffect钩子中从服务器检索可能的州值列表,如下面的代码所示。
mern-marketplace/client/order/ProductOrderEdit.js:
useEffect(() => {
const abortController = new AbortController()
const signal = abortController.signal
getStatusValues(signal).then((data) => {
if (data.error) {
setValues({...values, error: "Could not get status"})
} else {
setValues({...values, statusValues: data, error: ''})
}
})
return function cleanup(){
abortController.abort()
}
}, [])
从服务器检索的状态值被设置为状态并作为MenuItem渲染在下拉菜单中。当从下拉菜单中选择可能的州值时,将调用handleStatusChange方法来更新状态中的订单,以及根据所选值向适当的后端 API 发送请求。handleStatusChange方法的结构如下,根据所选的状态值调用不同的后端 API。
mern-marketplace/client/order/ProductOrderEdit.js:
const handleStatusChange = productIndex => event => {
let order = props.order
order.products[productIndex].status = event.target.value
let product = order.products[productIndex]
if (event.target.value == "Cancelled") {
// 1\. ... call the cancel product API ..
} else if (event.target.value == "Processing") {
// 2\. ... call the process charge API ...
} else {
// 3\. ... call the order update API ...
}
更新已订购产品的状态将根据从下拉菜单中选择的值产生不同的影响。选择取消或处理产品订单将调用后端中的单独 API,而不是选择其他任何状态值时调用的 API。在以下章节中,我们将学习当用户与下拉菜单交互并选择状态值时,如何在handleStatusChange方法中处理这些操作。
处理取消产品订单的操作
如果卖家希望取消产品的订单,并在订单中特定产品的状态值下拉菜单中选择已取消,我们将在handleStatusChange方法中调用cancelProduct获取方法,如下面的代码所示。
mern-marketplace/client/order/ProductOrderEdit.js:
cancelProduct({
shopId: props.shopId,
productId: product.product._id
}, {
t: jwt.token
}, {
cartItemId: product._id,
status: event.target.value,
quantity: product.quantity
})
.then((data) => {
if (data.error) {
setValues({
...values,
error: "Status not updated, try again"
})
} else {
props.updateOrders(props.orderIndex, order)
setValues({
...values,
error: ''
})
}
})
cancelProduct获取方法将接受相应的商店 ID、产品 ID、购物车项目 ID、所选状态值、产品的订购数量以及要发送的用户凭据,以及向后端取消产品 API 发送请求。在从后端收到成功响应后,我们将更新视图中的订单。
此取消产品 API 将更新受此操作影响的订单和产品的数据库。在深入实现此取消产品订单 API 之前,接下来,我们将看看如果卖家选择处理产品订单而不是取消它,将如何调用处理收费 API。
处理对产品进行收费的操作
如果卖家选择处理产品的订单,我们需要调用一个 API 来向客户收取订购产品的总费用。因此,当卖家在订单中为特定产品选择状态值下拉菜单中的“处理”时,我们将在handleStatusChange方法内部调用processChargefetch 方法,如下面的代码所示。
mern-marketplace/client/order/ProductOrderEdit.js:
processCharge({
userId: jwt.user._id,
shopId: props.shopId,
orderId: order._id
}, {
t: jwt.token
}, {
cartItemId: product._id,
status: event.target.value,
amount: (product.quantity * product.product.price)
})
.then((data) => {
if (data.error) {
setValues({
...values,
error: "Status not updated, try again"
})
} else {
props.updateOrders(props.orderIndex, order)
setValues({
...values,
error: ''
})
}
})
processChargefetch 方法将获取相应的订单 ID、商店 ID、客户的用户 ID、购物车项目 ID、选定的状态值、订购产品的总费用和用户凭据,以及发送到后端处理费用 API 的请求。在收到后端成功的响应后,我们将相应地更新视图中的订单。
此过程费用 API 将更新受此操作影响的订单和用户的数据库。在深入了解此 API 的实现之前,接下来,我们将查看如果卖家选择将已订购产品的状态更新为除已取消或处理之外的其他值时,如何调用更新订单 API。
处理更新产品状态的操作
如果卖家选择更新已订购产品的状态,使其具有除已取消或处理之外的其他值,我们需要调用一个 API 来更新数据库中订单的更改产品状态。因此,当卖家在订单中为特定产品选择下拉菜单中的其他状态值时,我们将在handleStatusChange方法内部调用updatefetch 方法,如下面的代码所示。
mern-marketplace/client/order/ProductOrderEdit.js:
update({
shopId: props.shopId
}, {
t: jwt.token
}, {
cartItemId: product._id,
status: event.target.value
})
.then((data) => {
if (data.error) {
setValues({
...values,
error: "Status not updated, try again"
})
} else {
props.updateOrders(props.orderIndex, order)
setValues({
...values,
error: ''
})
}
})
updatefetch 方法将获取相应的商店 ID、购物车项目 ID、选定的状态值和用户凭据,以及发送到后端更新订单 API 的请求。在收到后端成功的响应后,我们将更新视图中的订单。
cancelProduct、processCharge和updatefetch 方法定义在api-order.js中,以便它们可以调用后端中相应的 API 来更新已取消产品的库存数量,在处理产品订单时在客户的信用卡上创建费用,以及分别更新订单的产品状态更改。接下来,我们将查看这些 API 的实现。
订购产品的 API
允许卖家更新产品的状态将需要设置四个不同的 API,包括一个用于检索可能状态值的 API。然后,实际的状态更新操作将需要 API 来处理订单本身的状态更新,以便启动相关操作,例如增加已取消产品的库存数量,以及在处理产品时在客户的信用卡上创建费用。在以下部分,我们将查看检索可能状态值、更新订单状态、取消产品订单和处理已订购产品费用的 API 实现。
获取状态值
订单产品的可能状态值在CartItem模式中设置为枚举。为了将这些值作为选项显示在下拉视图中,我们将在/api/order/status_values上设置一个 GET API 路由,以检索这些值。此 API 路由将声明如下。
mern-marketplace/server/routes/order.routes.js:
router.route('/api/order/status_values')
.get(orderCtrl.getStatusValues)
getStatusValues控制器方法将返回CartItem模式中status字段的枚举值。getStatusValues控制器方法定义如下。
mern-marketplace/server/controllers/order.controller.js:
const getStatusValues = (req, res) => {
res.json(CartItem.schema.path('status').enumValues)
}
我们还需要在api-order.js中设置一个相应的fetch方法,该方法用于视图中的ProductOrderEdit组件,以便向此 API 发出请求,检索状态值,并在下拉菜单中渲染这些值。在下一节中,我们将查看更新订单 API 端点,当卖家从下拉菜单中选择相关状态值时需要调用此端点。
更新订单状态
当产品的状态更改为除处理中或已取消之外的任何值时,将对'/api/order/status/:shopId'发出 PUT 请求,直接在数据库中更新订单,前提是当前用户是订购产品的商店的验证所有者。我们将如此声明此更新 API 的路由。
mern-marketplace/server/routes/order.routes.js:
router.route('/api/order/status/:shopId')
.put(authCtrl.requireSignin, shopCtrl.isOwner, orderCtrl.update)
update控制器方法将查询订单集合,找到与更新产品匹配的CartItem对象,并在订单的products数组中设置此匹配的CartItem的status值。update控制器方法定义如下。
mern-marketplace/server/controllers/order.controller.js:
const update = async (req, res) => {
try {
let order = await Order.updateOne({'products._id': req.body.cartItemId}, {
'products.$.status': req.body.status
})
res.json(order)
} catch (err){
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
要从前端访问此 API,我们将在api-order.js中添加一个update获取方法,以便调用此更新 API,并传递从视图传递的所需参数。update获取方法将定义如下。
mern-marketplace/client/order/api-order.js:
const update = async (params, credentials, product) => {
try {
let response = await fetch('/api/order/status/' + params.shopId, {
method: 'PUT',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
},
body: JSON.stringify(product)
})
return response.json()
} catch(err){
console.log(err)
}
}
当卖家从订单产品下拉菜单中选择除“处理”或“已取消”之外的任何值时,会调用update获取方法,该方法在ProductOrderEdit视图中被调用。在下一节中,我们将查看取消产品订单 API,如果卖家选择“已取消”作为值,则会调用此 API。
取消产品订单
当卖家决定取消产品的订单时,将向/api/order/:shopId/cancel/:productId发送 PUT 请求,以便增加产品的库存数量并在数据库中更新订单。为了实现此取消产品订单 API,我们将声明 API 路由如下。
mern-marketplace/server/routes/order.routes.js:
router.route('/api/order/:shopId/cancel/:productId')
.put(authCtrl.requireSignin, shopCtrl.isOwner,
productCtrl.increaseQuantity, orderCtrl.update)
router.param('productId', productCtrl.productByID)
要检索与路由中productId参数关联的产品,我们也将使用productByID产品控制器方法。这将检索产品并将其附加到请求对象中,以便next方法可以访问。
当此 API 收到请求时,要更新产品的库存数量,我们将使用添加到product.controller.js中的increaseQuantity控制器方法,如下所示。
mern-marketplace/server/controllers/product.controller.js:
const increaseQuantity = async (req, res, next) => {
try {
await Product.findByIdAndUpdate(req.product._id,
{$inc: {"quantity": req.body.quantity}}, {new: true})
.exec()
next()
} catch (err){
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
increaseQuantity控制器方法通过在产品集合中找到匹配的 ID 来查找产品,并将数量值增加客户订购的数量。现在订单已取消,它执行此操作。
从视图中,我们将使用添加到api-order.js中的相应获取方法来调用此取消产品订单 API。cancelProduct获取方法定义如下。
mern-marketplace/client/order/api-order.js:
const cancelProduct = async (params, credentials, product) => {
try {
let response = await fetch('/api/order/'+params.shopId+'/cancel/'+params.productId, {
method: 'PUT',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
},
body: JSON.stringify(product)
})
return response.json()
}catch(err){
console.log(err)
}
}
当卖家在订单产品下拉菜单中选择已取消时,会调用cancelProduct获取方法,该方法在ProductOrderEdit视图中被调用。在下一节中,我们将查看处理费用 API,如果卖家选择“处理”作为状态值,则会调用此 API。
为产品处理费用
当卖家将已订购产品的状态更改为处理时,我们将设置后端 API 不仅更新订单,还要为客户信用卡创建产品价格乘以订购数量的费用。此 API 的路由声明如下。
mern-marketplace/server/routes/order.routes.js:
router.route('/api/order/:orderId/charge/:userId/:shopId')
.put(authCtrl.requireSignin, shopCtrl.isOwner,
userCtrl.createCharge, orderCtrl.update)
router.param('orderId', orderCtrl.orderByID)
要检索路由中与orderId参数关联的订单,我们将使用orderByID订单控制器方法,该方法从订单集合中获取订单并将其附加到请求对象中,以便next方法可以访问。此orderByID方法定义如下。
mern-marketplace/server/controllers/order.controller.js:
const orderByID = async (req, res, next, id) => {
try {
let order = await Order.findById(id)
.populate('products.product', 'name price')
.populate('products.shop', 'name').exec()
if (!order)
return res.status('400').json({
error: "Order not found"
})
req.order = order
next()
} catch (err){
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
订单费用 API 将在/api/order/:orderId/charge/:userId/:shopId接收一个 PUT 请求。在成功验证用户后,它将通过调用我们在使用 Stripe 进行支付部分讨论的createCharge用户控制器来创建费用。最后,将使用我们在更新订单状态部分讨论的update控制器方法更新相应的订单。
从视图中,我们将使用api-order.js中的processCharge获取方法,并提供所需的路由参数值、凭证和产品详情,包括要收取的金额。processCharge获取方法定义如下。
mern-marketplace/client/order/api-order.js:
const processCharge = async (params, credentials, product) => {
try {
let response = await fetch('/api/order/'+params.orderId+
'/charge/'+params.userId+'/'+params.shopId, {
method: 'PUT',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
},
body: JSON.stringify(product)
})
return response.json()
} catch(err) {
console.log(err)
}
}
当卖家从下拉菜单中选择“处理”时,ProductOrderEdit视图中会调用此processCharge获取方法。
在这些实现到位后,卖家可以查看他们每个店铺收到的产品订单,并轻松更新每个已订购产品的状态,同时应用程序处理其他任务,例如更新库存数量和启动支付。这涵盖了 MERN Marketplace 应用程序的基本订单管理功能,可以根据需要进一步扩展。在下一节中,我们将讨论如何轻松扩展当前实现以实现其他用于显示订单详情的视图。
查看单次订单详情
在设置了订单集合和数据库访问之后,继续前进,很容易添加为每个用户列出订单以及在一个单独的视图中显示单个订单的详细信息的功能,用户可以在该视图中跟踪每个已订购产品的状态。可以设计并实现一个视图来向客户展示单个订单的详细信息,其外观如下所示:

按照本书中反复提到的步骤设置后端 API 以检索数据并在前端构建前端视图,你可以根据需要开发订单相关的视图。例如,可以渲染以下视图来显示单个用户已下订单:

你可以将构建 MERN Marketplace 应用程序全栈功能时学到的经验应用到实现这些订单详情视图中,并从 MERN Marketplace 应用程序的这些样本视图快照中汲取灵感。
在本章和第七章(03fd3b4a-b7fd-4b42-ad7e-5bc34b5612b0.xhtml)中开发的 MERN Marketplace 应用程序,使用在线市场锻炼 MERN 技能,通过构建 MERN 骨架应用程序涵盖了标准在线市场应用程序的关键功能。这反过来又展示了如何扩展 MERN 堆栈以包含复杂功能。
摘要
在本章中,我们扩展了 MERN Marketplace 应用程序,探讨了如何在在线市场应用程序中为买家添加购物车,实现带有信用卡支付的结账流程,以及为卖家管理订单。
我们发现,当我们实现购物车结账流程并使用 Stripe 提供的工具处理订单产品的信用卡费用时,MERN 堆叠技术可以很好地与第三方集成。
我们还解锁了 MERN 的更多可能性,例如在 MongoDB 中优化批量写入操作,以响应单个 API 调用更新多个文档。这使得我们能够一次性减少多个产品的库存数量,例如当用户从不同商店订购多个产品时。
通过我们探索的这些新方法和实现,你可以轻松集成支付处理,在浏览器中使用离线存储,以及为任何你选择的基于 MERN 的应用程序执行批量数据库操作。
你在 MERN Marketplace 应用程序中开发的市场功能揭示了如何通过添加可能简单或更复杂的特性来利用这个堆叠和结构设计和构建不断增长的应用程序。
在下一章中,我们将利用本书中迄今为止学到的经验,通过扩展这个 MERN Marketplace 应用程序,使其包含实时竞标功能,来探索这个堆叠的更多高级可能性。
第十二章:向市场添加实时竞价功能
在比以往任何时候都更加紧密联系的世界中,即时通信和实时更新是任何允许用户之间互动的应用程序所期望的行为。向您的应用程序添加实时功能可以使您的用户保持参与,因此他们将在您的平台上花费更多时间。在本章中,我们将学习如何使用 MERN 堆栈技术以及 Socket.IO,轻松地将实时行为集成到全栈应用程序中。我们将通过在我们在第七章“使用在线市场锻炼 MERN 技能”和第八章“扩展市场以支持订单和支付”中开发的 MERN 市场应用程序中集成具有实时竞价功能的拍卖功能来实现这一点。在完成此拍卖和竞价功能的实现后,您将了解如何在 MERN 堆栈应用程序中利用套接字添加您选择的实时功能。
在本章中,我们将通过以下主题扩展在线市场应用程序:
-
在 MERN 市场中引入实时竞价
-
向市场添加拍卖
-
显示拍卖视图
-
使用 Socket.IO 实现实时竞价
在 MERN 市场中引入实时竞价
MERN 市场应用程序已经允许其用户成为卖家并维护有产品可供普通用户购买商店。在本章中,我们将扩展这些功能,允许卖家为其他用户创建在固定时间内可以出价的拍卖物品。拍卖视图将描述待售物品,并允许已登录用户在拍卖进行时进行出价。不同的用户可以放置自己的出价,并实时看到其他用户出价,视图将相应更新。完成的拍卖视图,其中拍卖处于活动状态,将呈现如下:

完整的 MERN 市场应用代码可在 GitHub 上找到,网址为 github.com/PacktPublishing/Full-Stack-React-Projects-Second-Edition/tree/master/Chapter09/mern-marketplace-bidding。本章中讨论的实现可以在存储库的 bidding 分支中访问。您可以克隆此代码,并在阅读本章其余部分的代码解释时运行应用程序。
以下组件树图显示了构成整个 MERN 市场前端的所有自定义组件,包括将在本章其余部分实现拍卖和竞价相关功能的组件:

本章将讨论的功能将修改一些现有组件,例如Profile和Menu,并添加新组件,例如NewAuction、MyAuctions、Auction和Bidding。在下一节中,我们将通过集成将拍卖添加到平台的功能来扩展这个在线市场。
将拍卖添加到市场
在 MERN 市场,我们将允许已登录并拥有活跃卖家账户的用户为其他用户想要下注的物品创建拍卖。为了启用添加和管理拍卖的功能,我们需要定义如何存储拍卖详情并实现全栈切片,使用户能够在平台上创建、访问和更新拍卖。在以下章节中,我们将为应用程序构建这个拍卖模块。首先,我们将使用 Mongoose 模式定义拍卖模型,以存储每个拍卖的详情。然后,我们将讨论后端 API 和前端视图的实现,这些是实现创建新拍卖、列出由同一卖家创建并由同一用户下注的正在进行的拍卖以及通过编辑详情或从应用程序中删除拍卖来修改现有拍卖所需的。
定义拍卖模型
我们将实现一个 Mongoose 模型,该模型将定义一个用于存储每个拍卖详情的拍卖模型。此模型将在server/models/auction.model.js中定义,其实现将与我们在前几章中介绍的其他 Mongoose 模型实现类似,例如我们在第七章中定义的 Shop 模型,使用在线市场锻炼 MERN 技能。此模型中的 Auction 模式将包含存储拍卖详情的字段,例如拍卖物品的名称和描述、图片以及创建此拍卖的卖家引用。它还将包含指定此拍卖下注开始和结束时间的字段、下注的起始值以及为此拍卖已放置的下注列表。定义这些拍卖字段的代码如下:
- 物品名称和描述:拍卖物品名称和描述字段将是字符串类型,其中
itemName为必填字段:
itemName: {
type: String,
trim: true,
required: 'Item name is required'
},
description: {
type: String,
trim: true
},
- 项目图片:
image字段将存储代表拍卖物品的图片文件,以便用户可以上传并作为数据存储在 MongoDB 数据库中:
image: {
data: Buffer,
contentType: String
},
- 卖家:
seller字段将引用创建拍卖的用户:
seller: {
type: mongoose.Schema.ObjectId,
ref: 'User'
},
- 创建和更新时间:
created和updated字段将是Date类型,其中created在添加新拍卖时生成,而updated在修改任何拍卖详情时更改:
updated: Date,
created: {
type: Date,
default: Date.now
},
- 拍卖开始时间:
bidStart字段将是一个Date类型,用于指定拍卖何时开始,以便用户可以开始下注:
bidStart: {
type: Date,
default: Date.now
},
- 出价结束时间:
bidEnd字段将是一个Date类型,用于指定拍卖何时结束,之后用户将无法对此拍卖进行出价:
bidEnd: {
type: Date,
required: "Auction end time is required"
},
- 起始出价:
startingBid字段将存储Number类型的值,并指定此次拍卖的起始价格:
startingBid: {
type: Number,
default: 0
},
- 出价列表:
bids字段将是一个包含对拍卖所出每个出价详情的数组。当我们将出价存储在这个数组中时,我们将最新的出价推送到数组的开头。每个出价将包含放置出价的用户的引用、用户提供的出价金额以及出价放置的时间戳:
bids: [{
bidder: {type: mongoose.Schema.ObjectId, ref: 'User'},
bid: Number,
time: Date
}]
这些与拍卖相关的字段将使我们能够为 MERN Marketplace 应用程序实现拍卖和竞标相关功能。在下一节中,我们将通过实现全栈切片来开始开发这些功能,这将允许卖家创建新的拍卖。
创建新的拍卖
为了使卖家能够在平台上创建新的拍卖,我们需要集成一个全栈切片,允许用户在前端填写表单视图,然后将输入的详细信息保存到后端数据库中的新拍卖文档中。为了实现此功能,在接下来的章节中,我们将在后端添加创建拍卖 API,以及在前端获取此 API 的方法,以及一个创建新拍卖表单视图,该视图接受用户对拍卖字段的输入。
创建拍卖 API
为了实现允许我们在数据库中创建新拍卖的后端 API,我们将声明一个 POST 路由,如下面的代码所示。
mern-marketplace/server/routes/auction.routes.js:
router.route('/api/auctions/by/:userId')
.post(authCtrl.requireSignin, authCtrl.hasAuthorization,
userCtrl.isSeller, auctionCtrl.create)
向/api/auctions/by/:userId此路由发送 POST 请求将确保请求的用户已登录并且也已授权。换句话说,它是与路由参数中指定的:userId关联的同一用户。然后,在创建拍卖之前,将使用在用户控制器方法中定义的isSeller方法检查此给定用户是否为卖家。
为了处理:userId参数并从数据库中检索关联的用户,我们将利用用户控制器方法中的userByID方法。我们将在auction.routes.js中的Auction路由中添加以下内容,以便用户在request对象中作为profile可用。
mern-marketplace/server/routes/auction.routes.js:
router.param('userId', userCtrl.userByID)
包含拍卖路由的auction.routes.js文件将与user.routes文件非常相似。为了在 Express 应用程序中加载这些新的拍卖路由,我们需要在express.js中挂载拍卖路由,就像我们为认证和用户路由所做的那样。
mern-marketplace/server/express.js:
app.use('/', auctionRoutes)
在卖家验证后调用的拍卖控制器中的create方法,使用formidable节点模块解析可能包含用户上传的物品图片的多部分请求。如果有文件,formidable将暂时将其存储在文件系统中,我们将使用fs模块读取它以检索文件类型和数据,以便我们可以将其存储在拍卖文档的image字段中。
create控制器方法将如下所示。
mern-marketplace/server/controllers/auction.controller.js:
const create = (req, res) => {
let form = new formidable.IncomingForm()
form.keepExtensions = true
form.parse(req, async (err, fields, files) => {
if (err) {
res.status(400).json({
message: "Image could not be uploaded"
})
}
let auction = new Auction(fields)
auction.seller= req.profile
if(files.image){
auction.image.data = fs.readFileSync(files.image.path)
auction.image.contentType = files.image.type
}
try {
let result = await auction.save()
res.status(200).json(result)
}catch (err){
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
})
}
拍卖的物品图片文件由用户上传并存储在 MongoDB 中作为数据。然后,为了在视图中显示,它作为单独的 GET API 从数据库中检索出来,作为一个图片文件。这个 GET API 被设置为 Express 路由在/api/auctions/image/:auctionId,它从 MongoDB 获取图像数据并将其作为文件发送在响应中。文件上传、存储和检索的实现步骤在第五章“将骨架扩展成社交媒体应用”的上传个人照片部分中详细说明。
现在可以在前端使用这个创建拍卖 API 端点来发起 POST 请求。接下来,我们将在客户端添加一个 fetch 方法,从应用程序的客户端界面发起这个请求。
在视图中获取创建 API
在前端,为了向这个创建 API 发起请求,我们将在客户端设置一个fetch方法,向 API 路由发送 POST 请求,并在body中传递包含新拍卖详情的多部分表单数据。这个 fetch 方法将定义如下。
mern-marketplace/client/auction/api-auction.js:
const create = (params, credentials, auction) => {
return fetch('/api/auctions/by/'+ params.userId, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer ' + credentials.t
},
body: auction
})
.then((response) => {
return response.json()
}).catch((err) => console.log(err))
}
从服务器接收到的响应将返回给调用这个 fetch 方法的组件。我们将使用这个方法在新拍卖表单视图中发送用户输入的拍卖详情到后端并在数据库中创建一个新的拍卖。在下一节中,我们将实现这个新的拍卖表单视图在 React 组件中。
新拍卖组件
市场应用中的卖家将通过表单视图输入新拍卖的详情并创建新的拍卖。我们将在这个NewAuction组件中渲染这个表单,允许卖家通过输入项目名称和描述、从本地文件系统上传图片文件、指定起始出价值以及为这个拍卖的起始和结束出价创建日期时间值来创建拍卖。
这个表单视图将渲染如下:

这个 NewAuction 组件的实现与其他我们之前讨论过的创建表单实现类似,例如来自第七章 [03fd3b4a-b7fd-4b42-ad7e-5bc34b5612b0.xhtml],使用在线市场锻炼 MERN 技能 的 NewShop 组件实现。在这个表单组件中不同的字段是拍卖开始和结束时间的日期时间输入选项。为了添加这些字段,我们将使用 Material-UI 的 TextField 组件,并将 type 设置为 datetime-local,如下面的代码所示。
mern-marketplace/client/auction/NewAuction.js:
<TextField
label="Auction Start Time"
type="datetime-local"
defaultValue={defaultStartTime}
onChange={handleChange('bidStart')}
/>
<TextField
label="Auction End Time"
type="datetime-local"
defaultValue={defaultEndTime}
onChange={handleChange('bidEnd')}
/>
我们还为这些字段分配了默认的日期时间值,格式与该输入组件期望的格式一致。我们将默认开始时间设置为当前日期时间,默认结束时间设置为当前日期时间后一小时,如下所示。
mern-marketplace/client/auction/NewAuction.js:
const currentDate = new Date()
const defaultStartTime = getDateString(currentDate)
const defaultEndTime = getDateString(new Date(currentDate.setHours(currentDate.getHours()+1)))
类型为 datetime-local 的 TextField 以 yyyy-mm-ddThh:mm 的格式接受日期。因此,我们定义了一个 getDateString 方法,该方法接受一个 JavaScript 日期对象并相应地格式化它。getDateString 方法的实现如下。
mern-marketplace/client/auction/NewAuction.js:
const getDateString = (date) => {
let year = date.getFullYear()
let day = date.getDate().toString().length === 1 ? '0' + date.getDate() : date.getDate()
let month = date.getMonth().toString().length === 1 ? '0' + (date.getMonth()+1) : date.getMonth() + 1
let hours = date.getHours().toString().length === 1 ? '0' + date.getHours() : date.getHours()
let minutes = date.getMinutes().toString().length === 1 ? '0' + date.getMinutes() : date.getMinutes()
let dateString = `${year}-${month}-${day}T${hours}:${minutes}`
return dateString
}
为了确保用户正确地输入了日期,开始时间设置为早于结束时间的值,我们需要在将表单详情提交到后端之前添加一个检查。日期组合的验证可以通过以下代码确认。
mern-marketplace/client/auction/NewAuction.js:
if(values.bidEnd < values.bidStart){
setValues({...values, error: "Auction cannot end before it starts"})
}
如果发现日期组合无效,则用户将被告知,并且不会将表单数据发送到后端。
这个 NewAuction 组件只能由登录且也是卖家的用户查看。因此,我们将在 MainRouter 组件中添加一个 PrivateRoute。这将在这个 MainRouter 组件中为经过身份验证的用户渲染 /auction/new 的表单。
mern-marketplace/client/MainRouter.js:
<PrivateRoute path="/auction/new" component={NewAuction}/>
这个链接可以添加到任何卖家可能访问的视图组件中,例如,在一个卖家在市场中管理他们的拍卖的视图中。现在,在市场中添加新的拍卖成为可能,在下一节中,我们将讨论如何从后端数据库中检索这些拍卖,以便它们可以在前端视图中列出。
列出拍卖
在 MERN 市场应用中,我们将向用户展示三个不同的拍卖列表。所有浏览平台的用户都将能够查看当前正在进行的拍卖,换句话说,即那些正在直播或将在未来某个日期开始的拍卖。卖家将能够查看他们创建的拍卖列表,而登录用户将能够查看他们投过标的拍卖列表。展示给所有用户的开放拍卖列表将如下渲染,提供每个拍卖的摘要,并有一个选项让用户可以在单独的视图中查看更多详细信息:

在以下章节中,为了实现这些不同的拍卖列表以便在应用程序中显示,我们将分别定义三个单独的后端 API 来检索开放拍卖、卖家拍卖和出价者拍卖。然后,我们将实现一个可重用的 React 组件,该组件将接受作为属性提供的任何拍卖列表并将其渲染到视图中。这将允许我们在使用相同组件的同时显示所有三个拍卖列表。
开放式拍卖 API
为了从数据库中检索开放拍卖的列表,我们将定义一个后端 API,该 API 接受 GET 请求并查询拍卖集合,以便在响应中返回找到的开放拍卖。为了实现这个开放拍卖 API,我们将声明一个路由,如下所示。
mern-marketplace/server/routes/auction.routes.js:
router.route('/api/auctions')
.get(auctionCtrl.listOpen)
当在/api/auctions路由上接收到 GET 请求时,将调用listOpen控制器方法,该方法将查询数据库中的拍卖集合,以便返回所有截至日期大于当前日期的拍卖。listOpen方法定义如下。
mern-marketplace/server/controllers/auction.controller.js:
const listOpen = async (req, res) => {
try {
let auctions = await Auction.find({ bidEnd: { $gt: new Date() }})
.sort('bidStart')
.populate('seller', '_id name')
.populate('bids.bidder', '_id name')
res.json(auctions)
} catch (err){
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
由listOpen方法查询返回的拍卖将按起始日期排序,较早开始的拍卖将首先显示。这些拍卖还将包含卖家的 ID 和名称详情以及每个出价者的详情。结果拍卖数组将发送回请求客户端的响应。
为了在前端获取此 API,我们将在api-auction.js中添加相应的listOpen方法,类似于其他 API 实现。此获取方法将用于在前端组件中显示开放拍卖给用户。接下来,我们将实现另一个 API 来列出特定用户参与的所有拍卖。
按出价者拍卖 API
为了能够显示给定用户参与的所有拍卖,我们将定义一个后端 API,该 API 接受 GET 请求并查询拍卖集合,以便在响应中返回相关的拍卖。为了实现按出价者拍卖 API,我们将声明一个路由,如下所示。
mern-marketplace/server/routes/auction.routes.js
router.route('/api/auctions/bid/:userId')
.get(auctionCtrl.listByBidder)
当在/api/auctions/bid/:userId路由上接收到 GET 请求时,将调用listByBidder控制器方法,该方法将查询数据库中的拍卖集合,以便返回所有包含与路由中指定的userId参数匹配的出价者的出价的拍卖。listByBidder方法定义如下。
mern-marketplace/server/controllers/auction.controller.js:
const listByBidder = async (req, res) => {
try {
let auctions = await Auction.find({'bids.bidder': req.profile._id})
.populate('seller', '_id name')
.populate('bids.bidder', '_id name')
res.json(auctions)
} catch (err){
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
此方法将向请求客户端返回结果拍卖,并且每个拍卖还将包含卖家和每个竞标者的 ID 和名称详情。为了在前端获取此 API,我们将在api-auction.js中添加相应的listByBidder方法,类似于其他 API 实现。此获取方法将用于显示与特定竞标者相关的拍卖的前端组件。接下来,我们将实现一个 API,该 API 将列出特定卖家在市场上创建的所有拍卖。
卖家拍卖 API
市场中的卖家将看到他们创建的拍卖列表。为了从数据库中检索这些拍卖,我们将定义一个后端 API,该 API 接受 GET 请求并查询拍卖集合,以便返回特定卖家的拍卖。为了实现此卖家拍卖 API,我们将声明一个路由,如下所示。
mern-marketplace/server/routes/auction.routes.js:
router.route('/api/auctions/by/:userId')
.get(authCtrl.requireSignin, authCtrl.hasAuthorization,
auctionCtrl.listBySeller)
当在/api/auctions/by/:userId路由接收到 GET 请求时,将调用listBySeller控制器方法,该方法将查询数据库中的拍卖集合,以便返回所有与通过路由中userId参数指定的用户匹配的卖家拍卖。listBySeller方法定义如下。
mern-marketplace/server/controllers/auction.controller.js:
const listBySeller = async (req, res) => {
try {
let auctions = await Auction.find({seller: req.profile._id})
.populate('seller', '_id name')
.populate('bids.bidder', '_id name')
res.json(auctions)
} catch (err){
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
此方法将向请求客户端返回指定卖家的拍卖,并且每个拍卖还将包含卖家和每个竞标者的 ID 和名称详情。
为了在前端获取此 API,我们将在api-auction.js中添加相应的listBySeller方法,类似于其他 API 实现。此获取方法将用于显示与特定卖家相关的拍卖的前端组件。在下一节中,我们将查看拍卖组件的实现,该组件将接受任何这些拍卖列表并将其显示给最终用户。
拍卖组件
应用程序中的不同拍卖列表将通过一个 React 组件渲染,该组件接受拍卖对象数组作为属性。我们将实现这个可重用的Auctions组件,并将其添加到将检索和显示开放拍卖、竞标者拍卖或卖家拍卖的视图中。使用Auctions组件检索和渲染特定卖家创建的拍卖列表的视图将如下所示:

Auctions组件将遍历作为属性接收的拍卖数组,并在 Material-UI ListItem组件中显示每个拍卖,如下面的代码所示。
mern-marketplace/client/auction/Auctions.js:
export default function Auctions(props){
return (
<List dense>
{props.auctions.map((auction, i) => {
return <span key={i}>
<ListItem button>
<ListItemAvatar>
<Avatar src={'/api/auctions/image/'+auction._id+"?"
+ new Date().getTime()}/>
</ListItemAvatar>
<ListItemText primary={auction.itemName}
secondary={auctionState(auction}/>
<ListItemSecondaryAction>
<Link to={"/auction/" + auction._id}>
<IconButton aria-label="View" color="primary">
<ViewIcon/>
</IconButton>
</Link>
</ListItemSecondaryAction>
</ListItem>
<Divider/>
</span>})}
</List>
)
}
对于每个拍卖项目,除了显示一些基本拍卖详情外,我们还为用户提供了一个选项,可以在单独的链接中打开每个拍卖。我们还条件性地渲染了诸如拍卖何时开始、竞标是否已经开始或结束、剩余时间有多少以及最新的出价是多少等详情。每个拍卖状态的这些详情是通过以下代码确定和渲染的。
mern-marketplace/client/auction/Auctions.js:
const currentDate = new Date()
const auctionState = (auction)=>{
return ( <span>
{currentDate < new Date(auction.bidStart) &&
`Auction Starts at ${new Date(auction.bidStart).toLocaleString()}`}
{currentDate > new Date(auction.bidStart) &&
currentDate < new Date(auction.bidEnd) && <>
{`Auction is live | ${auction.bids.length} bids |`}
{showTimeLeft(new Date(auction.bidEnd))}
</>}
{currentDate > new Date(auction.bidEnd) &&
`Auction Ended | ${auction.bids.length} bids `}
{currentDate > new Date(auction.bidStart) && auction.bids.length> 0 && `
| Last bid: $ ${auction.bids[0].bid}`}
</span>
)
}
为了计算和渲染已开始的拍卖的剩余时间,我们定义了一个showTimeLeft方法,它接受结束日期作为参数,并使用calculateTimeLeft方法来构建在视图中渲染的时间字符串。showTimeLeft方法定义如下。
mern-marketplace/client/auction/Auctions.js:
const showTimeLeft = (date) => {
let timeLeft = calculateTimeLeft(date)
return !timeLeft.timeEnd && <span>
{timeLeft.days != 0 && `${timeLeft.days} d `}
{timeLeft.hours != 0 && `${timeLeft.hours} h `}
{timeLeft.minutes != 0 && `${timeLeft.minutes} m `}
{timeLeft.seconds != 0 && `${timeLeft.seconds} s`} left
</span>
}
此方法使用calculateTimeLeft方法来确定剩余时间的日、时、分、秒的分解。
calculateTimeLeft方法接受结束日期并与当前日期进行比较,以计算差异并创建一个timeLeft对象,该对象记录剩余的天数、小时、分钟和秒,以及一个timeEnd状态。如果时间已结束,则将timeEnd状态设置为 true。calculateTimeLeft方法定义如下。
mern-marketplace/client/auction/Auctions.js:
const calculateTimeLeft = (date) => {
const difference = date - new Date()
let timeLeft = {}
if (difference > 0) {
timeLeft = {
days: Math.floor(difference / (1000 * 60 * 60 * 24)),
hours: Math.floor((difference / (1000 * 60 * 60)) % 24),
minutes: Math.floor((difference / 1000 / 60) % 60),
seconds: Math.floor((difference / 1000) % 60),
timeEnd: false
}
} else {
timeLeft = {timeEnd: true}
}
return timeLeft
}
此Auctions组件渲染了包含每个拍卖的详情和状态的列表,可以添加到其他视图中,以显示不同的拍卖列表。如果当前查看拍卖列表的用户恰好是列表中某个拍卖的卖家,我们还想为此用户渲染编辑或删除拍卖的选项。在下一节中,我们将学习如何将这些选项整合到从市场编辑或删除拍卖中。
编辑和删除拍卖
市场中的卖家将能够通过编辑或删除他们创建的拍卖来管理他们的拍卖。编辑和删除功能的实现需要构建后端 API,这些 API 将保存对数据库的更改并从集合中删除一个拍卖。这些 API 将在前端视图中使用,以允许用户使用表单编辑拍卖详情,并通过按钮点击来启动删除操作。在接下来的章节中,我们将学习如何有条件地将这些选项添加到拍卖列表中,并讨论全栈实现以完成这些编辑和删除功能。
更新列表视图
我们将更新拍卖列表视图的代码,以有条件地显示编辑和删除选项给卖家。在Auctions组件中,该组件迭代列表以渲染ListItem中的每个项目,我们将在ListItemSecondaryAction组件中添加两个额外的选项,如下面的代码所示。
mern-marketplace/client/auction/Auctions.js:
<ListItemSecondaryAction>
<Link to={"/auction/" + auction._id}>
<IconButton aria-label="View" color="primary">
<ViewIcon/>
</IconButton>
</Link>
{ auth.isAuthenticated().user &&
auth.isAuthenticated().user._id == auction.seller._id &&
(<>
<Link to={"/auction/edit/" + auction._id}>
<IconButton aria-label="Edit" color="primary">
<Edit/>
</IconButton>
</Link>}
<DeleteAuction auction={auction} onRemove={props.removeAuction}/>
</>)
}
</ListItemSecondaryAction>
如果当前登录用户的 ID 与拍卖卖家的 ID 匹配,则条件性地渲染编辑视图链接和删除组件。编辑视图组件和删除组件的实现与我们在第七章中讨论的 EditShop 组件和 DeleteShop 组件类似,即使用在线市场锻炼 MERN 技能。这些相同的组件将调用后端 API 来完成编辑和删除操作。我们将在下一节中查看所需的 API。
编辑和删除拍卖 API
为了完成前端发起的编辑拍卖和删除拍卖操作,我们需要在后端有相应的 API。这些 API 端点的路由,将接受更新和删除请求,可以声明如下。
mern-marketplace/server/routes/auction.routes.js:
router.route('/api/auctions/:auctionId')
.put(authCtrl.requireSignin, auctionCtrl.isSeller, auctionCtrl.update)
.delete(authCtrl.requireSignin, auctionCtrl.isSeller, auctionCtrl.remove)
router.param('auctionId', auctionCtrl.auctionByID)
/api/auctions/:auctionId 路由 URL 中的 :auctionId 参数将调用 auctionByID 控制器方法,该方法与 userByID 控制器方法类似。它从数据库中检索拍卖并将其附加到请求对象中,以便在 next 方法中使用。auctionByID 方法定义如下。
mern-marketplace/server/controllers/auction.controller.js:
const auctionByID = async (req, res, next, id) => {
try {
let auction = await Auction.findById(id)
.populate('seller', '_id name')
.populate('bids.bidder', '_id name').exec()
if (!auction)
return res.status('400').json({
error: "Auction not found"
})
req.auction = auction
next()
} catch (err) {
return res.status('400').json({
error: "Could not retrieve auction"
})
}
}
从数据库检索的拍卖对象还将包含卖家和竞标者的名称和 ID 详细信息,正如我们在 populate() 方法中指定的。对于这些 API 端点,使用 auction 对象来验证当前登录用户是否是创建此特定拍卖的卖家,通过调用在拍卖控制器中定义的 isSeller 方法。
mern-marketplace/server/controllers/auction.controller.js:
const isSeller = (req, res, next) => {
const isSeller = req.auction && req.auth && req.auction.seller._id == req.auth._id
if(!isSeller){
return res.status('403').json({
error: "User is not authorized"
})
}
next()
}
一旦卖家被验证,next 方法将被调用以更新或删除拍卖,具体取决于是否收到了 PUT 或 DELETE 请求。更新和删除拍卖的控制器方法与我们在第七章中讨论的编辑商店 API 和删除商店 API 的先前实现类似,即使用在线市场锻炼 MERN 技能。这些相同的组件将调用后端 API 来完成编辑和删除操作。我们将在下一节中查看所需的 API。
我们已经准备好了市场使用的拍卖模块,包括用于存储拍卖和竞标数据的拍卖模型,以及用于创建新拍卖、显示不同拍卖列表和修改现有拍卖的后端 API 和前端视图。在下一节中,我们将进一步扩展此模块并实现单个拍卖的视图,用户不仅可以了解更多关于拍卖的信息,还可以看到实时的竞标更新。
显示拍卖视图
显示单个拍卖的视图将包含市场实时拍卖和竞标功能的核心功能。在进入实时竞标的实现之前,我们将设置一个全栈切片来检索单个拍卖的详细信息,并在一个将包含拍卖显示、计时器和竞标功能的 React 组件中显示这些详细信息。在接下来的章节中,我们将首先讨论用于获取单个拍卖的后端 API。然后,我们将查看 Auction 组件的实现,该组件将使用此 API 检索并显示拍卖详情以及拍卖的状态。为了给用户提供拍卖状态的实时更新,我们还将在此视图中实现一个计时器,以指示距离现场拍卖结束的时间。
读取拍卖 API
要在单独的视图中显示现有拍卖的详细信息,我们需要添加一个后端 API,该 API 将接收来自客户端的拍卖请求并返回其详细信息。因此,我们将在后端实现一个读取拍卖 API,该 API 将接受一个带有指定拍卖 ID 的 GET 请求,并从数据库中的Auction集合返回相应的拍卖文档。我们将通过声明以下代码中的 GET 路由来开始添加此 API 端点。
mern-marketplace/server/routes/auction.routes.js:
router.route('/api/auction/:auctionId')
.get(auctionCtrl.read)
路由 URL 中的:auctionId参数在接收到此路由的 GET 请求时调用auctionByID控制器方法。auctionByID控制器方法从数据库中检索拍卖并将其附加到请求对象,以便在read控制器方法中访问,该方法随后被调用。返回此拍卖对象以响应客户端的read控制器方法定义如下。
mern-marketplace/server/controllers/auction.controller.js:
const read = (req, res) => {
req.auction.image = undefined
return res.json(req.auction)
}
在发送响应之前,我们将移除图像字段,因为图像将通过单独的路由作为文件检索。有了这个后端 API 就绪,我们现在可以在前端通过在api-auction.js中添加一个 fetch 方法来添加调用它的实现,类似于我们讨论的其他用于完成 API 实现的 fetch 方法。我们将使用 fetch 方法在 React 组件中调用读取拍卖 API,该组件将渲染检索到的拍卖详情。该 React 组件的实现将在下一节中讨论。
拍卖组件
我们将实现一个 Auction 组件来从后端获取并显示单个拍卖的详细信息给最终用户。此视图还将具有基于拍卖当前状态和查看页面的用户是否已登录的实时更新功能。例如,以下截图显示了当给定拍卖尚未开始时,Auction 组件如何渲染给访客。它仅显示拍卖的描述详情并指定拍卖开始的时间:

Auction 组件的实现将通过调用 useEffect 钩子中的读取拍卖 API 来检索拍卖详情。这部分组件实现与我们在第七章 使用在线市场锻炼 MERN 技能中讨论的 Shop 组件类似。
完成的 Auction 组件将通过浏览器中的 /auction/:auctionId 路由访问,该路由在 MainRouter 中定义如下。
mern-marketplace/client/MainRouter.js:
<Route path="/auction/:auctionId" component={Auction}/>
此路由可以用于任何组件来链接到特定的拍卖,就像我们在拍卖列表中所做的那样。此链接将用户带到带有加载的拍卖详情的相应拍卖视图。
在组件视图中,我们将通过考虑当前日期和给定拍卖的竞标开始和结束时间来渲染拍卖状态。可以添加以下代码来生成这些状态,这些状态将在视图中显示。
mern-marketplace/client/auction/Auction.js:
const currentDate = new Date()
...
<span>
{currentDate < new Date(auction.bidStart) && 'Auction Not Started'}
{currentDate > new Date(auction.bidStart) && currentDate < new Date(auction.bidEnd) && 'Auction Live'}
{currentDate > new Date(auction.bidEnd) && 'Auction Ended'}
</span>
在前面的代码中,如果当前日期早于 bidStart 日期,我们将显示一条消息,表明拍卖尚未开始。如果当前日期在 bidStart 和 bidEnd 日期之间,则拍卖正在进行。如果当前日期晚于 bidEnd 日期,则拍卖已结束。
Auction 组件还将根据当前用户是否已登录以及拍卖当前状态,有条件地渲染计时器和竞标部分。渲染这部分视图的代码如下。
mern-marketplace/client/auction/Auction.js:
<Grid item xs={7} sm={7}>
{currentDate > new Date(auction.bidStart)
? (<>
<Timer endTime={auction.bidEnd} update={update}/>
{ auction.bids.length > 0 &&
<Typography component="p" variant="subtitle1">
{` Last bid: $ ${auction.bids[0].bid}`}
</Typography>
}
{ !auth.isAuthenticated() &&
<Typography>
Please, <Link to='/signin'>
sign in</Link> to place your bid.
</Typography>
}
{ auth.isAuthenticated() &&
<Bidding auction={auction} justEnded=
{justEnded} updateBids={updateBids}/>
}
</>)
: <Typography component="p" variant="h6">
{`Auction Starts at ${new Date(auction.bidStart).toLocaleString()}`}
</Typography>
}
</Grid>
如果当前日期恰好晚于竞标开始时间,我们将显示计时器组件来显示竞标结束前剩余的时间。然后,我们显示最后出价金额,如果已经放置了一些出价,这将是在拍卖 bids 数组中的第一个项目。如果当前用户在拍卖处于此状态时已登录,我们还将渲染一个 Bidding 组件,允许他们出价并查看竞标历史。在下一节中,我们将学习如何实现我们在此视图中添加的计时器组件,以显示拍卖剩余时间。
添加计时器组件
当拍卖正在进行时,我们将向用户提供关于他们在此特定拍卖中竞标结束前剩余时间的实时更新。我们将实现一个 Timer 组件,并在 Auction 组件中有条件地渲染它以实现此功能。计时器将倒计时秒数,并显示观看直播拍卖的用户剩余时间。以下截图显示了当 Auction 组件向尚未登录的用户渲染直播拍卖时的外观:

当用户查看实时拍卖时,剩余时间每秒减少。我们将在 Timer 组件中实现这个倒计时功能,该组件被添加到 Auction 组件中。Auction 组件提供包含拍卖结束时间值的 props,以及一个在时间结束时更新拍卖视图的函数,如下面的代码所示。
mern-marketplace/client/auction/Auction.js:
<Timer endTime={auction.bidEnd} update={update}/>
传递给 Timer 组件的 update 函数将帮助将 justEnded 变量的值从 false 设置为 true。这个 justEnded 值传递给 Bidding 组件,以便在时间结束时禁用下注选项。justEnded 值的初始化和 update 函数的定义如下。
mern-marketplace/client/auction/Auction.js:
const [justEnded, setJustEnded] = useState(false)
const updateBids = () => {
setJustEnded(true)
}
这些 props 将在 Timer 组件中使用,以计算剩余时间并在时间结束时更新视图。
在 Timer 组件定义中,我们将使用从 Auction 组件传入的 props 中的结束时间值初始化 timeLeft 变量,如下面的代码所示。
mern-marketplace/client/auction/Timer.js:
export default function Timer (props) {
const [timeLeft, setTimeLeft] = useState(calculateTimeLeft(new Date(props.endTime)))
...
}
为了计算距离拍卖结束的剩余时间,我们利用本章 拍卖组件 部分中讨论过的 calculateTimeLeft 方法。
为了实现倒计时功能,我们将在 Timer 组件的 useEffect 钩子中使用 setTimeout,如下面的代码所示。
mern-marketplace/client/auction/Timer.js:
useEffect(() => {
let timer = null
if(!timeLeft.timeEnd){
timer = setTimeout(() => {
setTimeLeft(calculateTimeLeft(new Date(props.endTime)))
}, 1000)
}else{
props.update()
}
return () => {
clearTimeout(timer)
}
})
如果时间还没有结束,我们将使用 setTimeout 在 1 秒后更新 timeLeft 值。这个 useEffect 钩子将在每次由 setTimeLeft 状态更新引起的渲染后运行。
因此,timeLeft 值将每秒更新,直到 timeEnd 值变为 true。当 timeEnd 值确实变为 true,即时间到了,我们将执行从 Auctions 组件传入的 update 函数。
为了避免内存泄漏并在 useEffect 钩子中进行清理,我们将使用 clearTimeout 停止任何挂起的 setTimeout 调用。为了显示这个更新的 timeLeft 值,我们只需在视图中渲染它,如下面的代码所示。
mern-marketplace/client/auction/Timer.js:
return (<div className={props.style}>
{!timeLeft.timeEnd ?
<Typography component="p" variant="h6" >
{timeLeft.days != 0 && `${timeLeft.days} d `}
{timeLeft.hours != 0 && `${timeLeft.hours} h `}
{timeLeft.minutes != 0 && `${timeLeft.minutes} m `}
{timeLeft.seconds != 0 && `${timeLeft.seconds} s`} left
<span style={{fontSize:'0.8em'}}>
{`(ends at ${new Date(props.endTime).toLocaleString()})`}
</span>
</Typography> :
<Typography component="p" variant="h6">Auction ended</Typography>
}
</div>
)
如果还有时间剩余,我们将使用 timeLeft 对象渲染距离拍卖结束的剩余天数、小时、分钟和秒。我们还会指出拍卖结束的确切日期和时间。如果时间到了,我们只需指出拍卖已经结束。
在我们迄今为止实现的Auction组件中,我们能够从后端获取拍卖详情并将其与拍卖状态一起渲染。如果一个拍卖处于直播状态,我们能够显示剩余时间直到结束。当拍卖处于这种直播状态时,用户也将能够对拍卖进行出价,并实时看到其他用户在平台上的出价。在下一节中,我们将讨论如何使用 Socket.IO 集成平台所有直播拍卖的实时竞标功能。
使用 Socket.IO 实现实时竞标
已登录市场平台的用户将能够参与直播拍卖。他们可以在同一视图中进行出价并获得实时更新,同时看到平台上的其他用户正在对他们的出价进行回应。为了实现这一功能,我们将在实现前端界面以允许用户出价并查看变化的出价历史之前,将 Socket.IO 集成到我们的全栈 MERN 应用中。
集成 Socket.IO
Socket.IO 将允许我们在市场应用中添加实时竞标功能。Socket.IO 是一个 JavaScript 库,包含一个在浏览器中运行的客户端模块和一个与 Node.js 集成的服务器端模块。将这些模块集成到我们的 MERN 应用中,将使客户端和服务器之间实现双向和实时通信。
Socket.IO 的客户端部分作为 Node 模块socket.io-client提供,而服务器端部分作为 Node 模块socket.io提供。您可以在socket.io了解更多关于 Socket.IO 的信息,并尝试他们的入门教程。
在我们可以在代码中使用socket.io之前,我们将通过在命令行中运行以下命令使用 Yarn 安装客户端和服务器库:
yarn add socket.io socket.io-client
将 Socket.IO 库添加到项目后,我们将更新我们的后端以将 Socket.IO 集成到服务器代码中。我们需要使用与我们的应用程序相同的 HTTP 服务器初始化一个新的socket.io实例。
在我们的后端代码中,我们使用 Express 启动服务器。因此,我们将更新server.js中的代码以获取我们的 Express 应用使用的 HTTP 服务器引用,如下面的代码所示。
mern-marketplace/server/server.js:
import bidding from './controllers/bidding.controller'
const server = app.listen(config.port, (err) => {
if (err) {
console.log(err)
}
console.info('Server started on port %s.', config.port)
})
bidding(server)
然后,我们将把这个服务器的引用传递给一个竞标控制器函数。这个bidding.controller函数将包含在服务器端实现实时功能所需的 Socket.IO 代码。bidding.controller函数将初始化socket.io,然后监听connection事件以接收来自客户端的 socket 消息,如下面的代码所示。
mern-marketplace/server/controllers/bidding.controller.js:
export default (server) => {
const io = require('socket.io').listen(server)
io.on('connection', function(socket){
socket.on('join auction room', data => {
socket.join(data.room);
})
socket.on('leave auction room', data => {
socket.leave(data.room)
})
})
}
当新的客户端首次连接然后断开套接字连接时,我们将订阅和取消订阅客户端套接字到一个给定的频道。该频道将由客户端通过data.room属性传递的拍卖 ID 来识别。这样,我们将为每个拍卖有一个不同的频道或房间。
使用此代码,后端已准备好接收客户端通过套接字发送的通信,我们现在可以将 Socket.IO 集成到我们的前端。在前端,只有拍卖视图——特别是投标部分——将使用套接字进行实时通信。因此,我们只将在前端添加到拍卖组件的Bidding组件中集成 Socket.IO,如下面的代码所示。
mern-marketplace/client/auction/Auction.js:
<Bidding auction={auction} justEnded={justEnded} updateBids={updateBids}/>
投标组件从拍卖组件接收auction对象、justEnded值和updateBids函数作为属性,并在投标过程中使用这些属性。为了开始实现投标组件,我们将使用 Socket.IO 客户端库集成套接字,如下面的代码所示。
mern-marketplace/client/auction/Bidding.js:
const io = require('socket.io-client')
const socket = io()
export default function Bidding (props) {
useEffect(() => {
socket.emit('join auction room', {room: props.auction._id})
return () => {
socket.emit('leave auction room', {
room: props.auction._id
})
}
}, [])
...
}
在前面的代码中,我们引入了socket.io-client库并初始化了socket为此客户端。然后,在我们的Bidding组件定义中,我们使用useEffect钩子和初始化的socket在组件挂载和卸载时分别发出auction room joining和auction room leaving套接字事件。我们通过这些发出的套接字事件传递当前拍卖的 ID 作为data.room值。
这些事件将由服务器套接字连接接收,导致客户端订阅或取消订阅给定的拍卖房间。现在,客户端和服务器能够通过套接字进行实时通信,在下一节中,我们将学习如何使用这种能力让用户对拍卖进行即时投标。
放置投标
当平台上的用户登录并查看当前正在进行的拍卖时,他们将看到一个选项来放置自己的投标。此选项将在Bidding组件中渲染,如下面的截图所示:

为了允许用户放置他们的投标,在接下来的章节中,我们将添加一个表单,让他们输入一个高于上一个投标的值,并通过套接字通信将其提交到服务器。然后,在服务器上,我们将处理通过套接字发送的新投标,以便将更改后的拍卖投标保存到数据库中,并在服务器接受此投标时立即更新所有连接用户的视图。
添加投标表单
我们将在上一节开始构建的Bidding组件中添加一个用于拍卖竞标的表单。在我们向视图中添加表单元素之前,我们将初始化状态中的bid值,为表单输入添加一个变更处理函数,并跟踪允许的最小竞标金额,如下面的代码所示。
mern-marketplace/client/auction/Bidding.js:
const [bid, setBid] = useState('')
const handleChange = event => {
setBid(event.target.value)
}
const minBid = props.auction.bids && props.auction.bids.length> 0
? props.auction.bids[0].bid
: props.auction.startingBid
最小竞标金额是通过检查最新放置的竞标来确定的。如果有竞标被放置,最小竞标金额需要高于最新的竞标;否则,它需要高于拍卖卖家设定的起始竞标金额。
放置竞标的表单元素只有在当前日期早于拍卖结束日期时才会渲染。我们还检查justEnded值是否为false,以便当计时器倒计时到 0 时,表单可以实时隐藏。表单元素将包含一个输入字段,提示应输入的最小金额,以及一个提交按钮,除非输入了有效的竞标金额,否则该按钮将保持禁用状态。以下是将这些元素添加到Bidding组件视图中的方式。
mern-marketplace/client/auction/Bidding.js:
{!props.justEnded && new Date() < new Date(props.auction.bidEnd) && <>
<TextField label="Your Bid ($)"
value={bid} onChange={handleChange}
type="number" margin="normal"
helperText={`Enter $${Number(minBid)+1} or more`}/><br/>
<Button variant="contained" color="secondary"
disabled={bid < (minBid + 1)}
onClick={placeBid}>Place Bid
</Button><br/>
</>}
当用户点击提交按钮时,将调用placeBid函数。在这个函数中,我们构建一个包含新竞标详情的竞标对象,包括竞标金额、竞标时间和竞标者的用户引用。这个新的竞标将通过为这个拍卖室已经建立的套接字通信发送到服务器,如下面的代码所示:
const placeBid = () => {
const jwt = auth.isAuthenticated()
let newBid = {
bid: bid,
time: new Date(),
bidder: jwt.user
}
socket.emit('new bid', {
room: props.auction._id,
bidInfo: newBid
})
setBid('')
}
一旦消息通过套接字发送出去,我们将使用setBid('')清空输入字段。然后,我们需要更新后端中的竞标控制器以接收和处理从客户端发送的这条新的竞标消息。在下一节中,我们将添加套接字事件处理代码以完成放置竞标的整个过程。
服务器接收竞标
当用户通过套接字连接放置新的竞标并发出后,它将在服务器上被处理,以便存储在数据库中相应的拍卖中。
在竞标控制器中,我们将更新套接字连接监听器代码中的套接字事件处理器,以添加一个用于新竞标套接字消息的处理程序,如下面的代码所示。
mern-marketplace/server/controllers/bidding.controller.js:
io.on('connection', function(socket){
...
socket.on('new bid', data => {
bid(data.bidInfo, data.room)
})
})
在前面的代码中,当套接字接收到发出的新竞标消息时,我们使用附加的数据在名为bid的函数中更新指定的拍卖,以包含新的竞标信息。竞标函数定义如下。
mern-marketplace/server/controllers/bidding.controller.js:
const bid = async (bid, auction) => {
try {
let result = await Auction.findOneAndUpdate({_id:auction, $or: [{'bids.0.bid':{$lt:bid.bid}},{bids:{$eq:[]}} ]},
{$push: {bids: {$each:[bid], $position: 0}}},
{new: true})
.populate('bids.bidder', '_id name')
.populate('seller', '_id name')
.exec()
io.to(auction).emit('new bid', result)
} catch(err) {
console.log(err)
}
}
投标函数接受新的投标详情和拍卖 ID 作为参数,并在拍卖集合上执行findOneAndUpdate操作。为了找到要更新的拍卖,除了使用拍卖 ID 进行查询外,我们还确保新的投标金额大于此拍卖文档中bids数组在位置0的最后一个投标。如果找到与提供的 ID 匹配且满足最后一个投标小于新投标这一条件的拍卖,则通过将新投标推入bids数组的第一个位置来更新此拍卖。
在数据库中对拍卖进行更新后,我们通过socket.io连接向所有当前连接到相应拍卖房间的客户端发出新投标消息。在客户端,我们需要在 socket 事件处理程序代码中捕获此消息,并使用最新投标更新视图。在下一节中,我们将学习如何处理和显示所有查看实时拍卖的客户端的更新投标列表。
显示变化的投标历史
服务器接受新的投标并将其存储在数据库后,新的投标数组将在所有当前位于拍卖页面的客户端的视图中更新。在接下来的几节中,我们将扩展Bidding组件,使其能够处理更新的投标并显示给定拍卖的完整投标历史。
使用新投标更新视图状态
一旦服务器上处理了放置的投标,包含修改后的投标数组的更新拍卖将被发送到所有连接到拍卖房间的客户端。为了在客户端处理这些新数据,我们需要更新Bidding组件以添加对特定 socket 消息的监听器。
我们将使用useEffect钩子将此 socket 监听器添加到Bidding组件加载和渲染时。我们还将使用useEffect的清理中的socket.off()移除监听器。这个带有 socket 监听器以接收新投标数据的useEffect钩子将按以下方式添加。
mern-marketplace/client/auction/Bidding.js:
useEffect(() => {
socket.on('new bid', payload => {
props.updateBids(payload)
})
return () => {
socket.off('new bid')
}
})
当从服务器通过 socket 事件接收到新的带有更新投标的拍卖时,我们执行作为Auction组件属性发送的updateBids函数。updateBids函数在Auction组件中定义如下:
const updateBids = (updatedAuction) => {
setAuction(updatedAuction)
}
这将更新设置在拍卖组件状态中的拍卖数据,并因此使用更新的拍卖数据重新渲染完整的拍卖视图。此视图还将包括投标历史表,我们将在下一节中讨论。
渲染投标历史
在Bidding组件中,我们将渲染一个表格,显示给定拍卖的所有已放置的竞价的详情。这将通知用户已经放置的竞价以及他们正在实时查看直播拍卖时正在放置的竞价。拍卖的竞价历史将在视图中如下渲染:

这个竞价历史视图将基本上遍历该拍卖的bids数组,并显示每个在数组中找到的竞价对象的竞价金额、竞价时间和竞价者姓名。渲染此表格视图的代码将如下添加:
<div>
<Typography variant="h6"> All bids </Typography>
<Grid container spacing={4}>
<Grid item xs={3} sm={3}>
<Typography variant="subtitle1"
color="primary">Bid Amount</Typography>
</Grid>
<Grid item xs={5} sm={5}>
<Typography variant="subtitle1"
color="primary">Bid Time</Typography>
</Grid>
<Grid item xs={4} sm={4}>
<Typography variant="subtitle1"
color="primary">Bidder</Typography>
</Grid>
</Grid>
{props.auction.bids.map((item, index) => {
return <Grid container spacing={4} key={index}>
<Grid item xs={3} sm={3}>
<Typography variant="body2">${item.bid} </Typography>
</Grid>
<Grid item xs={5} sm={5}>
<Typography variant="body2">
{new Date(item.time).toLocaleString()}
</Typography></Grid>
<Grid item xs={4} sm={4}>
<Typography variant="body2">{item.bidder.name} </Typography>
</Grid>
</Grid>
})}
</div>
我们使用 Material-UI Grid组件添加了表头,然后在遍历bids数组以生成具有单个竞价详情的表格行之前。
当任何查看此拍卖的用户提交新的竞价,并且更新的拍卖在 socket 中接收并设置为状态时,这个包含竞价历史的表格将更新,并显示表格顶部的最新竞价。通过这种方式,它为拍卖室中的所有用户提供了竞价的实时更新。有了这个,我们就将完整的拍卖和实时竞价功能集成到了 MERN Marketplace 应用程序中。
摘要
在本章中,我们扩展了 MERN Marketplace 应用程序,并添加了一个具有实时竞价功能的拍卖特性。我们设计了一个用于存储拍卖和竞价详情的拍卖模型,并实现了全栈 CRUD 功能,允许用户创建新的拍卖、编辑和删除拍卖,以及查看不同的拍卖列表,包括单个拍卖的详情。
我们添加了一个表示单个拍卖的拍卖视图,用户可以在此视图中观看并参与拍卖。在视图中,我们计算并渲染了给定拍卖的当前状态,以及直播拍卖的倒计时计时器。在实现这个倒计时秒数计时器时,我们学习了如何在 React 组件中使用setTimeout和useEffect钩子。
对于每个拍卖,我们使用了 Socket.IO 实现了实时竞价功能。我们讨论了如何在应用程序的客户端和服务器端集成 Socket.IO,以建立客户端和服务器之间的实时双向通信。通过这些方法扩展 MERN 堆栈以包含实时通信功能,你可以在自己的全栈应用程序中实现更多令人兴奋的实时功能,使用 socket 进行。
通过在这里构建 MERN Marketplace 应用程序的不同功能所获得的经验,你还可以扩展本章中涵盖的拍卖特性,并将其与该应用程序中现有的订单管理和支付处理功能集成。
在下一章中,我们将通过扩展 MERN 骨架构建一个带有数据可视化功能的支出跟踪应用程序,以扩展我们的 MERN 堆栈技术选项。
第十三章:进阶至复杂 MERN 应用
在本部分,我们探讨如何实现具有高级和复杂功能的 MERN 应用,包括数据可视化、媒体流和 VR 功能。
本节包括以下章节:
-
第十章, 将数据可视化与支出跟踪应用集成
-
第十一章, 构建媒体流应用
-
第十二章, 定制媒体播放器并优化 SEO
-
第十三章, 开发基于 Web 的 VR 游戏
-
第十四章, 使用 MERN 使 VR 游戏动态化
第十四章:将数据可视化集成到支出跟踪应用程序中
这些天,收集和添加数据到互联网上的应用程序变得很容易。随着越来越多的数据变得可用,处理数据并将从这些数据中提取的见解以有意义和吸引人的可视化形式呈现给最终用户变得必要。在本章中,我们将学习如何使用 MERN 堆栈技术以及 Victory——一个用于 React 的图表库,以便轻松地将数据可视化功能集成到全栈应用程序中。我们将扩展 MERN 骨架应用程序来构建支出跟踪应用程序,该应用程序将包含用户随时间记录的支出数据的数据处理和可视化功能。
在了解了这些功能的实现之后,您应该掌握了如何利用 MongoDB 聚合框架和 Victory 图表库将您选择的数据可视化功能添加到任何全栈 MERN 网络应用程序中。
在本章中,我们将通过以下主题来构建一个集成了数据可视化功能的支出跟踪应用程序:
-
介绍 MERN 支出跟踪器
-
添加支出记录
-
随时间可视化支出数据
介绍 MERN 支出跟踪器
MERN 支出跟踪器应用程序将允许用户跟踪他们的日常支出。登录账户的用户将能够添加他们的支出记录,包括支出描述、类别、金额以及给定支出发生或支付的时间。应用程序将存储这些支出记录并提取有意义的数据模式,以使用户能够看到他们的支出习惯随时间如何发展。以下截图显示了 MERN 支出跟踪器应用程序上登录用户的首页视图,并提供了用户当前月份支出的概述:

完整的 MERN 支出跟踪器应用程序代码可在 GitHub 上找到:github.com/PacktPublishing/Full-Stack-React-Projects-Second-Edition/tree/master/Chapter10/mern-expense-tracker。您可以将此代码克隆并运行,在阅读本章剩余部分的代码解释时,您可以运行应用程序。
在本章中,我们将扩展 MERN 骨架以构建具有数据可视化功能的支出跟踪应用程序。这些支出跟踪和可视化功能所需的视图将通过扩展和修改 MERN 骨架应用程序中现有的 React 组件来开发。以下截图显示了本章中开发的 MERN 支出跟踪器前端的所有自定义 React 组件的组件树:

我们将添加新的 React 组件来实现创建费用记录、列出和修改已记录的费用以及显示报告的视图,这些报告可以提供用户随时间产生的费用的洞察。我们还将修改现有的组件,如 Home 组件,以渲染用户当前费用的概览。在我们能够实现用户费用数据的可视化之前,我们需要首先添加记录日常费用的功能。在下一节中,我们将讨论如何实现这个功能,允许已登录的用户在应用程序中创建和修改他们的费用记录。
添加费用记录
在 MERN 费用追踪器应用程序中,已登录的用户将能够创建和管理他们的费用记录。为了启用添加和管理费用记录的功能,我们需要定义如何存储费用详情,并实现全栈切片,使用户能够创建新的费用、查看这些费用以及更新或删除应用程序中的现有费用。
在以下章节中,首先,我们将使用 Mongoose 模式定义费用模型以存储每个费用记录的详情。然后,我们将讨论实现后端 API 和前端视图的方法,这些方法允许用户创建新的费用、查看他们的费用列表以及通过编辑费用详情或从应用程序中删除费用来修改现有费用。
定义费用模型
我们将实现一个 Mongoose 模型来定义一个用于存储每个费用记录详情的费用模型。此模型将在server/models/expense.model.js中定义,其实现将与之前章节中覆盖的其他 Mongoose 模型实现类似,例如在第六章中定义的课程模型,构建基于 Web 的教室应用程序。此模型中的费用模式将具有简单的字段来存储关于每个费用的详情,例如标题、金额、类别以及费用发生时的日期,以及一个指向创建记录的用户引用。定义费用字段的代码及其解释如下:
- 费用标题:
title字段将描述费用。它被声明为String类型,并且是一个必填字段:
title: {
type: String,
trim: true,
required: 'Title is required'
},
- 费用金额:
amount字段将存储费用的货币成本,作为Number类型的值,并且它将是一个必填字段,最小允许值为 0:
amount: {
type: Number,
min: 0,
required: 'Amount is required'
},
- 费用类别:
category字段将定义费用类型,以便可以根据此值对费用进行分组。它被声明为String类型,并且是一个必填字段:
category: {
type: String,
trim: true,
required: 'Category is required'
},
- 发生日期:
incurred_on字段将存储费用发生或支付时的日期时间。它被声明为Date类型,如果没有提供值,则默认为当前日期时间:
incurred_on: {
type: Date,
default: Date.now
},
- 注意事项:
notes字段,定义为String类型,将允许记录给定费用记录的额外详细信息或备注:
notes: {
type: String,
trim: true
},
- 记录费用的人:
recorded_by字段将引用创建费用记录的用户:
recorded_by: {
type: mongoose.Schema.ObjectId,
ref: 'User'
}
- 创建和更新时间:
created和updated字段将是Date类型,created字段在添加新费用时生成,而updated字段在修改任何费用详情时更改:
updated: Date,
created: {
type: Date,
default: Date.now
},
添加到此模式定义中的字段将使我们能够实现 MERN 费用追踪器中的所有费用相关功能。在下一节中,我们将通过实现允许用户创建新费用记录的全栈切片来开始开发这些功能。
创建新的费用记录
为了在应用程序中创建新的费用记录,我们需要集成一个全栈切片,允许用户在前端填写表单视图,然后在后端将输入的详细信息保存到数据库中的新费用文档中。为了实现此功能,在以下章节中,我们将添加一个创建费用 API,以及在前端获取此 API 的方法,以及一个用于获取费用详情的用户输入的新费用表单视图。
创建费用 API
为了实现允许在数据库中创建新费用的创建费用 API,我们首先添加一个POST路由,如下所示。
mern-expense-tracker/server/routes/expense.routes.js:
router.route('/api/expenses')
.post(authCtrl.requireSignin, expenseCtrl.create)
向/api/expenses路由发送POST请求将首先确保请求用户已通过auth控制器中的requireSignin方法登录,然后调用create方法在数据库中添加新的费用记录。此create方法在以下代码中定义。
mern-expense-tracker/server/controllers/expense.controller.js:
const create = async (req, res) = {
try {
req.body.recorded_by = req.auth._id
const expense = new Expense(req.body)
await expense.save()
return res.status(200).json({
message: "Expense recorded!"
})
} catch (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
在这个create方法中,我们将recorded_by字段设置为当前登录的用户,然后使用请求体中提供的费用数据在数据库中的费用集合中保存新的费用。
包含费用路由的expense.routes.js文件将与user.routes文件非常相似。为了在 Express 应用中加载这些新的费用路由,我们需要在express.js中挂载费用路由,如下所示,就像我们为 auth 和用户路由所做的那样。
mern-expense-tracker/server/express.js:
app.use('/', expenseRoutes)
这个创建费用 API 端点现在已在后端准备好,可以在前端使用以发送POST请求。为了在前端获取此 API,我们将在api-expense.js中添加相应的create方法,类似于我们在前几章中讨论的其他 API 实现,例如来自第九章的创建一个新的拍卖部分,向市场添加实时竞标功能。
此获取方法将在前端组件中使用,该组件将显示一个表单,用户可以在其中输入新费用的详细信息并将其保存到应用程序中。在下一节中,我们将实现一个 React 组件,该组件将渲染用于记录新费用的表单。
新增费用组件
在此费用跟踪应用程序上签到的用户将通过表单视图来输入新的费用记录的详细信息。此表单视图将在NewExpense组件中渲染,这将使用户能够通过输入费用标题、花费金额、费用类别、费用发生的时间以及任何附加说明来创建新的费用。
此表单将呈现如下:

此NewExpense组件的实现与其他我们之前讨论过的表单实现类似,例如来自第四章的Signup组件实现,添加 React 前端以完成 MERN。此表单组件中唯一不同的字段是用于“发生时间”的日期时间输入。点击此字段将显示日期时间选择器小部件,如下面的截图所示:

为了实现此表单的日期时间选择器,我们将使用 Material-UI Pickers 以及一个日期管理库。在我们能够集成这些库之前,我们首先需要通过在命令行运行以下yarn命令来安装以下 Material-UI Pickers 和date-fns模块:
yarn add @material-ui/pickers @date-io/date-fns@1.x date-fns
一旦安装了这些模块,我们就可以在NewExpense组件中导入所需的组件和模块,并将日期时间选择器小部件添加到表单中,如下面的代码所示。
mern-expense-tracker/client/expense/NewExpense.js:
import DateFnsUtils from '@date-io/date-fns'
import { DateTimePicker, MuiPickersUtilsProvider} from "@material-ui/pickers"
...
<MuiPickersUtilsProvider utils={DateFnsUtils}>
<DateTimePicker
label="Incurred on"
views={["year", "month", "date"]}
value={values.incurred_on}
onChange={handleDateChange}
showTodayButton
/>
</MuiPickersUtilsProvider>
此小部件将渲染选择年、月、日和时间的选项,以及一个设置当前时间为选定值的 TODAY 按钮。当用户完成日期时间的选择后,我们将使用handleDateChange方法捕获值,并将其与其他从表单收集的费用相关值一起设置到状态中。handleDateChange方法定义如下。
mern-expense-tracker/client/expense/NewExpense.js:
const handleDateChange = date = {
setValues({...values, incurred_on: date })
}
使用此功能,我们将为新的费用记录中的incurred_on字段设置一个date值。
此NewExpense组件只能由已签到的用户查看。因此,我们将在MainRouter组件中添加一个PrivateRoute,这样只有在/expenses/new路径的认证用户才能渲染此表单。
mern-expense-tracker/client/MainRouter.js:
PrivateRoute path="/expenses/new" component={NewExpense}/
此链接可以添加到任何视图中,例如菜单组件,当用户登录时条件性地渲染。现在,由于可以在本费用跟踪应用程序中添加新的费用记录,在下一节中,我们将讨论从后端到前端视图的实现,以获取和列出这些费用。
列出费用
在 MERN 费用跟踪器中,用户将能够查看他们在应用程序中已记录并在提供的日期范围内产生的费用列表。在以下各节中,我们将通过实现后端 API 来检索当前已登录用户记录的费用列表,并添加一个前端视图,该视图将使用此 API 将返回的费用列表渲染给最终用户。
用户费用 API
我们将实现一个 API 来获取特定用户在提供的日期范围内记录的费用。对此 API 的请求将在'/api/expenses'接收,路由在expense.routes.js中定义如下。
mern-expense-tracker/server/routes/expense.routes.js:
router.route('/api/expenses')
.get(authCtrl.requireSignin, expenseCtrl.listByUser)
对此路由的GET请求将首先确保请求用户已登录,然后调用控制器方法从数据库中检索费用。在这个应用程序中,用户只能查看他们自己的费用。在用户身份验证确认后,在listByUser控制器方法中,我们使用请求中指定的日期范围和已登录用户的 ID 在数据库中查询 Expense 集合。listByUser方法在以下代码中定义。
mern-expense-tracker/server/controllers/expense.controller.js:
const listByUser = async (req, res) = {
let firstDay = req.query.firstDay
let lastDay = req.query.lastDay
try {
let expenses = await Expense.find({'$and': {'incurred_on':
{ '$gte': firstDay, '$lte':lastDay }},
{'recorded_by': req.auth._id } }).sort('incurred_on')
.populate('recorded_by', '_id name')
res.json(expenses)
} catch (err){
console.log(err)
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
在这种方法中,我们首先收集请求查询中指定的日期范围的起始日和最后一天。然后,我们从数据库中检索在此日期范围内由已登录用户产生的费用。已登录用户与recorded _by字段中引用的用户进行匹配。使用这些值对 Expense 集合执行find查询将返回按incurred_on字段排序的匹配费用,最近产生的费用将列在前面。
用于检索特定用户记录的费用 API 可以在前端使用,以检索和向最终用户显示费用。为了在前端获取此 API,我们将在api-expense.js中添加相应的listByUser方法,如下所示。
mern-expense-tracker/client/expense/api-expense.js:
const listByUser = async (params, credentials, signal) = {
const query = queryString.stringify(params)
try {
let response = await fetch('/api/expenses?'+query, {
method: 'GET',
signal: signal,
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer ' + credentials.t
}
})
return await response.json()
}catch(err){
console.log(err)
}
}
在这个方法中,在向列出费用 API 发出请求之前,我们使用queryString库形成包含日期范围的查询字符串。然后,将此查询字符串附加到请求 URL。
此获取方法将在Expenses组件中使用,以检索并向用户显示费用。我们将在下一节中查看Expenses组件的实现。
费用组件
从数据库检索到的支出列表将使用名为Expenses的 React 组件进行渲染。该组件在初始加载时,将渲染当前月份由已登录用户发生的支出。在这个视图中,用户还可以选择日期范围以检索特定日期内发生的支出,如图所示:
![图片
在定义Expenses组件时,我们首先使用useEffect钩子调用获取支出列表 API 的 fetch 调用,以检索初始支出列表。我们还初始化了进行此请求和渲染从服务器接收到的响应所必需的值,如图所示。
mern-expense-tracker/client/expense/Expenses.js:
export default function Expenses() {
const date = new Date(), y = date.getFullYear(), m = date.getMonth()
const [firstDay, setFirstDay] = useState(new Date(y, m, 1))
const [lastDay, setLastDay] = useState(new Date(y, m + 1, 0))
const jwt = auth.isAuthenticated()
const [redirectToSignin, setRedirectToSignin] = useState(false)
const [expenses, setExpenses] = useState([])
useEffect(() = {
const abortController = new AbortController()
const signal = abortController.signal
listByUser({firstDay: firstDay, lastDay: lastDay},
{t: jwt.token}, signal)
.then((data) = {
if (data.error) {
setRedirectToSignin(true)
} else {
setExpenses(data)
}
})
return function cleanup(){
abortController.abort()
}
}, [])
...
}
我们首先确定当前月份的第一天和最后一天的日期。这些日期被设置为在搜索表单字段中渲染,并作为请求服务器的日期范围查询参数提供。因为我们只会获取与当前用户相关的支出,所以我们检索已登录用户的auth凭证,以便与请求一起发送。如果服务器请求导致错误,我们将用户重定向到登录页面。否则,我们将接收到的支出设置在状态中,以便在视图中渲染。
在“支出”组件的视图部分,我们将在遍历结果支出数组以渲染单个支出详情之前,添加一个表单以按日期范围搜索。在接下来的章节中,我们将查看组件视图中的搜索表单和支出列表的实现。
通过日期范围搜索
在“支出”视图中,用户将可以选择查看在特定日期范围内发生的支出列表。为了实现一个允许用户选择开始和结束日期范围的搜索表单,我们将使用来自 Material-UI Pickers 的DatePicker组件。
在视图中,我们将添加两个DatePicker组件来收集查询范围的开始和结束日期,并添加一个按钮来启动搜索,如图所示。
mern-expense-tracker/client/expense/Expenses.js:
div className={classes.search}
<MuiPickersUtilsProvider utils={DateFnsUtils}
<DatePicker
disableFuture
format="dd/MM/yyyy"
label="SHOWING RECORDS FROM"
views={["year", "month", "date"]}
value={firstDay}
onChange={handleSearchFieldChange('firstDay')}
/>
<DatePicker
format="dd/MM/yyyy"
label="TO"
views={["year", "month", "date"]}
value={lastDay}
onChange={handleSearchFieldChange('lastDay')}
/>
</MuiPickersUtilsProvider>
Button variant="contained" color="secondary"
onClick= {searchClicked} GO </Button>
</div>
当用户与DatePicker组件交互以选择日期时,我们将调用handleSearchFieldChange方法来获取选定的date值。此方法获取date值并将其相应地设置为状态中的firstDay或lastDay值。handleSearchFieldChange方法定义如下。
mern-expense-tracker/client/expense/Expenses.js:
const handleSearchFieldChange = name = date = {
if(name=='firstDay'){
setFirstDay(date)
}else{
setLastDay(date)
}
}
在选择了两个日期并将它们设置在状态中之后,当用户点击搜索按钮时,我们将调用searchClicked方法。在这个方法中,我们使用新的日期作为查询参数再次调用列表支出 API。searchClicked方法定义如下。
mern-expense-tracker/client/expense/Expenses.js:
const searchClicked = () = {
listByUser({firstDay: firstDay, lastDay: lastDay},{t: jwt.token}).then((data) = {
if (data.error) {
setRedirectToSignin(true)
} else {
setExpenses(data)
}
})
}
一旦从服务器接收到此新查询产生的费用,我们将将其设置到状态中以便在视图中渲染。在下一节中,我们将查看显示检索到的费用列表的实现。
渲染费用
在 Expenses 组件视图中,我们遍历从数据库检索到的费用列表,并在 Material-UI ExpansionPanel 组件中将每个费用记录显示给最终用户。在 ExpansionPanel 组件中,我们在 摘要 部分显示单个费用记录的详细信息。然后,在面板展开时,我们将给用户提供编辑费用详细信息或删除费用的选项,如下一节所述。
在搜索表单元素之后添加到视图代码中的以下代码,我们使用 map 来遍历 expenses 数组,并在 ExpansionPanel 组件中渲染每个 expense。
mern-expense-tracker/client/expense/Expenses.js:
{expenses.map((expense, index) = {
return span key={index}
<ExpansionPanel className={classes.panel}>
<ExpansionPanelSummary
expandIcon={ Edit / } >
<div className={classes.info}
Typography className={classes.amount} $ {expense.amount} </Typography>
<Divider style={{marginTop: 4, marginBottom: 4}}/>
<Typography {expense.category} </Typography>
<Typography className={classes.date}
{new Date(expense.incurred_on).toLocaleDateString()}
</Typography>
</div>
<div>
<Typography className={classes.heading} {expense.title} </Typography>
<Typography className={classes.notes} {expense.notes} </Typography>
</div>
</ExpansionPanelSummary>
<Divider/>
<ExpansionPanelDetails style={{display: 'block'}}
...
</ExpansionPanelDetails>
</ExpansionPanel>
</span>
})
}
费用详情在 ExpansionPanelSummary 组件中渲染,使用户能够了解他们在应用程序中记录的费用概述。ExpansionPanelDetails 组件将包含修改给定费用和完成允许用户管理他们在应用程序中记录的费用功能的选项。在下一节中,我们将讨论实现这些修改记录费用的选项。
修改费用记录
MERN 费用追踪器的用户将能够通过更新费用详情或完全删除费用记录来修改他们在应用程序中已记录的费用。
在应用程序的前端,用户在展开查看列表中单个费用的详细信息后,将在费用列表中接收到这些修改选项,如下面的截图所示:

为了实现这些费用修改功能,我们必须更新视图以渲染此表单和删除选项。此外,我们将在服务器上添加编辑和删除费用 API 端点。在以下章节中,我们将讨论如何在前端渲染这些编辑和删除元素,然后实现后端的编辑和删除 API。
渲染编辑表单和删除选项
我们将在 Expenses 组件视图中渲染编辑费用表单和删除选项。对于在此视图中以 Material-UI ExpansionPanel 组件渲染的每个费用记录,我们将在 ExpansionPanelDetails 部分添加表单字段,每个字段预先填充相应的费用详情值。用户将能够与这些表单字段交互以更改值,然后点击更新按钮将更改保存到数据库。我们将在视图中添加这些表单字段以及更新按钮和删除选项,如下面的代码所示。
mern-expense-tracker/client/expense/Expenses.js:
<ExpansionPanelDetails style={{display: 'block'}}>
<div>
<TextField label="Title" value={expense.title}
onChange={handleChange('title', index)}/>
<TextField label="Amount ($)" value={expense.amount}
onChange={handleChange('amount', index)} type="number"/>
</div>
<div>
<MuiPickersUtilsProvider utils={DateFnsUtils}>
<DateTimePicker
label="Incurred on"
views={["year", "month", "date"]}
value={expense.incurred_on}
onChange={handleDateChange(index)}
showTodayButton
/>
</MuiPickersUtilsProvider
<TextField label="Category" value={expense.category}
onChange={handleChange('category', index)}/>
</div>
<TextField label="Notes" multiline rows="2"
value={expense.notes}
onChange={handleChange('notes', index)}
/>
<div className={classes.buttons}
{ error && ( Typography component="p" color="error"
<Icon color="error" className={classes.error} error </Icon>
{error}
</Typography> )
}
{ saved && Typography component="span" color="secondary" Saved </Typography> }
<Button color="primary" variant="contained"
onClick={()= clickUpdate(index)} Update </Button>
DeleteExpense expense={expense} onRemove={removeExpense}/
</div>
</ExpansionPanelDetails>
在这里添加的表单字段与在 NewExpense 组件中添加的字段类似,用于创建新的费用记录。当用户与这些字段交互以更新值时,我们将使用给定费用在 expenses 数组中的相应索引、字段名称和更改值调用 handleChange 方法。handleChange 方法定义如下。
mern-expense-tracker/client/expense/Expenses.js:
const handleChange = (name, index) = event = {
const updatedExpenses = [...expenses]
updatedExpenses[index][name] = event.target.value
setExpenses(updatedExpenses)
}
将 expenses 数组中给定索引处的费用对象更新为指定字段的更改值,并将其设置为状态。这将使用户在更新编辑表单时渲染带有最新值的视图。当用户完成更改并点击 Update 按钮时,我们将调用 clickUpdate 方法,该方法定义如下。
mern-expense-tracker/client/expense/Expenses.js:
const clickUpdate = (index) = {
let expense = expenses[index]
update({
expenseId: expense._id
}, {
t: jwt.token
}, expense)
.then((data) = {
if (data.error) {
setError(data.error)
} else {
setSaved(true)
setTimeout(()= {setSaved(false)}, 3000)
}
}
在这个 clickUpdate 方法中,我们通过向编辑费用 API 发起一个 fetch 调用来将更新的费用发送到后端。这个编辑费用 API 的实现将在下一节中讨论。
将 DeleteExpense 组件添加到编辑表单中,它会渲染一个删除按钮,并使用作为属性传递的 expense 对象通过调用删除费用 API 从数据库中删除相关的费用。这个 DeleteExpense 的实现与在 第七章 中讨论的 DeleteShop 组件类似,即 使用在线市场锻炼 MERN 技能。在下一节中,我们将讨论编辑和删除费用 API 的实现,这些 API 由编辑表单使用,并将用户在数据库的 Expense 集合中做出的费用相关更新传递给删除选项。
在后端编辑和删除费用
为了完成由前端登录用户发起的编辑和删除费用操作,我们需要在后端有相应的 API。以下代码中可以声明这些 API 端点的路由,它们将接受更新和删除请求。
mern-expense-tracker/server/routes/expense.routes.js:
router.route('/api/expenses/:expenseId')
.put(authCtrl.requireSignin, expenseCtrl.hasAuthorization, expenseCtrl.update)
.delete(authCtrl.requireSignin, expenseCtrl.hasAuthorization, expenseCtrl.remove)
router.param('expenseId', expenseCtrl.expenseByID)
对此路由的 PUT 或 DELETE 请求将首先确保当前用户已通过 requireSignin auth 控制器方法登录,然后检查授权并在数据库中执行任何操作。
路由 URL 中的 :expenseId 参数,/api/expenses/:expenseId,将调用 expenseByID 控制器方法,该方法类似于 userByID 控制器方法。它从数据库中检索费用并将其附加到请求对象中,以便在 next 方法中使用。expenseByID 方法定义如下。
mern-expense-tracker/server/controllers/expense.controller.js:
const expenseByID = async (req, res, next, id) = {
try {
let expense = await Expense.findById(id).populate
('recorded_by', '_id name').exec()
if (!expense)
return res.status('400').json({
error: "Expense record not found"
})
req.expense = expense
next()
} catch (err){
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
从数据库检索到的费用对象还将包含记录费用的用户的名称和 ID 详情,正如我们在populate()方法中指定的。对于这些 API 端点,接下来我们将使用hasAuthorization方法验证此费用对象是否确实是由已登录的用户记录的,该方法在费用控制器中如下定义。
mern-expense-tracker/server/controllers/expense.controller.js:
const hasAuthorization = (req, res, next) = {
const authorized = req.expense && req.auth &&
req.expense.recorded_by._id == req.auth._id
if (!(authorized)) {
return res.status('403').json({
error: "User is not authorized"
})
}
next()
}
一旦确认尝试更新费用的用户是记录该费用的用户,并且如果是PUT请求,则接下来将调用update方法来更新费用文档,并在 Expense 集合中应用新的更改。update控制器方法在以下代码中定义。
mern-expense-tracker/server/controllers/expense.controller.js:
const update = async (req, res) = {
try {
let expense = req.expense
expense = extend(expense, req.body)
expense.updated = Date.now()
await expense.save()
res.json(expense)
} catch (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
该方法从req.expense检索费用详情,然后使用lodash模块将请求体中传入的更改扩展并合并到费用数据中,以更新费用数据。在将此更新后的费用保存到数据库之前,updated字段被填充为当前日期,以反映最后更新的时间戳。在成功保存此更新后,更新的费用对象将作为响应发送回。
如果是DELETE请求而不是PUT请求,则会调用remove方法来从数据库中的集合中删除指定的费用文档。remove控制器方法在以下代码中定义。
mern-expense-tracker/server/controllers/expense.controller.js:
const remove = async (req, res) = {
try {
let expense = req.expense
let deletedExpense = await expense.remove()
res.json(deletedExpense)
} catch (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
此方法中的remove操作将永久删除应用程序中的费用。
我们已经为应用程序上的用户提供了所有功能,以便他们开始记录和管理日常费用。我们定义了一个 Expense 模型来存储费用数据,以及后端 API 和前端视图来创建新的费用、显示特定用户的费用列表以及修改现有费用。我们现在准备实现基于用户在应用程序上记录的费用数据的数据可视化功能。我们将在下一节中讨论这些实现。
随时间可视化费用数据
除了允许用户记录他们的费用外,MERN 费用跟踪应用程序还将处理收集到的费用数据,以使用户能够了解他们的消费习惯随时间的变化。我们将实现简单的数据聚合和数据可视化功能,以展示 MERN 堆栈如何满足任何全栈应用程序的此类要求。为了启用这些功能,我们将利用 MongoDB 的聚合框架,以及由 Formidable 提供的基于 React 的图表和数据可视化库——Victory。
在接下来的章节中,我们首先将添加功能来总结用户当前月份的费用,并展示他们与之前月份相比的表现。然后,我们将添加不同的 Victory 图表,以提供他们在一个月、一年以及每个费用类别上的支出模式的可视化表示。
总结近期费用
当用户在应用程序上登录他们的账户时,他们将看到他们在当前月份到目前为止产生的费用预览。他们还将看到与之前月份的平均值相比,每个类别的花费更多或更少的比较。为了实现这些功能,我们必须添加后端 API,这些 API 将在数据库中的相关费用数据上运行聚合操作,并将计算结果返回到前端进行渲染。在接下来的章节中,我们将实现全栈切片——首先展示当前月份到目前为止产生的所有费用预览,然后展示与当前月份支出相比的每个类别的平均费用。
预览当前月份的费用
用户登录应用程序后,我们将展示他们当前费用的预览,包括他们当前月份的总支出以及他们在当前日期和前一天的花费。这个预览将显示给最终用户,如下面的截图所示:

为了实现这个功能,我们需要添加一个后端 API 来处理现有的费用数据,以返回这三个值,以便在 React 组件中渲染。在接下来的章节中,我们将查看这个 API 的实现和与前端视图的集成,以完成预览功能。
当前月份预览 API
我们将在后端添加一个 API,该 API 将返回当前月份到目前为止产生的费用预览。为了实现这个 API,我们首先声明一个GET路由,如下面的代码所示。
mern-expense-tracker/server/routes/expense.routes.js:
router.route('/api/expenses/current/preview')
.get(authCtrl.requireSignin, expenseCtrl.currentMonthPreview)
向'/api/expenses/current/preview'这个路由发送一个GET请求,首先会确保请求客户端已经登录,然后它会调用currentMonthPreview控制器方法。在这个方法中,我们将使用 MongoDB 的聚合框架对费用集合执行三组聚合操作,以检索当前月份、当前日期以及前一天的总费用。
currentMonthPreview控制器方法将按照以下结构定义,我们首先确定查找匹配费用所需的日期,然后执行聚合操作,最后在响应中返回结果。
mern-expense-tracker/server/controllers/expense.controller.js:
const currentMonthPreview = async (req, res) = {
const date = new Date(), y = date.getFullYear(), m = date.getMonth()
const firstDay = new Date(y, m, 1)
const lastDay = new Date(y, m + 1, 0)
const today = new Date()
today.setUTCHours(0,0,0,0)
const tomorrow = new Date()
tomorrow.setUTCHours(0,0,0,0)
tomorrow.setDate(tomorrow.getDate()+1)
const yesterday = new Date()
yesterday.setUTCHours(0,0,0,0)
yesterday.setDate(yesterday.getDate()-1)
try {
/* ... Perform aggregation operations on the Expense collection
to compute current month's numbers ... */
/* ... Send computed result in response ... */
} catch (err){
console.log(err)
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
我们首先确定当前月份的第一天和最后一天的日期,然后确定今天、明天和昨天的日期,分钟和秒数设置为零。我们需要这些日期来指定查找当前月份、今天和昨天的匹配费用的范围。然后,使用这些值和已登录用户的 ID 引用,我们构建检索当前月份、今天和昨天的总费用的聚合管道。我们使用 MongoDB 聚合框架中的$facet阶段将这些三个不同的聚合管道分组,如下面的代码所示。
mern-expense-tracker/server/controllers/expense.controller.js:
let currentPreview = await Expense.aggregate([
{ $facet: { month: [
{ $match: { incurred_on: { $gte: firstDay, $lt: lastDay },
recorded_by: mongoose.Types.ObjectId(req.auth._id)}},
{ $group: { _id: "currentMonth" , totalSpent: {$sum: "$amount"} }},
],
today: [
{ $match: { incurred_on: { $gte: today, $lt: tomorrow },
recorded_by: mongoose.Types.ObjectId(req.auth._id) }},
{ $group: { _id: "today" , totalSpent: {$sum: "$amount"} } },
],
yesterday: [
{ $match: { incurred_on: { $gte: yesterday, $lt: today },
recorded_by: mongoose.Types.ObjectId(req.auth._id) }},
{ $group: { _id: "yesterday" , totalSpent: {$sum: "$amount"} }
},
]
}
}])
let expensePreview = {month: currentPreview[0].month[0], today: currentPreview[0].today[0], yesterday: currentPreview[0].yesterday[0] }
res.json(expensePreview)
对于每个聚合管道,我们首先使用incurred_on字段的日期范围值匹配费用,以及与当前用户引用的recorded_by字段,因此聚合操作仅对当前用户记录的费用执行。然后,每个管道中匹配的费用被分组以计算总支出金额。
在分面聚合操作的结果中,每个管道在输出文档中都有自己的字段,结果作为文档数组存储。
聚合操作完成后,我们访问计算结果并组合响应以发送回请求客户端。这个 API 可以在前端使用 fetch 请求。你可以定义一个相应的 fetch 方法来发起请求,类似于其他 API 实现。然后,这个 fetch 方法可以在 React 组件中使用来检索和渲染这些聚合值给用户。在下文中,我们将讨论实现此视图以渲染用户当前支出预览的细节。
渲染当前支出的预览
我们可以在任何 React 组件中向用户提供当前支出的概览,该组件对已登录用户是可访问的,并添加到应用程序的前端。为了检索支出总额并在视图中渲染这些数据,我们可以在useEffect钩子中调用当前月份预览 API,或者在按钮被点击时调用。
在 MERN 支出跟踪应用程序中,我们使用 React 组件渲染这些详细信息,并将其添加到主页上。我们使用useEffect钩子,如下面的代码所示,来检索当前的支出预览数据。
mern-expense-tracker/client/expense/ExpenseOverview.js:
useEffect(() = {
const abortController = new AbortController()
const signal = abortController.signal
currentMonthPreview({t: jwt.token}, signal).then((data) = {
if (data.error) {
setRedirectToSignin(true)
} else {
setExpensePreview(data)
}
})
return function cleanup(){
abortController.abort()
}
}, [])
一旦从后端接收到数据,我们将它设置到名为expensePreview的状态变量中,以便在视图中显示信息。在组件的视图中,我们使用这个状态变量来组合一个界面,以显示这些详细信息。在下面的代码中,我们渲染了当前月份、当前日期和前一天的总支出。
mern-expense-tracker/client/expense/ExpenseOverview.js:
<Typography variant="h4" color="textPrimary" You've spent </Typography>
<div>
<Typography component="span"
${expensePreview.month ? expensePreview.month.totalSpent : '0'}
span so far this month </span>
</Typography>
<div>
<Typography variant="h5" color="primary"
${expensePreview.today ? expensePreview.today.totalSpent :'0'}
span today </span>
</Typography>
<Typography variant="h5" color="primary"
${expensePreview.yesterday
? expensePreview.yesterday.totalSpent: '0'}
<span className={classes.day} yesterday </span>
</Typography>
<Link to="/expenses/all" Typography variant="h6"> See more </Typography> </Link>
</div>
</div>
这些值只有在后端聚合结果中返回相应的值时才会渲染;否则,我们将渲染一个"0"。
通过实现当前支出预览功能,我们能够处理用户记录的支出数据,让他们了解他们当前的支出情况。在下一节中,我们将遵循类似的实现步骤,告知用户每个支出类别的支出状况。
按类别跟踪当前支出
在这个应用中,我们将向用户提供一个概述,展示他们目前在每个支出类别中的支出情况,并与之前的平均值进行比较。对于每个类别,我们将显示基于之前支出数据的月平均支出,展示当前月份到目前为止的总支出,并显示差异,以表明他们是否在本月额外支出或节省了钱。以下截图显示了最终用户将如何看到这个功能:

要实现这个功能,我们需要添加一个后端 API,该 API 将处理现有的支出数据,以返回每个类别的月平均支出以及当前月份的总支出,以便可以在 React 组件中渲染。在接下来的章节中,我们将探讨这个 API 的实现和集成,以及前端视图的整合,以完成按类别跟踪支出的功能。
当前支出按类别 API
我们将在后端添加一个 API,该 API 将返回每个支出类别的平均月支出和当前月份的总支出。为了实现这个 API,我们首先声明一个GET路由,如下面的代码所示。
mern-expense-tracker/server/routes/expense.routes.js:
router.route('/api/expenses/by/category')
.get(authCtrl.requireSignin, expenseCtrl.expenseByCategory)
对'/api/expenses/by/category'这个路由的GET请求将首先确保请求客户端已登录,然后它将调用expenseByCategory控制器方法。在这个方法中,我们将使用 MongoDB 聚合框架的不同特性来分别计算每个类别的月平均支出和每个类别的当前月份总支出,然后将这两个结果合并,返回与每个类别相关联的这两个值给请求客户端。
expenseByCategory控制器方法将按照以下结构定义,我们首先确定查找匹配支出所需的日期,然后执行聚合操作,最后在响应中返回结果。
mern-expense-tracker/server/controllers/expense.controller.js:
const expenseByCategory = async (req, res) = {
const date = new Date(), y = date.getFullYear(), m = date.getMonth()
const firstDay = new Date(y, m, 1)
const lastDay = new Date(y, m + 1, 0)
try {
let categoryMonthlyAvg = await Expense.aggregate([/*... aggregation ... */]).exec()
res.json(categoryMonthlyAvg)
} catch (err) {
console.log(err)
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
在这个方法中,我们将使用包含一个$facet和两个子管道的聚合管道,用于计算每个类别的月平均支出和当前月份的每个类别的总支出。然后,我们将从子管道中取这两个结果数组来合并结果。这个聚合管道的代码定义在下面的代码中。
mern-expense-tracker/server/controllers/expense.controller.js:
[
{ $facet: {
average: [
{ $match: { recorded_by: mongoose.Types.ObjectId(req.auth._id) }},
{ $group: { _id: {category: "$category", month: {$month: "$incurred_on"}},
totalSpent: {$sum: "$amount"} } },
{ $group: { _id: "$_id.category", avgSpent: { $avg: "$totalSpent"}}},
{ $project: {
_id: "$_id", value: {average: "$avgSpent"},
}
}
],
total: [
{ $match: { incurred_on: { $gte: firstDay, $lte: lastDay },
recorded_by: mongoose.Types.ObjectId(req.auth._id) }},
{ $group: { _id: "$category", totalSpent: {$sum: "$amount"} } },
{ $project: {
_id: "$_id", value: {total: "$totalSpent"},
}
}
]
}
},
{ $project: {
overview: { $setUnion:['$average','$total'] },
}
},
{ $unwind: '$overview' },
{ $replaceRoot: { newRoot: "$overview" } },
{ $group: { _id: "$_id", mergedValues: { $mergeObjects: "$value" } } }
]
在$facet阶段的子管道输出投影时,我们确保结果对象的键在两个输出数组中都是_id和value,以便可以统一合并。一旦完成分面聚合操作,我们使用$setUnion对结果进行操作以合并数组。然后,我们将合并后的数组作为新的根文档,以便对其运行$group聚合以合并每个类别的平均值和总值。
从这个聚合管道的最终输出将包含一个数组,其中每个支出类别都有一个对象。这个数组中的每个对象都将具有类别名称作为_id值,以及一个包含该类别平均和总值的mergedValues对象。然后,这个由聚合生成的最终输出数组被发送回请求客户端的响应中。
我们可以在前端使用 fetch 请求使用这个 API。你可以定义一个相应的 fetch 方法来发起请求,类似于其他 API 实现。然后,这个 fetch 方法可以在 React 组件中使用来检索并渲染这些聚合值给用户。在下一节中,我们将讨论这个视图的实现,以展示用户在当前月份与上个月相比,每个类别的支出比较。
渲染每个类别的支出概览
除了告知用户他们当前的支出情况外,我们还可以给他们一个与之前支出相比的情况。我们可以告诉他们,在当前月份的每个类别中,他们是支出更多还是节省了钱。我们可以实现一个 React 组件,该组件调用当前按类别支出的 API 来渲染后端发送的平均和总值,并显示这两个值之间的计算差异。
API 可以通过useEffect钩子或点击按钮来获取。在 MERN 支出跟踪应用程序中,我们将在主页上添加的 React 组件中渲染这些详细信息。我们使用以下代码中的useEffect钩子来检索每个类别的支出数据。
mern-expense-tracker/client/expense/ExpenseOverview.js:
useEffect(() = {
const abortController = new AbortController()
const signal = abortController.signal
expenseByCategory({t: jwt.token}, signal).then((data) = {
if (data.error) {
setRedirectToSignin(true)
} else {
setExpenseCategories(data)
}
})
return function cleanup(){
abortController.abort()
}
}, [])
我们将把从后端接收到的值设置到expenseCategories状态变量中,并在视图中渲染其详细信息。这个变量将包含一个数组,我们将在视图代码中遍历这个数组,为每个类别显示三个值——每月平均数、当前月份的总支出,以及这两个值之间的差异,并指示是否节省了钱。
在以下代码中,我们使用map来遍历接收到的数据数组,并为数组中的每个项目生成视图以显示接收到的平均和总值。除此之外,我们还使用这两个值显示一个计算值。
mern-expense-tracker/client/expense/ExpenseOverview.js:
{expenseCategories.map((expense, index) = {
return( div key={index}
<Typography variant="h5" {expense._id} </Typography>
<Divider style={{ backgroundColor:
indicateExpense(expense.mergedValues)}}/>
<div>
<Typography component="span" past average </Typography>
<Typography component="span" this month </Typography>
<Typography component="span" {expense.mergedValues.total
&& expense.mergedValues.total-
expense.mergedValues.average > 0 ? "spent extra" : "saved" }
</Typography>
</div>
<div>
<Typography component="span" ${expense.mergedValues.average} </Typography>
<Typography component="span" ${expense.mergedValues.total ?
expense.mergedValues.total : 0}
</Typography>
<Typography component="span" ${expense.mergedValues.total ?
Math.abs(expense.mergedValues.total-
expense.mergedValues.average) :
expense.mergedValues.average}
</Typography>
</div>
<Divider/>
</div> )
})
}
对于数组中的每个项目,我们首先渲染类别名称,然后渲染我们将显示的三个值的标题。第三个标题根据当前总金额是否多于或少于月平均金额有条件地渲染。然后,在每个标题下,我们渲染月平均金额、当前总金额(如果没有返回值,则将为零)以及这个平均金额和总金额之间的差异。对于第三个值,我们使用Math.abs()函数渲染平均金额和总金额之间计算出的差异的绝对值。
根据这个差异,我们还在类别名称下方渲染不同颜色的分隔线,以指示是否节省了资金、额外花费了资金,或者花费了相同金额的资金。为了确定颜色,我们定义了一个名为indicateExpense的方法,如下面的代码所示:
const indicateExpense = (values) = {
let color = '#4f83cc'
if(values.total){
const diff = values.total - values.average
if( diff 0){
color = '#e9858b'
}
if( diff 0 ){
color = '#2bbd7e'
}
}
return color
}
如果当前总金额多于、少于或等于月平均金额,将返回不同的颜色。这使用户能够快速直观地了解他们在当前月份按类别产生费用的表现。
我们通过利用 MERN 堆栈技术(如 MongoDB 中的聚合框架)的现有功能,向费用跟踪应用程序添加了简单的数据可视化功能。在下一节中,我们将演示如何通过集成外部图表库来向此应用程序添加更复杂的数据可视化功能。
显示费用数据图表
图表和图表是可视化复杂数据模式的经时间考验的机制。在 MERN 费用跟踪应用程序中,我们将通过图形表示向用户报告费用模式随时间的变化,并添加简单的图表使用 Victory。
Victory 是一个由 Formidable 开发的针对 React 和 React Native 的开源图表和数据可视化库。不同类型的图表作为模块化组件提供,可以自定义并添加到任何 React 应用程序中。要了解更多关于 Victory 的信息,请访问formidable.com/open-source/victory。
在我们开始将 Victory 图表集成到代码中之前,我们需要通过在命令行中运行以下命令来安装模块:
yarn add victory
在费用跟踪应用程序中,我们将添加三个不同的图表,作为向用户展示的交互式费用报告的一部分。这三个图表将包括一个散点图,显示在给定月份发生的费用,一个条形图,显示在给定年份每月发生的总费用,以及一个饼图,显示在提供的日期范围内每个类别的平均支出。
对于每个图表,我们将添加相应的后端 API 来检索相关的支出数据,并在前端添加一个 React 组件,该组件将使用检索到的数据来渲染相关的 Victory 图表。在以下章节中,我们将实现添加一个月度支出散点图、展示一年每月支出的条形图以及显示特定时间段内平均按类别支出的饼图所需的全栈切片。
散点图中的一个月支出
我们将通过散点图展示用户在给定月份发生的支出。这将为他们提供一个关于其一个月内支出情况的视觉概述。以下截图显示了散点图如何使用户支出数据呈现:

我们在 y 轴上绘制支出金额,在 x 轴上绘制该月支出发生的日期。将鼠标悬停在绘制的气泡上,将显示该特定支出记录在哪个日期花费了多少钱。在以下章节中,我们将通过首先添加一个后端 API 来实现此功能,该 API 将返回所需格式以在 Victory 散点图中渲染的给定月份的支出。然后,我们将添加一个 React 组件,该组件将从后端检索这些数据并在 Victory 散点图中渲染。
散点图数据 API
我们将在后端添加一个 API,该 API 将返回给定月份发生的支出,并使用前端渲染散点图所需的数据格式。为了实现此 API,我们首先声明一个GET路由,如下面的代码所示。
mern-expense-tracker/server/routes/expense.routes.js:
router.route('/api/expenses/plot')
.get(authCtrl.requireSignin, expenseCtrl.plotExpenses)
对'/api/expenses/plot'此路由的GET请求将首先确保请求客户端已登录,然后它将调用plotExpenses控制器方法。请求还将通过 URL 查询参数获取给定月份的值,该值将在plotExpenses方法中使用,以确定所提供月份的第一天和最后一天。我们需要这些日期来指定查找在指定月份发生的并记录在认证用户中的匹配支出的范围,并将支出汇总到图表所需的数据格式中。plotExpenses方法定义在以下代码中。
mern-expense-tracker/server/controllers/expense.controller.js:
const plotExpenses = async (req, res) = {
const date = new Date(req.query.month), y = date.getFullYear(), m = date.getMonth()
const firstDay = new Date(y, m, 1)
const lastDay = new Date(y, m + 1, 0)
try {
let totalMonthly = await Expense.aggregate( [
{ $match: { incurred_on: { $gte : firstDay, $lt: lastDay },
recorded_by: mongoose.Types.ObjectId(req.auth._id) }},
{ $project: {x: {$dayOfMonth: '$incurred_on'}, y: '$amount'}}
]).exec()
res.json(totalMonthly)
} catch (err){
console.log(err)
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
我们运行一个简单的聚合操作,找到匹配的支出,并返回一个包含散点图 y 轴和 x 轴所需格式的值的输出。聚合的最终结果包含一个对象数组,每个对象包含一个 x 属性和一个 y 属性。x 属性包含来自 incurred_on 日期的月份值。y 属性包含相应的支出金额。从聚合生成的最终输出数组被发送回请求客户端的响应。
我们可以使用此 API 在前端通过 fetch 请求。您可以定义一个相应的 fetch 方法来发出请求,类似于其他 API 实现。然后,fetch 方法可以在 React 组件中使用,以检索并渲染散点图中的 x 和 y 值数组。在下一节中,我们将讨论此视图的实现,以渲染显示给定月份发生的支出的散点图。
MonthlyScatter 组件
我们将实现一个 React 组件,该组件调用散点图数据 API,以在 Victory Scatter 图表中渲染给定月份发生的支出数组。
API 可以通过 useEffect 钩子或点击按钮时获取。在 MERN 支出跟踪应用程序中,我们在名为 MonthlyScatter 的 React 组件中渲染这个散点图。当这个组件加载时,我们渲染当前月份的支出散点图。我们还添加了一个 DatePicker 组件,允许用户选择所需的月份,并通过点击按钮检索该月份的数据。在下面的代码中,当组件加载时,我们使用 useEffect 钩子检索初始散点图数据。
mern-expense-tracker/client/report/MonthlyScatter.js:
const [plot, setPlot] = useState([])
const [month, setMonth] = useState(new Date())
const [error, setError] = useState('')
const jwt = auth.isAuthenticated()
useEffect(() = {
const abortController = new AbortController()
const signal = abortController.signal
plotExpenses({month: month},{t: jwt.token}, signal).then((data) = {
if (data.error) {
setError(data.error)
} else {
setPlot(data)
}
})
return function cleanup(){
abortController.abort()
}
}, [])
当从后端接收到绘制的数据并将其设置在状态中时,我们可以在 Victory Scatter 图表中渲染它。此外,我们可以在组件视图中添加以下代码以渲染带有标签的自定义散点图。
mern-expense-tracker/client/report/MonthlyScatter.js:
<VictoryChart
theme={VictoryTheme.material}
height={400}
width={550}
domainPadding={40}
<VictoryScatter
style={{
data: { fill: "#01579b", stroke: "#69f0ae", strokeWidth: 2 },
labels: { fill: "#01579b", fontSize: 10, padding:8}
}}
bubbleProperty="y"
maxBubbleSize={15}
minBubbleSize={5}
labels={({ datum }) = `$${datum.y} on ${datum.x}th`}
labelComponent={ VictoryTooltip/ }
data={plot}
domain={{x: [0, 31]}}
/>
<VictoryLabel
textAnchor="middle"
style={{ fontSize: 14, fill: '#8b8b8b' }}
x={270} y={390}
text={`day of month`}
/>
<VictoryLabel
textAnchor="middle"
style={{ fontSize: 14, fill: '#8b8b8b' }}
x={6} y={190}
angle = {270}
text={`Amount ($)`}
/>
</VictoryChart>
我们将 VictoryScatter 组件放置在 VictoryChart 组件中,这给了我们自定义散点图包装器和将轴标签文本放置在散点图外的灵活性。我们向 VictoryScatter 传递数据,指出气泡属性基于哪个值,自定义样式,并指定每个气泡的大小范围和标签。
此代码根据提供的数据绘制并渲染散点图,其中金额支出与月份的某一天分别对应于 y 轴和 x 轴。在下一节中,我们将遵循类似的步骤添加柱状图,以图形方式显示给定年份的月度支出。
一年中的每月总支出
我们将向用户展示一个表示他们在给定年份内每月总费用的条形图。这将让他们了解他们的费用是如何在一年中分布的。以下截图显示了条形图将如何使用用户费用数据渲染:

在这里,我们使用给定年份中每个月的总费用值填充条形图。我们将每月总价值作为标签添加到每个条形上。在 x 轴上,我们显示每个月的简称。在接下来的章节中,我们将通过首先添加一个后端 API 来实现这个功能,该 API 将返回给定年份每月发生的总费用,并且格式适合在前端渲染条形图。然后,我们将添加一个 React 组件,该组件将从后端检索这些数据并在 Victory Bar 图表中渲染。
年度费用 API
我们将在后端添加一个 API,该 API 将返回给定年份内每月发生的总费用,并且格式适合在前端渲染条形图。
要实现这个 API,我们首先将声明一个GET路由,如下面的代码所示。
mern-expense-tracker/server/routes/expense.routes.js:
router.route('/api/expenses/yearly')
.get(authCtrl.requireSignin, expenseCtrl.yearlyExpenses)
对'/api/expenses/yearly'这个路由的GET请求将首先确保请求客户端是一个已登录的用户,然后它将调用yearlyExpenses控制器方法。请求还将从 URL 查询参数中获取给定年份的值,该值将在yearlyExpenses方法中使用,以确定所提供年份的第一天和最后一天。我们需要这些日期来指定查找在指定年份发生并由认证用户记录的匹配费用的范围,并在将总月度费用聚合到图表所需的数据格式时使用。yearlyExpenses方法在下面的代码中定义。
mern-expense-tracker/server/controllers/expense.controller.js:
const yearlyExpenses = async (req, res) = {
const y = req.query.year
const firstDay = new Date(y, 0, 1)
const lastDay = new Date(y, 12, 0)
try {
let totalMonthly = await Expense.aggregate( [
{ $match: { incurred_on: { $gte : firstDay, $lt: lastDay } }},
{ $group: { _id: {$month: "$incurred_on"}, totalSpent: {$sum: "$amount"} } },
{ $project: {x: '$_id', y: '$totalSpent'}}
]).exec()
res.json({monthTot:totalMonthly})
} catch (err){
console.log(err)
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
我们运行一个聚合操作,找到匹配的费用,按月份分组费用以计算总和,并返回一个包含条形图 y 轴和 x 轴值所需格式的值的输出。聚合的最终结果包含一个对象数组,每个对象包含一个x属性和一个y属性。
x属性包含incurred_on日期的月份值。y属性包含该月的相应总费用金额。从聚合生成的最终输出数组将发送回请求客户端。
我们可以使用此 API 在前端使用 fetch 请求。您可以定义一个相应的 fetch 方法来发出请求,类似于其他 API 实现。然后,fetch 方法可以在 React 组件中使用来检索并渲染在柱状图中显示的x和y值的数组。在下一节中,我们将讨论实现此视图以渲染显示给定年份总月度支出的柱状图。
年度柱状图组件
我们将实现一个 React 组件,该组件调用年度支出数据 API,以在 Victory Bar 图表中渲染给定年份每月发生的支出数组。
API 可以通过useEffect钩子或当按钮被点击时获取。在 MERN 支出跟踪应用程序中,我们在名为YearlyBar的 React 组件中渲染此柱状图。当此组件加载时,我们渲染当前年份的支出柱状图。我们还添加了一个DatePicker组件,允许用户选择所需的年份,并通过按钮点击检索该年份的数据。在下面的代码中,我们在组件加载时使用useEffect钩子检索初始年度支出数据。
mern-expense-tracker/client/report/YearlyBar.js:
const [year, setYear] = useState(new Date())
const [yearlyExpense, setYearlyExpense] = useState([])
const [error, setError] = useState('')
const jwt = auth.isAuthenticated()
useEffect(() = {
const abortController = new AbortController()
const signal = abortController.signal
yearlyExpenses({year: year.getFullYear()},{t: jwt.token}, signal).then((data) = {
if (data.error) {
setError(data.error)
}
setYearlyExpense(data)
})
return function cleanup(){
abortController.abort()
}
}, [])
我们可以使用从后端接收并设置在状态中的数据在 Victory Bar 图表中渲染。我们可以在组件视图中添加以下代码来渲染一个带有标签且仅显示x轴的自定义柱状图。
mern-expense-tracker/client/report/YearlyBar.js:
const monthStrings = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
<VictoryChart
theme={VictoryTheme.material}
domainPadding={10}
height={300}
width={450}
<VictoryAxis/>
<VictoryBar
categories={{
x: monthStrings
}}
style={{ data: { fill: "#69f0ae", width: 20 }, labels: {fill: "#01579b"} }}
data={yearlyExpense.monthTot}
x={monthStrings['x']}
domain={{x: [0, 13]}}
labels={({ datum }) = `$${datum.y}`}
/>
</VictoryChart>
数据库返回的月份值是零基索引,因此我们定义了自己的月份名称字符串数组来映射这些索引。为了渲染柱状图,我们在VictoryChart组件中放置了一个VictoryBar组件,这使我们能够自定义柱状图包装器,并且还使用VictoryAxis组件添加了y轴,因为没有添加任何属性,所以y轴根本不会显示。
我们将数据传递给VictoryBar,并使用月份字符串定义x轴值的类别,以便在图表上显示整年的所有月份,即使尚未存在相应的总值。我们为每个柱状图渲染单独的标签,以显示每个月的总支出值。为了将x轴值与正确的月份字符串映射,我们在VictoryBar组件的x属性中指定它。
此代码根据提供的数据绘制并渲染柱状图,将每个月的支出总额映射到每个月。在下一节中,我们将遵循类似的步骤添加饼图,以图形方式显示给定日期范围内的平均支出类别。
饼图中的平均支出类别
我们可以渲染一个饼图,显示用户在给定时间段内平均在每个支出类别上花费的金额。这将帮助用户可视化哪些类别随着时间的推移消耗了更多或更少的财富。以下截图显示了饼图将如何使用用户支出数据渲染:

我们用每个类别及其平均支出值填充饼图,显示相应的名称和金额作为标签。在接下来的章节中,我们将通过首先添加一个后端 API 来实现此功能,该 API 将返回给定日期范围内每个类别的平均支出以及用于在 Victory Pie 图表中渲染的格式。然后,我们将添加一个 React 组件,该组件将从后端检索这些数据并在 Victory Pie 图表中渲染。
按类别平均支出 API
我们将在后端添加一个 API,该 API 将返回在给定时间段内每个类别的平均支出以及用于在前端渲染饼图的所需数据格式。为了实现此 API,我们首先声明一个 GET 路由,如下面的代码所示。
mern-expense-tracker/server/routes/expense.routes.js:
router.route('/api/expenses/category/averages')
.get(authCtrl.requireSignin, expenseCtrl.averageCategories)
对 '/api/expenses/category/averages' 路由的 GET 请求将首先确保请求客户端已登录,然后它将调用 averageCategories 控制器方法。请求还将通过 URL 查询参数获取给定日期范围的值,这些值将在 averageCategories 方法中使用,以确定提供的范围的起始日期和结束日期。我们需要这些日期来指定在指定日期范围内找到匹配的支出,这些支出由认证用户记录并在聚合每个类别的支出平均值到图表所需的数据格式时进行。averageCategories 方法在以下代码中定义。
mern-expense-tracker/server/controllers/expense.controller.js:
const averageCategories = async (req, res) = {
const firstDay = new Date(req.query.firstDay)
const lastDay = new Date(req.query.lastDay)
try {
let categoryMonthlyAvg = await Expense.aggregate([
{ $match : { incurred_on : { $gte : firstDay, $lte: lastDay },
recorded_by: mongoose.Types.ObjectId(req.auth._id)}},
{ $group : { _id : {category: "$category"},
totalSpent: {$sum: "$amount"} } },
{ $group: { _id: "$_id.category", avgSpent:
{ $avg: "$totalSpent"}}},
{ $project: {x: '$_id', y: '$avgSpent'}}
]).exec()
res.json({monthAVG:categoryMonthlyAvg})
} catch (err){
console.log(err)
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
我们运行一个聚合操作,找到匹配的支出,按类别分组支出以首先计算总数然后计算平均值,并返回一个包含饼图 y 和 x 值所需格式的输出。聚合的最终结果包含一个对象数组,每个对象包含一个 x 属性和一个 y 属性。x 属性包含类别名称作为值。y 属性包含该类别的相应平均支出金额。从聚合生成的最终输出数组将发送回请求客户端。
我们可以在前端使用 fetch 请求使用此 API。您可以定义一个相应的 fetch 方法来发送请求,类似于其他 API 实现。然后,fetch 方法可以在 React 组件中使用,以检索并渲染饼图中的 x 和 y 值数组。在下一节中,我们将讨论此视图的实现,以渲染一个饼图,显示在给定日期范围内每个类别的平均支出。
CategoryPie 组件
我们将实现一个 React 组件,该组件调用按类别平均支出 API,以在 Victory 饼图中渲染接收到的每个类别平均支出的数组。
API 可以通过useEffect钩子或当按钮被点击时获取。在 MERN 支出跟踪应用程序中,我们在名为CategoryPie的 React 组件中渲染这个饼图。当这个组件加载时,我们渲染给定月份每个类别平均支出的饼图。我们还添加了两个DatePicker组件,允许用户选择所需的日期范围,并通过点击按钮检索该范围的数据。在下面的代码中,我们使用useEffect钩子在组件加载时检索初始平均支出数据。
mern-expense-tracker/client/report/CategoryPie.js:
const [error, setError] = useState('')
const [expenses, setExpenses] = useState([])
const jwt = auth.isAuthenticated()
const date = new Date(), y = date.getFullYear(), m = date.getMonth()
const [firstDay, setFirstDay] = useState(new Date(y, m, 1))
const [lastDay, setLastDay] = useState(new Date(y, m + 1, 0))
useEffect(() = {
const abortController = new AbortController()
const signal = abortController.signal
averageCategories({firstDay: firstDay, lastDay: lastDay},
{t: jwt.token}, signal).then((data) = {
if (data.error) {
setError(data.error)
} else {
setExpenses(data)
}
})
return function cleanup(){
abortController.abort()
}
}, [])
通过从后端接收并设置在状态中的数据,我们可以在 Victory 饼图中渲染它。我们可以在组件视图中添加以下代码来渲染一个带有每个切片的单独文本标签和图表中心标签的自定义饼图。
mern-expense-tracker/client/report/CategoryPie.js:
<div style={{width: 550, margin: 'auto'}}>
<svg viewBox="0 0 320 320">
<VictoryPie standalone={false} data=
{expenses.monthAVG} innerRadius={50}
theme={VictoryTheme.material}
labelRadius={({ innerRadius }) = innerRadius + 14 }
labelComponent={ VictoryLabel angle={0} style={[{
fontSize: '11px',
fill: '#0f0f0f'
},
{
fontSize: '10px',
fill: '#013157'
}]} text={( {datum} ) = `${datum.x}\n $${datum.y}`}/ }
/>
<VictoryLabel
textAnchor="middle"
style={{ fontSize: 14, fill: '#8b8b8b' }}
x={175} y={170}
text={`Spent \nper category`}
/>
</svg>
</div>
要渲染带有单独中心标签的饼图,我们将VictoryPie组件放置在一个svg元素中,这使我们能够自定义饼图包装,并使用VictoryLabel在饼图代码外部添加一个单独的圆形标签。
我们将数据传递给VictoryPie,为每个切片定义自定义的标签,并使饼图独立,以便中心标签可以放置在图表上。此代码根据提供的数据绘制并渲染饼图,每个类别显示平均支出。
我们已根据用户记录的支出数据添加了三个不同的 Victory 图表到应用程序中,这些数据经过必要的处理,并从后端数据库中检索。MERN 支出跟踪应用程序功能齐全,允许用户记录他们的日常支出,并可视化从记录的支出数据中提取的数据模式和支出习惯。
摘要
在本章中,我们将 MERN 骨架应用程序扩展为开发一个具有数据可视化功能的支出跟踪应用程序。我们设计了一个支出模型来记录支出细节,并实现了全栈CRUD(创建、读取、更新、删除)功能,允许已登录用户记录他们的日常支出,查看他们的支出列表,并修改现有的支出记录。
我们添加了数据处理和可视化功能,使用户能够了解他们的当前支出,并了解他们在每个支出类别上花费的更多或更少的金额。我们还集成了不同类型的图表,以显示用户在不同时间范围内的支出模式。
在实现这些功能的过程中,我们了解到了 MongoDB 中聚合框架的一些数据处理选项,并且还整合了一些来自 Victory 的可定制图表组件。您可以进一步探索聚合框架和 Victory 库,以便在您自己的全栈应用程序中整合更复杂的数据可视化功能。
在下一章中,我们将通过扩展 MERN 骨架来构建一个媒体流应用程序,我们将探索 MERN 堆栈技术的一些更高级的可能性。
第十五章:构建 Media Streaming 应用程序
上传和流式传输媒体内容,特别是视频内容,已经是一段时间来互联网文化中增长的部分。从个人分享个人视频内容到娱乐行业在在线流媒体服务上传播商业内容,我们都依赖于能够实现顺畅上传和流式传输的 Web 应用程序。MERN 技术栈中的功能可以用于构建和集成这些核心流媒体功能到任何基于 MERN 的全栈应用程序中。在本章中,我们将扩展 MERN 框架应用程序来构建一个媒体流媒体应用程序,同时展示如何利用 MongoDB GridFS 并将媒体流式传输功能添加到您的 Web 应用程序中。
在本章中,我们将涵盖以下主题,通过扩展 MERN 框架应用程序来实现基本的媒体上传和流式传输:
-
介绍 MERN Mediastream
-
将视频上传到 MongoDB GridFS
-
存储和检索媒体详情
-
从 GridFS 流式传输视频到基本媒体播放器
-
列出、显示、更新和删除媒体
介绍 MERN Mediastream
我们将通过扩展框架应用程序来构建 MERN Mediastream 应用程序。这将是一个简单的视频流媒体应用程序,允许注册用户上传任何浏览应用程序的人都可以流式传输的视频。以下截图显示了 MERN Mediastream 应用程序的主页视图,以及平台上流行的视频列表:

完整的 MERN Mediastream 应用程序的代码可在 GitHub 上找到:github.com/PacktPublishing/Full-Stack-React-Projects-Second-Edition/tree/master/Chapter11%20and%2012/mern-mediastream。
您可以克隆此代码,并在阅读本章其余部分的代码解释时运行应用程序。
需要开发用于简单媒体播放器中媒体上传、编辑和流媒体功能的用户界面视图,这些视图将通过扩展和修改 MERN 框架应用程序中的现有 React 组件来实现。以下图表显示了将在本章中开发的 MERN Mediastream 前端的所有自定义 React 组件:

我们将添加新的 React 组件来实现上传新视频、列出已发布的媒体、修改媒体帖子细节以及显示视频的视图。用户可以与视频内容进行交互以流式传输和观看视频。我们还将修改现有的组件,例如Home组件,以便我们可以渲染热门视频列表,以及Profile组件,以便我们可以列出特定用户发布的所有视频。应用程序中的上传和流媒体功能将依赖于用户上传视频内容的能力。在下一节中,我们将讨论如何允许已登录用户向应用程序添加媒体。
上传和存储媒体
MERN Mediastream 应用程序的注册用户将能够从本地文件上传视频,并使用 GridFS 直接在 MongoDB 上存储每个视频及其相关细节。为了使应用程序能够上传媒体内容,我们需要定义如何存储媒体细节和视频内容,并实现一个全栈切片,使用户能够创建新的媒体帖子并上传视频文件。在接下来的章节中,首先我们将定义一个媒体模型来存储每个媒体帖子的细节,并配置 GridFS 以存储相关的视频内容。然后,我们将讨论后端 API 的实现,该 API 将接收并存储视频内容以及其他媒体细节,以及前端表单视图,它将允许用户在应用程序上创建新的媒体帖子。
定义媒体模型
我们将实现一个 Mongoose 模型来定义一个媒体模型,用于存储发布到应用程序的每条媒体的细节。此模型将在server/models/media.model.js中定义,其实现将与我们在前几章中介绍的 Mongoose 模型实现类似,例如我们在第六章中定义的课程模型。该模型中的媒体模式将包含记录媒体标题、描述、类型、观看次数、媒体发布和更新的日期以及引用发布媒体的用户的字段。定义媒体字段的代码如下:
- 媒体标题:
title字段被声明为String类型,并将是一个必填字段,用于介绍上传到应用程序的媒体:
title: {
type: String,
required: 'title is required'
}
- 媒体描述和类型:
description和genre字段将属于String类型,并将存储有关发布的媒体的其他细节。genre字段还将允许我们将上传到应用程序的不同媒体分组。
description: String,
genre: String,
- 观看次数:
views字段定义为Number类型,并将跟踪上传的媒体在应用程序中被用户观看的次数:
views: {
type: Number,
default: 0
},
- 发布媒体的用户:
postedBy字段将引用创建媒体帖子的用户:
postedBy: {
type: mongoose.Schema.ObjectId,
ref: 'User'
},
- 创建和更新时间:
created和updated字段将是Date类型,created在添加新媒体时生成,updated在修改任何媒体详细信息时更改:
updated: Date,
created: {
type: Date,
default: Date.now
},
添加到模式定义中的字段将只存储关于每个发布到应用程序的视频的详细信息。为了存储视频内容本身,我们将使用 MongoDB GridFS。在下一节中,在讨论如何实现上传视频文件之前,我们将讨论 GridFS 如何使 MongoDB 中存储大文件成为可能,然后添加初始化代码以开始在这个流式应用中使用 GridFS。
使用 MongoDB GridFS 存储大文件
在前面的章节中,我们讨论了用户上传的文件可以直接作为二进制数据存储在 MongoDB 中;例如,在第五章的“上传个人照片”部分添加个人照片时。但这仅适用于小于 16 MB 的文件。为了在 MongoDB 中存储更大的文件,例如本流式应用所需的视频文件,我们需要使用 GridFS。
GridFS 是 MongoDB 中的一个规范,允许我们将一个给定的文件分割成几个块来存储在 MongoDB 中。每个块的大小最大为 255 KB,并作为单独的文档存储。当需要根据对 GridFS 的查询检索文件时,块会根据需要重新组装。这提供了只获取和加载文件所需部分而不是整个文件的功能。
在存储和检索 MERN Mediastream 应用程序的视频文件的情况下,我们将利用 GridFS 来存储视频文件,并根据用户跳转到的部分和开始播放的部分流式传输视频的相应部分。
您可以在官方 MongoDB 文档中了解更多关于 GridFS 规范及其功能的信息,请参阅docs.mongodb.com/manual/core/gridfs/。
要从我们的后端代码访问和使用 MongoDB GridFS,我们将通过创建一个GridFSBucket并使用建立的数据库连接来使用 Node.js MongoDB 驱动程序的流式 API。
GridFSBucket 是 GridFS 流式接口,它为我们提供了访问流式 GridFS API 的权限。它可以用来与 GridFS 中的文件进行交互。您可以在 Node.js MongoDB 驱动程序 API 文档中了解更多关于 GridFSBucket 和流式 API 的信息,请参阅mongodb.github.io/node-mongodb-native/3.2/api/GridFSBucket.html。
由于我们使用 Mongoose 与 MongoDB 数据库建立应用程序的连接,因此将在连接建立后添加以下代码来初始化一个新的GridFSBucket。
mern-mediastream/server/controllers/media.controller.js:
import mongoose from 'mongoose'
let gridfs = null
mongoose.connection.on('connected', () => {
gridfs = new mongoose.mongo.GridFSBucket(mongoose.connection.db)
})
我们在这里创建的gridfs对象将为我们提供访问 GridFS 功能,这些功能在创建新媒体时存储视频文件以及在媒体要流回用户时检索文件时是必需的。在下一节中,我们将添加创建媒体表单视图和后端 API,这些 API 将使用此gridfs对象来保存与前端表单视图发送的请求一起上传的视频文件。
创建新的媒体帖子
为了让用户能够在应用程序中创建新的媒体帖子,我们需要集成一个全栈切片,允许用户在前端填写表单,然后在后端将提供的媒体细节和相关的视频文件保存到数据库中。为了实现这个功能,在接下来的章节中,我们将在后端添加一个创建媒体 API,以及在前端获取此 API 的方法。然后,我们将实现一个创建新媒体表单视图,允许用户输入媒体细节并从他们的本地文件系统中选择视频文件。
创建媒体 API
我们将在后端实现一个创建媒体 API,允许用户在应用程序上创建新的媒体帖子。此 API 将在'/api/media/new/:userId'接收包含媒体字段和上传的视频文件的 multipart 请求体。首先,我们将声明创建媒体路由并利用用户控制器中的userByID方法,如下所示。
mern-mediastream/server/routes/media.routes.js:
router.route('/api/media/new/:userId')
.post(authCtrl.requireSignin, mediaCtrl.create)
router.param('userId', userCtrl.userByID)
userByID方法处理 URL 中传递的:userId参数,并从数据库中检索相关的用户。用户对象将在请求对象中可用,以便在将要执行的下一种方法中使用。类似于用户和认证路由,我们将在express.js中将媒体路由挂载到 Express 应用上,如下所示。
mern-mediastream/server/express.js:
app.use('/', mediaRoutes)
向创建路由 URL /api/media/new/:userId 发送 POST 请求,将确保用户已登录,然后初始化媒体控制器中的create方法。create控制器方法将使用formidable节点模块来解析包含用户上传的媒体细节和视频文件的 multipart 请求体。您可以从命令行运行以下命令来安装此模块:
yarn add formidable
在create方法中,我们将使用在表单数据中接收并使用formidable解析的媒体字段来生成一个新的媒体对象,并将其保存到数据库中。这个create控制器方法定义如下。
mern-mediastream/server/controllers/media.controller.js:
const create = (req, res) => {
let form = new formidable.IncomingForm()
form.keepExtensions = true
form.parse(req, async (err, fields, files) => {
if (err) {
return res.status(400).json({
error: "Video could not be uploaded"
})
}
let media = new Media(fields)
media.postedBy= req.profile
if(files.video){
let writestream = gridfs.openUploadStream(media._id, {
contentType: files.video.type || 'binary/octet-stream'})
fs.createReadStream(files.video.path).pipe(writestream)
}
try {
let result = await media.save()
res.status(200).json(result)
}
catch (err){
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
})
}
如果请求中有文件,formidable 将将其临时存储在文件系统中。我们将使用这个临时文件和媒体对象的 ID,通过 gridfs.openUploadStream 创建一个可写流。在这里,临时文件将被读取并写入 MongoDB GridFS,同时设置 filename 值为媒体 ID。这将生成与 MongoDB 中的相关块和文件信息文档,当需要检索此文件时,我们将使用媒体 ID 来识别它。
要在前端使用此创建媒体 API,我们将在 api-media.js 中添加相应的 fetch 方法,通过传递视图中的多部分表单数据向 API 发送 POST 请求。此方法将定义如下。
mern-mediastream/client/media/api-media.js:
const create = async (params, credentials, media) => {
try {
let response = await fetch('/api/media/new/'+ params.userId, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer ' + credentials.t
},
body: media
})
return await response.json()
} catch(err) {
console.log(err)
}
}
此 create 获取方法将获取当前用户的 ID、用户凭据和媒体表单数据,向后端创建媒体 API 发送 POST 请求。当用户提交新的媒体表单以上传新视频并在应用程序上发布时,我们将使用此方法。在下一节中,我们将查看前端中此表单视图的实现。
新媒体组件
在 MERN Mediastream 应用程序上注册的用户将通过表单视图输入新媒体帖子的详细信息。此表单视图将在 NewMedia 组件中渲染,允许已登录用户通过输入视频的标题、描述和类型以及从本地文件系统中上传视频文件来创建媒体帖子。
此表单视图将呈现如下:

我们将在这个名为 NewMedia 的 React 组件中实现此表单。对于视图,我们将使用 Material-UI 的 Button 和 HTML5 文件 input 元素添加文件上传元素,如下面的代码所示。
mern-mediastream/client/media/NewMedia.js:
<input accept="video/*"
onChange={handleChange('video')}
id="icon-button-file"
type="file"
style={{display: none}}/>
<label htmlFor="icon-button-file">
<Button color="secondary" variant="contained" component="span">
Upload <FileUpload/>
</Button>
</label>
<span>{values.video ? values.video.name : ''}</span>
在文件 input 元素中,我们指定它接受视频文件,因此当用户点击上传并浏览其本地文件夹时,他们只有上传视频文件的选择。
然后,在视图中,我们添加了标题、描述和类型的表单字段,使用 TextField 组件,如下面的代码所示。
mern-mediastream/client/media/NewMedia.js:
<TextField id="title" label="Title" value={values.title}
onChange={handleChange('title')} margin="normal"/><br/>
<TextField id="multiline-flexible" label="Description"
multiline rows="2"
value={values.description}
onChange={handleChange('description')}/><br/>
<TextField id="genre" label="Genre" value={values.genre}
onChange={handleChange('genre')}/><br/>
当用户与输入字段交互输入值时,这些表单字段更改将通过 handleChange 方法进行跟踪。handleChange 函数将定义如下。
mern-mediastream/client/media/NewMedia.js:
const handleChange = name => event => {
const value = name === 'video'
? event.target.files[0]
: event.target.value
setValues({ ...values, [name]: value })
}
handleChange 方法通过更新状态来跟踪新值,包括如果用户上传了视频文件,则包括视频文件名。
最后,你可以通过添加一个提交按钮来完成此表单视图,当用户点击提交按钮时,应将表单数据发送到服务器。我们在这里定义一个 clickSubmit 方法,当用户点击提交按钮时将被调用。
mern-mediastream/client/media/NewMedia.js:
const clickSubmit = () => {
let mediaData = new FormData()
values.title && mediaData.append('title', values.title)
values.video && mediaData.append('video', values.video)
values.description && mediaData.append('description',
values.description)
values.genre && mediaData.append('genre', values.genre)
create({
userId: jwt.user._id
}, {
t: jwt.token
}, mediaData).then((data) => {
if (data.error) {
setValues({...values, error: data.error})
} else {
setValues({...values, error: '', mediaId: data._id,
redirect: true})
}
})
}
此clickSubmit函数将获取输入值并填充mediaData,这是一个FormData对象,确保数据以正确的格式存储在multipart/form-data编码类型中。然后,调用create fetch 方法使用此表单数据在后端创建新的媒体。在成功创建媒体后,用户可能会被重定向到不同的视图,例如,到一个包含新媒体详情的媒体视图,如下面的代码所示。
mern-mediastream/client/media/NewMedia.js:
if (values.redirect) {
return (<Redirect to={'/media/' + values.mediaId}/>)
}
NewMedia组件只能由已登录用户查看。因此,我们将在MainRouter组件中添加一个PrivateRoute,这样它只会在/media/new为认证用户渲染此表单。
mern-mediastream/client/MainRouter.js:
<PrivateRoute path="/media/new" component={NewMedia}/>
此链接可以添加到任何视图,例如在菜单组件中,以便在用户登录时条件性地渲染。现在,由于可以在本媒体流应用中添加新的媒体帖子,在下一节中,我们将讨论检索和渲染与每个媒体帖子关联的视频内容的实现。这将使用户能够从应用程序的前端流式传输和查看存储在 MongoDB GridFS 中的视频文件。
检索和流式传输媒体
任何浏览 MERN Mediastream 应用程序的访客都将能够查看用户在应用程序上发布的媒体。实现此功能需要将存储在 MongoDB GridFS 中的视频文件流式传输到请求客户端,并在媒体播放器中渲染流。在以下章节中,我们将设置一个后端 API 来检索单个视频文件,然后我们将将其用作基于 React 的媒体播放器的源来渲染流式视频。
视频 API
要检索与单个媒体帖子关联的视频文件,我们将实现一个接受 GET 请求的 get 视频 API,请求地址为'/api/medias/video/:mediaId',并查询媒体集合和 GridFS 文件。我们将通过声明以下代码中的路由以及处理 URL 中的:mediaId参数的方式来实现此视频 API。
mern-mediastream/server/routes/media.routes.js:
router.route('/api/medias/video/:mediaId')
.get(mediaCtrl.video)
router.param('mediaId', mediaCtrl.mediaByID)
路由 URL 中的:mediaId参数将在mediaByID控制器中处理,以从媒体集合和 GridFS 文件中检索相关文档和文件详情。然后,这些检索到的结果将附加到请求对象中,以便可以在video控制器方法中按需使用。此mediaByID控制器方法定义如下。
mern-mediastream/server/controllers/media.controller.js:
const mediaByID = async (req, res, next, id) => {
try{
let media = await Media.findById(id).populate('postedBy',
'_id name').exec()
if (!media)
return res.status('400').json({
error: "Media not found"
})
req.media = media
let files = await gridfs.find({filename:media._id}).toArray()
if (!files[0]) {
return res.status(404).send({
error: 'No video found'
})
}
req.file = files[0]
next()
}catch(err) {
return res.status(404).send({
error: 'Could not retrieve media file'
})
}
}
要从 GridFS 检索相关文件详情,我们使用 MongoDB 流式 API 中的find。我们通过文件名值查询存储在 GridFS 中的文件,该值应与媒体集合中相应的媒体 ID 相匹配。然后,我们以数组形式接收匹配的文件记录,并将第一个结果附加到请求对象中,以便在下一个方法中使用。
当这个 API 接收到请求时,调用的下一个方法是video控制器方法。在这个方法中,根据请求是否包含范围头,我们发送回正确的视频块,并将相关内容信息设置为响应头。video控制器方法定义如下结构,响应的组成取决于请求中是否存在范围头。
mern-mediastream/server/controllers/media.controller.js:
const video = (req, res) => {
const range = req.headers["range"]
if (range && typeof range === "string") {
...
... consider range headers and send only relevant chunks in response ...
...
} else {
res.header('Content-Length', req.file.length)
res.header('Content-Type', req.file.contentType)
let downloadStream = gridfs.openDownloadStream(req.file._id)
downloadStream.pipe(res)
downloadStream.on('error', () => {
res.sendStatus(404)
})
downloadStream.on('end', () => {
res.end()
})
}
}
在前面的代码中,如果请求不包含范围头,我们使用gridfs.openDownloadStream流回整个视频文件,这为我们提供了存储在 GridFS 中的相应文件的可读流。这个流与发送回客户端的响应一起管道传输。在响应头中,我们设置了文件的内容类型和总长度。
如果请求包含范围头——例如,当用户拖动到视频中间并从该点开始播放时——我们需要将接收到的范围头转换为起始和结束位置,这将与存储在 GridFS 中的正确块相对应,如下面的代码所示。
mern-mediastream/server/controllers/media.controller.js:
const parts = range.replace(/bytes=/, "").split("-")
const partialstart = parts[0]
const partialend = parts[1]
const start = parseInt(partialstart, 10)
const end = partialend ? parseInt(partialend, 10) : req.file.length - 1
const chunksize = (end - start) + 1
res.writeHead(206, {
'Accept-Ranges': 'bytes',
'Content-Length': chunksize,
'Content-Range': 'bytes ' + start + '-' + end + '/' + req.file.length,
'Content-Type': req.file.contentType
})
let downloadStream = gridfs.openDownloadStream(req.file._id, {start, end: end+1})
downloadStream.pipe(res)
downloadStream.on('error', () => {
res.sendStatus(404)
})
downloadStream.on('end', () => {
res.end()
})
我们将已从头部提取的起始和结束值作为范围传递给gridfs.openDownloadStream。这些起始和结束值指定了从 0 开始的字节数,以开始流式传输并在此之前停止流式传输。我们还设置了包含附加文件详情的响应头,包括内容长度、范围和类型。内容长度现在将是定义范围内的内容总大小。因此,返回给响应的可读流,在这种情况下,将只包含位于起始和结束范围内的文件数据块。
在接收到此获取视频 API 请求后,最终的可读流被管道传输到响应,可以直接在前端视图的基本 HTML5 媒体播放器或 React 风格的媒体播放器中渲染。在下一节中,我们将探讨如何在简单的 React 媒体播放器中渲染此视频流。
使用 React 媒体播放器渲染视频
在应用程序的前端,我们可以在媒体播放器中渲染从 MongoDB GridFS 流出的视频文件。对于 React 风格的媒体播放器,一个好的选择是作为节点模块提供的ReactPlayer组件,可以根据需要自定义。将视频流作为源提供给默认的ReactPlayer组件将渲染带有基本播放控件,如下面的截图所示:

要开始在前端代码中使用 ReactPlayer,我们需要通过在命令行中运行以下 Yarn 命令来安装相应的节点模块:
yarn add react-player
安装完成后,我们可以将其导入到任何 React 组件中,并将其添加到视图中。对于使用浏览器提供的默认控件的基本用法,我们可以在任何具有要渲染的媒体 ID 访问权限的应用程序中的任何 React 视图中添加它,如下面的代码所示:
<ReactPlayer url={'/api/media/video/'+media._id} controls/>
这将加载从获取视频 API 收到的视频流播放器,并为用户提供基本控制选项来与正在播放的流进行交互。ReactPlayer 可以进行自定义,以便提供更多选项。我们将在下一章中探讨一些用于自定义此 ReactPlayer 并使用我们自己的控件的高级选项。
要了解 ReactPlayer 可以实现的功能,请访问 cookpete.com/react-player。
现在,可以检索存储在 MongoDB GridFS 中的单个视频文件并将其流式传输到前端媒体播放器,以便用户可以按需查看和播放视频。在下一节中,我们将讨论如何从后端获取并显示多个视频列表到流媒体应用程序的前端。
列出媒体
在 MERN Mediastream 中,我们将添加相关媒体的列表视图,每个视频都有一个快照,以便访客更容易访问并对应用程序中的视频有一个概述。例如,在下面的屏幕截图中,Profile 组件显示对应用户发布的媒体列表,显示每个媒体的预览和其他详细信息:

我们将在后端设置列表 API 来检索不同的列表,例如单个用户上传的视频和应用程序中观看次数最高的最受欢迎的视频。然后,这些检索到的列表可以在可重用的 MediaList 组件中渲染,该组件将从获取特定 API 的父组件接收媒体对象列表作为属性。在以下章节中,我们将实现 MediaList 组件和后端 API,以从数据库中检索两种不同的媒体列表。
媒体列表组件
MediaList 组件是一个可重用的组件,它将接受一个媒体列表并遍历它,在视图中渲染每个媒体项。在 MERN Mediastream 中,我们使用它来渲染主页视图中最受欢迎的媒体列表以及特定用户在其个人资料中上传的媒体列表。
在 MediaList 组件的视图部分,我们将使用 map 通过 props 中接收到的 media 数组,如下面的代码所示。
mern-mediastream/client/media/MediaList.js:
<GridList cols={3}>
{props.media.map((tile, i) => (
<GridListTile key={i}>
<Link to={"/media/"+tile._id}>
<ReactPlayer url={'/api/media/video/'+tile._id}
width='100%' height='inherit' style=
{{maxHeight: '100%'}}/>
</Link>
<GridListTileBar title={<Link
to={"/media/"+tile._id}> {tile.title} </Link>}
subtitle={<span>
<span>{tile.views} views</span>
<span className={classes.tileGenre}>
<em>{tile.genre}</em>
</span>
</span>}
/>
</GridListTile>
))}
</GridList>
这个MediaList组件使用 Material-UI 的GridList组件,在遍历传入 props 的对象数组时渲染列表中每个项目的媒体详情。它还包括一个ReactPlayer组件,该组件渲染视频 URL 而不显示任何控件。在视图中,这为访客提供了每件媒体的一个简要概述,以及视频内容的预览。
这个组件可以添加到任何可以提供媒体对象数组的视图中。在 MERN Mediastream 应用程序中,我们使用它来渲染两个不同的媒体列表:一个是热门媒体列表,另一个是特定用户发布的媒体列表。在下一节中,我们将探讨如何从数据库中检索热门媒体列表并在前端渲染它。
列出热门媒体
为了从数据库中检索特定的媒体列表,我们需要在服务器上设置相关的 API。对于热门媒体,我们将设置一个接收/api/media/popular的 GET 请求的路由。该路由的声明如下。
mern-mediastream/server/routes/media.routes.js:
router.route('/api/media/popular')
.get(mediaCtrl.listPopular)
对此 URL 的 GET 请求将调用listPopular方法。listPopular控制器方法将查询媒体集合并检索整个集合中观看次数最高的九个媒体文档。listPopular方法定义如下。
mern-mediastream/server/controllers/media.controller.js:
const listPopular = async (req, res) => {
try{
let media = await Media.find({})
.populate('postedBy', '_id name')
.sort('-views')
.limit(9)
.exec()
res.json(media)
} catch(err){
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
查询媒体集合返回的结果按观看次数降序排列,并限制为九个。列表中的每个媒体文档也将包含发布该文档的用户名称和 ID,因为我们调用populate来添加这些用户属性。
这个 API 可以用一个 fetch 请求在前端使用。你可以在api-media.js中定义一个相应的 fetch 方法来发起请求,类似于其他 API 实现。然后,可以在 React 组件中调用这个 fetch 方法,例如在本应用程序的Home组件中。在Home组件中,我们将使用useEffect钩子获取热门视频列表,如下面的代码所示。
mern-mediastream/client/core/Home.js:
useEffect(() => {
const abortController = new AbortController()
const signal = abortController.signal
listPopular(signal).then((data) => {
if (data.error) {
console.log(data.error)
} else {
setMedia(data)
}
})
return function cleanup(){
abortController.abort()
}
}, [])
在此钩子中从 API 获取的列表被设置在状态中,以便可以将其传递给视图中的MediaList组件。在主页视图中,我们可以添加MediaList,如下所示,将列表作为 prop 提供。
mern-mediastream/client/core/Home.js:
<MediaList media={media}/>
这将在 MERN Mediastream 应用程序的主页上渲染数据库中最受欢迎的前九个视频列表。在下一节中,我们将讨论一个类似的实现来检索和渲染特定用户发布的媒体列表。
按用户列出媒体
为了能够从数据库中检索特定用户上传的媒体列表,我们将设置一个 API,该 API 通过/api/media/by/:userId接受一个GET请求。该路由的声明如下。
mern-mediastream/server/routes/media.routes.js:
router.route('/api/media/by/:userId')
.get(mediaCtrl.listByUser)
对此路由的 GET 请求将调用listByUser方法。listByUser控制器方法将查询 Media 集合以找到具有与 URL 中附加的userId匹配的postedBy值的媒体文档。listByUser控制器方法定义如下。
mern-mediastream/server/controllers/media.controller.js:
const listByUser = async (req, res) => {
try{
let media = await Media.find({postedBy: req.profile._id})
.populate('postedBy', '_id name')
.sort('-created')
.exec()
res.json(media)
} catch(err){
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
从 Media 集合查询返回的结果按创建日期排序,最新帖子首先显示。此列表中的每个媒体文档也将包含发布该文档的用户名称和 ID,因为我们调用populate来添加这些用户属性。
此 API 可以通过前端使用 fetch 请求来使用。你可以在api-media.js中定义相应的fetch方法来发起请求,类似于其他 API 实现。然后,可以在 React 组件中调用fetch方法。在我们的应用程序中,我们使用Profile组件中的fetch方法,类似于我们在主页视图中使用的listPopular fetch 方法,来检索列表数据,将其设置到状态中,然后传递给MediaList组件。这将渲染一个包含相应用户发布的媒体列表的个人信息页面。
我们可以通过利用后端已实现的 API 来获取列表数据,在应用程序中检索和显示多个视频。我们还可以在前端视图中渲染列表时,使用无控制条的 ReactPlayer 组件,让用户对每个视频有一个大致的了解。在下一节中,我们将讨论将显示媒体帖子并允许授权用户在应用程序中更新和删除单个媒体帖子的全栈切片。
显示、更新和删除媒体
每位访问 MERN Mediastream 的访客都将能够查看媒体详细信息并流式传输视频,而只有注册用户才能编辑媒体详情并在发布后随时删除它。在以下章节中,我们将实现包括后端 API 和前端视图在内的全栈切片,以显示单个媒体帖子、更新媒体帖子的详情以及从应用程序中删除媒体帖子。
显示媒体
每位访问 MERN Mediastream 的访客都将能够浏览到单个媒体视图来播放视频并读取与之相关的详细信息。每次在应用程序中加载特定视频时,我们也将增加与媒体相关的观看次数。在以下章节中,我们将通过向后端添加读取媒体 API、从前端调用此 API 以及将在视图中显示媒体详细信息的相关 React 组件来实现单个媒体视图。
读取媒体 API
在后端实现读取媒体 API,我们首先通过添加一个GET路由来查询带有 ID 的Media集合,并在响应中返回媒体文档。该路由声明如下。
mern-mediastream/server/routes/media.routes.js:
router.route('/api/media/:mediaId')
.get( mediaCtrl.incrementViews, mediaCtrl.read)
请求 URL 中的 mediaId 将导致 mediaByID 控制器方法执行,并将检索到的媒体文档附加到请求对象中,以便在下一个方法中访问。
向此 API 发送 GET 请求将执行 incrementViews 控制器方法,该方法将找到匹配的媒体记录,并将 views 值增加 1,然后将更新后的记录保存到数据库中。incrementViews 方法定义如下。
mern-mediastream/server/controllers/media.controller.js:
const incrementViews = async (req, res, next) => {
try {
await Media.findByIdAndUpdate(req.media._id,
{$inc: {"views": 1}}, {new: true}).exec()
next()
} catch(err){
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
每次调用此读取媒体 API 时,此方法将增加给定媒体的观看次数 1。从 incrementViews 方法更新媒体后,将调用 read 控制器方法。read 控制器方法将简单地返回检索到的媒体文档作为对请求客户端的响应,如下所示。
mern-mediastream/server/controllers/media.controller.js:
const read = (req, res) => {
return res.json(req.media)
}
要检索响应中发送的媒体文档,我们需要在前端使用 fetch 方法调用此读取媒体 API。我们将在 api-media.js 中设置相应的 fetch 方法,如下所示。
mern-mediastream/client/media/api-media.js:
const read = async (params, signal) => {
try {
let response = await fetch('/api/media/' + params.mediaId, {
method: 'GET',
signal: signal
})
return await response.json()
} catch(err) {
console.log(err)
}
}
此方法获取要检索的媒体 ID,并使用 fetch 向读取 API 路由发送 GET 请求。
读取媒体 API 可以用于在视图中渲染单个媒体详细信息,或预先填充媒体编辑表单。在下一节中,我们将使用此 fetch 方法在 React 组件中调用读取媒体 API 以渲染媒体详细信息,以及将播放相关视频的 ReactPlayer。
媒体组件
Media 组件将渲染单个媒体记录的详细信息,并使用基本 ReactPlayer 和默认浏览器控件进行视频流。完成的单个媒体视图将如下所示:

Media 组件可以调用读取 API 来获取媒体数据本身,或者从调用读取 API 的父组件接收数据作为 props。在后一种情况下,父组件将在 useEffect 钩子中从服务器获取媒体,将其设置为状态,并将其添加到 Media 组件中,如下所示。
mern-mediastream/client/media/PlayMedia.js:
<Media media={media}/>
在 MERN Mediastream 中,我们将在 PlayMedia 组件中添加 Media 组件,该组件使用读取 API 在 useEffect 钩子中从服务器获取媒体内容,并将其作为 props 传递给 Media。PlayMedia 组件的组成将在下一章中更详细地讨论。
Media 组件将接受 props 中的数据,并在视图中渲染以显示详细信息,并在 ReactPlayer 组件中加载视频。媒体标题、类型和观看次数的详细信息可以在 Media 组件中的 Material-UI CardHeader 组件中渲染,如下所示。
mern-mediastream/client/media/Media.js:
<CardHeader
title={props.media.title}
action={<span>
{props.media.views + ' views'}
</span>}
subheader={props.media.genre}
/>
除了渲染这些媒体详情外,我们还将加载Media组件中的视频。视频 URL 基本上是我们后端设置的获取视频 API 路由,在ReactPlayer中以默认浏览器控件加载,如下面的代码所示。
mern-mediastream/client/media/Media.js:
const mediaUrl = props.media._id
? `/api/media/video/${props.media._id}`
: null
…
<ReactPlayer url={mediaUrl}
controls
width={'inherit'}
height={'inherit'}
style={{maxHeight: '500px'}}
config={{ attributes:
{ style: { height: '100%', width: '100%'} }
}}/>
这将渲染一个简单的播放器,允许用户播放视频流。
Media 组件还会渲染发布视频的用户的其他详细信息,视频描述以及创建日期,如下面的代码所示。
mern-mediastream/client/media/Media.js:
<ListItem>
<ListItemAvatar>
<Avatar>
{props.media.postedBy.name &&
props.media.postedBy.name[0]}
</Avatar>
</ListItemAvatar>
<ListItemText primary={props.media.postedBy.name}
secondary={"Published on " +
(new Date(props.media.created))
.toDateString()}/>
</ListItem>
<ListItem>
<ListItemText primary={props.media.description}/>
</ListItem>
在 Material-UI ListItem 组件中显示的详情中,我们还将根据当前登录用户是否是显示的媒体发布者有条件地显示编辑和删除选项。为了在视图中有条件地渲染这些元素,我们将在显示日期的ListItemText之后添加以下代码。
mern-mediastream/client/media/Media.js:
{(auth.isAuthenticated().user && auth.isAuthenticated().user._id)
== props.media.postedBy._id && (<ListItemSecondaryAction>
<Link to={"/media/edit/" + props.media._id}>
<IconButton aria-label="Edit" color="secondary">
<Edit/>
</IconButton>
</Link>
<DeleteMedia mediaId={props.media._id} mediaTitle=
{props.media.title}/>
</ListItemSecondaryAction>)}
这将确保只有在当前用户已登录并且是显示的媒体的上传者时,才会渲染编辑和删除选项。编辑选项链接到媒体编辑表单,而删除选项打开一个对话框,可以启动从数据库中删除此特定媒体文档的操作。在下一节中,我们将实现此选项的功能,以编辑已上传媒体帖子的详情。
更新媒体详情
注册用户将能够访问他们每个媒体上传的编辑表单。更新并提交此表单将保存对媒体集合中给定文档的更改。为了实现这一功能,我们需要创建一个后端 API,允许在确认请求用户已认证并授权后对给定媒体进行更新操作。然后,需要从前端调用此更新 API,并带上媒体更改的详细信息。在接下来的章节中,我们将构建这个后端 API 和 React 组件,以便用户能够修改他们在应用程序上已发布的媒体。
媒体更新 API
在后端,我们需要一个 API,允许我们在用户是请求的媒体帖子的授权创建者的情况下更新数据库中的现有媒体。首先,我们将声明 PUT 路由,该路由接受来自客户端的更新请求。
mern-mediastream/server/routes/media.routes.js:
router.route('/api/media/:mediaId')
.put(authCtrl.requireSignin,
mediaCtrl.isPoster,
mediaCtrl.update)
当接收到 'api/media/:mediaId' 的 PUT 请求时,服务器将确保已登录用户是媒体内容的原始发布者,通过调用isPoster控制器方法。isPoster控制器方法定义如下。
mern-mediastream/server/controllers/media.controller.js:
const isPoster = (req, res, next) => {
let isPoster = req.media && req.auth
&& req.media.postedBy._id == req.auth._id
if(!isPoster){
return res.status('403').json({
error: "User is not authorized"
})
}
next()
}
此方法确保认证用户的 ID 与给定媒体文档中postedBy字段引用的用户 ID 相同。如果用户被授权,则将调用update控制器方法next以使用更改更新现有的媒体文档。update控制器方法定义如下。
mern-mediastream/server/controllers/media.controller.js:
const update = async (req, res) => {
try {
let media = req.media
media = extend(media, req.body)
media.updated = Date.now()
await media.save()
res.json(media)
} catch(err){
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
此方法通过在请求体中接收到的更改详情扩展现有的媒体文档,并将更新的媒体保存到数据库中。
为了在前端访问更新 API,我们将在api-media.js中添加一个相应的 fetch 方法,该方法在向此更新媒体 API 发出 fetch 调用之前,将必要的用户认证凭证和媒体详细信息作为参数传递,如下所示。
mern-mediastream/client/user/api-media.js:
const update = async (params, credentials, media) => {
try {
let response = await fetch('/api/media/' + params.mediaId, {
method: 'PUT',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
},
body: JSON.stringify(media)
})
return await response.json()
} catch(err) {
console.log(err)
}
}
当用户进行更新并提交表单时,将使用此 fetch 方法在媒体编辑表单中。在下一节中,我们将讨论此媒体编辑表单的实现。
媒体编辑表单
允许授权用户更改媒体帖子详细信息的媒体编辑表单将与新媒体表单类似。然而,它将没有上传选项,字段将预先填充现有值,如下面的截图所示:

包含此表单的EditMedia组件将通过在useEffect钩子中调用读取媒体 API 来获取媒体的现有值,如下面的代码所示。
mern-mediastream/client/media/EditMedia.js:
useEffect(() => {
const abortController = new AbortController()
const signal = abortController.signal
read({mediaId: match.params.mediaId}).then((data) => {
if (data.error) {
setError(data.error)
} else {
setMedia(data)
}
})
return function cleanup(){
abortController.abort()
}
}, [match.params.mediaId])
获取的媒体详细信息被设置为状态,以便可以在文本字段中渲染值。表单字段元素将与NewMedia组件中的相同。当用户更新表单中的任何值时,通过调用handleChange方法,这些更改将在状态中的media对象中注册。handleChange方法定义如下。
mediastream/client/media/EditMedia.js:
const handleChange = name => event => {
let updatedMedia = {...media}
updatedMedia[name] = event.target.value
setMedia(updatedMedia)
}
在这个方法中,表单中正在更新的特定字段会在状态中的媒体对象对应的属性中反映出来。当用户完成编辑并点击提交时,将调用带有所需凭证和更改后的媒体值的更新 API。这是通过调用以下定义的clickSubmit方法来完成的。
mediastream/client/media/EditMedia.js:
const clickSubmit = () => {
const jwt = auth.isAuthenticated()
update({
mediaId: media._id
}, {
t: jwt.token
}, media).then((data) => {
if (data.error) {
setError(data.error)
} else {
setRedirect(true)
}
})
}
调用更新媒体 API 将更新媒体集合中相应媒体文档的媒体详细信息,而与媒体关联的视频文件在数据库中保持不变。
这个EditMedia组件只能由已登录的用户访问,并将渲染在'/media/edit/:mediaId'。因此,我们将在MainRouter组件中添加一个PrivateRoute,如下所示。
mern-mediastream/client/MainRouter.js:
<PrivateRoute path="/media/edit/:mediaId" component={EditMedia}/>
此链接在Media组件中添加了一个编辑图标,允许发布媒体的用户访问编辑页面。在Media视图中,用户还可以选择删除他们的媒体帖子。我们将在下一节中实现此功能。
删除媒体
授权用户可以完全删除他们上传到应用程序的媒体,包括媒体集合中的媒体文档和在 MongoDB 中使用的 GridFS 存储的文件块。为了允许用户从应用程序中删除媒体,在以下章节中,我们将定义一个从数据库中删除媒体的后端 API,并实现一个 React 组件,当用户与前端交互以执行此删除操作时,该组件将使用此 API。
删除媒体 API
要从数据库中删除媒体,我们将在后端实现一个删除媒体 API,该 API 将接受客户端在/api/media/:mediaId上的 DELETE 请求。我们将为此 API 添加以下DELETE路由,这将允许授权用户删除他们上传的媒体记录。
mern-mediastream/server/routes/media.routes.js:
router.route('/api/media/:mediaId')
.delete(authCtrl.requireSignin,
mediaCtrl.isPoster,
mediaCtrl.remove)
当服务器在'/api/media/:mediaId'接收到 DELETE 请求时,它将通过调用isPoster控制器方法来确保已登录用户是媒体的原帖发布者。然后,remove控制器方法将完全从数据库中删除指定的媒体。remove方法定义如下。
mern-mediastream/server/controllers/media.controller.js:
const remove = async (req, res) => {
try {
let media = req.media
let deletedMedia = await media.remove()
gridfs.delete(req.file._id)
res.json(deletedMedia)
} catch(err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
除了从媒体集合中删除媒体记录外,我们还使用gridfs来删除存储在数据库中的相关文件详情和块。
要在前端访问此后端 API,您还需要一个具有此路由的 fetch 方法,类似于其他 API 实现。fetch 方法需要获取媒体 ID 和当前用户的认证凭证,以便使用这些值调用删除媒体 API。
当用户通过在前端界面中点击按钮执行删除操作时,将使用 fetch 方法。在下一节中,我们将讨论一个名为DeleteMedia的 React 组件,其中用户将通过此删除媒体操作执行。
DeleteMedia 组件
DeleteMedia组件被添加到Media组件中,并且只对添加了此特定媒体的已登录用户可见。此组件基本上是一个按钮,当点击时,会打开一个Dialog组件,提示用户确认删除操作,如以下截图所示:

当DeleteMedia组件在Media组件中添加时,它接受媒体 ID 和标题作为 props。其实现方式将与我们在第四章中讨论的DeleteUser组件类似,即添加 React 前端以完成 MERN。一旦添加了DeleteMedia组件,用户通过确认他们的操作,就能完全从应用程序中移除发布的媒体。
本章中我们开发的 MERN Mediastream 应用程序是一个完整的媒体流应用程序,具有将视频文件上传到数据库、流回存储的视频给观众、支持如媒体创建、更新、读取和删除等 CRUD 操作,以及支持按上传者或流行度列出媒体的功能。
摘要
在本章中,我们通过扩展 MERN 框架应用程序并利用 MongoDB GridFS 开发了媒体流应用程序。
除了为媒体上传添加基本的添加、更新、删除和列表功能外,我们还探讨了基于 MERN 的应用程序如何允许用户上传视频文件,将这些文件作为块存储到 MongoDB GridFS 中,并根据需要部分或全部流回给观众。我们还介绍了如何使用带有默认浏览器控制的ReactPlayer来流式传输视频文件。您可以将这些流式传输功能应用于可能需要从数据库中存储和检索大型文件的任何全栈应用程序。
在下一章中,我们将学习如何自定义ReactPlayer以包含我们自己的控件和功能,以便用户有更多的选择,例如播放列表中的下一个视频。此外,我们还将讨论如何通过实现带有媒体视图数据的服务器端渲染来提高媒体详情的 SEO。
第十六章:自定义媒体播放器并改进 SEO
用户访问媒体流应用主要是为了播放媒体和探索其他相关媒体。这使得媒体播放器——以及渲染相关媒体详情的视图——对于流应用至关重要。
在本章中,我们将专注于开发我们在上一章(第十一章,构建媒体流应用)开始构建的 MERN Mediastream 应用程序的播放媒体页面。我们将解决以下问题,以增强媒体播放功能,并帮助提升媒体内容在网上的影响力,使其触及更多用户:
-
在
ReactPlayer上自定义播放器控件 -
从相关视频列表中播放下一个视频
-
自动播放相关媒体列表
-
服务器端渲染(SSR)的
PlayMedia视图,以数据改进搜索引擎优化(SEO)
完成这些主题后,您将更擅长设计前端用户界面中 React 组件之间的复杂交互,并提高您的全栈 React 应用程序的 SEO。
将自定义媒体播放器添加到 MERN Mediastream
上一章中开发的 MERN Mediastream 应用程序实现了一个简单的媒体播放器,带有默认浏览器控件,一次播放一个视频。在本章中,我们将使用定制的ReactPlayer和相关媒体列表更新播放媒体视图,该列表可以在当前视频结束时自动播放。带有自定义播放器和相关播放列表的更新视图将类似于以下截图:

完整的 MERN Mediastream 应用程序代码可在 GitHub 上找到,网址为github.com/PacktPublishing/Full-Stack-React-Projects-Second-Edition/tree/master/Chapter11%20and%2012/mern-mediastream。您可以将此代码克隆并运行,以便在阅读本章剩余部分的代码解释时运行应用程序。
下面的组件树图显示了构成 MERN Mediastream 前端的所有自定义组件,包括本章中将改进或添加的组件:

本章中修改的组件和新添加的组件包括PlayMedia组件,它包含所有媒体播放器功能;MediaPlayer组件,它添加了一个带有自定义控件的ReactPlayer;以及RelatedMedia组件,它包含一个相关视频列表。在下一节中,我们将讨论播放媒体页面的结构,以及它将如何容纳本章中将在 MERN Mediastream 应用程序中扩展的所有媒体观看和交互功能。
播放媒体页面
当访客想在 MERN Mediastream 上查看特定媒体时,他们将被带到播放媒体页面,该页面将包含媒体详情、用于流式传输视频的媒体播放器以及可以播放的相关媒体列表。我们将使用名为 PlayMedia 的 React 组件实现此 PlayMedia 视图。在下一节中,我们将讨论如何构建此组件以实现这些功能。
组件结构
我们将在播放媒体页面中构建组件结构,以便将媒体数据从父组件逐级传递到内部组件。在这种情况下,PlayMedia 组件将是父组件,包含 RelatedMedia 组件,还包含 Media 组件,该组件将包含嵌套的 MediaPlayer 组件,如下面的截图所示:

当在应用程序的前端访问单个媒体链接时,PlayMedia 组件将从服务器检索并加载相应的媒体数据和相关媒体列表。然后,相关细节将通过 props 传递给 Media 和 RelatedMedia 子组件。
RelatedMedia 组件将列出并链接其他相关媒体,点击列表中的任何媒体将重新渲染 PlayMedia 组件及其内部组件,并使用新数据。
我们将更新第十一章 Building a Media-Streaming Application 中开发的 Media 组件,以添加一个自定义媒体播放器作为子组件。这个定制的 MediaPlayer 组件也将利用从 PlayMedia 传递过来的数据来流式传输当前视频并链接到相关媒体列表中的下一个视频。
在 PlayMedia 组件中,我们将添加一个自动播放切换功能,允许用户选择是否自动播放相关媒体列表中的视频,一个接一个。自动播放状态将由 PlayMedia 组件管理,但此功能需要在 MediaPlayer 嵌套子组件中视频结束时重新渲染父组件状态中的数据,以确保下一视频能够自动播放,同时跟踪相关列表。
为了实现这一点,PlayMedia 组件需要提供一个状态更新方法作为 prop,该 prop 将在 MediaPlayer 组件中使用,以更新这些组件之间的共享和相互依赖的状态值。
考虑到这个组件结构,我们将扩展并更新 MERN Mediastream 应用程序,以实现一个功能齐全的播放媒体页面。在下一节中,我们将首先添加一个功能,在 PlayMedia 视图中向用户提供相关媒体列表。
列出相关媒体
当用户在应用程序中查看单个媒体时,他们将在同一页面上看到相关媒体列表。相关媒体列表将包括属于与给定视频相同类型的其他媒体记录,并按观看次数最高的顺序排序。为了实现此功能,我们需要集成一个全栈切片,从后端的媒体集合中检索相关列表并在前端渲染它。在接下来的几节中,我们将在后端添加一个相关媒体列表 API,以及在前端获取此 API 的方法和一个 React 组件,用于渲染通过此 API 检索到的媒体列表。
相关媒体列表 API
我们将在后端实现一个 API 端点来从数据库中检索相关媒体的列表。该 API 将在'/api/media/related/:mediaId'接收GET请求,并且该路由将与其他媒体路由一起声明,如下所示:
mern-mediastream/server/routes/media.routes.js
router.route('/api/media/related/:mediaId')
.get(mediaCtrl.listRelated)
路径中的:mediaId参数将由第十一章中“视频 API”部分实现的mediaByID方法处理,该部分是构建媒体流应用。它从数据库中检索与该 ID 对应的媒体,并将其附加到request对象中,以便在下一种方法中访问。listRelated控制器方法是调用此 API 路由的GET请求的下一个方法。此方法将查询媒体集合以找到与提供的媒体具有相同类型的记录,并排除返回结果中的给定媒体记录。listRelated控制器方法定义如下所示:
mern-mediastream/server/controllers/media.controller.js
const listRelated = async (req, res) => {
try {
let media = await Media.find({ "_id": { "$ne": req.media },
"genre": req.media.genre})
.limit(4)
.sort('-views')
.populate('postedBy', '_id name')
.exec()
res.json(media)
} catch (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
查询返回的结果将按观看次数最高的顺序排序,并限制为前四个媒体记录。返回结果中的每个media对象也将包含发布媒体的用户的名称和 ID,如populate方法中指定。
在客户端,我们将设置一个相应的fetch方法,该方法将在PlayMedia组件中使用,以使用此 API 检索相关媒体列表。此方法定义如下:
mern-mediastream/client/media/api-media.js
const listRelated = async (params, signal) => {
try {
let response = await fetch('/api/media/related/'+ params.mediaId, {
method: 'GET',
signal: signal,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
})
return await response.json()
} catch(err) {
console.log(err)
}
}
这个listRelated获取方法将接受一个媒体 ID,并向后端的相关媒体列表 API 发起一个GET请求。我们将在PlayMedia组件中使用此方法来检索与当前媒体播放器中加载的媒体相关的媒体列表。然后,这个列表将在RelatedMedia组件中显示。在下一节中,我们将探讨这个RelatedMedia组件的实现。
相关媒体组件
在播放媒体页面上,除了在播放器中加载的媒体外,我们将在RelatedMedia组件中加载相关媒体列表。RelatedMedia组件将从PlayMedia组件接收相关媒体列表作为 prop,并渲染列表中每个视频的详细信息以及视频快照,如图所示:

在RelatedMedia视图的实现中,我们使用map函数遍历从 props 接收到的媒体数组,并按以下代码结构渲染每个媒体的详细信息以及视频快照:
mern-mediastream/client/media/RelatedMedia.js
{props.media.map((item, i) => {
return
<span key={i}>... video snapshot ... | ... media details ...</span>
})
}
在此结构中,为了渲染每个媒体项目的视频快照,我们将使用不带控件的基本ReactPlayer,如下所示:
mern-mediastream/client/media/RelatedMedia.js
<Link to={"/media/"+item._id}>
<ReactPlayer url={'/api/media/video/'+item._id}
width='160px'
height='140px'/>
</Link>
我们将ReactPlayer包装在一个链接中,以访问此媒体的单独视图。因此,点击给定的视频快照将重新渲染PlayMedia视图以加载链接媒体的详细信息。在快照旁边,我们将显示每个视频的详细信息,包括标题、类型、创建日期和观看次数,以下代码所示:
mern-mediastream/client/media/RelatedMedia.js
<Typography type="title" color="primary">{item.title}</Typography>
<Typography type="subheading"> {item.genre} </Typography>
<Typography component="p">
{(new Date(item.created)).toDateString()}
</Typography>
<Typography type="subheading">{item.views} views</Typography>
这将为接收到的 props 中每个相关媒体列表中的媒体渲染视频快照旁边的详细信息。
要在播放媒体页面上渲染此RelatedMedia组件,我们必须将其添加到PlayMedia组件中。PlayMedia组件将使用本节中较早实现的关联媒体列表 API 从后端检索相关媒体,并将其作为 props 传递给RelatedMedia组件。在下一节中,我们将讨论此PlayMedia组件的实现。
PlayMedia组件
PlayMedia组件将渲染播放媒体页面。此组件由Media和RelatedMedia子组件以及自动播放切换组成,并在加载到视图中时为这些组件提供数据。
当用户访问单个媒体链接时,我们将向MainRouter中添加一个Route,并在'/media/:mediaId'处挂载PlayMedia,如下所示:
mern-mediastream/client/MainRouter.js
<Route path="/media/:mediaId" component={PlayMedia}/>
当PlayMedia组件挂载时,它将根据路由链接中的mediaId参数使用useEffect钩子从服务器获取媒体数据和相关媒体列表。
在一个useEffect钩子中,它将获取要在媒体播放器中加载的媒体,如下所示:
mern-mediastream/client/media/PlayMedia.js
useEffect(() => {
const abortController = new AbortController()
const signal = abortController.signal
read({mediaId: props.match.params.mediaId}, signal).then((data) => {
if (data && data.error) {
console.log(data.error)
} else {
setMedia(data)
}
})
return function cleanup(){
abortController.abort()
}
}, [props.match.params.mediaId])
从 React Router 组件接收到的props.match中访问路由路径中的媒体 ID。它在调用read API 获取方法时用于从服务器检索媒体详细信息。接收到的media对象被设置在状态中,以便可以在Media组件中渲染。
在另一个 useEffect 钩子中,我们使用相同的媒体 ID 调用 listRelated API 获取方法,如下面的代码所示。
mern-mediastream/client/media/PlayMedia.js
useEffect(() => {
const abortController = new AbortController()
const signal = abortController.signal
listRelated({
mediaId: props.match.params.mediaId}, signal).then((data) => {
if (data.error) {
console.log(data.error)
} else {
setRelatedMedia(data)
}
})
return function cleanup(){
abortController.abort()
}
}, [props.match.params.mediaId])
listRelated API 获取方法从服务器检索相关媒体列表,并将值设置到状态中,以便在 RelatedMedia 组件中渲染。
存储在状态中的媒体和相关媒体列表值用于将这些属性传递给在视图中添加的子组件。例如,在以下代码中,只有当相关媒体列表包含任何媒体时,RelatedMedia 组件才会被渲染,并将列表作为属性传递给它:
mern-mediastream/client/media/PlayMedia.js
{relatedMedia.length > 0 &&
(<RelatedMedia media={relatedMedia}/>)}
在本章的后面部分,在 自动播放相关媒体 部分中,我们将在相关媒体列表长度大于 0 的情况下,仅在 RelatedMedia 组件上方添加 Autoplay 切换组件。我们还将讨论将作为属性传递给 Media 组件的 handleAutoPlay 方法的实现。它还将接收 media 详细对象,以及相关媒体列表中第一项的视频 URL,这将被视为下一个要播放的 URL。Media 组件被添加到 PlayMedia 中,并带有这些属性,如下面的代码所示:
mern-mediastream/client/media/PlayMedia.js
const nextUrl = relatedMedia.length > 0
? `/media/${relatedMedia[0]._id}` : ''
<Media media={media}
nextUrl={nextUrl}
handleAutoplay={handleAutoplay}/>
此 Media 组件在播放媒体页面上渲染媒体详情,以及一个定制的媒体播放器,允许观众控制视频的流。在下一节中,我们将讨论此定制媒体播放器的实现,并完成播放媒体页面的核心功能。
定制媒体播放器
在 MERN Mediastream 中,我们希望为用户提供一个比默认浏览器选项更多的控件,并且外观与应用程序的其他部分相匹配。我们将定制 ReactPlayer 上的播放器控件,用自定义的外观和功能替换这些默认控件,如下面的截图所示:

控件将被添加到视频下方,包括进度搜索栏;播放、暂停、下一曲、音量、循环和全屏选项;还将显示视频的总时长和已播放的量。在以下章节中,我们首先更新上一章中讨论的 Media 组件,第十一章,构建媒体流应用,以适应新的播放器功能。然后,在实现此播放器中自定义媒体控件的功能之前,我们将初始化一个包含新播放器的 MediaPlayer 组件。
更新媒体组件
现有的Media组件包含一个基本的ReactPlayer,它具有默认的浏览器控件,用于播放指定的视频。我们将用一个新的MediaPlayer组件替换这个ReactPlayer,我们将在下一节开始实现它。MediaPlayer组件将包含一个定制的ReactPlayer,并且它将被添加到Media组件代码中,如下所示:
mern-mediastream/client/media/Media.js
const mediaUrl = props.media._id
? `/api/media/video/${props.media._id}`
: null
...
<MediaPlayer srcUrl={mediaUrl}
nextUrl={props.nextUrl}
handleAutoplay={props.handleAutoplay}/>
当将此MediaPlayer组件添加到Media组件时,它将传递当前视频的源 URL、下一视频的源 URL 以及handleAutoPlay方法,这些作为props在Media组件中从PlayMedia组件接收。这些 URL 值和自动播放处理方法将在MediaPlayer组件中用于添加各种视频播放选项。在下一节中,我们将开始实现这个MediaPlayer组件,通过初始化添加到自定义媒体播放器中所需的不同值来添加功能控制。
初始化媒体播放器
我们将在MediaPlayer组件中实现定制的媒体播放器。这个播放器将渲染从后端流出的视频,并为用户提供不同的控制选项。我们将使用ReactPlayer组件将此媒体播放功能以及自定义控制选项集成到MediaPlayer中。正如前一章所讨论的,ReactPlayer组件提供了一系列的自定义选项,我们将利用这些选项来实现本应用程序中要添加的媒体播放器功能。
在定义MediaPlayer组件时,我们将在添加自定义功能及其对应的用户操作处理代码之前,首先初始化ReactPlayer组件的控制起始值。
我们自定义的控制值将对应于ReactPlayer组件中允许的属性。要查看可用属性列表及其解释,请访问 github.com/CookPete/react-player#props。
首先,我们需要在组件的状态中设置初始的控制值。我们将从以下对应于以下控制值的控制值开始:
-
媒体的播放状态
-
音频的音量
-
静音状态
-
视频的时长
-
搜索状态
-
视频的播放速率
-
循环值
-
全屏值
-
视频错误
-
正在流媒体的视频的播放、加载和结束状态
初始化这些值的代码将添加如下:
mern-mediastream/client/media/MediaPlayer.js
const [playing, setPlaying] = useState(false)
const [volume, setVolume] = useState(0.8)
const [muted, setMuted] = useState(false)
const [duration, setDuration] = useState(0)
const [seeking, setSeeking] = useState(false)
const [playbackRate, setPlaybackRate] = useState(1.0)
const [loop, setLoop] = useState(false)
const [fullscreen, setFullscreen] = useState(false)
const [videoError, setVideoError] = useState(false)
const [values, setValues] = useState({
played: 0, loaded: 0, ended: false
})
在状态中设置的这些值将允许我们自定义ReactPlayer组件中相应控制的功能,我们将在下一节中详细讨论。
在MediaPlayer组件的视图代码中,我们将添加这个ReactPlayer,并使用从Media组件发送的 prop,使用这些控制值和源 URL,如下面的代码所示:
mern-mediastream/client/media/MediaPlayer.js
<ReactPlayer
ref={ref}
width={fullscreen ? '100%':'inherit'}
height={fullscreen ? '100%':'inherit'}
style={fullscreen ? {position:'relative'} : {maxHeight: '500px'}}
config={{ attributes: { style: { height: '100%', width: '100%'} } }}
url={props.srcUrl}
playing={playing}
loop={loop}
playbackRate={playbackRate}
volume={volume}
muted={muted}
onEnded={onEnded}
onError={showVideoError}
onProgress={onProgress}
onDuration={onDuration}/>
除了设置控件值外,我们还将根据播放器是否处于全屏模式添加样式。我们还需要获取浏览器中渲染的此播放器元素的引用,以便可以在自定义控件的代码中使用它。我们将使用useRef React 钩子将引用初始化为null,然后使用ref方法将其设置为相应的播放器元素,如下面的代码所示:
mern-mediastream/client/media/MediaPlayer.js
let playerRef = useRef(null)
const ref = player => {
playerRef = player
}
playerRef中的值将提供对浏览器中渲染的播放器元素的访问权限。我们将使用此引用按需操作播放器,以使自定义控件功能化。
作为初始化媒体播放器的最后一步,我们将添加处理播放器抛出的错误代码,如果由于任何原因指定的视频源无法加载。我们将定义一个showVideoError方法,当发生视频错误时将被调用。showVideoError方法将定义如下:
mern-mediastream/client/media/MediaPlayer.js
const showVideoError = e => {
console.log(e)
setVideoError(true)
}
此方法将在媒体播放器上方的视图中渲染错误消息。我们可以通过在ReactPlayer上方的视图中添加以下代码有条件地显示此错误消息:
mern-mediastream/client/media/MediaPlayer.js
{videoError && <p className={classes.videoError}>Video Error. Try again later.</p>}
当发生错误时,这将渲染视频错误消息。由于我们将允许用户从相关媒体列表中在播放器中播放另一个视频,因此如果加载了新视频,我们将重置错误消息。我们可以通过确保useEffect仅在视频源 URL 更改时运行,使用useEffect钩子来隐藏新视频加载时的错误消息,如下面的代码所示:
mern-mediastream/client/media/MediaPlayer.js
useEffect(() => {
setVideoError(false)
}, [props.srcUrl])
这将确保在新的视频加载并正确流式传输时不会显示错误消息。
在设置了这些初始控件值并将ReactPlayer添加到组件后,在下一节中,我们可以开始自定义这些控件在我们应用程序中的外观和功能。
自定义媒体控件
我们将在MediaPlayer组件中渲染的视频下方添加自定义播放器控件元素,并使用ReactPlayer库提供的选项和事件操作其功能。在以下章节中,我们将实现播放、暂停和重放控件;播放下一个控件;循环功能;音量控制选项;进度控制选项;全屏选项,并显示视频的总时长和已播放的量。
播放、暂停和重放
用户将能够播放、暂停和重放当前视频。我们将使用绑定到ReactPlayer属性和事件的Material-UI组件实现这三个选项。播放、暂停和重放选项将渲染如下截图所示:

为了实现播放、暂停和重播功能,我们将根据视频是否正在播放、是否已暂停或已结束,有条件地添加播放、暂停或重播图标按钮,如下面的代码所示:
mern-mediastream/client/media/MediaPlayer.js
<IconButton color="primary" onClick={playPause}>
<Icon>{playing ? 'pause': (ended ? 'replay' : 'play_arrow')}</Icon>
</IconButton>
根据三元运算符的结果,在此 IconButton 中渲染播放、暂停或重播图标。
当用户点击按钮时,我们将更新状态中的 playing 值,以便更新 ReactPlayer。我们通过在按钮点击时调用 playPause 方法来实现这一点。playPause 方法如下定义:
mern-mediastream/client/media/MediaPlayer.js
const playPause = () => {
setPlaying(!playing)
}
状态中 playing 的更新值将根据 ReactPlayer 组件相应地播放或暂停视频。在下一节中,我们将看到如何添加一个控制选项,允许我们从相关媒体列表中播放下一个视频。
下一个播放
用户可以使用下一个播放按钮播放相关媒体列表中的下一个视频,该按钮将根据下一个视频是否可用而渲染。此下一个播放按钮的两个版本如下截图所示:

如果相关列表不包含任何媒体,则下一个播放按钮将被禁用。下一个播放图标将基本链接到从 PlayMedia 传递的作为属性的下一个 URL 值。此下一个播放按钮将被添加到 MediaPlayer 视图中,如下所示:
mern-mediastream/client/media/MediaPlayer.js
<IconButton disabled={!props.nextUrl} color="primary">
<Link to={props.nextUrl}>
<Icon>skip_next</Icon>
</Link>
</IconButton>
点击此下一个播放按钮将重新加载带有新媒体详情的 PlayMedia 组件并开始播放视频。在下一节中,我们将添加一个控制选项,允许当前视频循环播放。
视频结束时循环
用户可以使用循环按钮设置当前视频循环播放。循环按钮将以两种状态渲染,即设置和取消设置,如下面的截图所示:

此循环图标按钮将以不同的颜色显示,以指示它是否已被用户设置或取消设置。渲染此循环按钮的代码将被添加到 MediaPlayer 中,如下所示:
mern-mediastream/client/media/MediaPlayer.js
<IconButton color={loop ? 'primary' : 'default'}
onClick={onLoop}>
<Icon>loop</Icon>
</IconButton>
循环图标颜色将根据状态中 loop 的值而改变。当点击此循环图标按钮时,我们将通过调用以下定义的 onLoop 方法来更新状态中的 loop 值:
mern-mediastream/client/media/MediaPlayer.js
const onLoop = () => {
setLoop(!loop)
}
当此 loop 值设置为 true 时,视频将循环播放。我们需要捕获 onEnded 事件,以检查 loop 是否已设置为 true,以便相应地更新 playing 值。当视频到达结束时,将调用 onEnded 方法。此 onEnded 方法如下定义:
mern-mediastream/client/media/MediaPlayer.js
const onEnded = () => {
if(loop){
setPlaying(true)
} else{
setValues({...values, ended: true})
setPlaying(false)
}
}
因此,如果将loop值设置为true,当视频结束时,它将再次开始播放;否则,它将停止播放并渲染重播按钮。在下一节中,我们将添加设置视频音量的控件。
音量控制
为了控制正在播放的视频的音量,用户可以选择增加或降低音量,以及静音或取消静音。渲染的音量控件将根据用户操作和当前音量值进行更新。音量控件的不同状态如下:
- 如果音量提高,将渲染一个音量增加图标,如下所示截图:

- 如果用户将音量降低到零,将渲染一个音量关闭图标,如下所示:

- 如果用户点击图标以静音音量,将显示一个音量静音图标按钮,如下所示:

为了实现这一点,我们将根据volume、muted、volume_up和volume_off值有条件地渲染IconButton中的不同图标,如下所示代码:
<IconButton color="primary" onClick={toggleMuted}>
<Icon> {volume > 0 && !muted && 'volume_up' ||
muted && 'volume_off' ||
volume==0 && 'volume_mute'} </Icon>
</IconButton>
当点击此 IconButton 时,将通过调用toggleMuted方法来静音或取消静音音量,该方法定义如下:
mern-mediastream/client/media/MediaPlayer.js
const toggleMuted = () => {
setMuted(!muted)
}
根据状态中muted的当前值,音量将被静音或取消静音。为了允许用户增加或降低音量,我们将添加一个类型为range的输入元素,允许用户设置介于0和1之间的音量值。此输入元素将添加到代码中,如下所示:
mern-mediastream/client/media/MediaPlayer.js
<input type="range"
min={0}
max={1}
step='any'
value={muted? 0 : volume}
onChange={changeVolume}/>
在输入范围上更改value将通过调用changeVolume方法相应地设置状态中的volume值。此changeVolume方法定义如下:
mern-mediastream/client/media/MediaPlayer.js
const changeVolume = e => {
setVolume(parseFloat(e.target.value))
}
状态中volume值的变化将应用于ReactPlayer,这将设置当前播放媒体的音量。在下一节中,我们将添加控制正在播放的视频进度的选项。
进度控制
在媒体播放器中,用户将看到视频已加载和播放的部分,并在进度条中显示。为了实现此功能,我们将使用 Material-UI 的LinearProgress组件来指示视频已缓冲的部分以及已播放的部分。然后,我们将结合此组件与类型为range的输入元素,使用户能够将时间滑块移动到视频的不同部分并从那里播放。
这次时间滑块和进度条将渲染如下截图所示:

LinearProgress组件将使用状态中的played和loaded值来渲染这些条形。它将使用played和loaded值来显示不同的颜色,如下面的代码所示:
mern-mediastream/client/media/MediaPlayer.js
<LinearProgress color="primary" variant="buffer"
value={values.played*100} valueBuffer={values.loaded*100}
style={{width: '100%'}}
classes={{
colorPrimary: classes.primaryColor,
dashedColorPrimary : classes.primaryDashed,
dashed: classes.dashed
}}
/>
每个进度条的外观和颜色将由您为primaryColor、dashedColorPrimary和dashed类定义的样式决定。
为了在视频播放或加载时更新LinearProgress组件,我们将使用onProgress事件监听器来设置played和loaded的当前值。onProgress方法将定义如下所示:
mern-mediastream/client/media/MediaPlayer.js
const onProgress = progress => {
if (!seeking) {
setValues({...values, played: progress.played, loaded: progress.loaded})
}
}
我们只想在当前未搜索时更新时间滑块,因此我们首先在设置played和loaded值之前检查状态中的seeking值。
对于时间滑动控制,我们将添加范围输入元素并定义样式,如下面的代码所示,将其放置在LinearProgress组件之上。范围值将随着played值的变化而更新,因此范围值看起来会随着视频的进度而移动。这个代表时间滑块的输入元素将被添加到媒体播放器中,如下面的代码所示:
mern-mediastream/client/media/MediaPlayer.js
<input type="range" min={0} max={1}
value={values.played} step='any'
onMouseDown={onSeekMouseDown}
onChange={onSeekChange}
onMouseUp={onSeekMouseUp}
style={{ position: 'absolute',
width: '100%',
top: '-7px',
zIndex: '999',
'-webkit-appearance': 'none',
backgroundColor: 'rgba(0,0,0,0)' }}
/>
在用户自己拖动并设置范围选择器的情况下,我们将添加代码来处理onMouseDown、onMouseUp和onChange事件,以便从所需位置开始播放视频。
当用户按下鼠标开始拖动时,我们将seeking设置为true,这样就不会在played和loaded中设置进度值。这将通过定义如下所示的onSeekMouseDown方法来实现:
mern-mediastream/client/media/MediaPlayer.js
const onSeekMouseDown = e => {
setSeeking(true)
}
当范围值发生变化时,我们将调用onSeekChange方法来设置played值,并在检查用户是否将时间滑块拖动到视频末尾后设置ended值。此onSeekChange方法将定义如下:
mern-mediastream/client/media/MediaPlayer.js
const onSeekChange = e => {
setValues({...values, played:parseFloat(e.target.value),
ended: parseFloat(e.target.value) >= 1})
}
当用户完成拖动并抬起鼠标点击时,我们将seeking设置为false,并将媒体播放器的seekTo值设置为输入范围中当前设置的值。当用户完成搜索后,将执行onSeekMouseUp方法,其定义如下:
mern-mediastream/client/media/MediaPlayer.js
const onSeekMouseUp = e => {
setSeeking(false)
playerRef.seekTo(parseFloat(e.target.value))
}
这样,用户将能够选择视频的任何部分进行播放,并获得正在流式传输的视频的时间进度视觉信息。在下一节中,我们将添加一个控件,允许用户以全屏模式查看视频。
全屏
用户可以通过点击控制栏中的全屏按钮以全屏模式查看视频。播放器的全屏按钮将渲染如下截图所示:

为了为视频实现全屏选项,我们将使用screenfull节点模块来跟踪视图是否处于全屏状态,并使用react-dom中的findDOMNode来指定哪个文档对象模型(DOM)元素将通过screenfull实现全屏。
为了设置fullscreen代码,我们首先通过在命令行中运行以下命令来安装screenfull:
yarn add screenfull
然后,我们将导入screenfull和findDOMNode到MediaPlayer组件中,如下面的代码所示:
mern-mediastream/client/media/MediaPlayer.js
import screenfull from 'screenfull'
import { findDOMNode } from 'react-dom'
当MediaPlayer组件挂载时,我们将使用useEffect钩子添加一个screenfull更改事件监听器,该监听器将更新状态中的fullscreen值以指示屏幕是否处于全屏状态。useEffect钩子将添加如下,带有screenfull更改监听器代码:
mern-mediastream/client/media/MediaPlayer.js
useEffect(() => {
if (screenfull.enabled) {
screenfull.on('change', () => {
let fullscreen = screenfull.isFullscreen ? true : false
setFullscreen(fullscreen)
})
}
}, [])
在状态中设置的此fullscreen值将在用户与按钮交互以全屏模式渲染视频时更新。在视图中,我们将添加一个icon按钮用于fullscreen,与其他控制按钮一起,如下面的代码所示:
mern-mediastream/client/media/MediaPlayer.js
<IconButton color="primary" onClick={onClickFullscreen}>
<Icon>fullscreen</Icon>
</IconButton>
当用户点击此按钮时,我们将使用screenfull和findDOMNode通过调用定义如下所示的onClickFullscreen方法来使视频播放器全屏:
mern-mediastream/client/media/MediaPlayer.js
const onClickFullscreen = () => {
screenfull.request(findDOMNode(playerRef))
}
我们通过使用findDOMNode中的playerRef引用来访问浏览器中渲染媒体播放器的元素,并使用screenfull.request使其全屏。用户可以全屏观看视频,在任何时候按Esc键退出全屏并返回到PlayMedia视图。在下一节中,我们将实现媒体播放器控制中的最终定制,以显示视频的总长度以及已经播放的部分。
播放时长
在媒体播放器的自定义媒体控制部分,我们希望在可读的时间格式中显示已经过去的时间和视频的总时长,如下面的截图所示:

为了显示时间,我们可以利用 HTML 的time元素,它接受一个datetime值,并将其添加到MediaPlayer的视图代码中,如下所示:
mern-mediastream/client/media/MediaPlayer.js
<time dateTime={`P${Math.round(duration * played)}S`}>
{format(duration * played)}
</time> /
<time dateTime={`P${Math.round(duration)}S`}>
{format(duration)}
</time>
在这些time元素的dateTime属性中,我们提供了表示播放时长或视频总时长的总舍入秒数。我们将通过使用onDuration事件获取视频的总duration值,并将其设置到状态中,以便在time元素中渲染。onDuration方法定义如下:
mern-mediastream/client/media/MediaPlayer.js
const onDuration = (duration) => {
setDuration(duration)
}
为了使时长和已播放时间值可读,我们将使用以下 format 函数:
mern-mediastream/client/media/MediaPlayer.js
const format = (seconds) => {
const date = new Date(seconds * 1000)
const hh = date.getUTCHours()
let mm = date.getUTCMinutes()
const ss = ('0' + date.getUTCSeconds()).slice(-2)
if (hh) {
mm = ('0' + date.getUTCMinutes()).slice(-2)
return `${hh}:${mm}:${ss}`
}
return `${mm}:${ss}`
}
这个 format 函数将秒数转换为 hh/mm/ss 格式,使用 JavaScript 日期 API 中的方法。
添加到这个自定义媒体播放器的控件大多基于 ReactPlayer 模块中提供的一些可用功能及其官方文档中的示例。在为这个应用程序实现自定义媒体播放器时,我们更新并添加了相关的播放控件、循环选项、音量控件、进度搜索控件、全屏观看选项以及视频时长的显示。ReactPlayer 中还有更多选项可用于进一步的定制和扩展,具体取决于特定的功能需求。在实现了自定义媒体播放器的不同功能后,在下一节中,我们可以开始讨论如何从可用的媒体列表中实现播放器的自动播放视频。
自动播放相关媒体
在播放媒体页面,用户将可以选择从相关媒体列表中自动播放一个视频接一个视频。为了实现这一功能,PlayMedia 组件将管理自动播放状态,这将决定在当前视频在播放器中流式传输结束后,MediaPlayer 和 RelatedMedia 组件将如何渲染数据和数据。在接下来的章节中,我们将通过在 PlayMedia 组件中添加一个切换按钮并实现 handleAutoplay 方法来完成这个自动播放功能,该方法需要在 MediaPlayer 组件中视频结束时被调用。
切换自动播放
在播放媒体页面上,我们将在相关媒体列表上方添加一个自动播放切换选项。除了让用户设置自动播放外,切换按钮还将指示它是否当前已设置,如下面的截图所示:

为了添加自动播放切换选项,我们将使用 Material-UI 的 Switch 组件和 FormControlLabel,并将其添加到 PlayMedia 组件上方的 RelatedMedia 组件中。它只会在相关媒体列表中有媒体时渲染。我们将添加如下代码所示的表示自动播放切换的 Switch 组件:
mern-mediastream/client/media/PlayMedia.js
<FormControlLabel
control={
<Switch
checked={autoPlay}
onChange={handleChange}
color="primary"
/>
}
label={autoPlay ? 'Autoplay ON':'Autoplay OFF'}
/>
自动播放切换标签将根据状态中 autoPlay 的当前值进行渲染。为了处理用户与之交互时的切换变化,并在状态 autoPlay 值中反映这种变化,我们将使用以下 onChange 处理函数:
mern-mediastream/client/media/PlayMedia.js
const handleChange = (event) => {
setAutoPlay(event.target.checked)
}
这个autoPlay值表示用户是否选择了自动播放所有媒体,这将决定当前视频流结束时会发生什么。在下一节中,我们将讨论自动播放行为将如何根据用户设置的autoPlay切换值与PlayMedia中的子组件集成。
在组件间处理自动播放
当用户选择将自动播放切换设置为开启时,这里期望的功能是当视频结束时,如果autoPlay设置为true并且当前相关媒体列表不为空,PlayMedia应加载相关列表中第一个视频的媒体详情。
相应地,Media和MediaPlayer组件应该更新为新媒体详情,开始播放新视频,并适当地在播放器上渲染控件。RelatedMedia组件中的列表也应该更新,移除列表中的当前媒体,以便只显示剩余的播放列表项目。
为了处理PlayMedia组件及其子组件之间的自动播放行为,PlayMedia将一个handleAutoPlay方法作为属性传递给Media组件,以便在视频结束时由MediaPlayer组件使用。handleAutoPlay方法定义如下代码所示:
mern-mediastream/client/media/PlayMedia.js
const handleAutoplay = (updateMediaControls) => {
let playList = relatedMedia
let playMedia = playList[0]
if(!autoPlay || playList.length == 0 )
return updateMediaControls()
if(playList.length > 1){
playList.shift()
setMedia(playMedia)
setRelatedMedia(playList)
}else{
listRelated({
mediaId: playMedia._id}).then((data) => {
if (data.error) {
console.log(data.error)
} else {
setMedia(playMedia)
setRelatedMedia(data)
}
})
}
}
当MediaPlayer组件中的视频结束时,此handleAutoplay方法会处理以下情况:
-
它接收来自
MediaPlayer组件中onEnded事件监听器的回调函数。如果未设置自动播放或相关媒体列表为空,则此回调函数将被执行,以便在MediaPlayer上渲染控件以显示视频已结束。 -
如果设置了自动播放并且列表中有多个相关媒体,那么:
-
将相关媒体列表中的第一个项目设置为状态中的当前
media对象,以便可以渲染。 -
通过移除这个第一个项目来更新相关媒体列表,这个项目现在将在视图中开始播放。
-
-
如果设置了自动播放并且相关媒体列表中只有一个项目,则此最后一个项目被设置为
media以便开始播放,并且调用listRelated获取方法以重新填充RelatedMedia视图,包含此最后一个项目的相关媒体。
在handleAutoplay方法中完成这些步骤后,如果将自动播放设置为true,则可以在视频结束时相应地更新播放媒体页面的所有方面。在下一节中,我们将看到MediaPlayer组件如何在当前视频结束时利用此handleAutoplay方法,以便使自动播放功能生效。
在 MediaPlayer 中更新视频结束时状态
MediaPlayer组件从PlayMedia接收handleAutoplay方法作为属性。当当前视频在播放器中播放完毕时,将使用此方法。因此,我们将更新onEnded事件的监听器代码,仅在当前视频的loop设置为false时执行此方法。我们不希望播放下一个视频,如果用户决定循环当前视频。MediaPlayer中的onEnded方法将更新为以下代码块中显示的突出代码:
mern-mediastream/client/media/MediaPlayer.js
const onEnded = () => {
if(loop){
setPlaying(true)
} else{
props.handleAutoplay(()=>{
setValues({...values, ended: true})
setPlaying(false)
})
}
}
在此代码中,一个回调函数被传递给handleAutoplay方法,以便在PlayMedia确定自动播放未设置或相关媒体列表为空后,将playing值设置为false并渲染重播图标按钮,而不是播放或暂停图标按钮。
此实现将自动播放相关视频,一个接一个。这个实现演示了在组件之间值相互依赖时更新状态的另一种方式。
通过实现这个自动播放功能,我们拥有了一个完整的播放媒体页面,其中包括一个定制的媒体播放器和用户可以选择自动播放的媒体列表,就像播放列表一样。在下一节中,我们将通过在后端填充媒体数据来使用 SSR(服务器端渲染)使这个页面 SEO(搜索引擎优化)友好。
带数据的服务器端渲染
对于任何向用户交付内容并希望使内容易于查找的 Web 应用程序来说,SEO(搜索引擎优化)非常重要。通常,如果网页上的内容易于搜索引擎阅读,那么内容获得更多观众的机会会更大。当一个搜索引擎机器人访问一个 Web URL 时,它将获取 SSR 输出。因此,为了使内容可被发现,内容应该是 SSR 输出的一部分。
在 MERN Mediastream 中,我们将使用使媒体详情在搜索引擎结果中流行的案例,来演示如何在基于 MERN 的应用程序中将数据注入到 SSR 视图中。我们将专注于实现为在'/media/:mediaId'路径返回的PlayMedia组件注入数据的 SSR。这里概述的一般实现步骤可以用来实现其他视图的 SSR 和数据注入。
在接下来的几节中,我们将扩展在第四章中讨论的 SSR 实现,即添加 React 前端以完成 MERN。我们首先定义一个静态路由配置文件,并使用它来更新后端现有的 SSR 代码,以从数据库中注入必要的媒体数据。然后,我们将更新前端代码以在视图中渲染这些服务器注入的数据,最后检查这个 SSR 实现是否按预期工作。
添加路由配置文件
为了在服务器上渲染这些 React 视图时加载数据,我们需要在路由配置文件中列出前端路由。然后,这个文件可以与react-router-config模块一起使用,该模块为 React Router 提供静态路由配置助手。
我们将首先通过在命令行运行以下命令来安装该模块:
yarn add react-router-config
接下来,我们将创建一个路由配置文件,该文件将列出前端 React Router 路由。此配置将在服务器上用于将这些路由与传入的请求 URL 匹配,以检查在服务器返回针对此请求渲染的标记之前是否需要注入数据。
对于 MERN Mediastream 的路由配置,我们只列出渲染PlayMedia组件的路由,并演示如何使用从后端注入的数据来服务器端渲染特定组件。路由配置将定义如下:
mern-mediastream/client/routeConfig.js
import PlayMedia from './media/PlayMedia'
import { read } from './media/api-media.js'
const routes = [
{
path: '/media/:mediaId',
component: PlayMedia,
loadData: (params) => read(params)
}
]
export default routes
对于这个前端路由和PlayMedia组件,我们指定api-media.js中的read获取方法作为loadData方法。然后,可以使用它来检索并将数据注入到PlayMedia视图,当服务器生成此组件的标记后,在收到/media/:mediaId的请求时。在下文中,我们将使用此路由配置来更新后端现有的 SSR 代码。
更新 Express 服务器的 SSR 代码
我们将更新server/express.js中现有的基本 SSR 代码,以添加为将在服务器端渲染的 React 视图添加数据加载功能。在接下来的章节中,我们将首先了解如何使用路由配置来加载服务器渲染 React 组件时需要注入的数据。然后,我们将集成isomorphic-fetch,以便服务器能够使用来自前端相同的 API 获取代码进行read获取调用以检索必要的数据。最后,我们将将这些检索到的数据注入到服务器生成的标记中。
使用路由配置加载数据
当服务器收到任何请求时,我们将使用路由配置文件中定义的路由来查找匹配的路由。如果找到匹配项,我们将使用配置中为该路由声明的相应loadData方法来检索必要的数据,在将其注入到代表 React 前端的服务器端渲染的标记之前。我们将在名为loadBranchData的方法中执行这些路由匹配和数据加载操作,该方法定义如下:
mern-mediastream/server/express.js
import { matchRoutes } from 'react-router-config'
import routes from './../client/routeConfig'
const loadBranchData = (location) => {
const branch = matchRoutes(routes, location)
const promises = branch.map(({ route, match }) => {
return route.loadData
? route.loadData(branch[0].match.params)
: Promise.resolve(null)
})
return Promise.all(promises)
}
此方法使用matchRoutes从react-router-config,以及路由配置文件中定义的路由,来查找与传入请求 URL 匹配的路由,该 URL 作为location参数传递。如果找到匹配的路由,则将执行任何相关的loadData方法以返回包含获取数据的Promise,如果没有loadData方法,则返回null。这里定义的loadBranchData需要在服务器收到请求时调用,因此如果找到任何匹配的路由,我们可以在服务器端渲染时获取相关数据并将其注入到 React 组件中。在下文中,我们将确保前端代码中定义的 fetch 方法在服务器端也能正常工作,因此这些相同的方法也会从服务器端加载对应的数据。
Isomorphic-fetch
我们将通过使用isomorphic-fetch Node 模块来确保我们为客户端代码定义的任何 fetch 方法也可以在服务器上使用。我们首先通过从命令行运行以下命令来安装模块:
yarn add isomorphic-fetch
然后,我们只需在express.js中简单地导入isomorphic-fetch,如下所示,以确保 fetch 方法现在在客户端和服务器端都可以同构工作:
mern-mediastream/server/express.js
import 'isomorphic-fetch'
此isomorphic-fetch集成将确保read fetch 方法,或我们为客户端定义的任何其他 fetch 方法,现在也可以在服务器上使用。在集成成为功能之前,我们需要确保 fetch 方法使用绝对 URL,如下一节所述。
绝对 URL
使用isomorphic-fetch的一个问题是它目前需要 fetch URL 是绝对路径。因此,我们需要更新在api-media.js中定义的read fetch 方法中使用的 URL,将其更新为绝对路径。
在代码中而不是硬编码服务器地址,我们将在config.js中设置一个config变量,如下所示:
mern-mediastream/config/config.js
serverUrl: process.env.serverUrl || 'http://localhost:3000'
这将允许我们为开发环境和生产环境中的 API 路由定义和使用不同的绝对 URL。
然后,我们将更新api-media.js中的read方法,以确保它使用绝对 URL 调用服务器上的read API,如下所示:
mern-mediastream/client/media/api-media.js
import config from '../../config/config'
const read = (params) => {
return fetch(config.serverUrl +'/api/media/' + params.mediaId, {
method: 'GET'
}).then((response) => { ... })
这将使read fetch 调用与isomorphic-fetch兼容,因此可以在服务器端无问题地使用它来检索媒体数据,同时使用数据服务器端渲染PlayMedia组件。在下文中,我们将讨论如何将检索到的数据注入到表示已渲染 React 前端的服务器生成的标记中。
将数据注入到 React 应用中
在后端现有的 SSR 代码中,我们使用ReactDOMServer将 React 应用转换为标记。我们将更新express.js中的此代码,以将检索到的数据注入到MainRouter中,如下所示:
mern-mediastream/server/express.js
...
loadBranchData(req.url).then(data => {
const markup = ReactDOMServer.renderToString(
sheets.collect(
<StaticRouter location={req.url} context={context}>
<ThemeProvider theme={theme}>
<MainRouter data={data}/>
</ThemeProvider>
</StaticRouter>
)
)
...
}).catch(err => {
res.status(500).send({"error": "Could not load React view with data"})
})
...
我们使用loadBranchData方法检索请求视图的相关数据,然后将这些数据作为属性传递给MainRouter组件。为了在服务器生成标记时正确地将这些数据添加到渲染的PlayMedia组件中,我们需要更新客户端代码以考虑此服务器注入的数据,如下一节所述。
将服务器注入的数据应用于客户端代码
我们将更新前端中的 React 代码,以添加对可能从服务器注入的数据的考虑,如果视图正在服务器端渲染。对于这个 MERN Mediastream 应用程序,在客户端,我们将访问从服务器传递的媒体数据,并在服务器接收到直接请求以渲染此组件时将其添加到PlayMedia视图中。在接下来的章节中,我们将看到如何将MainRouter接收到的数据传递给PlayMedia组件,并相应地渲染它。
从 MainRouter 传递数据属性到 PlayMedia
在使用ReactDOMServer.renderToString生成标记时,我们将预加载数据作为属性传递给MainRouter。我们可以在MainRouter组件定义中访问此数据属性,如下所示:
mern-mediastream/client/MainRouter.js
const MainRouter = ({data}) => { ... }
为了让PlayMedia能够访问从MainRouter的这些数据,我们将更改最初添加的Route组件来声明PlayMedia的路由,并将此数据作为属性传递,如下所示:
mern-mediastream/client/MainRouter.js
<Route path="/media/:mediaId"
render={(props) => (
<PlayMedia {...props} data={data} />
)}
/>
发送到PlayMedia的数据属性需要在视图中渲染,如下一节所述。
在 PlayMedia 中渲染接收到的数据
在PlayMedia组件中,我们将检查从服务器传递的数据,并将值设置到状态中,以便在服务器生成相应的标记时,在视图中渲染媒体详情。我们将像以下代码所示进行此检查和分配:
mern-mediastream/client/media/PlayMedia.js
if (props.data && props.data[0] != null) {
media = props.data[0]
relatedMedia = []
}
如果从服务器接收到的媒体数据包含在 props 中,我们将它分配给状态中的media值。我们还将relatedMedia值设置为空数组,因为我们不打算在服务器生成的版本中渲染相关媒体列表。这种实现将在服务器直接接收到相应的前端路由请求时,在PlayMedia视图中注入媒体数据生成服务器生成的标记。在下一节中,我们将看到如何确保此实现实际上正在工作并且成功渲染了包含数据的服务器生成的标记。
检查使用数据的 SSR 实现
对于 MERN Mediastream,任何渲染 PlayMedia 的链接现在都应该在服务器端生成带有预加载媒体详情的标记。我们可以通过在浏览器中打开应用程序 URL 并关闭 JavaScript 来验证数据 SSR 实现是否正常工作。在接下来的章节中,我们将探讨如何在 Chrome 浏览器中实现此检查,以及最终视图应该向用户和搜索引擎展示什么内容。
Chrome 中的测试
在 Chrome 中测试此实现只需要更新 Chrome 设置并在标签页中加载应用程序,同时阻止 JavaScript。在接下来的章节中,我们将介绍检查 PlayMedia 视图是否仅使用服务器生成的标记来渲染数据的步骤。
启用 JavaScript 加载页面
首先,在 Chrome 中打开 MERN Mediastream 应用程序,然后浏览到任何媒体链接,并允许它在启用 JavaScript 的情况下正常渲染。这应该显示具有功能媒体播放器和相关媒体列表的已实现的 PlayMedia 视图。在执行下一步以禁用 Chrome 中的 JavaScript 之前,请保持此标签页打开。
从设置中禁用 JavaScript
要测试服务器生成的标记在视图中的渲染方式,我们需要在 Chrome 中禁用 JavaScript。为此,您可以前往 chrome://settings/content/javascript 的高级设置,并使用切换按钮来阻止 JavaScript,如图所示:

现在,在 MERN Mediastream 标签页中刷新媒体链接,地址旁边应该有一个图标,如图所示,表示 JavaScript 确实已被禁用:

在此阶段将在浏览器中显示的视图将仅渲染从后端接收到的服务器生成的标记。在下一节中,我们将讨论当浏览器中阻止 JavaScript 时预期的视图是什么。
阻止 JavaScript 的 PlayMedia 视图
当浏览器中阻止 JavaScript 时,PlayMedia 视图应仅渲染填充了媒体详情。但由于 JavaScript 被阻止,用户界面不再交互式,只有默认浏览器控件是可操作的,如图所示:

这是搜索引擎机器人将读取的媒体内容标记,也是当浏览器中没有加载 JavaScript 时用户将看到的内容。如果此数据 SSR 实现未添加到应用程序中,那么在此场景中此视图将不会渲染与相关媒体详情关联的视图,因此媒体信息将不会被搜索引擎读取和索引。
MERN Mediastream 现在拥有完全可操作的媒体播放工具,将使用户能够轻松浏览和播放视频。此外,由于 SSR 预加载数据,显示单个媒体内容项的媒体视图现在已针对搜索引擎优化。
摘要
在本章中,我们对 MERN Mediastream 的播放媒体页面进行了全面升级。我们首先添加了自定义媒体播放器控件,利用了ReactPlayer组件中可用的选项。然后,在从数据库中检索相关媒体后,我们为相关媒体播放列表集成了自动播放功能。最后,通过在服务器上渲染视图时从服务器注入数据,我们使媒体详细信息可由搜索引擎读取。
您可以将本章中探索的技术应用于构建播放媒体页面,使用相互依赖的 React 组件构建和组合您自己的复杂用户界面,以及为需要 SEO 友好的视图添加 SSR(服务器端渲染)和数据。
我们现在已经探索了 MERN 堆栈技术的先进功能,如流式传输和 SEO。在接下来的两个章节中,我们将通过将虚拟现实(VR)元素集成到使用 React 360 的全栈 Web 应用程序中,进一步测试这个堆栈的潜力。
第十七章:开发基于 Web 的 VR 游戏
虚拟现实(VR)和增强现实(AR)技术的出现正在改变用户与软件以及他们周围世界的互动方式。VR 和 AR 的可能应用数不胜数,尽管游戏行业是早期采用者,但这些快速发展的技术有潜力在多个学科和行业中改变范式。
为了展示 MERN 堆栈与 React 360 相结合如何轻松地为任何 Web 应用程序添加 VR 功能,我们将在本章和下一章中讨论和开发一个动态的、基于 Web 的 VR 游戏。在本章中,我们将专注于定义 VR 游戏的特点。此外,在开发使用 React 360 的游戏视图之前,我们将回顾与实现此 VR 游戏相关的关键 3D VR 概念。
在本章中,我们将通过以下主题构建 VR 游戏,使用 React 360:
-
介绍 MERN VR 游戏
-
开始使用 React 360
-
开发 3D VR 应用程序的关键概念
-
定义游戏详情
-
在 React 360 中构建游戏视图
-
将 React 360 代码打包以集成到 MERN 框架中
在了解这些主题之后,您将能够应用 3D VR 概念并使用 React 360 开始构建自己的基于 VR 的应用程序。
介绍 MERN VR 游戏
MERN VR 游戏 Web 应用程序将通过扩展 MERN 框架并使用 React 360 集成 VR 功能来开发。它将是一个动态的、基于 Web 的 VR 游戏应用程序,其中注册用户可以创建自己的游戏,任何访问该应用程序的访客都可以玩这些游戏。该应用程序的主页将列出平台上的游戏,如下面的截图所示:

使用 React 360 实现 VR 游戏功能的代码可在 GitHub 上找到:github.com/PacktPublishing/Full-Stack-React-Projects-Second-Edition/tree/master/Chapter13/MERNVR。您可以在阅读本章剩余部分的代码解释时克隆此代码并运行应用程序。
游戏的特点将足够简单,足以展示将 VR 引入基于 MERN 的应用程序的能力,而不会深入探讨可能用于实现更复杂 VR 功能的 React 360 的高级概念。在下一节中,我们将简要定义该应用程序中游戏的特点。
游戏特点
MERN VR 游戏应用程序中的每个游戏本质上都是一个不同的 VR 世界,用户可以与放置在 360 度全景世界中不同位置的 3D 对象进行交互。
游戏玩法将与寻宝游戏相似,为了完成每个游戏,用户必须找到并收集与每个游戏提示或描述相关的 3D 对象。这意味着游戏世界将包含一些可以被玩家收集的 VR 对象,以及一些不能被收集但可能被游戏制作者作为道具或提示放置的 VR 对象。最后,当所有相关的 3D 对象都被用户收集后,游戏即告胜利。
在本章中,我们将使用 React 360 构建这些游戏功能,主要关注与实现这里定义的功能相关的 VR 和 React 360 概念。一旦游戏功能准备就绪,我们将讨论如何将 React 360 代码打包并准备与第十四章(17bbfed7-9867-4c8b-99fd-42581044a906.xhtml)中开发的 MERN 应用程序代码集成。在深入实现游戏功能之前,我们首先将在下一节中查看设置和开始使用 React 360。
开始使用 React 360
React 360 使得使用与 React 中相同的声明式和组件化方法来构建 VR 体验成为可能。React 360 的底层技术利用了 Three.js JavaScript 3D 引擎,在任意兼容的 Web 浏览器中使用 WebGL 渲染 3D 图形,并且通过 Web VR API 提供了对 VR 头显的访问。
虽然 React 360 是基于 React 构建的,并且应用在浏览器中运行,但 React 360 与 React Native 有很多共同之处,这使得 React 360 应用成为跨平台应用。这也意味着 React Native 的一些概念也适用于 React 360。涵盖所有 React 360 概念超出了本书的范围;因此,我们将专注于构建游戏和与 MERN 栈 Web 应用程序集成的所需概念。在下一节中,我们将首先设置一个 React 360 项目,然后在章节的后续部分扩展以构建游戏功能。
设置 React 360 项目
React 360 提供了开发者工具,使得开始开发新的 React 360 项目变得简单。启动步骤在官方 React 360 文档中有详细说明,因此我们在这里只总结步骤,并指出与游戏开发相关的文件。
由于我们已为 MERN 应用程序安装了 Node 和 Yarn,我们可以通过在命令行中运行以下命令来开始安装 React 360 CLI 工具:
yarn global add react-360-cli
然后,使用这个 React 360 CLI 工具创建一个新的应用程序,并从命令行运行以下命令来安装所需的依赖项:
react-360 init MERNVR
这将在当前目录中创建一个名为 MERNVR 的文件夹,并将所有必要的文件添加到该文件夹中。最后,我们可以在命令行中进入这个文件夹,并使用以下命令运行应用程序:
yarn start
此start命令将初始化本地开发服务器,默认的 React 360 应用程序可以在浏览器中的http://localhost:8081/index.html查看。
要更新这个启动应用程序并实现我们的游戏功能,我们将主要修改index.js文件中的代码,并在client.js文件中进行一些小的更新,这些文件可以在MERNVR项目文件夹中找到。
由 React 360 生成的启动应用程序的默认index.js代码如下。请注意,它在一个 360 度的世界中渲染了“欢迎使用 React 360”文本:
import React from 'react'
import { AppRegistry, StyleSheet, Text, View } from 'react-360'
export default class MERNVR extends React.Component {
render() {
return (
<View style={styles.panel}>
<View style={styles.greetingBox}>
<Text style={styles.greeting}>
Welcome to React 360
</Text>
</View>
</View>
)
}
}
const styles = StyleSheet.create({
panel: {
// Fill the entire surface
width: 1000,
height: 600,
backgroundColor: 'rgba(255, 255, 255, 0.4)',
justifyContent: 'center',
alignItems: 'center',
},
greetingBox: {
padding: 20,
backgroundColor: '#000000',
borderColor: '#639dda',
borderWidth: 2,
},
greeting: {
fontSize: 30,
}
})
AppRegistry.registerComponent('MERNVR', () => MERNVR)
这个index.js文件包含了应用程序的内容和主要代码,包括视图和样式代码。client.js中的代码包含了将浏览器连接到index.js中 React 应用程序的样板代码。启动项目文件夹中的默认client.js文件应该看起来像这样:
import {ReactInstance} from 'react-360-web'
function init(bundle, parent, options = {}) {
const r360 = new ReactInstance(bundle, parent, {
// Add custom options here
fullScreen: true,
...options,
})
// Render your app content to the default cylinder surface
r360.renderToSurface(
r360.createRoot('MERNVR', { /* initial props */ }),
r360.getDefaultSurface()
)
// Load the initial environment
r360.compositor.setBackground(r360.getAssetURL('360_world.jpg'))
}
window.React360 = {init}
此代码执行index.js中定义的 React 代码,本质上创建了一个新的 React 360 实例,并通过将其附加到 DOM 来加载 React 代码。
这样,默认的 React 360 启动项目就设置好了,并准备好扩展。在修改此代码以实现游戏之前,在下一节中,我们将首先查看一些与开发 3D VR 体验相关的关键概念,以及这些概念如何与 React 360 结合应用。
开发 VR 游戏的关键概念
在创建游戏中的 VR 内容和交互式 360 度体验之前,我们将突出显示虚拟世界的相关方面,以及如何使用 React 360 与这些 VR 概念协同工作。鉴于 VR 空间中的广泛可能性以及 React 360 提供的各种选项,我们需要确定并探索特定的概念,这些概念将使我们能够实现为游戏定义的交互式 VR 功能。在接下来的章节中,我们将讨论构成游戏 360 度世界的图像,3D 定位系统,以及将用于实现游戏的 React 360 组件、API 和输入事件。
等经纬全景图像
游戏的 VR 世界将由一个全景图像组成,该图像作为背景图像添加到 React 360 环境中。
全景图像通常是 360 度图像或球形全景,这些图像被投影到一个完全围绕观众的球体上。360 度全景图像的一种常见且流行的格式是等经纬格式。React 360 目前支持等经纬图像的单色和立体格式。
要了解更多关于 React 360 中 360 度图像和视频支持的信息,请参阅 React 360 文档facebook.github.io/react-360/docs/setup.html。
这里展示的照片是一个等经纬,360 度全景图像的例子。在 MERN VR 游戏中设置世界背景时,我们将使用这种图像:

等经线全景图像由一个宽高比为 2:1 的单一图像组成,其中宽度是高度的两倍。这些图像使用特殊的 360 度相机创建。Flickr 是等经线图像的一个优秀来源;你只需搜索 equirectangular 标签。
通过在 React 360 环境中使用等经线图像设置背景场景来创建游戏世界,将使 VR 体验更加沉浸式,并将用户带到虚拟位置。为了有效地在这个 VR 世界中添加 3D 对象并增强这种体验,我们需要了解与 3D 空间相关的布局和坐标系,这将在下文中讨论。
3D 位置 - 坐标和变换
为了在 VR 世界空间中放置 3D 对象并使 VR 体验更加真实,我们需要了解定位和方向。在接下来的章节中,我们将回顾 3D 坐标系,以帮助我们确定虚拟对象在 3D 空间中的位置,以及 React 360 中的变换功能,这将允许我们按要求定位、定向和缩放对象。
3D 坐标系
对于 3D 空间的映射,React 360 使用一个类似于 OpenGL® 3D 坐标系的基于米的三维坐标系统。这允许单个组件相对于其父组件的布局在 3D 中进行变换、移动或旋转。
React 360 中使用的 3D 坐标系是一个右手坐标系。这意味着正 x 轴在右侧,正 y 轴向上,正 z 轴向后。这提供了与世界空间中资产和 3D 世界建模的常见坐标系统更好的映射。
如果我们尝试可视化 3D 空间,用户将开始于以下图中 x-y-z 轴的中心:

z 轴指向用户前方,用户朝向 -z 轴方向望去。y 轴垂直上下,而 x 轴则从一侧到另一侧。图中弯曲的箭头显示了正旋转值的方向。
在决定在 360 度世界中放置 3D 对象的位置和方式时,我们必须根据这个 3D 坐标系设置值。在下一节中,我们将通过设置变换属性来演示如何使用 React 360 放置 3D 对象。
转换 3D 对象
3D 对象的位置和方向将由其变换属性确定,这些属性将具有与 3D 坐标系相对应的值。在以下屏幕截图中,通过更改渲染 3D 对象的 React 360 Entity 组件样式属性中的 transform 属性,将相同的 3D 书籍对象放置在两个不同的位置和方向:

此变换功能基于 React 中使用的变换样式,React 360 将其扩展为完全 3D,考虑到x-y-z轴。transform属性以键值对数组的形式添加到Entity组件的style属性中:
style={{ ...
transform: [
{TRANSFORM_COMMAND: TRANSFORM_VALUE},
...
]
... }}
与我们要放置在游戏中的 3D 对象相关的变换命令和值是translate [x, y, z],单位为米;rotate [x, y, z],单位为度;以及scale,用于确定对象在所有轴上的大小。我们还可以利用matrix命令,它接受一个包含 16 个数字的数组,代表平移、旋转和缩放值。
要了解更多关于 React 360 3D 坐标和变换的信息,请参阅 React 360 文档,网址为facebook.github.io/react-360/docs/setup.html。
我们将利用这些变换属性来根据 3D 坐标系定位和定向 3D 对象,同时在用 React 360 构建的游戏世界中放置对象。在下一节中,我们将介绍 React 360 组件,这些组件将允许我们构建游戏世界。
React 360 组件
React 360 提供了一系列可以直接用于创建游戏 VR 用户界面的组件。这个范围包括从 React Native 可用的基本组件,以及允许你在 VR 游戏中集成交互式 3D 对象的 VR 特定组件。在接下来的几节中,我们将总结用于构建游戏视图和功能的特定组件,包括核心组件,如View和Text,以及 VR 特定组件,如Entity和VrButton。
核心组件
React 360 的核心组件包括 React Native 的两个内置组件——Text和View组件。在游戏中,我们将使用这两个组件向游戏世界添加内容。在接下来的几节中,我们将讨论这两个核心组件。
View
View组件是构建 React Native 用户界面的最基本组件,它直接映射到 React Native 在运行的平台上的原生视图等效物。在我们的案例中,由于应用程序将在浏览器中渲染,它将映射到浏览器的<div>元素。View组件可以添加如下:
<View>
<Text>Hello</Text>
</View>
View组件通常用作其他组件的容器;它可以嵌套在其他视图中,并且可以有零到多个任何类型的子组件。
我们将使用View组件来包含游戏世界视图,并添加 3D 对象实体和文本到游戏中。接下来,我们将查看Text组件,它将允许我们在视图中添加文本。
文本
Text组件是一个用于显示文本的 React Native 组件,我们将通过将Text组件放置在View组件内部来在 3D 空间中渲染字符串,如下面的代码所示:
<View>
<Text>Welcome to the MERN VR Game</Text>
</View>
我们将使用这两个 React Native 组件以及其他 React 360 组件来组合游戏世界,并将 VR 功能集成到游戏中。在下一节中,我们将介绍 React 360 组件,这些组件将允许我们在游戏世界中添加交互式 VR 对象。
3D VR 体验组件
React 360 提供了一套自己的组件来创建 VR 体验。具体来说,我们将使用Entity组件添加 3D 对象,并使用VrButton组件来捕获用户的点击。我们将在以下章节中讨论Entity和VrButton组件。
实体
为了将 3D 对象添加到游戏世界,我们将使用Entity组件,它允许我们在 React 360 中渲染 3D 对象。以下是在视图中添加Entity组件的方法:
<Entity
source={{
obj: {uri: "http://linktoOBJfile.obj "},
mtl: {uri: "http://linktoMTLfile.obj "}
}}
/>
包含特定 3D 对象信息的文件通过source属性添加到Entity组件中。source属性接受一个键值对对象,将资源文件类型映射到其位置。React 360 支持 Wavefront OBJ 文件格式,这是 3D 模型的常见表示。因此,在source属性中,Entity组件支持以下键:
-
obj:OBJ 格式模型的存储位置 -
mtl:MTL 格式材料(OBJ 的配套文件)的位置
obj和mtl属性的值指向这些文件的位置,可以是静态字符串、asset()调用、require()语句或 URI 字符串。
OBJ(或 .OBJ)是一种几何定义文件格式,最初由 Wavefront Technologies 开发。它是一种简单的数据格式,将 3D 几何形状表示为顶点和纹理顶点的列表。OBJ 坐标没有单位,但 OBJ 文件可以在可读的注释行中包含缩放信息。您可以在paulbourke.net/dataformats/obj/了解更多关于此格式的信息。
MTL(或 .MTL)是包含一个或多个材料定义的材料库文件,每个定义都包括单个材料的颜色、纹理和反射图。这些应用于对象的表面和顶点。您可以在paulbourke.net/dataformats/mtl/了解更多关于此格式的信息。
Entity组件还接受style属性中的transform属性值,因此可以将对象放置在 3D 世界空间中期望的位置和方向。
在我们的 MERN VR 游戏应用中,制作者将为每个游戏中的Entity对象添加指向 VR 对象文件(.obj和.mtl)的 URL,并指定transform属性值,以指示 3D 对象在游戏世界中的放置位置和方式。
一个好的 3D 对象来源是clara.io/,提供多种文件格式可供下载和使用。
Entity 组件将在 3D 世界空间中渲染 3D 对象。为了使这些对象具有交互性,我们需要使用 VrButton 组件,这在下一节中将会讨论。
VrButton
React 360 中的 VrButton 组件将帮助我们为要添加到游戏中的对象和 Text 按钮实现简单的、按钮风格的 onClick 行为。默认情况下,VrButton 组件在视图中是不可见的,它仅作为事件捕获的包装器,但可以像 View 组件一样进行样式化,如下面的代码所示:
<VrButton onClick={this.clickHandler}>
<View>
<Text>Click me to make something happen!</Text>
</View>
</VrButton>
此组件是管理用户在不同输入设备上进行的点击类型交互的辅助工具。将触发点击事件的输入事件包括键盘上的空格键按下、鼠标的左键点击以及屏幕上的触摸。
React 360 的 Entity 和 VrButton 组件将使我们能够在游戏世界中渲染交互式 3D 对象。为了在游戏世界中集成其他 VR 功能,如设置背景场景和播放音频,我们将在下一节中探索 React 360 API 中的相关选项。
React 360 API
除了上一节中讨论的 React 360 组件外,我们还将利用 React 360 提供的 API 实现设置背景场景、播放音频、处理外部链接、添加样式、捕获用户当前视图方向以及使用静态资产文件等功能。在接下来的章节中,我们将探讨 Environment API、Audio 和 Location 原生模块、StyleSheet API、VrHeadModel 模块以及资产指定选项。
环境
在游戏中,我们将使用等角全景图像设置世界或背景场景。我们将使用 React 360 的 Environment API,通过其 setBackgroundImage 方法,在 React 代码中动态地更改此背景场景。此方法可以使用如下方式:
Environment.setBackgroundImage( {uri: 'http://linktopanoramaimage.jpg' } )
此方法使用指定 URL 的资源设置当前背景图像。当我们将 React 360 游戏代码与包含游戏应用后端的 MERN 栈集成时,我们可以使用此方法通过用户提供的图像链接动态设置游戏世界图像。在下一节中,我们将探讨允许我们在浏览器中播放此渲染场景中的音频以及提供浏览器位置访问权限的原生模块。
原生模块
React 360 中的原生模块为我们提供了访问仅在主浏览器环境中可用的功能的能力。在游戏中,我们将使用 NativeModules 中的 AudioModule 来响应用户活动播放声音,以及 Location 模块,以在浏览器中访问 window.location 来处理外部链接。这些模块可以在 index.js 中按如下方式访问:
import {
...
NativeModules
} from 'react-360'
const { AudioModule, Location } = NativeModules
我们可以在代码中使用这些导入的模块来操作浏览器中的音频和位置 URL。在接下来的章节中,我们将探讨这些模块如何被用来实现游戏的功能。
音频模块
当用户与游戏中的 3D 对象交互时,我们将根据对象是否可收集以及游戏是否已完成来播放声音。NativeModules中的AudioModule允许我们在 VR 世界中添加声音,作为背景环境音频、一次性音效和空间音频。在我们的游戏中,我们将使用环境音频和一次性音效:
- 环境音频:为了在游戏成功完成后循环播放音频并设置氛围,我们将使用
playEnvironmental方法,该方法将音频文件路径作为source属性,并将loop选项作为playback参数,如下面的代码所示:
AudioModule.playEnvironmental({
source: asset('happy-bot.mp3'),
loop: true
})
- 音效:当用户点击 3D 对象时,我们将使用
playOneShot方法播放单个声音,该方法将音频文件路径作为source属性,如下面的代码所示:
AudioModule.playOneShot({
source: asset('clog-up.mp3'),
})
传递给playEnvironmental和playOneShot的选项中的source属性接受一个资源文件位置来加载音频。它可以是asset()语句或形式为{uri: 'PATH'}的资源 URL 声明。
我们将在游戏实现代码中调用这些AudioModule方法,根据需要播放指定的音频文件。在下一节中,我们将探讨如何使用Location模块,这是 React 360 中的另一个原生模块。
位置
在我们将包含游戏的 React 360 代码与包含游戏应用程序后端的 MERN 堆栈集成后,VR 游戏将从声明路由中的 MERN 服务器启动,该路由包含特定游戏的 ID。然后,一旦用户完成游戏,他们也将有选择离开 VR 空间并前往包含其他游戏列表的 URL。为了在 React 360 代码中处理这些传入和传出的应用链接,我们将利用NativeModules中的Location模块。
Location模块实际上是浏览器中只读window.location属性返回的Location对象。我们将使用Location对象中的replace方法和search属性来实现与外部链接相关的功能。我们将如下处理传入和传出链接:
- 处理传出链接:当我们想要将用户从 VR 应用程序导向另一个链接时,我们可以使用
Location中的replace方法,如下面的代码所示:
Location.replace(url)
- 处理传入链接:当 React 360 应用从外部 URL 启动并在已注册的组件挂载后,我们可以通过
Location中的search属性访问 URL 并检索其查询字符串部分,如下面的代码所示。
componentDidMount = () => {
let queryString = Location.search
let gameId = queryString.split('?id=')[1]
}
为了将此 React 360 组件与 MERN VR 游戏(MERN VR Game)集成,并动态加载游戏详情,我们将捕获此初始 URL,从查询参数中解析游戏 ID,然后使用它向 MERN 应用程序服务器发起读取 API 调用。这种实现方式在 第十一章,使用 MERN 使 VR 游戏动态化 中有进一步的阐述。
除了使用 React 360 API 中的这些原生模块外,我们还将使用 StyleSheet API 为渲染的游戏视图添加样式。我们将在下一节中演示如何使用 StyleSheet API。
StyleSheet
React Native 的 StyleSheet API 也可以在 React 360 中使用,以便在单个位置定义多个样式,而不是向单个组件添加样式。样式可以使用 StyleSheet 定义,如下面的代码所示:
const styles = StyleSheet.create({
subView: {
width: 10,
borderColor: '#d6d7da',
},
text: {
fontSize: '1em',
fontWeight: 'bold',
}
})
使用 StyleSheet.create 定义的这些样式对象可以根据需要添加到组件中,如下面的代码所示:
<View style={styles.subView}>
<Text style={styles.text}>hello</Text>
</View>
这将根据需要将 CSS 样式应用于 View 和 Text 组件。
在 React 360 中将 CSS 属性(如宽度、高度)映射到 3D 空间时,默认的距离单位是米,而 React Native 中的 2D 接口的默认距离单位是像素。
我们将使用 StyleSheet 以这种方式为将构成游戏视图的组件定义样式。在下一节中,我们将讨论 React 360 中的 VrHeadModel 模块,它将使我们能够确定用户当前正在看哪里。
VrHeadModel
VrHeadModel 是 React 360 中的一个实用模块,它简化了获取头戴式设备当前方向的过程。由于用户在 VR 空间中移动,当需要将对象或文本放置在用户当前方向之前或相对于用户当前方向时,了解用户当前注视的确切位置变得至关重要。
在 MERN VR 游戏(MERN VR Game)中,我们将使用此功能向用户展示游戏完成的消息,无论他们从初始位置转向何处。例如,当用户在收集最终对象时可能向上或向下看,完成消息应出现在用户注视的任何位置。
为了实现这一点,我们将使用 VrHeadModel 中的 getHeadMatrix() 从数组中检索当前头矩阵,并将其设置为包含游戏完成消息的 View 组件样式属性中的 transform 属性的值。
这将在用户当前注视的位置渲染消息。我们将在本章的 在 React 360 中构建游戏视图 部分中看到 getHeadMatrix() 函数的使用。在下一节中,我们将讨论如何在 React 360 中加载静态资源。
加载资源
为了在代码中加载任何静态资产文件,例如图像或音频文件,我们可以利用 React 360 中的 asset 方法。React 360 中的 asset() 功能使我们能够检索外部资源文件,包括音频和图像文件。
例如,我们将游戏的声音音频文件放置在 static_assets 文件夹中,使用 asset() 来检索添加到游戏中的每个音频,如下面的代码所示:
AudioModule.playOneShot({
source: asset('collect.mp3'),
})
这将在调用 playOneShot 时加载要播放的音频文件。
在 React 360 中,我们有这些不同的 API 和模块可用,我们将集成不同的功能以用于 VR 游戏,例如设置背景场景、播放音频、添加样式、加载静态文件和检索用户方向。在下一节中,我们将查看 React 360 中的一些可用输入事件,这将使我们能够使游戏具有交互性。
React 360 输入事件
为了使游戏界面具有交互性,我们将利用 React 360 中公开的一些输入事件处理器。输入事件来自鼠标、键盘、触摸和游戏手柄交互,以及 VR 头盔上的 gaze 按钮点击。
我们将工作的特定输入事件是 onEnter、onExit 和 onClick 事件,如下表所述:
-
onEnter:每当平台光标开始与组件相交时,都会触发此事件。我们将捕获这个事件用于游戏中的 VR 对象,以便当平台光标进入特定对象时,对象可以开始围绕 y 轴旋转。 -
onExit:每当平台光标停止与组件相交时,都会触发此事件。它具有与onEnter事件相同的属性,我们将使用它来停止旋转刚刚退出的 VR 对象。 -
onClick:onClick事件与VrButton组件一起使用,当与VrButton进行点击交互时触发。我们将使用它来设置 VR 对象上的点击事件处理器,以及游戏完成消息,以便将用户从 VR 应用程序重定向到包含游戏列表的链接。
这些事件将允许我们在游戏中添加动作,即当用户进行某些操作时发生的动作。
在实现 VR 游戏时,我们将应用 3D 世界概念来确定如何使用等距圆盘全景图像设置游戏世界,并根据 3D 坐标系在这个世界中定位 VR 物体。我们将使用 React 360 组件,如 View、Text、Entity 和 VrButton 来渲染 VR 游戏视图。我们还可以使用可用的 React 360 API 在浏览器环境中加载音频和外部 URL,用于 VR 游戏。最后,我们可以利用可用的 React 360 事件来捕获用户交互,使 VR 游戏具有交互性。在本节中,我们讨论了与 VR 相关的概念、React 360 组件、API、模块和事件,我们准备在开始使用这些概念实现完整的 VR 游戏之前定义具体的游戏数据详情。在下一节中,我们将介绍游戏数据结构和详情。
定义游戏详情
在 MERN VR 游戏中,每一款游戏都将定义在一个通用的数据结构中,React 360 应用在渲染单个游戏详情时也将遵循此数据结构。在接下来的章节中,我们将讨论捕获游戏详情的数据结构,并突出使用静态游戏数据和动态加载游戏数据之间的区别。
游戏数据结构
游戏数据将包括游戏名称、指向游戏世界等距圆盘图像位置的 URL,以及包含要添加到游戏世界中的每个 VR 物体详情的两个数组。以下列表指出了对应游戏数据属性的字段:
-
name: 一个表示游戏名称的字符串 -
world: 一个字符串,包含指向等距圆盘图像的 URL,这些图像可以托管在云存储、CDNs 上,或者存储在 MongoDB 中 -
answerObjects: 一个包含玩家可收集的 VR 物体详情的 JavaScript 对象数组 -
wrongObjects: 一个包含其他 VR 物体详情的 JavaScript 对象数组,这些物体将被放置在 VR 世界中,但玩家无法收集
这些详情将定义 MERN VR 游戏应用程序中的每个游戏。包含 VR 物体详情的数组将存储要添加到游戏 3D 世界中的每个对象的属性。在以下章节中,我们将介绍表示这些数组中 VR 物体的详情。
VR 物体的详情
游戏数据结构中的两个数组将存储要添加到游戏世界中的 VR 物体的详情。answerObjects 数组将包含可收集的 3D 物体的详情,而 wrongObjects 数组将包含无法收集的 3D 物体的详情。每个对象将包含指向 3D 数据资源文件的链接和 transform 样式属性值。在以下列表中,我们将介绍每个对象需要存储的这些具体详情:
-
OBJ 和 MTL 链接: VR 物体的 3D 数据信息资源将被添加到
objUrl和mtlUrl属性中。这些属性将包含以下值:-
objUrl: 3D 对象的.obj文件的链接 -
mtlUrl: 到配套.mtl文件的链接
-
objUrl和mtlUrl链接可能指向存储在云存储、CDNs 或 MongoDB 上的文件。对于 MERN VR 游戏,我们将假设制作者将添加他们自己托管 OBJ、MTL 和等角图像文件的 URL。
-
Translation values: VR 对象在 3D 空间中的位置将通过以下属性中的
translate值来定义:-
translateX: 沿着x轴的对象平移值 -
translateY: 沿着y轴的对象平移值 -
translateZ: 沿着z轴的对象平移值
-
所有平移值都是米为单位的数字。
-
Rotation values: 3D 对象的方向将通过以下键中的
rotate值来定义:-
rotateX: 围绕x轴旋转对象的值;换句话说,向上或向下转动对象 -
rotateY: 围绕y轴旋转对象的值,这将使对象向左或向右转动 -
rotateZ: 围绕z轴旋转对象的值,使对象向前或向后倾斜
-
所有旋转值都是数字或数字的字符串表示形式。
-
Scale value:
scale值将定义 3D 对象在 3D 环境中的相对大小和外观:scale: 一个数值,用于定义所有轴上的统一缩放比例
-
颜色: 如果 3D 对象的材质纹理在 MTL 文件中没有提供,可以在
color属性中定义一个颜色值来设置对象的默认颜色:color: 表示 CSS 中允许的颜色值的字符串
这些属性将定义要添加到游戏中的每个 VR 对象的详细信息。
使用这种能够存储游戏及其 VR 对象详细信息的游戏数据结构,我们可以根据示例数据值相应地在 React 360 中实现游戏。在下一节中,我们将查看示例游戏数据,并区分静态设置游戏数据与为不同游戏动态加载数据。
静态数据与动态数据
在下一章中,当将使用 React 360 开发的游戏与基于 MERN 的应用程序集成时,我们将更新 React 360 代码以从后端数据库动态获取游戏数据。这将渲染存储在数据库中的不同游戏的 React 360 游戏视图。目前,我们将在这里使用设置在组件状态中的虚拟游戏数据来开发游戏功能。示例游戏数据将按以下方式设置,使用定义的游戏数据结构:
game: {
name: 'Space Exploration',
world: 'https://s3.amazonaws.com/mernbook/vrGame/milkyway.jpg',
answerObjects: [
{
objUrl: 'https://s3.amazonaws.com/mernbook/vrGame/planet.obj',
mtlUrl: 'https://s3.amazonaws.com/mernbook/vrGame/planet.mtl',
translateX: -50,
translateY: 0,
translateZ: 30,
rotateX: 0,
rotateY: 0,
rotateZ: 0,
scale: 7,
color: 'white'
}
],
wrongObjects: [
{
objUrl: 'https://s3.amazonaws.com/mernbook/vrGame/tardis.obj',
mtlUrl: 'https://s3.amazonaws.com/mernbook/vrGame/tardis.mtl',
translateX: 0,
translateY: 0,
translateZ: 90,
rotateX: 0,
rotateY: 20,
rotateZ: 0,
scale: 1,
color: 'white'
}
]
}
此游戏对象包含一个示例游戏的详细信息,包括名称、到 360 世界图像的链接以及包含每个数组中一个详细 3D 对象的两个对象数组。出于初始开发目的,此示例游戏数据可以设置在状态中,以便在游戏视图中渲染。使用此游戏结构和数据,在下一节中,我们将实现 React 360 中的游戏功能。
在 React 360 中构建游戏视图
我们将应用 React 360 的概念,并使用游戏数据结构来实现 MERN VR 游戏应用中每个游戏的特性。对于这些实现,我们将更新在初始化的 React 360 项目中生成的index.js和client.js文件中的默认启动代码。
对于一个可工作的游戏版本,我们将从使用上一节中的示例游戏数据初始化的MERNVR组件的状态开始。
MERNVR组件在index.js中定义,代码将使用上一节中的示例游戏数据初始化的状态进行更新,如下面的代码所示:
/MERNVR/index.js
export default class MERNVR extends React.Component {
constructor() {
super()
this.state = {
game: sampleGameData
...
}
}
...
}
这将使示例游戏的详细信息可用于构建其余的游戏功能。在接下来的几节中,我们将更新index.js和client.js文件中的代码,首先挂载游戏世界,定义 CSS 样式,并为游戏加载 360 度环境。然后,我们将向游戏中添加 3D VR 对象,使这些对象具有交互性,并实现表示游戏完成的操作。
更新 client.js 并将其挂载到位置
client.js中的默认代码将挂载在index.js中声明的挂载点到 React 360 应用中的默认Surface上,其中Surface是一个用于放置 2D 用户界面的圆柱层。为了在 3D 空间中进行布局,我们需要挂载到一个Location对象上而不是Surface。因此,我们将更新client.js以将renderToSurface替换为renderToLocation,如下面的代码所示:
/MERNVR/client.js
r360.renderToLocation(
r360.createRoot('MERNVR', { /* initial props */ }),
r360.getDefaultLocation()
)
这将使我们的游戏视图挂载到 React 360 的Location上。
您还可以通过更新client.js中的r360.compositor.setBackground(**r360.getAssetURL('360_world.jpg')**)代码来自定义初始背景场景,以使用您希望使用的图像。
在client.js中添加此更新后,我们可以继续更新index.js中的代码,其中将包含我们的游戏功能。在下一节中,我们将首先定义游戏视图中要渲染的元素的 CSS 样式。
使用 StyleSheet 定义样式
在index.js中,我们将更新初始 React 360 项目中生成的默认样式,以添加我们自己的 CSS 规则。在StyleSheet.create调用中,我们将定义用于游戏组件的样式对象,如下面的代码所示:
/MERNVR/index.js
const styles = StyleSheet.create({
completeMessage: {
margin: 0.1,
height: 1.5,
backgroundColor: 'green',
transform: [ {translate: [0, 0, -5] } ]
},
congratsText: {
fontSize: 0.5,
textAlign: 'center',
marginTop: 0.2
},
collectedText: {
fontSize: 0.2,
textAlign: 'center'
},
button: {
margin: 0.1,
height: 0.5,
backgroundColor: 'blue',
transform: [ { translate: [0, 0, -5] } ]
},
buttonText: {
fontSize: 0.3,
textAlign: 'center'
}
})
对于本书中实现的游戏功能,我们使用 CSS 仅对显示在游戏完成时的文本和按钮进行简单样式化。在下一节中,我们将探讨如何加载代表每个游戏 3D 世界的 360 度全景图像。
世界背景
为了设置游戏的 360 度世界背景,我们将使用Environment API 中的setBackgroundImage方法更新当前的背景场景。我们将在index.js中定义的MERNVR组件的componentDidMount内部调用此方法,如下面的代码所示:
/MERNVR/index.js
componentDidMount = () => {
Environment.setBackgroundImage(
{uri: this.state.game.world}
)
}
这将用我们从云存储中获取的示例游戏世界图像替换入门级 React 360 项目中的默认 360 度背景。如果你正在编辑默认的 React 360 应用程序并且正在运行它,在浏览器中刷新http://localhost:8081/index.html链接应该会显示外太空背景,你可以使用鼠标进行平移:

为了生成前面的截图,默认代码中的View和Text组件也被更新为自定义 CSS 规则,以在屏幕上显示此文本。
这样,我们将拥有一个用户可以探索的 360 度游戏世界。在下一节中,我们将探讨如何在这个世界中放置 3D 对象。
添加 3D VR 对象
我们将使用 React 360 的Entity组件将 3D 对象添加到游戏世界中,以及为游戏定义的answerObjects和wrongObjects数组中的示例对象详情。
首先,我们将在componentDidMount中将answerObjects和wrongObjects数组连接起来,形成一个包含所有 VR 对象的单一数组,如下面的代码所示:
/MERNVR/index.js
componentDidMount = () => {
let vrObjects = this.state.game.answerObjects.concat(this.state.game.wrongObjects)
this.setState({vrObjects: vrObjects})
...
}
这将给我们一个包含游戏所有 VR 对象的单一数组。然后,在主视图中,我们将遍历这个合并的vrObjects数组来渲染具有每个对象详情的Entity组件。迭代代码将通过map添加,如下面的代码所示:
/MERNVR/index.js
{this.state.vrObjects.map((vrObject, i) => {
return (
<Entity key={i} style={this.setModelStyles(vrObject, i)}
source={{
obj: {uri: vrObject.objUrl},
mtl: {uri: vrObject.mtlUrl}
}}
/>
)
})
}
obj和mtl文件链接被添加到Entity的source属性中,transform样式的详细信息通过调用setModelStyles应用于Entity组件的样式。setModelStyles方法使用在 VR 对象详情中定义的值构建要渲染的特定 VR 对象的样式。
setModelStyles方法实现如下:
/MERNVR/index.js
setModelStyles = (vrObject, index) => {
return {
display: this.state.collectedList[index] ? 'none' : 'flex',
color: vrObject.color,
transform: [
{
translateX: vrObject.translateX
}, {
translateY: vrObject.translateY
}, {
translateZ: vrObject.translateZ
}, {
scale: vrObject.scale
}, {
rotateY: vrObject.rotateY
}, {
rotateX: vrObject.rotateX
}, {
rotateZ: vrObject.rotateZ
}
]
}
}
display属性将允许我们根据对象是否已经被玩家收集来显示或隐藏对象。translate和rotate值将在 VR 世界中渲染 3D 对象到期望的位置和方向。接下来,我们将进一步更新Entity代码以启用用户与这些 3D 对象的交互。
与 VR 对象交互
为了使 VR 游戏对象具有交互性,我们将使用 React 360 事件处理程序,如与Entity一起使用的onEnter和onExit,以及与VrButton一起使用的onClick,以添加旋转动画和游戏行为。在接下来的章节中,我们将添加在用户聚焦于 VR 对象时旋转 VR 对象的实现,以及为对象添加点击行为,以便用户在游戏中收集正确的对象。
旋转 VR 对象
我们希望添加一个功能,即当玩家聚焦于 3D 对象时,开始围绕其y轴旋转 3D 对象,即当平台光标开始与渲染特定 3D 对象的Entity组件相交时。
我们将更新上一节中的Entity组件,添加onEnter和onExit处理程序,如下面的代码所示:
/MERNVR/index.js
<Entity
...
onEnter={this.rotate(i)}
onExit={this.stopRotate}
/>
使用此Entity组件渲染的对象将在光标进入或聚焦于对象时开始旋转,并在平台光标退出对象且不再在玩家焦点中时停止。在下一节中,我们将讨论此旋转动画的实现。
使用requestAnimationFrame进行动画
每个 3D 对象的旋转行为是在添加到渲染 3D 对象的Entity组件的事件处理程序中实现的。具体来说,在onEnter和onExit事件发生时调用的rotate(index)和stopRotate()处理方法中,我们将使用requestAnimationFrame实现旋转动画行为,以在浏览器中实现平滑动画。
window.requestAnimationFrame()方法要求浏览器在下次重绘之前调用指定的回调函数来更新动画。使用requestAnimationFrame,浏览器优化动画以使其更平滑且更高效。
使用rotate方法,我们将使用requestAnimationFrame在设定的时间间隔内以恒定速率更新给定对象的rotateY变换值,如下面的代码所示:
/MERNVR/index.js
this.lastUpdate = Date.now()
rotate = index => event => {
const now = Date.now()
const diff = now - this.lastUpdate
const vrObjects = this.state.vrObjects
vrObjects[index].rotateY = vrObjects[index].rotateY + diff / 200
this.lastUpdate = now
this.setState({vrObjects: vrObjects})
this.requestID = requestAnimationFrame(this.rotate(index))
}
requestAnimationFrame方法将rotate方法作为递归回调函数,然后执行它以使用新值重绘旋转动画的每一帧,并相应地更新屏幕上的动画。
requestAnimationFrame方法返回一个requestID,我们将使用它在stopRotate调用中,以便在stopRotate方法中取消动画。此stopRotate方法定义如下:
/MERNVR/index.js
stopRotate = () => {
if (this.requestID) {
cancelAnimationFrame(this.requestID)
this.requestID = null
}
}
这将实现仅在 3D 对象处于观看者焦点时才对其动画化的功能。如图所示,当 3D 魔方处于焦点时,它围绕其y轴顺时针旋转:

虽然这里没有涉及,但探索 React 360 Animated 库也是值得的,它可以用来组合不同类型的动画。核心组件可以使用这个库进行原生动画,并且可以使用createAnimatedComponent()方法来动画化其他组件。这个库最初是从 React Native 实现的;要了解更多信息,您可以参考 React Native 文档。
现在玩游戏的用户将观察到,当他们聚焦于游戏世界中放置的任何 VR 对象时,会有运动效果。在下一节中,我们将添加捕捉用户点击这些对象的功能。
点击 3D 对象
为了在游戏中添加的每个 3D 对象上注册点击行为,我们需要将Entity组件包裹在一个可以调用onClick处理器的VrButton组件中。
我们将更新vrObjects数组迭代代码中添加的Entity组件,将其包裹在VrButton组件中,如下面的代码所示:
/MERNVR/index.js
<VrButton onClick={this.collectItem(vrObject)} key={i}>
<Entity … />
</VrButton>
当点击VrButton组件时,它将调用collectItem方法,并传递当前对象的详细信息。
当用户点击一个 3D 对象时,我们需要collectItem方法根据游戏功能执行以下操作:
-
检查点击的对象是
answerObject还是wrongObject。 -
根据对象类型,播放相关的声音。
-
如果对象是
answerObject,则应将其收集并从视图中移除,然后添加到收集对象列表中。 -
检查是否通过这次点击成功收集了所有
answerObject实例:- 如果是,向玩家显示游戏完成消息并播放游戏完成的声音。
我们将在collectItem方法中使用以下结构和步骤来实现这些动作:
collectItem = vrObject => event => {
if (vrObject is an answerObject) {
... update collected list ...
... play sound for correct object collected ...
if (all answer objects collected) {
... show game completed message in front of user ...
... play sound for game completed ...
}
} else {
... play sound for wrong object clicked ...
}
}
任何时间用户点击 VR 对象,在这个方法中,我们首先检查对象的类型,然后再采取相关的行动。我们将在下一节详细讨论这些步骤和动作的实现。
在点击时收集正确的对象
当用户点击一个 3D 对象时,我们首先需要检查点击的对象是否是答案对象。如果是,这个对象将被收集并从视图中隐藏,同时更新收集对象列表以及总数以跟踪用户在游戏中的进度。
为了检查点击的 VR 对象是否是answerObject,我们将使用indexOf方法在answerObjects数组中找到一个匹配项,如下面的代码所示:
let match = this.state.game.answerObjects.indexOf(vrObject)
如果vrObject是answerObject,indexOf将返回匹配对象的数组索引;如果没有找到匹配项,它将返回-1。
为了跟踪游戏中的收集对象,我们还将维护一个布尔值数组collectedList在相应的索引处,以及到目前为止收集的对象总数collectedNum,如下面的代码所示:
let updateCollectedList = this.state.collectedList
let updateCollectedNum = this.state.collectedNum + 1
updateCollectedList[match] = true
this.setState({collectedList: updateCollectedList,
collectedNum: updateCollectedNum})
使用 collectedList 数组,我们还将确定哪个 Entity 组件应该从视图中隐藏,因为相关的对象已被收集。相关的 Entity 组件的 display 样式属性将根据 collectedList 数组中相应索引的布尔值设置。我们使用前面在 添加 3D VR 对象 部分中提到的 setModelStyles 方法设置此样式。此显示样式值使用以下代码条件性地设置:
display: this.state.collectedList[index] ? 'none' : 'flex'
根据渲染的 VR 对象的数组索引是否在对象的收集列表中设置为 true,我们将隐藏或显示视图中的 Entity 组件。
例如,在下面的屏幕截图中,宝箱可以被点击并收集,因为它是一个 answerObject,而花盆不能被收集,因为它是一个 wrongObject:

当点击宝箱时,由于 collectedList 被更新,它将从视图中消失,并且我们还会使用 AudioModule.playOneShot 以以下代码播放收集音效:
AudioModule.playOneShot({
source: asset('collect.mp3'),
})
然而,当点击花盆并且它被识别为错误对象时,我们将播放另一个音效,表明它不能被收集,如下面的代码所示:
AudioModule.playOneShot({
source: asset('clog-up.mp3'),
})
由于花盆被识别为错误对象,collectedList 没有被更新,它仍然在屏幕上,而宝箱已经消失,如下面的屏幕截图所示:

当对象被点击时,执行所有这些步骤的 collectItem 方法中的完整代码如下。
/MERNVR/index.js:
collectItem = vrObject => event => {
let match = this.state.game.answerObjects.indexOf(vrObject)
if (match != -1) {
let updateCollectedList = this.state.collectedList
let updateCollectedNum = this.state.collectedNum + 1
updateCollectedList[match] = true
this.checkGameCompleteStatus(updateCollectedNum)
AudioModule.playOneShot({
source: asset('collect.mp3'),
})
this.setState({collectedList: updateCollectedList,
collectedNum: updateCollectedNum})
} else {
AudioModule.playOneShot({
source: asset('clog-up.mp3'),
})
}
}
使用此方法收集到点击的对象后,我们还将通过调用 checkGameCompleteStatus 方法来检查是否所有 answerObjects 都已被收集,以及游戏是否完成。我们将在下一节中查看此方法的实现和游戏完成功能。
游戏完成状态
每次收集到 answerObject 时,我们将检查收集到的项目总数是否等于 answerObjects 数组中的对象总数,以确定游戏是否完成。我们将通过调用 checkGameCompleteStatus 方法来实现这一点,如下面的代码所示:
/MERNVR/index.js
checkGameCompleteStatus = (collectedTotal) => {
if (collectedTotal == this.state.game.answerObjects.length) {
AudioModule.playEnvironmental({
source: asset('happy-bot.mp3'),
loop: true
})
this.setState({hide: 'flex', hmMatrix: VrHeadModel.getHeadMatrix()})
}
}
在此方法中,我们首先确认游戏确实已完成,然后执行以下操作:
-
使用
AudioModule.playEnvironmental播放游戏完成的音频。 -
使用
VrHeadModel获取当前的headMatrix值,以便将其设置为包含游戏完成信息的View组件的变换矩阵值。 -
将
View消息的display样式属性设置为flex,以便消息渲染给观众。
将包含向玩家表示祝贺完成游戏的View组件添加到父View组件中,如下所示:
/MERNVR/index.js
<View style={this.setGameCompletedStyle}>
<View style={this.styles.completeMessage}>
<Text style={this.styles.congratsText}>Congratulations!</Text>
<Text style={this.styles.collectedText}>
You have collected all items in {this.state.game.name}
</Text>
</View>
<VrButton onClick={this.exitGame}>
<View style={this.styles.button}>
<Text style={this.styles.buttonText}>Play another game</Text>
</View>
</VrButton>
</View>
对setGameCompletedStyle()方法的调用将设置View消息的样式,包括更新的display值和transform矩阵值。setGameCompletedStyle方法定义如下:
/MERNVR/index.js
setGameCompletedStyle = () => {
return {
position: 'absolute',
display: this.state.hide,
layoutOrigin: [0.5, 0.5],
width: 6,
transform: [{translate: [0, 0, 0]}, {matrix: this.state.hmMatrix}]
}
}
这些样式值将使View组件在用户当前视图的中心显示完成消息,无论他们是在 360 度 VR 世界的向上、向下、向后还是向前看,如下面的截图所示:

View消息中的最终文本将充当按钮,因为我们把这个View包裹在一个调用exitGame方法的VrButton组件中。exitGame方法定义如下:
/MERNVR/index.js
exitGame = () => {
Location.replace('/')
}
exitGame方法将使用Location.replace方法将用户重定向到可能包含游戏列表的外部 URL。
replace方法可以传递任何有效的 URL,一旦这个 React 360 游戏代码与第十四章中基于 MERN 的 VR 游戏应用程序集成,replace('/')将用户带到整个应用程序的主页。
VR 游戏功能已经通过这些对 React 360 项目的更新而完整。现在可以设置 360 度全景背景作为游戏世界,并向这个世界添加交互式 VR 对象。如果游戏规则允许,这些 3D 对象将原地旋转,并且可以根据用户交互进行收集。在下一节中,我们将演示如何打包这个 React 360 代码,以便游戏可以与基于 MERN 的 Web 应用程序集成。
生产打包和与 MERN 集成
现在我们已经实现了 VR 游戏的功能,并且与示例游戏数据一起是功能性的,我们可以为生产做准备,并将其添加到我们的 MERN 应用程序中,以查看如何将 VR 添加到现有的 Web 应用程序中。在接下来的几节中,我们将探讨如何打包 React 360 代码,将其与 MERN 应用程序集成,并通过从应用程序中运行游戏来测试集成。
打包 React 360 文件
React 360 工具提供了一个脚本来将所有 React 360 应用程序代码打包成几个文件,我们可以将这些文件直接放置在 MERN Web 服务器上,并在指定的路由上作为内容提供。要创建打包文件,我们可以在 React 360 项目目录中运行以下命令:
yarn bundle
这将在名为 build 的文件夹中生成 React 360 应用程序文件的编译版本。编译后的打包文件是 client.bundle.js 和 index.bundle.js。这两个文件,加上 index.html 文件和 static-assets/ 文件夹,构成了开发的整个 React 360 应用程序的生产版本。最终的生产代码将在以下文件夹和文件中:
-- static_assets/
-- index.html
-- index.bundle.js
-- client.bundle.js
我们必须将这些文件夹和文件移动到 MERN 项目目录中,以便将游戏与 MERN 应用程序集成,如下一节所述。
与 MERN 应用程序集成
为了将用 React 360 开发的游戏与基于 MERN 的 Web 应用程序集成,我们首先将上一节中讨论的 React 360 生产文件引入我们的 MERN 应用程序项目中。然后,我们将更新生成的 index.html 代码中的打包文件引用,使其指向打包文件的新位置,在 Express 应用程序中指定路由加载 index.html 代码之前。
添加 React 360 生产文件
考虑到现有 MERN 骨架应用程序的文件夹结构,我们将添加 static_assets 文件夹并将 React 360 生产文件中的打包文件添加到 dist/ 文件夹中。这将使我们的 MERN 代码保持有序,所有打包文件都在同一位置。index.html 文件将被放置在 server 文件夹下的一个新文件夹中,命名为 vr,如下面的文件夹结构所示:
-- ...
-- client/
-- dist/
--- static_assets/
--- ...
--- client.bundle.js
--- index.bundle.js
-- ...
-- server/
--- ...
--- vr/
---- index.html
-- ...
这将把 React 360 代码带到 MERN 应用程序中。然而,为了使其功能正常,我们需要更新 index.html 代码中的文件引用,如下一节所述。
更新 index.html 中的引用
在打包 React 360 项目后生成的 index.html 文件引用了打包文件,期望这些文件在同一个文件夹中,如下面的代码所示:
<html>
<head>
<title>MERNVR</title>
<style>body { margin: 0 }</style>
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
</head>
<body>
<!-- Attachment point for your app -->
<div id="container"></div>
<script src="img/client.bundle.js"></script>
<script>
// Initialize the React 360 application
React360.init(
'index.bundle.js',
document.getElementById('container'),
{
assetRoot: 'static_assets/',
}
)
</script>
</body>
</html>
我们需要更新这个 index.html 代码,使其引用 client.bundle.js、index.bundle.js 和 static_assets 文件夹的正确位置。
首先,按照以下方式更新对 client.bundle.js 的引用,以指向我们放置在 dist 文件夹中的文件:
<script src="img/strong>" type="text/javascript"></script>
然后,更新 React360.init 调用,使用正确的引用 index.bundle.js,并将 assetRoot 设置为 static_assets 文件夹的正确位置,如下面的代码所示:
React360.init(
'./../dist/index.bundle.js',
document.getElementById('container'),
{ assetRoot: '/dist/static_assets/' }
)
assetRoot 指定了在用 asset() 在 React 360 组件中设置资源时查找资源文件的位置。
使用 React 360 实现的游戏视图现在可在 MERN 应用程序中使用。在下一节中,我们将通过设置一个路由从 Web 应用程序加载游戏来尝试这种集成。
尝试集成
如果我们在 MERN 应用程序中设置一个 Express 路由以在响应中返回 index.html 文件,那么在浏览器中访问该路由将渲染 React 360 游戏。为了测试这种集成,我们可以设置一个示例路由,如下所示:
router.route('/game/play')
.get((req, res) => {
res.sendFile(process.cwd()+'/server/vr/index.html')
})
这在'/game/play'路径中声明了一个GET路由,它将简单地返回我们放置在vr文件夹中的与服务器代码一起的index.html文件,作为对请求客户端的响应。
然后,我们可以运行 MERN 服务器,并在浏览器中的localhost:3000/game/play打开此路由。这应该会在基于 MERN 的 Web 应用内部渲染本章实现的完整的 React 360 游戏。
摘要
在本章中,我们使用 React 360 开发了一个可以轻松集成到 MERN 应用的基于 Web 的 VR 游戏。
我们首先为游戏玩法定义了简单的 VR 功能。然后,我们为开发设置了 React 360,并研究了关键的 VR 概念,例如等角全景图像、3D 位置和 360 度 VR 世界的坐标系。我们探索了实现游戏功能所需的 React 360 组件和 API,包括View、Text、Entity和VrButton组件,以及Environment、VrHeadModel和NativeModules API。
最后,我们更新了入门级 React 360 项目的代码,以使用示例游戏数据实现游戏,然后打包了代码文件,并讨论了如何将这些编译后的文件添加到现有的 MERN 应用中。
完成这些步骤后,你现在将能够使用 React 360 构建自己的 VR 界面,这些界面可以轻松地集成到任何基于 MERN 的 Web 应用中。
在下一章中,我们将开发一个 MERN VR 游戏应用,包括游戏数据库和后端 API。这样我们就可以通过从存储在 MongoDB 中的游戏收藏中获取数据,使本章开发的游戏变得动态。
第十八章:使用 MERN 使 VR 游戏动态化
在本章中,我们将扩展 MongoDB、Express.js、React.js、和 Node.js(MERN)骨架应用程序以构建 MERN VR Game 应用程序,并使用它将上一章开发的静态 React 360 游戏转换为动态游戏。我们将通过用从数据库直接获取的游戏详情替换示例游戏数据来实现这一点。我们将使用 MERN 栈技术在后端实现游戏模型和 创建、读取、更新和删除(CRUD)应用程序编程接口(APIs),这将允许存储和检索游戏,以及前端视图,这将允许用户在浏览器中创建自己的游戏,同时还可以玩平台上任何游戏。我们将更新并将使用 React 360 开发的游戏集成到使用 MERN 技术开发的游戏平台中。完成这些实现和集成后,您将能够设计和构建具有动态 VR 功能的全栈 Web 应用程序。
为了使 MERN VR Game 成为完整且动态的游戏应用程序,我们将实现以下功能:
-
用于在 MongoDB 中存储游戏详情的游戏模型模式
-
游戏 CRUD 操作的 API
-
用于创建、编辑、列出和删除游戏的 React 视图
-
使用 API 更新 React 360 游戏以获取数据
-
使用动态游戏数据加载 VR 游戏
介绍动态 MERN VR Game 应用程序
在本章中,我们将使用 MERN-stack 技术开发 MERN VR Game 应用程序。在这个平台上,注册用户可以通过提供游戏世界的等距图像以及放置在游戏世界中的每个对象的变换属性值来创建和修改自己的游戏。任何访问应用程序的访客都可以浏览所有由制作者添加的游戏,并玩任何游戏,以找到和收集与每个游戏线索或描述相关的游戏世界中的 3D 对象。当注册用户登录应用程序时,他们将看到一个包含所有游戏列表的主页,以及创建自己游戏的选项,如下面的截图所示:

完整的 MERN VR Game 应用程序的代码可在 GitHub 上找到,网址为 github.com/PacktPublishing/Full-Stack-React-Projects-Second-Edition/tree/master/Chapter14/mern-vrgame。您可以在阅读本章其余部分的代码解释时克隆此代码并运行应用程序。
MERN VR Game 应用程序所需的视图将通过扩展和修改 MERN 骨架应用程序中现有的 React 组件来开发。以下截图所示的组件树显示了本章开发的 MERN VR Game 前端的所有自定义 React 组件:

我们将添加与创建、编辑和列出 VR 游戏相关的新的 React 组件,并且随着我们在本章的其余部分构建 MERN VR 游戏应用程序的功能,我们还将修改现有的组件,如Profile、Menu和Home组件。在这个游戏平台的核心功能依赖于存储每个游戏特定细节的能力。在下一节中,我们将通过定义存储每个游戏详细信息的游戏模型来开始实现 MERN VR 游戏应用程序。
定义游戏模型
为了在平台上存储每场比赛的详细信息,我们将实现一个 Mongoose 模型来定义一个游戏模型,其实现方式将与之前章节中介绍的其他 Mongoose 模型实现类似,例如在第六章,构建基于 Web 的课堂应用程序中定义的课程模型。在第十三章,开发基于 Web 的 VR 游戏中,游戏数据结构部分详细说明了实现游戏玩法中定义的寻宝功能所需的每个游戏的详细信息。
我们将根据这些关于游戏、其 VR 物体以及游戏制作者的具体细节来设计游戏模式。在以下章节中,我们将讨论游戏模式的细节,存储游戏中将作为一部分的单独 VR 物体的子模式,以及确保在游戏中放置的最小 VR 物体数量的验证检查。
探索游戏模式
游戏模式,它定义了游戏数据结构,将指定存储每个游戏详细信息的字段。这些详细信息将包括游戏名称;游戏世界图像文件、文本描述或线索的链接;包含游戏中 3D 物体详细信息的数组;表示游戏创建或更新的时间戳;以及创建游戏的用户的引用。游戏模型的模式将在server/models/game.model.js中定义,以下列表中给出了定义这些游戏字段的代码,并附有说明:
- 游戏名称:
name字段将存储游戏的标题。它被声明为String类型,并且是一个必填字段:
name: {
type: String,
trim: true,
required: 'Name is required'
},
- 世界图像 URL:
world字段将包含指向构成游戏 3D 世界的等经圆图像的 URL。它被声明为String类型,并且是一个必填字段:
world: {
type: String, trim: true,
required: 'World image is required'
},
- 线索文本:
clue字段将存储String类型的文本,以描述游戏或提供完成游戏的线索:
clue: {
type: String,
trim: true
},
- 可收集和其他 VR 对象:
answerObjects字段将是一个包含要添加到游戏中的可收集 VR 对象详细信息的数组,而wrongObjects字段将是一个包含游戏中不能收集的 VR 对象的数组。这些数组中的对象将在下一节中讨论的单独 VR 对象模式中定义:
answerObjects: [VRObjectSchema],
wrongObjects: [VRObjectSchema],
- 创建时间和更新时间:
created和updated字段为Date类型,created在添加新游戏时生成,updated在修改任何游戏详细信息时更改:
updated: Date,
created: {
type: Date,
default: Date.now
},
- 游戏制作者:
maker字段将是创建游戏的用户的引用:
maker: {type: mongoose.Schema.ObjectId, ref: 'User'}
在游戏模式定义中添加的这些字段将捕获平台上每个游戏的详细信息,并允许我们在 MERN VR 游戏应用程序中实现游戏相关功能。在游戏模式中的 answerObjects 和 wrongObjects 数组中存储的 VR 对象将包含放置在游戏世界中的每个 VR 对象的详细信息。在下一节中,我们将探讨定义每个 VR 对象存储详细信息的模式。
指定 VR 对象模式
游戏模式中已定义的 answerObjects 和 wrongObjects 字段都将包含 VR 对象文档的数组。这些文档将代表游戏中的 VR 对象。我们将为这些文档单独定义 VR 对象 Mongoose 模式,其中包含用于存储 对象(OBJ)文件和 材质模板库(MTL)文件 URL 的字段,以及 React 360 变换值、每个 VR 对象的缩放值和颜色值。
VR 对象的模式也将定义在 server/models/game.model.js 中,定义这些字段的代码如下列表所示,并附有说明:
- OBJ 和 MTL 文件 URL:
objUrl和mtlUrl字段将存储表示 3D 对象数据的 OBJ 和 MTL 文件的链接。这些字段为String类型,是存储 VR 对象的必需字段:
objUrl: {
type: String, trim: true,
required: 'OBJ file is required'
},
mtlUrl: {
type: String, trim: true,
required: 'MTL file is required'
},
- 平移变换值:
translateX、translateY和translateZ字段将包含 VR 对象在 3D 空间中的位置值。这些字段为Number类型,每个字段的默认值均为0:
translateX: {type: Number, default: 0},
translateY: {type: Number, default: 0},
translateZ: {type: Number, default: 0},
- 旋转变换值:
rotateX、rotateY和rotateZ字段将包含 VR 对象在 3D 空间中的方向值。这些字段为Number类型,每个字段的默认值均为0:
rotateX: {type: Number, default: 0},
rotateY: {type: Number, default: 0},
rotateZ: {type: Number, default: 0},
- 缩放:
scale字段将表示 VR 对象的相对大小外观。此字段为Number类型,默认值为1:
scale: {type: Number, default: 1},
- 颜色:如果 MTL 文件中没有提供,
color字段将指定对象的默认颜色。此字段为String类型,默认值为white:
color: {type: String, default: 'white'}
VR 对象模式中的这些字段代表要添加到游戏世界中的 VR 对象。当新的游戏文档保存到数据库时,answerObjects和wrongObjects数组将填充符合此模式定义的VRObject文档。当用户使用定义的游戏和 VR 对象模式创建新游戏时,我们希望确保用户至少在每个游戏数据数组中添加一个 VR 对象。在下一节中,我们将探讨如何将此验证检查添加到游戏模型中。
在游戏模式中验证数组长度
在定义游戏模型的游戏模式中,我们有两个数组用于向游戏中添加 VR 对象。当游戏在游戏集合中保存时,这些在游戏文档中的answerObjects和wrongObjects数组必须每个数组至少包含一个 VR 对象。为了将最小数组长度验证添加到游戏模式中,我们将向使用 Mongoose 定义的GameSchema中的answerObjects和wrongObjects路径添加以下自定义验证检查。
我们将使用validate为answerObjects字段添加数组长度验证,如下面的代码所示:
mern-vrgame/server/models/game.model.js:
GameSchema.path('answerObjects').validate(function(v) {
if (v.length == 0) {
this.invalidate('answerObjects',
'Must add alteast one VR object to collect')
}
}, null)
在此验证检查中,如果发现数组长度为0,我们将抛出一个验证错误消息,指出在将游戏文档保存到数据库之前,至少必须向数组中添加一个对象。
相同的验证代码也添加到wrongObjects字段,如下面的代码所示:
mern-vrgame/server/models/game.model.js:
GameSchema.path('wrongObjects').validate(function(v) {
if (v.length == 0) {
this.invalidate('wrongObjects',
'Must add alteast one other VR object')
}
}, null)
这些检查会在每次要将游戏保存到数据库时运行,并有助于确保游戏至少包含两个 VR 对象,包括一个可收集的对象和一个不可收集的对象。用于定义游戏模型的这些模式定义和验证将允许维护应用程序的游戏数据库。为了允许用户访问游戏集合,无论是制作自己的游戏还是检索他人的游戏,我们需要在后端实现相应的 CRUD API。在下一节中,我们将实现这些 CRUD API,这将使用户能够从应用程序中创建、读取、列出、更新和删除游戏。
实现游戏 CRUD API
为了构建一个允许制作、管理和访问 VR 游戏的游戏平台,我们需要扩展后端以接受允许在数据库中操作游戏数据的请求。为了使这些功能成为可能,MERN VR Game 应用程序的后端将公开一组 CRUD API,用于在数据库中创建、编辑、读取、列出和删除游戏,这些 API 可以在应用程序的前端使用 fetch 调用中使用,包括在 React 360 游戏实现中。在接下来的章节中,我们将在后端实现这些 CRUD API 端点,以及将在前端部署以使用这些 API 的相应 fetch 方法。
创建一个新的游戏
已登录应用程序的用户将能够通过创建游戏 API 端点在数据库中创建新游戏。对于在后端实现此 API,我们首先在 /api/games/by/:userId 上声明一个 POST 路由,如下所示:
mern-vrgame/server/routes/game.routes.js:
router.route('/api/games/by/:userId')
.post(authCtrl.requireSignin, gameCtrl.create)
向此路由发送 POST 请求将处理 :userId 参数,验证当前用户是否已登录,然后使用请求中传递的游戏数据创建一个新游戏。
包含此路由声明的 game.routes.js 文件将与 user.routes 文件非常相似,为了在 Express 应用中加载这些新路由,我们需要在 express.js 中挂载游戏路由,就像我们为认证和用户路由所做的那样。可以通过添加以下代码行将游戏路由挂载到 Express 应用中:
mern-vrgame/server/express.js
app.use('/', gameRoutes)
这将使声明的游戏路由在服务器运行时能够接收请求。
在接收到创建游戏 API 的请求后,为了处理 :userId 参数并从数据库检索相关用户,我们将利用用户控制器中的 userByID 方法。我们还将添加以下代码到游戏路由中,以便用户在 request 对象中可用:
mern-vrgame/server/routes/game.routes.js:
router.param('userId', userCtrl.userByID)
在接收到包含游戏数据的 POST 请求并验证用户身份验证后,将调用 create 控制器方法,将新游戏添加到数据库中。此 create 控制器方法定义如下所示:
mern-vrgame/server/controllers/game.controller.js
const create = async (req, res, next) => {
const game = new Game(req.body)
game.maker = req.profile
try{
let result = await game.save()
res.status(200).json(result)
} catch (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
在此 create 方法中,根据游戏模型和客户端请求体中传递的数据创建一个新的游戏文档。然后,在用户引用设置为游戏制作者后,将此文档保存到游戏集合中。
在前端,我们将在 api-game.js 中添加一个相应的 fetch 方法,通过传递从已登录用户收集的表单数据向创建游戏 API 发送 POST 请求。此 fetch 方法定义如下所示:
mern-vrgame/client/game/api-game.js
const create = async (params, credentials, game) => {
try {
let response = await fetch('/api/games/by/'+ params.userId, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
},
body: JSON.stringify(game)
})
return await response.json()
} catch(err) {
console.log(err)
}
}
这个 fetch 方法将在前端使用,并提供创建游戏 API 所需的用户凭证以进行 POST 请求。fetch 方法的响应将告诉用户游戏是否成功创建。
这个创建游戏 API 端点是准备好的,可以在一个表单视图中使用,它可以收集用户的新游戏详情,因此可以将新游戏添加到数据库中。在下一节中,我们将实现一个 API 端点,该端点将检索已添加到数据库中的游戏。
列出所有游戏
在 MERN VR Game 应用程序中,将可以使用后端中的列表游戏 API 从数据库中检索 Game 集合中的所有游戏列表。我们将通过向游戏路由添加一个 GET 路由来实现这个 API 端点,如下面的代码所示:
mern-vrgame/server/routes/game.routes.js:
router.route('/api/games')
.get(gameCtrl.list)
对 /api/games 的 GET 请求将执行 list 控制器方法,该方法将查询数据库中的 Game 集合,以在客户端响应中返回所有游戏。
这个 list 控制器方法将定义如下:
mern-vrgame/server/controllers/game.controller.js:
const list = async (req, res) => {
try {
let games = await Game.find({}).populate('maker', '_id name').sort('-created').exec()
res.json(games)
} catch (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
在此方法中,查询 Game 集合检索到的结果将按创建日期排序,最新游戏排在前面。列表中的每个游戏也将填充创建它的用户的名字和 ID。排序后的游戏列表将作为响应返回给请求客户端。
在前端,要使用此列表 API 获取游戏,我们将在 api-game.js 中设置相应的 fetch 方法,如下面的代码所示:
mern-vrgame/client/game/api-game.js:
const list = async (signal) => {
try {
let response = await fetch('/api/games', {
method: 'GET',
signal: signal
})
return await response.json()
} catch(err) {
console.log(err)
}
}
这个 fetch 方法可以在任何前端界面中使用,以调用列表游戏 API。fetch 将向 API 发出 GET 请求,并在响应中接收游戏列表,这可以在界面中渲染。在下一节中,我们将实现另一个仅返回特定用户制作的游戏的列表 API。
按制作者列出游戏
在 MERN VR Game 应用程序中,还可以检索由特定用户制作的游戏列表。为了实现这一点,我们将在后端添加另一个 API 端点,该端点接受在 /api/games/by/:userId 路由上的 GET 请求。此路由将与其他游戏路由一起声明,如下面的代码所示:
mern-vrgame/server/routes/game.routes.js:
router.route('/api/games/by/:userId')
.get(gameCtrl.listByMaker)
在此路由收到的 GET 请求将调用 listByMaker 控制器方法,该方法将查询数据库中的 Game 集合以获取匹配的游戏。listByMaker 控制器方法将定义如下:
mern-vrgame/server/controllers/game.controller.js:
const listByMaker = async (req, res) => {
try {
let games = await Game.find({maker:
req.profile._id}).populate('maker', '_id name')
res.json(games)
} catch (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
在此方法中对游戏集合的查询中,我们找到所有 maker 字段与 userId 路由参数中指定的用户匹配的游戏。检索到的游戏将包含制造商的名称和 ID,并将作为响应返回给请求客户端。
在前端,为了通过制造商 API 使用此列表获取特定用户的游戏,我们将在 api-game.js 中添加相应的 fetch 方法,如下所示:
mern-vrgame/client/game/api-game.js:
const listByMaker = async (params, signal) => {
try {
let response = await fetch('/api/games/by/'+params.userId, {
method: 'GET',
signal: signal,
})
return await response.json()
} catch(err) {
console.log(err)
}
}
此 fetch 方法可以在前端界面中使用用户 ID 调用由制造商 API 列出的游戏。fetch 方法将对 API 发出 GET 请求并接收由 URL 中指定的用户创建的游戏列表。在下一节中,我们将实现一个类似的 GET API 来检索单个游戏的详细信息。
加载游戏
在 MERN VR Game 应用程序的后端,我们将公开一个 API,该 API 将检索游戏集合中指定 ID 的单个游戏的详细信息。为了实现这一点,我们可以添加一个 GET API,该 API 查询 Game 集合并返回响应中的相应游戏文档。我们将开始实现此 API 以通过声明接受在 '/api/game/:gameId' 处的 GET 请求的路由来获取单个游戏,如下所示:
mern-vrgame/server/routes/game.routes.js:
router.route('/api/game/:gameId')
.get(gameCtrl.read)
当接收到此路由的请求时,路由 URL 中的 :gameId 参数将首先被处理以从数据库中检索单个游戏。因此,我们还将以下内容添加到游戏路由中:
router.param('gameId', gameCtrl.gameByID)
路由中存在 :gameId 参数将触发 gameByID 控制器方法,该方法与 userByID 控制器方法类似。它将从数据库中检索游戏并将其附加到 request 对象中,以便在 next 方法中使用。此 gameByID 控制器方法定义如下所示:
mern-vrgame/server/controllers/game.controller.js:
const gameByID = async (req, res, next, id) => {
try {
let game = await Game.findById(id).populate('maker', '_id name').exec()
if (!game)
return res.status('400').json({
error: "Game not found"
})
req.game = game
next()
} catch (err) {
return res.status('400').json({
error: "Could not retrieve game"
})
}
}
从数据库查询到的游戏还将包含制造商的名称和 ID 详细信息,如 populate() 方法中指定的。在此情况下,next 方法——即 read 控制器方法——简单地返回检索到的游戏作为对客户端的响应。此 read 控制器方法定义如下:
mern-vrgame/server/controllers/game.controller.js:
const read = (req, res) => {
return res.json(req.game)
}
此读取单个游戏详细信息的 API 将用于在 React 360 游戏世界的实现中加载游戏。我们可以在前端代码中使用 fetch 方法调用此 API,根据其 ID 检索单个游戏的详细信息。可以定义一个相应的 fetch 方法来调用此游戏 API,如下所示:
mern-vrgame/client/game/api-game.js:
const read = async (params) => {
try {
let response = await fetch('/api/game/' + params.gameId, {
method: 'GET'
})
return await response.json()
} catch(err) {
console.log(err)
}
}
此 read 方法将获取 params 中的游戏 ID 并使用 fetch 方法向 API 发出 GET 请求。
此用于加载单个游戏的 API 将被用于 React 视图获取游戏详情以及 React 360 游戏视图,该视图将在 MERN VR 游戏应用中渲染游戏界面。在下节中,我们将实现允许制作者更新他们在平台上已创建的游戏的 API。
编辑游戏
已登录的授权用户以及特定游戏的制作者将能够编辑该游戏在数据库中的详细信息。为了启用此功能,我们将在后端实现一个编辑游戏的 API。我们将添加一个PUT路由,允许授权用户编辑他们自己的其中一个游戏。该路由声明如下:
mern-vrgame/server/routes/game.routes.js:
router.route('/api/games/:gameId')
.put(authCtrl.requireSignin, gameCtrl.isMaker, gameCtrl.update)
对'/api/games/:gameId'的PUT请求将首先执行gameByID控制器方法以检索特定游戏的详细信息。requireSignin认证控制器方法也将被调用以确保当前用户已登录。然后,isMaker控制器方法将确定当前用户是否是此特定游戏的制作者,最后运行游戏update控制器方法以在数据库中修改游戏。
isMaker控制器方法确保已登录的用户实际上是正在编辑的游戏的制作者,并且它定义如下所示:
mern-vrgame/server/controllers/game.controller.js:
const isMaker = (req, res, next) => {
let isMaker = req.game && req.auth && req.game.maker._id == req.auth._id
if(!isMaker){
return res.status('403').json({
error: "User is not authorized"
})
}
next()
}
如果isMaker条件不满足,这意味着当前登录的用户不是正在编辑的游戏的制作者,并且响应中返回授权错误。但如果条件满足,则调用next方法。在这种情况下,update控制器方法是next方法,它将更改保存到数据库中的游戏。此更新方法定义如下所示:
mern-vrgame/server/controllers/game.controller.js:
const update = async (req, res) => {
try {
let game = req.game
game = extend(game, req.body)
game.updated = Date.now()
await game.save()
res.json(game)
} catch (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
此update方法将接受现有的游戏详情和请求体中接收到的表单数据以合并更改,并将更新后的游戏保存到数据库中的 Game 集合。
此编辑游戏 API 可以通过前端视图使用一个fetch方法调用,该方法将更改作为表单数据发送到后端,并随请求一起发送用户凭据。相应的fetch方法定义如下所示:
mern-vrgame/client/game/api-game.js:
const update = async (params, credentials, game) => {
try {
let response = await fetch('/api/games/' + params.gameId, {
method: 'PUT',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
},
body: JSON.stringify(game)
})
return await response.json()
} catch(err) {
console.log(err)
}
}
此方法向编辑游戏 API 发出PUT请求,在请求体中提供游戏的更改,在请求头中提供当前用户的凭据,并在路由 URL 中提供要编辑的游戏的 ID。此方法可以在前端使用,它渲染一个表单,允许用户更新游戏详情。在下节中,我们将在后端实现另一个 API,允许授权用户删除他们在平台上创建的游戏。
删除游戏
经过认证和授权的用户将能够删除他们在应用程序中创建的任何游戏。为了启用此功能,我们将在后端实现一个删除游戏的 API。我们将首先添加一个DELETE路由,允许授权的制作者删除他们自己的游戏,如下面的代码所示:
mern-vrgame/server/routes/game.routes.js:
router.route('/api/games/:gameId')
.delete(authCtrl.requireSignin, gameCtrl.isMaker, gameCtrl.remove)
在服务器上接收到api/games/:gameId的DELETE请求后,控制器方法执行的流程将与编辑游戏 API 类似,最终调用的是remove控制器方法而不是update。
当接收到/api/games/:gameId的DELETE请求,并且已经验证当前用户是给定游戏的原始制作者时,remove控制器方法会从数据库中删除指定的游戏。remove控制器方法定义如下所示:
mern-vrgame/server/controllers/game.controller.js:
const remove = async (req, res) => {
try {
let game = req.game
let deletedGame = await game.remove()
res.json(deletedGame)
} catch (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
}
这个remove方法会永久地从数据库中的游戏集合中删除指定的游戏。
为了从前端使用此 API,我们将在api-game.js中添加相应的remove方法,以向删除游戏 API 发送 fetch 请求。此fetch方法定义如下:
mern-vrgame/client/game/api-game.js:
const remove = async (params, credentials) => {
try {
let response = await fetch('/api/games/' + params.gameId, {
method: 'DELETE',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
}
})
return await response.json()
} catch(err) {
console.log(err)
}
}
此方法使用fetch向删除游戏 API 发送DELETE请求。它接受params中的游戏 ID 以及后端 API 端点需要的用户凭据,以检查当前用户是否是指定游戏的授权制作者。如果请求成功并且相应的游戏已从数据库中删除,则响应中返回成功消息。
在后端这些游戏 CRUD API 功能实现后,我们准备实现前端,该前端将使用这些 API 允许用户创建新游戏、列出游戏、修改现有游戏以及在 React 360 游戏视图中加载单个游戏。我们可以在下一节开始构建这个前端,从创建和编辑应用程序中游戏的 React 视图开始。
添加创建和编辑游戏的表单
在 MERN VR 游戏应用程序上注册的用户将能够创建新游戏并从应用程序的视图中修改这些游戏。为了实现这些视图,我们将添加 React 组件,允许用户为每个游戏组合和修改游戏详情以及 VR 对象详情。由于创建新游戏和编辑现有游戏的表单将具有类似的表单字段来组合游戏详情和 VR 对象详情,我们将创建可重用的组件,这些组件可以用于创建和编辑目的。在以下章节中,我们将讨论创建新游戏和编辑现有游戏的表单视图,以及在这些视图中实现通用表单组件。
创建新游戏
当任何用户登录到应用程序时,他们将被提供创建自己的 VR 游戏的选择。他们将在菜单中看到一个“创建游戏”链接,该链接将引导他们到一个表单,他们可以在其中填写游戏详情以在平台上创建新游戏。在接下来的部分中,我们将更新前端代码以在菜单上添加此链接并实现NewGame组件,该组件将包含创建新游戏的表单。
更新菜单
我们将更新应用程序中的导航菜单以添加“创建游戏”按钮,该按钮将根据用户是否已登录而条件显示,并将用户重定向到包含创建新游戏表单的视图。创建游戏按钮将显示在菜单中,如下面的截图所示:

要将此按钮添加到Menu组件,我们将使用一个Link组件,其中包含指向NewGame组件的表单的路由。为了使其条件渲染,我们将将其放置在先前的截图所示的“我的个人资料”链接之前,在仅当用户已认证时渲染的章节中。按钮代码将按以下代码所示添加:
mern-vrgame/client/core/Menu.js:
<Link to="/game/new">
<Button style={isActive(history, "/game/new")}>
<AddBoxIcon color="secondary"/> Make Game
</Button>
</Link>
这将向已登录用户显示“创建游戏”选项,他们可以点击它以重定向到包含在平台上创建新游戏表单视图的/game/new路由。在下一节中,我们将查看将渲染此表单的组件。
新游戏组件
我们将在NewGameReact 组件中实现创建新游戏的表单视图。此表单视图将允许用户填写单个游戏的字段。NewGame组件将渲染与游戏详情相对应的这些表单元素,包括 VR 对象详情,如下面的截图所示:

NewGame组件将使用GameForm组件,该组件将包含所有渲染的表单字段,以组成这个新游戏表单。GameForm组件将是一个可重用组件,我们将在创建和编辑表单中使用它。
当添加到NewGame组件时,它将一个onSubmit方法作为属性,以及任何服务器返回的错误消息,如下面的代码所示:
mern-vrgame/client/game/NewGame.js:
<GameForm onSubmit={clickSubmit} errorMsg={error}/>
传递给onSubmit属性的函数将在用户提交表单时执行。在这种情况下传递的clickSubmit方法是在NewGame组件中定义的。它使用api-game.js中的创建游戏fetch方法向创建游戏 API 发送带有游戏表单数据和用户详情的POST请求。
此clickSubmit方法定义如下所示:
mern-vrgame/client/game/NewGame.js:
const clickSubmit = game => event => {
const jwt = auth.isAuthenticated()
create({
userId: jwt.user._id
}, {
t: jwt.token
}, game).then((data) => {
if (data.error) {
setError(data.error)
} else {
setError('')
setRedirect(true)
}
})
}
如果用户在表单中输入游戏详情时出错,当在表单提交时调用此clickSubmit方法时,后端会发送错误消息。如果没有错误并且游戏在数据库中成功创建,用户将被重定向到另一个视图。
为了在指定的 URL 加载此NewGame组件并且仅对认证用户,我们将在MainRouter中添加一个PrivateRoute,如下面的代码所示:
mern-vrgame/client/MainRouter.js:
<PrivateRoute path="/game/new" component={NewGame}/>
这将使得当认证用户访问时,NewGame组件将在浏览器的/game/new路径上加载。在下一节中,我们将看到类似的实现,用于从数据库中编辑现有游戏并渲染相同的表单。
编辑游戏
用户将能够使用与创建新游戏表单类似的表单编辑他们在平台上创建的游戏。我们将在EditGame组件中实现此编辑游戏视图,该组件将渲染预填充现有游戏详情的游戏表单字段。我们将在下一节中查看此EditGame组件的实现。
The EditGame component
正如NewGame组件中一样,EditGame组件也将使用GameForm组件来渲染表单元素。但在这个表单中,字段将加载要编辑的游戏的当前值,并且用户将能够更新这些值,如下面的截图所示:

在此EditGame组件的情况下,GameForm将接受给定的游戏 ID 作为属性,以便它可以获取游戏详情,除了onSubmit方法和可能的服务器生成的错误消息。GameForm组件将带有这些属性添加到EditGame组件中,如下所示:
mern-vrgame/client/game/EditGame.js:
<GameForm gameId={params.gameId} onSubmit={clickSubmit} errorMsg={error}/>
编辑表单的clickSubmit方法将使用api-game.js中的更新游戏fetch方法向编辑游戏 API 发送带有表单数据和用户详情的PUT请求。此编辑表单提交的clickSubmit方法将定义如下面的代码所示:
mern-vrgame/client/game/EditGame.js:
const clickSubmit = game => event => {
const jwt = auth.isAuthenticated()
update({
gameId: match.params.gameId
}, {
t: jwt.token
}, game).then((data) => {
if (data.error) {
setError(data.error)
} else {
setError('')
setRedirect(true)
}
})
}
如果用户在表单中修改游戏详情时出错,当在表单提交时调用此clickSubmit方法时,后端会发送错误消息。如果没有错误并且游戏在数据库中成功更新,用户将被重定向到另一个视图。
为了在指定的 URL 加载此EditGame组件并且仅对认证用户,我们将在MainRouter中添加一个PrivateRoute,如下面的代码所示:
mern-vrgame/client/MainRouter.js:
<PrivateRoute path="/game/edit/:gameId" component={EditGame}/>
当认证用户访问时,EditGame 组件将在浏览器中的 /game/edit/:gameId 路径上加载。这个 EditGame 组件和 NewGame 组件都使用 GameForm 组件来渲染允许用户添加游戏详情的表单元素。在下一节中,我们将讨论这个可重用的 GameForm 组件的实现。
实现 GameForm 组件
GameForm 组件在 NewGame 和 EditGame 组件中都被使用,它包含了允许用户输入单个游戏的游戏详情和 VR 对象详情的元素。它可能从一个空白的游戏对象开始,或者加载一个现有的游戏。为了开始实现这个组件,我们首先在组件状态中初始化一个空白的游戏对象,如下面的代码所示:
mern-vrgame/client/game/GameForm.js:
const [game, setGame] = useState({ name: '',
clue: '',
world: '',
answerObjects: [],
wrongObjects: []
})
如果 GameForm 组件从父组件(例如从 EditGame 组件)接收到 gameId 属性——那么它将使用加载游戏 API 来检索游戏的详情并将其设置到状态中,以便在表单视图中渲染。我们将在下面的代码中调用这个 API,如下所示:
mern-vrgame/client/game/GameForm.js:
useEffect(() => {
if(props.gameId){
const abortController = new AbortController()
const signal = abortController.signal
read({gameId: props.gameId}, signal).then((data) => {
if (data.error) {
setReadError(data.error)
} else {
setGame(data)
}
})
return function cleanup(){
abortController.abort()
}
}
}, [])
在 useEffect 钩子中,我们首先检查从父组件接收到的属性中是否包含 gameId 属性,然后使用该值来调用加载游戏 API。如果 API 调用返回错误,我们将错误设置到状态中;否则,我们将检索到的游戏设置到状态中。通过这段代码,我们将根据初始值初始化游戏详情,以便在表单视图中使用。
GameForm 组件中的表单视图部分基本上有两个部分:一部分用于输入简单的游戏详情(如名称、世界图像链接和线索文本),另一部分允许用户将可变数量的 VR 对象添加到答案对象数组或错误对象数组中。在接下来的几节中,我们将查看这两个部分的实现,它们将构成游戏详情表单视图。
输入简单的游戏详情
在创建或编辑游戏时,用户首先会看到游戏简单详情的表单元素,例如名称、世界图像 URL 和线索文本。这个包含简单游戏详情的表单部分将主要是使用 Material-UI TextField 组件添加的文本输入元素,并通过 onChange 处理器传递一个更改处理方法。我们将在 GameForm 组件中构建这个部分,该组件在 mern-vrgame/client/game/GameForm.js 中实现,如下所示的相关代码:
- 表单标题:表单标题将根据是否将现有的游戏 ID 作为属性从父组件传递给
GameForm来决定,如下面的代码所示:
<Typography type="headline" component="h2">
{props.gameId? 'Edit': 'New'} Game
</Typography>
- 游戏世界图像输入:我们将在表单最顶部的
img元素中渲染背景图像 URL,以向用户显示他们添加的游戏世界图像 URL。图像 URL 输入将在渲染的图像下方的TextField组件中获取,如下面的代码所示:
<img src={game.world}/>
<TextField id="world" label="Game World Equirectangular Image (URL)"
value={game.world} onChange={handleChange('world')}/>
- 游戏名称:游戏名称将被添加到默认的
text类型的单个TextField中,如下面的代码所示:
<TextField id="name" label="Name" value={game.name} onChange={handleChange('name')}/>
- 线索文本:线索文本将被添加到一个多行
TextField组件中,如下面的代码所示:
<TextField id="multiline-flexible" label="Clue Text" multiline rows="2"
value={game.clue} onChange={handleChange('clue')}/>
在添加到GameForm组件的这些表单元素中,输入字段也接受一个onChange处理函数,该函数被定义为handleChange。这个handleChange方法会在用户更改输入元素中的值时更新状态中的游戏值。handleChange方法定义如下:
mern-vrgame/client/game/GameForm.js:
const handleChange = name => event => {
const newGame = {...game}
newGame[name] = event.target.value
setGame(newGame)
}
在这个方法中,根据被更改的具体字段值,我们更新状态中游戏对象的相应属性。这捕捉了用户输入的值作为他们 VR 游戏的简单细节。表单还将提供定义将作为游戏一部分的 VR 对象数组的选项。在下一节中,我们将查看允许用户操作 VR 对象数组的表单实现。
修改 VR 对象数组
用户将能够为每个游戏定义两个不同数组中的动态数量的 VR 对象。为了允许用户修改他们希望添加到 VR 游戏中的answerObjects和wrongObjects数组,GameForm将遍历每个数组,并为每个对象渲染一个 VR 对象表单组件。这样,它将使从GameForm组件中添加、删除和修改 VR 对象成为可能,如下面的截图所示:

在以下几节中,我们将在GameForm组件中添加这些数组操作功能。我们首先将渲染 VR 对象数组中的每个项目,并包含添加新项目或从数组中删除现有项目的选项。然后,由于数组中的每个项目本质上都是一个输入 VR 对象细节的表单,我们还将讨论如何处理在GameForm组件中每个项目内进行的输入更改。
迭代和渲染对象详情表单
我们将添加上一节中看到的表单界面,使用 Material-UI ExpansionPanel组件来创建给定游戏中每种 VR 对象数组类型的可修改的 VR 对象列表。
在嵌套的ExpansionPanelDetails组件内部,我们将遍历answerObjects数组或wrongObjects数组,为每个 VR 对象渲染一个VRObjectForm组件,如下面的代码所示:
mern-vrgame/client/game/GameForm.js:
<ExpansionPanel>
<ExpansionPanelSummary expandIcon={<ExpandMoreIcon />}>
<Typography>VR Objects to collect</Typography>
</ExpansionPanelSummary>
<ExpansionPanelDetails> {
game.answerObjects.map((item, i) => {
return <div key={i}>
<VRObjectForm index={i} type={'answerObjects'}
handleUpdate={handleObjectChange}
vrObject={item}
removeObject={removeObject}/>
</div>
})
}
...
</ExpansionPanelDetails>
</ExpansionPanel>
为了渲染数组中的每个对象,我们使用VRObjectForm组件。我们将在本章的后面部分查看VRObjectForm组件的具体实现。在添加VRObjectForm到这段代码时,我们传递单个vrObject项目作为属性,以及数组中的当前index、数组的类型,以及两个方法,用于在GameForm中通过更改详细信息或从VRObjectForm组件内部删除对象来修改数组详细信息时更新状态。这将渲染一个表单,用于在GameForm组件中与游戏关联的数组中的每个 VR 对象。在下一节中,我们将看到包括将这些数组中添加新对象选项的实现。
向数组中添加新对象
对于游戏表单中渲染的每个数组,我们将添加一个按钮,允许用户将新的 VR 对象推送到给定的数组。添加对象的此按钮将渲染一个新的VRObjectForm组件以获取新 VR 对象的详细信息。我们将在迭代代码之后将此按钮添加到ExpansionPanelDetails组件中,如下所示:
mern-vrgame/client/game/GameForm.js:
<ExpansionPanelDetails>
...
<Button color="primary" variant="contained"
onClick={addObject('answerObjects')}>
<AddBoxIcon color="secondary"/>
Add Object
</Button>
</ExpansionPanelDetails>
此添加对象按钮将渲染在每个 VR 对象表单列表的末尾。当点击时,它将通过调用addObject方法添加一个新的空白 VR 对象表单。此addObject方法将如下定义:
mern-vrgame/client/game/GameForm.js:
const addObject = name => event => {
const newGame = {...game}
newGame[name].push({})
setGame(newGame)
}
addObject方法传递了数组类型,因此我们知道用户想要将新对象添加到哪个数组。在此方法中,我们只需将一个空对象添加到正在迭代的数组中,这样就会在它的位置渲染一个空表单,用户可以填写以输入新对象的详细信息。在下一节中,我们将看到如何让用户从 VR 对象表单列表中删除这些项目之一。
从数组中删除对象
VR 对象表单列表中渲染的每个项目也可以由用户删除。显示项目的VRObjectForm组件将包含一个删除选项,这将从给定的数组中删除对象。
为了实现此删除按钮的删除项功能,我们将一个removeObject方法作为属性传递给从父组件GameForm组件的VRObjectForm组件。此方法将允许在用户在特定的VRObjectForm上点击 DELETE 时,更新父组件的状态中的数组。此removeObject方法将按照以下代码所示定义:
mern-vrgame/client/game/GameForm.js:
const removeObject = (type, index) => event => {
const newGame = {...game}
newGame[type].splice(index, 1)
setGame(newGame)
}
在此方法中,将根据指定的 index 从具有指定 type 的数组中切片,移除对应于点击项的 VR 对象。当在状态中设置时,此更新后的对象数组将在游戏中反映出来,删除的 VR 对象将从表单视图中移除。在下一节中,我们将探讨当用户在 VR 对象表单中更新值时如何处理 VR 对象详情的更改,该表单是根据 VR 对象数组中的项渲染的。
处理对象详情更改
当用户在相应的 VR 对象表单中的任何字段更改输入值时,游戏中的任何 VR 对象的详情都将被更新。为了注册此更新,包含 VR 对象表单的 GameForm 将 handleObjectChange 方法传递给 VRObjectForm 组件,该组件将渲染 VR 对象表单。此 handleObjectChange 方法将定义如下:
mern-vrgame/client/game/GameForm.js:
const handleObjectChange = (index, type, name, val) => {
var newGame = {...game}
newGame[type][index][name] = val
setGame(newGame)
}
这个 handleObjectChange 方法将在 VRObjectFrom 组件中使用,以捕获更改后的输入值并更新给定 type 的数组中指定 index 的 VR 对象的相应字段,因此它反映在 GameForm 中的游戏对象状态中。
GameForm 组件用于渲染修改游戏详情的表单元素,包括 VR 对象列表。使用此表单,用户可以在列表中添加、修改和删除 VR 对象。列表以 VR 对象表单的形式渲染每个项,用户可以使用它来组合对象的详情。在下一节中,我们将实现渲染游戏中每个 VR 对象的 VR 对象表单的 React 组件。
VRObjectForm 组件
我们将实现 VRObjectForm 组件以渲染用于修改单个 VR 对象详情的输入字段,这些字段被添加到 GameForm 组件中的 answerObjects 和 wrongObjects 数组中。VRObjectForm 组件将渲染一个表单,如下面的截图所示:

要开始实现包含 VR 对象表单的 VRObjectForm 组件,我们将在组件的状态中使用 useState 钩子初始化 VR 对象的空白详情,如下面的代码所示:
mern-vrgame/client/game/VRObjectForm.js:
const [values, setValues] = useState({
objUrl: '',
mtlUrl: '',
translateX: 0,
translateY: 0,
translateZ: 0,
rotateX: 0,
rotateY: 0,
rotateZ: 0,
scale: 1,
color:'white'
})
这些详情对应于存储 VR 对象定义的架构。当 VRObjectForm 组件被添加到 GameForm 组件时,它可能接收一个空的 VR 对象或一个填充了详情的 VR 对象,具体取决于是否正在渲染空表单或具有现有对象详情的表单。如果传递了现有的 VR 对象作为 prop,我们将使用 useEffect 钩子将此对象的详情设置在组件状态中,如下面的代码所示:
mern-vrgame/client/game/VRObjectForm.js:
useEffect(() => {
if(props.vrObject && Object.keys(props.vrObject).length != 0){
const vrObject = props.vrObject
setValues({...values,
objUrl: vrObject.objUrl,
mtlUrl: vrObject.mtlUrl,
translateX: Number(vrObject.translateX),
translateY: Number(vrObject.translateY),
translateZ: Number(vrObject.translateZ),
rotateX: Number(vrObject.rotateX),
rotateY: Number(vrObject.rotateY),
rotateZ: Number(vrObject.rotateZ),
scale: Number(vrObject.scale),
color:vrObject.color
})
}
}, [])
在此useEffect钩子中,如果通过 prop 传入的vrObject值不是一个空对象,我们将设置接收到的 VR 对象的详细信息到状态中。这些值将用于组成 VR 对象表单的输入字段。我们将使用 Material-UI TextField组件在VRObjectForm视图的视图中添加与 VR 对象详情对应的输入字段,如下面的代码和以下列表所示:
- 3D 对象文件输入:每个 VR 对象的 OBJ 和 MTL 文件链接将通过
TextField组件以文本输入的形式收集,如下面的代码所示:
<TextField label=".obj url" value={values.objUrl}
onChange={handleChange('objUrl')} />
<TextField label=".mtl url" value={values.mtlUrl}
onChange={handleChange('mtlUrl')} />
- 翻译值输入:VR 对象在 x、y 和 z 轴上的翻译值将通过
number类型的TextField组件输入,如下面的代码所示:
<TextField type="number" value={values.translateX}
label="TranslateX" onChange={handleChange('translateX')} />
<TextField type="number" value={values.translateY}
label="TranslateY" onChange={handleChange( 'translateY')} />
<TextField type="number" value={values.translateZ}
label="TranslateZ" onChange={handleChange('translateZ')} />
- 旋转值输入:VR 对象围绕 x、y 和 z 轴的旋转值将通过
number类型的TextField组件输入,如下面的代码所示:
<TextField type="number" value={values.rotateX}
label="RotateX" onChange={handleChange('rotateX')} />
<TextField type="number" value={values.rotateY}
label="RotateY" onChange={handleChange('rotateY')} />
<TextField type="number" value={values.rotateZ}
label="RotateZ" onChange={handleChange('rotateZ')} />
- 缩放值输入:VR 对象的缩放值将通过
TextField组件的number类型输入,如下面的代码所示:
<TextField type="number" value={values.scale}
label="Scale" onChange={handleChange('scale')} />
- 对象颜色输入:VR 对象的颜色值将通过
text类型的TextField组件输入,如下面的代码所示:
<TextField value={values.color} label="Color"
onChange={handleChange('color')} />
这些输入字段将允许用户在游戏中设置 VR 对象的详细信息。当用户在这些输入字段中更改任何 VR 对象详细信息时,将调用handleChange方法。此handleChange方法将定义如下所示,代码如下:
mern-vrgame/client/game/VRObjectForm.js:
const handleChange = name => event => {
setValues({...values, [name]: event.target.value})
props.handleUpdate(props.index, props.type, name, event.target.value)
}
此handleChange方法将更新VRObjectForm组件状态中的相应值,并使用从GameForm作为 prop 传递的handleUpdate方法来更新GameForm状态中的 VR 对象,以特定对象详情的更改值。
VRObjectForm还将包含一个 DELETE 按钮,该按钮将执行在GameForm中作为 prop 接收的removeObject方法,这将允许从游戏列表中移除指定的对象。此删除按钮将按以下代码添加到视图中:
mern-vrgame/client/game/VRObjectForm.js:
<Button onClick={props.removeObject(props.type, props.index)}>
<Icon style={{marginRight: '5px'}}>cancel</Icon> Delete
</Button>
removeObject方法将接受对象数组类型和数组索引位置值,以从GameForm组件的状态中的相关 VR 对象数组中移除指定的对象。
通过这些实现,创建和编辑游戏的表单已经就绪,包括不同大小的 VR 对象输入表单。我们使用了可重用组件来组合创建和编辑游戏所需的形式元素,并添加了修改游戏中 VR 对象数组的 capability。任何注册用户都可以使用这些表单在 MERN VR 游戏应用程序中添加和编辑游戏详情。在下文中,我们将讨论实现视图,该视图将在平台上渲染不同的游戏列表。
添加游戏列表视图
访问 MERN VR 游戏的访客将从主页和单个用户个人资料中渲染的列表访问应用程序中的游戏。主页将列出应用程序上的所有游戏,特定制作者的游戏将列在其用户个人资料页面上。这些列表视图将通过使用后端 API 列出游戏来迭代游戏数据,并在可重用的 React 组件中渲染每个游戏的详细信息。
在以下章节中,我们将讨论使用可重用组件渲染列表中所有游戏以及仅由特定制作者的游戏的实现。
渲染游戏列表
我们将在应用程序的主页上渲染平台上可用的所有游戏。为了实现此功能,Home 组件将首先使用列表游戏 API 从数据库中的游戏集合中获取所有游戏的列表。我们将在 Home 组件中的 useEffect 钩子中实现这一点,如下所示:
mern-vrgame/client/core/Home.js:
useEffect(() => {
const abortController = new AbortController()
const signal = abortController.signal
list(signal).then((data) => {
if (data.error) {
console.log(data.error)
} else {
setGames(data)
}
})
return function cleanup(){
abortController.abort()
}
}, [])
在此 useEffect 钩子中从服务器检索到的游戏列表将设置到状态中,并遍历以渲染列表中的每个游戏的 GameDetail 组件,如下所示:
mern-vrgame/client/core/Home.js:
{games.map((game, i) => {
return <GameDetail key={i} game={game} updateGames={updateGames}/>
})}
GameDetail 组件将被实现为一个可重用的组件,用于渲染单个游戏的详细信息。它将传递游戏细节和 updateGames 方法。updateGames 方法将允许在列表上的任何游戏被制作者删除时更新 Home 组件中的游戏列表。updateGames 方法定义如下所示:
mern-vrgame/client/core/Home.js:
const updateGames = (game) => {
const updatedGames = [...games]
const index = updatedGames.indexOf(game)
updatedGames.splice(index, 1)
setGames(updatedGames)
}
updateGames 方法将通过从游戏数组中切割指定的游戏来更新 Home 组件中渲染的游戏列表。当用户使用 GameDetail 组件中条件渲染的 EDIT 和 DELETE 选项删除他们的游戏时,将调用此方法,如下图中所示的应用程序主页上的游戏列表截图:

我们可以在用户个人资料页面上渲染类似的列表视图,仅显示相应用户制作的游戏,如下图中所示:

与 Home 组件中的实现步骤类似,在这个 Profile 组件中,我们可以通过调用相关的制作者列表游戏 API,在 useEffect 钩子中获取给定用户的游戏列表。在状态中设置检索到的游戏列表后,我们可以遍历它,在 GameDetail 组件中渲染每个游戏,正如之前讨论的那样,用于在主页上渲染所有游戏。在下文中,我们将讨论此 GameDetail 组件的实现,该组件将渲染单个游戏的详细信息。
游戏详情组件
我们将在应用程序中的任何游戏列表视图中实现GameDetail组件以渲染单个游戏。这个GameDetail组件接受游戏对象作为属性,并渲染游戏的详细信息,包括一个链接到 VR 游戏视图的“玩游戏”按钮,如下面的截图所示:

如果当前用户是游戏的制作者,此组件将条件性地渲染“编辑”和“删除”按钮。
在GameDetail组件的视图代码中,我们首先添加游戏详情——如名称、世界图像、线索文本和制作者名称——以使用户对游戏有一个概述。我们将使用 Material-UI 组件将这些详情组合成界面,如下面的代码所示:
mern-vrgame/client/game/GameDetail.js:
<Typography type="headline" component="h2">
{props.game.name}
</Typography>
<CardMedia image={props.game.world}
title={props.game.name}/>
<Typography type="subheading" component="h4">
<em>by</em>
{props.game.maker.name}
</Typography>
<CardContent>
<Typography type="body1" component="p">
{props.game.clue}
</Typography>
</CardContent>
此代码将渲染传入属性的游戏世界图像、游戏名称、制作者名称和线索文本。
在GameDetail组件中渲染的“玩游戏”按钮将简单地是一个包裹在 HTML 链接元素中的按钮,该链接指向打开 React 360 生成的index.html文件的路径(服务器上此路径的实现将在玩 VR 游戏部分讨论)。这个“玩游戏”链接添加到GameDetail组件中,如下所示:
mern-vrgame/client/game/GameDetail.js:
<a href={"/game/play?id=" + props.game._id} target='_self'>
<Button variant="contained" color="secondary"
className={classes.button}>
Play Game
</Button>
</a>
游戏视图的路径使用游戏 ID 作为查询参数。我们在链接上设置了target='_self',这样 React Router 就会跳过转到下一个状态,让浏览器处理这个链接。这样做将允许浏览器在点击链接时直接向服务器发送请求,并渲染服务器对此请求发送的index.html文件,使用户能够立即开始玩渲染的 VR 游戏。
在GameDetail组件的最后部分,我们将条件性地显示“编辑”和“删除”选项,仅当当前登录的用户也是渲染的游戏的制作者时。我们将使用以下代码添加这些选项:
mern-vrgame/client/game/GameDetail.js:
{auth.isAuthenticated().user
&& auth.isAuthenticated().user._id == props.game.maker._id &&
(<div>
<Link to={"/game/edit/" + props.game._id}>
<Button variant="raised" color="primary"
className={classes.editbutton}>
Edit
</Button>
</Link>
<DeleteGame game={props.game}
removeGame={props.updateGames}/>
</div>)}
在确保当前用户确实认证后,我们检查已登录用户的用户 ID 是否与游戏中的制作者 ID 匹配。然后,相应地,我们渲染链接到编辑表单视图的“编辑”按钮,以及带有DeleteGame组件的“删除”选项。
这个DeleteGame组件的实现与第七章中讨论的DeleteShop组件类似,使用在线市场锻炼 MERN 技能。不同于商店,DeleteGame组件将接受要删除的游戏和从父组件接收的updateGames函数定义作为属性。在集成此实现后,游戏的制作者将能够从平台上删除游戏。
访问 MERN VR 游戏应用的用户可以浏览在这些视图中渲染的游戏列表,并通过点击相应的GameDetail组件中渲染的“播放游戏”链接来选择播放游戏。在下一节中,我们将看到如何更新服务器以处理播放游戏的请求。
播放 VR 游戏
MERN VR 游戏应用的用户将能够打开并播放应用内的任何 VR 游戏。为了实现这一点,我们将在服务器上添加一个 API,该 API 渲染由 React 360 生成的index.html文件,如前一章所述,第十三章,开发基于 Web 的 VR 游戏。此后端 API 将在以下路径接收一个GET请求:
/game/play?id=<game ID>
此路径将一个游戏 ID值作为查询参数。此 URL 中的游戏 ID将在本章后面详细说明的 React 360 代码中使用,用于通过加载游戏 API 获取游戏详情。在下一节中,我们将查看实现后端 API 的过程,该 API 将处理用户点击“播放游戏”按钮时开始的GET请求。
实现渲染 VR 游戏视图的 API
为了实现将在浏览器中渲染 VR 游戏的 API,我们将在后端添加一个路由,该路由将接收一个GET请求并打开 React 360 的index.html页面。
此路由将在game.routes.js中声明,与其他游戏路由一起,如下所示:
mern-vrgame/server/routes/game.routes.js:
router.route('/game/play')
.get(gameCtrl.playGame)
在此路由接收到的GET请求将执行playGame控制器方法,该方法将返回响应请求的index.html页面。playGame控制器方法将定义如下代码:
mern-vrgame/server/controllers/game.controller.js:
const playGame = (req, res) => {
res.sendFile(process.cwd()+'/server/vr/index.html')
}
playGame控制器方法将简单地发送放置在/server/vr/文件夹中的index.html页面给请求客户端。
在浏览器中,这将渲染 React 360 游戏代码,该代码需要使用加载游戏 API 从数据库中获取游戏详情,并渲染游戏世界,以及用户可以与之交互的 VR 对象。在下一节中,我们将看到我们之前用 React 360 构建的游戏视图需要如何更新以动态加载这些游戏详情。
更新 React 360 中的游戏代码
在 MERN 应用中设置好游戏后端后,我们可以更新我们在第十三章,开发基于 Web 的 VR 游戏中开发的 React 360 项目代码,使其能够直接从数据库中的游戏集合中渲染游戏。
我们将使用在打开 React 360 应用程序的链接中的游戏 ID 来获取游戏详情,使用 React 360 代码内的加载游戏 API。然后,我们将检索到的游戏数据设置为状态,以便游戏从数据库加载详情,而不是我们在 第十三章,“开发基于 Web 的 VR 游戏” 中使用的静态示例数据。一旦代码更新,我们再次捆绑它,并将编译的文件放置在 MERN 应用程序中,然后尝试集成,如以下几节所述。
从链接中获取游戏 ID
为了根据用户在 MERN VR 游戏应用程序中选择要玩的游戏渲染 VR 游戏,我们需要从加载 VR 游戏视图的链接中检索相应的游戏 ID。在 React 360 项目文件夹的 index.js 文件中,我们将更新 componentDidMount 方法,首先从传入的 URL 中检索游戏 ID,然后对加载游戏 API 进行获取调用,如下面的代码所示:
/MERNVR/index.js:
componentDidMount = () => {
let gameId = Location.search.split('?id=')[1]
read({
gameId: gameId
}).then((data) => {
if (data.error) {
this.setState({error: data.error});
} else {
this.setState({
vrObjects: data.answerObjects.concat(data.wrongObjects),
game: data
});
Environment.setBackgroundImage(
{uri: data.world}
)
}
})
}
Location.search 允许我们访问加载 index.html 的传入 URL 中的查询字符串。检索到的查询字符串被 split 以从 URL 中附加的 id 查询参数中获取 gameId 值。我们使用这个 gameId 值从后端的加载游戏 API 获取游戏详情,并将其设置为游戏和 vrObjects 的状态值。为了能够在 React 360 项目中使用加载游戏 API,我们将在项目中定义一个相应的 fetch 方法,如下一节所述。
使用加载游戏 API 获取游戏数据
我们希望在 React 360 代码内部获取游戏数据。在 React 360 项目文件夹中,我们将添加一个 api-game.js 文件,该文件将包含一个 read 获取方法,该方法使用提供的游戏 ID 调用服务器上的加载游戏 API。这个 fetch 方法将定义如下:
/MERNVR/api-game.js:
const read = (params) => {
return fetch('/api/game/' + params.gameId, {
method: 'GET'
}).then((response) => {
return response.json()
}).catch((err) => console.log(err))
}
export {
read
}
这个 fetch 方法接收 params 中的游戏 ID 并对数据库中的相应游戏进行 API 调用。它用于 React 360 入口组件的 componentDidMount 中,该组件定义在 index.js 文件中,用于检索游戏详情,如前所述。
此更新后的 React 360 代码可在 GitHub 仓库的 dynamic-game-second-edition 分支上找到,网址为 github.com/shamahoque/MERNVR/tree/dynamic-game-second-edition。
在更新 React 360 代码并能够根据传入 URL 中指定的游戏 ID 获取和渲染游戏详情后,我们可以将此更新代码捆绑并集成到 MERN VR 游戏应用程序中,如下一节所述。
捆绑和集成更新后的代码
当 React 360 代码更新为从服务器动态获取和渲染游戏详细信息时,我们可以使用提供的捆绑脚本来捆绑此代码,并将新编译的文件放置在 MERN VR 游戏项目目录的dist文件夹中。
要从命令行捆绑 React 360 代码,请转到 React 360 MERNVR项目文件夹并运行以下代码:
yarn bundle
这将在build/文件夹中生成带有更新后的 React 360 代码的client.bundle.js和index.bundle.js捆绑文件。这些文件,连同index.html文件和static_assets文件夹,需要添加到 MERN VR 游戏应用程序代码中,正如在第十三章[4f633dd6-f392-490d-b3a6-eb5430b58ec8.xhtml]“开发基于 Web 的 VR 游戏”中讨论的那样,以集成最新的 VR 游戏代码。
完成此集成后,如果我们运行 MERN VR 游戏应用程序并点击任何游戏中的 PLAY GAME 链接,它应该会打开游戏视图,在 VR 场景中渲染特定游戏的详细信息,并允许与 VR 对象进行交互,如游戏玩法中指定的。
摘要
在本章中,我们将 MERN 堆栈技术的功能与 React 360 集成,以开发一个用于 Web 的动态 VR 游戏应用程序。
我们扩展了 MERN 骨架应用程序,构建了一个可工作的后端,用于存储 VR 游戏详细信息,并允许我们通过 API 调用来操作这些详细信息。我们添加了 React 视图,让用户可以修改游戏并浏览游戏,有选项在由服务器直接渲染的指定路由上启动和玩 VR 游戏。
最后,我们通过从传入的 URL 中检索查询参数并使用 fetch 通过游戏 API 检索数据,更新了 React 360 项目代码,以便在 MERN 应用程序和 VR 游戏视图中传递数据。
将 React 360 代码与 MERN 堆栈应用程序集成产生了功能齐全且动态的基于 Web 的 VR 游戏应用程序,展示了如何使用和扩展 MERN 堆栈技术来创建独特的用户体验。您可以将这里揭示的能力应用于构建自己的 VR 增强全栈 Web 应用程序。
在下一章中,我们将回顾本书中构建的全栈 MERN 应用程序,讨论不仅遵循的最佳实践,还有改进和进一步发展的空间。
第十九章:MERN 的进一步发展
在这部分,我们总结了书中涵盖的课程,并加入了可以进一步改善 MERN 堆栈应用开发的额外概念。
本节包括以下章节:
第十五章,遵循最佳实践和进一步开发 MERN
第二十章:遵循最佳实践并进一步开发 MERN
在本章中,我们将详细阐述在构建本书中的六个 MERN 应用程序时应应用的一些最佳实践。此外,我们还将探讨本书未应用但应考虑用于实际应用的其它实践,以确保随着复杂性的增长,可靠性和可扩展性。我们将回顾组织项目代码在模块中的决策,应用前端样式的途径,仅对选定视图进行服务器端渲染,以及如何通过 React 接口来管理组件间的状态。我们还将探讨提高安全性、向项目添加测试以及使用 webpack 优化打包的方法。最后,我们将总结增强构建的应用程序的建议和扩展步骤。有了这些见解,你将更好地为准备面向现实世界的全栈 MERN 项目做好准备。
本章涵盖以下主题:
-
在应用程序结构中通过模块化实现关注点分离
-
考虑 CSS 样式解决方案的选项
-
仅对选定视图进行数据的服务器端渲染
-
使用 ES6 类来区分有状态组件和纯函数组件
-
决定是否使用 Redux 或 Flux
-
为存储用户凭据提供安全增强
-
编写测试代码
-
优化包大小
-
如何向现有应用程序添加新功能
通过模块化实现关注点分离
在本书构建 MERN 堆栈应用程序时,我们遵循了每个应用程序的共同文件夹结构。我们通过根据相关性及共同功能划分和分组代码来采用模块化方法。创建这些较小且独立的代码部分背后的想法是确保每个部分都解决一个单独的问题,这样各个部分就可以被重用,以及独立开发和更新。在下一节中,我们将回顾这个结构和其优势。
重新审视应用程序文件夹结构
在应用程序文件夹结构中,我们通过在这两个部分内部进行进一步细分,将客户端和服务器端代码分开。这给了我们一定的自由度来独立设计和构建应用程序的前端和后端。在项目根级别,client和server文件夹是主要的划分,如下所示的结构:
| mern_application/
| -- client/
| -- server/
在这些client和server文件夹中,我们将代码进一步细分为映射到独特功能的子文件夹。我们通过在服务器中按特定功能划分模型、控制器和路由,并在客户端将所有与功能相关的组件分组在一个地方来实现这一点。在接下来的章节中,我们将回顾server和client文件夹内的划分。
服务器端代码
在服务器端,我们根据功能划分代码,通过将定义业务模型的代码与实现路由逻辑的代码以及响应这些路由上客户端请求的控制器代码分离。在server文件夹中,我们保持了三个主要部分,如下所示的结构:
| -- server/
| --- controllers/
| --- models/
| --- routes/
在这种结构中,每个文件夹都包含具有特定目的的代码:
-
models:这个文件夹旨在包含所有 Mongoose 模式模型定义的单独文件,每个文件代表一个单独的模型。 -
routes:这个文件夹包含了所有允许客户端与服务器交互的路由,这些路由被放置在单独的文件中,这些文件可能与models文件夹中的某个模型相关联。 -
controllers:这个文件夹包含了所有控制器函数,这些函数定义了响应在定义的路由上接收到的请求的逻辑。这些控制器被分为与相关模型和路由文件对应的单独文件。
如本书中所示,这些针对服务器端代码的具体关注点分离,使我们能够通过仅添加所需的模型、路由和控制器文件来扩展为骨架应用程序开发的服务器。在下一节中,我们将介绍客户端代码结构中的划分。
客户端代码
MERN 应用程序的客户端代码主要由 React 组件组成。为了以合理和可理解的方式组织组件代码和相关辅助代码,我们将代码分离到与功能实体或独特功能相关的文件夹中,如下所示的结构:
| -- client/
| --- auth/
| --- core/
| --- post/
| --- user/
| --- componentFolderN/
在前面的结构中,我们将所有与身份验证相关的组件和辅助代码放在了auth文件夹中;常见的和基本的组件,如Home和Menu组件,放在了core文件夹中;然后为所有与帖子相关或用户相关的组件创建了post和user文件夹。
这种基于功能分离和组合组件的方法,使我们能够通过向client文件夹添加新的与功能相关的组件代码文件夹,按需扩展每个后续应用程序的骨架应用程序的前端视图。
将客户端和服务器代码分离,并在这些分离中模块化代码,使得扩展我们在整本书中开发的不同应用程序变得更加容易。在本章的最后部分,我们将进一步展示这种模块化方法分离应用程序代码的优势,我们将概述可以遵循的一般工作流程,以向本书中开发的任何现有应用程序添加新功能。在下一节中,我们将探讨定义和应用于前端 React 组件的不同选项,这对于每个全栈 MERN 项目都是一个必要的决策。
添加 CSS 样式
当讨论本书中应用程序的用户界面(UI)实现时,我们选择不关注应用的 CSS 样式代码的细节,而是主要依赖默认的 Material-UI 样式。然而,鉴于实现任何 UI 都需要我们考虑样式解决方案,我们将简要地查看一些可用的选项。
当涉及到向前端添加 CSS 样式时,有几种选择,每种选择都有其优缺点。在接下来的几节中,我们将讨论两种最常见的选择,即外部样式表和内联样式,以及相对较新的在 JavaScript 中编写 CSS 的方法,或者更具体地说,是 JSS,它在 Material-UI 组件中使用,因此也适用于本书中的应用程序。
外部样式表
外部样式表允许我们在单独的文件中定义 CSS 规则,这些文件可以注入到必要的视图中。以这种方式将 CSS 样式放在外部样式表中曾经被认为是最佳实践,因为它强制了样式和内容的分离,允许重用,并且如果为每个组件创建单独的 CSS 文件,还可以保持模块化。
然而,随着 Web 开发技术的不断发展,更好的 CSS 组织和性能需求已不再满足这种做法。例如,在用 React 组件开发前端视图时使用外部样式表限制了我们对基于组件状态更新样式的控制。此外,为 React 应用程序加载外部 CSS 需要额外的 webpack 配置,包括css-loader和style-loader。
当应用程序增长并共享多个样式表时,由于 CSS 有一个单一的全球命名空间,因此也变得无法避免选择器冲突。因此,尽管外部样式表可能足够用于简单和琐碎的应用程序,但随着应用程序的增长,使用 CSS 的其他选项变得更加相关。在下一节中,我们将探讨直接内联添加样式的选项。
内联样式
内联 CSS 是直接应用于视图中的单个元素的样式。尽管这解决了一些使用外部样式表时遇到的问题,例如消除选择器冲突问题,并允许基于状态的样式,但它牺牲了可重用性,并引入了一些自身的问题,例如限制了可以应用的 CSS 功能。
仅使用内联 CSS 进行基于 React 的前端开发,对于增长中的应用程序来说,有一些重要的限制,例如性能不佳,因为所有的内联样式在每个渲染时都会重新计算,而且内联样式一开始就比类名慢。
内联 CSS 在某些情况下可能看起来是一个简单的解决方案,但它并不适合整体使用。在下一节中,我们将探讨使用 JavaScript 添加 CSS 样式的选项,这解决了使用内联和外部样式的一些问题。
JavaScript 样式表(JSS)
JSS 允许我们以声明式的方式使用 JavaScript 编写 CSS 样式。这也意味着 JavaScript 的所有功能现在都可以用于编写 CSS,使得编写可重用和可维护的样式代码成为可能。
JSS 充当一个 JS 到 CSS 编译器,它接受 JS 对象,其中键代表类名,值代表相应的 CSS 规则,然后生成带有作用域类名的 CSS。
这样,当 JSS 将 JSON 表示编译为 CSS 时,它默认生成唯一的类名,消除了可能遇到的外部样式表的选择器冲突。此外,与内联样式不同,使用 JSS 定义的 CSS 规则可以在多个元素之间共享,并且可以在定义中使用所有 CSS 功能。
Material-UI 使用 JSS 为其组件设置样式,因此我们使用 JSS 来应用 Material-UI 主题,并将自定义 CSS 应用到所有应用程序的前端视图开发的组件中。根据每种方法的实用性,您可以选择使用外部样式表、内联样式或 JSS 的组合来为您的全栈应用程序的前端设置样式。在下一节中,我们将回顾在 MERN 全栈应用程序中集成 React 前端服务器端渲染的方法及其相关性。
带有数据的选择性服务器端渲染
当我们在第四章中开发基础骨架应用程序的前端时,添加 React 前端以完成 MERN,我们集成了基本的服务器端渲染,以便在请求发送到服务器时直接从浏览器地址栏加载客户端路由。在这个服务器端渲染实现中,在渲染 React 组件的服务器端时,我们没有考虑为显示数据的组件从数据库加载数据。数据仅在客户端 JavaScript 在服务器端渲染标记的初始加载之后接管时才加载到这些组件中。
我们确实更新了这个实现,为 MERN Mediastream 应用程序中的单个媒体详情页面添加了带有数据的服务器端渲染,这在第十二章中讨论过,定制媒体播放器和改进 SEO。在这种情况下,我们决定通过将数据注入到 React 前端生成的服务器端标记中,以数据的方式渲染这个特定的视图。这种仅针对特定视图的选择性服务器端渲染数据背后的推理可以基于对所讨论视图的某些期望行为,如下一节所述。
何时使用带有数据的服务器端渲染相关?
在应用程序的所有 React 视图中实现带有数据的服务器端渲染可能会变得复杂,如果需要考虑具有客户端身份验证的视图或由多个数据源组成的视图,这将是额外的工作。在许多情况下,如果视图不需要带有数据的服务器端渲染,那么处理这些复杂性可能是不必要的。为了判断一个视图是否需要带有数据的服务器端渲染,针对特定的视图回答以下问题以做出决定:
-
当浏览器中可能没有 JavaScript 时,数据是否需要在视图的初始加载中显示?
-
视图及其数据需要 SEO 友好吗?
在页面初始加载时加载数据可能从可用性角度来看是相关的,所以这实际上取决于特定视图的使用场景。对于 SEO,带有数据的服务器端渲染将使搜索引擎更容易访问视图中的数据内容;因此,如果这对于所讨论的视图至关重要,那么添加带有数据的服务器端渲染是一个好主意。在下一节中,我们将讨论在全栈应用程序中组合 React 前端的各种方法。
使用有状态与纯函数组件
在使用 React 组件构建 UI 时,使用更多无状态功能组件来组合视图可以使前端代码易于管理、清洁且易于测试。然而,某些组件将需要状态或生命周期 Hooks 来成为比纯展示组件更多的组件。在接下来的几节中,我们将探讨构建有状态和无状态功能 React 组件需要什么,何时使用哪一个,以及频率。
使用 ES6 类或 Hooks 的有状态 React 组件
我们可以使用 ES6 类或通过不编写类的方式使用 Hooks 来定义有状态 React 组件。使用 ES6 类定义的 React 组件可以访问生命周期方法、this 关键字,并在构建有状态组件时使用 setState 来管理状态。同样,使用函数定义的 React 组件也可以通过 Hooks 访问一些这些特性,例如使用 useState Hook 来管理状态,以便构建有状态组件。
有状态组件使我们能够构建可以管理状态中数据变化并传播需要应用于 UI 的任何业务逻辑的交互式组件。通常,对于复杂的 UI,有状态组件应该是更高级的容器组件,管理它们所组成的较小、无状态功能组件的状态。相比之下,这些更简单的无状态组件可以定义为纯函数,如下一节所述。
无状态 React 组件作为纯函数
React 组件可以使用 ES6 类语法或作为纯函数来定义为无状态功能组件。主要思想是,无状态组件不修改状态,只接收 props。
以下代码使用 ES6 类语法定义了一个无状态组件:
class Greeting extends React.Component {
render() {
return <h1>Hello, {this.props.name}</h1>
}
}
这个组件虽然是用类定义的,但不使用状态。同样的组件也可以使用 JavaScript 纯函数来定义,如下所示:
function Greeting(props) {
return <h1>Hello, {props.name}</h1>
}
纯函数在给定相同输入且没有任何副作用的情况下始终给出相同的输出。将 React 组件建模为纯函数强制创建更小、更定义、更自包含的组件,强调 UI 而不是业务逻辑,因为这些组件中没有状态操作。这类组件是可组合的、可重用的,并且易于调试和测试。在下文中,我们将讨论在设计 UI 时如何组合状态组件和无状态组件。
使用状态组件和无状态功能组件设计 UI
当思考 UI 的组件组合时,你可以将根组件或父组件设计为包含子组件的状态组件,或者设计为只接收 props 而不能操作状态的可组合组件。所有状态更改操作和生命周期问题将由根组件或父组件处理,并将更改传播到子组件。
在为本书开发的应用程序中,存在状态组件和较小无状态组件的混合。例如,在 MERN 社交应用中,Profile 组件会修改无状态子组件的状态,例如 FollowProfileButton 和 FollowGrid 组件。有空间将本书中开发的一些较大组件重构为更小、更自包含的组件,在扩展应用程序以包含更多功能之前应考虑这一点。
可以应用于新组件设计或重构现有组件的主要经验教训是,随着 React 应用程序的扩展和复杂化,最好在负责管理内部组件状态的高级状态组件中添加更多无状态功能组件。在下文中,我们将简要讨论可以在 React 上使用以处理增长中的 React 应用程序状态管理的流行库和模式。
使用 Redux 或 Flux
当 React 应用程序开始增长并变得更加复杂时,管理组件之间的通信可能会变得有问题。在使用常规 React 时,通信的方式是将值和回调函数作为 props 传递给子组件。然而,如果有很多中间组件需要传递回调,这可能会很繁琐。为了解决随着 React 应用程序增长而出现的这些状态通信和管理相关的问题,人们转向使用带有 Redux 和 Flux 等库和架构模式的 React。
本书范围之外,不深入探讨将 React 与 Redux 库或 Flux 架构集成的细节,但在考虑扩展 MERN 应用程序时,请记住以下几点:
-
Redux 和 Flux 利用模式强制在 React 应用程序中从中央位置更改状态。对于可管理的 React 应用程序,避免使用 Redux 或 Flux 的一个技巧是将所有状态更改移动到组件树的上层父组件。
-
较小的应用程序在没有 Flux 或 Redux 的情况下也能正常工作。
你可以在redux.js.org/了解更多关于使用 Redux 与 React 结合的信息,以及如何在facebook.github.io/flux/中使用 React 与 Flux 结合。
你可以根据应用程序的大小和复杂性选择集成 Flux 或 Redux。在下一节中,我们将讨论本书开发的 MERN 应用程序中应用的安全实现,以及可能进行的改进。
增强安全性
在本书开发的 MERN 应用程序中,我们通过使用JSON web tokens(JWTs)作为认证机制,并在用户集合中存储散列密码,使与认证相关的安全实现保持简单。这些实现中遵循的方法是向 Web 应用程序添加认证的标准做法。然而,如果某些应用程序需要更多的安全层,则还有更高级的选项可供选择。在接下来的章节中,我们将讨论为构建本书中的应用程序所做的安全选择,并指出可能的改进。
JSON web tokens – 客户端或服务器端存储
使用 JWT 认证机制,客户端负责维护用户状态。一旦用户登录,服务器发送的令牌被存储并由客户端代码在浏览器存储中维护,例如sessionStorage。因此,客户端代码也有责任在用户注销或需要注销时通过删除令牌来使令牌失效。这种机制对于大多数只需要最小认证来保护资源访问的应用程序来说效果很好。然而,对于可能需要跟踪用户登录、注销,并让服务器知道特定令牌不再有效的情况,仅客户端处理令牌是不够的。
对于这些情况,讨论的客户端 JWT 令牌处理实现可以扩展到服务器端存储。在特定情况下,为了跟踪已失效的令牌,服务器可以维护一个 MongoDB 集合来存储这些失效的令牌作为参考,这与在服务器端存储会话数据的方式相似。
需要小心的是,在客户端和服务器端存储和维护与身份验证相关的信息在大多数情况下可能是过度行为。因此,这完全取决于具体的用例和相关权衡。在下一节中,我们将回顾我们存储用户密码的安全选项。
保护密码存储
在用户集合中存储用于身份验证的用户凭据时,我们确保用户提供的原始密码字符串从未直接存储在数据库中。相反,我们使用 Node 中的crypto模块生成密码的哈希值和盐值。
在我们的应用程序的user.model.js中,我们定义了以下函数来生成加密的password和salt值:
encryptPassword: function(password) {
if (!password) return ''
try {
return crypto
.createHmac('sha1', this.salt)
.update(password)
.digest('hex')
} catch (err) {
return ''
}
},
makeSalt: function() {
return Math.round((new Date().valueOf() * Math.random())) + ''
}
使用此实现,每次用户输入密码进行登录时,都会使用盐值生成一个哈希值。如果生成的哈希值与存储的哈希值匹配,则密码正确;否则,密码错误。因此,为了检查密码是否正确,需要盐值,因此它将盐值与哈希值一起存储在数据库中,与用户详细信息一起。
这是保护存储在用户身份验证中的密码的标准做法,但如果有特定的应用程序需要更高级别的安全性,可以探索其他高级方法。可以考虑的一些选项包括多迭代哈希方法、其他安全的哈希算法、限制每个用户账户的登录尝试次数以及多级认证,包括回答安全问题或输入安全代码等额外步骤。根据需要,这些选项可以增加更多安全层。在下一节中,我们将讨论在全栈 React 应用程序中添加测试代码的选项,这对于构建健壮的生产就绪应用程序至关重要。
编写测试代码
虽然讨论和编写测试代码超出了本书的范围,但它却是开发可靠软件的关键部分。随着全栈 JavaScript 应用程序越来越主流,对更好的测试能力的需求在这个生态系统中产生了大量测试工具。在接下来的几节中,我们将首先查看一些可用于测试基于 MERN 应用程序不同部分的流行测试工具。然后,为了帮助您开始编写本书中开发的 MERN 应用程序的测试代码,我们还将讨论一个将客户端测试添加到第五章中 MERN 社交应用程序的例子,即将骨架扩展成社交媒体应用程序。
全栈 JavaScript 项目的测试工具
对于全栈 JavaScript 项目,有一系列测试工具可用于集成测试和维护代码质量。这些工具包括可以帮助执行代码静态分析以保持可读性的工具,以及帮助在 MERN 基础应用程序中集成单元测试、集成测试和端到端测试的工具。在接下来的几节中,我们将突出介绍一些可用于本书中项目的流行测试工具,例如用于静态分析的 ESLint、用于前端测试的 Cypress 和用于 JavaScript 应用程序全面测试的 Jest。
使用 ESLint 进行静态分析
提高和维护代码质量的一个好习惯是使用 linting 工具与您的项目一起使用。linting 工具对代码执行静态分析,以查找违反指定规则和指南的问题模式或行为。在 JavaScript 项目中进行 linting 可以提高整体代码的可读性,并帮助您在代码执行之前找到语法错误。对于 MERN 基础项目中的 linting,您可以探索 ESLint,这是一个允许开发者创建自己的 lint 规则的 JavaScript linting 工具。
您可以在 eslint.org 上了解更多关于使用和自定义 ESLint 的信息。您可以选择使用 Airbnb JavaScript 风格指南 (github.com/airbnb/javascript),并通过 eslint-config-airbnb. 定义您的 lint 规则。
您可以在您偏好的编辑器中配置 ESLint,并使其成为您开发工作流程中无缝的一部分。这将帮助您在编写代码时保持代码标准。在下一节中,我们将探讨 Cypress,它可以帮助测试在浏览器中运行的任何代码。
使用 Cypress 进行端到端测试
Cypress 为测试现代网络应用程序的前端提供了一套完整的工具。使用 Cypress,我们可以为我们的 MERN 基础应用程序的前端编写端到端测试、单元测试和集成测试。Cypress 还提供自己的本地安装的测试运行器,允许我们在构建应用程序的同时在浏览器中实时编写、运行测试和调试。
您可以在 cypress.io 上了解更多关于使用 Cypress 的信息,以开始设置在浏览器中为 JavaScript 应用程序设置端到端测试。
使用 Cypress 进行 UI 测试将使您更有信心地发布项目,因为您将能够在最终用户遇到之前更早地捕捉到更多错误。在下一节中,我们将讨论 Jest,它可以用于向任何 JavaScript 代码库添加测试。
使用 Jest 进行全面测试
Jest 是一个全面的 JavaScript 测试框架。尽管它更常被用于测试 React 组件,但它可以用于任何 JavaScript 库或框架的通用测试。在 Jest 的众多 JavaScript 测试解决方案中,它提供了模拟和快照测试的支持,附带一个断言库,并且 Jest 中的测试以 行为驱动开发(BDD)风格编写。
要了解更多关于 Jest 的信息,请阅读位于 facebook.github.io/jest/docs/en/getting-started.html 的文档。
除了测试 React 组件外,Jest 还可以适应编写基于 Node-Express-Mongoose 的后端测试代码。因此,它是为 MERN 应用程序添加测试代码的一个可靠的测试选项。在下一节中,我们将探讨如何使用 Jest 向在 第五章 中开发的 MERN 社交应用程序添加测试,该应用程序名为 将骨架扩展成社交媒体应用程序。
向 MERN 社交应用添加测试
为了演示如何开始向 MERN 应用程序添加测试,我们将设置 Jest 并使用它向 MERN 社交应用程序添加客户端测试。在定义测试用例、编写和运行相应的测试代码之前,首先,我们将通过安装必要的包、定义测试运行脚本和创建测试代码文件夹来设置测试环境,如以下章节所述。
安装包
为了设置 Jest 并将测试代码与我们的项目集成,我们首先需要安装相关的 Node 包。以下包将用于编写测试代码和运行测试:
-
jest:用于包含 Jest 测试框架 -
babel-jest:用于编译 Jest 的 JS 代码 -
react-test-renderer:用于创建不使用浏览器的 React DOM 渲染的 DOM 树快照
要将它们作为 devDependencies 安装,请在命令行中运行以下 yarn 命令:
yarn add --dev jest babel-jest react-test-renderer
一旦安装了这些包,我们就可以在配置测试运行脚本之后开始添加测试,如下一节所述。
定义运行测试的脚本
为了运行我们使用 Jest 编写的任何测试代码,我们将定义一个脚本来运行测试。我们将更新 package.json 中定义的运行脚本,以便添加一个使用 jest 命令运行测试的脚本,如下面的代码所示:
"scripts": {
"test": "jest"
}
定义了此脚本后,如果我们从命令行运行 yarn test,它将提示 Jest 在应用程序文件夹中查找测试代码并运行测试。在下一节中,我们将添加包含项目测试代码文件的文件夹。
添加测试文件夹
要在 MERN 社交应用中添加客户端测试,我们将在客户端文件夹中创建一个名为tests的文件夹,该文件夹将包含与测试 React 组件相关的测试文件。当运行测试命令时,Jest 将在这些文件中查找测试代码。
此示例的测试用例将是 MERN 社交应用前端Post组件的测试,我们将在名为post.test.js的文件中添加对Post组件的测试。此文件将放置在tests文件夹中。现在我们已经准备好添加测试代码的文件,在下一节中,我们将演示如何添加一个示例测试用例。
添加测试
对于 MERN 社交应用,我们将编写一个测试来检查帖子上的删除按钮是否仅在登录用户也是帖子创建者时可见。这意味着如果认证用户的_id与正在渲染的帖子数据的postedby值相同,则删除按钮才会是渲染的 Post 视图的一部分。
为了实现此测试用例,我们将添加处理以下内容的代码:
-
定义了一个帖子和一个包含认证用户详情的
auth对象的虚拟数据 -
模拟
auth-helper.js中的方法 -
定义测试,并在测试定义中执行以下操作:
-
声明
post和auth变量 -
将模拟的
isAuthenticated方法的返回值设置为虚拟auth对象 -
使用
renderer.create创建带有所需虚拟属性并通过MemoryRouter包装的Post组件,以提供与react-router相关的属性 -
生成并匹配快照
-
post.test.js中的代码将按照以下步骤进行:
import auth from './../auth/auth-helper.js'
import Post from './../post/Post.js'
import React from 'react'
import renderer from 'react-test-renderer'
import { MemoryRouter } from 'react-router-dom'
jest.mock('./../auth/auth-helper.js')
const dummyPostObject = {"_id":"5a3cb2399bcc621874d7e42f",
"postedBy":{"_id":"5a3cb1779bcc621874d7e428",
"name":"Joe"}, "text":"hey!",
"created":"2017-12-22T07:20:25.611Z",
"comments":[], "likes":[]}
const dummyAuthObject = {user: {"_id":"5a3cb1779bcc621874d7e428",
"name":"Joe",
"email":"abc@def.com"}}
test('delete option visible only to authorized user', () => {
const post = dummyPostObject
const auth = dummyAuthObject
auth.isAuthenticated.mockReturnValue(auth)
const component = renderer.create(
<MemoryRouter>
<Post post={post} key={post._id} ></Post>
</MemoryRouter>
)
let tree = component.toJSON()
expect(tree).toMatchSnapshot()
})
在此代码中,我们首先定义了虚拟帖子post和auth对象,然后添加了检查删除选项可见性的测试用例。在这个测试用例中,我们模拟了isAuthenticated方法,并使用虚拟帖子数据渲染了Post组件。然后,我们使用此渲染组件生成了一个快照,该快照将与预期的快照进行匹配。在下一节中,我们将讨论生成的快照是如何在这个测试中进行比较的。
生成正确的 Post 视图快照
第一次运行此测试时,我们将提供生成 Post 视图正确快照所需的值。对于此测试用例的正确快照将包含删除按钮,当auth对象的user._id等于post对象的postedBy值时。此快照是在测试第一次运行时生成的,它将在未来的测试执行中进行比较。
Jest 中的这种快照测试基本上记录了渲染组件结构的快照,以与未来的渲染进行比较。当记录的快照和当前的渲染不匹配时,测试失败,表明有东西发生了变化。在下一节中,我们将介绍运行测试和检查测试输出的步骤。
运行和检查测试
在我们添加到 post.test.js 文件中的代码中,模拟的 auth 对象和 post 对象指向同一个用户;因此,在命令行中运行此测试将提示 Jest 生成一个包含删除选项的快照,并通过测试。
要运行测试,请从命令行进入项目文件夹:
yarn test
当运行此命令时,生成的测试输出将显示测试已通过,如下面的截图所示:

当此测试首次成功运行时,生成的记录快照会自动添加到 tests 文件夹中的 _snapshots_ 文件夹。这个快照代表了删除按钮在视图中渲染的状态,因为认证用户也是帖子的创建者。
现在,我们可以检查当组件以不是帖子创建者的认证用户渲染时,测试是否实际上失败了。为了执行此检查,我们将通过更改 user._id 来更新模拟数据对象,使其不匹配 postedBy 值,然后再次运行测试。这将给我们一个失败的测试,因为当前的渲染将不再有记录快照中存在的删除按钮。
如以下测试日志所示,测试失败,并指示渲染的树与记录的快照不匹配,因为接收到的值中缺少表示删除按钮的元素:

我们有一个客户端测试来检查已登录用户是否可以在他们的帖子中查看删除按钮。使用这个设置,可以为利用 Jest 功能的 MERN 应用程序添加更多测试。
编写测试代码将使你开发的应用程序可靠,并有助于确保代码质量。使用 ESLint、Cypress 和 Jest 等工具,我们可以采用不同的方式来确保基于 MERN 的应用程序的整体质量。在下一节中,我们将讨论优化应用程序代码打包的方法。
优化包大小
随着你开发和扩展 MERN 应用程序,webpack 产生的包的大小也可能增长,尤其是如果使用了大型第三方库。更大的包大小会影响性能并增加应用程序的初始加载时间。我们可以通过修改代码来确保我们不会得到大的包,并利用 webpack 中打包的功能来帮助优化打包。
在进入代码以更新它以进行包大小优化之前,您还可以熟悉 webpack 的一部分默认优化选项。在 MERN 应用程序中,我们使用了 mode 配置来利用开发和生产模式的默认设置。要查看可用选项的概述,请参阅文章 medium.com/webpack/webpack-4-mode-and-optimization-5423a6bc597a。
在下一节中,我们将强调代码拆分和动态导入等概念,这些概念可以让我们控制生成更小的包并减少加载时间。
代码拆分
我们不是一次性将所有代码加载到一个包中,而是可以使用 webpack 支持的代码拆分功能按需懒加载应用程序代码的一部分。在修改应用程序代码以引入代码拆分后,webpack 可以创建多个包而不是一个大的包。这些包可以在运行时动态加载,从而提高应用程序的性能。
要了解更多关于 webpack 中的代码拆分支持以及如何对设置和配置进行必要的更改,请参阅文档中的指南 webpack.js.org/guides/code-splitting/。
有几种方法可以为应用程序代码引入代码拆分,但您将遇到的最重要语法是动态 import()。在下一节中,我们将探讨如何使用 import() 与我们的 MERN 应用程序。
动态导入()
Dynamic import() 是常规导入的类似函数版本,它允许动态加载 JS 模块。使用 import(moduleSpecifier) 将返回请求模块的模块命名空间对象的承诺。当使用常规静态导入时,我们在代码顶部导入一个模块,然后在代码中使用它如下:
import { convert } from './metric'
...
console.log(convert('km', 'miles', 202))
与此相反,如果我们使用动态 import() 而不是在开头添加静态导入,代码将看起来像这样:
import('./metric').then({ convert } => {
console.log( convert('km', 'miles', 202) )
})
这允许我们在代码需要时导入和加载模块。在打包应用程序代码时,webpack 将将 import() 的调用视为拆分点,并自动通过将请求的模块及其子模块放入主包之外的一个单独的块中来实现代码拆分。
为了通过在给定组件处应用代码拆分来优化前端 React 代码的打包,我们需要将动态 import() 与 React Loadable 配对——这是一个用于使用承诺加载组件的高阶组件。作为一个例子,我们将查看第八章 扩展订单和支付的市场 中开发的购物车。在构建购物车界面时,我们通过导入并添加 Checkout 组件到视图中来组合 Cart 组件,如下所示:
import Checkout from './Checkout'
class Cart extends Component {
...
render(){
...
<Checkout/>
}
...
}
要在此处引入代码拆分并动态导入Checkout组件,我们可以将开始的静态导入替换为Loadable Checkout,如下所示:
import Loadable from 'react-loadable'
const Checkout = Loadable({
loader: () => import('./Checkout'),
loading: () => <div>Loading...</div>,
})
进行此更改并使用 webpack 重新构建代码将生成一个更小的bundle.js文件,并生成另一个表示拆分代码的更小的 bundle 文件,该文件现在仅在Cart组件渲染时加载。
除了使用动态导入之外,基于路由的代码拆分也是另一种选择。对于使用路由在视图中加载组件的 React 应用程序,这是一种有效的引入代码拆分的方法。要了解更多关于实现代码拆分的信息,特别是关于 React Router,请查看tylermcginnis.com/react-router-code-splitting/上的文章。
我们可以根据需要在我们应用程序代码中应用代码拆分机制。需要注意的是,有效的代码拆分将取决于正确使用它并在代码的正确位置应用它——这些位置将从资源加载优先级优化中受益。在下一节中,我们将概述可以重复执行的步骤,以添加本书中开发的 MERN 应用程序的新功能。
扩展应用程序
在本书的章节中,随着我们开发每个应用程序,我们通过扩展现有代码的常见和可重复的步骤添加了功能。在本节的最后,我们将回顾这些步骤,为添加更多功能到当前版本的应用程序设定一个指南。
扩展服务器代码
对于需要数据持久性和 API 以允许视图操作数据的特定功能,我们可以从扩展服务器代码和添加必要的模型、路由和控制器函数开始,如下文所述。
添加模型
对于该功能的数据库持久性方面,在设计数据模型时考虑需要存储的字段和值。然后,在单独的文件中定义并导出一个 Mongoose 模式,并将其放置在server/models文件夹中。在定义好数据结构并准备好数据库后,你可以继续添加操作此数据的 API 端点,如下一节所述。
实现 API
为了根据模型操纵和访问将存储在数据库中的数据,你需要设计与所需功能相关的 API。要开始实现 API,你必须添加相应的控制器方法和路由声明,如下文所述。
添加控制器
确定了 API 之后,在server/controllers文件夹中的单独文件中添加相应的控制器函数,这些函数将响应对这些 API 的请求。这个文件中的控制器函数应访问和操作为该功能定义的模型数据。接下来,我们将探讨如何在请求到来时声明将调用这些控制器方法的路由。
添加路由
要完成后端 API 的实现,需要声明相应的路由并将其挂载到 Express 应用程序上。在server/routes文件夹中的单独文件中,首先声明并导出这些 API 的路由,指定在请求特定路由时应执行的相关控制器函数。然后,在server/express.js文件中加载这些新路由,就像应用程序中的其他现有路由一样。
这将生成一个可以由 REST API 客户端应用程序运行和检查的新后端 API 的工作版本。然后,这些 API 可以用于正在开发的特征的前端视图中,您将通过扩展客户端代码来实现这一点,如下一节所述。
扩展客户端代码
在客户端,首先设计所需的视图,并确定这些视图将如何结合用户与该功能相关的数据交互。然后,添加 fetch API 代码以与新的后端 API 集成,定义代表这些新视图的新组件,并更新现有代码以包括这些新组件在前端应用程序中,如以下各节所述。
添加 API fetch 方法
在添加将调用后端 API 的 fetch 方法之前,您将确定放置新前端代码的位置。在客户端文件夹中,创建一个新文件夹来存放与正在开发的特征模块相关的组件和辅助代码。然后,为了将新后端 API 与应用程序的前端集成,在这个新组件文件夹中定义并导出相应的 fetch 方法。最后,您可以填充这个文件夹,其中包含将成为该功能前端的 React 组件,如下一节所述。
添加组件
要开始添加该功能的 UI,您可以在新文件夹中创建并导出代表所需功能视图的新 React 组件,这些组件位于单独的文件中。如果需要身份验证,您可以使用现有的 auth-helper 方法将这些新组件集成到其中。一旦实现了 React 组件,它们需要被加载到主应用程序视图中,如下一节所述。
加载新组件
为了将这些新组件集成到前端,这些组件要么需要添加到现有组件中,要么在它们自己的客户端路由中渲染。
如果这些新组件需要在单独的路由上渲染,更新MainRouter.js代码以添加新路由,这些路由在给定的 URL 路径上加载这些组件。然后,这些 URL 可以用作链接,从应用的其他视图中加载组件,或者直接通过浏览器地址栏访问这些 URL。
然而,如果新组件需要成为现有视图的一部分,那么将组件导入现有组件中,以按需将它们添加到视图中。
新组件也可以通过链接到带有单独路由的新组件,与现有组件(如Menu组件)相关联。
随着组件集成并连接到后端,新功能实现完成。这些步骤可以重复使用,以向本书中构建的现有 MERN 应用添加更多新功能。
摘要
在本章的最后,我们回顾并详细阐述了在构建本书中的 MERN 应用时使用的最佳实践,突出了改进领域,提供了解决应用增长时可能出现的问题的指导,并最终确定了继续开发现有应用更多功能的步骤。
我们看到,模块化应用代码结构有助于轻松扩展应用,选择使用 JSS 而不是内联 CSS 和外部样式表,使样式代码易于管理和操作,并且仅在需要时实现特定视图的服务器端渲染,从而避免了代码中的不必要复杂性。
我们讨论了创建更少的状态组件(由更小、更明确的非状态功能性组件组成)的好处,以及如何在重构现有组件或设计新组件以扩展应用时应用这一点。对于可能遇到在数百个组件之间管理和通信状态问题的增长型应用,我们指出了如 Redux 和 Flux 等选项,这些选项可以考虑用来解决这些问题。
对于可能对更严格的安全执行有更高要求的用例,我们回顾了现有的基于 JWT 的用户身份验证和密码加密实现,并讨论了可能的扩展以增强安全性。
我们强调了测试工具,如 ESLint、Cypress 和 Jest。然后,我们使用 Jest 演示了如何向 MERN 应用添加测试代码,并讨论了如何通过编写测试代码和使用代码检查工具等良好实践来提高代码质量,同时确保应用的可靠性。
我们还研究了捆绑优化功能,如代码拆分,这些功能可以通过减少初始捆绑大小以及按需懒加载应用的部分来帮助提高性能。
最后,我们回顾并记录了整本书中使用的可重复步骤,您可以用这些步骤作为指南,在前进的过程中通过添加更多所需功能来扩展 MERN 应用程序。


浙公网安备 33010602011771号