React-全栈项目-全-
React 全栈项目(全)
原文:
zh.annas-archive.org/md5/05F04F9004AE49378ED0525C32CB85EB
译者:飞龙
前言
本书探讨了通过将 React 的力量与经过行业测试的服务器端技术(如 Node、Express 和 MongoDB)相结合,开发全栈 JavaScript Web 应用的潜力。JavaScript 领域已经快速增长了一段时间。在这个主题上有大量的选择和资源可用,当你需要从这些经常变化的部分中进行选择、了解它们并使它们一起工作来构建自己的 Web 应用时,很容易迷失。为了解决这一痛点,本书采用了一种实用的方法,帮助您设置和构建使用这种流行的 JavaScript 堆栈的各种工作应用程序。
本书适合的读者
本书面向有一些 React 经验但没有涉及 Node、Express 和 MongoDB 的全栈开发经验的 JavaScript 开发人员,他们希望获得实用的指南,以开始使用这种堆栈构建不同类型的 Web 应用程序。
本书涵盖的内容
第一章,《使用 MERN 释放 React 应用程序》,介绍了 MERN 堆栈技术和本书中开发的应用程序。我们将讨论使用 React、Node、Express 和 MongoDB 开发 Web 应用程序的背景和相关性。
第二章,《准备开发环境》,帮助设置 MERN 堆栈技术以进行开发。我们将探索必要的开发工具,安装 Node、MongoDB、Express、React 和其他所需的库,然后运行代码来检查设置。
第三章,《使用 MongoDB、Express 和 Node 构建后端》,实现了一个骨架 MERN 应用的后端。我们将构建一个独立的服务器端应用程序,其中包括 MongoDB、Express 和 Node,用于存储用户详细信息,并具有用于用户身份验证和 CRUD 操作的 API。
第四章,《添加 React 前端以完成 MERN》,通过集成 React 前端完成了 MERN 骨架应用程序。我们将使用 React 视图实现一个可与服务器上的用户 CRUD 和 auth API 进行交互的工作前端。
第五章,“从简单的社交媒体应用开始”,通过扩展骨架应用程序构建了一个社交媒体应用程序。我们将通过实现社交媒体功能来探索 MERN 堆栈的能力,例如帖子分享、点赞和评论;关注朋友;以及聚合新闻源。
第六章,“通过在线市场锻炼新的 MERN 技能”,在在线市场应用程序中实现了基本功能。我们将实现与买卖相关的功能,支持卖家账户、产品列表和按类别搜索产品。
第七章,“扩展订单和支付的市场”,进一步构建了市场应用程序,包括购物车、订单管理和支付处理。我们将添加购物车功能,允许用户使用购物车中的商品下订单。我们还将集成 Stripe 以收集和处理付款。
第八章,“构建媒体流应用程序”,使用 MongoDB GridFS 实现媒体上传和流媒体。我们将开始构建一个基本的媒体流应用程序,允许注册用户上传视频文件,这些文件将存储在 MongoDB 上并流回,以便观众可以在简单的 React 媒体播放器中播放每个视频。
第九章,“定制媒体播放器和改善 SEO”,通过定制媒体播放器和自动播放媒体列表来升级媒体查看功能。我们将在默认的 React 媒体播放器上实现定制控件,添加可以自动播放的播放列表,并通过为媒体详细信息添加有选择的服务器端渲染和数据来改善 SEO。
第十章,“开发基于 Web 的 VR 游戏”,使用 React 360 开发了一个用于 Web 的 3D 虚拟现实游戏。我们将探索 React 360 的 3D 和 VR 功能,并构建一个简单的基于 Web 的 VR 游戏。
第十一章,使用 MERN 使 VR 游戏动态化,通过扩展 MERN 骨架应用程序并集成 React 360,构建了一个动态的 VR 游戏应用程序。我们将实现一个游戏数据模型,允许用户创建自己的 VR 游戏,并将动态游戏数据与使用 React 360 开发的游戏相结合。
第十二章,遵循最佳实践并进一步开发 MERN,反思了前几章的教训,并提出了进一步基于 MERN 的应用程序开发的改进建议。我们将扩展一些已经应用的最佳实践,比如应用程序结构中的模块化,其他应该应用的实践,比如编写测试代码,以及可能的改进,比如优化捆绑大小。
为了充分利用本书
本书的内容组织假定您熟悉基本的基于 Web 的技术,了解 JavaScript 中的编程构造,并对 React 应用程序的工作原理有一般了解。在阅读本书时,您将了解这些概念在使用 React、Node、Express 和 MongoDB 构建完整的 Web 应用程序时是如何结合在一起的。
为了在阅读各章节时最大限度地提高学习体验,建议您并行运行相关应用程序代码的关联版本,并使用每章提供的相关说明。
下载示例代码文件
您可以从www.packtpub.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便文件直接通过电子邮件发送给您。
您可以按照以下步骤下载代码文件:
-
登录或注册www.packtpub.com。
-
选择“支持”选项卡。
-
单击“代码下载和勘误”。
-
在搜索框中输入书名,然后按照屏幕上的说明操作。
下载文件后,请确保使用以下最新版本的解压缩或提取文件夹:
-
Windows 上的 WinRAR/7-Zip
-
Mac 上的 Zipeg/iZip/UnRarX
-
Linux 上的 7-Zip/PeaZip
该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Full-Stack-React-Projects
。如果代码有更新,将在现有的 GitHub 存储库上进行更新。
我们还有来自我们丰富书籍和视频目录的其他代码包,可在github.com/PacktPublishing/
上找到。去看看吧!
使用的约定
本书中使用了许多文本约定。
CodeInText
:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。例如:"将下载的WebStorm-10*.dmg
磁盘映像文件挂载为系统中的另一个磁盘。"
代码块设置如下:
import path from 'path'
const CURRENT_WORKING_DIR = process.cwd()
app.use('/dist', express.static(path.join(CURRENT_WORKING_DIR, 'dist')))
当我们希望引起您对代码块的特定部分的注意时,相关行或项将以粗体显示:
{
"presets": [
"env",
"stage-2",
"react"
],
"plugins": [
"react-hot-loader/babel"
]
}
任何命令行输入或输出都将按照以下方式书写:
npm install babel-preset-react --save-dev
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会在文本中以这种方式出现。例如:"从管理面板中选择系统信息。"
警告或重要说明会以这种方式出现。提示和技巧会以这种方式出现。
第一章:使用 MERN 释放 React 应用程序
React 可能已经为前端 Web 开发开辟了新的领域,并改变了我们编写 JavaScript 用户界面的方式,但我们仍然需要一个坚实的后端来构建完整的 Web 应用程序。尽管在选择后端技术时有很多选择,但使用完整的 JavaScript 堆栈的好处和吸引力是不可否认的,特别是当有像 Node、Express 和 MongoDB 这样强大且被广泛采用的后端技术时。将 React 的潜力与这些经过行业测试的服务器端技术相结合,可以在开发现实世界 Web 应用程序时创造多样的可能性。
本书将指导您进行基于 MERN 的 Web 开发设置,以构建不同复杂性的实际 Web 应用程序。
在深入开发这些 Web 应用程序之前,我们将在本章中回答以下问题,以便为使用 MERN 设置背景:
-
什么是 MERN 堆栈?
-
为什么 MERN 如今仍然相关?
-
MERN 何时适合开发 Web 应用程序?
-
这本书如何组织以帮助掌握 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/
。
Express
Express 是一个用于构建带有 Node 服务器的 Web 应用程序和 API 的基本框架。它提供了一层简单的基本 Web 应用程序功能,可以补充 Node。
在使用 Node 开发的任何 Web 应用程序中,Express 可以用作路由和中间件 Web 框架,具有自己的最小功能-Express 应用程序本质上是一系列中间件函数调用。
中间件函数是具有对 HTTP 请求和响应对象的访问权限,以及 Web 应用程序请求-响应周期中的下一个中间件函数的访问权限的函数。
可以将几乎任何兼容的中间件插入到请求处理链中,几乎可以按任何顺序进行,使 Express 非常灵活易用。
在expressjs.com上了解 Express.js 的可能性。
MongoDB
在决定用于任何应用程序的 NoSQL 数据库时,MongoDB 是首选。它是一个面向文档的数据库,可以将数据存储在灵活的类 JSON 文档中。这意味着字段可以在文档之间变化,并且数据模型可以随着应用程序要求的变化而随时间演变。
将高可用性和可扩展性放在首位的应用程序受益于 MongoDB 的分布式架构功能。它内置支持高可用性,使用分片进行水平扩展,并且可以跨地理分布进行多数据中心的可扩展性。
MongoDB 具有表达丰富的查询语言,可以进行即席查询,索引以实现快速查找,并提供实时聚合,从而提供了强大的访问和分析数据的方式,即使数据量呈指数级增长,也能保持性能。
在www.mongodb.com/
上探索 MongoDB 的功能和服务。
React
React 是一个声明式的、基于组件的 JavaScript 库,用于构建用户界面。它的声明式和模块化特性使开发人员能够轻松创建和维护可重用、交互式和复杂的用户界面。
如果使用 React 构建,显示大量变化数据的大型应用程序可以快速响应,因为它会在特定数据更改时高效地更新和渲染正确的 UI 组件。React 通过其对虚拟 DOM 的显著实现进行高效渲染,这使其与其他处理页面更新的 Web UI 库有所区别,后者直接在浏览器的 DOM 中进行昂贵的操作。
使用 React 开发用户界面也迫使前端程序员编写合理和模块化的代码,这些代码是可重用的,更容易调试、测试和扩展。
在reactjs.org/
上查看有关 React 的资源。
由于所有四种技术都是基于 JavaScript 的,它们本质上都是为集成进行了优化。然而,实际上如何将它们组合在一起形成 MERN 堆栈可能会根据应用程序要求和开发者偏好而有所不同,使 MERN 可以根据特定需求进行定制和扩展。
MERN 的相关性
自 JavaScript 诞生以来,它已经走过了很长的路,而且它还在不断发展。MERN 堆栈技术挑战了现状,并为 JavaScript 的可能性开辟了新的领域。但是,当涉及到开发需要可持续的真实应用程序时,选择 MERN 是否是一个值得的选择呢?以下简要概述了选择 MERN 作为下一个 Web 应用程序的强有力理由。
技术堆栈的一致性
由于 JavaScript 一直在使用,开发人员不需要频繁学习和切换到使用非常不同的技术。这也促进了在不同部分的 Web 应用程序上工作的团队之间更好的沟通和理解。
学习、开发、部署和扩展所需的时间更少
技术堆栈的一致性也使学习和使用 MERN 变得更加容易,减少了采用新堆栈的开销和开发工作的时间。一旦建立了 MERN 应用程序的工作基础并建立了工作流程,复制、进一步开发和扩展任何应用程序就需要更少的工作量。
在行业中被广泛采用
基于其需求,各种规模的组织一直在采用此堆栈中的技术,因为他们可以更快地构建应用程序,处理高度多样化的需求,并在规模上更有效地管理应用程序。
社区支持和增长
围绕非常流行的 MERN 堆栈技术的开发者社区非常多样化,并且定期增长。由于有很多人不断使用、修复、更新,并愿意帮助发展这些技术,支持系统在可预见的未来将保持强大。这些技术将继续得到维护,并且在文档、附加库和技术支持方面很可能会提供资源。
使用这些技术的便利性和好处已经被广泛认可。由于继续采用和适应的知名公司,以及不断增加的为代码库做出贡献、提供支持和创建资源的人数,MERN 堆栈中的技术将在很长一段时间内继续保持相关性。
MERN 应用范围
考虑到每种技术的独特特性,以及通过集成其他技术来扩展此堆栈的功能的便利性,可以使用此堆栈构建的应用程序范围实际上非常广泛。
如今,网络应用程序默认应该是丰富的客户端应用程序,具有沉浸式、互动性,并且在性能和可用性上不会逊色。MERN 的优势组合使其非常适合开发满足这些方面和需求的网络应用程序。
此外,一些技术的新颖和即将推出的特性,例如使用 Node 进行低级操作操作、使用 MongoDB GridFS 进行大文件流传输功能,以及使用 React 360 在网络上实现虚拟现实功能,使得可以使用 MERN 构建更复杂和独特的应用程序。
挑选 MERN 技术中的特定功能,并论述为什么这些功能不适用于某些应用可能看起来是合理的。但考虑到 MERN 堆栈如何灵活地组合和扩展,这些问题可以在 MERN 中逐案解决。在本书中,我们将演示在构建应用程序时如何考虑特定要求和需求。
本书中开发的 MERN 应用程序
为了展示 MERN 的广泛可能性以及如何轻松开始构建具有不同功能的 Web 应用程序,本书将展示日常使用的 Web 应用程序以及复杂和罕见的 Web 体验:
上述截图展示了本书其余部分开发的四个不同的 MERN 应用程序
社交媒体平台
对于第一个 MERN 应用程序,我们将构建一个受 Twitter 和 Facebook 启发的基本社交媒体应用程序。这个社交媒体平台将实现诸如帖子分享、点赞和评论、关注朋友以及聚合新闻源等简单功能。
在线市场
各种类型的电子商务 Web 应用程序在互联网上随处可见,而且这些应用程序在短期内不会过时。使用 MERN,我们将构建一个在线市场应用程序,涵盖核心方面,如支持卖家账户、产品列表、顾客购物车和支付处理。
媒体流应用程序
为了测试一些高级的 MERN 功能,下一个选择是更加沉浸式的应用程序,比如媒体流应用程序。受 Netflix 和 YouTube 的功能启发,该应用程序将实现内容上传和查看功能,为内容提供者提供媒体内容上传功能,并为观众提供实时内容流。
Web 的 VR 游戏
React 360 的发布使得将 Web VR 功能应用于 React 用户界面成为可能。我们将探索如何在 MERN 中使用 React 360 创建罕见的 Web 体验,通过组合基本的虚拟现实游戏应用程序。用户将能够制作和玩 VR 游戏,每个游戏都将有动画的 VR 对象,玩家可以收集以完成游戏。
书的结构
这本书旨在帮助那些对 MERN 堆栈有零到一些经验的 JavaScript 开发人员,设置并开始开发不同复杂性的 Web 应用程序。它包括构建和运行不同应用程序的指南,以及代码片段和关键概念的解释。
这本书分为五个部分,从基础到高级主题逐步展开,带领你一路构建 MERN,然后利用它开发具有简单到复杂功能的不同应用程序,同时演示如何根据应用程序要求扩展 MERN 堆栈的功能。
开始使用 MERN
第一章,释放 MERN 的 React 应用程序和第二章,准备开发环境为在 MERN 堆栈中开发 Web 应用程序设定了背景,并指导您设置开发环境。
从头开始构建 MERN——一个骨架应用程序
第三章,使用 MongoDB、Express 和 Node 构建后端和第四章,添加 React 前端以完成 MERN展示了如何将 MERN 堆栈技术结合起来形成一个具有最少和基本功能的骨架 Web 应用程序。这个骨架 MERN 应用程序作为本书其余部分开发的四个主要应用程序的基础。
使用 MERN 开发基本 Web 应用程序
在这一部分,您将通过构建两个真实世界的应用程序——一个简单的社交媒体平台(第五章),从一个简单的社交媒体应用开始,和一个在线市场(第六章),通过在线市场锻炼新的 MERN 技能和第七章,扩展订单和支付的市场来熟悉 MERN 堆栈 Web 应用程序的核心属性。
深入复杂的 MERN 应用
第八章,构建媒体流应用程序,第九章,自定义媒体播放器和改善 SEO,第十章,开发基于 Web 的 VR 游戏和第十一章,使用 MERN 使 VR 游戏动态展示了这个堆栈如何用于开发具有更复杂和沉浸式功能的应用程序,例如使用 React 360 进行媒体流和虚拟现实。
继续前进与 MERN
最后第十二章,遵循最佳实践并进一步开发 MERN总结了前面的章节和应用程序,通过扩展最佳实践来开发成功的 MERN 应用程序,提出改进建议和进一步发展。
您可以根据自己的经验水平和偏好,选择是否按照规定的顺序使用本书。对于一个对 MERN 非常陌生的开发人员,可以按照本书中的路径进行。对于更有经验的 JS 开发人员,从零开始构建 MERN - 一个骨架应用程序 部分的章节将是开始设置基础应用程序的好地方,然后选择任何四个应用程序进行构建和扩展。
充分利用本书
这本书的内容是以实践为导向的,涵盖了构建每个 MERN 应用程序所需的实施步骤、代码和相关概念。建议您不要仅仅试图通读章节,而是应该并行运行相关代码,并在阅读书中的解释时浏览应用程序的功能。
讨论代码实现的章节将指向包含完整代码及其运行说明的 GitHub 存储库。您可以在阅读章节之前拉取代码、安装并运行它:
您可以考虑按照本书中概述的推荐步骤来实施:
-
在深入讨论章节中的实施细节之前,从相关的 GitHub 存储库中拉取代码
-
按照代码的说明安装和运行应用程序
-
在阅读相关章节中的功能描述时,浏览正在运行的应用程序的功能
-
在开发模式下运行代码并在编辑器中打开后,参考书中的步骤和解释,以更深入地理解实施细节
本书旨在为每个应用程序提供快速的入门指南和工作代码。您可以根据需要对此代码进行实验、改进和扩展。为了获得积极的学习体验,鼓励您在遵循本书的同时重构和修改代码。在一些示例中,本书选择冗长的代码而不是简洁和更清晰的代码,因为对于新手来说更容易理解。在一些其他实现中,本书坚持使用更广泛使用的传统约定,而不是现代和即将到来的 JavaScript 约定。这样做是为了在您自行研究讨论的技术和概念时,最小化参考在线资源和文档时的差异。本书中代码可以更新的这些实例,是探索和发展超出本书涵盖范围的技能的好机会。
摘要
在本章中,我们了解了在 MERN 堆栈中开发 Web 应用程序的背景,以及本书将如何帮助您使用该堆栈进行开发。
MERN 堆栈项目集成了 MongoDB、Express、React 和 Node,用于构建 Web 应用程序。该堆栈中的每种技术在 Web 开发领域都取得了相关进展。这些技术被广泛采用,并在不断壮大的社区支持下不断改进。可以开发具有不同需求的 MERN 应用程序,从日常使用的应用程序到更复杂的 Web 体验。本书中的实用导向方法可用于从基础到高级的 MERN 技能成长,或者直接开始构建更复杂的应用程序。
在下一章中,我们将开始为 MERN 应用程序开发做准备,通过设置开发环境。
第二章:准备开发环境
在使用 MERN 堆栈构建应用程序之前,我们首先需要准备每种技术的开发环境,以及用于辅助开发和调试的工具。本章将指导您了解工作空间选项、基本开发工具、如何在工作空间中设置 MERN 技术以及检查此设置的实际代码步骤。
我们将涵盖以下主题:
-
工作空间选项
-
代码编辑器
-
Chrome 开发者工具
-
Git 设置
-
MongoDB 设置
-
Node 设置
-
npm 模块以完成 MERN 堆栈
-
用于检查 MERN 设置的代码
选择开发工具
在选择基本开发工具(如文本编辑器或 IDE、版本控制软件甚至开发工作空间本身)时有很多选择。在本节中,我们将介绍与 MERN Web 开发相关的选项和建议,以便您可以根据个人偏好做出明智的决定。
工作空间选项
在本地计算机上开发是程序员中最常见的做法,但随着诸如 Cloud9(aws.amazon.com/cloud9/?origin=c9io
)等优秀的云开发服务的出现,现在可以同时使用本地和云端。您可以使用 MERN 技术设置本地工作空间,并且在本书的其余部分将假定为这种情况,但您也可以选择在配备了 Node 开发的云服务中运行和开发代码。
本地和云开发
您可以选择同时使用这两种类型的工作空间,以享受在本地工作的好处,而不必担心带宽/互联网问题,并在没有您喜爱的本地计算机时远程工作。为此,您可以使用 Git 对代码进行版本控制,将最新代码存储在 GitHub 或 BitBucket 等远程 Git 托管服务上,然后在所有工作空间中共享相同的代码。
IDE 或文本编辑器
大多数云开发环境都将集成源代码编辑器。但是对于您的本地工作空间,您可以根据自己作为程序员的偏好选择任何编辑器,然后为 MERN 开发进行自定义。例如,以下流行选项都可以根据需要进行自定义:
-
Atom(
atom.io/
):GitHub 的免费开源文本编辑器,有许多其他开发人员提供的与 MERN 堆栈相关的包可用 -
SublimeText(https://www.sublimetext.com/):一款专有的跨平台文本编辑器,还有许多与 MERN 堆栈相关的软件包可用,支持 JavaScript 开发
-
Visual Studio Code(https://code.visualstudio.com/):微软开发的功能丰富的源代码编辑器,广泛支持现代 Web 应用程序开发工作流程,包括对 MERN 堆栈技术的支持
-
WebStorm(https://www.jetbrains.com/webstorm/):由 JetBrains 开发的全功能 JavaScript IDE,支持基于 MERN 堆栈的开发
Chrome 开发者工具
加载、查看和调试前端是 Web 开发过程中非常关键的一部分。Chrome 开发者工具是 Chrome 浏览器的一部分,具有许多出色的功能,允许调试、测试和实验前端代码,以及 UI 的外观、响应和性能。此外,React 开发者工具扩展可作为 Chrome 插件使用,并将 React 调试工具添加到 Chrome 开发者工具中。
Git
任何开发工作流程都不完整,如果没有版本控制系统来跟踪代码更改、共享代码和协作。多年来,Git 已成为许多开发人员的事实标准版本控制系统,并且是最广泛使用的分布式源代码管理工具。在本书中,Git 将主要帮助跟踪进度,因为我们逐步构建每个应用程序。
安装
要开始使用 Git,首先根据您的系统规格在本地计算机或基于云的开发环境上安装它。有关下载和安装最新 Git 的相关说明,以及使用 Git 命令的文档,可在以下网址找到:https://git-scm.com/downloads。
远程 Git 托管服务
基于云的 Git 存储库托管服务,如 GitHub 和 BitBucket,有助于在工作空间和部署环境之间共享最新的代码,并备份代码。这些服务提供了许多有用的功能,以帮助代码管理和开发工作流程。要开始使用,您可以创建一个帐户,并为您的代码库设置远程存储库。
所有这些基本工具将丰富您的 Web 开发工作流程,并在您完成工作区的必要设置并开始构建 MERN 应用程序后提高生产力。
设置 MERN 技术栈
MERN 技术栈正在开发和升级,因此在撰写本书时,我们使用的是最新的稳定版本。大多数这些技术的安装指南取决于工作区的系统环境,因此本节指向所有相关的安装资源,并且也作为设置完全功能的 MERN 技术栈的指南。
MongoDB
在向 MERN 应用程序添加任何数据库功能之前,必须在开发环境中设置并运行 MongoDB。在撰写本文时,MongoDB 的当前稳定版本是 3.6.3,本书中用于开发应用程序的是 MongoDB Community Edition 的这个版本。本节的其余部分提供了有关如何安装和运行 MongoDB 的资源。
安装
您需要在工作区安装并启动 MongoDB,以便在开发中使用它。MongoDB 的安装和启动过程取决于工作区的规格:
-
云开发服务将有其自己的安装和设置 MongoDB 的说明。例如,Cloud9 的操作步骤可以在此找到:
community.c9.io/t/setting-up-mongodb/1717
。 -
在本地机器上安装的指南详见:
docs.mongodb.com/manual/installation/
。
运行 mongo shell
mongo shell 是 MongoDB 的交互式工具,是熟悉 MongoDB 操作的好地方。一旦安装并运行了 MongoDB,您可以在命令行上运行 mongo shell。在 mongo shell 中,您可以尝试查询和更新数据以及执行管理操作的命令。
Node
MERN 应用程序的后端服务器实现依赖于 Node 和 npm。在撰写本文时,8.11.1 是最新的稳定 Node 版本,并且附带 npm 版本 5.6.0。然而,npm 的最新版本是 5.8.0,因此在安装 Node 后,需要根据下一节的讨论升级 npm。
安装
Node 可以通过直接下载、安装程序或 Node 版本管理器进行安装。
-
您可以通过直接下载源代码或针对您的工作平台特定的预构建安装程序来安装 Node。下载地址为nodejs.org/en/download。
-
云开发服务可能已经预装了 Node,比如 Cloud9,或者会有特定的添加和更新 Node 的说明。
要测试安装是否成功,可以打开命令行并运行node -v
来查看它是否正确返回版本号。
升级 npm 版本
为了安装 npm 版本 5.8.0,可以从命令行运行以下安装命令,并使用npm -v
检查版本:
npm install -g npm@5.8.0
npm -v
使用 nvm 进行 Node 版本管理
如果您需要为不同的项目维护多个 Node 和 npm 版本,nvm 是一个有用的命令行工具,可以在同一工作空间中安装和管理不同的版本。您必须单独安装 nvm。设置说明可以在github.com/creationix/nvm找到。
MERN 的 npm 模块
其余的 MERN 堆栈技术都可以作为 npm 模块使用,并且可以通过npm install
添加到每个项目中。这些包括关键模块,如 React 和 Express,这些模块是运行每个 MERN 应用程序所必需的,还有在开发过程中将需要的模块。在本节中,我们列出并讨论这些模块,然后在下一节中看如何在一个工作项目中使用这些模块。
关键模块
为了集成 MERN 堆栈技术并运行您的应用程序,我们将需要以下 npm 模块:
-
React:要开始使用 React,我们将需要两个模块:
-
react
-
react-dom
-
Express:要在代码中使用 Express,您需要
express
模块 -
MongoDB:要在 Node 应用程序中使用 MongoDB,还需要添加驱动程序,该驱动程序可作为名为
mongodb
的 npm 模块使用
devDependency 模块
为了在 MERN 应用程序的开发过程中保持一致性,我们将在整个堆栈中使用 JavaScript ES6。因此,为了辅助开发过程,我们将使用以下额外的 npm 模块来编译和捆绑代码,并在开发过程中更新代码时自动重新加载服务器和浏览器应用程序:
-
Babel 模块用于将 ES6 和 JSX 转换为适合所有浏览器的 JavaScript。需要的模块来使 Babel 工作的有:
-
babel-core
-
babel-loader
用于使用 Webpack 转换 JavaScript 文件 -
babel-preset-env
,babel-preset-react
和babel-preset-stage-2
用于支持 React,最新的 JS 功能以及一些 stage-x 功能,例如声明目前未在babel-preset-env
下覆盖的类字段 -
Webpack 模块将帮助捆绑编译后的 JavaScript,用于客户端和服务器端代码。需要使 Webpack 工作的模块有:
-
webpack
-
webpack-cli
用于运行 Webpack 命令 -
webpack-node-externals
在 Webpack 打包时忽略外部 Node 模块文件 -
webpack-dev-middleware
在开发过程中通过连接的服务器提供从 Webpack 发出的文件 -
webpack-hot-middleware
将热模块重新加载添加到现有服务器中,通过将浏览器客户端连接到 Webpack 服务器,并在开发过程中接收代码更改的更新 -
nodemon
在开发过程中监视服务器端的更改,以便重新加载服务器以使更改生效。 -
react-hot-loader
用于加快客户端的开发。每当 React 前端中的文件更改时,react-hot-loader
使浏览器应用程序能够在不重新捆绑整个前端代码的情况下更新。
尽管react-hot-loader
旨在帮助开发流程,但安装此模块作为常规依赖项而不是 devDependency 是安全的。它会自动确保在生产中禁用热重新加载,并且占用空间很小。
检查您的开发设置
在这一部分,我们将逐步进行开发工作流程,并编写代码,以确保环境正确设置以开始开发和运行 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/shamahoque/mern-simplesetup。您可以克隆此代码,并在本章的其余部分中阅读代码解释时运行它。
初始化 package.json 并安装 npm 模块
我们将首先使用 npm 安装所有必需的模块。在每个项目文件夹中添加package.json
文件以维护、记录和共享 MERN 应用程序中使用的 npm 模块是最佳实践。package.json
文件将包含有关应用程序的元信息,以及列出模块依赖项。
按照以下步骤生成package.json
文件,修改它,并用它来安装 npm 模块:
-
npm init
: 从命令行进入项目文件夹,运行npm init
。您将被问及一系列问题,然后将自动生成一个package.json
文件,其中包含您的答案。 -
dependencies
: 在编辑器中打开package.json
,修改 JSON 对象,添加关键模块和react-hot-loader
作为常规的dependencies
。
在代码块之前提到的文件路径表示项目目录中代码的位置。本书始终遵循这一约定,以提供更好的上下文和指导,让您能够跟着代码进行学习。
mern-simplesetup/package.json
:
"dependencies": {
"express": "⁴.16.3",
"mongodb": "³.0.7",
"react": "¹⁶.3.2",
"react-dom": "¹⁶.3.2",
"react-hot-loader": "⁴.1.2"
}
devDependencies
: 进一步修改package.json
,添加以下在开发过程中所需的 npm 模块作为devDependencies
。
mern-simplesetup/package.json
:
"devDependencies": {
"babel-core": "⁶.26.2",
"babel-loader": "⁷.1.4",
"babel-preset-env": "¹.6.1",
"babel-preset-react": "⁶.24.1",
"babel-preset-stage-2": "⁶.24.1",
"nodemon": "¹.17.3",
"webpack": "⁴.6.0",
"webpack-cli": "².0.15",
"webpack-dev-middleware": "³.1.2",
"webpack-hot-middleware": "².22.1",
"webpack-node-externals": "¹.7.2"
}
npm install
: 保存package.json
,然后从命令行运行npm install
,以获取并添加所有这些模块到您的项目中。
配置 Babel、Webpack 和 Nodemon
在我们开始编写 Web 应用程序之前,我们需要配置 Babel、Webpack 和 Nodemon,在开发过程中编译、打包和自动重新加载代码更改。
Babel
在项目文件夹中创建一个.babelrc
文件,并添加以下 JSON,其中指定了presets
和plugins
。
mern-simplesetup/.babelrc
:
{
"presets": [
"env",
"stage-2"
"react"
],
"plugins": [
"react-hot-loader/babel"
]
}
react-hot-loader/babel
插件是由react-hot-loader
模块需要编译React
组件。
Webpack
我们将不得不为捆绑客户端和服务器端代码以及生产代码分别配置 Webpack。在项目文件夹中创建webpack.config.client.js
、webpack.config.server.js
和webpack.config.client.production.js
文件。所有三个文件都将具有以下代码结构:
const path = require('path')
const webpack = require('webpack')
const CURRENT_WORKING_DIR = process.cwd()
const config = { ... }
module.exports = config
config
JSON 对象的值将根据客户端或服务器端代码以及开发与生产代码而有所不同。
用于开发的客户端 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: [
'react-hot-loader/patch',
'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()
]
}
-
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 配置
修改代码以要求nodeExternals
,并在webpack.config.server.js
文件中更新config
对象以配置 Webpack 用于打包服务器端代码。
mern-simplesetup/webpack.config.server.js
:
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
文件夹开始打包,然后将打包后的代码输出到dist
文件夹中的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.js
文件,并添加以下配置。
mern-simplesetup/nodemon.js
{
"verbose": false,
"watch": [ "./server" ],
"exec": "webpack --mode=development --config
webpack.config.server.js
&& node ./dist/server.generated.js"
}
这个配置将设置nodemon
在开发过程中监视服务器文件的更改,然后根据需要执行编译和构建命令。
使用 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="/dist/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
组件。
HelloWorld.js
包含一个基本的HelloWorld
组件,它被热导出以在开发过程中使用react-hot-loader
进行热重载。
mern-simplesetup/client/HelloWorld.js
:
import React, { Component } from 'react'
import { hot } from 'react-hot-loader'
class HelloWorld extends Component {
render() {
return (
<div>
<h1>Hello World!</h1>
</div>
)
}
}
export default hot(module)(HelloWorld)
为了在服务器收到对根 URL 的请求时在浏览器中看到React
组件被渲染,我们需要使用 Webpack 和 Babel 设置来编译和打包这段代码,并添加服务器端代码来响应根路由请求并返回打包后的代码。
使用 Express 和 Node 构建服务器
在项目文件夹中,创建一个名为server
的文件夹,并添加一个名为server.js
的文件来设置服务器。然后,添加另一个名为devBundle.js
的文件,它将在开发模式下使用 Webpack 配置来编译 React 代码。
Express 应用程序
在server.js
中,我们首先将添加代码来导入express
模块,以初始化一个 Express 应用程序。
mern-simplesetup/server/server.js
:
import express from 'express'
const app = express()
然后我们将使用这个 Express 应用程序来构建出 Node 服务器应用程序的其余部分。
在开发过程中打包 React 应用程序
为了保持开发流程简单,我们将初始化 Webpack 来在运行服务器时编译客户端代码。在devBundle.js
中,我们将设置一个编译方法,它接受 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/folder
中提供静态文件。
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')))
在根目录渲染模板
当服务器在根 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())
})
最后,添加服务器代码以侦听指定端口的传入请求。
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)
})
将服务器连接到 MongoDB
要将 Node 服务器连接到 MongoDB,请在server.js
中添加以下代码,并确保您的工作区中正在运行 MongoDB。
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
是连接到运行中的MongoDB
实例的驱动程序,使用其url
,并允许我们在后端实现与数据库相关的代码。
运行 npm 脚本
更新package.json
文件,添加以下 npm 运行脚本以进行开发和生产。
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"
}
-
npm run development
:此命令将启动 Nodemon、Webpack 和服务器以进行开发 -
npm run build
:这将为生产模式生成客户端和服务器代码包(在运行此脚本之前,请确保从server.js
中删除devBundle.compile
代码) -
npm run start
:此命令将在生产环境中运行捆绑代码
实时开发和调试
要运行到目前为止开发的代码,并确保一切正常运行,可以按照以下步骤进行:
-
从命令行运行应用程序:
npm run development
。 -
在浏览器中加载:在浏览器中打开根 URL,即
http://localhost:3000
,如果您正在使用本地机器设置。您应该看到一个标题为 MERN Kickstart 的页面,上面只显示 Hello World!。 -
开发代码并调试实时更改:将
HelloWorld.js
组件文本中的'Hello World!'
更改为'hello'
。保存更改以在浏览器中看到即时更新,并检查命令行输出以查看bundle.js
是否未重新创建。类似地,当您更改服务器端代码时,您也可以看到即时更新,从而提高开发效率。
如果您已经走到了这一步,恭喜您,您已经准备好开始开发令人兴奋的 MERN 应用程序了。
总结
在本章中,我们讨论了开发工具选项以及如何安装 MERN 技术,然后编写了代码来检查开发环境是否设置正确。
我们首先看了推荐的工作区、IDE、版本控制软件和适用于 Web 开发的浏览器选项。您可以根据自己作为开发人员的偏好从这些选项中进行选择。
接下来,我们首先安装 MongoDB、Node 和 npm,然后使用 npm 添加其余所需的库,从而设置了 MERN 堆栈技术。
在继续编写代码以检查此设置之前,我们配置了 Webpack 和 Babel 以在开发期间编译和捆绑代码,并构建生产就绪的代码。我们了解到,在在浏览器上打开 MERN 应用程序之前,有必要编译用于开发 MERN 应用程序的 ES6 和 JSX 代码。
此外,我们通过为前端开发包括 React Hot Loader,为后端开发配置 Nodemon,并在开发期间运行服务器时编译客户端和服务器端代码的方式,使开发流程更加高效。
在下一章中,我们将使用此设置开始构建一个骨架 MERN 应用程序,该应用程序将作为功能齐全应用程序的基础。
第三章:使用 MongoDB、Express 和 Node 构建后端
在大多数 Web 应用程序的开发过程中,存在常见任务、基本功能和实现代码的重复。这本书中开发的 MERN 应用程序也是如此。考虑到这些相似之处,我们将首先为一个骨架 MERN 应用程序奠定基础,该应用程序可以轻松修改和扩展,以实现各种 MERN 应用程序。
在本章中,我们将涵盖以下主题,并从 MERN 骨架的后端实现开始,使用 Node、Express 和 MongoDB:
-
MERN 应用程序中的用户 CRUD 和 auth
-
使用 Express 服务器处理 HTTP 请求
-
使用 Mongoose 模式进行用户模型
-
用户 CRUD 和 auth 的 API
-
用 JWT 进行受保护路由的身份验证
-
运行后端代码并检查 API
骨架应用程序概述
骨架应用程序将封装基本功能和一个在大多数 MERN 应用程序中重复的工作流程。我们将构建骨架本质上作为一个基本但完全功能的 MERN Web 应用程序,具有用户创建(CRUD)和身份验证-授权(auth)功能,这也将展示如何开发、组织和运行使用这个堆栈构建的一般 Web 应用程序的代码。目标是保持骨架尽可能简单,以便易于扩展,并可用作开发不同 MERN 应用程序的基础应用程序。
功能分解
在骨架应用程序中,我们将添加以下用例,其中包括用户 CRUD 和 auth 功能的实现:
-
注册:用户可以通过使用电子邮件地址注册创建新帐户
-
用户列表:任何访问者都可以看到所有注册用户的列表
-
身份验证:注册用户可以登录和退出
-
受保护的用户资料:只有注册用户可以在登录后查看个人用户详细信息
-
授权用户编辑和删除:只有注册和经过身份验证的用户才能编辑或删除自己的用户帐户详细信息
本章重点-后端
在本章中,我们将专注于使用 Node、Express 和 MongoDB 构建骨架应用程序的工作后端。完成的后端将是一个独立的服务器端应用程序,可以处理 HTTP 请求以创建用户、列出所有用户,并在考虑用户身份验证和授权的情况下查看、更新或删除数据库中的用户。
用户模型
用户模型将定义要存储在 MongoDB 数据库中的用户详细信息,并处理与用户相关的业务逻辑,如密码加密和用户数据验证。这个骨架版本的用户模型将是基本的,支持以下属性:
字段名称 | 类型 | 描述 |
---|---|---|
name |
String | 存储用户姓名的必需字段 |
email |
String | 必需的唯一字段,用于存储用户的电子邮件并标识每个帐户(每个唯一电子邮件只允许一个帐户) |
password |
String | 用于身份验证的必需字段,数据库将存储加密后的密码而不是实际字符串,以确保安全性 |
created |
Date | 当创建新用户帐户时自动生成的时间戳 |
updated |
Date | 当现有用户详细信息更新时自动生成的时间戳 |
用户 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 操作将具有受保护的访问权限,这将要求请求的客户端进行身份验证、授权或两者都要求。最后两个路由用于身份验证,将允许用户登录和退出登录。
使用 JSON Web Tokens 进行身份验证
为了根据骨架特性限制和保护对用户 API 端点的访问,后端需要整合身份验证和授权机制。在实现 Web 应用程序的用户身份验证时有许多选择。最常见和经过时间考验的选项是使用会话在客户端和服务器端存储用户状态。但是,一种较新的方法是使用JSON Web Token(JWT)作为无状态身份验证机制,不需要在服务器端存储用户状态。
这两种方法在相关的真实用例中都有优势。然而,为了简化本书中的代码,并且因为它与 MERN 堆栈和我们的示例应用程序配合得很好,我们将使用 JWT 进行身份验证实现。此外,本书还将在未来章节中提出安全增强选项。
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 骨架的后端部分,我们将首先设置项目文件夹,安装和配置必要的 npm 模块,然后准备运行脚本以帮助开发和运行代码。然后,我们将逐步通过代码实现用户模型、API 端点和基于 JWT 的身份验证,以满足我们之前为面向用户的功能定义的规范。
本章讨论的代码以及完整的骨架应用程序的代码可在 GitHub 的存储库github.com/shamahoque/mern-skeleton中找到。仅后端的代码可在同一存储库的名为mern-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
这个结构将在下一章进一步扩展,在那里我们通过添加React
前端来完成骨架应用程序。
项目设置
如果开发环境已经设置好,我们可以初始化 MERN 项目以开始开发后端。首先,我们将在项目文件夹中初始化package.json
,配置和安装开发依赖项,设置用于代码的配置变量,并更新package.json
以帮助开发和运行代码的运行脚本。
初始化package.json
我们需要一个package.json
文件来存储有关项目的元信息,列出模块依赖项及其版本号,并定义运行脚本。要在项目文件夹中初始化package.json
文件,请从命令行转到项目文件夹并运行npm init
,然后按照说明添加必要的细节。有了package.json
文件后,我们可以继续设置和开发,并在代码实现过程中根据需要更新文件。
开发依赖项
为了开始开发并运行后端服务器代码,我们将配置和安装 Babel、Webpack 和 Nodemon,如第二章中所讨论的那样,对于仅后端,进行一些微小的调整。
Babel
由于我们将使用 ES6 编写后端代码,我们将配置和安装 Babel 模块来转换 ES6。
首先,在.babelrc
文件中配置 Babel,使用最新 JS 特性的预设和一些目前未在babel-preset-env
下覆盖的 stage-x 特性。
mern-skeleton/.babelrc
:
{
"presets": [
"env",
"stage-2"
]
}
接下来,我们从命令行安装 Babel 模块作为devDependencies
:
npm install --save-dev babel-core babel-loader babel-preset-env babel-preset-stage-2
一旦模块安装完成,您会注意到package.json
文件中的devDependencies
列表已更新。
Webpack
我们需要 Webpack 使用 Babel 编译和捆绑服务器端代码,并且对于配置,我们可以使用在第二章中讨论的相同的webpack.config.server.js
。
从命令行运行以下命令来安装webpack
,webpack-cli
和webpack-node-externals
模块:
npm install --save-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
:区分开发和生产模式 -
端口
:定义服务器的监听端口 -
jwtSecret
:用于签署 JWT 的秘钥 -
mongoUri
:项目的 MongoDB 数据库位置
运行脚本
为了在开发后端代码时运行服务器,我们可以从package.json
文件中的npm run development
脚本开始。对于完整的骨架应用程序,我们将使用第二章中定义的相同的运行脚本,准备开发环境。
mern-skeleton/package.json
:
"scripts": {
"development": "nodemon"
}
npm run development
:从项目文件夹的命令行中运行此命令基本上会根据nodemon.js
中的配置启动 Nodemon。配置指示 Nodemon 监视服务器文件的更新,并在更新时重新构建文件,然后重新启动服务器,以便立即使用更改。
准备服务器
在本节中,我们将集成 Express、Node 和 MongoDB,以在开始实现特定于用户的功能之前运行完全配置的服务器。
配置 Express
要使用 Express,我们将首先安装 Express,然后在server/express.js
文件中添加和配置它。
从命令行运行以下命令来安装带有--save
标志的express
模块,以便package.json
文件会自动更新:
npm install express --save
一旦 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 来简化浏览器-服务器通信: -
安装
body-parser
模块:npm install body-parser --save
-
配置 Express:
bodyParser.json()
和bodyParser.urlencoded({ extended: true })
-
cookie-parser
:用于解析和设置请求对象中的 cookie 的 cookie 解析中间件:
安装cookie-parser
模块:npm install cookie-parser --save
压缩
:压缩中间件,将尝试压缩所有通过中间件传递的请求的响应主体:
安装compression
模块:npm install compression --save
头盔
:一组中间件函数,通过设置各种 HTTP 头部来帮助保护 Express 应用程序:
安装头盔
模块:npm install helmet --save
cors
:中间件以启用CORS(跨源资源共享):
安装cors
模块:npm install cors --save
在安装了上述模块之后,我们可以更新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 请求,我们可以继续使用它来实现服务器以监听传入的请求。
在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 应用程序以启动服务器。
要使此代码运行并继续开发,现在可以从命令行运行npm run development
。如果代码没有错误,服务器应该开始运行,并且 Nodemon 会监视代码更改。
设置 Mongoose 并连接到 MongoDB
我们将使用Mongoose
模块来在此骨架中实现用户模型,以及我们 MERN 应用程序的所有未来数据模型。在这里,我们将首先配置 Mongoose,并利用它来定义与 MongoDB 数据库的连接。
首先,要安装mongoose
模块,请运行以下命令:
npm install mongoose --save
然后,更新server.js
文件以导入mongoose
模块,配置它以使用原生的 ES6 promises,并最终使用它来处理与项目的 MongoDB 数据库的连接。
mern-skeleton/server/server.js
:
import mongoose from 'mongoose'
mongoose.Promise = global.Promise
mongoose.connect(config.mongoUri)
mongoose.connection.on('error', () => {
throw new Error(`unable to connect to database: ${mongoUri}`)
})
如果您在开发中运行代码,则保存此更新应重新启动现在已集成了 Mongoose 和 MongoDB 的服务器。
Mongoose 是一个 MongoDB 对象建模工具,它提供了基于模式的解决方案来对应用程序数据进行建模。它包括内置的类型转换、验证、查询构建和业务逻辑钩子。在此后端堆栈中使用 Mongoose 提供了对 MongoDB 的更高层次的功能,包括将对象模型映射到数据库文档。因此,使用 Node 和 MongoDB 后端进行开发变得更简单和更高效。要了解有关 Mongoose 的更多信息,请访问mongoosejs.com。
在根 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/
。
用户模型
我们将在server/models/user.model.js
文件中实现用户模型,使用 Mongoose 来定义包含必要用户数据字段的模式,为字段添加内置验证,并整合密码加密、认证和自定义验证等业务逻辑。
我们将首先导入mongoose
模块,并使用它来生成一个UserSchema
。
mern-skeleton/server/models/user.model.js
:
import mongoose from 'mongoose'
const UserSchema = new mongoose.Schema({ … })
mongoose.Schema()
函数以模式定义对象作为参数,生成一个新的 Mongoose 模式对象,可以在后端代码的其余部分中使用。
用户模式定义
生成新的 Mongoose 模式所需的用户模式定义对象将声明所有用户数据字段和相关属性。
名称
name
字段是一个必填字段,类型为String
。
mern-skeleton/server/models/user.model.js
:
name: {
type: String,
trim: true,
required: 'Name is required'
},
电子邮件
email
字段是一个必填字段,类型为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,
哈希密码和盐
hashed_password
和salt
字段代表我们将用于认证的加密用户密码。
mern-skeleton/server/models/user.model.js
:
hashed_password: {
type: String,
required: "Password is required"
},
salt: String
出于安全目的,实际密码字符串不会直接存储在数据库中,而是单独处理。
用于认证的密码
密码字段对于在任何应用程序中提供安全用户认证非常重要,它需要作为用户模型的一部分进行加密、验证和安全认证。
作为虚拟字段
用户提供的password
字符串不会直接存储在用户文档中。相反,它被处理为一个“虚拟”字段。
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
})
当在用户创建或更新时接收到password
值时,它将被加密为一个新的哈希值,并设置为hashed_password
字段,以及在salt
字段中设置salt
值。
加密和认证
加密逻辑和盐生成逻辑,用于生成代表password
值的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())) + ''
}
}
此外,authenticate
方法也被定义为UserSchema
方法,用于在用户提供的密码必须进行验证以进行登录时使用。
Node 中的crypto
模块用于将用户提供的密码字符串加密为带有随机生成的salt
值的hashed_password
。当用户详细信息在创建或更新时保存到数据库中,hashed_password
和 salt 将存储在用户文档中。在用户登录时,需要hashed_password
和 salt 值来匹配和验证提供的密码字符串,使用之前定义的authenticate
方法。
密码字段验证
为了在最终用户选择的实际密码字符串上添加验证约束,我们需要添加自定义验证逻辑并将其与模式中的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
被定义,并且所有与密码相关的业务逻辑都像之前讨论的那样被添加,我们最终可以在user.model.js
文件的底部导出模式,以便在后端代码的其他部分中使用它。
mern-skeleton/server/models/user.model.js
:
export default mongoose.model('User', UserSchema)
Mongoose 错误处理
向用户模式字段添加的验证约束将在将用户数据保存到数据库时引发错误消息。为了处理这些验证错误以及我们向数据库查询时可能引发的其他错误,我们将定义一个辅助方法来返回相关的错误消息,以便在请求-响应周期中适当地传播。
我们将在server/helpers/dbErrorHandler.js
文件中添加getErrorMessage
辅助方法。该方法将解析并返回与使用 Mongoose 查询 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 验证器违规而抛出的错误将包含错误代码,并且在某些情况下需要以不同方式处理。例如,由于违反唯一约束而导致的错误将返回一个与 Mongoose 验证错误不同的错误对象。唯一选项不是验证器,而是用于构建 MongoDB 唯一索引的便捷助手,因此我们将添加另一个getUniqueErrorMessage
方法来解析与唯一约束相关的错误对象,并构造适当的错误消息。
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 执行的用户 CRUD 操作引发的错误时添加有意义的错误消息。
用户 CRUD API
Express 应用程序公开的用户 API 端点将允许前端对根据用户模型生成的文档执行 CRUD 操作。为了实现这些工作端点,我们将编写 Express 路由和相应的控制器回调函数,当这些声明的路由收到 HTTP 请求时应该执行这些函数。在本节中,我们将看看这些端点在没有任何身份验证限制的情况下如何工作。
用户 API 路由将在server/routes/user.routes.js
中使用 Express 路由器声明,然后挂载到我们在server/express.js
中配置的 Express 应用程序上。
mern-skeleton/server/express.js
:
import userRoutes from './routes/user.routes'
...
app.use('/', userRoutes)
...
用户路由
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
用户控制器
server/controllers/user.controller.js
文件将包含在前面的用户路由声明中使用的控制器方法,作为服务器接收到路由请求时的回调。
user.controller.js
文件将具有以下结构:
import User from '../models/user.model'
import _ from 'lodash'
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
,请从命令行运行npm install lodash --save
。
先前定义的每个控制器函数都与路由请求相关,并将根据每个 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 = (req, res, next) => {
const user = new User(req.body)
user.save((err, result) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
res.status(200).json({
message: "Successfully signed up!"
})
})
}
此函数使用从前端收到的用户 JSON 对象在req.body
中创建新用户。user.save
尝试在 Mongoose 对数据进行验证检查后将新用户保存到数据库中,因此将向请求的客户端返回错误或成功响应。
列出所有用户
获取所有用户的 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 = (req, res) => {
User.find((err, users) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
res.json(users)
}).select('name email updated created')
}
list
控制器函数从数据库中找到所有用户,仅填充结果用户列表中的名称、电子邮件、创建和更新字段,然后将这些用户列表作为 JSON 对象数组返回给请求的客户端。
按 ID 加载用户以进行读取、更新或删除
读取、更新和删除的所有三个 API 端点都需要根据正在访问的用户的用户 ID 从数据库中检索用户。在响应特定的读取、更新或删除请求之前,我们将编程 Express 路由器执行此操作。
加载
每当 Express 应用程序收到与路径中包含:userId
参数匹配的路由的请求时,该应用程序将首先执行userByID
控制器函数,然后传播到传入请求特定的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 = (req, res, next, id) => {
User.findById(id).exec((err, user) => {
if (err || !user)
return res.status('400').json({
error: "User not found"
})
req.profile = user
next()
})
}
如果在数据库中找到匹配的用户,则用户对象将附加到请求对象的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 请求时,类似于read
,它首先加载具有:userId
参数值的用户,然后执行update
控制器函数。
mern-skeleton/server/controllers/user.controller.js
:
const update = (req, res, next) => {
let user = req.profile
user = _.extend(user, req.body)
user.updated = Date.now()
user.save((err) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
user.hashed_password = undefined
user.salt = undefined
res.json(user)
})
}
update
函数从req.profile
中检索用户详细信息,然后使用lodash
模块来扩展和合并请求体中的更改以更新用户数据。在将此更新后的用户保存到数据库之前,updated
字段将填充为当前日期以反映最后更新的时间戳。成功保存此更新后,更新后的用户对象将通过删除敏感数据,如hashed_password
和salt
,然后将用户对象发送到请求客户端的响应中。
删除
声明了删除用户的 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 = (req, res, next) => {
let user = req.profile
user.remove((err, deletedUser) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
deletedUser.hashed_password = undefined
deletedUser.salt = undefined
res.json(deletedUser)
})
}
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)
...
认证路由
使用express.Router()
在auth.routes.js
文件中定义了两个认证 API,以声明具有相关 HTTP 方法的路由路径,并分配了应在收到这些路由的请求时调用的相应认证控制器函数。
认证路由如下:
-
'/auth/signin'
:使用电子邮件和密码进行用户认证的 POST 请求 -
'/auth/signout'
:GET 请求以清除包含在登录后设置在响应对象上的 JWT 的 cookie
生成的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
认证控制器
server/controllers/auth.controller.js
中的认证控制器函数不仅处理登录和登出路由的请求,还提供 JWT 和express-jwt
功能,以启用受保护的用户 API 端点的认证和授权。
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 实现用户认证。
登录
在以下路由中声明了用于登录用户的 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 = (req, res) => {
User.findOne({
"email": req.body.email
}, (err, user) => {
if (err || !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}
})
})
}
POST
请求对象在req.body
中接收电子邮件和密码。该电子邮件用于从数据库中检索匹配的用户。然后,UserSchema
中定义的密码验证方法用于验证从客户端req.body
中接收的密码。
如果密码成功验证,JWT 模块将用秘密密钥和用户的_id
值生成一个签名的 JWT。
安装jsonwebtoken
模块,通过在命令行中运行npm install jsonwebtoken --save
来使其在导入此控制器时可用。
然后,签名的 JWT 将与用户详细信息一起返回给经过身份验证的客户端。可选地,我们还可以将令牌设置为响应对象中的 cookie,以便在客户端选择 JWT 存储的情况下可用。在客户端,当从服务器请求受保护的路由时,必须将此令牌附加为Authorization
头。
登出
在以下路由中声明了用于登出用户的 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,用户状态存储是客户端的责任,并且除了 cookie 之外,客户端存储的选择有多种选项。在登出时,客户端需要在客户端删除令牌,以确立用户不再经过身份验证。
使用 express-jwt 保护路由
为了保护对读取、更新和删除路由的访问,服务器需要检查请求的客户端是否真的是经过身份验证和授权的用户。
在访问受保护的路由时,我们将使用express-jwt
模块来检查请求用户是否已登录并具有有效的 JWT。
express-jwt
模块是验证 JSON Web 令牌的中间件。运行npm install express-jwt --save
来安装express-jwt
。
要求登录
auth.controller.js
中的requireSignin
方法使用express-jwt
来验证传入请求的Authorization
头中是否有有效的 JWT。如果令牌有效,它会将经过验证的用户 ID 附加在请求对象的'auth'
键中,否则会抛出身份验证错误。
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()
}
req.auth
对象由express-jwt
在身份验证验证后的requireSignin
中填充,而req.profile
由user.controller.js
中的userByID
函数填充。我们将在需要身份验证和授权的路由中添加hasAuthorization
函数。
保护用户路由
我们将在需要受到身份验证和授权保护的用户路由声明中添加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 操作之前应检查身份验证和授权。
对于 express-jwt 的身份验证错误处理
处理由express-jwt
抛出的与验证传入请求中的 JWT 令牌相关的错误时,我们需要在 Express 应用程序配置中添加以下错误捕获代码,该配置位于mern-skeleton/server/express.js
中的代码末尾,在挂载路由之后并在导出应用程序之前:
app.use((err, req, res, next) => {
if (err.name === 'UnauthorizedError') {
res.status(401).json({"error" : err.name + ": " + err.message})
}
})
当令牌由于某种原因无法验证时,express-jwt
会抛出一个名为UnauthorizedError
的错误。我们在这里捕获此错误,以便向请求的客户端返回401
状态。
通过实施用户身份验证来保护路由,我们已经涵盖了骨架 MERN 应用程序的所有期望功能。在下一节中,我们将看看如何在不实施前端的情况下检查这个独立后端是否按预期运行。
检查独立后端
在选择用于检查后端 API 的工具时,有许多选项,从命令行工具 curl(github.com/curl/curl
)到 Advanced REST Client(chrome.google.com/webstore/detail/advanced-rest-client/hgmloofddffdnphfgcellkdfbfbjeloo
)—一个具有交互式用户界面的 Chrome 扩展程序应用。
要检查本章实现的 API,首先从命令行运行服务器,并使用这些工具之一请求路由。如果您在本地计算机上运行代码,则根 URL 为http://localhost:3000/
。
使用 ARC,我们将展示检查实现的 API 端点的五个用例的预期行为。
创建新用户
首先,我们将使用/api/users
的 POST 请求创建一个新用户,并在请求体中传递名称、电子邮件和密码值。当用户在数据库中成功创建且没有任何验证错误时,我们将看到一个 200 OK 的成功消息,如下面的屏幕截图所示:
获取用户列表
我们可以通过对/api/users
进行GET
请求来查看数据库中是否有新用户。响应应包含存储在数据库中的所有用户对象的数组:
尝试获取单个用户
接下来,我们将尝试在未登录的情况下访问受保护的 API。对任何一个用户进行GET
请求将返回 401 未经授权,例如,在以下示例中,对/api/users/5a1c7ead1a692aa19c3e7b33
的GET
请求将返回 401:
登录
为了能够访问受保护的路由,我们将使用第一个示例中创建的用户的凭据进行登录。要登录,需要在/auth/signin
发送带有电子邮件和密码的 POST 请求。成功登录后,服务器将返回一个签名的 JWT 和用户详细信息。我们将需要这个令牌来访问获取单个用户的受保护路由:
成功获取单个用户
使用登录后收到的令牌,我们现在可以访问之前失败的受保护路由。在向/api/users/5a1c7ead1a692aa19c3e7b33
发出 GET 请求时,令牌以 Bearer 方案设置在Authorization
标头中,这次用户对象成功返回。
总结
在本章中,我们使用 Node、Express 和 MongoDB 开发了一个完全独立的服务器端应用程序,涵盖了 MERN 骨架应用程序的第一部分。在后端,我们实现了以下功能:
-
用 Mongoose 实现的用于存储用户数据的用户模型
-
使用 Express 实现的用户 API 端点执行 CRUD 操作
-
使用 JWT 和
express-jwt
实现受保护路由的用户认证
我们还通过配置 Webpack 编译 ES6 代码和 Nodemon 在代码更改时重新启动服务器来设置开发流程。最后,我们使用 Chrome 的高级 Rest API 客户端扩展应用程序检查了 API 的实现。
我们现在准备在下一章中扩展这个后端应用程序代码,添加 React 前端,并完成 MERN 骨架应用程序。
第四章:添加 React 前端以完成 MERN
没有前端的 Web 应用程序是不完整的。这是用户与之交互的部分,对于任何 Web 体验都至关重要。在本章中,我们将使用 React 为我们在上一章开始构建的 MERN 骨架应用程序的后端实现的基本用户和认证功能添加交互式用户界面。
我们将涵盖以下主题,以添加一个可工作的前端并完成 MERN 骨架应用程序:
-
骨架的前端特性
-
使用 React、React Router 和 Material-UI 进行开发设置
-
后端用户 API 集成
-
认证集成
-
主页、用户、注册、登录、用户资料、编辑和删除视图
-
导航菜单
-
基本的服务器端渲染
骨架前端
为了完全实现在第三章的功能拆分部分中讨论的骨架应用程序功能,即使用 MongoDB、Express 和 Node 构建后端,我们将向基本应用程序添加以下用户界面组件:
-
主页:在根 URL 上呈现的视图,欢迎用户访问 Web 应用程序
-
用户列表页面:获取并显示数据库中所有用户列表的视图,并链接到单个用户资料
-
注册页面:一个带有用户注册表单的视图,允许新用户创建用户账户,并在成功创建后将他们重定向到登录页面
-
登录页面:带有登录表单的视图,允许现有用户登录,以便他们可以访问受保护的视图和操作
-
个人资料页面:获取并显示单个用户信息的组件,只有已登录用户才能访问,并且还包含编辑和删除选项,仅当已登录用户查看自己的个人资料时才可见
-
编辑个人资料页面:一个表单,获取用户的信息,允许他们编辑信息,并且仅当已登录用户尝试编辑自己的个人资料时才可访问
-
删除用户组件:一个选项,允许已登录用户在确认意图后删除自己的个人资料
-
菜单导航栏:列出所有可用和相关的视图的组件,还帮助指示用户在应用程序中的当前位置
以下 React 组件树图显示了我们将开发的所有 React 组件,以构建出这个基本应用程序的视图:
MainRouter将是根 React 组件,其中包含应用程序中的所有其他自定义 React 视图。Home,Signup,Signin,Users,Profile和EditProfile将在使用 React Router 声明的各个路由上呈现,而Menu组件将在所有这些视图中呈现,DeleteUser将成为Profile视图的一部分。
本章讨论的代码以及完整的骨架代码都可以在 GitHub 的存储库中找到,网址为github.com/shamahoque/mern-skeleton。您可以克隆此代码,并在本章的其余部分中阅读代码解释时运行应用程序。
文件夹和文件结构
以下文件夹结构显示了要添加到骨架中的新文件夹和文件,以完成具有 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
| -- server/
| --- devBundle.js
| -- webpack.config.client.js
| -- webpack.config.client.production.js
客户端文件夹将包含 React 组件,辅助程序和前端资产,例如图像和 CSS。除了这个文件夹和用于编译和捆绑客户端代码的 Webpack 配置之外,我们还将修改一些其他现有文件,以整合完整的骨架。
为 React 开发设置
在我们可以在现有的骨架代码库中开始使用 React 进行开发之前,我们首先需要添加配置来编译和捆绑前端代码,添加构建交互式界面所需的与 React 相关的依赖项,并在 MERN 开发流程中将所有这些联系在一起。
配置 Babel 和 Webpack
为了在开发期间编译和捆绑客户端代码并在生产环境中运行它,我们将更新 Babel 和 Webpack 的配置。
Babel
为了编译 React,首先安装 Babel 的 React 预设模块作为开发依赖项:
npm install babel-preset-react --save-dev
然后,更新.babelrc
以包括该模块,并根据需要配置react-hot-loader
Babel 插件。
mern-skeleton/.babelrc
:
{
"presets": [
"env",
"stage-2",
"react"
],
"plugins": [
"react-hot-loader/babel"
]
}
Webpack
在使用 Babel 编译后捆绑客户端代码,并为更快的开发启用react-hot-loader
,安装以下模块:
npm install --save-dev webpack-dev-middleware webpack-hot-middleware file-loader
npm install --save react-hot-loader
然后,为了配置前端开发的 Webpack 并构建生产捆绑包,我们将添加一个webpack.config.client.js
文件和一个webpack.config.client.production.js
文件,其中包含与第二章中描述的相同配置代码,准备开发环境。
加载 Webpack 中间件进行开发
在开发过程中,当我们运行服务器时,Express 应用程序应加载与客户端代码设置的配置相关的 Webpack 中间件,以便集成前端和后端开发工作流程。为了实现这一点,我们将使用第二章中讨论的devBundle.js
文件,准备开发环境,设置一个compile
方法,该方法接受 Express 应用程序并配置它使用 Webpack 中间件。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
}
然后,通过添加以下突出显示的行,导入并调用express.js
中的compile
方法,仅在开发时添加。
mern-skeleton/server/express.js
:
**import devBundle from './devBundle'**
const app = express()
**devBundle.compile(app)**
这两行突出显示的代码仅用于开发模式,在构建生产代码时应将其注释掉。此代码将在 Express 应用程序以开发模式运行时导入中间件和 Webpack 配置,然后启动 Webpack 编译和捆绑客户端代码。捆绑后的代码将放置在dist
文件夹中。
使用 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')))
更新模板以加载捆绑的脚本
为了在 HTML 视图中添加捆绑的前端代码,我们将更新template.js
文件,将脚本文件从dist
文件夹添加到<body>
标签的末尾。
mern-skeleton/template.js
:
...
<body>
<div id="root"></div>
**<script type="text/javascript" src="/dist/bundle.js"></script>**
</body>
添加 React 依赖项
前端视图将主要使用 React 实现。此外,为了实现客户端路由,我们将使用 React Router,并且为了增强用户体验,使其看起来更加流畅,我们将使用 Material-UI。
React
在本书中,我们将使用 React 16 来编写前端代码。要开始编写React
组件代码,我们需要安装以下模块作为常规依赖项:
npm install --save react react-dom
React Router
React Router 提供了一组导航组件,可以在 React 应用程序的前端进行路由。为了利用声明式路由并拥有可书签的 URL 路由,我们将添加以下 React Router 模块:
npm install --save react-router react-router-dom
Material-UI
为了保持我们的 MERN 应用程序中的 UI 简洁,而不过多涉及 UI 设计和实现,我们将利用Material-UI
库。它提供了可立即使用和可定制的React
组件,实现了谷歌的材料设计。要开始使用 Material-UI 组件制作前端,我们需要安装以下模块:
npm install --save material-ui@1.0.0-beta.43 material-ui-icons
在撰写本文时,Material-UI 的最新预发布版本是1.0.0-beta.43
,建议安装此确切版本,以确保示例项目的代码不会中断。
将Roboto
字体按照 Material-UI 的建议添加,并使用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 组件。
实现 React 视图
一个功能齐全的前端应该将 React 组件与后端 API 集成,并允许用户根据授权在应用程序中无缝导航。为了演示如何为这个 MERN 骨架实现一个功能齐全的前端视图,我们将从详细说明如何在根路由处呈现主页组件开始,然后涵盖后端 API 和用户认证集成,然后突出实现剩余视图组件的独特方面。
呈现主页
在根路由处实现和呈现一个工作的Home
组件的过程也将暴露骨架中前端代码的基本结构。我们将从顶级入口组件开始,该组件包含整个 React 应用程序,并呈现链接应用程序中所有 React 组件的主路由器组件。
在main.js
的入口点
客户端文件夹中的client/main.js
文件将是渲染完整 React 应用程序的入口点。在这段代码中,我们导入将包含完整前端并将其呈现到在template.js
中指定的 HTML 文档中的div
元素的根或顶级 React 组件。
mern-skeleton/client/main.js
:
import React from 'react'
import { render } from 'react-dom'
import App from './App'
render(<App/>, document.getElementById('root'))
根 React 组件
定义应用程序前端所有组件的顶层 React 组件在client/App.js
文件中。在这个文件中,我们配置 React 应用程序以使用定制的 Material-UI 主题渲染视图组件,启用前端路由,并确保 React Hot Loader 可以在我们开发组件时立即加载更改。
定制 Material-UI 主题
可以使用MuiThemeProvider
组件轻松定制 Material-UI 主题,并通过在createMuiTheme()
中配置自定义值来设置主题变量。
mern-skeleton/client/App.js
:
import {MuiThemeProvider, createMuiTheme} from 'material-ui/styles'
import {indigo, pink} from 'material-ui/colors'
const theme = createMuiTheme({
palette: {
primary: {
light: '#757de8',
main: '#3f51b5',
dark: '#002984',
contrastText: '#fff',
},
secondary: {
light: '#ff79b0',
main: '#ff4081',
dark: '#c60055',
contrastText: '#000',
},
openTitle: indigo['400'],
protectedTitle: pink['400'],
type: 'light'
}
})
对于骨架,我们只需进行最少的定制,通过将一些颜色值设置为 UI 中使用的值。在这里生成的主题变量将传递给我们构建的所有组件,并在其中可用。
用 MUI 主题和 BrowserRouter 包装根组件
我们创建的自定义 React 组件将通过MainRouter
组件中指定的前端路由进行访问。基本上,这个组件包含了为应用程序开发的所有自定义视图。在App.js
中定义根组件时,我们使用MuiThemeProvider
将MainRouter
组件包装起来,以便让它可以访问 Material-UI 主题,并使用BrowserRouter
启用 React Router 的前端路由。之前定义的自定义主题变量作为 prop 传递给MuiThemeProvider
,使主题在所有自定义 React 组件中可用。
mern-skeleton/client/App.js
:
import React from 'react'
import MainRouter from './MainRouter'
import {BrowserRouter} from 'react-router-dom'
const App = () => (
<BrowserRouter>
<MuiThemeProvider theme={theme}>
<MainRouter/>
</MuiThemeProvider>
</BrowserRouter>
)
将根组件标记为热导出
在App.js
中的最后一行代码导出App
组件使用react-hot-loader
中的hot
模块将根组件标记为hot
。这将在开发过程中启用 React 组件的实时重新加载。
mern-skeleton/client/App.js
:
import { hot } from 'react-hot-loader'
...
export default hot(module)(App)
对于我们的 MERN 应用程序,在这一点之后,我们不需要太多更改main.js
和App.js
的代码,可以继续通过在MainRouter
组件中注入新组件来构建 React 应用程序的其余部分。
向 MainRouter 添加主页路由
MainRouter.js
代码将帮助根据应用程序中的路由或位置渲染我们的自定义 React 组件。在这个第一个版本中,我们只会添加根路由来渲染Home
组件。
mern-skeleton/client/MainRouter.js
:
import React, {Component} from 'react'
import {Route, Switch} from 'react-router-dom'
import Home from './core/Home'
class MainRouter extends Component {
render() {
return (<div>
<Switch>
<Route exact path="/" component={Home}/>
</Switch>
</div>)
}
}
export default MainRouter
随着我们开发更多的视图组件,我们将更新MainRouter
以在Switch
组件中为新组件添加路由。
React Router 中的Switch
组件专门用于呈现路由。换句话说,它只呈现与请求的路由路径匹配的第一个子组件。而不在Switch
中嵌套时,每个Route
组件在路径匹配时都会进行包容性渲染。例如,对'/'
的请求也会匹配'/contact'
的路由。
Home 组件
当用户访问根路由时,Home
组件将在浏览器上呈现,并且我们将使用 Material-UI 组件来组合它。以下屏幕截图显示了Home
组件和稍后在本章中作为独立组件实现的Menu
组件,以提供应用程序中的导航:
Home
组件和其他视图组件将按照通用的代码结构在浏览器中呈现给用户进行交互,该结构包含以下部分,按照给定的顺序。
导入
组件文件将从 React、Material-UI、React Router 模块、图像、CSS、API fetch 和我们代码中的 auth helpers 中导入所需的特定组件。例如,在Home.js
中的Home
组件代码中,我们使用以下导入。
mern-skeleton/client/core/Home.js
:
import React, {Component} from 'react'
import PropTypes from 'prop-types'
import {withStyles} from 'material-ui/styles'
import Card, {CardContent, CardMedia} from 'material-ui/Card'
import Typography from 'material-ui/Typography'
import seashellImg from './../assets/images/seashell.jpg'
图像文件保存在client/assets/images/
文件夹中,并被导入/添加到Home
组件中。
样式声明
在导入之后,我们将根据需要使用Material-UI
主题变量来定义 CSS 样式,以便对组件中的元素进行样式设置。对于Home.js
中的Home
组件,我们有以下样式。
mern-skeleton/client/core/Home.js
:
const styles = theme => ({
card: {
maxWidth: 600,
margin: 'auto',
marginTop: theme.spacing.unit * 5
},
title: {
padding:`${theme.spacing.unit * 3}px ${theme.spacing.unit * 2.5}px
${theme.spacing.unit * 2}px`,
color: theme.palette.text.secondary
},
media: {
minHeight: 330
}
})
在这里定义的 JSS 样式对象将被注入到组件中,并用于对组件中的元素进行样式设置,就像下面的Home
组件定义中所示。
Material-UI 使用 JSS,这是一种 CSS-in-JS 的样式解决方案,用于向组件添加样式。JSS 使用 JavaScript 作为描述样式的语言。本书不会详细介绍 CSS 和样式实现,而是更多地依赖于 Material-UI 组件的默认外观和感觉。要了解更多关于 JSS 的信息,请访问cssinjs.org/?v=v9.8.1
。要了解如何自定义Material-UI
组件样式的示例,请查看 Material-UI 文档material-ui-next.com/
。
组件定义
在组件定义中,我们将组合组件的内容和行为。Home
组件将包含一个 Material-UI 的Card
,其中包括一个标题、一个图像和一个标题,所有这些都使用之前定义的类进行样式设置,并作为 props 传递进来。
mern-skeleton/client/core/Home.js
:
class Home extends Component {
render() {
const {classes} = this.props
return (
<div>
<Card className={classes.card}>
<Typography type="headline" component="h2" className=
{classes.title}>
Home Page
</Typography>
<CardMedia className={classes.media} image={seashellImg}
title="Unicorn Shells"/>
<CardContent>
<Typography type="body1" component="p">
Welcome to the Mern Skeleton home page
</Typography>
</CardContent>
</Card>
</div>
)
}
}
PropTypes 验证
为了验证将样式声明作为 props 注入到组件中的要求,我们向已定义的组件添加了PropTypes
要求验证器。
mern-skeleton/client/core/Home.js
:
Home.propTypes = {
classes: PropTypes.object.isRequired
}
导出组件
最后,在组件文件的最后一行代码中,我们将使用Material-UI
中的withStyles
导出组件并传递定义的样式。像这样使用withStyles
创建了一个具有对定义样式对象的访问权限的Higher-order component (HOC)。
mern-skeleton/client/core/Home.js
:
export default withStyles(styles)(Home)
导出的组件现在可以在其他组件中进行组合使用,就像我们在之前讨论的MainRouter
组件中的路由中使用Home
组件一样。
在我们的 MERN 应用程序中要实现的其他视图组件将遵循相同的结构。在本书的其余部分,我们将主要关注组件定义,突出已实现组件的独特方面。
捆绑图像资源
我们导入到Home
组件视图中的静态图像文件也必须与编译后的 JS 代码一起包含在捆绑包中,以便代码可以访问和加载它。为了实现这一点,我们需要更新 Webpack 配置文件,添加一个模块规则来加载、捆绑和发射图像文件到输出目录中,该目录包含编译后的前端和后端代码。
更新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
npm 模块,需要安装为开发依赖,如下所示:
npm install --save-dev file-loader
运行并在浏览器中打开
到目前为止,客户端代码可以运行,以在根 URL 的浏览器中查看Home
组件。要运行应用程序,请使用以下命令:
npm run 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 组件可以使用这些方法与服务器和数据库交换用户数据。
创建用户
create
方法将从视图组件获取用户数据,使用fetch
进行POST
调用,在后端创建一个新用户,最后将来自服务器的响应作为一个 promise 返回给组件。
mern-skeleton/client/user/api-user.js
:
const create = (user) => {
return fetch('/api/users/', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(user)
})
.then((response) => {
return response.json()
}).catch((err) => console.log(err))
}
列出用户
list
方法将使用 fetch 进行GET
调用,以检索数据库中的所有用户,然后将来自服务器的响应作为 promise 返回给组件。
mern-skeleton/client/user/api-user.js
:
const list = () => {
return fetch('/api/users/', {
method: 'GET',
}).then(response => {
return response.json()
}).catch((err) => console.log(err))
}
读取用户配置文件
read
方法将使用 fetch 进行GET
调用,按 ID 检索特定用户。由于这是一个受保护的路由,除了将用户 ID 作为参数传递之外,请求组件还必须提供有效的凭据,这种情况下将是成功登录后收到的有效 JWT。
mern-skeleton/client/user/api-user.js
:
const read = (params, credentials) => {
return fetch('/api/users/' + params.userId, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
}
}).then((response) => {
return response.json()
}).catch((err) => console.log(err))
}
JWT 附加到GET
fetch 调用中的Authorization
标头,使用Bearer
方案,然后将来自服务器的响应作为 promise 返回给组件。
更新用户数据
update
方法将从视图组件获取特定用户的更改用户数据,然后使用fetch
进行PUT
调用,更新后端现有用户。这也是一个受保护的路由,需要有效的 JWT 作为凭据。
mern-skeleton/client/user/api-user.js
:
const update = (params, credentials, user) => {
return fetch('/api/users/' + params.userId, {
method: 'PUT',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
},
body: JSON.stringify(user)
}).then((response) => {
return response.json()
}).catch((err) => {
console.log(err)
})
}
删除用户
remove
方法将允许视图组件使用 fetch 来删除数据库中的特定用户,发出DELETE
调用。同样,这是一个受保护的路由,将需要有效的 JWT 作为凭据,类似于read
和update
方法。服务器对删除请求的响应将以 promise 的形式返回给组件。
mern-skeleton/client/user/api-user.js
:
const remove = (params, credentials) => {
return fetch('/api/users/' + params.userId, {
method: 'DELETE',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
}
}).then((response) => {
return response.json()
}).catch((err) => {
console.log(err)
})
}
最后,将用户 API 辅助方法导出,以便根据需要被导入和使用 React 组件。
mern-skeleton/client/user/api-user.js
:
export { create, list, read, update, remove }
用于认证 API 的 fetch
为了将服务器的认证 API 端点与前端 React 组件集成,我们将在client/auth/api-auth.js
文件中添加用于获取登录和登出 API 端点的方法。
登录
signin
方法将从视图组件获取用户登录数据,然后使用fetch
发出POST
调用来验证后端的用户。服务器的响应将以 promise 的形式返回给组件,其中可能包含 JWT 如果登录成功的话。
mern-skeleton/client/user/api-auth.js
:
const signin = (user) => {
return fetch('/auth/signin/', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(user)
})
.then((response) => {
return response.json()
}).catch((err) => console.log(err))
}
登出
signout
方法将使用 fetch 来向服务器的 signout API 端点发出 GET 调用。
mern-skeleton/client/user/api-auth.js
:
const signout = () => {
return fetch('/auth/signout/', {
method: 'GET',
}).then(response => {
return response.json()
}).catch((err) => console.log(err))
}
在api-auth.js
文件的末尾,导出signin
和signout
方法。
mern-skeleton/client/user/api-auth.js
:
export { signin, signout }
有了这些 API fetch 方法,React 前端可以完全访问后端可用的端点。
前端的认证
如前一章所讨论的,使用 JWT 实现认证将责任转移到客户端来管理和存储用户认证状态。为此,我们需要编写代码,允许客户端存储从服务器成功登录时收到的 JWT,在访问受保护的路由时使其可用,当用户退出时删除或使令牌无效,并且还根据用户认证状态限制前端的视图和组件访问。
使用 React Router 文档中的认证工作流示例,我们将编写辅助方法来管理组件之间的认证状态,并且还将使用自定义的PrivateRoute
组件来向前端添加受保护的路由。
管理认证状态
在client/auth/auth-helper.js
中,我们将定义以下辅助方法来从客户端sessionStorage
中存储和检索 JWT 凭据,并在用户退出时清除sessionStorage
:
authenticate(jwt, cb)
: 在成功登录时保存凭据:
authenticate(jwt, cb) {
if(typeof window !== "undefined")
sessionStorage.setItem('jwt', JSON.stringify(jwt))
cb()
}
isAuthenticated()
: 如果已登录,则检索凭据:
isAuthenticated() {
if (typeof window == "undefined")
return false
if (sessionStorage.getItem('jwt'))
return JSON.parse(sessionStorage.getItem('jwt'))
else
return false
}
signout(cb)
: 删除凭据并退出登录:
signout(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=/;"
})
}
使用这里定义的方法,我们构建的 React 组件将能够检查和管理用户认证状态,以限制前端的访问,就像在自定义的PrivateRoute
中所示的那样。
PrivateRoute 组件
client/auth/PrivateRoute.js
中定义了PrivateRoute
组件,如 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
中呈现的组件只有在用户经过认证时才会加载,否则用户将被重定向到Signin
组件。
随着后端 API 的集成,和认证管理辅助方法在组件中准备就绪,我们可以开始构建剩余的视图组件。
用户和认证组件
本节中描述的 React 组件完成了骨架定义的交互功能,允许用户查看、创建和修改存储在数据库中的用户数据,同时考虑认证限制。对于以下每个组件,我们将介绍每个组件的独特方面,以及如何将组件添加到应用程序中的MainRouter
中。
Users 组件
client/user/Users.js
中的Users
组件显示了从数据库中获取的所有用户的名称,并将每个名称链接到用户配置文件。任何访问应用程序的访问者都可以查看此组件,并且将在路径'/users'
上呈现:
在组件定义中,我们首先使用空数组初始化状态。
mern-skeleton/client/user/Users.js
:
class Users extends Component {
state = { users: [] }
...
接下来,在componentDidMount
中,我们使用api-user.js
中的list
方法,从后端获取用户列表,并通过更新状态将用户数据加载到组件中。
mern-skeleton/client/user/Users.js
:
componentDidMount = () => {
list().then((data) => {
if (data.error)
console.log(data.error)
else
this.setState({users: data})
})
}
render
函数包含Users
组件的实际视图内容,并与 Material-UI 组件(如Paper
、List
和ListItems
)组合在一起。这些元素使用定义的 CSS 进行样式化,并作为 props 传递。
mern-skeleton/client/user/Users.js
:
render() {
const {classes} = this.props
return (
<Paper className={classes.root} elevation={4}>
<Typography type="title" className={classes.title}>
All Users
</Typography>
<List dense>
{this.state.users.map(function(item, i) {
return <Link to={"/user/" + item._id} key={i}>
<ListItem button="button">
<ListItemAvatar>
<Avatar>
<Person/>
</Avatar>
</ListItemAvatar>
<ListItemText primary={item.name}/>
<ListItemSecondaryAction>
<IconButton>
<ArrowForward/>
</IconButton>
</ListItemSecondaryAction>
</ListItem>
</Link>
})}
</List>
</Paper>
)
}
为了生成每个列表项,我们使用 map 函数遍历状态中的用户数组。
要将此Users
组件添加到 React 应用程序中,我们需要使用Route
更新MainRouter
组件,在'/users'
路径处呈现此组件。在Home
路由之后,在Switch
组件内添加Route
。
mern-skeleton/client/MainRouter.js
:
<Route path="/users" component={Users}/>
要在浏览器中看到此视图呈现,可以暂时在Home
组件中添加一个Link
组件,以路由到Users
组件:
<Link to="/users">Users</Link>
注册组件
client/user/Signup.js
中的Signup
组件向用户呈现一个带有名称、电子邮件和密码字段的表单,用于在'/signup'
路径上注册。
在组件定义中,我们首先使用空输入字段值,空错误消息和将对话框打开变量设置为 false 来初始化状态。
mern-skeleton/client/user/Signup.js
:
constructor() {
state = { name: '', password: '', email: '', open: false, error: '' }
...
我们还定义了两个处理函数,当输入值更改或单击提交按钮时将被调用。handleChange
函数获取输入字段中输入的新值,并将其设置为state
。
mern-skeleton/client/user/Signup.js
:
handleChange = name => event => {
this.setState({[name]: event.target.value})
}
当表单提交时,将调用clickSubmit
函数。它从状态中获取输入值,并调用create
获取方法来注册用户。然后,根据服务器的响应,要么显示错误消息,要么显示成功对话框。
mern-skeleton/client/user/Signup.js
:
clickSubmit = () => {
const user = {
name: this.state.name || undefined,
email: this.state.email || undefined,
password: this.state.password || undefined
}
create(user).then((data) => {
if (data.error)
this.setState({error: data.error})
else
this.setState({error: '', open: true})
})
}
在render
函数中,我们使用诸如来自 Material-UI 的TextField
等组件来组成和样式化注册视图中的表单组件。
mern-skeleton/client/user/Signup.js
:
render() {
const {classes} = this.props
return (<div>
<Card className={classes.card}>
<CardContent>
<Typography type="headline" component="h2"
className={classes.title}>
Sign Up
</Typography>
<TextField id="name" label="Name"
className={classes.textField}
value={this.state.name}
onChange={this.handleChange('name')}
margin="normal"/> <br/>
<TextField id="email" type="email" label="Email"
className={classes.textField} value=
{this.state.email}
onChange={this.handleChange('email')}
margin="normal"/><br/>
<TextField id="password" type="password"
label="Password" className={classes.textField}
value={this.state.password}
onChange={this.handleChange('password')}
margin="normal"/><br/>
{this.state.error && ( <Typography component="p"
color="error">
<Icon color="error"
className={classes.error}>error</Icon>
{this.state.error}</Typography>)}
</CardContent>
<CardActions>
<Button color="primary" raised="raised"
onClick={this.clickSubmit}
className={classes.submit}>Submit</Button>
</CardActions>
</Card>
<Dialog> ... </Dialog>
</div>)
}
渲染还包含一个错误消息块,以及一个Dialog
组件,根据服务器的注册响应条件渲染。Signup.js
中的Dialog
组件组成如下。
mern-skeleton/client/user/Signup.js
:
<Dialog open={this.state.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="raised">
Sign In
</Button>
</Link>
</DialogActions>
</Dialog>
成功创建帐户后,用户将收到确认,并被要求使用此Dialog
组件登录,该组件链接到Signin
组件:
要将Signup
组件添加到应用程序中,在Switch
组件中添加以下Route
到MainRouter
中。
mern-skeleton/client/MainRouter.js
:
<Route path="/signup" component={Signup}/>
这将在'/signup'
处呈现Signup
视图。
登录组件
client/auth/Signin.js
中的Signin
组件也是一个只有电子邮件和密码字段的登录表单。该组件与Signup
组件非常相似,并将在'/signin'
路径下呈现。主要区别在于成功登录后重定向和接收 JWT 的存储实现:
对于重定向,我们将使用 React Router 中的Redirect
组件。首先,在状态中将redirectToReferrer
值初始化为false
,并与其他字段一起使用:
mern-skeleton/client/auth/Signin.js
:
class Signin extends Component {
state = { email: '', password: '', error: '', redirectToReferrer: false }
...
当用户成功提交表单并且接收到 JWT 存储在sessionStorage
中时,redirectToReferrer
应设置为true
。为了存储 JWT 并在之后重定向,我们将调用auth-helper.js
中定义的authenticate()
方法。这段代码将放在clickSubmit()
函数中,在表单提交时调用。
mern-skeleton/client/auth/Signin.js
:
clickSubmit = () => {
const user = {
email: this.state.email || undefined,
password: this.state.password || undefined
}
signin(user).then((data) => {
if (data.error) {
this.setState({error: data.error})
} else {
auth.authenticate(data, () => {
this.setState({redirectToReferrer: true})
})
}
})
}
基于redirectToReferrer
值的条件,重定向将在render
函数中的Redirect
组件中发生。在返回之前,在 render 函数中添加重定向代码如下:
mern-skeleton/client/auth/Signin.js
:
render() {
const {classes} = this.props
const {from} = this.props.location.state || {
from: {pathname: '/' }
}
const {redirectToReferrer} = this.state
if (redirectToReferrer)
return (<Redirect to={from}/>)
return (...)
}
}
如果渲染Redirect
组件,将会将应用程序带到上次的位置或根目录下的Home
组件。
返回将包含类似于Signup
的表单元素,只有email
和password
字段,条件错误消息和submit
按钮。
要将Signin
组件添加到应用程序中,在Switch
组件的MainRouter
中添加以下路由。
mern-skeleton/client/MainRouter.js
:
<Route path="/signin" component={Signin}/>
这将在"/signin"
处呈现Signin
组件。
Profile 组件
client/user/Profile.js
中的Profile
组件在'/user/:userId'
路径中显示单个用户的信息,其中userId
参数表示特定用户的 ID:
只有在用户登录后,才能从服务器获取此配置文件信息,并且为了验证这一点,组件必须向read
获取调用提供 JWT,否则用户应该被重定向到登录视图。
在Profile
组件定义中,我们首先需要用空用户初始化状态,并将redirectToSignin
设置为false
。
mern-skeleton/client/user/Profile.js
:
class Profile extends Component {
constructor({match}) {
super()
this.state = { user: '', redirectToSignin: false }
this.match = match
} ...
我们还需要访问由Route
组件传递的匹配 props,其中将包含:userId
参数值,并且在组件挂载时可以作为this.match.params.userId
进行访问。
Profile
组件应在路由中的userId
参数更改时获取用户信息并呈现它。然而,当应用程序从一个配置文件视图切换到另一个配置文件视图时,只是路由路径中的参数更改,React 组件不会重新挂载。相反,它会在componentWillReceiveProps
中传递新的 props。为了确保组件在路由参数更新时加载相关用户信息,我们将在init()
函数中放置read
获取调用,然后可以在componentDidMount
和componentWillReceiveProps
中调用它。
mern-skeleton/client/user/Profile.js
:
init = (userId) => {
const jwt = auth.isAuthenticated()
read({
userId: userId
}, {t: jwt.token}).then((data) => {
if (data.error)
this.setState({redirectToSignin: true})
else
this.setState({user: data})
})
}
init(userId)
函数接受userId
值,并调用读取用户获取方法。由于此方法还需要凭据来授权登录用户,因此 JWT 是使用auth-helper.js
中的isAuthenticated
方法从sessionStorage
中检索的。一旦服务器响应,要么更新状态与用户信息,要么将视图重定向到登录视图。
init
函数在componentDidMount
和componentWillReceiveProps
中被调用,并传入相关的userId
值作为参数,以便在组件中获取和加载正确的用户信息。
mern-skeleton/client/user/Profile.js
:
componentDidMount = () => {
this.init(this.match.params.userId)
}
componentWillReceiveProps = (props) => {
this.init(props.match.params.userId)
}
在render
函数中,我们设置了条件重定向到登录视图,并返回Profile
视图的内容:
mern-skeleton/client/user/Profile.js
:
render() {
const {classes} = this.props
const redirectToSignin = this.state.redirectToSignin
if (redirectToSignin)
return <Redirect to='/signin'/>
return (...)
}
如果当前登录的用户正在查看另一个用户的配置文件,则render
函数将返回Profile
视图,并包含以下元素。
mern-skeleton/client/user/Profile.js
:
<div>
<Paper className={classes.root} elevation={4}>
<Typography type="title" className={classes.title}> Profile </Typography>
<List dense>
<ListItem>
<ListItemAvatar>
<Avatar>
<Person/>
</Avatar>
</ListItemAvatar>
<ListItemText primary={this.state.user.name}
secondary={this.state.user.email}/>
</ListItem>
<Divider/>
<ListItem>
<ListItemText primary={"Joined: " +
(new Date(this.state.user.created)).toDateString()}/>
</ListItem>
</List>
</Paper>
</div>
但是,如果当前登录的用户正在查看自己的配置文件,则可以在Profile
组件中看到编辑和删除选项,如下截图所示:
要实现此功能,在Profile
中的第一个ListItem
组件中添加一个包含Edit
按钮和DeleteUser
组件的ListItemSecondaryAction
组件,根据当前用户是否查看自己的配置文件来有条件地呈现。
mern-skeleton/client/user/Profile.js
:
{ auth.isAuthenticated().user && auth.isAuthenticated().user._id == this.state.user._id &&
(<ListItemSecondaryAction>
<Link to={"/user/edit/" + this.state.user._id}>
<IconButton color="primary">
<Edit/>
</IconButton>
</Link>
<DeleteUser userId={this.state.user._id}/>
</ListItemSecondaryAction>)}
Edit
按钮将路由到EditProfile
组件,此处使用的自定义DeleteUser
组件将处理传递给它的userId
的删除操作。
要将Profile
组件添加到应用程序中,请将Route
添加到Switch
组件中的MainRouter
中。
mern-skeleton/client/MainRouter.js
:
<Route path="/user/:userId" component={Profile}/>
EditProfile 组件
client/user/EditProfile.js
中的EditProfile
组件在实现上与Signup
和Profile
组件都有相似之处。它将允许授权用户在类似注册表单的表单中编辑自己的个人资料信息:
在'/user/edit/:userId'
加载时,组件将通过验证 JWT 以获取 ID 的用户信息,然后使用接收到的用户信息加载表单。表单将允许用户仅编辑和提交更改的信息到update
fetch 调用,并在成功更新后将用户重定向到具有更新信息的Profile
视图。
EditProfile
将以与Profile
组件相同的方式加载用户信息,通过在componentDidMount
中使用read
从this.match.params
获取userId
参数,并使用auth.isAuthenticated
的凭据。表单视图将具有与Signup
组件相同的元素,输入值在更改时更新状态。
在表单提交时,组件将使用userId
、JWT 和更新后的用户数据调用update
fetch 方法。
mern-skeleton/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
}
update({
userId: this.match.params.userId
}, {
t: jwt.token
}, user).then((data) => {
if (data.error) {
this.setState({error: data.error})
} else {
this.setState({'userId': data._id, 'redirectToProfile': true})
}
})
}
根据服务器的响应,用户将要么看到错误消息,要么在渲染函数中使用以下Redirect
组件重定向到更新后的 Profile 页面。
mern-skeleton/client/user/EditProfile.js
:
if (this.state.redirectToProfile)
return (<Redirect to={'/user/' + this.state.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
路由混淆。
DeleteUser 组件
client/user/DeleteUser.js
中的DeleteUser
组件基本上是一个按钮,我们将其添加到 Profile 视图中,当点击时会打开一个要求用户确认delete
操作的Dialog
组件:
该组件首先使用open
设置为false
来初始化Dialog
组件的状态,并且还将redirect
设置为false
,因此首先不会被渲染。
mern-skeleton/client/user/DeleteUser.js
:
class DeleteUser extends Component {
state = { redirect: false, open: false }
...
接下来,我们需要处理打开和关闭dialog
按钮的方法。当用户点击delete
按钮时,对话框将被打开。
mern-skeleton/client/user/DeleteUser.js
:
clickButton = () => {
this.setState({open: true})
}
当用户在对话框上点击cancel
时,对话框将被关闭。
mern-skeleton/client/user/DeleteUser.js
:
handleRequestClose = () => {
this.setState({open: false})
}
该组件将从Profile
组件中作为属性传递的userId
,这是调用remove
fetch 方法所需的,同时还需要 JWT,用户在对话框中确认delete
操作后。
mern-skeleton/client/user/DeleteUser.js
:
deleteAccount = () => {
const jwt = auth.isAuthenticated()
remove({
userId: this.props.userId
}, {t: jwt.token}).then((data) => {
if (data.error) {
console.log(data.error)
} else {
auth.signout(() => console.log('deleted'))
this.setState({redirect: true})
}
})
}
确认后,deleteAccount
函数使用来自属性的userId
和来自isAuthenticated
的 JWT 调用remove
fetch 方法。在服务器成功删除后,用户将被注销并重定向到主页视图。
渲染函数包含对主页视图的条件性Redirect
,并返回DeleteUser
组件元素、DeleteIcon
按钮和确认Dialog
:
mern-skeleton/client/user/DeleteUser.js
:
render() {
const redirect = this.state.redirect
if (redirect) {
return <Redirect to='/'/>
}
return (<span>
<IconButton aria-label="Delete" onClick={this.clickButton}
color="secondary">
<DeleteIcon/>
</IconButton>
<Dialog open={this.state.open} onClose={this.handleRequestClose}>
<DialogTitle>{"Delete Account"}</DialogTitle>
<DialogContent>
<DialogContentText>
Confirm to delete your account.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={this.handleRequestClose} color="primary">
Cancel
</Button>
<Button onClick={this.deleteAccount} color="secondary"
autoFocus="autoFocus">
Confirm
</Button>
</DialogActions>
</Dialog>
</span>)
}
DeleteUser
将userId
作为属性传递,用于delete
fetch 调用,因此我们为所需的属性userId
添加了propType
检查。
mern-skeleton/client/user/DeleteUser.js
:
DeleteUser.propTypes = {
userId: PropTypes.string.isRequired
}
由于我们在Profile
组件中使用DeleteUser
组件,所以当Profile
添加到MainRouter
中时,它将被添加到应用视图中。
菜单组件
Menu
组件将作为整个前端应用程序的导航栏,提供到所有可用视图的链接,并指示应用程序中的当前位置。
为了实现这些导航栏功能,我们将使用 React Router 中的 HOC withRouter
来访问历史对象的属性。Menu
组件中的以下代码仅添加了标题、与根路由相关联的Home
图标以及与'/users'
路由相关联的Users
按钮。
mern-skeleton/client/core/Menu.js
:
const Menu = withRouter(({history}) => (<div>
<AppBar position="static">
<Toolbar>
<Typography type="title" 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>
</div>))
为了指示应用程序的当前位置在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")}
剩下的链接,如 SIGN IN、SIGN UP、MY PROFILE 和 SIGN OUT,将根据用户是否已登录显示在Menu
上:
例如,当用户未登录时,注册和登录的链接应该只显示在菜单上。因此,我们需要在Menu
组件中添加它,并在Users
按钮之后加上条件。
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.signout(() => history.push('/')) }}>
Sign out
</Button>
</span>)}
MY PROFILE
按钮使用已登录用户的信息链接到用户自己的个人资料,并且SIGN OUT
按钮在点击时调用auth.signout()
方法。当用户已登录时,菜单将如下所示:
为了在所有视图中显示Menu
导航栏,我们需要在MainRouter
中添加它,放在所有其他路由之前,并且在Switch
组件之外。
mern-skeleton/client/MainRouter.js
:
<Menu/>
<Switch>
…
</Switch>
当在路由上访问组件时,这将使Menu
组件呈现在所有其他组件的顶部。
骨架前端已经完整,包括所有必要的组件,以便用户可以在后端注册、查看和修改用户数据,并考虑到认证和授权限制。然而,目前还不能直接在浏览器地址栏中访问前端路由,只能在前端视图内部链接时访问。为了在骨架应用程序中实现此功能,我们需要实现基本的服务器端渲染。
基本的服务器端渲染
目前,当 React Router 路由或路径名直接输入到浏览器地址栏,或者刷新不在根路径的视图时,URL 无法工作。这是因为服务器无法识别 React Router 路由。我们需要在后端实现基本的服务器端渲染,以便服务器在收到对前端路由的请求时能够响应。
在服务器接收到前端路由的请求时,我们需要根据 React Router 和 Material-UI 组件在服务器端正确渲染相关的 React 组件。
React 应用程序服务器端渲染的基本思想是使用react-dom
中的renderToString
方法将根 React 组件转换为标记字符串,并将其附加到服务器在接收到请求时渲染的模板上。
在express.js
中,我们将用代码替换对'/'
的GET
请求返回template.js
的代码,该代码在接收到任何传入的 GET 请求时,生成相关 React 组件的服务器端渲染标记,并将此标记添加到模板中。此代码将具有以下结构:
app.get('*', (req, res) => {
// 1\. Prepare Material-UI styles
// 2\. Generate markup with renderToString
// 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'
- Router 模块:
StaticRouter
是一个无状态路由器,它接受请求的 URL 以匹配前端路由和MainRouter
组件,这是我们前端的根组件:
import StaticRouter from 'react-router-dom/StaticRouter'
import MainRouter from './../client/MainRouter'
- Material-UI 模块:以下模块将帮助基于前端使用的 Material-UI 主题为前端组件生成 CSS 样式:
import { SheetsRegistry } from 'react-jss/lib/jss'
import JssProvider from 'react-jss/lib/JssProvider'
import { MuiThemeProvider, createMuiTheme, createGenerateClassName } from 'material-ui/styles'
import { indigo, pink } from 'material-ui/colors'
有了这些模块,我们可以准备、生成和返回服务器端渲染的前端代码。
为 SSR 准备 Material-UI 样式
当服务器接收到任何请求时,在响应包含 React 视图的生成标记之前,我们需要准备应该添加到标记中的 CSS 样式,以便 UI 在初始渲染时不会中断。
mern-skeleton/server/express.js
:
const sheetsRegistry = new SheetsRegistry()
const theme = createMuiTheme({
palette: {
primary: {
light: '#757de8',
main: '#3f51b5',
dark: '#002984',
contrastText: '#fff',
},
secondary: {
light: '#ff79b0',
main: '#ff4081',
dark: '#c60055',
contrastText: '#000',
},
openTitle: indigo['400'],
protectedTitle: pink['400'],
type: 'light'
},
})
const generateClassName = createGenerateClassName()
为了注入 Material-UI 样式,在每个请求上,我们首先生成一个新的SheetsRegistry
和 MUI 主题实例,与前端代码中使用的相匹配。
生成标记
使用renderToString
的目的是生成要响应请求的用户显示的 React 组件的 HTML 字符串版本:
mern-skeleton/server/express.js
:
const context = {}
const markup = ReactDOMServer.renderToString(
<StaticRouter location={req.url} context={context}>
<JssProvider registry={sheetsRegistry} generateClassName=
{generateClassName}>
<MuiThemeProvider theme={theme} sheetsManager={new Map()}>
<MainRouter/>
</MuiThemeProvider>
</JssProvider>
</StaticRouter>
)
客户端应用程序的根组件MainRouter
被 Material-UI 主题和 JSS 包裹,以提供MainRouter
子组件所需的样式属性。在这里使用无状态的StaticRouter
代替客户端使用的BrowserRouter
,来包裹MainRouter
并提供在实现客户端组件时使用的路由属性。基于这些值,例如请求的location
路由和作为属性传递给包装组件的主题,renderToString
将返回包含相关视图的标记。
发送包含标记和 CSS 的模板
一旦生成了标记,我们首先检查组件中是否有渲染的redirect
,以便在标记中发送。如果没有重定向,那么我们从sheetsRegistry
生成 CSS 字符串,并在响应中发送带有标记和注入的 CSS 的模板。
mern-skeleton/server/express.js
:
if (context.url) {
return res.redirect(303, context.url)
}
const css = sheetsRegistry.toString()
res.status(200).send(Template({
markup: markup,
css: css
}))
在组件中渲染重定向的一个例子是尝试通过服务器端渲染访问PrivateRoute
时。由于服务器端无法从客户端的sessionStorage
访问 auth 令牌,PrivateRoute
中的重定向将被渲染。在这种情况下,context.url
将具有'/signin'
路由,因此不会尝试渲染PrivateRoute
组件,而是重定向到'/signin'
路由。
更新 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>
...`
}
更新 MainRouter
一旦在服务器端渲染的代码到达浏览器,并且前端脚本接管后,我们需要在主组件挂载时移除服务器端注入的 CSS。这将完全控制 React 应用程序的渲染权力交给客户端:
mern-skeleton/client/MainRouter.js
:
componentDidMount() {
const jssStyles = document.getElementById('jss-server-side')
if (jssStyles && jssStyles.parentNode)
jssStyles.parentNode.removeChild(jssStyles)
}
用 hydrate 代替 render
现在 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 Web 应用程序。我们可以扩展这个骨架中的代码,为不同的应用程序添加各种功能。
总结
在本章中,我们通过添加一个工作的 React 前端完成了 MERN 骨架应用程序,包括前端路由和 React 视图的基本服务器端渲染。
我们首先更新了开发流程,以包括用于 React 视图的客户端代码捆绑。我们更新了 Webpack 和 Babel 的配置以编译 React 代码,并讨论了如何从 Express 应用程序加载配置的 Webpack 中间件,以便在开发过程中从一个地方启动服务器端和客户端代码的编译。
在更新开发流程并构建前端之前,我们添加了相关的 React 依赖项,以及用于前端路由的 React Router 和用于在骨架应用程序的用户界面中使用现有组件的 Material-UI。
然后,我们实现了顶层根 React 组件,并集成了 React Router,这使我们能够添加用于导航的客户端路由。使用这些路由,我们加载了使用 Material-UI 组件开发的自定义 React 组件,以构成骨架应用程序的用户界面。
为了使这些 React 视图能够与从后端获取的数据动态交互,我们使用 Fetch API 连接到后端用户 API。然后,我们使用sessionStorage
存储用户特定的细节和从服务器成功登录时获取的 JWT,还通过使用PrivateRoute
组件限制对某些视图的访问来在前端视图上实现身份验证和授权。
最后,我们修改了服务器代码,实现了基本的服务器端渲染,允许在服务器识别到传入请求实际上是针对 React 路由时,在浏览器中直接加载经服务器端渲染的标记。
在下一章中,我们将利用开发这个基本的 MERN 应用程序时学到的概念,扩展骨架应用程序的代码,构建一个功能齐全的社交媒体应用程序。
第五章:从一个简单的社交媒体应用程序开始
社交媒体是当今网络的一个重要组成部分,我们构建的许多以用户为中心的网络应用程序最终都需要社交组件来推动用户参与。
对于我们的第一个真实世界 MERN 应用程序,我们将修改和扩展上一章开发的 MERN 骨架应用程序,以构建一个简单的社交媒体应用程序。
在本章中,我们将介绍以下社交媒体风格功能的实现:
-
带有描述和照片的用户个人资料
-
用户互相关注
-
关注建议
-
发布带有照片的消息
-
来自关注用户的帖子的新闻订阅
-
按用户列出帖子
-
点赞帖子
-
评论帖子
MERN Social
MERN Social 是一个受现有社交媒体平台(如 Facebook 和 Twitter)启发的具有基本功能的社交媒体应用程序。该应用程序的主要目的是演示如何使用 MERN 堆栈技术来实现允许用户在内容上连接和互动的功能。您可以根据需要进一步扩展这些实现,以实现更复杂的功能:
完整的 MERN Social 应用程序代码可在 GitHub 的github.com/shamahoque/mern-social存储库中找到。您可以在阅读本章其余部分的代码解释时,克隆此代码并运行应用程序。
MERN Social 应用程序所需的视图将通过扩展和修改 MERN 骨架应用程序中的现有 React 组件来开发。我们还将添加新的自定义组件来组成视图,包括一个新闻订阅视图,用户可以在其中创建新帖子,并浏览 MERN Social 上关注的所有人的帖子列表。以下组件树显示了构成 MERN Social 前端的所有自定义 React 组件,还公开了我们将用于构建本章其余部分视图的组合结构:
更新用户个人资料
骨架应用程序只支持用户的姓名、电子邮件和密码。但在 MERN Social 中,我们将允许用户在注册后编辑个人资料时添加关于自己的描述,并上传个人资料照片:
添加关于描述
为了存储用户在“关于”字段中输入的描述,我们需要在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={this.state.about}
onChange={this.handleChange('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)保存到 MongoDB 中作为缓冲区类型的数据
对于 MERN Social,我们将假设用户上传的照片文件将是小型的,并演示如何将这些文件存储在 MongoDB 中以实现个人资料照片上传功能。在第八章中,构建媒体流应用程序,我们将讨论如何使用 GridFS 在 MongoDB 中存储较大的文件。
更新用户模型以在 MongoDB 中存储照片
为了直接将上传的个人资料照片存储在数据库中,我们将更新用户模型以添加一个photo
字段,该字段将文件作为Buffer
类型的data
存储,并附带其contentType
。
mern-social/server/models/user.model.js
:
photo: {
data: Buffer,
contentType: String
}
从编辑表单上传照片
用户将能够在编辑个人资料时从其本地文件中上传图像文件。我们将在client/user/EditProfile.js
中更新EditProfile
组件,添加一个上传照片选项,然后将用户选择的文件附加到提交给服务器的表单数据中。
使用 Material-UI 的文件输入
我们将利用 HTML5 文件输入类型,让用户从其本地文件中选择图像。当用户选择文件时,文件输入将在更改事件中返回文件名。
mern-social/client/user/EditProfile.js
:
<input accept="image/*" type="file"
onChange={this.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="raised" color="default" component="span">
Upload <FileUpload/>
</Button>
</label>
将Button
的组件属性设置为span
,Button
组件将呈现为label
元素内的span
元素。单击Upload
span 或 label 将由具有与 label 相同 ID 的文件输入注册,因此将打开文件选择对话框。用户选择文件后,我们可以在调用handleChange(...)
中将其设置为状态,并在视图中显示名称。
mern-social/client/user/EditProfile.js
:
<span className={classes.filename}>
{this.state.photo ? this.state.photo.name : ''}
</span>
带有附加文件的表单提交
通过表单将文件上传到服务器需要一个多部分表单提交,与之前的实现中发送的stringed
对象形成对比。我们将修改EditProfile
组件,使用FormData
API 将表单数据存储在编码类型multipart/form-data
所需的格式中。
首先,我们需要在componentDidMount()
中初始化FormData
。
mern-social/client/user/EditProfile.js
:
this.userData = new FormData()
接下来,我们将更新输入handleChange
函数,以存储文本字段和文件输入的输入值在FormData
中。
mern-social/client/user/EditProfile.js
:
handleChange = name => event => {
const value = name === 'photo'
? event.target.files[0]
: event.target.value
this.userData.set(name, value)
this.setState({ [name]: value })
}
然后在提交时,this.userData
将与 fetch API 调用一起发送到更新用户。由于发送到服务器的数据的内容类型不再是'application/json'
,因此我们还需要修改api-user.js
中的update
fetch 方法,以在fetch
调用中从标头中删除Content-Type
。
mern-social/client/user/api-user.js
:
const update = (params, credentials, user) => {
return fetch('/api/users/' + params.userId, {
method: 'PUT',
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer ' + credentials.t
},
body: user
}).then((response) => {
return response.json()
}).catch((e) => {
console.log(e)
})
}
现在,如果用户选择在编辑配置文件时上传个人资料照片,服务器将收到附加文件的请求以及其他字段值。
在developer.mozilla.org/en-US/docs/Web/API/FormData上了解有关 FormData API 的更多信息。
处理包含文件上传的请求
在服务器上,为了处理可能包含文件的更新 API 的请求,我们将使用formidable
npm 模块:
npm install --save formidable
Formidable 将允许我们读取multipart
表单数据,从而访问字段和文件(如果有)。如果有文件,formidable
将在文件系统中临时存储它。我们将从文件系统中读取它,使用fs
模块检索文件类型和数据,并将其存储到用户模型中的照片字段中。formidable
代码将放在user.controller.js
中的update
控制器中。
mern-social/server/controllers/user.controller.js
:
import formidable from 'formidable'
import fs from 'fs'
const update = (req, res, next) => {
let form = new formidable.IncomingForm()
form.keepExtensions = true
form.parse(req, (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
}
user.save((err, result) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
user.hashed_password = undefined
user.salt = undefined
res.json(user)
})
})
}
这将把上传的文件存储为数据库中的数据。接下来,我们将设置文件检索以能够在前端视图中访问和显示用户上传的照片。
检索个人资料照片
从数据库中检索文件并在视图中显示的最简单选项是设置一个路由,该路由将获取数据并将其作为图像文件返回给请求的客户端。
个人资料照片 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
组件中,我们从状态中获取用户 ID 并使用它来构建照片 URL。
mern-social/client/user/Profile.js
:
const photoUrl = this.state.user._id
? `/api/users/photo/${this.state.user._id}?${new Date().getTime()}`
: '/api/users/defaultphoto'
为了确保在编辑中更新照片后Profile
视图中的img
元素重新加载,我们还向照片 URL 添加了一个时间值,以绕过浏览器的默认图像缓存行为。
然后,我们可以将photoUrl
设置为 Material-UI 的Avatar
组件,该组件在视图中呈现链接的图像:
<Avatar src={photoUrl}/>
在 MERN Social 中更新的用户个人资料现在可以显示用户上传的个人资料照片和about
描述:
在 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 = (req, res, next, id) => {
User.findById(id)
.populate('following', '_id name')
.populate('followers', '_id name')
.exec((err, user) => {
if (err || !user) return res.status('400').json({
error: "User not found"
})
req.profile = user
next()
})
}
我们使用 Mongoose 的populate
方法来指定从查询返回的用户对象应包含following
和followers
列表中引用的用户的名称和 ID。这将在我们使用读取 API 调用获取用户时,给我们followers
和following
列表中的用户引用的名称和 ID。
关注和取消关注的 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 = (req, res, next) => {
User.findByIdAndUpdate(req.body.userId, {$push: {following: req.body.followId}}, (err, result) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
next()
})
}
在following
数组成功更新后,将执行addFollower
方法,将当前用户的引用添加到被关注用户的'followers'
数组中。
mern-social/server/controllers/user.controller.js
:
const addFollower = (req, res) => {
User.findByIdAndUpdate(req.body.followId, {$push: {followers: req.body.userId}}, {new: true})
.populate('following', '_id name')
.populate('followers', '_id name')
.exec((err, result) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
result.hashed_password = undefined
result.salt = undefined
res.json(result)
})
}
对于取消关注,实现方式类似。removeFollowing
和removeFollower
控制器方法通过使用$pull
而不是$push
从相应的'following'
和'followers'
数组中删除用户引用。
mern-social/server/controllers/user.controller.js
:
const removeFollowing = (req, res, next) => {
User.findByIdAndUpdate(req.body.userId, {$pull: {following: req.body.unfollowId}}, (err, result) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
next()
})
}
const removeFollower = (req, res) => {
User.findByIdAndUpdate(req.body.unfollowId, {$pull: {followers: req.body.userId}}, {new: true})
.populate('following', '_id name')
.populate('followers', '_id name')
.exec((err, result) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
result.hashed_password = undefined
result.salt = undefined
res.json(result)
})
}
在视图中访问关注和取消关注的 API
为了在视图中访问这些 API 调用,我们将使用api-user.js
更新follow
和unfollow
fetch 方法。follow
和unfollow
方法将类似,使用当前用户的 ID 和凭据以及被关注或取消关注的用户的 ID 调用相应的路由。follow
方法将如下所示。
mern-social/client/user/api-user.js
:
const follow = (params, credentials, followId) => {
return 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})
}).then((response) => {
return response.json()
}).catch((err) => {
console.log(err)
})
}
unfollow
的 fetch 方法类似,它获取取消关注的用户 ID,并调用unfollow
API。
mern-social/client/user/api-user.js
:
const unfollow = (params, credentials, unfollowId) => {
return 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})
}).then((response) => {
return response.json()
}).catch((err) => {
console.log(err)
})
}
关注和取消关注按钮
该按钮将允许用户有条件地关注或取消关注另一个用户,具体取决于当前用户是否已关注该用户:
FollowProfileButton 组件
我们将为关注按钮创建一个单独的组件,称为FollowProfileButton
,它将添加到Profile
组件中。该组件将根据当前用户是否已关注个人资料中的用户来显示Follow
或Unfollow
按钮。FollowProfileButton
组件将如下所示。
mern-social/client/user/FollowProfileButton.js
:
class FollowProfileButton extends Component {
followClick = () => {
this.props.onButtonClick(follow)
}
unfollowClick = () => {
this.props.onButtonClick(unfollow)
}
render() {
return (<div>
{ this.props.following
? (<Button variant="raised" color="secondary" onClick=
{this.unfollowClick}>Unfollow</Button>)
: (<Button variant="raised" color="primary" onClick=
{this.followClick}>Follow</Button>)
}
</div>)
}
}
FollowProfileButton.propTypes = {
following: PropTypes.bool.isRequired,
onButtonClick: PropTypes.func.isRequired
}
当FollowProfileButton
添加到个人资料时,'following'
值将从Profile
组件确定并作为 prop 发送到FollowProfileButton
,同时还会发送点击处理程序,该处理程序将特定的follow
或unfollow
fetch API 作为参数调用:
更新个人资料组件
在Profile
视图中,只有在用户查看其他用户的个人资料时才应显示FollowProfileButton
,因此我们需要修改在查看个人资料时显示Edit
和Delete
按钮的条件如下:
{auth.isAuthenticated().user && auth.isAuthenticated().user._id == this.state.user._id
? (edit and delete buttons)
: (follow button)
}
在Profile
组件中,在componentDidMount
成功获取用户数据后,我们将检查已登录用户是否已关注个人资料中的用户,并将following
值设置为状态。
mern-social/client/user/Profile.js
:
let following = this.checkFollow(data)
this.setState({user: data, following: following})
为了确定在following
中设置的值,checkFollow
方法将检查登录用户是否存在于获取的用户的关注者列表中,如果找到,则返回match
,否则如果找不到匹配,则返回undefined
。
mern-social/client/user/Profile.js
:
checkFollow = (user) => {
const jwt = auth.isAuthenticated()
const match = user.followers.find((follower)=> {
return follower._id == jwt.user._id
})
return match
}
Profile
组件还将为FollowProfileButton
定义点击处理程序,因此当关注或取消关注操作完成时,可以更新Profile
的状态。
mern-social/client/user/Profile.js
:
clickFollowButton = (callApi) => {
const jwt = auth.isAuthenticated()
callApi({
userId: jwt.user._id
}, {
t: jwt.token
}, this.state.user._id).then((data) => {
if (data.error) {
this.setState({error: data.error})
} else {
this.setState({user: data, following: !this.state.following})
}
})
}
点击处理程序定义将获取 API 调用作为参数,并在将其添加到Profile
视图时,将其与following
值一起作为 prop 传递给FollowProfileButton
。
mern-social/client/user/Profile.js
:
<FollowProfileButton following={this.state.following} onButtonClick={this.clickFollowButton}/>
列出关注者和粉丝
在每个用户的个人资料中,我们将添加一个关注者列表和他们正在关注的人的列表:
在使用read
API 加载个人资料时,following
和followers
列表中引用的用户的详细信息已经在用户对象中。为了呈现这些单独的关注者和正在关注的人列表,我们将创建一个名为FollowGrid
的新组件。
FollowGrid 组件
FollowGrid
组件将接受用户列表作为 props,显示用户的头像和名称,并链接到每个用户的个人资料。我们可以根据需要将此组件添加到Profile
视图中,以显示followings
或followers
。
mern-social/client/user/FollowGrid.js
:
class FollowGrid extends Component {
render() {
const {classes} = this.props
return (<div className={classes.root}>
<GridList cellHeight={160} className={classes.gridList} cols={4}>
{this.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 = {
classes: PropTypes.object.isRequired,
people: PropTypes.array.isRequired
}
要将FollowGrid
组件添加到Profile
视图中,我们可以根据需要将其放置在视图中,并将followers
或followings
列表作为people
prop 传递:
<FollowGrid people={this.state.user.followers}/>
<FollowGrid people={this.state.user.following}/>
如前所述,在 MERN 社交中,我们选择在Profile
组件内的选项卡中显示FollowGrid
组件。我们使用 Material-UI 选项卡组件创建了一个单独的ProfileTabs
组件,并将其添加到Profile
组件中。这个ProfileTabs
组件包含两个FollowGrid
组件,其中包含关注者和粉丝列表,以及一个PostList
组件,显示用户的帖子。这将在本章后面讨论。
寻找要关注的人
“谁来关注”功能将向登录用户显示 MERN 社交中他们当前未关注的人的列表,提供关注他们或查看他们的个人资料的选项:
获取未关注的用户
我们将在服务器上实现一个新的 API 来查询数据库并获取当前用户未关注的用户列表。
mern-social/server/routes/user.routes.js
:
router.route('/api/users/findpeople/:userId')
.get(authCtrl.requireSignin, userCtrl.findPeople)
在findPeople
控制器方法中,我们将查询数据库中的用户集合,以查找当前用户following
列表中没有的用户。
mern-social/server/controllers/user.controller.js
:
const findPeople = (req, res) => {
let following = req.profile.following
following.push(req.profile._id)
User.find({ _id: { $nin : following } }, (err, users) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
res.json(users)
}).select('name')
}
为了在前端使用这个用户列表,我们将更新api-user.js
以添加对这个查找用户 API 的获取。
mern-social/client/user/api-user.js
:
const findPeople = (params, credentials) => {
return fetch('/api/users/findpeople/' + params.userId, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
}
}).then((response) => {
return response.json()
}).catch((err) => console.log(err))
}
FindPeople 组件
为了显示谁来关注功能,我们将创建一个名为FindPeople
的组件,可以添加到任何视图中或单独呈现。在这个组件中,我们将首先通过调用componentDidMount
中的findPeople
方法来获取未关注的用户。
mern-social/client/user/FindPeople.js
:
componentDidMount = () => {
const jwt = auth.isAuthenticated()
findPeople({
userId: jwt.user._id
}, {
t: jwt.token
}).then((data) => {
if (data.error) {
console.log(data.error)
} else {
this.setState({users: data})
}
})
}
获取的用户列表将被迭代并呈现在 Material-UI 的List
组件中,每个列表项包含用户的头像、名称、到个人资料页面的链接和Follow
按钮。
mern-social/client/user/FindPeople.js
:
<List>{this.state.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="raised" color="secondary"
className={classes.viewButton}>
<ViewIcon/>
</IconButton>
</Link>
<Button aria-label="Follow" variant="raised"
color="primary"
onClick={this.clickFollow.bind(this, item, i)}>
Follow
</Button>
</ListItemSecondaryAction>
</ListItem>
</span>
})
}
</List>
点击Follow
按钮将调用关注 API,并通过删除新关注的用户来更新要关注的用户列表。
mern-social/client/user/FindPeople.js
:
clickFollow = (user, index) => {
const jwt = auth.isAuthenticated()
follow({
userId: jwt.user._id
}, {
t: jwt.token
}, user._id).then((data) => {
if (data.error) {
this.setState({error: data.error})
} else {
let toFollow = this.state.users
toFollow.splice(index, 1)
this.setState({users: toFollow, open: true, followMessage:
`Following ${user.name}!`})
}
})
}
我们还将添加一个 Material-UI 的Snackbar
组件,当用户成功关注时会临时打开,告诉用户他们开始关注这个新用户。
mern-social/client/user/FindPeople.js
:
<Snackbar
anchorOrigin={{ vertical: 'bottom', horizontal: 'right'}}
open={this.state.open}
onClose={this.handleRequestClose}
autoHideDuration={6000}
message={<span className={classes.snack}>{this.state.followMessage}</span>}
/>
Snackbar
将在页面的右下角显示消息,并在设置的持续时间后自动隐藏:
MERN Social 用户现在可以互相关注,查看每个用户的关注和粉丝列表,还可以看到他们可以关注的人的列表。在 MERN Social 中关注另一个用户的主要目的是跟踪他们的社交帖子,所以下一步我们将看一下帖子功能的实现。
帖子
MERN Social 中的发布功能将允许用户在 MERN Social 应用平台上分享内容,并通过评论或点赞帖子与其他用户互动:
用于 Post 的 Mongoose 模式模型
为了存储每个帖子,我们将首先在server/models/post.model.js
中定义 Mongoose 模式。帖子模式将存储帖子的文本内容、照片、发布者的引用、创建时间、用户对帖子的喜欢以及用户对帖子的评论:
- 帖子文本:
文本
将是用户在新帖子创建视图中提供的必填字段:
text: {
type: String,
required: 'Name is required'
}
- 帖子照片:
照片
将在帖子创建时从用户的本地文件上传,并类似于用户个人资料照片上传功能存储在 MongoDB 中。每个帖子的照片将是可选的:
photo: {
data: Buffer,
contentType: String
}
- 发布者:创建帖子将需要用户首先登录,因此我们可以在
postedBy
字段中存储发布帖子的用户的引用:
postedBy: {type: mongoose.Schema.ObjectId, ref: 'User'}
- 创建时间:
创建
时间将在帖子创建时自动生成在数据库中:
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 中实现所有与帖子相关的功能。
新闻订阅组件
在进一步深入 MERN Social 中的发布功能实现之前,我们将查看 Newsfeed 视图的组成,以展示如何设计共享状态的嵌套 UI 组件的基本示例。Newsfeed
组件将包含两个主要的子组件——一个新帖子表单和来自关注用户的帖子列表:
Newsfeed
组件的基本结构将如下所示,包括NewPost
组件和PostList
组件。
mern-social/client/post/Newsfeed.js
:
<Card>
<Typography type="title"> Newsfeed </Typography>
<Divider/>
<NewPost addUpdate={this.addPost}/>
<Divider/>
<PostList removeUpdate={this.removePost} posts={this.state.posts}/>
</Card>
作为父组件,Newsfeed
将控制在子组件中呈现的帖子数据的状态。当在子组件中修改帖子数据时,例如在NewPost
组件中添加新帖子或在PostList
组件中删除帖子时,它将提供一种更新跨组件的帖子状态的方法。
在这里,Newsfeed 中的loadPosts
函数首先调用服务器以从当前登录用户关注的人那里获取帖子列表,并将其设置为状态以在PostList
组件中呈现。 Newsfeed
组件提供了addPost
和removePost
函数给NewPost
和PostList
,当创建新帖子或删除现有帖子时,将用于更新Newsfeed
状态中的帖子列表,并最终在PostList
中反映出来。
在Newsfeed
组件中定义的addPost
函数将获取NewPost
组件中创建的新帖子,并将其添加到状态中的帖子中。
mern-social/client/post/Newsfeed.js
:
addPost = (post) => {
const updatedPosts = this.state.posts
updatedPosts.unshift(post)
this.setState({posts: updatedPosts})
}
在Newsfeed
组件中定义的removePost
函数将从PostList
中的Post
组件中获取已删除的帖子,并从状态中删除它。
mern-social/client/post/Newsfeed.js
:
removePost = (post) => {
const updatedPosts = this.state.posts
const index = updatedPosts.indexOf(post)
updatedPosts.splice(index, 1)
this.setState({posts: updatedPosts})
}
由于帖子是通过这种方式在Newsfeed
的状态中更新的,PostList
将向观众呈现已更改的帖子列表。这种从父组件到子组件再到父组件的状态更新机制将应用于其他功能,例如帖子中的评论更新,以及在Profile
组件中为单个用户呈现PostList
时。
列出帖子
在 MERN Social 中,我们将在Newsfeed
和每个用户的个人资料中列出帖子。我们将创建一个通用的PostList
组件,该组件将呈现提供给它的任何帖子列表,并且我们可以在Newsfeed
和Profile
组件中都使用它。
mern-social/client/post/PostList.js
:
class PostList extends Component {
render() {
return (
<div style={{marginTop: '24px'}}>
{this.props.posts.map((item, i) => {
return <Post post={item} key={i}
onRemove={this.props.removeUpdate}/>
})
}
</div>
)
}
}
PostList.propTypes = {
posts: PropTypes.array.isRequired,
removeUpdate: PropTypes.func.isRequired
}
PostList
组件将遍历从Newsfeed
或Profile
传递给它的帖子列表,并将每个帖子的数据传递给Post
组件,该组件将呈现帖子的详细信息。 PostList
还将传递从父组件作为 prop 发送到Post
组件的removeUpdate
函数,以便在删除单个帖子时更新状态。
在 Newsfeed 中列出
我们将在服务器上设置一个 API,该 API 查询帖子集合,并从指定用户关注的人那里返回帖子。因此,这些帖子可能会在Newsfeed
的PostList
中显示。
帖子的 Newsfeed API
这个特定于 Newsfeed 的 API 将在以下路由接收请求,该路由将在server/routes/post.routes.js
中定义:
router.route('/api/posts/feed/:userId')
.get(authCtrl.requireSignin, postCtrl.listNewsFeed)
我们在这条路线中使用:userID
参数来指定当前登录的用户,并且我们将利用user.controller
中的userByID
控制器方法来获取用户详细信息,就像之前一样,并将它们附加到在listNewsFeed
中访问的请求对象中。因此,还要将以下内容添加到mern-social/server/routes/post.routes.js
中:
router.param('userId', userCtrl.userByID)
post.routes.js
文件将与user.routes.js
文件非常相似,为了在 Express 应用程序中加载这些新路线,我们需要像对 auth 和 user 路线一样在express.js
中挂载 post 路线。
mern-social/server/express.js
:
app.use('/', postRoutes)
post.controller.js
中的listNewsFeed
控制器方法将查询数据库中的 Post 集合以获取匹配的帖子。
mern-social/server/controllers/post.controller.js
:
const listNewsFeed = (req, res) => {
let following = req.profile.following
following.push(req.profile._id)
Post.find({postedBy: { $in : req.profile.following } })
.populate('comments', 'text created')
.populate('comments.postedBy', '_id name')
.populate('postedBy', '_id name')
.sort('-created')
.exec((err, posts) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
res.json(posts)
})
}
在对 Post 集合的查询中,我们找到所有具有与当前用户的关注和当前用户匹配的postedBy
用户引用的帖子。
在视图中获取 Newsfeed 帖子
为了在前端使用此 API,我们将在client/post/api-post.js
中添加一个获取方法:
const listNewsFeed = (params, credentials) => {
return fetch('/api/posts/feed/'+ params.userId, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
}
}).then(response => {
return response.json()
}).catch((err) => console.log(err))
}
这是将加载在PostList
中呈现的帖子的获取方法,它作为Newsfeed
组件的子组件添加。因此,需要在Newsfeed
组件的loadPosts
方法中调用此获取方法。
mern-social/client/post/Newsfeed.js
:
loadPosts = () => {
const jwt = auth.isAuthenticated()
listNewsFeed({
userId: jwt.user._id
}, {
t: jwt.token
}).then((data) => {
if (data.error) {
console.log(data.error)
} else {
this.setState({posts: data})
}
})
}
loadPosts
方法将在Newsfeed
组件的componentDidMount
中调用,以最初加载呈现在PostList
组件中的帖子的状态:
在 Profile 中按用户列出
获取特定用户创建的帖子列表并在Profile
中显示的实现将类似于前一部分中的讨论。我们将在服务器上设置一个 API,该 API 查询 Post 集合,并将特定用户的帖子返回到Profile
视图。
用户的帖子 API
将接收查询以返回特定用户发布的帖子的路线添加到mern-social/server/routes/post.routes.js
中:
router.route('/api/posts/by/:userId')
.get(authCtrl.requireSignin, postCtrl.listByUser)
post.controller.js
中的listByUser
控制器方法将查询 Post 集合,以查找在路线中指定的用户的userId
参数与postedBy
字段中的匹配引用的帖子。
mern-social/server/controllers/post.controller.js
:
const listByUser = (req, res) => {
Post.find({postedBy: req.profile._id})
.populate('comments', 'text created')
.populate('comments.postedBy', '_id name')
.populate('postedBy', '_id name')
.sort('-created')
.exec((err, posts) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
res.json(posts)
})
}
在视图中获取用户帖子
为了在前端使用此 API,我们将在mern-social/client/post/api-post.js
中添加一个获取方法:
const listByUser = (params, credentials) => {
return fetch('/api/posts/by/'+ params.userId, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
}
}).then(response => {
return response.json()
}).catch((err) => console.log(err))
}
这个fetch
方法将加载添加到Profile
视图的PostList
所需的帖子。我们将更新Profile
组件以定义一个loadPosts
方法,该方法调用listByUser
获取方法。
mern-social/client/user/Profile.js
:
loadPosts = (user) => {
const jwt = auth.isAuthenticated()
listByUser({
userId: user
}, {
t: jwt.token
}).then((data) => {
if (data.error) {
console.log(data.error)
} else {
this.setState({posts: data})
}
})
}
在Profile
组件中,当从服务器中的init()
函数中获取用户详细信息后,将调用loadPosts
方法,并传入正在加载的用户的用户 ID。为特定用户加载的帖子将设置为状态,并在添加到Profile
组件的PostList
组件中呈现。Profile
组件还提供了一个removePost
函数,类似于Newsfeed
组件,作为PostList
组件的属性,以便在删除帖子时更新帖子列表。
创建新帖子
创建新帖子功能将允许已登录用户发布消息,并可选择从本地文件上传图片到帖子中。
创建帖子 API
在服务器上,我们将定义一个 API 来在数据库中创建帖子,首先声明一个路由,以接受/api/posts/new/:userId
的 POST 请求,位于mern-social/server/routes/post.routes.js
中。
router.route('/api/posts/new/:userId')
.post(authCtrl.requireSignin, postCtrl.create)
post.controller.js
中的create
方法将使用formidable
模块来访问字段和图像文件(如果有),就像我们为用户配置文件照片更新一样。
mern-social/server/controllers/post.controller.js
:
const create = (req, res, next) => {
let form = new formidable.IncomingForm()
form.keepExtensions = true
form.parse(req, (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
}
post.save((err, result) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
res.json(result)
})
})
}
检索帖子的照片
为了检索上传的照片,我们还将设置一个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
方法访问。在此实现中附加的帖子数据还将包含postedBy
用户引用的 ID 和名称。
mern-social/server/controllers/post.controller.js
:
const postByID = (req, res, next, id) => {
Post.findById(id).populate('postedBy', '_id name').exec((err, post) => {
if (err || !post)
return res.status('400').json({
error: "Post not found"
})
req.post = post
next()
})
}
在视图中获取创建帖子的 API
我们将更新api-post.js
,添加一个create
方法来调用创建 API 的fetch
请求。
mern-social/client/post/api-post.js
:
const create = (params, credentials, post) => {
return fetch('/api/posts/new/'+ params.userId, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer ' + credentials.t
},
body: post
}).then((response) => {
return response.json()
}).catch((err) => {
console.log(err)
})
}
这种方法,就像用户edit
fetch 一样,将使用一个FormData
对象发送一个多部分表单提交,其中可以包含文本字段和图像文件。
NewPost 组件
在Newsfeed
组件中添加的NewPost
组件将允许用户撰写包含文本消息和可选图像的新帖子:
NewPost
组件将是一个标准表单,其中包括一个 Material-UI 的TextField
和一个文件上传按钮,就像在EditProfile
中实现的那样,它会获取这些值并将它们设置在一个FormData
对象中,以便在提交帖子时传递给create
fetch 方法。
mern-social/client/post/NewPost.js
:
clickPost = () => {
const jwt = auth.isAuthenticated()
create({
userId: jwt.user._id
}, {
t: jwt.token
}, this.postData).then((data) => {
if (data.error) {
this.setState({error: data.error})
} else {
this.setState({text:'', photo: ''})
this.props.addUpdate(data)
}
})
}
NewPost
组件被添加为Newsfeed
中的子组件,并且作为一个 prop 给予addUpdate
方法。在成功创建帖子后,表单视图将被清空,并且将执行addUpdate
,以便在Newsfeed
中更新帖子列表。
帖子组件
每个帖子中的帖子详细信息将在Post
组件中呈现,该组件将从PostList
组件中接收帖子数据作为 props,以及onRemove
prop,以便在删除帖子时应用。
布局
Post
组件布局将包括一个显示发帖人详细信息的标题,帖子内容,带有赞和评论计数的操作栏,以及评论部分:
标题
标题将包含诸如姓名、头像、指向发帖用户个人资料的链接以及帖子创建日期等信息。
mern-social/client/post/Post.js
:
<CardHeader
avatar={<Avatar src={'/api/users/photo/'+this.props.post.postedBy._id}/>}
action={this.props.post.postedBy._id ===
auth.isAuthenticated().user._id &&
<IconButton onClick={this.deletePost}>
<DeleteIcon />
</IconButton>
}
title={<Link to={"/user/" + this.props.post.postedBy._id}>
{this.props.post.postedBy.name}
</Link>}
subheader={(new Date(this.props.post.created)).toDateString()}
className={classes.cardHeader}
/>
标题还将有条件地显示一个“删除”按钮,如果已登录用户正在查看自己的帖子。
内容
内容部分将显示帖子的文本内容以及帖子包含照片的情况。
mern-social/client/post/Post.js
:
<CardContent className={classes.cardContent}>
<Typography component="p" className={classes.text}>
{this.props.post.text}
</Typography>
{this.props.post.photo &&
(<div className={classes.photo}>
<img className={classes.media}
src={'/api/posts/photo/'+this.props.post._id}/>
</div>)
}
</CardContent>
操作
操作部分将包含一个交互式的“喜欢”选项,显示帖子上的总赞数,以及一个评论图标,显示帖子上的总评论数。
mern-social/client/post/Post.js
:
<CardActions>
{ this.state.like
? <IconButton onClick={this.like} className={classes.button}
aria-label="Like" color="secondary">
<FavoriteIcon />
</IconButton>
:<IconButton onClick={this.like} className={classes.button}
aria-label="Unlike" color="secondary">
<FavoriteBorderIcon />
</IconButton>
} <span> {this.state.likes} </span>
<IconButton className={classes.button}
aria-label="Comment" color="secondary">
<CommentIcon/>
</IconButton> <span>{this.state.comments.length}</span>
</CardActions>
评论
评论部分将包含Comments
组件中的所有与评论相关的元素,并将获得诸如postId
和comments
数据等props
,以及一个state
更新方法,当在Comments
组件中添加或删除评论时可以调用。
mern-social/client/post/Post.js
:
<Comments postId={this.props.post._id}
comments={this.state.comments}
updateComments={this.updateComments}/>
删除帖子
只有在登录用户和postedBy
用户对于正在呈现的特定帖子是相同时,delete
按钮才可见。为了从数据库中删除帖子,我们将不得不设置一个删除帖子 API,该 API 在单击delete
时也将在前端应用中有一个 fetch 方法。
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 的其余实现与其他 API 实现相同,具有remove
控制器方法和用于前端的 fetch 方法。在删除帖子功能中的重要区别在于,在成功删除时在Post
组件中调用onRemove
更新方法。onRemove
方法作为 prop 从Newsfeed
或Profile
发送,以在成功删除时更新状态中的帖子列表。
在Post
组件中定义的以下deletePost
方法在单击帖子上的delete
按钮时被调用。
mern-social/client/post/Post.js
:
deletePost = () => {
const jwt = auth.isAuthenticated()
remove({
postId: this.props.post._id
}, {
t: jwt.token
}).then((data) => {
if (data.error) {
console.log(data.error)
} else {
this.props.onRemove(this.props.post)
}
})
}
此方法调用删除帖子 API 的 fetch 调用,并在成功时通过执行从父组件接收的onRemove
方法更新状态中的帖子列表。
喜欢
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 来查找帖子文档,并通过将当前用户的 ID 推送到likes
数组来更新它。
mern-social/server/controllers/post.controller.js
:
const like = (req, res) => {
Post.findByIdAndUpdate(req.body.postId,
{$push: {likes: req.body.userId}}, {new: true})
.exec((err, result) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
res.json(result)
})
}
为了使用此 API,将在api-post.js
中添加一个名为like
的 fetch 方法,当用户点击like
按钮时将使用该方法。
mern-social/client/post/api-post.js
:
const like = (params, credentials, postId) => {
return 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})
}).then((response) => {
return response.json()
}).catch((err) => {
console.log(err)
})
}
取消喜欢 API
“不喜欢”API 将类似于喜欢 API 进行实现,其自己的路由在mern-social/server/routes/post.routes.js
中:
router.route('/api/posts/unlike')
.put(authCtrl.requireSignin, postCtrl.unlike)
控制器中的“不喜欢”方法将通过其 ID 找到帖子,并使用$pull
而不是$push
更新likes
数组,从而删除当前用户的 ID。
mern-social/server/controllers/post.controller.js
:
const unlike = (req, res) => {
Post.findByIdAndUpdate(req.body.postId, {$pull: {likes: req.body.userId}}, {new: true})
.exec((err, result) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
res.json(result)
})
}
不喜欢 API 还将有一个类似于api-post.js
中的like
方法的对应获取方法。
检查是否喜欢并计算喜欢的数量
当渲染Post
组件时,我们需要检查当前登录的用户是否喜欢帖子,以便显示适当的like
选项。
mern-social/client/post/Post.js
:
checkLike = (likes) => {
const jwt = auth.isAuthenticated()
let match = likes.indexOf(jwt.user._id) !== -1
return match
}
checkLike
函数可以在Post
组件的componentDidMount
和componentWillReceiveProps
期间调用,以在检查当前用户是否在帖子的likes
数组中引用后为帖子设置like
状态:
使用checkLike
方法在状态中设置的like
值可以用于渲染心形轮廓按钮或完整的心形按钮。如果用户尚未喜欢帖子,将呈现心形轮廓按钮,点击后将调用like
API,显示完整的心形按钮,并增加likes
计数。完整的心形按钮将指示当前用户已经喜欢了这篇帖子,点击这将调用unlike
API,呈现心形轮廓按钮,并减少likes
计数。
当Post
组件挂载并且通过设置this.props.post.likes.length
将likes
值设置为状态时,likes
计数也会最初设置。
mern-social/client/post/Post.js
:
componentDidMount = () => {
this.setState({like:this.checkLike(this.props.post.likes),
likes: this.props.post.likes.length,
comments: this.props.post.comments})
}
componentWillReceiveProps = (props) => {
this.setState({like:this.checkLike(props.post.likes),
likes: props.post.likes.length,
comments: props.post.comments})
}
当喜欢或不喜欢操作发生时,更新帖子数据并从 API 调用返回时,likes
相关的值也会再次更新。
处理类似点击
为了处理对“喜欢”和“不喜欢”按钮的点击,我们将设置一个“喜欢”方法,该方法将根据是喜欢还是不喜欢操作调用适当的获取方法,并更新帖子的“喜欢”和“喜欢”计数的状态。
mern-social/client/post/Post.js
:
like = () => {
let callApi = this.state.like ? unlike : like
const jwt = auth.isAuthenticated()
callApi({
userId: jwt.user._id
}, {
t: jwt.token
}, this.props.post._id).then((data) => {
if (data.error) {
console.log(data.error)
} else {
this.setState({like: !this.state.like, likes:
data.likes.length})
}
})
}
评论
每篇帖子中的评论部分将允许已登录用户添加评论,查看评论列表,并删除自己的评论。评论列表的任何更改,例如新添加或删除,都将更新评论,以及Post
组件的操作栏部分中的评论计数:
添加评论
当用户添加评论时,帖子文档将在数据库中更新为新评论。
评论 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 = (req, res) => {
let comment = req.body.comment
comment.postedBy = req.body.userId
Post.findByIdAndUpdate(req.body.postId,
{$push: {comments: comment}}, {new: true})
.populate('comments.postedBy', '_id name')
.populate('postedBy', '_id name')
.exec((err, result) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
res.json(result)
})
}
在响应中,更新后的帖子对象将与帖子和评论中的postedBy
用户的详细信息一起发送回来。
要在视图中使用此 API,我们将在api-post.js
中设置一个 fetch 方法,该方法获取当前用户的 ID、帖子 ID 和视图中的comment
对象,以便与添加评论请求一起发送。
mern-social/client/post/api-post.js
:
const comment = (params, credentials, postId, comment) => {
return 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})
}).then((response) => {
return response.json()
}).catch((err) => {
console.log(err)
})
}
在视图中写一些东西
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={this.addComment}
multiline
value={this.state.text}
onChange={this.handleChange('text')}
placeholder="Write something ..."
className={classes.commentField}
margin="normal"/>}
className={classes.cardHeader}
/>
当值改变时,文本将存储在状态中,并且在onKeyDown
事件上,如果按下Enter键,addComment
方法将调用comment
fetch 方法。
mern-social/client/post/Comments.js
:
addComment = (event) => {
if(event.keyCode == 13 && event.target.value){
event.preventDefault()
const jwt = auth.isAuthenticated()
comment({
userId: jwt.user._id
}, {
t: jwt.token
}, this.props.postId, {text: this.state.text}).then((data) => {
if (data.error) {
console.log(data.error)
} else {
this.setState({text: ''})
this.props.updateComments(data.comments)
}
})
}
}
Comments
组件从Post
组件中作为 prop 接收updateComments
方法(在上一节中讨论)。当添加新评论时,将执行此方法,以更新帖子视图中的评论和评论计数。
列出评论
Comments
组件从Post
组件中作为 prop 接收特定帖子的评论列表,然后迭代每个评论以呈现评论者的详细信息和评论内容。
mern-social/client/post/Comments.js
:
{this.props.comments.map((item, i) => {
return <CardHeader
avatar={
<Avatar src=
{'/api/users/photo/'+item.postedBy._id}/>
}
title={commentBody(item)}
className={classes.cardHeader}
key={i}/>
})
}
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={this.deleteComment(item)}
className={classes.commentDelete}>delete</Icon> }
</span>
</p>
)
}
如果评论的postedBy
引用与当前已登录用户匹配,commentBody
还将呈现评论的删除选项。
删除评论
在评论中点击删除按钮将通过从数据库中的comments
数组中移除评论来更新帖子:
取消评论 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 = (req, res) => {
let comment = req.body.comment
Post.findByIdAndUpdate(req.body.postId, {$pull: {comments: {_id: comment._id}}}, {new: true})
.populate('comments.postedBy', '_id name')
.populate('postedBy', '_id name')
.exec((err, result) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
res.json(result)
})
}
更新后的帖子将像评论 API 中一样在响应中返回。
为了在视图中使用这个 API,我们还将在api-post.js
中设置一个 fetch 方法,类似于添加comment
的 fetch 方法,该方法需要当前用户的 ID、帖子 ID 和已删除的comment
对象,以发送uncomment
请求。
从视图中移除评论
当评论者点击评论的删除按钮时,Comments
组件将调用deleteComment
方法来获取uncomment
API,并在评论成功从服务器中移除时更新评论以及评论计数。
mern-social/client/post/Comments.js
:
deleteComment = comment => event => {
const jwt = auth.isAuthenticated()
uncomment({
userId: jwt.user._id
}, {
t: jwt.token
}, this.props.postId, comment).then((data) => {
if (data.error) {
console.log(data.error)
} else {
this.props.updateComments(data.comments)
}
})
}
评论计数更新
updateComments
方法用于在Post
组件中定义,并作为 prop 传递给Comments
组件,以便在添加或删除评论时更新comments
和评论计数。
mern-social/client/post/Post.js
:
updateComments = (comments) => {
this.setState({comments: comments})
}
该方法将更新后的评论列表作为参数,并更新保存在视图中的评论列表的状态。当Post
组件挂载时,评论的初始状态在Post
组件中设置,并作为 props 接收帖子数据。这里设置的评论作为 props 发送到Comments
组件,并用于在帖子布局的操作栏中渲染评论计数旁边的点赞操作。
mern-social/client/post/Post.js
:
<IconButton aria-label="Comment" color="secondary">
<CommentIcon/>
</IconButton> <span>{this.state.comments.length}</span>
Post
组件中评论计数与Comments
组件中渲染和更新的评论之间的关系,再次简单演示了在 React 中如何在嵌套组件之间共享更改的数据,以创建动态和交互式用户界面。
MERN 社交应用程序已经完整地具备了我们之前为应用程序定义的功能集。用户可以更新其个人资料,上传照片和描述,在应用程序上互相关注,并创建带有照片和文字的帖子,以及对帖子点赞和评论。这里展示的实现可以进一步调整和扩展,以添加更多功能,利用 MERN 堆栈的工作机制。
总结
本章开发的 MERN 社交应用程序演示了如何将 MERN 堆栈技术一起使用,构建出具有社交媒体功能的功能齐全的网络应用程序。
我们首先更新了骨架应用程序中的用户功能,允许在 MERN 社交上拥有账户的任何人添加关于自己的描述,并从本地文件上传个人资料图片。在上传个人资料图片的实现中,我们探讨了如何从客户端上传多部分表单数据,然后在服务器上接收它,直接将文件数据存储在 MongoDB 数据库中,然后能够检索回来进行查看。
接下来,我们进一步更新了用户功能,允许用户在 MERN 社交平台上互相关注。在用户模型中,我们添加了维护用户引用数组的功能,以表示每个用户的关注者和关注者列表。扩展了这一功能,我们在视图中加入了关注和取消关注选项,并显示了关注者、被关注者甚至尚未关注的用户列表。
然后,我们添加了允许用户发布内容并通过点赞或评论进行互动的功能。在后端,我们设置了帖子模型和相应的 API,能够存储可能包含或不包含图像的帖子内容,并记录任何用户在帖子上产生的点赞和评论。
最后,在实现发布、点赞和评论功能的视图时,我们探讨了如何使用组件组合和共享组件之间的状态值来创建复杂和交互式视图。
在下一章中,我们将进一步扩展 MERN 堆栈的这些能力,并在扩展 MERN 骨架应用程序的同时,开启新的可能性,开发一个在线市场应用程序。
第六章:通过在线市场锻炼新的 MERN 技能
随着越来越多的企业继续转向网络,能够在在线市场环境中进行买卖已经成为许多网络平台的核心要求。在本章和下一章中,我们将利用 MERN 堆栈技术开发一个在线市场应用程序,其中包括使用户能够买卖的功能。
在本章中,我们将通过扩展 MERN 骨架来构建在线市场,添加以下功能:
-
具有卖家账户的用户
-
商店管理
-
产品管理
-
按名称和类别搜索产品
MERN Marketplace
MERN Marketplace 应用程序将允许用户成为卖家,他们可以管理多个商店,并在每个商店中添加他们想要出售的产品。访问 MERN Marketplace 的用户将能够搜索和浏览他们想要购买的产品,并将产品添加到购物车中以下订单:
完整的 MERN Marketplace 应用程序的代码可在 GitHub 上找到:github.com/shamahoque/mern-marketplace。本章讨论的实现可以在存储库的 seller-shops-products 分支中访问。您可以在阅读本章其余部分的代码解释时,克隆此代码并运行应用程序。
与卖家账户、商店和产品相关的功能所需的视图将通过扩展和修改 MERN 骨架应用程序中的现有 React 组件来开发。下图显示的组件树展示了本章中开发的 MERN Marketplace 前端中的所有自定义 React 组件:
用户作为卖家
在 MERN Marketplace 注册的任何用户都可以选择通过更新其个人资料成为卖家:
与成为普通用户相比,成为卖家将允许用户创建和管理自己的商店,他们可以在其中管理产品:
为了添加这个卖家功能,我们需要更新用户模型、编辑个人资料视图,并在菜单中添加一个“我的商店”链接,只有卖家才能看到。
更新用户模型
用户模型将需要一个卖家值,默认情况下将其设置为false
以表示普通用户,并且可以将其设置为true
以表示也是卖家的用户。
mern-marketplace/server/models/user.model.js
:
seller: {
type: Boolean,
default: false
}
卖家值必须与成功登录时收到的用户详细信息一起发送到客户端,以便视图可以相应地呈现与卖家相关的信息。
更新编辑个人资料视图
已登录用户将在编辑个人资料视图中看到一个切换按钮,用于激活或停用卖家功能。我们将更新EditProfile
组件,在FormControlLabel
中添加Material-UI
的Switch
组件。
mern-marketplace/client/user/EditProfile.js
:
<Typography type="subheading" component="h4" className={classes.subheading}>
Seller Account
</Typography>
<FormControlLabel
control = { <Switch classes={{ checked: classes.checked, bar: classes.bar}}
checked={this.state.seller}
onChange={this.handleCheck}
/> }
label={this.state.seller? 'Active' : 'Inactive'}
/>
通过调用handleCheck
方法,对切换进行的任何更改都将设置为状态中seller
的值。
mern-marketplace/client/user/EditProfile.js
:
handleCheck = (event, checked) => {
this.setState({'seller': checked})
}
提交时,seller
值将被添加到发送到服务器的详细信息中。
mern-marketplace/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,
seller: this.state.seller
}
update({
userId: this.match.params.userId
}, {
t: jwt.token
}, user).then((data) => {
if (data.error) {
this.setState({error: data.error})
} else {
auth.updateUser(data, ()=> {
this.setState({'userId':data._id,'redirectToProfile':true})
})
}
})
}
成功更新后,存储在sessionStorage
中的用户详细信息也应该更新。调用auth.updateUser
方法来进行sessionStorage
的更新。它与其他auth-helper.js
方法一起定义,并传递更新后的用户数据和一个更新视图的回调函数作为参数。
mern-marketplace/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()
}
}
}
更新菜单
在导航栏中,为了有条件地显示一个链接到我的商店,该链接只对已登录的也是卖家的用户可见,我们将更新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 Marketplace 上的卖家可以创建商店,并向每个商店添加产品。为了存储商店数据并启用商店管理,我们将实现一个用于商店的 Mongoose 模式,用于访问和修改商店数据的后端 API,以及用于商店所有者和买家浏览市场的前端视图。
商店模型
在server/models/shop.model.js
中定义的商店模式将具有简单的字段来存储商店详细信息,以及一个标志图像和拥有该商店的用户的引用。
- 商店名称和描述:名称和描述字段将是字符串类型,其中
name
是一个必填字段:
name: {
type: String,
trim: true,
required: 'Name is required'
},
description: {
type: String,
trim: true
},
- 商店标志图像:
image
字段将存储用户上传的标志图像文件,作为 MongoDB 数据库中的数据:
image: {
data: Buffer,
contentType: String
},
- 商店所有者:所有者字段将引用创建商店的用户:
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
在后端,我们将添加一个 POST 路由,验证当前用户是否为卖家,并使用请求中传递的商店数据创建一个新的商店。
mern-marketplace/server/routes/shop.routes.js
:
router.route('/api/shops/by/:userId')
.post(authCtrl.requireSignin,authCtrl.hasAuthorization,
userCtrl.isSeller, shopCtrl.create)
shop.routes.js
文件将与user.routes
文件非常相似,为了在 Express 应用程序中加载这些新路由,我们需要在express.js
中挂载商店路由,就像我们为 auth 和 user 路由所做的那样。
mern-marketplace/server/express.js
:
app.use('/', shopRoutes)
我们将更新用户控制器以添加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
npm 模块来解析可能包含用户上传的商店标志图片文件的多部分请求。如果有文件,formidable
将在文件系统中临时存储它,然后我们将使用fs
模块来读取它,以检索文件类型和数据,以将其存储到商店文档中的image
字段中。
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
在前端,为了使用这个创建 API,我们将在client/shop/api-shop.js
中设置一个fetch
方法,通过传递多部分表单数据向创建 API 发出 POST 请求:
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
组件中,我们将呈现一个表单,允许卖家输入名称和描述,并从其本地文件系统上传商店的标志图像文件:
我们将使用 Material-UI 按钮和 HTML5 文件输入元素添加文件上传元素。
mern-marketplace/client/shop/NewShop.js
:
<input accept="image/*" onChange={this.handleChange('image')}
style={display:'none'} id="icon-button-file" type="file" />
<label htmlFor="icon-button-file">
<Button raised color="secondary" component="span">
Upload Logo <FileUpload/>
</Button>
</label>
<span> {this.state.image ? this.state.image.name : ''} </span>
名称和描述表单字段将使用TextField
组件添加。
mern-marketplace/client/shop/NewShop.js
:
<TextField
id="name"
label="Name"
value={this.state.name}
onChange={this.handleChange('name')}/> <br/>
<TextField
id="multiline-flexible"
label="Description"
multiline rows="2"
value={this.state.description}
onChange={this.handleChange('description')}/>
这些表单字段的更改将通过handleChange
方法进行跟踪。
mern-marketplace/client/shop/NewShop.js
:
handleChange = name => event => {
const value = name === 'image'
? event.target.files[0]
: event.target.value
this.shopData.set(name, value)
this.setState({ [name]: value })
}
handleChange
方法更新状态并填充shopData
,这是一个FormData
对象,确保数据以multipart/form-data
编码类型存储在正确的格式中。shopData
对象在componentDidMount
中初始化。
mern-marketplace/client/shop/NewShop.js
:
componentDidMount = () => {
this.shopData = new FormData()
}
在表单提交时,clickSubmit
函数中将调用create
fetch 方法。
mern-marketplace/client/shop/NewShop.js
:
clickSubmit = () => {
const jwt = auth.isAuthenticated()
create({
userId: jwt.user._id
}, {
t: jwt.token
}, this.shopData).then((data) => {
if (data.error) {
this.setState({error: data.error})
} else {
this.setState({error: '', redirect: true})
}
})
}
在成功创建商店后,用户将被重定向回MyShops
视图。
mern-marketplace/client/shop/NewShop.js
:
if (this.state.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 中,普通用户将能够浏览平台上所有商店的列表,商店所有者将管理他们自己商店的列表。
列出所有商店
所有商店的列表将从后端获取并显示给最终用户。
商店列表 API
在后端,当服务器在'/api/shops'
路径接收到 GET 请求时,我们将在server/routes/shop.routes.js
中添加一个路由来检索数据库中存储的所有商店:
router.route('/api/shops')
.get(shopCtrl.list)
shop.controller.js
中的list
控制器方法将查询数据库中的商店集合,以返回所有商店。
mern-marketplace/server/controllers/shop.controller.js
:
const list = (req, res) => {
Shop.find((err, shops) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
res.json(shops)
})
}
获取视图的所有商店
在前端,为了使用此列表 API 获取商店,我们将在client/shop/api-shop.js
中设置一个fetch
方法:
const list = () => {
return fetch('/api/shops', {
method: 'GET',
}).then(response => {
return response.json()
}).catch((err) => console.log(err))
}
商店组件
在“商店”组件中,我们将在 Material-UIList
中呈现商店列表,在组件挂载时获取数据并将数据设置为状态:
在componentDidMount
中调用loadShops
方法以在组件挂载时加载商店。
mern-marketplace/client/shop/Shops.js
:
componentDidMount = () => {
this.loadShops()
}
它使用list
fetch 方法来检索商店列表并将数据设置为状态。
mern-marketplace/client/shop/Shops.js
:
loadShops = () => {
list().then((data) => {
if (data.error) {
console.log(data.error)
} else {
this.setState({shops: data})
}
})
}
在“商店”组件中,使用map
迭代检索到的商店数组,每个商店的数据在视图中以 Material-UIListItem
的形式呈现,每个ListItem
也链接到单独的商店视图。
mern-marketplace/client/shop/Shops.js
:
{this.state.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>
<Typography type="headline" component="h2"
color="primary">
{shop.name}
</Typography>
<Typography type="subheading" component="h4">
{shop.description}
</Typography>
</div>
</ListItem><Divider/>
</Link>})}
“商店”组件将由最终用户在/shops/all
访问,使用 React Router 设置并在MainRouter.js
中声明。
mern-marketplace/client/MainRouter.js
:
<Route path="/shops/all" component={Shops}/>
按所有者列出商店
经授权的卖家将看到他们创建的商店列表,他们可以通过编辑或删除列表上的任何商店来管理。
按所有者查询商店 API
我们将在后端声明的商店路由中添加一个 GET 路由,以检索特定用户拥有的商店。
mern-marketplace/server/routes/shop.routes.js
:
router.route('/api/shops/by/:userId')
.get(authCtrl.requireSignin, authCtrl.hasAuthorization, shopCtrl.listByOwner)
为了处理:userId
参数并从数据库中检索关联的用户,我们将在用户控制器中利用userByID
方法。我们将在shop.routes.js
的Shop
路由中添加以下内容,以便用户作为profile
在request
对象中可用。
mern-marketplace/server/routes/shop.routes.js
:
router.param('userId', userCtrl.userByID)
shop.controller.js
中的listByOwner
控制器方法将查询数据库中的Shop
集合以获取匹配的商店。
mern-marketplace/server/controllers/shop.controller.js
:
const listByOwner = (req, res) => {
Shop.find({owner: req.profile._id}, (err, shops) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
res.json(shops)
}).populate('owner', '_id name')
}
在对商店集合的查询中,我们找到所有owner
字段与使用userId
参数指定的用户匹配的商店。
获取用户拥有的所有商店以供查看
在前端,为了使用此按所有者列表 API 获取特定用户的商店,我们将在client/shop/api-shop.js
中添加一个 fetch 方法:
const listByOwner = (params, credentials) => {
return fetch('/api/shops/by/'+params.userId, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer ' + credentials.t
}
}).then((response) => {
return response.json()
}).catch((err) => {
console.log(err)
})
}
MyShops 组件
MyShops
组件类似于Shops
组件,它在componentDIdMount
中获取当前用户拥有的商店列表,并在ListItem
中呈现每个商店:
此外,每个商店都有“编辑”和“删除”选项,而不像“商店”中的物品列表。
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={this.removeShop}/>
</ListItemSecondaryAction>
编辑
按钮链接到编辑商店视图。DeleteShop
组件处理删除操作,并通过调用从MyShops
传递的removeShop
方法来更新列表,以更新当前用户的修改后的商店列表状态。
mern-marketplace/client/shop/MyShops.js
:
removeShop = (shop) => {
const updatedShops = this.state.shops
const index = updatedShops.indexOf(shop)
updatedShops.splice(index, 1)
this.setState({shops: updatedShops})
}
MyShops
组件只能被已登录且也是卖家的用户查看。因此,我们将在MainRouter
组件中添加一个PrivateRoute
,仅为授权用户在/seller/shops
处呈现此组件。
mern-marketplace/client/MainRouter.js
:
<PrivateRoute path="/seller/shops" component={MyShops}/>
展示一个商店
任何浏览 MERN Marketplace 的用户都可以浏览每个单独的商店。
读取商店 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
方法中使用。
mern-marketplace/server/controllers/shop.controller.js
:
const shopByID = (req, res, next, id) => {
Shop.findById(id).populate('owner', '_id name').exec((err, shop) => {
if (err || !shop)
return res.status('400').json({
error: "Shop not found"
})
req.shop = shop
next()
})
}
然后read
控制器方法将这个shop
对象返回给客户端的响应中。
mern-marketplace/server/controllers/shop.controller.js
:
const read = (req, res) => {
return res.json(req.shop)
}
在视图中获取商店
在api-shop.js
中,我们将添加一个fetch
方法来在前端使用这个读取 API。
mern-marketplace/client/shop/api-shop.js
:
const read = (params, credentials) => {
return fetch('/api/shop/' + params.shopId, {
method: 'GET'
}).then((response) => {
return response.json()
}).catch((err) => console.log(err) )
}
商店组件
Shop
组件将呈现商店的详细信息,还使用产品列表组件呈现指定商店的产品列表,这将在产品部分讨论:
Shop
组件可以在浏览器中通过/shops/:shopId
路由访问,该路由在MainRouter
中定义如下。
mern-marketplace/client/MainRouter.js
:
<Route path="/shops/:shopId" component={Shop}/>
在componentDidMount
中,使用api-shop.js
中的read
方法获取商店详情。
mern-marketplace/client/shop/Shop.js
:
componentDidMount = () => {
read({
shopId: this.match.params.shopId
}).then((data) => {
if (data.error) {
this.setState({error: data.error})
} else {
this.setState({shop: data})
}
})
}
检索到的商店数据被设置为状态,并在视图中呈现以显示商店的名称、logo 和描述。
mern-marketplace/client/shop/Shop.js
:
<CardContent>
<Typography type="headline" component="h2">
{this.state.shop.name}
</Typography><br/>
<Avatar src={logoUrl}/><br/>
<Typography type="subheading" component="h2">
{this.state.shop.description}
</Typography><br/>
</CardContent>
如果存在,logoUrl
指向从数据库中检索 logo 图像的路由,并定义如下。
mern-marketplace/client/shop/Shop.js
:
const logoUrl = this.state.shop._id
? `/api/shops/logo/${this.state.shop._id}?${new Date().getTime()}`
: '/api/shops/defaultphoto'
编辑一个商店
授权卖家也可以编辑他们拥有的商店的详细信息。
编辑商店 API
在后端,我们将添加一个PUT
路由,允许授权的卖家编辑他们的商店之一。
mern-marketplace/server/routes/shop.routes.js
:
router.route('/api/shops/:shopId')
.put(authCtrl.requireSignin, shopCtrl.isOwner, shopCtrl.update)
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()
}
update
控制器方法将使用formidable
和fs
模块,如前面讨论的create
控制器方法一样,解析表单数据并更新数据库中的现有商店。
mern-marketplace/server/controllers/shop.controller.js
:
const update = (req, res, next) => {
let form = new formidable.IncomingForm()
form.keepExtensions = true
form.parse(req, (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
}
shop.save((err) => {
if (err) {
return res.status(400).send({
error: errorHandler.getErrorMessage(err)
})
}
res.json(shop)
})
})
}
在视图中获取编辑 API
在视图中使用fetch
方法调用编辑 API,该方法获取表单数据并将多部分请求发送到后端。
mern-marketplace/client/shop/api-shop.js
:
const update = (params, credentials, shop) => {
return fetch('/api/shops/' + params.shopId, {
method: 'PUT',
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer ' + credentials.t
},
body: shop
}).then((response) => {
return response.json()
}).catch((err) => {
console.log(err)
})
}
EditShop 组件
EditShop
组件将显示一个类似于创建新商店表单的表单,预先填充现有商店的详细信息。该组件还将显示该商店中的产品列表,将在产品部分中讨论:
表单部分类似于NewShop
组件中的表单,具有相同的表单字段和一个formData
对象,该对象保存了与update
fetch 方法一起发送的多部分表单数据。
EditShop
组件只能被授权的商店所有者访问。因此,我们将在MainRouter
组件中添加一个PrivateRoute
,该组件将仅为/seller/shop/edit/:shopId
上的授权用户呈现此组件。
mern-marketplace/client/MainRouter.js
:
<PrivateRoute path="/seller/shop/edit/:shopId" component={EditShop}/>
这个链接是在MyShops
组件中为每个商店添加的编辑图标。
删除商店
授权的卖家可以从MyShops
列表中删除他们自己的任何商店。
删除商店 API
在后端,我们将添加一个DELETE
路由,允许授权的卖家删除他们自己的商店之一。
mern-marketplace/server/routes/shop.routes.js
:
router.route('/api/shops/:shopId')
.delete(authCtrl.requireSignin, shopCtrl.isOwner, shopCtrl.remove)
remove
控制器方法从数据库中删除指定的商店,如果isOwner
确认已登录的用户是商店的所有者。
mern-marketplace/server/controllers/shop.controller.js
:
const remove = (req, res, next) => {
let shop = req.shop
shop.remove((err, deletedShop) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
res.json(deletedShop)
})
}
在视图中获取删除 API
我们将在前端添加一个相应的方法,向删除 API 发出删除请求。
mern-marketplace/client/shop/api-shop.js
:
const remove = (params, credentials) => {
return fetch('/api/shops/' + params.shopId, {
method: 'DELETE',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
}
}).then((response) => {
return response.json()
}).catch((err) => {
console.log(err)
})
}
DeleteShop 组件
DeleteShop
组件添加到MyShops
组件中,用于列表中的每个商店。它从MyShops
中获取shop
对象和onRemove
方法作为 props:
该组件基本上是一个图标按钮,点击后会打开一个确认对话框,询问用户是否确定要删除他们的商店。
mern-marketplace/client/shop/DeleteShop.js
:
<IconButton aria-label="Delete" onClick={this.clickButton} color="secondary">
<DeleteIcon/>
</IconButton>
<Dialog open={this.state.open} onRequestClose={this.handleRequestClose}>
<DialogTitle>{"Delete "+this.props.shop.name}</DialogTitle>
<DialogContent>
<DialogContentText>
Confirm to delete your shop {this.props.shop.name}.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={this.handleRequestClose} color="primary">
Cancel
</Button>
<Button onClick={this.deleteShop} color="secondary"
autoFocus="autoFocus">
Confirm
</Button>
</DialogActions>
</Dialog>
在对话框中用户确认删除后,将调用deleteShop
中的delete
获取方法。
mern-marketplace/client/shop/DeleteShop.js
:
deleteShop = () => {
const jwt = auth.isAuthenticated()
remove({
shopId: this.props.shop._id
}, {t: jwt.token}).then((data) => {
if (data.error) {
console.log(data.error)
} else {
this.setState({open: false}, () => {
this.props.onRemove(this.props.shop)
})
}
})
}
成功删除后,对话框将关闭,并通过调用onRemove
属性更新MyShops
中的商店列表,该属性从MyShops
中作为属性传递的removeShop
方法获取。
这些商店视图将允许买家和卖家与商店互动。商店还将拥有产品,接下来将讨论,业主将管理这些产品,买家将通过浏览并选择添加到购物车的选项。
产品
产品是市场应用程序中最关键的方面。在 MERN Marketplace 中,卖家可以管理他们商店中的产品,访问者可以搜索和浏览产品。
产品模型
产品将存储在数据库中的产品集合中,使用 Mongoose 定义的模式。对于 MERN Marketplace,我们将保持产品模式简单,支持诸如产品名称、描述、图像、类别、数量、价格、创建时间、更新时间以及对商店的引用等字段。
- 产品名称和描述:
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 Marketplace 中实现所有与产品相关的功能。
创建新产品
在 MERN Marketplace 中,卖家将能够向他们拥有的商店和平台上创建的商店添加新产品。
创建产品 API
在后端,我们将在/api/products/by/:shopId
添加一个路由,接受包含产品数据的POST
请求,以创建一个与:shopId
参数标识的商店相关联的新产品。处理这个请求的代码将首先检查当前用户是否是将要添加新产品的商店的所有者,然后在数据库中创建新产品。
这个创建产品 API 路由在product.routes.js
文件中声明,并利用了商店控制器中的shopByID
和isOwner
方法来处理:shopId
参数,并验证当前用户是否为商店所有者。
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
中挂载产品路由。
在前端,为了使用这个创建 API,我们将在client/product/api-product.js
中设置一个fetch
方法,通过传递视图中的多部分表单数据,向创建 API 发起 POST 请求。
app.use('/', productRoutes)
在产品控制器中,create
方法使用formidable
npm 模块来解析可能包含用户上传的图像文件和产品字段的多部分请求。然后将解析的数据保存到Product
集合中作为新产品。
mern-marketplace/server/controllers/product.controller.js
:
const create = (req, res, next) => {
let form = new formidable.IncomingForm()
form.keepExtensions = true
form.parse(req, (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
}
product.save((err, result) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
res.json(result)
})
})
}
在视图中获取创建 API
NewProduct 组件
在 MERN Marketplace 中,产品将以多种方式呈现给用户,两个主要区别在于产品在卖家列表和买家列表中的呈现方式。
const create = (params, credentials, product) => {
return fetch('/api/products/by/'+ params.shopId, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer ' + credentials.t
},
body: product
})
.then((response) => {
return response.json()
}).catch((err) => console.log(err))
}
NewProduct
组件将类似于NewShop
组件。它将包含一个表单,允许卖家输入名称、描述、类别、数量和价格,并从本地文件系统上传产品图像文件。
列出产品
这个NewProduct
组件只会在与特定商店相关联的路由上加载,因此只有已登录的卖家用户才能向他们拥有的商店添加产品。为了定义这个路由,我们在MainRouter
组件中添加了一个PrivateRoute
,它只会在/seller/:shopId/products/new
上为经过授权的用户渲染这个表单。
mern-marketplace/client/MainRouter.js
:
<PrivateRoute path="/seller/:shopId/products/new" component={NewProduct}/>
mern-marketplace/server/express.js
:
mern-marketplace/client/product/api-product.js
:
按商店列出
市场的访问者将浏览每个店铺中的产品,卖家将管理他们各自店铺中的产品列表。
按店铺 API
为了从数据库中检索特定店铺的产品,我们将在/api/products/by/:shopId
设置一个 GET 路由,如下所示。
mern-marketplace/server/routes/product.routes.js
:
router.route('/api/products/by/:shopId')
.get(productCtrl.listByShop)
对这个请求执行的listByShop
控制器方法将查询产品集合,返回与给定店铺引用匹配的产品。
mern-marketplace/server/controllers/product.controller.js
:
const listByShop = (req, res) => {
Product.find({shop: req.shop._id}, (err, products) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
res.json(products)
}).populate('shop', '_id name').select('-image')
}
在前端,使用此列表按店铺 API 获取特定店铺的产品,我们将在api-product.js
中添加一个 fetch 方法。
mern-marketplace/client/product/api-product.js
:
const listByShop = (params) => {
return fetch('/api/products/by/'+params.shopId, {
method: 'GET'
}).then((response) => {
return response.json()
}).catch((err) => {
console.log(err)
})
}
买家的产品组件
Products
组件主要用于向访问者展示可能购买的产品。我们将使用此组件来呈现与买家相关的产品列表。它将从显示产品列表的父组件中作为 props 接收产品列表。
店铺中的产品列表将显示给用户在单独的Shop
视图中。因此,将此Products
组件添加到Shop
组件中,并将相关产品列表作为 props 传递。searched
prop 传递了这个列表是否是产品搜索的结果,因此可以呈现适当的消息。
mern-marketplace/client/shop/Shop.js
:
<Products products={this.state.products} searched={false}/></Card>
在Shop
组件中,我们需要在componentDidMount
中添加对listByShop
fetch 方法的调用,以检索相关产品并将其设置为状态。
mern-marketplace/client/shop/Shop.js
:
listByShop({
shopId: this.match.params.shopId
}).then((data)=>{
if (data.error) {
this.setState({error: data.error})
} else {
this.setState({products: data})
}
})
在Products
组件中,如果 props 中发送的产品列表包含产品,那么将对列表进行迭代,并在 Material-UI 的GridListTile
中呈现每个产品的相关细节,同时提供到单个产品视图的链接和一个AddToCart
组件(其实现在第七章中讨论,扩展订单和支付的市场)。
mern-marketplace/client/product/Products.js
:
{this.props.products.length > 0 ?
(<div><GridList cellHeight={200} cols={3}>
{this.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={tile}/>}
/>
</GridListTile>
))}
</GridList></div>) : this.props.searched &&
(<Typography type="subheading" component="h4">
No products found! :(</Typography>)}
这个Products
组件用于呈现商店中的产品,按类别的产品以及搜索结果中的产品。
店主的 MyProducts 组件
与Products
组件相比,client/product/MyProducts.js
中的MyProducts
组件仅用于向卖家展示产品,以便他们可以管理每个店铺中的产品。
MyProducts
组件被添加到EditShop
视图中,这样卖家就可以在一个地方管理商店及其内容。它通过一个 prop 提供了商店的 ID,以便可以获取相关产品。
mern-marketplace/client/shop/EditShop.js
:
<MyProducts shopId={this.match.params.shopId}/>
在MyProducts
中,相关产品首先在componentDidMount
中加载。
mern-marketplace/client/product/MyProducts.js
:
componentDidMount = () => {
this.loadProducts()
}
loadProducts
方法使用相同的listByShop
获取方法来检索商店中的产品,并将其设置为状态。
mern-marketplace/client/product/MyProducts.js
:
loadProducts = () => {
listByShop({
shopId: this.props.shopId
}).then((data)=>{
if (data.error) {
this.setState({error: data.error})
} else {
this.setState({products: data})
}
})
}
遍历产品列表,并在ListItem
中呈现每个产品,同时提供编辑和删除选项,类似于MyShops
列表视图。编辑按钮链接到编辑产品视图。DeleteProduct
组件处理删除操作,并通过调用从MyProducts
传递的onRemove
方法重新加载列表,以更新当前商店的产品列表状态。
removeProduct
方法在MyProducts
中定义,并作为onRemove
prop 提供给DeleteProduct
组件。
mern-marketplace/client/product/MyProducts.js
:
**removeProduct** = (product) => {
const updatedProducts = this.state.products
const index = updatedProducts.indexOf(product)
updatedProducts.splice(index, 1)
this.setState({shops: updatedProducts})
}
...
<DeleteProduct
product={product}
shopId={this.props.shopId}
**onRemove={this.removeProduct}**/>
列出产品建议
访问 MERN Marketplace 的访客将看到产品建议,例如最新添加到市场的产品以及与他们当前查看的产品相关的产品。
最新产品
在 MERN Marketplace 的主页上,我们将显示最新添加到市场的五个产品。为了获取最新产品,我们将设置一个 API,该 API 将在/api/products/latest
接收 GET 请求。
mern-marketplace/server/routes/product.routes.js
:
router.route('/api/products/latest')
.get(productCtrl.listLatest)
listLatest
控制器方法将对数据库中的产品列表按照created
日期从新到旧进行排序,并在响应中返回排序后的列表中的前五个产品。
mern-marketplace/server/controllers/product.controller.js
:
const listLatest = (req, res) => {
Product.find({}).sort('-created').limit(5).populate('shop', '_id
name').exec((err, products) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
res.json(products)
})
}
在前端,我们将为这个最新的products
API 设置一个对应的 fetch 方法,类似于检索商店列表的fetch
。然后将检索到的列表呈现在添加到主页的Suggestions
组件中。
相关产品
在每个单独的产品视图中,我们将显示五个相关产品作为建议。为了检索这些相关产品,我们将设置一个 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
方法中使用。
mern-marketplace/server/controllers/product.controller.js
:
const productByID = (req, res, next, id) => {
Product.findById(id).populate('shop', '_id name').exec((err, product) => {
if (err || !product)
return res.status('400').json({
error: "Product not found"
})
req.product = product
next()
})
}
listRelated
控制器方法查询Product
集合,以查找具有与给定产品相同类别的其他产品,排除给定产品,并返回结果列表中的前五个产品。
mern-marketplace/server/controllers/product.controller.js
:
const listRelated = (req, res) => {
Product.find({ "_id": { "$ne": req.product },
"category": req.product.category}).limit(5)
.populate('shop', '_id name')
.exec((err, products) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
res.json(products)
})
}
为了在前端利用这个相关产品的 API,我们将在api-product.js
中设置一个对应的 fetch 方法。这个 fetch 方法将在Product
组件中被调用,用于填充在产品视图中渲染的Suggestions
组件。
建议组件
Suggestions
组件将在主页和单个产品页面上呈现,分别显示最新产品和相关产品:
它将从父组件作为 props 接收相关的产品列表,以及列表的标题:
<Suggestions products={this.state.suggestions} title={this.state.suggestionTitle}/>
在Suggestions
组件中,接收到的列表被迭代,并渲染出具体的产品细节,一个指向单个产品页面的链接,以及一个AddToCart
组件。
mern-marketplace/client/product/Suggestions.js
:
<Typography type="title"> {this.props.title} </Typography>
{this.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>})}
显示一个产品
MERN Marketplace 的访客将能够浏览每个产品,显示在单独的视图中的更多细节。
阅读产品 API
在后端,我们将添加一个 GET 路由,用于查询带有 ID 的Product
集合,并在响应中返回产品。
mern-marketplace/server/routes/product.routes.js
:
router.route('/api/products/:productId')
.get(productCtrl.read)
:productId
参数调用productByID
控制器方法,从数据库中检索产品并将其附加到请求对象。请求对象中的产品由read
控制器方法使用,以响应read
请求。
mern-marketplace/server/controllers/product.controller.js
:
const read = (req, res) => {
req.product.image = undefined
return res.json(req.product)
}
在api-product.js
中,我们将添加一个 fetch 方法来在前端使用这个 read API。
mern-marketplace/client/product/api-product.js
:
const read = (params) => {
return fetch('/api/products/' + params.productId, {
method: 'GET'
}).then((response) => {
return response.json()
}).catch((err) => console.log(err))
}
产品组件
Product
组件将呈现产品细节,包括加入购物车选项,并显示相关产品列表:
Product
组件可以在浏览器中通过 /product/:productID
路由访问,该路由在 MainRouter
中定义如下。
mern-marketplace/client/MainRouter.js
:
<Route path="/product/:productId" component={Product}/>
当组件挂载时,将获取产品详情和相关列表数据,或者在前端路由路径中的 productId
更改后,将接收新的 props,用户点击相关列表中的另一个产品时。
mern-marketplace/client/product/Product.js
:
componentDidMount = () => {
this.loadProduct(this.match.params.productId)
}
componentWillReceiveProps = (props) => {
this.loadProduct(props.match.params.productId)
}
loadProduct
方法调用 read
和 listRelated
获取产品和相关列表数据,然后将数据设置到状态中。
mern-marketplace/client/product/Product.js
:
loadProduct = (productId) => {
read({productId: productId}).then((data) => {
if (data.error) {
this.setState({error: data.error})
} else {
this.setState({product: data})
listRelated({
productId: data._id}).then((data) => {
if (data.error) {
console.log(data.error)
} else {
this.setState({suggestions: data})
}
})
}
})
}
组件的产品详情部分显示有关产品的相关信息,以及在 Material-UI Card
组件中的 AddToCart
组件。
mern-marketplace/client/product/Product.js
:
<Card>
<CardHeader
action={<AddToCart cartStyle={classes.addCart}
item= {this.state.product}/>}
title={this.state.product.name}
subheader={this.state.product.quantity > 0? 'In Stock': 'Out of
Stock'}
/>
<CardMedia image={imageUrl} title={this.state.product.name}/>
<Typography component="p" type="subheading">
{this.state.product.description}<br/>
$ {this.state.product.price}
<Link to={'/shops/'+this.state.product.shop._id}>
<Icon>shopping_basket</Icon> {this.state.product.shop.name}
</Link>
</Typography>
</Card>
...
<Suggestions products={this.state.suggestions} title='Related Products'/>
Suggestions
组件添加到产品视图中,相关列表数据作为 prop 传递。
编辑和删除产品
在应用程序中编辑和删除产品的实现与编辑和删除商店类似,如前几节所述。这些功能将需要后端中相应的 API、前端中的 fetch 方法,以及带有表单和操作的 React 组件视图。
编辑
编辑功能与创建产品非常相似,EditProduct
表单组件也只能由经过验证的卖家在 /seller/:shopId/:productId/edit
访问。
mern-marketplace/client/MainRouter.js
:
<PrivateRoute path="/seller/:shopId/:productId/edit" component={EditProduct}/>
EditProduct
组件包含与 NewProduct
相同的表单,使用读取产品 API 检索到的产品的填充值,并使用 fetch 方法将多部分表单数据发送到后端的编辑产品 API,位于 /api/products/by/:shopId
。
mern-marketplace/server/routes/product.routes.js
:
router.route('/api/product/:shopId/:productId')
.put(authCtrl.requireSignin, shopCtrl.isOwner, productCtrl.update)
update
控制器类似于产品 create
方法和商店 update
方法;它使用 formidable
处理多部分表单数据,并扩展产品详情以保存更新。
删除
DeleteProduct
组件添加到 MyProducts
组件中,用于列表中的每个产品,如前面讨论的。它从 MyProducts
中获取 product
对象、shopID
和 loadProducts
方法作为 prop。该组件类似于 DeleteShop
,当用户确认删除意图时,它调用删除的 fetch 方法,向服务器发出 DELETE 请求,位于 /api/product/:shopId/:productId
。
mern-marketplace/server/routes/product.routes.js
:
router.route('/api/product/:shopId/:productId')
.delete(authCtrl.requireSignin, shopCtrl.isOwner, productCtrl.remove)
带类别的产品搜索
在 MERN Marketplace 中,访问者将能够按名称和特定类别搜索特定产品。
类别 API
为了让用户选择要搜索的特定类别,我们将设置一个 API,该 API 从数据库中的Product
集合中检索所有不同的类别。对/api/products/categories
的 GET 请求将返回一个唯一类别的数组。
mern-marketplace/server/routes/product.routes.js
:
router.route('/api/products/categories')
.get(productCtrl.listCategories)
listCategories
控制器方法通过对category
字段进行distinct
调用来查询Product
集合。
mern-marketplace/server/controllers/product.controller.js
:
const listCategories = (req, res) => {
Product.distinct('category',{},(err, products) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
res.json(products)
})
}
这个类别 API 可以在前端使用相应的 fetch 方法来检索不同类别的数组,并在视图中显示。
搜索产品 API
搜索产品 API 将在/api/products?search=value&category=value
处接收 GET 请求,URL 中带有查询参数,用于查询Product
集合中提供的搜索文本和类别值。
mern-marketplace/server/routes/product.routes.js
:
router.route('/api/products')
.get(productCtrl.list)
list
控制器方法将首先处理请求中的查询参数,然后查找给定类别中的产品(如果有的话),这些产品的名称部分匹配提供的搜索文本。
mern-marketplace/server/controllers/product.controller.js
:
const list = (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
Product.find(query, (err, products) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
res.json(products)
}).populate('shop', '_id name').select('-image')
}
获取视图的搜索结果
为了在前端利用这个搜索 API,我们将设置一个方法来构建带有查询参数的 URL,并调用 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
npm 模块,它将帮助将参数对象字符串化为可以附加到请求路由的查询字符串。
搜索组件
应用类别 API 和搜索 API 的第一个用例是Search
组件:
搜索组件为用户提供了一个简单的表单,其中包含一个搜索输入文本字段和一个下拉菜单,该下拉菜单包含从父组件接收的类别选项,父组件将使用不同类别的 API 检索列表。
mern-marketplace/client/product/Search.js
:
<TextField id="select-category" select label="Select category" value={this.state.category}
onChange={this.handleChange('category')}
SelectProps={{ MenuProps: { className: classes.menu, } }}>
<MenuItem value="All"> All </MenuItem>
{this.props.categories.map(option => (
<MenuItem key={option} value={option}> {option} </MenuItem>
))}
</TextField>
<TextField id="search" label="Search products" type="search" onKeyDown={this.enterKey}
onChange={this.handleChange('search')}
/>
<Button raised onClick={this.search}> Search </Button>
<Products products={this.state.results} searched={this.state.searched}/>
一旦用户输入搜索文本并点击Enter,就会调用搜索 API 来检索结果。
mern-marketplace/client/product/Search.js
:
search = () => {
if(this.state.search){
list({
search: this.state.search || undefined, category:
this.state.category
}).then((data) => {
if (data.error) {
console.log(data.error)
} else {
this.setState({results: data, searched:true})
}
})
}
}
然后将结果数组作为 props 传递给“产品”组件,以在搜索表单下方呈现匹配的产品。
类别组件
“类别”组件是不同类别和搜索 API 的第二个用例。对于这个组件,我们首先在父组件中获取类别列表,并将其作为 props 发送以显示给用户:
当用户在显示的列表中选择一个类别时,将使用搜索 API 调用一个类别值,并且后端返回所选类别中的所有产品。然后在“产品”组件中呈现返回的产品。
在 MERN Marketplace 的第一个版本中,用户可以成为卖家创建商店和添加产品,访问者可以浏览商店和搜索产品,同时应用程序还会向访问者推荐产品。
总结
在这一章中,我们开始使用 MERN 堆栈构建一个在线市场应用程序。MERN 骨架被扩展以向用户添加卖家角色,这样他们就可以创建商店并向每个商店添加产品,以便向其他用户出售。我们还探讨了如何利用堆栈来实现产品浏览、搜索以及对有兴趣购买的普通用户提出建议等功能。但是,一个市场应用程序如果没有购物车用于结账、订单管理和支付处理就是不完整的。
在下一章中,我们将扩展我们的应用程序以添加这些功能,并了解更多关于如何使用 MERN 堆栈来实现电子商务应用程序的核心方面。
第七章:扩展市场以支持订单和付款
处理顾客下订单时的付款,并允许卖家管理这些订单是电子商务应用的关键方面。在本章中,我们将通过引入以下功能来扩展上一章中构建的在线市场:
-
购物车
-
使用 Stripe 进行付款处理
-
订单管理
具有购物车、付款和订单的 MERN 市场
在第六章中开发的 MERN 市场应用程序,通过在线市场锻炼新的 MERN 技能 将扩展到包括购物车功能、Stripe 集成以处理信用卡付款,以及基本的订单管理流程。以下的实现保持简单,以便作为开发这些功能更复杂版本的起点。
以下的组件树图显示了构成 MERN 市场前端的所有自定义组件。本章讨论的功能修改了一些现有的组件,如Profile
、MyShops
、Products
和Suggestions
,还添加了新的组件,如AddToCart
、MyOrders
、Cart
和ShopOrders
:
完整的 MERN 市场应用程序的代码可在 GitHub 上找到github.com/shamahoque/mern-marketplace。您可以在阅读本章其余部分的代码解释时,克隆此代码并运行应用程序。要使 Stripe 付款的代码工作,您需要创建自己的 Stripe 账户,并在
config/config.js
文件中更新您的测试值,包括 Stripe API 密钥、秘密密钥和 Stripe Connect 客户端 ID。
购物车
访问 MERN 市场的访客可以通过点击每个产品上的“加入购物车”按钮将他们想要购买的产品添加到购物车中。菜单中的购物车图标将指示已添加到购物车中的产品数量,当用户继续浏览市场时。他们还可以更新购物车内容,并通过打开购物车视图开始结账。但是,要完成结账并下订单,用户将需要登录。
购物车主要是一个前端功能,因此购物车详情将在客户端本地存储,直到用户在结账时下订单。为了实现购物车功能,我们将在client/cart/cart-helper.js
中设置辅助方法,以帮助使用相关的 React 组件操纵购物车详情。
添加到购物车
client/Cart/AddToCart.js
中的AddToCart
组件从父组件中获取product
对象和 CSS 样式对象作为 props。例如,在 MERN Marketplace 中,它被添加到产品视图中,如下所示:
<AddToCart cartStyle={classes.addCart} item={this.state.product}/>
AddToCart
组件本身根据传递的项目是否有库存显示购物车图标按钮:
例如,如果项目数量大于0
,则显示AddCartIcon
,否则呈现DisabledCartIcon
。
mern-marketplace/client/cart/AddToCart.js
:
{this.props.item.quantity >= 0 ?
<IconButton color="accent" dense="dense" onClick={this.addToCart}>
<AddCartIcon className={this.props.cartStyle ||
classes.iconButton}/>
</IconButton> :
<IconButton disabled={true} color="accent" dense="dense"
<DisabledCartIcon className={this.props.cartStyle ||
classes.disabledIconButton}/>
</IconButton>}
当点击AddCartIcon
按钮时,将调用addToCart
方法。
mern-marketplace/client/cart/AddToCart.js
:
addToCart = () => {
cart.addItem(this.props.item, () => {
this.setState({redirect:true})
})
}
在cart-helper.js
中定义的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
中的购物车数组的长度,以便直观地通知用户当前购物车中有多少商品:
购物车的链接将类似于菜单中的其他链接,唯一的区别是 Material-UI 的Badge
组件显示购物车长度。
mern-marketplace/client/core/Menu.js
:
<Link to="/cart">
<Button color={isActive(history, "/cart")}>
Cart
<Badge color="accent" badgeContent={cart.itemTotal()} >
<CartIcon />
</Badge>
</Button>
</Link>
itemTotal
辅助方法在cart-helper.js
中返回购物车长度,它读取存储在localStorage
中的购物车数组并返回数组的长度。
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={this.state.checkout}
setCheckout={this.setCheckout}/>
</Grid>
{this.state.checkout &&
<Grid item xs={6} sm={6}>
<Checkout/>
</Grid>}
</Grid>
CartItems
组件被传递了一个checkout
布尔值,以及一个用于更新此结账值的状态更新方法,以便基于用户交互来呈现Checkout
组件和选项。
mern-marketplace/client/cart/Cart.js
:
setCheckout = val =>{
this.setState({checkout: val})
}
Cart
组件将在/cart
路由处访问,因此我们需要在MainRouter
组件中添加一个Route
,如下所示。
mern-marketplace/client/MainRouter.js
:
<Route path="/cart" component={Cart}/>
CartItems 组件
CartItems
组件将允许用户查看和更新其购物车中当前的物品。如果用户已登录,还将为他们提供开始结账流程的选项:
如果购物车中包含物品,CartItems
组件将遍历物品并呈现购物车中的产品。如果没有添加物品,则购物车视图只显示一条消息,说明购物车是空的。
mern-marketplace/client/cart/CartItems.js
:
{this.state.cartItems.length > 0 ? <span>
{this.state.cartItems.map((item, i) => {
...
… Product details
… Edit quantity
… Remove product option
...
})
}
… Show total price and Checkout options …
</span> :
<Typography type="subheading" component="h3" color="primary">
No items added to your cart.
</Typography>
}
每个产品项目显示产品的详细信息和可编辑的数量文本字段,以及删除项目选项。最后,它显示购物车中物品的总价和开始结账的选项。
检索购物车详细信息
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
组件中,我们将使用componentDidMount
中的getCart
辅助方法检索购物车项目并将其设置为状态。
mern-marketplace/client/cart/CartItems.js
:
componentDidMount = () => {
this.setState({cartItems: 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={this.handleChange(i)}
type="number"
inputProps={{ min:1 }}
InputLabelProps={{
shrink: true,
}}
/>
当用户更新此值时,将调用handleChange
方法来强制执行最小值验证,更新状态中的cartItems
,并使用辅助方法更新localStorage
中的购物车。
mern-marketplace/client/cart/CartItems.js
:
handleChange = index => event => {
let cartItems = this.state.cartItems
if(event.target.value == 0){
cartItems[index].quantity = 1
}else{
cartItems[index].quantity = event.target.value
}
this.setState({cartItems: cartItems})
cart.updateCart(index, event.target.value)
}
updateCart
辅助方法接受要在购物车数组中更新的产品的索引和新的数量值作为参数,并更新localStorage
中存储的详细信息。
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={this.removeItem(i)}>x Remove</Button>
removeItem
点击处理程序方法使用removeItem
辅助方法从localStorage
中删除购物车中的物品,然后更新状态中的cartItems
。该方法还检查购物车是否已清空,因此可以使用从Cart
组件传递的setCheckout
函数来隐藏结账。
mern-marketplace/client/cart/CartItems.js
:
removeItem = index => event =>{
let cartItems = cart.removeItem(index)
if(cartItems.length == 0){
this.props.setCheckout(false)
}
this.setState({cartItems: cartItems})
}
cart-helper.js
中的removeItem
辅助方法获取要从数组中删除的产品的索引,然后将其切出,并在返回更新后的cart
数组之前更新localStorage
。
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: ${this.getTotal()}</span>
getTotal
方法将计算总价,考虑到cartItems
数组中每个物品的单价和数量。
mern-marketplace/client/cart/CartItems.js
:
getTotal(){
return this.state.cartItems.reduce( function(a, b){
return a + (b.quantity*b.product.price)
}, 0)
}
结账选项
用户将看到执行结账的选项,这取决于他们是否已登录以及结账是否已经打开。
mern-marketplace/client/cart/CartItems.js
:
{!this.props.checkout && (auth.isAuthenticated() ?
<Button onClick={this.openCheckout}>
Checkout
</Button> :
<Link to="/signin">
<Button>Sign in to checkout</Button>
</Link>)
}
当单击结账按钮时,openCheckout
方法将使用作为属性传递的setCheckout
方法在Cart
组件中将结账值设置为true
:
openCheckout = () => {
this.props.setCheckout(true)
}
一旦在购物车视图中将结账值设置为true
,Checkout
组件将被呈现,允许用户输入结账详情并下订单。
使用条纹进行支付
支付处理需要跨结账、订单创建和订单管理流程的实现。它还涉及对买家和卖家用户数据的更新。在我们深入讨论结账和订单功能的实现之前,我们将简要讨论使用条纹的支付处理选项和考虑事项,以及它在 MERN Marketplace 中的集成方式。
条纹
条纹提供了一套必要的工具,可以在任何 Web 应用程序中集成支付。这些工具可以根据应用程序的特定类型和正在实施的支付用例以不同的方式选择和使用。
在 MERN Marketplace 设置的情况下,应用程序本身将在 Stripe 上拥有一个平台,并且希望卖家在平台上连接 Stripe 账户,以便应用程序可以代表卖家对在结账时输入其信用卡详细信息的用户进行收费。在 MERN Marketplace 中,用户可以从不同商店添加产品到其购物车,因此他们的卡上的费用只会由应用程序为特定订购的产品创建,当卖家处理时。此外,卖家将完全控制从其 Stripe 仪表板上代表他们创建的费用。我们将演示如何使用 Stripe 提供的工具来使此付款设置工作。
Stripe 为每个工具提供了完整的文档和指南,并公开了在 Stripe 上设置的账户和平台的测试数据。为了在 MERN Marketplace 中实现付款,我们将使用测试密钥,并让您扩展实现以进行实时付款。
每个卖家的 Stripe 连接账户
为了代表卖家创建费用,应用程序将允许作为卖家的用户将其 Stripe 账户连接到其 MERN Marketplace 用户账户。
更新用户模型
在成功连接用户的 Stripe 账户后,我们将使用以下字段更新用户模型以存储 Stripe OAuth 凭据。
mern-marketplace/server/models/user.model.js
:
stripe_seller: {}
stripe_seller
字段将存储卖家的 Stripe 账户凭据,并且在需要通过 Stripe 处理他们从商店出售的产品的收费时将使用此字段。
连接 Stripe 的按钮
在卖家的用户资料页面上,如果用户尚未连接其 Stripe 账户,我们将显示一个按钮,该按钮将带用户前往 Stripe 进行身份验证并连接其 Stripe 账户:
如果用户已成功连接其 Stripe 账户,我们将显示一个禁用的 STRIPE CONNECTED 按钮:
在Profile
组件中添加的代码将首先检查用户是否是卖家,然后再渲染任何STRIPE CONNECTED
按钮。然后,第二个检查将确认给定用户的stripe_seller
字段中是否已经存在 Stripe 凭据。如果用户已经存在 Stripe 凭据,则显示禁用的STRIPE CONNECTED
按钮,否则显示一个连接到 Stripe 的 OAuth 链接的链接。
mern-marketplace/client/user/Profile.js
:
{this.state.user.seller &&
(this.state.user.stripe_seller ?
(<Button variant="raised" disabled>
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"}}>
<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}/>
StripeConnect
组件
StripeConnect
组件将基本上完成与 Stripe 的剩余认证过程步骤,并根据 Stripe 连接是否成功呈现相关消息:
当StripeConnect
组件加载时,在componentDidMount
中,我们将首先解析附加到来自 Stripe 重定向的 URL 的查询参数。对于解析,我们使用了之前用于产品搜索的相同query-string
npm 模块。然后,如果 URL 的query
参数包含认证代码,我们将在服务器上进行必要的 API 调用,以完成来自 Stripe 的 OAuth。
mern-marketplace/client/user/StripeConnect.js
:
componentDidMount = () => {
const parsed = queryString.parse(this.props.location.search)
if(parsed.error){
this.setState({error: true})
}
if(parsed.code){
this.setState({connecting: true, error: false})
const jwt = auth.isAuthenticated()
stripeUpdate({
userId: jwt.user._id
}, {
t: jwt.token
}, parsed.code).then((data) => {
if (data.error) {
this.setState({error: true, connected: false,
connecting:false})
} else {
this.setState({connected: true, connecting: false,
error:false})
}
})
}
}
stripeUpdate
fetch 方法在api-user.js
中定义,并将从 Stripe 检索的认证代码传递给我们将在服务器上设置的 API'/api/stripe_auth/:userId'
。
mern-marketplace/client/user/api-user.js
:
const stripeUpdate = (params, credentials, auth_code) => {
return fetch('/api/stripe_auth/'+params.userId, {
method: 'PUT',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
},
body: JSON.stringify({stripe: auth_code})
}).then((response)=> {
return response.json()
}).catch((err) => {
console.log(err)
})
}
Stripe 认证更新 API
一旦 Stripe 账户连接成功,为了完成 OAuth 过程,我们需要使用检索到的授权码从我们的服务器向 Stripe OAuth 发出 POST API 调用,并检索凭据以存储在卖家的用户账户中以处理收费。Stripe 授权更新 API 在/api/stripe_auth/:userId
接收请求,并启动向 Stripe 发出 POST API 调用以检索凭据。
此 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
npm 模块:
npm install request --save
用户控制器中的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 调用需要平台的秘钥和检索到的授权码来完成授权,并返回连接账户的凭据,然后将其附加到请求体中,以便用户可以在next()
方法中更新。
有了这些凭据,应用程序可以代表卖家在客户信用卡上创建收费。
用于结账的 Stripe Card Elements
在结账过程中,为了从用户那里收集信用卡详细信息,我们将使用 Stripe 的Card
Elements
来在结账表单中添加信用卡字段。为了将Card
Elements
与我们的 React 界面集成,我们将利用react-stripe-elements
npm 模块:
npm install --save react-stripe-elements
我们还需要在template.js
中注入Stripe.js
代码,以便在前端代码中访问 Stripe:
<script id="stripe-js" src="https://js.stripe.com/v3/" async></script>
对于 MERN Marketplace,Stripe 仅在购物车视图中需要,在那里Checkout
组件需要它来渲染Card
Elements
并处理卡片详细信息。因此,在Cart
组件挂载后,我们将使用应用程序的 Stripe API 密钥初始化 Stripe 实例,在其componentDidMount
中。
mern-marketplace/client/cart/Cart.js
:
componentDidMount = () => {
if (window.Stripe) {
this.setState({stripe:
window.Stripe(config.stripe_test_api_key)})
} else {
document.querySelector('#stripe-js')
.addEventListener('load', ()
=> {
this.setState({stripe:
window.Stripe(config.stripe_test_api_key)})
})
}
}
在Cart.js
中添加的Checkout
组件应该使用react-stripe-elements
中的StripeProvider
组件进行包装,以便Checkout
中的Elements
可以访问 Stripe 实例。
mern-marketplace/client/cart/Cart.js
:
<StripeProvider stripe={this.state.stripe}>
<Checkout/>
</StripeProvider>
然后,在Checkout
组件中,我们将使用 Stripe 的Elements
组件。使用 Stripe 的Card Elements
将使应用程序能够收集用户的信用卡详细信息,并使用 Stripe 实例对卡片信息进行标记,而不是在我们自己的服务器上处理。关于在结账流程中收集卡片详细信息和生成卡片令牌的实现将在结账和创建新订单部分讨论。
Stripe 客户记录卡片详细信息
在结账流程结束时下订单时,生成的卡片令牌将被用来创建或更新代表我们用户的 Stripe 客户(stripe.com/docs/api#customers
),这是一个存储信用卡信息的好方法(stripe.com/docs/saving-cards
),以便进一步使用,比如在卖家从他们的商店处理已订购的产品时,仅为购物车中的特定产品创建收费。这消除了在自己的服务器上安全存储用户信用卡详细信息的复杂性。
更新用户模型
为了在我们的数据库中跟踪用户对应的 StripeCustomer
信息,我们将使用以下字段更新用户模型:
stripe_customer: {},
更新用户控制器
当用户在输入信用卡详细信息后下订单时,我们将创建一个新的或更新现有的 Stripe 客户。为了实现这一点,我们将更新用户控制器,添加一个stripeCustomer
方法,该方法将在我们的服务器收到请求创建订单 API(在创建新订单部分讨论)时,在创建订单之前被调用。
在stripeCustomer
控制器方法中,我们将需要使用stripe
npm 模块:
npm install stripe --save
安装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 客户 ID 引用存储在stripe_customer
字段中来更新当前用户的数据。我们还将将此客户 ID 添加到正在下订单的订单中,以便更简单地创建与订单相关的收费。
更新现有的 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。
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,
}, {
stripe_account: 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,
}, {
stripe_account: req.profile.stripe_seller.stripe_user_id,
}).then((charge) => {
next()
})
})
}
如果卖家尚未连接他们的 Stripe 账户,createCharge
方法将返回 400 错误响应,以指示需要连接 Stripe 账户。
为了能够代表卖家的 Stripe 账户向 Stripe 客户收费,我们首先需要使用客户 ID 和卖家的 Stripe 账户 ID 生成一个 Stripe 令牌,然后使用该令牌创建一个收费。
当服务器收到请求将产品状态更改为处理中的订单更新请求时,将调用createCharge
控制器方法(关于此订单更新请求的 API 实现将在按商店排序的订单部分讨论)。
这涵盖了与 MERN Marketplace 特定用例的支付处理实现相关的所有 Stripe 相关概念。现在我们将继续允许用户完成结账并下订单。
结账
已登录并且已将商品添加到购物车的用户将能够开始结账流程。结账表单将收集客户详细信息、送货地址信息和信用卡信息:
初始化结账详细信息
在Checkout
组件中,我们将在收集表单详细信息之前,在状态中初始化checkoutDetails
对象。
mern-marketplace/client/cart/Checkout.js
:
state = {
checkoutDetails: {customer_name: '', customer_email:'',
delivery_address: {street: '', city: '', state:
'', zipcode: '', country:''}},
}
组件挂载后,我们将根据当前用户的详细信息预填充客户详细信息,并将当前购物车商品添加到checkoutDetails
中。
mern-marketplace/client/cart/Checkout.js
:
componentDidMount = () => {
let user = auth.isAuthenticated().user
let checkoutDetails = this.state.checkoutDetails
checkoutDetails.products = cart.getCart()
checkoutDetails.customer_name = user.name
checkoutDetails.customer_email = user.email
this.setState({checkoutDetails: checkoutDetails})
}
客户信息
在结账表单中,我们将添加文本字段以收集客户姓名和电子邮件。
mern-marketplace/client/cart/Checkout.js
:
<TextField id="name" label="Name" value={this.state.checkoutDetails.customer_name} onChange={this.handleCustomerChange('customer_name')}/>
<TextField id="email" type="email" label="Email" value={this.state.checkoutDetails.customer_email} onChange={this.handleCustomerChange('customer_email')}/><br/>
当用户更新值时,handleCustomerChange
方法将更新状态中的相关详细信息:
handleCustomerChange = name => event => {
let checkoutDetails = this.state.checkoutDetails
checkoutDetails[name] = event.target.value || undefined
this.setState({checkoutDetails: checkoutDetails})
}
送货地址
为了从用户那里收集送货地址,我们将在结账表单中添加以下文本字段以收集街道地址、城市、邮政编码、州和国家。
mern-marketplace/client/cart/Checkout.js
:
<TextField id="street" label="Street Address" value={this.state.checkoutDetails.delivery_address.street} onChange={this.handleAddressChange('street')}/>
<TextField id="city" label="City" value={this.state.checkoutDetails.delivery_address.city} onChange={this.handleAddressChange('city')}/>
<TextField id="state" label="State" value={this.state.checkoutDetails.delivery_address.state} onChange={this.handleAddressChange('state')}/>
<TextField id="zipcode" label="Zip Code" value={this.state.checkoutDetails.delivery_address.zipcode} onChange={this.handleAddressChange('zipcode')}/>
<TextField id="country" label="Country" value={this.state.checkoutDetails.delivery_address.country} onChange={this.handleAddressChange('country')}/>
当用户更新这些地址字段时,handleAddressChange
方法将更新状态中的相关详细信息。
mern-marketplace/client/cart/Checkout.js
:
handleAddressChange = name => event => {
let checkoutDetails = this.state.checkoutDetails
checkoutDetails.delivery_address[name] = event.target.value ||
undefined
this.setState({checkoutDetails: checkoutDetails})
}
PlaceOrder 组件
将使用来自react-stripe-elements
的 Stripe 的CardElement
组件将信用卡字段添加到结账表单中。
CardElement
组件必须是使用injectStripe
higher-order component (HOC)构建的支付表单组件的一部分,并且使用Elements
组件进行包装。因此,我们将创建一个名为PlaceOrder
的组件,其中包含injectStripe
,它将包含 Stripe 的CardElement
和PlaceOrder
按钮。
mern-marketplace/client/cart/PlaceOrder.js
:
class **PlaceOrder** extends Component { … } export default **injectStripe**(withStyles(styles)(PlaceOrder))
然后我们将在结账表单中添加PlaceOrder
组件,将checkoutDetails
对象作为 prop 传递给它,并使用来自react-stripe-elements
的Elements
组件进行包装。
mern-marketplace/client/cart/Checkout.js
:
<Elements> <PlaceOrder checkoutDetails={this.state.checkoutDetails} /> </Elements>
injectStripe
HOC 提供了this.props.stripe
属性,用于管理Elements
组。这将允许我们在PlaceOrder
中调用this.props.stripe.createToken
来提交卡片详情到 Stripe 并获取卡片令牌。
Stripe CardElement 组件
Stripe 的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',
},
}}}/>
下订单
在PlaceOrder
组件中的CardElement
之后,也放置了“下订单”按钮。
mern-marketplace/client/cart/PlaceOrder.js
:
<Button color="secondary" variant="raised" onClick={this.placeOrder}>Place Order</Button>
点击“下订单”按钮将调用placeOrder
方法,该方法将尝试使用stripe.createToken
对卡片详情进行标记。如果失败,用户将被告知错误,但如果成功,结账详情和生成的卡片令牌将被发送到我们服务器的创建订单 API(在下一节中介绍)。
mern-marketplace/client/cart/PlaceOrder.js
:
placeOrder = ()=>{
this.props.stripe.createToken().then(payload => {
if(payload.error){
this.setState({error: payload.error.message})
}else{
const jwt = auth.isAuthenticated()
create({userId:jwt.user._id}, {
t: jwt.token
}, this.props.checkoutDetails, payload.token.id).then((data) =>
{
if (data.error) {
this.setState({error: data.error})
} else {
cart.emptyCart(()=> {
this.setState({'orderId':data._id,'redirect': true})
})
}
})
}
})
}
在client/order/api-order.js
中定义了create
fetch 方法,该方法向后端的创建订单 API 发出 POST 请求。它将结账详情、卡片令牌和用户凭据作为参数,并将其发送到/api/orders/:userId
的 API。
mern-marketplace/client/order/api-order.js
:
const create = (params, credentials, order, token) => {
return fetch('/api/orders/'+params.userId, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
},
body: JSON.stringify({order: order, token:token})
})
.then((response) => {
return response.json()
}).catch((err) => console.log(err))
}
购物车为空
如果创建订单 API 成功,我们将使用cart-helper.js
中的emptyCart
辅助方法清空购物车。
mern-marketplace/client/cart/cart-helper.js
:
emptyCart(cb) {
if(typeof window !== "undefined"){
localStorage.removeItem('cart')
cb()
}
}
emptyCart
方法从localStorage
中移除购物车对象,并通过执行传递的回调来更新视图的状态。
重定向到订单视图
下订单并清空购物车后,用户将被重定向到订单视图,该视图将显示刚刚下的订单的详细信息。
mern-marketplace/client/cart/PlaceOrder.js
:
if (this.state.redirect) {
return (<Redirect to={'/order/' + this.state.orderId}/>)
}
这将表明结账过程已经完成,并成功调用了我们在服务器端设置的创建订单 API,用于在数据库中创建和存储订单。
创建新订单
当用户下订单时,将使用在结账时确认的订单详情来在数据库中创建新的订单记录,更新或创建用户的 Stripe 客户端,并减少已订购产品的库存数量。
订单模型
为了存储订单,我们将为订单模型定义一个 Mongoose 模式,记录客户详细信息以及用户帐户引用,交货地址信息,付款参考,创建和更新时间戳,以及一个订购产品的数组,其中每个产品的结构将在名为CartItemSchema
的单独子模式中定义。
下订单者和客户
为了记录订单面向的客户的详细信息,我们将在Order
模式中添加customer_name
和customer_email
字段。
mern-marketplace/server/models/order.model.js
:
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
字段。
mern-marketplace/server/models/order.model.js
:
ordered_by: {type: mongoose.Schema.ObjectId, ref: 'User'}
交货地址
订单的交货地址信息将存储在交货地址子文档中,其中包括street
,city
,state
,zipcode
和country
字段。
mern-marketplace/server/models/order.model.js
:
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'}
},
付款参考
付款信息将在订单更新时相关,卖家处理订购产品后需要创建费用时。我们将在Order
模式的payment_id
字段中记录与信用卡详细信息相关的 Stripe 客户 ID。
mern-marketplace/server/models/order.model.js
:
payment_id: {},
订购的产品
订单的主要内容将是订购产品的列表以及每个产品的数量等详细信息。我们将在Order
模式的一个名为products
的字段中记录此列表。每个产品的结构将在CartItemSchema
中单独定义。
mern-marketplace/server/models/order.model.js
:
products: [CartItemSchema],
购物车项目模式
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
只能具有枚举中定义的值,表示卖家更新的产品订购的当前状态。
在这里定义的Order
模式将记录客户和卖家完成订购产品的购买步骤所需的详细信息。
创建订单 API
创建订单 API 路由在server/routes/order.routes.js
中声明。订单路由将与用户路由非常相似。要在 Express 应用程序中加载订单路由,我们需要在express.js
中挂载路由,就像我们为 auth 和 user 路由所做的那样。
mern-marketplace/server/express.js
:
app.use('/', orderRoutes)
当创建订单 API 在/api/orders/:userId
接收到 POST 请求时,将按以下顺序执行一系列操作。
-
确保用户已登录
-
使用之前讨论过的
stripeCustomer
用户控制器方法,创建或更新 StripeCustomer
-
使用
decreaseQuanity
产品控制器方法,更新所有订购产品的库存数量 -
使用
create
订单控制器方法在订单集合中创建订单
路由将被定义如下。
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)
减少产品库存数量
我们将更新产品控制器文件,添加decreaseQuantity
控制器方法,该方法将更新新订单中购买的所有产品的库存数量。
mern-marketplace/server/controllers/product.controller.js
:
const decreaseQuantity = (req, res, next) => {
let bulkOps = req.body.order.products.map((item) => {
return {
"updateOne": {
"filter": { "_id": item.product._id } ,
"update": { "$inc": {"quantity": -item.quantity} }
}
}
})
Product.bulkWrite(bulkOps, {}, (err, products) => {
if(err){
return res.status(400).json({
error: "Could not update product"
})
}
next()
})
}
在这种情况下,更新操作涉及在与产品数组匹配后对集合中的多个产品进行批量更新,我们将使用 MongoDB 中的bulkWrite
方法,以便一次性向 MongoDB 服务器发送多个updateOne
操作。首先使用map
函数将需要的多个updateOne
操作列在bulkOps
中。这将比发送多个独立的保存或更新操作更快,因为使用bulkWrite()
只需要一次往返到 MongoDB。
创建订单控制器方法
在订单控制器中定义的create
控制器方法接收订单详情,创建新订单,并将其保存到 MongoDB 的订单集合中。
mern-marketplace/server/controllers/order.controller.js
:
const create = (req, res) => {
req.body.order.user = req.profile
const order = new Order(req.body.order)
order.save((err, result) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
res.status(200).json(result)
})
}
通过这样的实现,任何在 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、名称和价格字段。
mern-marketplace/server/controllers/order.controller.js
:
const listByShop = (req, res) => {
Order.find({"products.shop": req.shop._id})
.populate({path: 'products.product', select: '_id name price'})
.sort('-created')
.exec((err, orders) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
res.json(orders)
})
}
为了在前端获取这个 API,我们将在api-order.js
中添加一个相应的listByShop
方法,用于在ShopOrders
组件中显示每个商店的订单。
mern-marketplace/client/order/api-order.js
:
const listByShop = (params, credentials) => {
return fetch('/api/orders/shop/'+params.shopId, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer ' + credentials.t
}
}).then((response) => {
return response.json()
}).catch((err) => {
console.log(err)
})
}
ShopOrders 组件
卖家将在ShopOrders
组件中查看他们的订单列表,每个订单只显示与商店相关的已购买产品,并允许卖家使用可能状态值的下拉菜单更改产品的状态:
我们将在MainRouter
中更新一个PrivateRoute
,以在/seller/orders/:shop/:shopId
路由处加载ShopOrders
组件。
mern-marketplace/client/MainRouter.js
:
<PrivateRoute path="/seller/orders/:shop/:shopId" component={ShopOrders}/>
列出订单
当ShopOrders
组件挂载时,我们将使用listByShop
获取方法加载相关订单,并将检索到的订单设置为状态。
mern-marketplace/client/order/ShopOrders.js
:
loadOrders = () => {
const jwt = auth.isAuthenticated()
listByShop({
shopId: this.match.params.shopId
}, {t: jwt.token}).then((data) => {
if (data.error) {
console.log(data)
} else {
this.setState({orders: data})
}
})
}
在视图中,我们将遍历订单列表,并在Material-UI
的可折叠列表中呈现每个订单,点击时会展开。
mern-marketplace/client/order/ShopOrders.js
:
<Typography type="title"> Orders in {this.match.params.shop} </Typography>
<List dense> {this.state.orders.map((order, index) => { return
<span key={index}>
<ListItem button onClick={this.handleClick(index)}>
<ListItemText primary={'Order # '+order._id}
secondary={(new Date(order.created)).toDateString()}/>
{this.state.open == index ? <ExpandLess /> : <ExpandMore />}
</ListItem>
<Collapse component="li" in={this.state.open == index}
timeout="auto" unmountOnExit>
<ProductOrderEdit shopId={this.match.params.shopId}
order={order} orderIndex={index}
updateOrders={this.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
方法作为属性传递给ProductOrderEdit
组件,以便在更改产品状态时可以更新状态。
mern-marketplace/client/order/ShopOrders.js
:
updateOrders = (index, updatedOrder) => {
let orders = this.state.orders
orders[index] = updatedOrder
this.setState({orders: orders})
}
产品订单编辑组件
ProductOrderEdit
组件将订单对象作为属性,并遍历订单的产品数组,仅显示从当前商店购买的产品,以及更改每个产品状态值的下拉菜单。
mern-marketplace/client/order/ProductOrderEdit.js
:
{this.props.order.products.map((item, index) => { return <span key={index}>
{ item.shop == this.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={this.handleStatusChange(index)}
SelectProps={{
MenuProps: { className: classes.menu },
}}>
{this.state.statusValues.map(option => (
<MenuItem key={option} value={option}>
{option}
</MenuItem>
))}
</TextField>
</ListItem>}
在加载ProductOrderEdit
组件时,从服务器获取可能的状态值列表,并设置为statusValues
状态,以在下拉菜单中呈现为MenuItem
。
mern-marketplace/client/order/ProductOrderEdit.js
:
loadStatusValues = () => {
getStatusValues().then((data) => {
if (data.error) {
this.setState({error: "Could not get status"})
} else {
this.setState({statusValues: data, error: ''})
}
})
}
当从可能的状态值中选择一个选项时,将调用handleStatusChange
方法来更新状态中的订单,并根据所选状态的值发送请求到适当的后端 API。
mern-marketplace/client/order/ProductOrderEdit.js
:
handleStatusChange = productIndex => event => {
let order = this.props.order
order.products[productIndex].status = event.target.value
let product = order.products[productIndex]
const jwt = auth.isAuthenticated()
if(event.target.value == "Cancelled"){
cancelProduct({ shopId: this.props.shopId,
productId: product.product._id },
{t: jwt.token},
{cartItemId: product._id, status:
event.target.value,
quantity: product.quantity
}).then((data) => {
if (data.error) {
this.setState({error: "Status not updated,
try again"})
} else {
this.props.updateOrders(this.props.orderIndex, order) this.setState(error: '')
}
})
} else if(event.target.value == "Processing"){
processCharge({ userId: jwt.user._id, shopId:
this.props.shopId, orderId: order._id },
{ t: jwt.token},
{ cartItemId: product._id,
amount: (product.quantity *
product.product.price)
status: event.target.value }).then((data) => { ...
})
} else {
update({ shopId: this.props.shopId }, {t:
jwt.token},
{ cartItemId: product._id,
status: event.target.value}).then((data) => { ... })
}
}
在api-order.js
中定义了cancelProduct
、processCharge
和update
获取方法,以调用后端对应的 API 来更新取消产品的库存数量,在处理产品时在客户的信用卡上创建一个费用,并更新订单以更改产品状态。
已订购产品的 API
允许卖家更新产品状态将需要设置四个不同的 API,包括一个用于检索可能状态值的 API。然后实际状态更新将需要处理订单本身的更新 API,因为状态已更改,以启动相关操作,例如增加取消产品的库存数量,并在处理产品时在客户的信用卡上创建一个费用。
获取状态值
已订购产品的可能状态值在CartItem
模式中设置为枚举,并且为了在下拉视图中显示这些值作为选项,我们将在/api/order/status_values
设置一个 GET API 路由,以检索这些值。
mern-marketplace/server/routes/order.routes.js
:
router.route('/api/order/status_values')
.get(orderCtrl.getStatusValues)
getStatusValues
控制器方法将从CartItem
模式的status
字段返回枚举值。
mern-marketplace/server/controllers/order.controller.js
:
const getStatusValues = (req, res) => {
res.json(CartItem.schema.path('status').enumValues)
}
我们还将在api-order.js
中设置一个fetch
方法,这在视图中用于向 API 路由发出请求。
mern-marketplace/client/order/api-order.js
:
const getStatusValues = () => {
return fetch('/api/order/status_values', {
method: 'GET'
}).then((response) => {
return response.json()
}).catch((err) => console.log(err))
}
更新订单状态
当产品的状态更改为除处理中和已取消之外的任何值时,将直接向'/api/order/status/:shopId'
发送 PUT 请求,以更新数据库中的订单,假设当前用户是已验证的拥有订购产品的商店的所有者。
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
值。
mern-marketplace/server/controllers/order.controller.js
:
const update = (req, res) => {
Order.update({'products._id':req.body.cartItemId}, {'$set': {
'products.$.status': req.body.status
}}, (err, order) => {
if (err) {
return res.status(400).send({
error: errorHandler.getErrorMessage(err)
})
}
res.json(order)
})
}
在api-order.js
中,我们将添加一个update
fetch 方法,以使用从视图传递的必需参数调用此更新 API。
mern-marketplace/client/order/api-order.js
:
const update = (params, credentials, product) => {
return fetch('/api/order/status/' + params.shopId, {
method: 'PUT',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
},
body: JSON.stringify(product)
}).then((response) => {
return response.json()
}).catch((err) => {
console.log(err)
})
}
取消产品订单
当卖家决定取消产品的订单时,将发送一个 PUT 请求到/api/order/:shopId/cancel/:productId
,以便增加产品库存数量,并在数据库中更新订单。
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
产品控制器方法。
增加数量的控制器方法被添加到product.controller.js
中。它在产品集合中通过匹配的 ID 找到产品,并将数量值增加到客户订购的数量,现在该产品的订单已被取消。
mern-marketplace/server/controllers/product.controller.js
:
const increaseQuantity = (req, res, next) => {
Product.findByIdAndUpdate(req.product._id, {$inc:
{"quantity": req.body.quantity}}, {new: true})
.exec((err, result) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
next()
})
}
从视图中,我们将使用在api-order.js
中添加的相应 fetch 方法来调用取消产品订单 API。
mern-marketplace/client/order/api-order.js
:
const cancelProduct = (params, credentials, product) => {
return 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)
}).then((response) => {
return response.json()
}).catch((err) => {
console.log(err)
})
}
为产品处理收费
当卖家将产品的状态更改为处理中时,我们将建立一个后端 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
方法访问,如下所示。
mern-marketplace/server/controllers/order.controller.js:
const orderByID = (req, res, next, id) => {
Order.findById(id).populate('products.product', 'name price')
.populate('products.shop', 'name')
.exec((err, order) => {
if (err || !order)
return res.status('400').json({
error: "Order not found"
})
req.order = order
next()
})
}
此过程收费 API 将在/api/order/:orderId/charge/:userId/:shopId
接收 PUT 请求,并在成功验证用户后,通过调用createCharge
用户控制器来创建收费,如前面的使用 Stripe 进行付款部分所讨论的,最后使用update
方法更新订单。
从视图中,我们将在api-order.js
中使用processCharge
fetch 方法,并提供所需的路由参数值、凭据和产品详情,包括要收费的金额。
mern-marketplace/client/order/api-order.js
:
const processCharge = (params, credentials, product) => {
return 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)
}).then((response) => {
return response.json()
}).catch((err) => {
console.log(err)
})
}
卖家可以查看其店铺中收到的产品订单,并可以轻松更新每个产品订单的状态,而应用程序会处理其他任务,例如更新库存数量和发起付款。这涵盖了 MERN Marketplace 应用程序的基本订单管理功能,可以根据需要进一步扩展。
查看订单详情
随着订单集合和数据库访问的设置完成,向前推进很容易添加每个用户的订单列表功能,并在单独的视图中显示单个订单的详细信息,用户可以在该视图中跟踪每个已订购产品的状态。
遵循本书中反复出现的步骤,设置后端 API 以检索数据并在前端使用它来构建前端视图,您可以根据需要开发与订单相关的视图,并从 MERN Marketplace 应用程序代码中的这些示例视图的快照中获得灵感:
在本章和第六章中开发的 MERN Marketplace 应用程序,通过在 MERN 骨架应用程序的基础上构建,涵盖了标准在线市场应用程序的关键功能。这反过来展示了 MERN 堆栈如何扩展以包含复杂功能。
总结
在本章中,我们扩展了 MERN Marketplace 应用程序,并探讨了如何为买家添加购物车,使用信用卡付款的结账流程,以及在线市场应用程序中卖家的订单管理。
我们发现 MERN 堆栈技术如何与第三方集成良好,因为我们实现了购物车结账流程,并使用 Stripe 提供的工具处理已订购产品的信用卡付款,用于管理在线付款。
我们还解锁了 MERN 的更多可能性,例如在 MongoDB 中进行优化的批量写操作,以响应单个 API 调用更新多个文档。这使我们能够一次性减少多个产品的库存数量,例如当用户从不同商店订购多个产品时。
在 MERN Marketplace 应用程序中开发的市场功能揭示了这种堆栈和结构如何被利用来设计和构建不断增长的应用程序,通过添加可能是简单或更复杂的特性。
在下一章中,我们将借鉴本书迄今为止所学到的经验,通过扩展 MERN 骨架构建媒体流应用程序,探索更高级的可能性。
第八章:构建媒体流应用程序
上传和流媒体内容,特别是视频内容,已经成为互联网文化的一个日益增长的部分。从个人分享个人视频内容到娱乐行业在在线流媒体服务上发布商业内容,我们都依赖于能够实现平稳上传和流媒体的网络应用程序。MERN 堆栈技术中的功能可以用于构建和集成这些核心流媒体功能到任何基于 MERN 的 Web 应用程序中。
在这一章中,我们将通过扩展 MERN 骨架应用程序来覆盖以下主题,实现基本的媒体上传和流媒体:
-
将视频上传到 MongoDB GridFS
-
存储和检索媒体详情
-
从 GridFS 流式传输到基本媒体播放器
MERN Mediastream
我们将通过扩展基本应用程序来构建 MERN Mediastream 应用程序。这将是一个简单的视频流应用程序,允许注册用户上传视频,任何浏览应用程序的人都可以观看:
完整的 MERN Mediastream 应用程序的代码可在 GitHub 上找到github.com/shamahoque/mern-mediastream。本章讨论的实现可以在同一存储库的
simple-mediastream-gridfs
分支中访问。您可以克隆此代码,并在本章的其余部分中阅读代码解释时运行应用程序。
为了实现与媒体上传、编辑和流媒体相关的功能所需的视图,我们将通过扩展和修改 MERN 骨架应用程序中的现有 React 组件来开发。下图显示了构成本章中开发的 MERN Mediastream 前端的所有自定义 React 组件的组件树:
上传和存储媒体
在 MERN Mediastream 上注册的用户将能够从其本地文件上传视频,直接在 MongoDB 上使用 GridFS 存储视频和相关详情。
媒体模型
为了存储媒体详情,我们将在server/models/media.model.js
中为媒体模型添加一个 Mongoose 模式,其中包含用于记录媒体标题、描述、流派、观看次数、创建时间、更新时间以及发布媒体的用户的引用字段。
mern-mediastream/server/models/media.model.js
:
import mongoose from 'mongoose'
import crypto from 'crypto'
const MediaSchema = new mongoose.Schema({
title: {
type: String,
required: 'title is required'
},
description: String,
genre: String,
views: {type: Number, default: 0},
postedBy: {type: mongoose.Schema.ObjectId, ref: 'User'},
created: {
type: Date,
default: Date.now
},
updated: {
type: Date
}
})
export default mongoose.model('Media', MediaSchema)
MongoDB GridFS 用于存储大文件
在之前的章节中,我们讨论了用户上传的文件可以直接存储在 MongoDB 中作为二进制数据。但这仅适用于小于 16 MB 的文件。为了在 MongoDB 中存储更大的文件,我们需要使用 GridFS。
GridFS 通过将文件分成最大为 255 KB 的几个块,然后将每个块存储为单独的文档来在 MongoDB 中存储大文件。当需要响应 GridFS 查询检索文件时,根据需要重新组装块。这打开了根据需要获取和加载文件的部分而不是检索整个文件的选项。
在 MERN Mediastream 中存储和检索视频文件时,我们将利用 GridFS 存储视频文件,并根据用户跳转到和开始播放的部分来流式传输视频的部分。
我们将使用gridfs-stream
npm 模块将 GridFS 功能添加到我们的服务器端代码中:
npm install gridfs-stream --save
为了将gridfs-stream
与我们的数据库连接配置,我们将使用 Mongoose 将其链接如下。
mern-mediastream/server/controllers/media.controller.js
:
import mongoose from 'mongoose'
import Grid from 'gridfs-stream'
Grid.mongo = mongoose.mongo
let gridfs = null
mongoose.connection.on('connected', () => {
gridfs = Grid(mongoose.connection.db)
})
gridfs
对象将提供访问 GridFS 所需的功能,以便在创建新媒体时存储文件,并在需要向用户流回媒体时获取文件。
创建媒体 API
我们将在 Express 服务器上设置一个创建媒体 API,该 API 将在'/api/media/new/:userId'
接收包含媒体字段和上传的视频文件的多部分内容的 POST 请求。
创建媒体的路由
在server/routes/media.routes.js
中,我们将添加创建路由,并利用用户控制器中的userByID
方法。userByID
方法处理 URL 中传递的:userId
参数,并从数据库中检索关联的用户。
mern-mediastream/server/routes/media.routes.js
:
router.route('/api/media/new/:userId')
.post(authCtrl.requireSignin, mediaCtrl.create)
router.param('userId', userCtrl.userByID)
对创建路由的 POST 请求将首先确保用户已登录,然后在媒体控制器中启动create
方法。
类似于用户和认证路由,我们将不得不在express.js
中将媒体路由挂载到 Express 应用程序上。
mern-mediastream/server/express.js
:
app.use('/', mediaRoutes)
处理创建请求的控制器方法
媒体控制器中的create
方法将使用formidable
npm 模块解析包含媒体详细信息和用户上传的视频文件的多部分请求体:
npm install formidable --save
以formidable
解析的表单数据接收的媒体字段将用于生成新的媒体对象并保存到数据库中。
mern-mediastream/server/controllers/media.controller.js
:
const create = (req, res, next) => {
let form = new formidable.IncomingForm()
form.keepExtensions = true
form.parse(req, (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.createWriteStream({_id: media._id})
fs.createReadStream(files.video.path).pipe(writestream)
}
media.save((err, result) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
res.json(result)
})
})
}
如果请求中有文件,formidable
将在文件系统中临时存储它,我们将使用媒体对象的 ID 创建一个gridfs.writeStream
来读取临时文件并将其写入 MongoDB。这将在 MongoDB 中生成关联的块和文件信息文档。当需要检索此文件时,我们将使用媒体 ID 来识别它。
在视图中创建 API 获取
在api-media.js
中,我们将添加一个相应的方法,通过传递视图中的多部分表单数据来向创建 API 发出POST
请求。
mern-mediastream/client/user/api-user.js
:
const create = (params, credentials, media) => {
return fetch('/api/media/new/'+ params.userId, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer ' + credentials.t
},
body: media
}).then((response) => {
return response.json()
}).catch((err) => {
console.log(err)
})
}
当用户提交新的媒体表单以上传新视频时,将使用此create
获取方法。
新媒体表单视图
注册用户将在菜单中看到一个链接,用于添加新媒体。这个链接将带他们到新的媒体表单视图,并允许他们上传视频文件以及视频的详细信息。
添加媒体菜单按钮
在client/core/Menu.js
中,我们将更新现有的代码,以添加添加媒体按钮链接的 My Profile 和 Signout 链接:
只有在用户当前已登录时才会在菜单上呈现。
mern-mediastream/client/core/Menu.js/
:
<Link to="/media/new">
<Button style={isActive(history, "/media/new")}>
<AddBoxIcon style={{marginRight: '8px'}}/> Add Media
</Button>
</Link>
NewMedia 视图的 React 路由
当用户点击添加媒体链接时,我们将更新MainRouter
文件以添加/media/new
React 路由,这将渲染NewMedia
组件,将用户带到新的媒体表单视图。
mern-mediastream/client/MainRouter.js
:
<PrivateRoute path="/media/new" component={NewMedia}/>
由于这个新的媒体表单只能由已登录用户访问,我们将把它添加为PrivateRoute
。
NewMedia 组件
在NewMedia
组件中,我们将渲染一个表单,允许用户输入标题、描述和流派,并从本地文件系统上传视频文件:
我们将使用 Material-UI 的Button
和 HTML5 的file input
元素添加文件上传元素。
mern-mediastream/client/media/NewMedia.js
:
<input accept="video/*"
onChange={this.handleChange('video')}
id="icon-button-file"
type="file"
style={{display: none}}/>
<label htmlFor="icon-button-file">
<Button color="secondary" variant="raised" component="span">
Upload <FileUpload/>
</Button>
</label>
<span>{this.state.video ? this.state.video.name : ''}</span>
Title
,Description
和Genre
表单字段将添加TextField
组件。
mern-mediastream/client/media/NewMedia.js
:
<TextField id="title" label="Title" value={this.state.title}
onChange={this.handleChange('title')} margin="normal"/><br/>
<TextField id="multiline-flexible" label="Description"
multiline rows="2"
value={this.state.description}
onChange={this.handleChange('description')}/><br/>
<TextField id="genre" label="Genre" value={this.state.genre}
onChange={this.handleChange('genre')}/><br/>
这些表单字段的更改将通过handleChange
方法进行跟踪。
mern-mediastream/client/media/NewMedia.js
:
handleChange = name => event => {
const value = name === 'video'
? event.target.files[0]
: event.target.value
this.mediaData.set(name, value)
this.setState({ [name]: value })
}
handleChange
方法使用新值更新状态并填充mediaData
,这是一个FormData
对象。FormData
API 确保要发送到服务器的数据以multipart/form-data
编码类型所需的正确格式存储。这个mediaData
对象在componentDidMount
中初始化。
mern-mediastream/client/media/NewMedia.js
:
componentDidMount = () => {
this.mediaData = new FormData()
}
在表单提交时,将使用必要的凭据调用create
fetch 方法,并将表单数据作为参数传递:
clickSubmit = () => {
const jwt = auth.isAuthenticated()
create({
userId: jwt.user._id
}, {
t: jwt.token
}, this.mediaData).then((data) => {
if (data.error) {
this.setState({error: data.error})
} else {
this.setState({redirect: true, mediaId: data._id})
}
})
}
在成功创建媒体后,用户可以根据需要重定向到不同的视图,例如,到一个带有新媒体详情的媒体视图。
mern-mediastream/client/media/NewMedia.js
:
if (this.state.redirect) {
return (<Redirect to={'/media/' + this.state.mediaId}/>)
}
为了允许用户流媒体和查看存储在 MongoDB 中的视频文件,接下来我们将实现如何在视图中检索和渲染视频。
检索和流媒体
在服务器上,我们将设置一个路由来检索单个视频文件,然后将其用作 React 媒体播放器中的源,以渲染流媒体视频。
获取视频 API
我们将在媒体路由中添加一个路由,以在'/api/medias/video/:mediaId'
接收到 GET 请求时获取视频。
mern-mediastream/server/routes/media.routes.js
:
router.route('/api/medias/video/:mediaId')
.get(mediaCtrl.video)
router.param('mediaId', mediaCtrl.mediaByID)
路由 URL 中的:mediaId
参数将在mediaByID
控制器中处理,以从媒体集合中获取关联文档并附加到请求对象中,因此可以根据需要在video
控制器方法中使用。
mern-mediastream/server/controllers/media.controller.js
:
const mediaByID = (req, res, next, id) => {
Media.findById(id).populate('postedBy', '_id name').exec((err, media) => {
if (err || !media)
return res.status('400').json({
error: "Media not found"
})
req.media = media
next()
})
}
media.controller.js
中的video
控制器方法将使用gridfs
在 MongoDB 中查找与mediaId
关联的视频。然后,如果找到匹配的视频并且取决于请求是否包含范围标头,响应将发送回正确的视频块,并将相关内容信息设置为响应标头。
mern-mediastream/server/controllers/media.controller.js
:
const video = (req, res) => {
gridfs.findOne({
_id: req.media._id
}, (err, file) => {
if (err) {
return res.status(400).send({
error: errorHandler.getErrorMessage(err)
})
}
if (!file) {
return res.status(404).send({
error: 'No video found'
})
}
if (req.headers['range']) {
...
... consider range headers and send only relevant chunks in
response ...
...
} else {
res.header('Content-Length', file.length)
res.header('Content-Type', file.contentType)
gridfs.createReadStream({
_id: file._id
}).pipe(res)
}
})
}
如果请求包含范围标头,例如当用户拖动到视频中间并从那一点开始播放时,我们需要将范围标头转换为与使用 GridFS 存储的正确块对应的起始和结束位置。然后,我们将这些起始和结束值作为范围传递给 gridfs-stream 的createReadStream
方法,并且还使用附加文件详情设置响应标头,包括内容长度、范围和类型。
mern-mediastream/server/controllers/media.controller.js
:
let parts = req.headers['range'].replace(/bytes=/, "").split("-")
let partialstart = parts[0]
let partialend = parts[1]
let start = parseInt(partialstart, 10)
let end = partialend ? parseInt(partialend, 10) : file.length - 1
let chunksize = (end - start) + 1
res.writeHead(206, {
'Accept-Ranges': 'bytes',
'Content-Length': chunksize,
'Content-Range': 'bytes ' + start + '-' + end + '/' + file.length,
'Content-Type': file.contentType
})
gridfs.createReadStream({
_id: file._id,
range: {
startPos: start,
endPos: end
}
}).pipe(res)
最终的readStream
管道传输到响应中可以直接在前端视图中使用基本的 HTML5 媒体播放器或 React 风格的媒体播放器进行渲染。
使用 React 媒体播放器来呈现视频
作为 npm 可用的 React 风格媒体播放器的一个很好的选择是ReactPlayer
组件,可以根据需要进行自定义:
可以通过安装相应的npm
模块在应用程序中使用它:
npm install react-player --save
对于使用浏览器提供的默认控件的基本用法,我们可以将其添加到应用程序中任何具有要呈现的媒体 ID 访问权限的 React 视图中:
<ReactPlayer url={'/api/media/video/'+media._id} controls/>
在下一章中,我们将探讨使用我们自己的控件自定义这个ReactPlayer
的高级选项。
要了解有关ReactPlayer
可能性的更多信息,请访问cookpete.com/react-player。
媒体列表
在 MERN Mediastream 中,我们将添加相关媒体的列表视图,并为每个视频提供快照,以便访问者更容易地访问应用程序中的视频概述。我们将在后端设置列表 API 来检索不同的列表,例如单个用户上传的视频以及应用程序中观看次数最多的最受欢迎视频。然后,这些检索到的列表可以在MediaList
组件中呈现,该组件将从父组件接收一个列表作为 prop,该父组件从特定 API 中获取列表:
在前面的屏幕截图中,Profile
组件使用用户 API 列表来获取前面配置文件中看到的用户发布的媒体列表,并将接收到的列表传递给MediaList
组件以呈现每个视频和媒体详细信息。
媒体列表组件
MediaList
组件是一个可重用的组件,它将获取一个媒体列表并在视图中迭代每个项目进行呈现。在 MERN Mediastream 中,我们使用它来在主页视图中呈现最受欢迎的媒体列表,以及在用户配置文件中上传的媒体列表。
mern-mediastream/client/media/MediaList.js
:
<GridList cols={3}>
{this.props.media.map((tile, i) => (
<GridListTile key={i}>
<Link to={"/media/"+tile._id}>
<ReactPlayer url={'/api/media/video/'+tile._id}
width='100%' height='inherit'/>
</Link>
<GridListTileBar
title={<Link to={"/media/"+tile._id}>{tile.title}</Link>}
subtitle={<span>{tile.views} views
<span style={{float: 'right'}}>{tile.genre}</span>}/>
</GridListTile>
))}
</GridList>
MediaList
组件使用 Material-UI 的GridList
组件,它在 props 中迭代列表,并为列表中的每个项目呈现媒体详细信息,以及一个ReactPlayer
组件,用于呈现视频 URL 而不显示任何控件。在视图中,这为访问者提供了媒体的简要概述,也可以一瞥视频内容。
列出热门媒体
为了从数据库中检索特定的媒体列表,我们需要在服务器上设置相关的 API。对于热门媒体,我们将设置一个路由,接收/api/media/popular
的 GET 请求。
mern-mediastream/server/routes/media.routes.js
:
router.route('/api/media/popular')
.get(mediaCtrl.listPopular)
listPopular
控制器方法将查询媒体集合,以检索具有整个集合中最高views
的十个媒体文档。
mern-mediastream/server/controllers/media.controller.js
:
const listPopular = (req, res) => {
Media.find({}).limit(10)
.populate('postedBy', '_id name')
.sort('-views')
.exec((err, posts) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
res.json(posts)
})
}
为了在视图中使用此 API,我们将在api-media.js
中设置相应的 fetch 方法。
mern-mediastream/client/media/api-media.js
:
const listPopular = (params) => {
return fetch('/api/media/popular', {
method: 'GET',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
}).then(response => {
return response.json()
}).catch((err) => console.log(err))
}
当Home
组件挂载时,将调用此fetch
方法,以便将列表设置为状态,并传递给视图中的MediaList
组件。
mern-mediastream/client/core/Home.js
:
componentDidMount = () => {
listPopular().then((data) => {
if (data.error) {
console.log(data.error)
} else {
this.setState({media: data})
}
})
}
在主页视图中,我们将添加MediaList
如下,列表作为 prop 提供:
<MediaList media={this.state.media}/>
按用户列出媒体
为了检索特定用户上传的媒体列表,我们将设置一个 API,该 API 在路由上接受'/api/media/by/:userId'
的 GET 请求。
mern-mediastream/server/routes/media.routes.js
:
router.route('/api/media/by/:userId')
.get(mediaCtrl.listByUser)
listByUser
控制器方法将查询媒体集合,以查找postedBy
值与userId
匹配的媒体文档。
mern-mediastream/server/controllers/media.controller.js
:
const listByUser = (req, res) => {
Media.find({postedBy: req.profile._id})
.populate('postedBy', '_id name')
.sort('-created')
.exec((err, posts) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
res.json(posts)
})
}
为了在前端视图中使用此用户列表 API,我们将在api-media.js
中设置相应的fetch
方法。
mern-mediastream/client/user/api-user.js
:
const listByUser = (params) => {
return fetch('/api/media/by/'+ params.userId, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
}).then(response => {
return response.json()
}).catch((err) => console.log(err))
}
这个 fetch 方法可以在Profile
组件中使用,类似于在主页视图中使用的listPopular
fetch 方法,以检索列表数据,设置状态,然后传递给MediaList
组件。
显示、更新和删除媒体
MERN Mediastream 的任何访问者都可以查看媒体详细信息并流式传输视频,而只有注册用户才能在在应用程序上发布后随时编辑详细信息和删除媒体。
显示媒体
MERN Mediastream 的任何访问者都可以浏览到单个媒体视图,播放视频并阅读与媒体相关的详细信息。每次在应用程序上加载特定视频时,我们还将增加与媒体相关的观看次数。
阅读媒体 API
为了获取特定媒体记录的媒体信息,我们将设置一个路由,接受'/api/media/:mediaId'
的 GET 请求。
mern-mediastream/server/routes/media.routes.js
:
router.route('/api/media/:mediaId')
.get( mediaCtrl.incrementViews, mediaCtrl.read)
请求 URL 中的mediaId
将导致执行mediaByID
控制器方法,并将检索到的媒体文档附加到请求对象。然后,此媒体数据将由read
控制器方法返回在响应中。
mern-mediastream/server/controllers/media.controller.js
:
const read = (req, res) => {
return res.json(req.media)
}
对此 API 的 GET 请求还将执行incrementViews
控制器方法,该方法将找到匹配的媒体记录,并将views
值增加 1,然后将更新后的记录保存到数据库中。
mern-mediastream/server/controllers/media.controller.js
:
const incrementViews = (req, res, next) => {
Media.findByIdAndUpdate(req.media._id, {$inc: {"views": 1}}, {new: true})
.exec((err, result) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
next()
})
}
为了在前端使用此读取 API,我们将在api-media.js
中设置相应的 fetch 方法。
mern-mediastream/client/user/api-user.js
:
const read = (params) => {
return fetch(config.serverUrl+'/api/media/' + params.mediaId, {
method: 'GET'
}).then((response) => {
return response.json()
}).catch((err) => console.log(err))
}
读取 API 可用于在视图中呈现单个媒体详细信息,或者预填充媒体编辑表单。
媒体组件
Media
组件将呈现单个媒体记录的详细信息,并在具有默认浏览器控件的基本ReactPlayer
中流式传输视频。
Media
组件可以调用读取 API 来获取媒体数据,也可以从调用读取 API 的父组件作为 prop 接收数据。在后一种情况下,父组件将添加Media
组件,如下所示。
mern-mediastream/client/media/PlayMedia.js
:
<Media media={this.state.media}/>
在 MERN Mediastream 中,我们在PlayMedia
组件中添加了Media
组件,该组件使用读取 API 从服务器获取媒体内容,并将其作为 prop 传递给 Media。 Media
组件将获取这些数据并在视图中呈现它们,以显示详细信息并在ReactPlayer
组件中加载视频。
标题,流派和观看次数可以在 Material-UICardHeader
组件中呈现。
mern-mediastream/client/media/Media.js
:
<CardHeader
title={this.props.media.title}
action={<span>
{this.props.media.views + ' views'}
</span>}
subheader={this.props.media.genre}
/>
视频 URL,基本上是我们在后端设置的 GET API 路由,将在ReactPlayer
中加载,并具有默认的浏览器控件。
mern-mediastream/client/media/Media.js
:
const mediaUrl = this.props.media._id
? `/api/media/video/${this.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>
{this.props.media.postedBy.name &&
this.props.media.postedBy.name[0]}
</Avatar>
</ListItemAvatar>
<ListItemText primary={this.props.media.postedBy.name}
secondary={"Published on " +
(new Date(this.props.media.created))
.toDateString()}/>
</ListItem>
<ListItem>
<ListItemText primary={this.props.media.description}/>
</ListItem>
如果当前登录的用户也是发布显示的媒体的用户,则Media
组件还会有条件地显示编辑和删除选项。
mern-mediastream/client/media/Media.js
:
{(auth.isAuthenticated().user && auth.isAuthenticated().user._id)
== this.props.media.postedBy._id && (<ListItemSecondaryAction>
<Link to={"/media/edit/" + this.props.media._id}>
<IconButton aria-label="Edit" color="secondary">
<Edit/>
</IconButton>
</Link>
<DeleteMedia mediaId={this.props.media._id} mediaTitle=
{this.props.media.title}/>
</ListItemSecondaryAction>)}
编辑选项链接到媒体编辑表单,删除选项打开一个对话框,可以启动从数据库中删除特定媒体文档。
更新媒体详细信息
注册用户将可以访问其每个媒体上传的编辑表单,更新并提交此表单将保存更改到媒体集合中的文档中。
媒体更新 API
为了允许用户更新媒体详细信息,我们将设置一个媒体更新 API,该 API 将在'/api/media/:mediaId'
处接受 PUT 请求,并在请求正文中包含更新的详细信息。
mern-mediastream/server/routes/media.routes.js
:
router.route('/api/media/:mediaId')
.put(authCtrl.requireSignin,
mediaCtrl.isPoster,
mediaCtrl.update)
当收到此请求时,服务器将首先通过调用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()
}
如果用户被授权,将调用update
控制器方法next
,以更新现有的媒体文档并将其保存到数据库中。
mern-mediastream/server/controllers/media.controller.js
:
const update = (req, res, next) => {
let media = req.media
media = _.extend(media, req.body)
media.updated = Date.now()
media.save((err) => {
if (err) {
return res.status(400).send({
error: errorHandler.getErrorMessage(err)
})
}
res.json(media)
})
}
为了在前端访问更新 API,我们将在api-media.js
中添加相应的获取方法,该方法将以必要的凭据和媒体详细信息作为参数。
mern-mediastream/client/user/api-user.js
:
const update = (params, credentials, media) => {
return fetch('/api/media/' + params.mediaId, {
method: 'PUT',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
},
body: JSON.stringify(media)
}).then((response) => {
return response.json()
}).catch((err) => {
console.log(err)
})
}
当用户更新并提交表单时,此获取方法将用于媒体编辑表单。
媒体编辑表单
媒体编辑表单将类似于新媒体表单,但不包括上传选项,并且字段将预填充现有细节:
包含此表单的EditMedia
组件只能由登录用户访问,并将呈现在'/media/edit/:mediaId'
。此私有路由将在MainRouter
中与其他前端路由一起声明。
mern-mediastream/client/MainRouter.js
:
<PrivateRoute path="/media/edit/:mediaId" component={EditMedia}/>
一旦EditMedia
组件挂载到视图上,将调用获取调用以从读取媒体 API 检索媒体详细信息并设置为状态,以便在文本字段中呈现值。
mern-mediastream/client/media/EditMedia.js
:
componentDidMount = () => {
read({mediaId: this.match.params.mediaId}).then((data) => {
if (data.error) {
this.setState({error: data.error})
} else {
this.setState({media: data})
}
})
}
表单字段元素将与NewMedia
组件中的相同。当用户更新表单中的任何值时,将通过调用handleChange
方法在状态中注册media
对象中的更改。
mediastream/client/media/EditMedia.js
:
handleChange = name => event => {
let updatedMedia = this.state.media
updatedMedia[name] = event.target.value
this.setState({media: updatedMedia})
}
当用户完成编辑并点击提交时,将调用更新 API,并提供所需的凭据和更改后的媒体值。
mediastream/client/media/EditMedia.js
:
clickSubmit = () => {
const jwt = auth.isAuthenticated()
update({
mediaId: this.state.media._id
}, {
t: jwt.token
}, this.state.media).then((data) => {
if (data.error) {
this.setState({error: data.error})
} else {
this.setState({error: '', redirect: true, media: data})
}
})
}
这将更新媒体详情,并且与媒体相关的视频文件将保持在数据库中不变。
删除媒体
经过身份验证的用户可以完全删除他们上传到应用程序的媒体,包括媒体集合中的媒体文档,以及使用 GridFS 存储在 MongoDB 中的文件块。
删除媒体 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 请求时,它将首先确保登录用户是需要删除的媒体的原始发布者。然后remove
控制器方法将从数据库中删除指定的媒体详情。
mern-mediastream/server/controllers/media.controller.js
:
const remove = (req, res, next) => {
let media = req.media
media.remove((err, deletedMedia) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
gridfs.remove({ _id: req.media._id })
res.json(deletedMedia)
})
}
除了从媒体集合中删除媒体记录外,我们还使用gridfs
来删除数据库中存储的相关文件详情和块。
我们还将在api-media.js
中添加一个相应的方法来从视图中获取delete
API。
mern-mediastream/client/user/api-user.js
:
const remove = (params, credentials) => {
return fetch('/api/media/' + params.mediaId, {
method: 'DELETE',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
}
}).then((response) => {
return response.json()
}).catch((err) => {
console.log(err)
})
}
删除媒体组件
DeleteMedia
组件被添加到Media
组件中,只对添加了特定媒体的已登录用户可见。该组件以媒体 ID 和标题作为 props:
这个DeleteMedia
组件基本上是一个图标按钮,点击后会打开一个确认对话框,询问用户是否确定要删除他们的视频。
mern-mediastream/client/media/DeleteMedia.js
:
<IconButton aria-label="Delete" onClick={this.clickButton} color="secondary">
<DeleteIcon/>
</IconButton>
<Dialog open={this.state.open} onClose={this.handleRequestClose}>
<DialogTitle>{"Delete "+this.props.mediaTitle}</DialogTitle>
<DialogContent>
<DialogContentText>
Confirm to delete {this.props.mediaTitle} from your account.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={this.handleRequestClose} color="primary">
Cancel
</Button>
<Button onClick={this.deleteMedia}
color="secondary"
autoFocus="autoFocus"
variant="raised">
Confirm
</Button>
</DialogActions>
</Dialog>
当用户确认删除意图时,将调用delete
获取方法。
mern-mediastream/client/media/DeleteMedia.js
:
deleteMedia = () => {
const jwt = auth.isAuthenticated()
remove({
mediaId: this.props.mediaId
}, {t: jwt.token}).then((data) => {
if (data.error) {
console.log(data.error)
} else {
this.setState({redirect: true})
}
})
}
然后在成功删除后,用户将被重定向到主页。
mern-mediastream/client/media/DeleteMedia.js
:
if (this.state.redirect) {
return <Redirect to='/'/>
}
本章开发的 MERN Mediastream 应用程序是一个完整的媒体流应用程序,具有将视频文件上传到数据库的功能,将存储的视频流回给观众的功能,支持 CRUD 操作,如媒体创建、更新、读取和删除,以及按上传者或受欢迎程度列出媒体的选项。
总结
在本章中,我们通过扩展 MERN 骨架应用程序并利用 MongoDB GridFS 开发了一个媒体流应用程序。
除了为媒体添加基本的添加、更新、删除和列表功能外,我们还研究了基于 MERN 的应用如何允许用户上传视频文件,将这些文件存储到 MongoDB GridFS 中,并根据需要部分或完全地向观看者流式传输视频。我们还介绍了使用默认浏览器控件来流式传输视频文件的ReactPlayer
的基本用法。
在下一章中,我们将看到如何使用自定义控件和功能定制ReactPlayer
,以便用户拥有更多选项,比如播放列表中的下一个视频。此外,我们将讨论如何通过实现带有媒体视图数据的服务器端渲染来改善媒体详情的搜索引擎优化。
第九章:自定义媒体播放器和改善 SEO
用户主要是为了播放媒体和探索其他相关媒体而访问媒体流应用程序。这使得媒体播放器和呈现相关媒体详情的视图对于流媒体应用程序至关重要。
在本章中,我们将专注于为我们在上一章开始构建的 MERN Mediastream 应用程序开发播放媒体页面。我们将讨论以下主题,以加强媒体播放功能,并帮助增加媒体内容在网络上的存在,以便能够触达更多用户:
-
自定义
ReactPlayer
上的控件 -
从相关视频列表中播放下一个视频
-
自动播放相关媒体列表
-
服务器端渲染媒体视图以改善 SEO
使用自定义媒体播放器的 MERN Mediastream
在上一章中开发的 MERN Mediastream 应用程序实现了一个简单的媒体播放器,具有默认的浏览器控件,一次只能播放一个视频。在本章中,我们将使用自定义的ReactPlayer
和相关媒体列表更新播放媒体的视图,可以在当前视频结束时自动播放。更新后的具有自定义播放器和相关播放列表的视图如下图所示:
完整的 MERN Mediastream 应用程序的代码可在 GitHub 上找到,网址为github.com/shamahoque/mern-mediastream。您可以在阅读本章其余部分的代码解释时,克隆此代码并运行应用程序。
以下组件树图显示了构成 MERN Mediastream 前端的所有自定义组件,突出显示了本章中将改进或添加的组件。
本章中新增的组件包括MediaPlayer
组件,它添加了带有自定义控件的ReactPlayer
,以及RelatedMedia
组件,其中包含相关视频列表。
播放媒体页面
当访问者想要在 MERN Mediastream 上观看特定媒体时,他们将被带到播放媒体页面,其中包含媒体详情、用于流媒体视频的媒体播放器,以及可以接下来播放的相关媒体列表。
组件结构
我们将以一种允许媒体数据从父组件向内部组件传递的方式构成播放媒体页面的组件结构。在这种情况下,PlayMedia
组件将是父组件,包含RelatedMedia
组件和带有嵌套的MediaPlayer
组件的Media
组件:
当访问单个媒体链接时,PlayMedia
组件将挂载并从服务器检索媒体数据和相关媒体列表。然后,相关数据将作为 props 传递给Media
和RelatedMedia
子组件。
RelatedMedia
组件将链接到其他相关媒体的列表,点击每个将重新渲染PlayMedia
组件和内部组件以显示新数据。
我们将更新我们在第八章中开发的Media
组件,构建媒体流应用程序,以添加一个定制的媒体播放器作为子组件。这个定制的MediaPlayer
组件还将利用从PlayMedia
传递的数据来播放当前视频并链接到相关媒体列表中的下一个视频。
在PlayMedia
组件中,我们将添加一个自动播放切换按钮,让用户选择自动播放相关媒体列表中的视频,一个接着一个。自动播放状态将从PlayMedia
组件管理,但此功能将需要在MediaPlayer
中视频结束时重新渲染状态中的数据,这是一个嵌套的子组件,所以下一个视频可以在保持相关列表跟踪的同时自动开始播放。
为了实现这一点,PlayMedia
组件将需要提供一个状态更新方法作为 prop,该方法将在MediaPlayer
组件中使用,以更新这些组件之间共享和相互依赖的状态值。
考虑到这种组件结构,我们将扩展和更新 MERN Mediastream 应用程序,以实现一个功能性的播放媒体页面。
相关媒体列表
相关媒体列表将包括属于与给定视频相同流派的其他媒体记录,并按观看次数最多的顺序排序。
相关列表 API
为了从数据库中检索相关媒体列表,我们将在服务器上设置一个 API,该 API 将在'/api/media/related/:mediaId'
接收 GET 请求。
mern-mediastream/server/routes/media.routes.js
:
router.route('/api/media/related/:mediaId')
.get(mediaCtrl.listRelated)
listRelated
控制器方法将查询媒体集合,以找到与提供的媒体具有相同流派的记录,并从返回的结果中排除此媒体记录。返回的结果将按照最高的观看次数进行排序,并限制为前四个媒体记录。返回的结果中的每个media
对象还将包含发布媒体的用户的名称和 ID。
mern-mediastream/server/controllers/media.controller.js
:
const listRelated = (req, res) => {
Media.find({ "_id": { "$ne": req.media },
"genre": req.media.genre}).limit(4)
.sort('-views')
.populate('postedBy', '_id name')
.exec((err, posts) => {
if (err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
res.json(posts)
})
}
在客户端,我们将设置一个相应的fetch
方法,该方法将在PlayMedia
组件中用于使用此 API 检索相关媒体列表。
mern-mediastream/client/media/api-media.js
:
const listRelated = (params) => {
return fetch('/api/media/related/'+ params.mediaId, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
}).then(response => {
return response.json()
}).catch((err) => console.log(err))
}
相关媒体组件
RelatedMedia
组件从PlayMedia
组件中以 prop 的形式获取相关媒体列表,并呈现每个视频的详细信息以及视频快照。
我们使用map
函数遍历媒体列表,以呈现每个媒体项。
mern-mediastream/client/media/RelatedMedia.js
:
{this.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>
单击快照将重新呈现 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>
为了在视图中使用RelatedMedia
组件,我们将在PlayMedia
组件中添加它。
播放媒体组件
PlayMedia
组件由Media
和RelatedMedia
子组件以及自动播放切换按钮组成,并在视图加载时向这些组件提供数据。为了在用户访问单个媒体链接时呈现PlayMedia
组件,我们将在MainRouter
中添加一个Route
来在'/media/:mediaId'
处挂载PlayMedia
。
mern-mediastream/client/MainRouter.js
:
<Route path="/media/:mediaId" component={PlayMedia}/>
当PlayMedia
组件挂载时,它将使用loadMedia
函数基于路由链接中的媒体 ID
参数从服务器获取媒体数据和相关媒体列表。
mern-mediastream/client/media/PlayMedia.js
:
loadMedia = (mediaId) => {
read({mediaId: mediaId}).then((data) => {
if (data.error) {
this.setState({error: data.error})
} else {
this.setState({media: data})
listRelated({
mediaId: data._id}).then((data) => {
if (data.error) {
console.log(data.error)
} else {
this.setState({relatedMedia: data})
}
})
}
})
}
loadMedia
函数使用媒体 ID 和read
API 的fetch
方法从服务器检索媒体详细信息。然后,它使用listRelated
API 的 fetch 方法从服务器检索相关媒体列表,并将值设置为状态。
当组件挂载时,将使用mediaId
值调用loadMedia
函数,也会在接收到 props 时调用。
mern-mediastream/client/media/PlayMedia.js
:
componentDidMount = () => {
this.loadMedia(this.match.params.mediaId)
}
componentWillReceiveProps = (props) => {
this.loadMedia(props.match.params.mediaId)
}
为了在组件挂载时访问路由 URL 中的mediaId
参数,我们需要在组件的构造函数中访问 react-router 的match
对象。
mern-mediastream/client/media/PlayMedia.js
:
constructor({match}) {
super()
this.state = {
media: {postedBy: {}},
relatedMedia: [],
autoPlay: false,
}
this.match = match
}
存储在组件状态中的媒体和相关媒体列表值用于将相关的 props 传递给视图中添加的子组件。例如,只有在相关媒体列表包含任何项目时,才会渲染RelatedMedia
组件,并将其作为 prop 传递给列表。
mern-mediastream/client/media/PlayMedia.js
:
{this.state.relatedMedia.length > 0 &&
(<RelatedMedia media={this.state.relatedMedia}/>)}
在本章的自动播放相关媒体部分,如果相关媒体列表的长度大于零,我们将在RelatedMedia
组件上方添加自动播放切换组件。我们还将讨论handleAutoPlay
方法的实现,该方法将作为 prop 传递给Media
组件,以及媒体详情对象和相关媒体列表中第一个媒体的视频 URL 作为下一个要播放的 URL。
mern-mediastream/client/media/PlayMedia.js
:
const nextUrl = this.state.relatedMedia.length > 0
? `/media/${this.state.relatedMedia[0]._id}` : ''
<Media media={this.state.media}
nextUrl={nextUrl}
handleAutoplay={this.handleAutoplay}/>
Media
组件渲染媒体详情,还有一个媒体播放器,允许观众控制视频的流媒体。
媒体播放器
我们将自定义ReactPlayer
上的播放器控件,以替换默认的浏览器控件,具有自定义外观和功能,如屏幕截图所示:
控件将添加在视频下方,并包括进度查找栏、播放、暂停、下一个、音量、循环和全屏选项,还会显示已播放的持续时间。
更新媒体组件
我们将创建一个新的MediaPlayer
组件,其中包含自定义的ReactPlayer
。在Media
组件中,我们将用新的MediaPlayer
组件替换先前使用的ReactPlayer
,并将视频源 URL、下一个视频的 URL 和handleAutoPlay
方法作为 props 从PlayMedia
组件接收。
mern-mediastream/client/media/Media.js
:
const mediaUrl = this.props.media._id
? `/api/media/video/${this.props.media._id}`
: null
...
<MediaPlayer srcUrl={mediaUrl}
nextUrl={this.props.nextUrl}
handleAutoplay={this.props.handleAutoplay}/>
初始化媒体播放器
MediaPlayer
组件将包含ReactPlayer
组件,首先使用初始控制值,然后添加自定义控件和处理代码。
首先,我们将将初始控制值设置为state
。
mern-mediastream/client/media/MediaPlayer.js
:
state = {
playing: true,
volume: 0.8,
muted: false,
played: 0,
loaded: 0,
duration: 0,
ended:false,
playbackRate: 1.0,
loop: false,
fullscreen: false,
videoError: false
}
在视图中,我们将使用从Media
组件发送的 prop 来添加带有控制值和源 URL 的ReactPlayer
。
mern-mediastream/client/media/MediaPlayer.js
:
const { playing, ended, volume, muted, loop, played, loaded, duration, playbackRate, fullscreen, videoError } = this.state
...
<ReactPlayer
ref={this.ref}
width={fullscreen ? '100%':'inherit'}
height={fullscreen ? '100%':'inherit'}
style={fullscreen ? {position:'relative'} : {maxHeight: '500px'}}
config={{ attributes: { style: { height: '100%', width: '100%'} } }}
url={this.props.srcUrl}
playing={playing}
loop={loop}
playbackRate={playbackRate}
volume={volume}
muted={muted}
onEnded={this.onEnded}
onError={this.videoError}
onProgress={this.onProgress}
onDuration={this.onDuration}/>
我们将获取对此播放器的引用,以便在自定义控件的更改处理代码中使用它。
mern-mediastream/client/media/MediaPlayer.js
:
ref = player => {
this.player = player
}
如果无法加载源视频,我们将捕获错误。
mern-mediastream/client/media/MediaPlayer.js
:
videoError = e => {
this.setState({videoError: true})
}
然后我们将在视图中有条件地显示错误消息。
mern-mediastream/client/media/MediaPlayer.js
:
{videoError && <p className={classes.videoError}>Video Error. Try again later.</p>}
自定义媒体控件
我们将在视频下方添加自定义播放器控件元素,并使用ReactPlayer
API 提供的选项和事件来操纵它们的功能。
播放、暂停和重播
用户将能够播放、暂停和重播当前视频,我们将使用Material-UI
组件绑定到ReactPlayer
属性和事件来实现这三个选项:
为了实现播放、暂停和重播功能,我们将有条件地添加一个播放、暂停或重播图标按钮,具体取决于视频是正在播放、暂停还是已结束。
mern-mediastream/client/media/MediaPlayer.js
:
<IconButton color="primary" onClick={this.playPause}>
<Icon>{playing ? 'pause': (ended ? 'replay' : 'play_arrow')}</Icon>
</IconButton>
当用户点击按钮时,我们将更新状态中的 playing 值,以便更新ReactPlayer
。
mern-mediastream/client/media/MediaPlayer.js
:
playPause = () => {
this.setState({ playing: !this.state.playing })
}
播放下一个
用户将能够使用下一个按钮播放相关媒体列表中的下一个视频:
如果相关列表不包含任何媒体,下一个按钮将被禁用。播放下一个图标基本上将链接到从PlayMedia
传递的下一个 URL 值。
mern-mediastream/client/media/MediaPlayer.js
:
<IconButton disabled={!this.props.nextUrl} color="primary">
<Link to={this.props.nextUrl}>
<Icon>skip_next</Icon>
</Link>
</IconButton>
点击此“下一个”按钮将重新加载带有新媒体详情的PlayMedia
组件并开始播放视频。
结束时循环
用户还可以使用循环按钮将当前视频设置为保持循环播放:
我们将设置一个循环图标按钮,以显示不同的颜色,以指示它是设置还是未设置。
mern-mediastream/client/media/MediaPlayer.js
:
<IconButton color={loop? 'primary' : 'default'}
onClick={this.onLoop}>
<Icon>loop</Icon>
</IconButton>
当循环图标按钮被点击时,它会更新状态中的loop
值。
mern-mediastream/client/media/MediaPlayer.js
:
onLoop = () => {
this.setState({ loop: !this.state.loop })
}
我们需要捕获onEnded
事件,以检查loop
是否被设置为 true,这样playing
值可以相应地更新。
mern-mediastream/client/media/MediaPlayer.js
:
onEnded = () => {
if(this.state.loop){
this.setState({ playing: true})
}else{
this.setState({ ended: true, playing: false })
}
}
因此,如果loop
设置为 true,当视频结束时,它将重新开始播放,否则它将停止播放并渲染重播按钮。
音量控制
为了控制正在播放的视频的音量,用户可以选择增加或减少音量,以及静音或取消静音。渲染的音量控件将根据用户操作和音量的当前值进行更新:
- 如果音量提高,将呈现一个音量增加图标:
- 如果用户将音量减少到零,将呈现一个音量关闭图标:
- 当用户点击图标静音音量时,将显示一个音量静音图标按钮:
为了实现这一点,我们将有条件地在IconButton
中渲染不同的图标,根据volume
、muted
、volume_up
和volume_off
的值:
<IconButton color="primary" onClick={this.toggleMuted}>
<Icon> {volume > 0 && !muted && 'volume_up' ||
muted && 'volume_off' ||
volume==0 && 'volume_mute'} </Icon>
</IconButton>
当点击音量按钮时,它将静音或取消静音。
mern-mediastream/client/media/MediaPlayer.js
:
toggleMuted = () => {
this.setState({ muted: !this.state.muted })
}
为了允许用户增加或减少音量,我们将添加一个input range
,允许用户设置音量值在0
和1
之间。
mern-mediastream/client/media/MediaPlayer.js
:
<input type="range"
min={0}
max={1}
step='any'
value={muted? 0 : volume}
onChange={this.setVolume}/>
更改输入范围上的value
将相应地设置volume
值。
mern-mediastream/client/media/MediaPlayer.js
:
setVolume = e => {
this.setState({ volume: parseFloat(e.target.value) })
}
进度控制
我们将使用 Material-UI 的LinearProgress
组件来指示视频已缓冲的部分和已播放的部分。然后我们将把这个组件与range input
结合起来,让用户能够移动时间滑块到视频的不同部分并从那里播放:
LinearProgress
组件将采用played
和loaded
值来显示不同的颜色:
<LinearProgress color="primary" variant="buffer"
value={played*100} valueBuffer={loaded*100}
style={{width: '100%'}}
classes={{ colorPrimary: classes.primaryColor,
dashedColorPrimary: classes.primaryDashed,
dashed: {animation: 'none'} }}
/>
为了在视频播放或加载时更新LinearProgress
组件,我们将使用onProgress
事件监听器来设置played
和loaded
的当前值。
mern-mediastream/client/media/MediaPlayer.js
:
onProgress = progress => {
if (!this.state.seeking) {
this.setState({played: progress.played, loaded: progress.loaded})
}
}
对于时间滑动控制,我们将添加range input
元素,并使用 CSS 样式将其放置在LinearProgress
组件上。随着played
值的变化,范围的当前值将更新,因此范围值似乎随着视频的进展而移动。
mern-mediastream/client/media/MediaPlayer.js
:
<input type="range" min={0} max={1}
value={played} step='any'
onMouseDown={this.onSeekMouseDown}
onChange={this.onSeekChange}
onMouseUp={this.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
。
mern-mediastream/client/media/MediaPlayer.js
:
onSeekMouseDown = e => {
this.setState({ seeking: true })
}
随着范围值的变化,我们将设置played
值和ended
值,并检查用户是否将时间滑块拖到视频的末尾。
mern-mediastream/client/media/MediaPlayer.js
:
onSeekChange = e => {
this.setState({ played: parseFloat(e.target.value),
ended: parseFloat(e.target.value) >= 1 })
}
当用户完成拖动并松开鼠标点击时,我们将把seeking
设置为false
,并将播放器的seekTo
值设置为range input
中的当前值。
mern-mediastream/client/media/MediaPlayer.js
:
onSeekMouseUp = e => {
this.setState({ seeking: false })
this.player.seekTo(parseFloat(e.target.value))
}
这样,用户将能够选择视频的任何部分,并获得视频流的时间进度的视觉信息。
全屏
用户可以通过单击控件中的全屏按钮在全屏模式下观看视频:
为了为视频实现全屏选项,我们将使用screenfull
npm 模块来跟踪视图是否处于全屏状态,并使用react-dom
中的findDOMNode
来指定哪个 DOM 元素将与screenfull
一起全屏显示。
要设置“全屏”代码,我们首先安装screenfull
:
npm install screenfull --save
然后将screenfull
和findDOMNode
导入到MediaPlayer
组件中。
mern-mediastream/client/media/MediaPlayer.js
:
import screenfull from 'screenfull'
import { findDOMNode } from 'react-dom'
当MediaPlayer
组件挂载时,我们将添加一个screenfull
更改事件侦听器,以更新状态中的“全屏”值,以指示屏幕是否处于全屏状态。
mern-mediastream/client/media/MediaPlayer.js
:
componentDidMount = () => {
if (screenfull.enabled) {
screenfull.on('change', () => {
let fullscreen = screenfull.isFullscreen ? true : false
this.setState({fullscreen: fullscreen})
})
}
}
在视图中,我们将在其他控制按钮中添加一个“全屏”图标按钮。
mern-mediastream/client/media/MediaPlayer.js
:
<IconButton color="primary" onClick={this.onClickFullscreen}>
<Icon>fullscreen</Icon>
</IconButton>
当用户点击此按钮时,我们将使用screenfull
和findDOMNode
使视频播放器全屏。
mern-mediastream/client/media/MediaPlayer.js
:
onClickFullscreen = () => {
screenfull.request(findDOMNode(this.player))
}
然后用户可以在全屏模式下观看视频,可以随时按Esc退出全屏并返回到 PlayMedia 视图。
播放持续时间
在媒体播放器的自定义媒体控件部分,我们希望以可读的时间格式显示已经过去的时间和视频的总持续时间:
为了显示时间,我们可以利用 HTML 的time
元素。
mern-mediastream/client/media/MediaPlayer.js
:
<time dateTime={`P${Math.round(duration * played)}S`}>
{this.format(duration * played)}
</time> /
<time dateTime={`P${Math.round(duration)}S`}>
{this.format(duration)}
</time>
我们将通过使用onDuration
事件获取视频的duration
值,然后将其设置为状态,以便在时间元素中渲染。
mern-mediastream/client/media/MediaPlayer.js
:
onDuration = (duration) => {
this.setState({ duration })
}
为了使持续时间值可读,我们将使用以下的format
函数。
mern-mediastream/client/media/MediaPlayer.js
:
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
格式。
添加到自定义媒体播放器的控件大多基于ReactPlayer
模块中的一些可用功能,以及其提供的示例作为文档。还有更多选项可用于进一步定制和扩展,具体取决于特定的功能需求。
自动播放相关媒体
我们将通过在PlayMedia
中添加一个切换并实现handleAutoplay
方法来完成之前讨论的自动播放功能,当相关媒体列表中有媒体时,需要在MediaPlayer
组件中调用该方法。
切换自动播放
除了允许用户设置自动播放外,切换还将指示当前是否已设置自动播放:
对于自动播放切换,我们将使用Material-UI
的Switch
组件以及FormControlLabel
,并将其添加到PlayMedia
组件中,仅在相关媒体列表中有媒体时进行渲染。
mern-mediastream/client/media/PlayMedia.js
:
<FormControlLabel
control={
<Switch
checked={this.state.autoPlay}
onChange={this.handleChange}
color="primary"
/>
}
label={this.state.autoPlay? 'Autoplay ON':'Autoplay OFF'}
/>
处理切换并在状态的autoplay
值中反映这一变化,我们将使用以下的onChange
处理函数。
mern-mediastream/client/media/PlayMedia.js
:
handleChange = (event) => {
this.setState({ autoPlay: event.target.checked })
}
跨组件处理自动播放
PlayMedia
将handleAutoPlay
方法作为属性传递给Media
组件,以便在视频结束时由MediaPlayer
组件使用。
这里期望的功能是,当视频结束时,如果自动播放设置为 true 并且当前相关媒体列表不为空,则PlayMedia
应加载相关列表中第一个视频的媒体详情。反过来,Media
和MediaPlayer
组件应更新为新的媒体详情,开始播放新视频,并适当地渲染播放器上的控件。RelatedMedia
组件中的列表也应更新,从列表中移除当前媒体,因此只有剩余的播放列表项可见。
mern-mediastream/client/media/PlayMedia.js
:
handleAutoplay = (updateMediaControls) => {
let playList = this.state.relatedMedia
let playMedia = playList[0]
if(!this.state.autoPlay || playList.length == 0 )
return updateMediaControls()
if(playList.length > 1){
playList.shift()
this.setState({media: playMedia, relatedMedia:playList})
}else{
listRelated({
mediaId: playMedia._id}).then((data) => {
if (data.error) {
console.log(data.error)
} else {
this.setState({media: playMedia, relatedMedia: data})
}
})
}
}
handleAutoplay
方法在MediaPlayer
组件中视频结束时处理以下内容:
-
它从
MediaPlayer
组件的onEnded
事件监听器中获取回调函数。如果未设置自动播放或相关媒体列表为空,则将执行此回调,以便在MediaPlayer
上呈现视频已结束的控件。 -
如果设置了自动播放并且列表中有多个相关媒体,则:
-
将相关媒体列表中的第一项设置为状态中的当前媒体对象,以便进行渲染
-
通过删除将在视图中开始播放的第一个项目来更新相关媒体列表
-
如果设置了自动播放并且相关媒体列表中只有一个项目,则将此最后一个项目设置为媒体,以便开始播放,并调用
listRelated
获取方法来重新填充 RelatedMedia 视图与此最后一个项目的相关媒体。
在 MediaPlayer 中视频结束时更新状态
MediaPlayer
从PlayMedia
中接收handleAutoplay
方法作为属性。我们将更新onEnded
事件的监听器代码,仅当loop
设置为当前视频的false
时才执行此方法。
mern-mediastream/client/media/MediaPlayer.js
:
onEnded = () => {
if(this.state.loop){
this.setState({ playing: true})
}else{
this.props.handleAutoplay(() => {
this.setState({ ended: true,
playing: false })
})
}
}
回调函数被传递给handleAutoplay
方法,以便在PlayMedia
中确定自动播放未设置或相关媒体列表为空后,将播放设置为 false,并渲染重播图标按钮而不是播放或暂停图标按钮。
使用这种实现,自动播放功能将继续播放相关视频。这种实现演示了在值相互依赖时跨组件更新状态的另一种方式。
使用数据进行服务器端渲染
搜索引擎优化对于向用户提供内容并希望使内容易于查找的任何 Web 应用程序都很重要。通常,如果网页上的内容对搜索引擎易于阅读,那么该网页上的内容就有更多的机会获得更多的观众。当搜索引擎爬虫访问网址时,它将获取服务器端渲染的输出。因此,为了使内容可发现,内容应该是服务器端渲染输出的一部分。
在 MERN Mediastream 中,我们将使用使媒体详情在搜索引擎结果中受欢迎的案例,以演示如何在 MERN 应用程序中将数据注入到服务器端渲染的视图中。我们将专注于为在'/media/:mediaId'
路径返回的PlayMedia
组件实现服务器端渲染并注入数据。这里概述的一般步骤可以用于为其他视图实现带有数据的 SSR。
路由配置
为了在服务器上渲染 React 视图时加载数据,我们将使用 React Router Config npm 模块,该模块为 React Router 提供了静态路由配置助手:
npm install react-router-config --save
我们将创建一个路由配置文件,用于在服务器上匹配路由和传入的请求 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
对于这个路由和组件,我们将指定来自api-media.js
的read
获取方法作为加载数据的方法。然后它将用于在服务器生成标记时检索并注入数据到 PlayMedia 视图中。
更新 Express 服务器的 SSR 代码
我们将更新server/express.js
中现有的基本服务器端渲染代码,以添加用于在服务器端呈现的 React 视图的数据加载功能。
使用路由配置加载数据
我们将定义loadBranchData
来使用react-router-config
中的matchRoutes
,以及路由配置文件中定义的路由,以查找与传入请求 URL 匹配的路由。
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)
}
如果找到匹配的路由,则将执行任何相关的loadData
方法,以返回包含获取的数据的Promise
,或者如果没有loadData
方法,则返回null
。
在这里定义的loadBranchData
需要在服务器接收到请求时调用,因此如果找到任何匹配的路由,我们可以获取相关数据并在服务器端渲染时将其注入到 React 组件中。
同构抓取
我们还将在express.js
中导入同构抓取,以便可以在服务器上使用read
抓取方法,或者我们为客户端定义的任何其他抓取。
mern-mediastream/server/express.js
:
import 'isomorphic-fetch'
绝对 URL
使用同构抓取
的一个问题是它当前要求抓取 URL 是绝对的。因此,我们需要将在api-media.js
中定义的read
抓取方法中使用的 URL 更新为绝对 URL。
我们将在config.js
中设置一个config
变量,而不是在代码中硬编码服务器地址。
mern-mediastream/config/config.js
:
serverUrl: process.env.serverUrl || 'http://localhost:3000'
然后,我们将更新api-media.js
中的read
方法,使其使用绝对 URL 来调用服务器上的读取 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
抓取调用与同构抓取
兼容,因此在服务器上可以无问题地使用它。
将数据注入到 React 应用程序中
在后端现有的服务器端渲染代码中,我们使用ReactDOMServer
将 React 应用程序转换为标记。我们将在express.js
中更新此代码,以在使用loadBranchData
方法获取数据后将数据作为属性注入到MainRouter
中。
mern-mediastream/server/express.js
:
...
loadBranchData(req.url).then(data => {
const markup = ReactDOMServer.renderToString(
<StaticRouter location={req.url} context={context}>
<JssProvider registry={sheetsRegistry}
generateClassName={generateClassName}>
<MuiThemeProvider theme={theme} sheetsManager={new Map()}>
< MainRouter data={data}/>
</MuiThemeProvider>
</JssProvider>
</StaticRouter>
)
...
}).catch(err => {
res.status(500).send("Data could not load")
})
...
为了在服务器生成标记时将这些数据添加到渲染的PlayMedia
组件中,我们需要更新客户端代码以考虑服务器注入的数据。
在客户端代码中应用服务器注入的数据
在客户端,我们将访问从服务器传递的数据,并将其添加到 PlayMedia 视图中。
从 MainRouter 向 PlayMedia 传递数据属性
在使用ReactDOMServer.renderToString
生成标记时,我们将预加载的数据传递给MainRouter
作为属性。我们可以在MainRouter
的构造函数中访问该数据属性。
mern-mediastream/client/MainRouter.js
:
constructor({data}) {
super()
this.data = data
}
为了让PlayMedia
访问这些数据,我们将更改PlayMedia
的Route
组件,以将这些数据作为属性传递。
mern-mediastream/client/MainRouter.js
:
<Route path="/media/:mediaId"
render={(props) => (
<PlayMedia {...props} data={this.data} />
)} />
在 PlayMedia 中呈现接收到的数据
在PlayMedia
组件中,我们将检查从服务器传递的数据并将值设置为状态,以便在视图中呈现媒体详细信息。
mern-mediastream/client/media/PlayMedia.js
:
...
render() {
if (this.props.data && this.props.data[0] != null) {
this.state.media = this.props.data[0]
this.state.relatedMedia = []
}
...
}
这将生成带有媒体数据注入 PlayMedia 视图的服务器生成标记。
检查带有数据的 SSR 实现
对于 MERN Mediastream,任何呈现 PlayMedia 的链接现在应该在服务器端生成预加载媒体详情的标记。我们可以通过在关闭 JavaScript 的浏览器中打开应用程序 URL 来验证服务器端渲染数据的实现是否正常工作。我们将研究如何在 Chrome 浏览器中实现这一点,以及结果视图应该向用户和搜索引擎显示什么。
在 Chrome 中进行测试
在 Chrome 中测试这个实现只需要更新 Chrome 设置,并在禁用 JS 的标签中加载应用程序。
加载启用 JS 的页面
首先,在 Chrome 中打开应用程序,然后浏览到任何媒体链接,并让它以启用 JavaScript 的正常方式呈现。这应该显示已实现的 PlayMedia 视图,其中包括功能齐全的媒体播放器和相关的媒体列表。
从设置中禁用 JS
接下来,在 Chrome 上禁用 JavaScript。您可以转到chrome://settings/content/javascript
的高级设置,并使用切换按钮来阻止 JavaScript:
现在,刷新 MERN Mediastream 标签中的媒体链接,地址 URL 旁边将会显示一个图标,表明 JavaScript 确实已禁用:
带有 JS 阻止的 PlayMedia 视图
PlayMedia 视图应该呈现类似于以下图片,只有媒体详情被填充。但是由于 JavaScript 被阻止,用户界面不再具有交互性,只有默认的浏览器控件是可操作的。
这是搜索引擎机器人将读取的媒体内容,以及当浏览器未加载 JavaScript 时用户将看到的内容。
MERN Mediastream 现在拥有完全操作的媒体播放工具,这将允许用户轻松浏览和播放视频。此外,显示单个媒体内容的媒体视图现在经过了服务器端渲染预加载数据的优化,以便搜索引擎优化。
摘要
在本章中,我们通过使用ReactPlayer
提供的选项,完全升级了 MERN Mediastream 上的播放媒体页面,实现了自定义媒体播放器控件,使相关媒体从数据库中检索后,能够启用自动播放功能,并且在服务器渲染视图时,通过从服务器注入数据,使媒体详细信息对搜索引擎可读。
既然我们已经探索了 MERN 堆栈技术的高级功能,比如流媒体和 SEO,在接下来的章节中,我们将通过将虚拟现实元素融入到 Web 应用程序中,进一步测试这个堆栈的潜力。
第十章:开发基于 Web 的 VR 游戏
虚拟现实(VR)和增强现实(AR)技术的出现正在改变用户与软件以及周围世界的互动方式。VR 和 AR 的可能应用数不胜数,尽管游戏行业是早期采用者,但这些快速发展的技术有潜力在多个学科和行业中改变范式。
为了演示 MERN 堆栈与 React 360 如何轻松地为任何 Web 应用程序添加 VR 功能,我们将在本章和下一章中讨论和开发一个动态的基于 Web 的 VR 游戏。
通过涵盖以下主题,本章将重点定义 VR 游戏的特性,并使用 React 360 开发游戏视图:
-
VR 游戏规格
-
开发 3D VR 应用的关键概念
-
开始使用 React 360
-
定义游戏数据
-
实现游戏视图
-
将 React 360 代码捆绑以与 MERN 骨架集成
MERN VR 游戏
MERN VR 游戏 Web 应用程序将通过扩展 MERN 骨架并使用 React 360 集成 VR 功能来开发。这将是一个动态的、基于 Web 的 VR 游戏应用程序,注册用户可以制作自己的游戏,任何访问应用程序的访客都可以玩这些游戏。
游戏本身的特性将足够简单,以展示将 VR 引入基于 MERN 的应用程序的能力,而不深入探讨 React 360 的高级概念,这些概念可能用于实现更复杂的 VR 功能。
使用 React 360 实现 VR 游戏功能的代码可在 GitHub 上找到,网址为github.com/shamahoque/MERNVR。您可以克隆此代码,并在本章的其余部分中阅读代码解释时运行应用程序。
游戏特性
MERN VR 游戏中的每个游戏基本上都是一个不同的 VR 世界,用户可以在 360 度全景世界中的不同位置与 3D 对象进行交互。
游戏玩法将类似于寻宝游戏,为了完成每个游戏,用户必须找到并收集与每个游戏的线索或描述相关的 3D 对象。这意味着游戏世界将包含一些可以被玩家收集的 VR 对象,以及一些无法被收集的 VR 对象,但可能被游戏制作者放置为道具或提示。
本章重点
在这一章中,我们将使用 React 360 构建游戏功能,重点关注实现之前定义的功能的相关概念。一旦游戏功能准备就绪,我们将讨论如何捆绑和准备 React 360 代码,以便与第十一章中开发的 MERN 应用程序代码集成,使用 MERN 使 VR 游戏动态化。
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 文档中有详细说明,因此我们只会总结这些步骤,并指出与开发游戏相关的文件。
由于我们已经安装了用于 MERN 应用程序的 Node,我们可以开始安装 React 360 CLI 工具:
npm install -g react-360-cli
使用这个 React 360 CLI 工具创建一个新的应用程序并安装所需的依赖。
react-360 init MERNVR
这将在当前目录中的一个名为MERNVR
的文件夹中添加所有必要的文件。最后,我们可以在命令行中进入这个文件夹,并运行应用程序:
npm start
start
命令将初始化本地开发服务器,并且默认的 React 360 应用程序可以在浏览器中查看,网址为http://localhost:8081/index.html
。
为了更新起始应用程序并实现我们的游戏功能,我们将主要修改index.js
文件中的代码,并在MERNVR
项目文件夹中的client.js
文件中进行一些小的更新。
起始应用程序中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 环境中作为背景图像。
全景图像通常是投影到完全环绕观众的球体上的 360 度图像或球形全景图像。360 度全景图像的常见和流行格式是等距矩形格式。React 360 目前支持等距矩形图像的单眼和立体格式。
要了解有关 React 360 中 360 度图像和视频支持的更多信息,请参考 React 360 文档,网址为facebook.github.io/react-360/docs/setup.html。
这里显示的图像是一个等距矩形的 360 度全景图像的示例。为了在 MERN VR 游戏中设置游戏的世界背景,我们将使用这种类型的图像。
等距矩形全景图像由一个宽高比为 2:1 的单个图像组成,其中宽度是高度的两倍。这些图像是用特殊的 360 度相机创建的。等距矩形图像的一个很好的来源是 Flickr,您只需要搜索“等距矩形”标签。
通过在 React 360 环境中使用等距投影图像来设置背景场景,可以使 VR 体验更加沉浸,将用户带到虚拟位置。为了增强这种体验并有效地在这个 VR 世界中添加 3D 对象,我们需要更多地了解与 3D 空间相关的布局和坐标系统。
3D 位置 - 坐标和变换
我们需要了解 VR 世界空间中的位置和方向,以便将 3D 对象放置在所需位置,并使 VR 体验更加真实。
3D 坐标系统
为了在 3D 空间中进行映射,React 360 使用了类似于 OpenGL® 3D 坐标系统的三维米制坐标系统,允许单独的组件相对于其父组件的布局进行变换、移动或旋转。
React 360 中使用的 3D 坐标系统是右手坐标系。这意味着正 x 轴在右侧,正 y 轴向上,正 z 轴向后。这与世界空间中常见的坐标系统有更好的映射。
如果我们试图可视化 3D 空间,用户从下一张图中所示的X-Y-Z轴的中心开始。Z轴指向用户前方,用户朝着-Z轴方向观看。Y轴上下运行,而X轴从一侧到另一侧运行。
图像中的弯曲箭头显示了正旋转值的方向:
变换
在以下两个图像中,通过改变渲染 3D 对象的 React 360 Entity
组件的样式属性中的transform
属性,将 3D 书籍对象放置在两个不同的位置和方向。这里的变换是基于 React 的变换样式,React 360 将其扩展为完全的 3D,考虑 X-Y-Z 轴:
transform
属性被添加到style
属性中的组件中,形式如下的键和值数组:
style={{ ...
transform: [
{TRANSFORM_COMMAND: TRANSFORM_VALUE},
...
]
... }}
与我们游戏中放置的 3D 对象相关的变换命令和值是translate [x, y, z]
,单位为米,rotate [x, y, z]
,单位为度,以及scale
,用于确定对象在所有轴上的大小。我们还将利用矩阵命令,它接受一个由表示平移、旋转和缩放值的 16 个数字组成的数组作为值。
要了解更多关于 React 360 3D 坐标和变换的信息,请查看 React 360 文档facebook.github.io/react-360/docs/setup.html。
React 360 组件
React 360 提供了一系列组件,可以直接用来创建游戏的 VR 用户界面。接下来,我们将总结将用于构建游戏功能的特定组件。
核心组件
React 360 的核心组件包括 React Native 内置的组件:Text
和View
。在游戏中,我们将使用这两个组件来添加游戏世界中的内容。
视图
View
组件是在 React Native 中构建用户界面最基本的组件,它直接映射到 React Native 运行平台上的本地视图等效物。在我们的情况下,在浏览器上将是<div>
:
<View>
<Text>Hello</Text>
</View>
View
组件通常用作其他组件的容器,它可以嵌套在其他视图中,并且可以有零到多个任何类型的子元素。
我们将使用View
组件来容纳游戏世界的视图,并向游戏中添加 3D 对象实体和文本。
文本
Text
组件是一个用于显示文本的 React Native 组件,我们将使用它在 3D 空间中呈现字符串,通过将Text
组件放置在View
组件中:
<View>
<Text>Welcome to the MERN VR Game</Text>
</View>
用于 3D VR 体验的组件
React 360 提供了一组自己的组件来创建 VR 体验。具体来说,我们将使用Entity
组件来添加 3D 对象,使用VrButton
组件来捕获用户的点击。
实体
为了将 3D 对象添加到游戏世界中,我们将使用Entity
组件,它允许我们在 React 360 中渲染 3D 对象:
<Entity
source={{
obj: {uri: "http://linktoOBJfile.obj "},
mtl: {uri: "http://linktoMTLfile.obj "}
}}
/>
包含特定 3D 对象信息的文件被添加到Entity
组件中,使用source
属性。源属性接受一个键值对对象,将资源文件类型映射到它们的位置。React 360 支持 Wavefront OBJ 文件格式,这是一种常见的 3D 模型表示。因此,在源属性中,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/
,提供多种文件格式可供下载和使用。
VrButton
在 React 360 中,VrButton
组件将帮助实现简单的、按钮样式的onClick
行为,这些按钮将被添加到游戏中。VrButton
默认情况下在视图中不可见,只会作为一个包装器来捕获事件,但它可以以与View
组件相同的方式进行样式设置:
<VrButton onClick={this.clickHandler}>
<View>
<Text>Click me to make something happen!</Text>
</View>
</VrButton>
该组件是一个辅助工具,用于管理用户在不同输入设备上的点击类型交互。将触发点击事件的输入事件包括键盘上的空格键按下,鼠标上的左键单击以及屏幕上的触摸。
React 360 API
除了之前讨论的 React 360 组件,我们还将利用 React 360 提供的 API 来实现功能,比如设置背景场景、播放音频、处理外部链接、添加样式、捕捉用户视图的当前方向,以及使用静态资源文件。
环境
我们将使用Environment
API 来从 React 代码中改变背景场景,使用它的setBackgroundImage
方法:
Environment.setBackgroundImage( {uri: 'http://linktopanoramaimage.jpg' } )
这个方法使用指定 URL 的资源来设置当前背景图像。当我们将 React 360 游戏代码与包含游戏应用后端的 MERN 堆栈集成时,我们可以使用这个方法来动态设置游戏世界图像,使用用户提供的图像链接。
本地模块
React 360 中的本地模块提供了访问主浏览器环境中可用功能的能力。在游戏中,我们将使用本地模块中的AudioModule
来响应用户活动播放声音,以及Location
模块来处理浏览器中的window.location
以处理外部链接。这些模块可以在index.js
中如下访问:
import {
...
NativeModules
} from 'react-360'
const { AudioModule, Location } = NativeModules
AudioModule
当用户与 3D 对象交互时,我们将根据对象是否可以收集以及游戏是否已经完成来播放声音。本地模块中的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()
语句,或者是一个资源 URL 声明,形式为{uri: 'PATH'}
。
Location
在我们将 React 360 代码与包含游戏应用后端的 MERN 堆栈集成后,VR 游戏将从 MERN 服务器在包含特定游戏 ID 的声明路由上启动。然后,一旦用户完成游戏,他们还可以选择离开 VR 空间,转到包含其他游戏列表的 URL。为了处理 React 360 代码中的这些传入和传出应用链接,我们将利用本地模块中的Location
模块。
Location
模块本质上是浏览器中只读的window.location
属性返回的Location
对象。我们将使用Location
对象中的replace
方法和search
属性来实现与外部链接相关的功能。
- 处理传出链接:当我们想要将用户从 VR 应用程序引导到另一个链接时,我们可以在
Location
中使用replace
方法:
Location.replace(url)
- 处理传入链接:当 React 360 应用从外部 URL 启动并在注册的组件挂载后,我们可以访问 URL 并使用
Location
中的search
属性检索其查询字符串部分:
componentDidMount = () => {
let queryString = Location.search
let gameId = queryString.split('?id=')[1]
}
为了将这个 React 360 组件与 MERN VR 游戏集成,并动态加载游戏详情,我们将捕获这个初始 URL,从查询参数中解析游戏 ID,然后使用它来调用 MERN 应用服务器的读取 API。这个实现在第十一章中有详细说明,使用 MERN 使 VR 游戏动态化。
StyleSheet
React Native 中的 StyleSheet API 也可以在 React 360 中使用,以便在一个地方定义多个样式,而不是将样式添加到单个组件中:
const styles = StyleSheet.create({
subView: {
width: 10,
borderColor: '#d6d7da',
},
text: {
fontSize: '1em',
fontWeight: 'bold',
}
})
定义的样式可以根据需要添加到组件中:
<View style={styles.subView}>
<Text style={styles.text}>hello</Text>
</View>
在 React 360 中,用于 CSS 属性(如宽度和高度)的默认距离单位是米,而在 2D 界面中(如 React Native 中),默认距离单位是像素。
VrHeadModel
VrHeadModel
是 React 360 中的一个实用模块,它简化了获取头盔当前方向的操作。由于用户在 VR 空间中移动,当所需功能需要将对象或文本放置在用户当前方向的前面或相对于用户当前方向时,有必要知道用户当前凝视的确切位置。
在 MERN VR 游戏中,我们将使用它在用户的视野前显示游戏完成消息,无论他们从初始位置转向何处。
例如,用户可能在收集最终对象时向上或向下看,完成消息应该在用户注视的位置弹出。为了实现这一点,我们将使用VrHeadModel
中的getHeadMatrix()
将当前头部矩阵作为数字数组检索出来,并将其设置为包含游戏完成消息的View
的样式属性中的transform
属性的值。
资产
在 React 360 中,asset()
功能允许我们检索外部资源文件,如音频和图像文件。我们将把游戏的声音音频文件放在static_assets
文件夹中,以便使用asset()
检索每个添加到游戏中的音频:
AudioModule.playOneShot({
source: asset('collect.mp3'),
})
React 360 输入事件
为了使游戏界面具有交互性,我们将利用 React 360 中暴露的一些输入事件处理程序。输入事件来自鼠标、键盘、触摸和游戏手柄交互,还有 VR 头盔上的凝视
按钮点击。我们将处理的具体输入事件是onEnter
、onExit
和onClick
事件。
-
onEnter:每当平台光标开始与组件相交时,就会触发此事件。我们将捕获此事件用于游戏中的 VR 对象,这样当平台光标进入特定对象时,对象就可以开始围绕 Y 轴旋转。
-
onExit:每当平台光标停止与组件相交时,就会触发此事件。它具有与
onEnter
事件相同的属性,我们将使用它来停止旋转刚刚退出的 VR 对象。 -
onClick:
onClick
事件与VrButton
组件一起使用,当与VrButton
进行点击交互时触发。我们将使用它在 VR 对象上设置点击事件处理程序,还有在游戏完成消息上,以将用户重定向到包含游戏列表的链接,从而退出 VR 应用程序。
通过本节讨论的与 VR 相关的概念和 React 360 组件,我们已经准备好定义游戏数据细节并开始实现完整的 VR 游戏。
游戏详情
MERN VR 游戏中的每个游戏都将在一个通用数据结构中定义,当渲染各个游戏细节时,React 360 应用程序也将遵循这一结构。
游戏数据结构
游戏数据结构将保存游戏名称、指向游戏世界等距投影图像位置的 URL,以及包含每个 VR 对象的详细信息的两个数组:
-
name:表示游戏名称的字符串
-
world:一个字符串,其中包含指向等距投影图像的 URL,可以是存储在云存储、CDN 上的文件,或存储在 MongoDB 上的文件
-
answerObjects:包含可以被玩家收集的 VR 对象详细信息的对象数组
-
wrongObjects:包含其他 VR 对象详细信息的对象数组,这些对象将放置在 VR 世界中,玩家无法收集
VR 对象的详细信息
answerObjects
数组将包含可以被收集的 3D 对象的详细信息,wrongObjects
数组将包含无法被收集的 3D 对象的详细信息。每个对象将包含到 3D 数据资源文件和transform
样式属性值的链接。
OBJ 和 MTL 链接
VR 对象的 3D 数据信息资源将添加在objUrl
和mtlUrl
键中:
-
objUrl:3D 对象的
.obj
文件的链接 -
mtlUrl:附带的
.mtl
文件的链接
objUrl
和mtlUrl
链接可能指向存储在云存储、CDN 上的文件,或存储在 MongoDB 上的文件。对于 MERN VR 游戏,我们将假设制作者将向他们自己托管的 OBJ、MTL 和等距投影图像文件添加 URL。
平移数值
VR 对象在 3D 空间中的位置将由以下键中的translate
值定义:
-
translateX:对象沿 X 轴的平移值
-
translateY:对象沿 Y 轴的平移值
-
translateZ:对象沿 Z 轴的平移值
所有平移数值都是以米为单位的数字。
旋转数值
3D 对象的方向将由以下键中的rotate
值定义:
-
rotateX:绕 X 轴的旋转值,换句话说,将对象向上或向下旋转
-
rotateY:绕 Y 轴的旋转值,将对象向左或向右旋转
-
rotateZ:绕 Z 轴的旋转值,使对象向前或向后倾斜
所有旋转数值都以度数的数字或字符串表示。
比例值
scale
值将定义 3D 对象的相对大小外观:
scale:定义所有轴上的均匀比例的数字值
颜色
如果 3D 对象的材质纹理没有在 MTL 文件中提供,颜色值可以定义对象的默认颜色。
color:表示 CSS 中允许的颜色值的字符串值
有了这个游戏数据结构,能够保存游戏及其 VR 对象的详细信息,我们可以相应地使用示例数据值在 React 360 中实现游戏。
静态数据与动态数据
在下一章中,我们将更新 React 360 代码,以动态从后端数据库获取游戏数据。目前,我们将从定义的游戏数据结构中设置虚拟游戏数据到state
中开始在这里开发游戏功能。
示例数据
为了初始开发目的,以下示例游戏数据可以设置为状态以在游戏视图中呈现:
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'
}
]
}
在 React 360 中构建游戏视图
我们将应用 React 360 的概念,并使用游戏数据结构来通过更新index.js
和client.js
中的代码来实现游戏功能。为了获得一个可工作的版本,我们将从上一节中使用示例游戏数据初始化状态开始。
/MERNVR/index.js
:
export default class MERNVR extends React.Component {
constructor() {
super()
this.state = {
game: sampleGameData
...
}
}
...
}
更新 client.js 并挂载到 Location
client.js
中的默认代码将在 React 360 应用中将在index.js
中声明的挂载点附加到默认表面上,其中表面是用于放置 2D UI 的圆柱形图层。为了在 3D 空间中使用基于 3D 米的坐标系进行布局,我们需要挂载到Location
而不是表面。因此,更新client.js
以用renderToLocation
替换renderToSurface
。
/MERNVR/client.js
:
r360.renderToLocation(
r360.createRoot('MERNVR', { /* initial props */ }),
r360.getDefaultLocation()
)
您还可以通过更新client.js
中的代码r360.compositor.setBackground(**r360.getAssetURL('360_world.jpg')**)
来使用您想要的图像来自定义初始背景场景。
使用 StyleSheet 定义样式
在index.js
中,我们将使用我们自己的 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'
}
})
世界背景
为了设置游戏的 360 度世界背景,我们将使用componentDidMount
中的Environment
API 的setBackgroundImage
方法来更新当前背景场景。
/MERNVR/index.js
:
componentDidMount = () => {
Environment.setBackgroundImage(
{uri: this.state.game.world}
)
}
这将用我们从云存储中获取的示例游戏世界图像替换起始 React 360 项目中的默认 360 背景。如果您正在编辑默认的 React 360 应用程序并且它正在运行,刷新浏览器上的http://localhost:8081/index.html
链接应该显示一个外太空背景,可以使用鼠标在周围移动:
为了生成上述截图,还更新了默认代码中的View
和Text
组件,使用自定义 CSS 规则在屏幕上显示这个 hello 文本。
添加 3D VR 对象
我们将使用Entity
组件和answerObjects
和wrongObjects
数组中的示例对象详细信息向游戏世界添加 3D 对象。
首先,我们将在componentDidMount
中连接answerObjects
和wrongObjects
数组,形成一个包含所有 VR 对象的单个数组。
/MERNVR/index.js
:
componentDidMount = () => {
let vrObjects = this.state.game.answerObjects.concat(this.state.game.wrongObjects)
this.setState({vrObjects: vrObjects})
...
}
然后在主视图中,我们将遍历vrObjects
数组,添加每个对象的Entity
组件详细信息。
/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
文件链接被添加到source
中,并且transform
样式细节在Entity
组件的样式中应用setModelStyles(vrObject, index)
。
/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 事件处理程序,如onEnter
和onExit
与Entity
,以及VrButton
的onClick
,添加旋转动画和游戏行为。
旋转
我们希望添加一个功能,当玩家关注 3D 对象时,即平台光标开始与渲染特定 3D 对象的Entity
相交时,开始围绕其 Y 轴旋转 3D 对象。
我们将更新上一节中的Entity
组件,添加onEnter
和onExit
处理程序。
/MERNVR/index.js
:
<Entity
...
onEnter={this.rotate(i)}
onExit={this.stopRotate}
/>
当进入对象时,对象将开始旋转,并且当平台光标退出对象并且不再处于玩家的焦点时,对象将停止旋转。
使用 requestAnimationFrame 进行动画
在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
方法作为递归回调函数,然后执行它以重新绘制旋转动画的每一帧,并依次更新屏幕上的动画。
requestAnimateFrame
方法返回一个requestID
,我们将在stopRotate
中使用它来取消stopRotate
方法中的动画。
/MERNVR/index.js
:
stopRotate = () => {
if (this.requestID) {
cancelAnimationFrame(this.requestID)
this.requestID = null
}
}
这将实现仅当 3D 对象处于观看者焦点时才对其进行动画处理。如下图所示,3D 魔方在焦点时沿其 Y 轴顺时针旋转:
虽然这里没有涉及,但值得探索的是 React 360 动画库,它可以用于组合不同类型的动画。核心组件可以使用此库本地进行动画处理,并且可以使用
createAnimatedComponent()
使其他组件可动画化。该库最初是从 React Native 实现的,要了解更多信息,可以参考 React Native 文档。
点击 3D 对象
为了在游戏中注册对添加到游戏中的每个 3D 对象的点击行为,我们需要用VrButton
组件包装Entity
组件,以便调用onClick
处理程序。
我们将更新vrObjects
数组迭代代码中添加的Entity
组件,以用VrButton
组件包装它。当单击时,VrButton
将调用collectItem
方法,并传递当前对象的详细信息。
/MERNVR/index.js
:
<VrButton onClick={this.collectItem(vrObject)} key={i}>
<Entity … />
</VrButton>
当单击 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 ...
}
}
接下来,我们将看一下这些步骤的实现。
点击收集正确的对象
当用户点击 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
数组中相应索引的布尔值进行设置,同时使用setModelStyles
方法设置Entity
组件的样式,就像在添加 3D VR 对象部分中所示的那样:
display: this.state.collectedList[index] ? 'none' : 'flex'
在下图中,宝箱可以被点击收集,因为它是一个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'),
})
}
}
在收集了一个点击的对象之后,我们还将检查是否已收集了所有的answerObjects
,并且游戏是否已经完成了checkGameCompleteStatus
方法,如下一节所讨论的那样。
游戏完成状态
每次收集一个answerObject
时,我们将检查收集的物品总数是否等于answerObjects
数组中的物品总数,以确定是否通过调用checkGameCompleteStatus
完成游戏。
/MERNVR/index.js
:
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()”方法将为带有更新的display
值和transform
矩阵值的消息View
设置样式。
/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
包装在一个VrButton
组件中,当点击时调用exitGame
方法。
/MERNVR/index.js
:
exitGame = () => {
Location.replace('/')
}
exitGame
方法将使用Location.replace
方法将用户重定向到可能包含游戏列表的外部 URL。
replace
方法可以传递任何有效的 URL,一旦这个 React 360 游戏代码与第十一章中的 MERN VR 游戏应用集成,replace('/')
将把用户带到应用程序的主页。
生产捆绑和与 MERN 集成
现在我们已经实现了 VR 游戏的功能,并且使用了示例游戏数据,我们可以为生产做准备,并将其添加到我们的 MERN 基础应用程序中,以查看 VR 如何添加到现有的 Web 应用程序中。
React 360 工具提供了一个脚本,将所有 React 360 应用程序代码捆绑成几个文件,我们可以将其放在 MERN web 服务器上,并在指定路由上作为内容提供。
捆绑 React 360 文件
要创建捆绑文件,我们可以从 React 360 项目目录中运行以下命令:
npm run 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 应用程序集成
我们需要将这三个文件和static_assets
文件夹添加到我们的 MERN 应用程序中,然后确保index.html
中的捆绑文件引用准确,并最终在 Express 应用程序中的指定路由加载index.html
。
添加 React 360 生产文件
考虑到 MERN 骨架应用程序中的文件夹结构,我们将把static_assets
文件夹和捆绑文件添加到dist/
文件夹中,以保持我们的 MERN 代码有序,并将所有捆绑文件放在同一个位置。index.html
文件将放在server
文件夹中的一个名为vr
的新文件夹中:
-- ...
-- client/
-- dist/
--- static_assets/
--- ...
--- client.bundle.js
--- index.bundle.js
-- ...
-- server/
--- ...
--- vr/
---- index.html
-- ...
在 index.html 中更新引用
生成的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="./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
的引用如下:
<script src="/dist/client.bundle.js" 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
将告诉 React 360 在使用asset()
设置组件资源时从哪里查找资源文件。
现在,如果我们在 MERN 应用程序中设置一个 Express 路由来返回响应中的index.html
文件,那么在浏览器中访问该路由将呈现 React 360 游戏。
尝试集成
要测试这个集成,我们可以设置一个示例路由,如下所示:
router.route('/game/play')
.get((req, res) => {
res.sendFile(process.cwd()+'/server/vr/index.html')
})
然后运行 MERN 服务器,并在浏览器中打开localhost:3000/game/play
路由。这应该在我们的 MERN 应用程序中呈现本章中实现的 React 360 游戏。
总结
在本章中,我们使用 React 360 开发了一个基于 Web 的 VR 游戏,可以轻松集成到 MERN 应用程序中。
我们首先为游戏定义了简单的 VR 功能,然后设置了 React 360 进行开发,并研究了 360 度 VR 世界中的关键 VR 概念,如等距全景图像、3D 位置和坐标系统。我们探索了 React 360 组件和 API,以实现游戏功能,包括诸如View
、Text
、Entity
和VrButton
等组件,以及Environment
、VrHeadModel
和NativeModules
API。
最后,我们更新了起始的 React 360 项目中的代码,以使用示例游戏数据实现游戏,然后捆绑了代码文件,并讨论了如何将这些编译后的文件添加到现有的 MERN 应用程序中。
在下一章中,我们将开发 MERN VR 游戏应用程序,包括游戏数据库和 API,以便我们可以通过从 MongoDB 中的游戏集合中获取数据,使本章开发的游戏动态化。
第十一章:使用 MERN 使 VR 游戏动态化
在本章中,我们将扩展 MERN 骨架应用程序,构建 MERN VR 游戏应用程序,并使用它来使上一章中开发的静态 React 360 游戏动态化,通过直接从 MERN 服务器获取游戏细节来替换示例游戏数据。
为了使 MERN VR 游戏成为一个完整和动态的游戏应用程序,我们将实现以下内容:
-
在 MongoDB 中存储游戏细节的游戏模型模式
-
游戏 CRUD 操作的 API
-
用于游戏创建、编辑、列表和删除的 React 视图
-
更新 React 360 游戏以从 API 获取数据
-
加载具有动态游戏数据的 VR 游戏
动态 MERN VR 游戏
在 MERN VR 游戏上注册的用户将能够通过提供游戏世界的等距图像和 VR 对象资源(包括要放置在游戏世界中的每个对象的变换属性值)来制作和修改自己的游戏。任何访问应用程序的访客都可以浏览制作者添加的所有游戏,并玩任何游戏以找到并收集与每个游戏的线索或描述相关的游戏世界中的 3D 对象:
完整的 MERN VR 游戏应用程序的代码可在 GitHub 上找到:github.com/shamahoque/mern-vrgame。您可以克隆此代码,并在阅读本章其余部分的代码解释时运行该应用程序。
为了实现与创建、编辑和列出 VR 游戏相关的功能所需的视图,我们将通过扩展和修改 MERN 骨架应用程序中的现有 React 组件来开发。下图显示了构成本章中开发的 MERN VR 游戏前端的所有自定义 React 组件的组件树:
游戏模型
在第十章中,《开发基于 Web 的 VR 游戏》,游戏数据结构部分详细介绍了为了实现游戏中定义的寻宝功能所需的每个游戏的细节。我们将根据游戏的具体细节、其 VR 对象以及游戏制作者的参考设计游戏模式。
游戏模式
在game.model.js
中为游戏模型定义的 Mongoose 模式中,我们将添加以下字段:
-
游戏的名称
-
世界图像 URL
-
线索文本
-
包含要添加为可收集答案对象的 VR 对象详细信息的数组
-
包含无效对象详细信息的数组,无法收集
-
指示游戏创建和更新的时间戳
-
制作游戏的用户的引用
GameSchema
将定义如下。
mern-vrgame/server/models/game.model.js
:
const GameSchema = new mongoose.Schema({
name: {
type: String,
trim: true,
required: 'Name is required'
},
world: {
type: String, trim: true,
required: 'World image is required'
},
clue: {
type: String,
trim: true
},
answerObjects: [VRObjectSchema],
wrongObjects: [VRObjectSchema],
updated: Date,
created: {
type: Date,
default: Date.now
},
maker: {type: mongoose.Schema.ObjectId, ref: 'User'}
})
VRObject 模式
游戏模式中的answerObjects
和wrongObjects
字段都将是 VRObject 文档的数组,VRObject Mongoose 模式将单独定义,包括存储 OBJ 文件和 MTL 文件的 URL 字段,以及每个 VR 对象的 React 360 transform
值,scale
值和color
值。
mern-vrgame/server/models/game.model.js
:
const VRObjectSchema = new mongoose.Schema({
objUrl: {
type: String, trim: true,
required: 'ObJ file is required'
},
mtlUrl: {
type: String, trim: true,
required: 'MTL file is required'
},
translateX: {type: Number, default: 0},
translateY: {type: Number, default: 0},
translateZ: {type: Number, default: 0},
rotateX: {type: Number, default: 0},
rotateY: {type: Number, default: 0},
rotateZ: {type: Number, default: 0},
scale: {type: Number, default: 1},
color: {type: String, default: 'white'}
})
当新的游戏文档保存到数据库时,answerObjects
和wrongObjects
数组将填充符合此模式定义的 VRObject 文档。
游戏模式中的数组长度验证
在保存到游戏集合中时,游戏文档中的answerObjects
和wrongObjects
数组必须至少包含一个 VRObject 文档。为了为游戏模式添加最小数组长度的验证,我们将在GameSchema
中的answerObjects
和wrongObjects
路径中添加以下自定义验证检查。
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)
GameSchema.path('wrongObjects').validate(function(v) {
if (v.length == 0) {
this.invalidate('wrongObjects',
'Must add alteast one other VR object')
}
}, null)
这些模式定义将满足根据 MERN VR 游戏规范开发动态 VR 游戏的所有要求。
游戏 API
MERN VR 游戏中的后端将公开一组 CRUD API,用于在前端应用程序中使用,包括在 React 360 游戏实现中使用 fetch 调用创建、编辑、读取、列出和删除游戏。
创建 API
已登录应用程序的用户将能够使用create
API 在数据库中创建新游戏。
路由
在后端,我们将在game.routes.js
中添加一个POST
路由,验证当前用户是否已登录并获得授权,然后使用请求中传递的游戏数据创建新游戏。
mern-vrgame/server/routes/game.routes.js
:
router.route('/api/games/by/:userId')
.post(authCtrl.requireSignin,authCtrl.hasAuthorization, gameCtrl.create)
为了处理:userId
参数并从数据库中检索相关联的用户,我们将利用用户控制器中的userByID
方法。我们还将在游戏路由中添加以下内容,以便用户在request
对象中作为profile
可用。
mern-vrgame/server/routes/game.routes.js
:
router.param('userId', userCtrl.userByID)
game.routes.js
文件将与user.routes
文件非常相似,并且为了在 Express 应用程序中加载这些新路由,我们需要在express.js
中挂载游戏路由,就像我们为 auth 和 user 路由所做的那样。
mern-vrgame/server/express.js
:
app.use('/', gameRoutes)
控制器
当收到'/api/games/by/:userId'
的 POST 请求并且请求体包含新游戏数据时,将执行create
控制器方法。
mern-vrgame/server/controllers/game.controller.js
:
const create = (req, res, next) => {
const game = new Game(req.body)
game.maker= req.profile
game.save((err, result) => {
if(err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
res.status(200).json(result)
})
}
在这个create
方法中,使用游戏模式创建一个新的游戏文档,并使用从客户端传递的请求体中的数据。然后在将用户引用设置为游戏制造商后,将此文档保存在Game
集合中。
获取
在前端,我们将在api-game.js
中添加相应的fetch
方法,通过传递从已登录用户收集的表单数据来向create
API 发起POST
请求。
mern-vrgame/client/game/api-game.js
:
const create = (params, credentials, game) => {
return fetch('/api/games/by/'+ params.userId, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
},
body: JSON.stringify(game)
})
.then((response) => {
return response.json();
}).catch((err) => console.log(err))
}
列表 API
可以通过使用列表 API 从后端获取Game
集合中所有游戏的列表。
路由
我们将在游戏路由中添加一个 GET 路由,以检索存储在数据库中的所有游戏。
mern-vrgame/server/routes/game.routes.js
:
router.route('/api/games')
.get(gameCtrl.list)
对/api/games
的GET
请求将执行list
控制器方法。
控制器
list
控制器方法将查询数据库中的Game
集合,以返回响应给客户端的所有游戏。
mern-vrgame/server/controllers/game.controller.js
:
const list = (req, res) => {
Game.find({}).populate('maker', '_id name')
.sort('-created').exec((err, games) => {
if(err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
res.json(games)
})
}
获取
在前端,为了使用这个列表 API 获取游戏,我们将在api-game.js
中设置一个fetch
方法。
mern-vrgame/client/game/api-game.js
:
const list = () => {
return fetch('/api/games', {
method: 'GET',
}).then(response => {
return response.json()
}).catch((err) => console.log(err))
}
按制造商列表 API
该应用程序还将允许我们使用制造商列表 API 获取特定用户制作的游戏。
路由
在游戏路由中,我们将添加一个GET
路由,以检索特定用户制作的游戏。
mern-vrgame/server/routes/game.routes.js
:
router.route('/api/games/by/:userId')
.get(gameCtrl.listByMaker)
对这个路由的GET
请求将执行游戏控制器中的listByMaker
方法。
控制器
listByMaker
控制器方法将查询数据库中的 Game 集合,以获取匹配的游戏。
mern-vrgame/server/controllers/game.controller.js
:
const listByMaker = (req, res) => {
Game.find({maker: req.profile._id}, (err, games) => {
if(err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
res.json(games)
}).populate('maker', '_id name')
}
在对 Game 集合的查询中,我们找到所有制造商字段与req.profile
中指定的用户匹配的游戏。
获取
在前端,为了使用制造商列表 API 获取特定用户的游戏,我们将在api-game.js
中添加一个fetch
方法。
mern-vrgame/client/game/api-game.js
:
const listByMaker = (params) => {
return fetch('/api/games/by/'+params.userId, {
method: 'GET',
headers: {
'Accept': 'application/json'
}
}).then((response) => {
return response.json()
}).catch((err) => {
console.log(err)
})
}
读取 API
将使用'/api/game/:gameId'
的read
API 从数据库中检索单个游戏数据。
路由
在后端,我们将添加一个GET
路由,查询带有 ID 的Game
集合,并在响应中返回游戏。
mern-vrgame/server/routes/game.routes.js
:
router.route('/api/game/:gameId')
.get(gameCtrl.read)
路由 URL 中的:gameId
参数将首先被处理,以从数据库中检索单个游戏。因此,我们还将在游戏路由中添加以下内容:
router.param('gameId', gameCtrl.gameByID)
控制器
对 read API 的请求中的:gameId
参数将调用gameByID
控制器方法,该方法类似于userByID
控制器方法。它将从数据库中检索游戏并将其附加到request
对象中,以在next
方法中使用。
mern-vrgame/server/controllers/game.controller.js
:
const gameByID = (req, res, next, id) => {
Game.findById(id).populate('maker', '_id name').exec((err, game) => {
if (err || !game)
return res.status('400').json({
error: "Game not found"
})
req.game = game
next()
})
}
在这种情况下,next
方法,即read
控制器方法,简单地将这个game
对象返回给客户端的响应。
mern-vrgame/server/controllers/game.controller.js
:
const read = (req, res) => {
return res.json(req.game)
}
获取
在前端代码中,我们将添加一个fetch
方法来利用这个 read API 根据其 ID 检索单个游戏的详细信息。
mern-vrgame/client/game/api-game.js
:
const read = (params, credentials) => {
return fetch('/api/game/' + params.gameId, {
method: 'GET'
}).then((response) => {
return response.json()
}).catch((err) => console.log(err))
}
这个read
API 将用于 React 视图获取游戏详情,还将用于 React 360 游戏视图,该视图将呈现游戏界面。
编辑 API
已登录并且也是特定游戏的制作者的授权用户将能够使用edit
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()
}
游戏控制器中的update
方法将获取现有的游戏详情和请求体中接收到的表单数据,合并更改,并将更新后的游戏保存到数据库中的 Game 集合中。
mern-vrgame/server/controllers/game.controller.js
:
const update = (req, res) => {
let game = req.game
game = _.extend(game, req.body)
game.updated = Date.now()
game.save((err) => {
if(err) {
return res.status(400).send({
error: errorHandler.getErrorMessage(err)
})
}
res.json(game)
})
}
获取
在视图中调用edit
API 时,使用fetch
方法获取表单数据,并将其与用户凭据一起发送到后端。
mern-vrgame/client/game/api-game.js
:
const update = (params, credentials, game) => {
return fetch('/api/games/' + params.gameId, {
method: 'PUT',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
},
body: JSON.stringify(game)
}).then((response) => {
return response.json()
}).catch((err) => {
console.log(err)
})
}
删除 API
经过身份验证和授权的用户将能够使用delete
游戏 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
控制器方法将从数据库中删除指定的游戏。
mern-vrgame/server/controllers/game.controller.js
:
const remove = (req, res) => {
let game = req.game
game.remove((err, deletedGame) => {
if(err) {
return res.status(400).json({
error: errorHandler.getErrorMessage(err)
})
}
res.json(deletedGame)
})
}
获取
我们将在api-game.js
中添加相应的remove
方法,以便向删除 API 发出delete
请求。
mern-vrgame/client/game/api-game.js
:
const remove = (params, credentials) => {
return fetch('/api/games/' + params.gameId, {
method: 'DELETE',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + credentials.t
}
}).then((response) => {
return response.json()
}).catch((err) => {
console.log(err)
})
}
有了这些游戏 API,我们可以构建应用程序的 React 视图,并更新 React 360 游戏视图代码以获取和呈现动态游戏详情。
创建和编辑游戏
在 MERN VR Game 上注册的用户将能够在应用程序内制作新游戏并修改这些游戏。我们将添加 React 组件,允许用户修改每个游戏的游戏详情和 VR 对象详情。
创建新游戏
当用户登录应用程序时,他们将在菜单中看到一个 MAKE GAME 链接,该链接将引导他们到包含创建新游戏表单的NewGame
组件。
更新菜单
我们将更新导航菜单,添加 MAKE GAME 按钮,如下截图所示:
在Menu
组件中,我们将在用户经过身份验证时渲染的部分中,在 MY PROFILE 链接之前,为NewGame
组件的路由添加Link
。
mern-vrgame/client/core/Menu.js
:
<Link to="/game/new">
<Button style={isActive(history, "/game/new")}>
<AddBoxIcon color="secondary"/> Make Game
</Button>
</Link>
NewGame 组件
NewGame
组件使用GameForm
组件来渲染用户将填写以创建新游戏的表单元素:
GameForm
包含所有表单字段,并从NewGame
组件中获取onSubmit
方法(用户提交表单时应执行的方法),以及任何服务器返回的错误消息。
mern-vrgame/client/game/NewGame.js
:
<GameForm onSubmit={this.clickSubmit} errorMsg={this.state.error}/>
clickSubmit
方法使用api-game.js
中的创建fetch
方法,向create
API 发出 POST 请求,携带游戏表单数据和用户详情。
mern-vrgame/client/game/NewGame.js
:
clickSubmit = game => event => {
const jwt = auth.isAuthenticated()
create({
userId: jwt.user._id
}, {
t: jwt.token
}, game).then((data) => {
if (data.error) {
this.setState({error: data.error})
} else {
this.setState({error: '', redirect: true})
}
})
}
我们将在MainRouter
中添加一个PrivateRoute
,以便NewGame
组件在浏览器中加载,路径为/game/new
。
mern-vrgame/client/MainRouter.js
:
<PrivateRoute path="/game/new" component={NewGame}/>
编辑游戏
用户将能够使用EditGame
组件编辑他们创建的游戏,该组件将呈现预填充现有游戏详情的游戏表单字段。
EditGame 组件
就像在NewGame
组件中一样,EditGame
组件也将使用GameForm
组件来呈现表单元素,但这次字段将显示游戏字段的当前值,并且用户将能够更新这些值:
在EditGame
组件的情况下,GameForm
将接受给定游戏的 ID 作为属性,以便获取游戏详情,以及onSubmit
方法和服务器生成的错误消息(如果有)。
mern-vrgame/client/game/EditGame.js
:
<GameForm gameId={this.match.params.gameId} onSubmit={this.clickSubmit} errorMsg={this.state.error}/>
编辑表单的clickSubmit
方法将使用api-game.js
中的update
获取方法,向编辑 API 发出 PUT 请求,携带表单数据和用户详情。
mern-vrgame/client/game/EditGame.js
:
clickSubmit = game => event => {
const jwt = auth.isAuthenticated()
update({
gameId: this.match.params.gameId
}, {
t: jwt.token
}, game).then((data) => {
if (data.error) {
this.setState({error: data.error})
} else {
this.setState({error: '', redirect: true})
}
})
}
EditGame
组件将在浏览器中加载,路径为/game/edit/:gameId
,在MainRouter
中声明为PrivateRoute
。
mern-vrgame/client/MainRouter.js
:
<PrivateRoute path="/game/edit/:gameId" component={EditGame}/>
GameForm 组件
GameForm
组件在NewGame
和EditGame
组件中都使用,包含允许用户输入游戏详情和单个游戏的 VR 对象详情的元素。它可以从空白游戏对象开始,或在componentDidMount
中加载现有游戏。
mern-vrgame/client/game/GameForm.js
:
state = {
game: {name: '', clue:'', world:'', answerObjects:[], wrongObjects:[]},
redirect: false,
readError: ''
}
如果GameForm
组件从父组件(如EditGame
组件)接收到gameId
属性,则它将使用读取 API 来检索游戏的详情并将其设置为状态以在表单视图中呈现。
mern-vrgame/client/game/GameForm.js
:
componentDidMount = () => {
if(this.props.gameId){
read({gameId: this.props.gameId}).then((data) => {
if (data.error) {
this.setState({readError: data.error})
} else {
this.setState({game: data})
}
})
}
}
GameForm
组件中的表单视图基本上分为两部分,一部分是接受简单的游戏细节,比如名称、世界图片链接和线索文本作为输入,另一部分允许用户向答案对象数组或错误对象数组中添加可变数量的 VR 对象。
输入简单的游戏细节
简单的游戏细节部分将主要是使用 Material-UI 的TextField
组件添加的文本输入,并传递给onChange
的更改处理方法。
表单标题
表单标题将是“新游戏”或“编辑游戏”,具体取决于是否将现有游戏 ID 作为 prop 传递给GameForm
。
mern-vrgame/client/game/GameForm.js
:
<Typography type="headline" component="h2">
{this.props.gameId? 'Edit': 'New'} Game
</Typography>
游戏世界图片
我们将在顶部的img
元素中渲染背景图片 URL,以显示用户添加的游戏世界图片 URL。
mern-vrgame/client/game/GameForm.js
:
<img src={this.state.game.world}/>
<TextField id="world" label="Game World Equirectangular Image (URL)"
value={this.state.game.world} onChange={this.handleChange('world')}/>
游戏名称
游戏名称将添加在一个默认类型为text
的单个TextField
中。
mern-vrgame/client/game/GameForm.js
:
<TextField id="name" label="Name" value={this.state.game.name} onChange={this.handleChange('name')}/>
线索文本
线索文本将添加到多行TextField
组件中。
mern-vrgame/client/game/GameForm.js
:
<TextField id="multiline-flexible" label="Clue Text" multiline rows="2" value={this.state.game.clue} onChange={this.handleChange('clue')}/>
处理输入
所有输入更改将由handleChange
方法处理,该方法将使用用户输入更新状态中的游戏数值。
mern-vrgame/client/game/GameForm.js
:
handleChange = name => event => {
const newGame = this.state.game
newGame[name] = event.target.value
this.setState({game: newGame})
}
修改 VR 对象数组
为了允许用户修改他们希望添加到他们的 VR 游戏中的answerObjects
和wrongObjects
数组,GameForm
将遍历每个数组,并为每个对象渲染一个VRObjectForm
组件。通过这样做,将可以从GameForm
组件中添加、删除和修改 VR 对象:
迭代和渲染对象细节表单
使用 Material-UI 的ExpansionPanel
组件,我们将添加先前看到的表单界面,以为给定游戏中的每种类型的 VR 对象数组创建一个可修改的数组。
在ExpansionPanelDetails
组件中,我们将迭代answerObjects
数组或wrongObjects
数组,为每个 VR 对象渲染一个VRObjectForm
组件。
mern-vrgame/client/game/GameForm.js
:
<ExpansionPanel>
<ExpansionPanelSummary expandIcon={<ExpandMoreIcon/>}>
<Typography>VR Objects to collect</Typography>
</ExpansionPanelSummary>
<ExpansionPanelDetails>{
this.state.game.answerObjects.map((item, i) => {
return <div key={i}>
<VRObjectForm index={i} type={'answerObjects'}
vrObject={item}
handleUpdate={this.handleObjectChange}
removeObject={this.removeObject}/>
</div> })}
<Button color="primary" variant="raised" onClick={this.addObject('answerObjects')}>
<AddBoxIcon color="secondary"/> Add Object
</Button>
</ExpansionPanelDetails>
</ExpansionPanel>
每个VRObjectForm
将作为 props 接受vrObject
本身,数组中的当前index
,对象数组的类型,以及在VRObjectForm
组件内部通过更改细节或删除对象时更新GameForm
状态的两种方法。
向数组中添加一个新对象
添加对象的按钮将允许用户添加一个新的VRObjectForm
组件,以获取新的 VR 对象的细节。
mern-vrgame/client/game/GameForm.js
:
addObject = name => event => {
const newGame = this.state.game
newGame[name].push({})
this.setState({game: newGame})
}
这基本上只会向正在迭代的数组中添加一个空对象,并使用名称值中指定的数组类型调用addObject
方法。
从数组中移除对象
每个VRObjectForm
组件也可以被删除,以从给定数组中移除对象。GameForm
将会传递一个removeObject
方法给VRObjectForm
组件作为属性,这样当用户点击特定VRObjectForm
上的delete
时,数组就可以在状态中更新。
mern-vrgame/client/game/GameForm.js
:
removeObject = (type, index) => event => {
const newGame = this.state.game
newGame[type].splice(index, 1)
this.setState({game: newGame})
}
对象将通过在指定名称的数组中的给定index
处进行切片来从数组中移除。
处理对象细节变化
当用户更改任何VRObjectForm
字段中的输入值时,VR
对象的细节将在GameForm
组件状态中更新。为了注册这个更新,GameForm
将handleObjectChange
方法传递给VRObjectForm
组件。
mern-vrgame/client/game/GameForm.js
:
handleObjectChange = (index, type, name, val) => {
var newGame = this.state.game
newGame[type][index][name] = val
this.setState({game: newGame})
}
handleObjectChange
方法会更新数组中特定对象的字段值,使用给定的type
和index
,因此它会在GameForm
中存储的游戏对象状态中反映出来。
VRObjectForm 组件
VRObjectForm
组件将渲染输入字段,以修改单个 VR 对象的细节,该对象被添加到GameForm
组件中的answerObjects
和wrongObjects
数组中:
它可以从一个空的 VR 对象开始,或者在componentDidMount
中加载现有的 VR 对象的细节。
mern-vrgame/client/game/VRObjectForm.js
:
state = {
objUrl: '', mtlUrl: '',
translateX: 0, translateY: 0, translateZ: 0,
rotateX: 0, rotateY: 0, rotateZ: 0,
scale: 1, color:'white'
}
在componentDidMount
中,状态将被设置为从GameForm
组件传递的vrObject
的细节。
mern-vrgame/client/game/VRObjectForm.js
:
componentDidMount = () => {
if(this.props.vrObject &&
Object.keys(this.props.vrObject).length != 0){
const vrObject = this.props.vrObject
this.setState({
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
})
}
}
使用 Material-UI 的TextField
组件来添加修改这些值的输入字段。
3D 对象文件输入
OBJ 和 MTL 文件链接将作为文本输入添加到每个 VR 对象中,使用TextField
组件。
mern-vrgame/client/game/VRObjectForm.js
:
<TextField
id="obj"
label=".obj url"
value={this.state.objUrl}
onChange={this.handleChange('objUrl')}
/><br/>
<TextField
id="mtl"
label=".mtl url"
value={this.state.mtlUrl}
onChange={this.handleChange('mtlUrl')}
/>
翻译值输入
VR 对象在 X、Y 和 Z 轴上的翻译值将在number
类型的TextField
组件中输入。
mern-vrgame/client/game/VRObjectForm.js
:
<TextField
value={this.state.translateX}
label="TranslateX"
onChange={this.handleChange('translateX')}
type="number"
/>
<TextField
value={this.state.translateY}
label="TranslateY"
onChange={this.handleChange( 'translateY')}
type="number"
/>
<TextField
value={this.state.translateZ}
label="TranslateZ"
onChange={this.handleChange('translateZ')}
type="number"
/>
旋转值输入
VR 对象围绕 X、Y 和 Z 轴的“旋转”值将在“数字”类型的 TextField 组件中输入。
mern-vrgame/client/game/VRObjectForm.js
:
<TextField
value={this.state.rotateX}
label="RotateX"
onChange={this.handleChange('rotateX')}
type="number"
/>
<TextField
value={this.state.rotateY}
label="RotateY"
onChange={this.handleChange('rotateY')}
type="number"
/>
<TextField
value={this.state.rotateZ}
label="RotateZ"
onChange={this.handleChange('rotateZ')}
type="number"
/>
比例值输入
VR 对象的“比例”值将在“数字”类型的 TextField 组件中输入。
mern-vrgame/client/game/VRObjectForm.js
:
<TextField
value={this.state.scale}
label="Scale"
onChange={this.handleChange('scale')}
type="number"
/>
对象颜色输入
VR 对象的颜色值将在“文本”类型的 TextField 组件中输入:
mern-vrgame/client/game/VRObjectForm.js
:
<TextField
value={this.state.color}
label="Color"
onChange={this.handleChange('color')}
/>
删除对象按钮
VRObjectForm
将包含一个“删除”按钮,该按钮将执行从GameForm
props 表单中接收到的removeObject
方法:
mern-vrgame/client/game/VRObjectForm.js
:
<Button onClick={this.props.removeObject(this.props.type, this.props.index)}>
<Icon>cancel</Icon> Delete
</Button>
removeObject
方法将获取对象数组类型的值和要删除的数组索引位置,从GameForm
状态中删除相关 VR 对象数组中的给定对象。
处理输入更改
当输入字段中的任何 VR 对象细节发生更改时,handleChange
方法将更新VRObjectForm
组件的状态,并使用从GameForm
传递的handleUpdate
方法来更新GameForm
状态中的 VR 对象的更改值。
mern-vrgame/client/game/VRObjectForm.js
:
handleChange = name => event => {
this.setState({[name]: event.target.value})
this.props.handleUpdate(this.props.index,
this.props.type,
name,
event.target.value)
}
通过这种实现,创建和编辑游戏表单已经就位,包括用于不同大小数组的 VR 对象输入表单。任何注册用户都可以使用这些表单在 MERN VR 游戏应用程序上添加和编辑游戏。
游戏列表视图
访问 MERN VR 游戏的访问者将从主页和个人用户资料页面上呈现的列表中访问应用程序中的游戏。主页将列出应用程序中的所有游戏,特定制作者的游戏将列在其用户资料页面上。列表视图将通过使用“列表”API 获取的游戏数据进行迭代,并在GameDetail
组件中呈现每个游戏的详细信息。
所有游戏
Home
组件将在组件挂载时使用列表 API 获取游戏集合中所有游戏的列表。
mern-vrgame/client/core/Home.js
:
componentDidMount = () => {
list().then((data) => {
if (data.error) {
console.log(data.error)
} else {
this.setState({games: data})
}
})
}
从服务器检索到的游戏列表将设置为状态,并进行迭代以呈现每个列表中的GameDetail
组件。
mern-vrgame/client/core/Home.js
:
{this.state.games.map((game, i) => {
return <GameDetail key={i} game={game} updateGames={this.updateGames}/>
})}
GameDetail
组件将传递游戏详情和updateGames
方法。
mern-vrgame/client/core/Home.js
:
updateGames = (game) => {
const updatedGames = this.state.games
const index = updatedGames.indexOf(game)
updatedGames.splice(index, 1)
this.setState({games: updatedGames})
}
updateGames
方法将在用户从GameDetail
组件中删除他们的游戏时更新Home
组件中的列表,该组件呈现了游戏制作者的edit
和delete
选项:
制作者的游戏
用户Profile
组件将使用制作者 API 获取给定用户制作的游戏列表。我们将更新Profile
组件中的init
方法,在检索到用户详细信息后调用listByMaker
获取方法。
mern-vrgame/client/user/Profile.js
:
init = (userId) => {
const jwt = auth.isAuthenticated()
read({
userId: userId
}, {t: jwt.token}).then((data) => {
if (data.error) {
this.setState({redirectToSignin: true})
} else {
this.setState({user: data})
listByMaker({userId: data._id}).then((data) => {
if (data.error) {
console.log(data.error)
} else {
this.setState({games: data})
}
})
}
})
}
类似于在Home
组件中呈现游戏列表的方式,我们将在Profile
组件中将从服务器检索到的游戏列表设置为状态,并在视图中对其进行迭代以呈现GameDetail
组件,该组件将传递个别游戏详情和updateGames
方法。
mern-vrgame/client/user/Profile.js
:
{this.state.games.map((game, i) => {
return <GameDetail key={i} game={game} updateGames={this.updateGames}/>
})}
这将为特定用户制作的每个游戏呈现一个GameDetail
组件:
游戏详情组件
GameDetail
组件以游戏对象作为属性,并呈现游戏的详细信息,以及一个链接到 VR 游戏视图的 PLAY GAME 按钮。如果当前用户是游戏制作者,则还会显示edit
和delete
按钮:
游戏详情
游戏详情,如名称、世界图片、线索文本和制作者名称,都会被呈现,以便用户对游戏有一个概览。
mern-vrgame/client/game/GameDetail.js
:
<Typography type="headline" component="h2">
{this.props.game.name}
</Typography>
<CardMedia image={this.props.game.world}
title={this.props.game.name}/>
<Typography type="subheading" component="h4">
<em>by</em>
{this.props.game.maker.name}
</Typography>
<CardContent>
<Typography type="body1" component="p">
{this.props.game.clue}
</Typography>
</CardContent>
Play Game 按钮
GameDetail
组件中的Play Game
按钮将简单地是一个指向打开 React 360 生成的index.html
路由的Link
组件(服务器上此路由的实现在玩 VR 游戏部分讨论)。
mern-vrgame/client/game/GameDetail.js
:
<Link to={"/game/play?id=" + this.props.game._id} target='_self'>
<Button variant="raised" color="secondary">
Play Game
</Button>
</Link>
游戏视图的路由将以游戏 ID 作为query
参数。我们在Link
上设置target='_self'
,这样 React Router 就会跳过转换到下一个状态,让浏览器处理这个链接。这样做的效果是允许浏览器直接在此路由发出请求,并在响应此请求时渲染服务器发送的index.html
文件。
编辑和删除按钮
GameDetail
组件只会在当前登录用户也是正在呈现的游戏的制作者时显示edit
和delete
选项。
mern-vrgame/client/game/GameDetail.js
:
{auth.isAuthenticated().user
&& auth.isAuthenticated().user._id == this.props.game.maker._id &&
(<div>
<Link to={"/game/edit/" + this.props.game._id}>
<Button variant="raised" color="primary"
className={classes.editbutton}>
Edit
</Button>
</Link>
<DeleteGame game={this.props.game}
removeGame={this.props.updateGames}/>
</div>)}
如果已登录用户的用户 ID 与游戏中的制作者 ID 匹配,则在视图中显示链接到编辑表单视图的edit
按钮和DeleteGame
组件。
删除游戏
已登录用户将能够通过点击GameDetail
组件中制作者可见的delete
按钮来删除他们制作的特定游戏。GameDetail
组件使用DeleteGame
组件添加了这个delete
选项。
DeleteGame 组件
DeleteGame
组件添加到每个游戏的GameDetail
组件中,从GameDetail
中获取游戏详情和removeGame
方法作为 props,该方法更新了GameDetail
所属的父组件。
mern-vrgame/client/game/GameDetail.js
:
<DeleteGame game={this.props.game} removeGame={this.props.updateGames}/>
这个DeleteGame
组件基本上是一个按钮,当点击时,会打开一个确认对话框,询问用户是否确定要删除他们的游戏:
对话框是使用 Material-UI 的Dialog
组件实现的。
mern-vrgame/client/game/DeleteGame.js
:
<Button variant="raised" onClick={this.clickButton}>
Delete
</Button>
<Dialog open={this.state.open} onClose={this.handleRequestClose}>
<DialogTitle>{"Delete "+this.props.game.name}</DialogTitle>
<DialogContent>
<DialogContentText>
Confirm to delete your game {this.props.game.name}.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={this.handleRequestClose} color="primary">
Cancel
</Button>
<Button onClick={this.deleteGame} color="secondary"
autoFocus="autoFocus">
Confirm
</Button>
</DialogActions>
</Dialog>
成功删除后,对话框将关闭,并通过调用作为 prop 传递的removeGame
方法更新包含GameDetail
组件的父组件。
mern-vrgame/client/game/DeleteGame.js
:
deleteGame = () => {
const jwt = auth.isAuthenticated()
remove({
gameId: this.props.game._id
}, {t: jwt.token}).then((data) => {
if (data.error) {
console.log(data.error)
} else {
this.props.removeGame(this.props.game)
this.setState({open: false})
}
})
}
在这个deleteGame
处理程序方法中调用的removeGame
方法更新了父组件的状态,这可能是Home
组件或用户Profile
组件,因此已删除的游戏不再显示在视图中。
玩 VR 游戏
MERN VR 游戏上的用户将能够在应用程序内打开和玩任何游戏。为了实现这一点,我们将在服务器上设置一个路由,以在以下路径对 GET 请求的响应中呈现使用 React 360 生成的index.html
:
/game/play?id=<game ID>
路径以query
参数作为游戏 ID 值,该值在 React 360 代码中用于使用读取 API 获取游戏详情。
API 来渲染 VR 游戏视图
打开 React 360 index.html
页面的 GET 请求将在game.routes.js
中声明,如下所示。
mern-vrgame/server/routes/game.routes.js
:
router.route('/game/play')
.get(gameCtrl.playGame)
这将执行playGame
控制器方法,以响应传入请求返回index.html
页面。
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 中的游戏代码
在 MERN 应用程序中设置了游戏后端之后,我们可以更新在第十章中开发的 React 360 项目代码,使其直接从数据库中的游戏集合中呈现游戏。
我们将在打开 React 360 应用程序的链接中使用游戏 ID,以在 React 360 代码内部使用读取 API 获取游戏详情,然后将数据设置为状态,以便游戏加载从数据库中检索的详情,而不是我们在第十章中使用的静态示例数据,开发基于 Web 的 VR 游戏。
代码更新后,我们可以再次对其进行打包,并将编译后的文件放在 MERN 应用程序中。
从链接中获取游戏 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
查询参数中获取游戏 ID 值。我们需要这个游戏 ID 值来使用读取 API 从服务器获取游戏详情,并将其设置为游戏和vrObjects
值的状态。
使用读取 API 获取游戏数据
在 React 360 项目文件夹中,我们将添加一个api-game.js
文件,其中包含一个读取fetch
方法,用于使用提供的游戏 ID 调用服务器上的读取游戏 API。
/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 方法在 React 360 入口组件的componentDidMount
中用于检索游戏详情。
这个更新的 React 360 代码可以在 GitHub 仓库的名为'dynamic-game'的分支中找到:github.com/shamahoque/MERNVR。
打包和集成更新的代码
将 React 360 代码更新为从服务器动态获取和呈现游戏详情后,我们可以使用提供的打包脚本对此代码进行打包,并将新编译的文件放在 MERN VR 游戏项目目录的dist
文件夹中。
要从命令行打包 React 360 代码,请转到 React 360 MERNVR
项目文件夹并运行:
npm run bundle
这将在build/
文件夹中生成client.bundle.js
和index.bundle.js
捆绑文件,其中包含更新的 React 360 代码。这些文件以及index.html
和static_assets
文件夹需要根据第十章中讨论的内容添加到 MERN VR 游戏应用程序代码中,开发基于 Web 的 VR 游戏,以集成最新的 VR 游戏代码。
完成了这个集成后,如果我们运行 MERN VR 游戏应用程序,并在任何游戏的“播放游戏”链接上点击,它应该会打开游戏视图,并显示特定游戏的详细信息,允许按照游戏玩法规定与 VR 对象进行交互。
摘要
在本章中,我们将 MERN 堆栈技术的功能与 React 360 集成,开发了一个用于 Web 的动态 VR 游戏应用程序。
我们扩展了 MERN 骨架应用程序,以构建一个工作的后端,用于存储 VR 游戏的详细信息,并允许我们进行 API 调用来操作这些详细信息。我们添加了 React 视图,让用户可以修改游戏并浏览游戏,还可以选择在特定路由上启动和玩 VR 游戏,由服务器直接呈现。
最后,我们更新了 React 360 项目代码,通过从传入 URL 检索查询参数,并使用 fetch 来检索游戏 API 的数据,在 MERN 应用程序和 VR 游戏视图之间传递数据。
React 360 代码与 MERN 堆栈应用程序的集成产生了一个完全功能且动态的基于 Web 的 VR 游戏应用程序,展示了 MERN 堆栈技术如何被使用和扩展以创建独特的用户体验。
在下一章中,我们将反思本书中构建的 MERN 应用程序,讨论不仅是遵循的最佳实践,还有改进和进一步发展的空间。
第十二章:遵循最佳实践并进一步开发 MERN
在本章中,我们详细阐述了在构建本书中的四个 MERN 应用程序时应用的一些最佳实践,以及本书中未应用的其他实践,但应考虑用于真实世界的应用程序,以确保可靠性和可扩展性随着复杂性的增长。最后,我们提出了增强和扩展构建的应用程序的建议和步骤。
本章涵盖的主题包括以下内容:
-
应用程序结构中的模块化关注点分离
-
考虑 CSS 样式解决方案的选项
-
为选定视图提供数据的服务器端渲染
-
使用 ES6 类来区分有状态和纯函数组件
-
决定使用 Redux 还是 Flux
-
用于存储用户凭据的安全增强功能
-
编写测试代码
-
优化捆绑大小
-
如何向现有应用程序添加新功能
模块化的关注点分离
在构建 MERN 堆栈应用程序时,我们遵循了每个应用程序中的常见文件夹结构,根据相关性和常见功能划分和分组了代码。在创建代码中的这些较小和独特的部分的背后思想是确保每个部分都解决了一个单独的问题,因此可以重复使用每个部分,以及独立开发和更新。
重新审视应用程序文件夹结构
更具体地说,在应用程序文件夹结构中,我们将客户端和服务器端代码分开,并在这两个部分内进一步划分。这使我们可以自由设计和独立构建应用程序的前端和后端:
| mern_application/
| -- client/
| -- server/
在client
和server
部分,我们进一步将代码划分为子文件夹,这些子文件夹映射到唯一的功能,例如在服务器端的模型、控制器和路由,以及在客户端将所有与用户相关的组件分组。
服务器端代码
在服务器端,我们根据功能将代码分开,通过将定义业务模型的代码与实现路由逻辑的代码以及在这些路由上响应客户端请求的代码分开:
| -- server/
| --- controllers/
| --- models/
| --- routes/
在这种结构中,每个文件夹都包含具有特定目的的代码:
-
模型:该文件夹旨在包含所有 Mongoose 模式模型定义的单独文件,每个文件代表一个单独的模型。
-
routes:此文件夹包含允许客户端与服务器交互的所有路由 - 放在单独的文件中,其中每个文件可能与模型文件夹中的模型相关联。
-
controllers:包含定义逻辑以响应定义路由的传入请求的所有控制器函数,分成相应的模型和路由文件。
正如本书中所示,服务器端代码的这些特定关注点的分离使我们能够通过添加所需的模型、路由和控制器文件来扩展骨架应用程序开发的服务器。
客户端代码
MERN 应用程序的客户端代码主要由 React 组件组成。为了以合理和可理解的方式组织组件代码和相关的辅助代码,我们将代码分成与功能实体或独特功能相关的文件夹:
| -- client/
| --- auth/
| --- core/
| --- post/
| --- user/
| --- componentFolderN/
在前述结构中,我们将所有与认证相关的组件和辅助代码放在auth
文件夹中,将常见和基本组件,如Home
和Menu
组件,放在core
文件夹中,然后我们为所有与帖子或用户相关的组件在相应的文件夹中创建post
和user
文件夹。
这种基于功能的组件的分离和分组使我们能够通过根据需要向客户端文件夹添加新的与功能相关的组件代码文件夹来扩展骨架应用程序的前端视图。
在本章的最后一节中,我们进一步展示了这种模块化方法分离应用程序代码的优势,同时概述了可以采用的一般工作流程,以向本书中开发的任何现有应用程序添加新功能。
添加 CSS 样式
在讨论本书中应用程序的用户界面实现时,我们选择不专注于应用的 CSS 样式代码的细节,并主要依赖于默认的 Material-UI 样式。但是,考虑到实现任何用户界面都需要考虑样式解决方案,我们将简要介绍一些可用的选项。
在添加 CSS 样式到前端时,有许多选项,每种都有其优缺点。在本节中,我们将讨论两种最常见的选项,即外部样式表和内联样式,以及在 JavaScript 中编写 CSS 的较新方法,或者更具体地说是 JSS,它用于 Material-UI 组件,因此也用于本书中的应用程序。
外部样式表
外部样式表允许我们在单独的文件中定义 CSS 规则,然后将其注入到必要的视图中。以这种方式在外部样式表中放置 CSS 样式曾经被认为是更好的做法,因为它强制执行了样式和内容的分离,允许重用,并且如果为每个组件创建单独的 CSS 文件,则还可以保持模块化。
然而,随着 Web 开发技术的不断发展,对更好的 CSS 组织和性能的需求不再适用于这种方法。例如,在使用 React 组件开发前端视图时,使用外部样式表会限制根据组件状态更新样式的控制。此外,为 React 应用程序加载外部 CSS 需要额外的 Webpack 配置,包括css-loader
和style-loader
。
当应用程序增长并共享多个样式表时,也变得不可能避免选择器冲突,因为 CSS 具有单一的全局命名空间。因此,尽管外部样式表可能足够简单和琐碎的应用程序,但随着应用程序的增长,使用 CSS 的其他选项变得更加相关。
内联样式
内联 CSS 是直接定义和应用于视图中的单个元素的样式。虽然这解决了外部样式表面临的一些问题,比如消除选择器冲突的问题和允许状态相关样式,但它剥夺了可重用性,并引入了一些自己的问题,比如限制可以应用的 CSS 特性。
对于基于 React 的前端,仅使用内联 CSS 存在重要的限制,例如性能不佳,因为所有内联样式在每次渲染时都会重新计算,并且内联样式本身比类名慢。
内联 CSS 在某些情况下可能看起来是一个简单的解决方案,但并不适合作为整体使用的好选择。
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。
具有数据的选择性服务器端渲染
在我们在第四章中开发了基本骨架应用程序的前端时,添加 React 前端以完成 MERN,我们集成了基本的服务器端渲染,以便在请求发送到服务器时能够直接从浏览器地址栏加载客户端路由。在这种 SSR 实现中,当在服务器端渲染 React 组件时,我们没有考虑为显示数据的组件加载来自数据库的数据。只有在服务器端渲染标记的初始加载后,客户端 JavaScript 接管后,这些组件才会加载数据。
我们确实更新了这个实现,以在 MERN Mediastream 应用程序的个别媒体详细页面中添加带有数据的服务器端渲染,该应用程序在第九章中讨论了自定义媒体播放器和改善 SEO。在这种情况下,我们决定通过将数据注入到 React 前端的服务器端生成标记中来呈现此特定视图。对于特定视图的选择性服务器端渲染与数据的推理可以基于对所讨论视图的某些期望行为。
什么时候使用带有数据的 SSR 是相关的?
在应用程序中为所有 React 视图实现具有数据的服务器端渲染可能会变得复杂,并且在需要考虑具有客户端身份验证或由多个数据源组成的视图时会增加额外的工作。在许多情况下,如果视图不需要服务器端渲染数据,则可能不必解决这些复杂性。为了判断视图是否需要使用数据进行服务器端渲染,请回答以下问题以做出决定:
-
当浏览器中可能无法使用 JavaScript 时,数据在视图的初始加载中显示是否很重要?
-
视图及其数据是否需要对 SEO 友好?
在页面的初始加载中加载数据可能与可用性相关,因此这实际上取决于特定视图的用例。对于 SEO,使用数据进行服务器端渲染将使搜索引擎更容易访问视图中的数据内容,因此如果这对于所讨论的视图至关重要,则添加服务器端渲染数据是一个好主意。
使用 ES6 类来定义有状态与纯功能组件
在使用 React 组件构建 UI 时,使用更多无状态功能组件来组合视图可以使前端代码更易管理、清晰,并且更易于测试。但是,一些组件将需要状态或生命周期钩子来超出纯呈现组件。在本节中,我们将看看构建有状态和无状态功能 React 组件需要什么,何时使用其中一个,以及多久使用一次。
使用 ES6 类的 React 组件
使用 ES6 类定义的 React 组件可以访问生命周期方法、this
关键字,并且在构建有状态组件时可以使用setState
来管理状态。有状态组件允许我们构建交互式组件,可以管理状态中的变化数据,并传播需要应用于 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 的组件组成时,设计根组件或父组件作为一个有状态的组件,它将包含子组件或只接收 props 且不能操作状态的可组合组件。所有使用 setState
进行状态更改的操作和生命周期问题将由根组件或父组件处理。
在本书开发的应用程序中,有一些有状态的高级组件和较小的无状态组件的混合。例如,在 MERN Social 应用程序中,Profile
组件修改了无状态子组件的状态,比如 FollowProfileButton
和 FollowGrid
组件。在将应用程序扩展到包含更多功能之前,应考虑将本书中开发的一些较大组件重构为更小、更自包含的组件。
可以应用于新组件设计或重构现有组件的主要要点是,随着 React 应用程序的增长和复杂性的增加,最好在负责管理其内部组件状态的高级有状态组件中添加更多的无状态函数组件。
使用 Redux 或 Flux
当 React 应用程序开始增长并变得更加复杂时,管理组件之间的通信可能会变得棘手。在使用常规 React 时,通信的方式是将值和回调函数作为 props 传递给子组件。但是,如果有许多中间组件必须经过回调传递,这可能会很繁琐。随着 React 应用程序的增长,人们会转向使用 Redux 和 Flux 等库和架构模式来解决这些与状态通信和管理相关的问题。
本书不涉及将 React 与 Redux 库或 Flux 架构集成的细节,但读者可以在考虑这些选项时牢记他们不断增长的 MERN 应用程序。
-
Redux 和 Flux 利用模式来强制从中心位置改变 React 应用程序的状态。在可管理大小的 React 应用程序中避免使用 Redux 或 Flux 的一个技巧是将所有状态更改移到父组件的组件树上。
-
较小的应用程序同样可以在没有 Flux 或 Redux 的情况下运行良好。
您可以在redux.js.org/
了解有关在 React 中使用 Redux 的更多信息,并在facebook.github.io/flux/了解有关 Flux 的信息。
增强安全性
在为本书开发的 MERN 应用程序中,我们通过使用 JSON Web Tokens 作为身份验证机制,并在用户集合中存储哈希密码,简化了与身份验证相关的安全实现。在本节中,我们将介绍这些选择,并指出可能的增强功能。
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())) + ''
}
使用这种实现方式,每当用户输入密码进行登录时,就会使用盐生成一个哈希值。如果生成的哈希值与存储的哈希值匹配,那么密码就是正确的,否则密码就是错误的。因此,为了检查密码是否正确,需要盐,因此它与用户详细信息一起存储在数据库中。
这是为了保护用户身份验证存储的密码的标准做法,但如果特定应用程序的安全要求需要,还可以探索其他高级方法。一些可以考虑的选项包括多次迭代的哈希方法、其他安全的哈希算法、限制每个用户帐户的登录尝试次数,以及具有额外步骤的多级身份验证,例如回答安全问题或输入安全代码。
编写测试代码
虽然讨论和编写测试代码不在本书的范围之内,但对于开发可靠的软件来说是至关重要的。在本节中,首先我们将看一下用于测试 MERN 应用程序不同部分的测试工具。然后,为了帮助开始编写本书中开发的 MERN 应用程序的测试代码,我们还将讨论一个真实的例子,即向 MERN Social 应用程序添加客户端测试,该应用程序来自第五章 从一个简单的社交媒体应用程序开始。
使用 Jest 进行测试
Jest 是一个全面的 JavaScript 测试框架。虽然它更常用于测试 React 组件,但它也可以用于任何 JavaScript 库或框架的通用测试。在 Jest 的许多 JavaScript 测试解决方案中,它提供了对模拟和快照测试的支持,配备了一个断言库,并且 Jest 中的测试是以行为驱动开发(BDD)风格编写的。除了测试 React 组件外,Jest 也可以根据需要适应编写 Node-Express-Mongoose 后端的测试代码。因此,它是一个可靠的测试选项,可以为 MERN 应用程序添加测试代码。
要了解更多关于 Jest 的信息,请阅读facebook.github.io/jest/docs/en/getting-started.html
上的文档。
向 MERN Social 应用程序添加测试
使用 Jest,我们将向 MERN Social 应用程序添加一个客户端测试,并演示如何开始向 MERN 应用程序添加测试。
在编写测试代码之前,首先我们将通过安装必要的软件包、定义测试运行脚本和创建一个tests
文件夹来设置测试。
安装软件包
为了编写测试代码并运行测试,将需要以下 npm 软件包:
-
jest:包括 Jest 测试框架
-
babel-jest:用于为 Jest 编译 JS 代码
-
react-test-renderer:用于创建 React DOM 渲染的 DOM 树的快照,而无需使用浏览器
要将这些软件包安装为devDependencies
,请从命令行运行以下npm install
命令:
npm install --save-dev jest babel-jest react-test-renderer
定义脚本以运行测试
为了运行测试代码,我们将更新package.json
中定义的运行脚本,以添加一个使用jest
运行测试的脚本:
"scripts": {
"test": "jest"
}
在命令行中,如果我们运行npm run test
,它将提示 Jest 在应用程序文件夹中查找测试代码并运行测试。
添加一个 tests 文件夹
要在 MERN Social 应用程序中添加客户端测试,我们将在客户端文件夹中创建一个名为tests
的文件夹,其中将包含与测试 React 组件相关的测试文件。当运行测试命令时,Jest 将在这些文件中查找测试代码。
此示例的测试用例将是对Post
组件的测试,并且Post
组件的测试将添加到tests
文件夹中的名为post.test.js
的文件中。
测试用例
我们将编写一个测试,以检查帖子上的“删除”按钮是否只在登录用户也是帖子创建者时可见。这意味着只有在认证用户的 user._id
与正在渲染的帖子数据的 postedby
值相同时,“删除”按钮才会成为帖子视图的一部分。
添加测试
为了实现这个测试用例,我们将添加代码来处理以下事项:
-
为帖子和
auth
对象定义虚拟数据 -
模拟
auth-helper.js
-
定义测试,并在测试定义内部
-
声明
post
和auth
变量 -
将模拟的
isAuthenticated
方法的返回值设置为虚拟的auth
对象 -
使用
renderer.create
创建带有必需虚拟 props 的Post
组件,并包装在MemoryRouter
中以提供与react-router
相关的 props -
生成并匹配快照
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 视图的快照
第一次运行此测试时,我们将为其提供生成正确的帖子视图快照所需的值。此测试用例的正确快照将在 user._id
的 auth 对象等于帖子对象的 postedBy
值时包含删除按钮。首次运行测试时生成的快照将用于将来的测试执行进行比较。
Jest 中的快照测试基本上记录了渲染组件结构的快照,以便将来进行比较。当记录的快照与当前渲染不匹配时,测试将失败,表示发生了变化。
运行并检查测试
在我们刚刚添加到 post.test.js
中的代码中,虚拟的 auth
对象和 post
对象引用同一用户,因此在命令行中运行此测试将提示 Jest 生成一个包含删除选项的快照,并且测试也会通过。
要运行测试,请从命令行进入项目文件夹:
npm run test
测试输出将显示测试通过:
当此测试首次成功运行时生成的记录快照将自动添加到 tests
文件夹中的 _snapshots_
文件夹中。此快照代表了在视图中呈现删除按钮的状态,因为认证用户也是帖子的创建者。
现在我们可以检查一下,当组件与一个不是帖子创建者的已认证用户一起渲染时,测试是否实际失败。为了进行这个检查,我们将通过将user._id
更改为不匹配postedBy
的值来更新虚拟数据对象,然后再次运行测试。这将导致测试失败,因为当前的渲染将不再有一个在记录的快照中存在的删除按钮。
如下图所示的测试日志中,测试失败并指示渲染树与记录的快照不匹配,因为接收到的值中缺少代表删除按钮的元素:
通过这个屏幕截图,我们可以对客户端进行测试,以检查已登录用户是否可以在他们的帖子上看到删除
按钮。使用这个设置,可以为利用 Jest 的 MERN 应用程序添加更多的测试。
编写测试代码将使您开发的应用程序更可靠,还有助于确保代码质量。另一个提高和维护代码质量的好方法是在项目中使用一个 linting 工具。Linting 工具对代码进行静态分析,以查找违反指定规则和准则的问题模式或行为。在 JavaScript 项目中进行 linting 可以提高整体代码的可读性,还可以帮助在代码执行之前找到语法错误。对于基于 MERN 的项目,您可以探索 ESLint,它是一个 JavaScript linting 实用程序,允许开发人员创建自己的 linting 规则。
优化捆绑大小
随着您开发和扩展 MERN 应用程序,使用 Webpack 生成的捆绑包的大小也会增长,特别是如果使用了大型第三方库。更大的捆绑包大小会影响性能,并增加应用程序的初始加载时间。我们可以在代码中进行更改,以确保我们不会得到大型捆绑包,并且还可以利用 Webpack 4 中打包的功能来帮助优化捆绑。在本节中,我们将重点介绍一些关键概念,这些概念可以让我们控制生成更小的捆绑包,并减少加载时间。
在进入代码以更新其捆绑大小优化之前,您还可以熟悉现在作为 Webpack 4 一部分的默认优化选项。在 MERN 应用程序中,我们使用mode
配置来利用开发模式和生产模式的默认设置。要了解可用选项的概述,请查看此文章medium.com/webpack/webpack-4-mode-and-optimization-5423a6bc597a
。
代码拆分
我们可以使用 Webpack 支持的代码拆分功能,而不是一次性加载所有代码在一个捆绑包中,以懒加载用户当前需要的应用程序代码的部分。在我们修改应用程序代码以引入代码拆分之后,Webpack 可以创建多个捆绑包而不是一个大捆绑包。这些捆绑包可以在运行时动态加载,从而可以改善应用程序的性能。
要了解 Webpack 中的代码拆分支持以及如何对设置和配置进行必要更改,请查看文档中的指南webpack.js.org/guides/code-splitting/
。
引入应用程序代码的代码拆分有几种方法,但为此目的遇到的最重要的语法是动态import()
。在下一节中,我们将看看如何在我们的 MERN 应用程序中使用import()
。
动态导入()
动态import()
是常规导入的新函数版本,它使 JS 模块的动态加载成为可能。使用import(moduleSpecifier)
将返回所请求模块的模块命名空间对象的 promise。在使用常规静态导入时,我们在代码顶部导入模块,然后在代码中使用它:
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 配对——这是一个用于通过 promise 加载组件的高阶组件。例如,我们将看一下在第七章中开发的购物车,扩展订单和支付的市场。在构建购物车的界面时,我们通过导入和添加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/
上的文章。
扩展应用程序
在本书的各章中,当我们开发每个应用程序时,我们通过在一系列常见且可重复的步骤中扩展现有代码来添加功能。在本节中,我们将回顾这些步骤,并为向当前版本的应用程序添加更多功能设定一个指南。
扩展服务器代码
对于特定功能,这将需要数据持久性和 API 来允许视图操作数据,我们可以通过扩展服务器代码并添加必要的模型、路由和控制器函数来开始。
添加模型
对于功能的数据持久性方面,设计数据模型时要考虑需要存储的字段和值。然后,在server/models
文件夹中的单独文件中定义并导出此数据模型的 Mongoose 模式。
实现 API
接下来,设计与所需功能相关的 API,以便根据模型操作和访问将存储在数据库中的数据。
添加控制器
确定了 API 后,在server/controllers
文件夹中的一个单独文件中添加将响应这些 API 请求的相应控制器函数。此文件中的控制器函数应访问和操作为此功能定义的模型的数据。
添加路由
完成服务器端 API 的实现,需要声明相应的路由并挂载到 Express 应用程序上。在server/routes
文件夹中的一个单独文件中,首先声明并导出这些 API 的路由,分配应该在请求特定路由时执行的相关控制器函数。然后,在server/express.js
文件中加载这些新路由到 Express 应用程序中,就像应用程序中的其他现有路由一样。
这将产生一个可以从 REST API 客户端应用程序运行和检查的新后端 API 的工作版本,然后再构建和集成正在开发的功能的前端视图。
扩展客户端代码
在客户端,首先设计所需的功能视图,并确定这些视图将如何与与功能相关的数据进行用户交互。然后添加获取 API 代码以与新后端 API 集成,定义代表这些新视图的新组件,并更新现有代码以在应用程序的前端中包含这些新组件。
添加 API 获取方法
在客户端文件夹中,创建一个新文件夹,用于存放正在开发的功能模块相关的组件和辅助代码。然后,为了集成新的后端 API,添加并导出相应的获取方法到这个新组件文件夹中的一个单独文件中。
添加组件
在新文件夹的单独文件中创建和导出代表所需功能的 React 组件,将其视图分离。使用现有的 auth-helper 方法将 auth 集成到这些新组件中。
加载新组件
为了将这些新组件整合到前端,这些组件要么需要添加到现有组件中,要么在自己的客户端路由上呈现。
更新前端路由
如果这些新组件需要在单独的路由上呈现,更新MainRouter.js
代码以添加加载这些组件的新路由,以给定的 URL 路径。
与现有组件集成
如果新组件将成为现有视图的一部分,请将该组件导入现有组件,以便根据需要将其添加到视图中。新组件也可以与现有组件集成,例如在Menu
组件中,通过链接到添加了单独路由的新组件。
通过与后端集成和连接组件,新功能实现已经完成。这些步骤可以重复,以向应用程序添加新功能。
总结
在本书的最后一章中,我们回顾并详细阐述了构建 MERN 应用程序时使用的一些最佳实践,突出了改进的领域,给出了解决应用程序增长时可能出现的问题的指引,并最终制定了继续向现有应用程序中开发更多功能的准则。
我们看到,模块化应用程序的代码结构有助于轻松扩展应用程序,选择在内联 CSS 和外部样式表之间使用 JSS 保持了样式代码的可控性和易用性,并且只在必要时为特定视图实现服务器端渲染,避免了代码中不必要的复杂性。
我们讨论了创建更少的有状态组件的好处,这些组件由更小、更明确定义的无状态功能组件组成,以及如何在重构现有组件或设计新组件以扩展应用程序时应用这一原则。对于可能遇到跨数百个组件管理和通信状态问题的不断增长的应用程序,我们指出了诸如 Redux 或 Flux 之类的选项,可以考虑解决这些问题。
对于可能对更严格的安全执行有更高要求的应用程序,我们回顾了我们现有的使用 JWT 和密码加密的用户认证实现,并讨论了改进安全性的可能扩展。
我们使用 Jest 演示了如何向 MERN 应用程序添加测试代码,并讨论了良好的实践,比如编写测试代码和使用 linting 工具,可以提高代码质量,同时确保应用程序的可靠性。
我们还研究了捆绑优化功能,例如代码拆分,可以通过减少初始捆绑大小和根据需要延迟加载应用程序的部分来提高性能。
最后,我们回顾并确定了整本书中使用的可重复步骤,并可以作为指导方针,通过添加更多功能来扩展 MERN 应用程序。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· 上周热点回顾(2.24-3.2)