React-全栈项目-全-

React 全栈项目(全)

原文:zh.annas-archive.org/md5/05F04F9004AE49378ED0525C32CB85EB

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书探讨了通过将 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并注册,以便文件直接通过电子邮件发送给您。

您可以按照以下步骤下载代码文件:

  1. 登录或注册www.packtpub.com

  2. 选择“支持”选项卡。

  3. 单击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

下载文件后,请确保使用以下最新版本的解压缩或提取文件夹:

  • 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 开发进行自定义。例如,以下流行选项都可以根据需要进行自定义:

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 的安装和启动过程取决于工作区的规格:

运行 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-envbabel-preset-reactbabel-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,其中指定了presetsplugins

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.jswebpack.config.server.jswebpack.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()
      ]
}
  • modeprocess.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.jsHelloWorld.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:此命令将在生产环境中运行捆绑代码

实时开发和调试

要运行到目前为止开发的代码,并确保一切正常运行,可以按照以下步骤进行:

  1. 从命令行运行应用程序npm run development

  2. 在浏览器中加载:在浏览器中打开根 URL,即http://localhost:3000,如果您正在使用本地机器设置。您应该看到一个标题为 MERN Kickstart 的页面,上面只显示 Hello World!。

  3. 开发代码并调试实时更改:将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 TokenJWT)作为无状态身份验证机制,不需要在服务器端存储用户状态。

这两种方法在相关的真实用例中都有优势。然而,为了简化本书中的代码,并且因为它与 MERN 堆栈和我们的示例应用程序配合得很好,我们将使用 JWT 进行身份验证实现。此外,本书还将在未来章节中提出安全增强选项。

JWT 的工作原理

当用户成功使用其凭据登录时,服务器端会生成一个使用秘钥和唯一用户详细信息签名的 JWT。然后,将此令牌返回给请求的客户端,以便在本地保存,可以保存在localStoragesessionStorage或浏览器的 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

从命令行运行以下命令来安装webpackwebpack-cliwebpack-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'
},

创建和更新时间戳

createdupdated字段是Date值,将被程序生成以记录用户创建和更新的时间戳。

mern-skeleton/server/models/user.model.js:

created: {
  type: Date,
  default: Date.now
},
updated: Date,

哈希密码和盐

hashed_passwordsalt字段代表我们将用于认证的加密用户密码。

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_passwordsalt值,被定义为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_passwordsalt值。

更新

声明了更新单个用户的 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_passwordsalt,然后将用户对象发送到请求客户端的响应中。

删除

声明了删除用户的 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.profileuser.controller.js中的userByID函数填充。我们将在需要身份验证和授权的路由中添加hasAuthorization函数。

保护用户路由

我们将在需要受到身份验证和授权保护的用户路由声明中添加requireSigninhasAuthorization

更新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/5a1c7ead1a692aa19c3e7b33GET请求将返回 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 视图。HomeSignupSigninUsersProfileEditProfile将在使用 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中定义根组件时,我们使用MuiThemeProviderMainRouter组件包装起来,以便让它可以访问 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.jsApp.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.jswebpack.config.server.jswebpack.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 是一个较新的标准,用于发出类似于XMLHttpRequestXHR)的网络请求,但使用 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 作为凭据,类似于readupdate方法。服务器对删除请求的响应将以 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文件的末尾,导出signinsignout方法。

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 组件(如PaperListListItems)组合在一起。这些元素使用定义的 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组件中添加以下RouteMainRouter中。

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的表单元素,只有emailpassword字段,条件错误消息和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获取调用,然后可以在componentDidMountcomponentWillReceiveProps中调用它。

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函数在componentDidMountcomponentWillReceiveProps中被调用,并传入相关的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组件在实现上与SignupProfile组件都有相似之处。它将允许授权用户在类似注册表单的表单中编辑自己的个人资料信息:

'/user/edit/:userId'加载时,组件将通过验证 JWT 以获取 ID 的用户信息,然后使用接收到的用户信息加载表单。表单将允许用户仅编辑和提交更改的信息到update fetch 调用,并在成功更新后将用户重定向到具有更新信息的Profile视图。

EditProfile将以与Profile组件相同的方式加载用户信息,通过在componentDidMount中使用readthis.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>)
}

DeleteUseruserId作为属性传递,用于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的组件属性设置为spanButton组件将呈现为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列表。

更新用户模型

为了在数据库中存储followingfollowers列表,我们将使用两个用户引用数组更新用户模型。

mern-social/server/models/user.model.js:

following: [{type: mongoose.Schema.ObjectId, ref: 'User'}],
followers: [{type: mongoose.Schema.ObjectId, ref: 'User'}]

这些引用将指向正在被关注或正在关注给定用户的集合中的用户。

更新userByID控制器方法

当从后端检索到单个用户时,我们希望user对象包括followingfollowers数组中引用的用户的名称和 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方法来指定从查询返回的用户对象应包含followingfollowers列表中引用的用户的名称和 ID。这将在我们使用读取 API 调用获取用户时,给我们followersfollowing列表中的用户引用的名称和 ID。

关注和取消关注的 API

当用户从视图中关注或取消关注另一个用户时,数据库中的两个用户记录将响应followunfollow请求而更新。

我们将在user.routes.js中设置followunfollow路由如下。

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)
  })
}

对于取消关注,实现方式类似。removeFollowingremoveFollower控制器方法通过使用$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更新followunfollow fetch 方法。followunfollow方法将类似,使用当前用户的 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组件中。该组件将根据当前用户是否已关注个人资料中的用户来显示FollowUnfollow按钮。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,同时还会发送点击处理程序,该处理程序将特定的followunfollow fetch API 作为参数调用:

更新个人资料组件

Profile视图中,只有在用户查看其他用户的个人资料时才应显示FollowProfileButton,因此我们需要修改在查看个人资料时显示EditDelete按钮的条件如下:

{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 加载个人资料时,followingfollowers列表中引用的用户的详细信息已经在用户对象中。为了呈现这些单独的关注者和正在关注的人列表,我们将创建一个名为FollowGrid的新组件。

FollowGrid 组件

FollowGrid组件将接受用户列表作为 props,显示用户的头像和名称,并链接到每个用户的个人资料。我们可以根据需要将此组件添加到Profile视图中,以显示followingsfollowers

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视图中,我们可以根据需要将其放置在视图中,并将followersfollowings列表作为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组件提供了addPostremovePost函数给NewPostPostList,当创建新帖子或删除现有帖子时,将用于更新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组件,该组件将呈现提供给它的任何帖子列表,并且我们可以在NewsfeedProfile组件中都使用它。

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组件将遍历从NewsfeedProfile传递给它的帖子列表,并将每个帖子的数据传递给Post组件,该组件将呈现帖子的详细信息。 PostList还将传递从父组件作为 prop 发送到Post组件的removeUpdate函数,以便在删除单个帖子时更新状态。

在 Newsfeed 中列出

我们将在服务器上设置一个 API,该 API 查询帖子集合,并从指定用户关注的人那里返回帖子。因此,这些帖子可能会在NewsfeedPostList中显示。

帖子的 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组件中的所有与评论相关的元素,并将获得诸如postIdcomments数据等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 从NewsfeedProfile发送,以在成功删除时更新状态中的帖子列表。

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组件的componentDidMountcomponentWillReceiveProps期间调用,以在检查当前用户是否在帖子的likes数组中引用后为帖子设置like状态:

使用checkLike方法在状态中设置的like值可以用于渲染心形轮廓按钮或完整的心形按钮。如果用户尚未喜欢帖子,将呈现心形轮廓按钮,点击后将调用likeAPI,显示完整的心形按钮,并增加likes计数。完整的心形按钮将指示当前用户已经喜欢了这篇帖子,点击这将调用unlikeAPI,呈现心形轮廓按钮,并减少likes计数。

Post组件挂载并且通过设置this.props.post.likes.lengthlikes值设置为状态时,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-UISwitch组件。

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'
}
  • 创建和更新时间createdupdated字段将是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()
}

它使用listfetch 方法来检索商店列表并将数据设置为状态。

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.jsShop路由中添加以下内容,以便用户作为profilerequest对象中可用。

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控制器方法将使用formidablefs模块,如前面讨论的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,我们将保持产品模式简单,支持诸如产品名称、描述、图像、类别、数量、价格、创建时间、更新时间以及对商店的引用等字段。

  • 产品名称和描述namedescription字段将是String类型,namerequired字段:
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'
}
  • 创建和更新时间createdupdated字段将是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文件中声明,并利用了商店控制器中的shopByIDisOwner方法来处理: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中定义,并作为onRemoveprop 提供给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)
  })
}

在前端,我们将为这个最新的productsAPI 设置一个对应的 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 方法调用 readlistRelated 获取产品和相关列表数据,然后将数据设置到状态中。

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 对象、shopIDloadProducts 方法作为 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 市场前端的所有自定义组件。本章讨论的功能修改了一些现有的组件,如ProfileMyShopsProductsSuggestions,还添加了新的组件,如AddToCartMyOrdersCartShopOrders

完整的 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)
}

一旦在购物车视图中将结账值设置为trueCheckout组件将被呈现,允许用户输入结账详情并下订单。

使用条纹进行支付

支付处理需要跨结账、订单创建和订单管理流程的实现。它还涉及对买家和卖家用户数据的更新。在我们深入讨论结账和订单功能的实现之前,我们将简要讨论使用条纹的支付处理选项和考虑事项,以及它在 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 的CardElementPlaceOrder按钮。

mern-marketplace/client/cart/PlaceOrder.js

class **PlaceOrder** extends Component { … } export default **injectStripe**(withStyles(styles)(PlaceOrder))

然后我们将在结账表单中添加PlaceOrder组件,将checkoutDetails对象作为 prop 传递给它,并使用来自react-stripe-elementsElements组件进行包装。

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_namecustomer_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'}

交货地址

订单的交货地址信息将存储在交货地址子文档中,其中包括streetcitystatezipcodecountry字段。

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中定义了cancelProductprocessChargeupdate获取方法,以调用后端对应的 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数组中匹配的CartItemstatus值。

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>

TitleDescriptionGenre表单字段将添加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组件中使用,类似于在主页视图中使用的listPopularfetch 方法,以检索列表数据,设置状态,然后传递给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 传递给MediaRelatedMedia子组件。

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组件由MediaRelatedMedia子组件以及自动播放切换按钮组成,并在视图加载时向这些组件提供数据。为了在用户访问单个媒体链接时呈现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 和readAPI 的fetch方法从服务器检索媒体详细信息。然后,它使用listRelatedAPI 的 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中渲染不同的图标,根据volumemutedvolume_upvolume_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,允许用户设置音量值在01之间。

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组件将采用playedloaded值来显示不同的颜色:

<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事件监听器来设置playedloaded的当前值。

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)' }}
/>

在用户自行拖动并设置范围选择器的情况下,我们将添加代码来处理onMouseDownonMouseUponChange事件,以从所需位置开始播放视频。

当用户按住鼠标开始拖动时,我们将把 seeking 设置为 true,以便进度值不设置为playedloaded

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

然后将screenfullfindDOMNode导入到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>

当用户点击此按钮时,我们将使用screenfullfindDOMNode使视频播放器全屏。

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-UISwitch组件以及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 }) 
} 

跨组件处理自动播放

PlayMediahandleAutoPlay方法作为属性传递给Media组件,以便在视频结束时由MediaPlayer组件使用。

这里期望的功能是,当视频结束时,如果自动播放设置为 true 并且当前相关媒体列表不为空,则PlayMedia应加载相关列表中第一个视频的媒体详情。反过来,MediaMediaPlayer组件应更新为新的媒体详情,开始播放新视频,并适当地渲染播放器上的控件。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 中视频结束时更新状态

MediaPlayerPlayMedia中接收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.jsread获取方法作为加载数据的方法。然后它将用于在服务器生成标记时检索并注入数据到 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访问这些数据,我们将更改PlayMediaRoute组件,以将这些数据作为属性传递。

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 内置的组件:TextView。在游戏中,我们将使用这两个组件来添加游戏世界中的内容。

视图

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 的伴侣)

objmtl属性的值指向这些文件的位置,可以是静态字符串,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'),
})

传递给playEnvironmentalplayOneShot的选项中的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 头盔上的凝视按钮点击。我们将处理的具体输入事件是onEnteronExitonClick事件。

  • onEnter:每当平台光标开始与组件相交时,就会触发此事件。我们将捕获此事件用于游戏中的 VR 对象,这样当平台光标进入特定对象时,对象就可以开始围绕 Y 轴旋转。

  • onExit:每当平台光标停止与组件相交时,就会触发此事件。它具有与onEnter事件相同的属性,我们将使用它来停止旋转刚刚退出的 VR 对象。

  • onClickonClick事件与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 数据信息资源将添加在objUrlmtlUrl键中:

  • objUrl:3D 对象的.obj文件的链接

  • mtlUrl:附带的.mtl文件的链接

objUrlmtlUrl链接可能指向存储在云存储、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.jsclient.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链接应该显示一个外太空背景,可以使用鼠标在周围移动:

为了生成上述截图,还更新了默认代码中的ViewText组件,使用自定义 CSS 规则在屏幕上显示这个 hello 文本。

添加 3D VR 对象

我们将使用Entity组件和answerObjectswrongObjects数组中的示例对象详细信息向游戏世界添加 3D 对象。

首先,我们将在componentDidMount中连接answerObjectswrongObjects数组,形成一个包含所有 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}
                  }}
                 />
            )
    })
}

objmtl文件链接被添加到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属性将允许我们根据玩家是否已经收集了对象来显示或隐藏对象。

translaterotate值将在 VR 世界中呈现所需位置和方向的 3D 对象。

接下来,我们将进一步更新Entity代码,以使用户可以与 3D 对象进行交互。

与 VR 对象交互

为了使 VR 游戏对象具有交互性,我们将使用 React 360 事件处理程序,如onEnteronExitEntity,以及VrButtononClick,添加旋转动画和游戏行为。

旋转

我们希望添加一个功能,当玩家关注 3D 对象时,即平台光标开始与渲染特定 3D 对象的Entity相交时,开始围绕其 Y 轴旋转 3D 对象。

我们将更新上一节中的Entity组件,添加onEnteronExit处理程序。

/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) 

如果vrObjectanswerObjectindexOf将返回匹配对象的数组索引,否则如果找不到匹配项,则返回-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组件应该从视图中隐藏,因为相关的对象已被收集。Entitydisplay样式属性将根据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组件的变换矩阵值

  • 将消息Viewdisplay样式属性设置为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.jsindex.bundle.js。这两个文件,加上index.htmlstatic-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.jsindex.bundle.jsstatic_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,以实现游戏功能,包括诸如ViewTextEntityVrButton等组件,以及EnvironmentVrHeadModelNativeModulesAPI。

最后,我们更新了起始的 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 模式

游戏模式中的answerObjectswrongObjects字段都将是 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'}
}) 

当新的游戏文档保存到数据库时,answerObjectswrongObjects数组将填充符合此模式定义的 VRObject 文档。

游戏模式中的数组长度验证

在保存到游戏集合中时,游戏文档中的answerObjectswrongObjects数组必须至少包含一个 VRObject 文档。为了为游戏模式添加最小数组长度的验证,我们将在GameSchema中的answerObjectswrongObjects路径中添加以下自定义验证检查。

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

已登录应用程序的用户将能够使用createAPI 在数据库中创建新游戏。

路由

在后端,我们将在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方法,通过传递从已登录用户收集的表单数据来向createAPI 发起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/gamesGET请求将执行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方法,向createAPI 发出 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组件在NewGameEditGame组件中都使用,包含允许用户输入游戏详情和单个游戏的 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 游戏中的answerObjectswrongObjects数组,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组件状态中更新。为了注册这个更新,GameFormhandleObjectChange方法传递给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方法会更新数组中特定对象的字段值,使用给定的typeindex,因此它会在GameForm中存储的游戏对象状态中反映出来。

VRObjectForm 组件

VRObjectForm组件将渲染输入字段,以修改单个 VR 对象的细节,该对象被添加到GameForm组件中的answerObjectswrongObjects数组中:

它可以从一个空的 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将包含一个“删除”按钮,该按钮将执行从GameFormprops 表单中接收到的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组件中的列表,该组件呈现了游戏制作者的editdelete选项:

制作者的游戏

用户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 按钮。如果当前用户是游戏制作者,则还会显示editdelete按钮:

游戏详情

游戏详情,如名称、世界图片、线索文本和制作者名称,都会被呈现,以便用户对游戏有一个概览。

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组件只会在当前登录用户也是正在呈现的游戏的制作者时显示editdelete选项。

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.jsindex.bundle.js捆绑文件,其中包含更新的 React 360 代码。这些文件以及index.htmlstatic_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/

clientserver部分,我们进一步将代码划分为子文件夹,这些子文件夹映射到唯一的功能,例如在服务器端的模型、控制器和路由,以及在客户端将所有与用户相关的组件分组。

服务器端代码

在服务器端,我们根据功能将代码分开,通过将定义业务模型的代码与实现路由逻辑的代码以及在这些路由上响应客户端请求的代码分开:

  | -- server/
    | --- controllers/
    | --- models/
    | --- routes/

在这种结构中,每个文件夹都包含具有特定目的的代码:

  • 模型:该文件夹旨在包含所有 Mongoose 模式模型定义的单独文件,每个文件代表一个单独的模型。

  • routes:此文件夹包含允许客户端与服务器交互的所有路由 - 放在单独的文件中,其中每个文件可能与模型文件夹中的模型相关联。

  • controllers:包含定义逻辑以响应定义路由的传入请求的所有控制器函数,分成相应的模型和路由文件。

正如本书中所示,服务器端代码的这些特定关注点的分离使我们能够通过添加所需的模型、路由和控制器文件来扩展骨架应用程序开发的服务器。

客户端代码

MERN 应用程序的客户端代码主要由 React 组件组成。为了以合理和可理解的方式组织组件代码和相关的辅助代码,我们将代码分成与功能实体或独特功能相关的文件夹:

  | -- client/
    | --- auth/
    | --- core/
    | --- post/
    | --- user/
    | --- componentFolderN/

在前述结构中,我们将所有与认证相关的组件和辅助代码放在auth文件夹中,将常见和基本组件,如HomeMenu组件,放在core文件夹中,然后我们为所有与帖子或用户相关的组件在相应的文件夹中创建postuser文件夹。

这种基于功能的组件的分离和分组使我们能够通过根据需要向客户端文件夹添加新的与功能相关的组件代码文件夹来扩展骨架应用程序的前端视图。

在本章的最后一节中,我们进一步展示了这种模块化方法分离应用程序代码的优势,同时概述了可以采用的一般工作流程,以向本书中开发的任何现有应用程序添加新功能。

添加 CSS 样式

在讨论本书中应用程序的用户界面实现时,我们选择不专注于应用的 CSS 样式代码的细节,并主要依赖于默认的 Material-UI 样式。但是,考虑到实现任何用户界面都需要考虑样式解决方案,我们将简要介绍一些可用的选项。

在添加 CSS 样式到前端时,有许多选项,每种都有其优缺点。在本节中,我们将讨论两种最常见的选项,即外部样式表和内联样式,以及在 JavaScript 中编写 CSS 的较新方法,或者更具体地说是 JSS,它用于 Material-UI 组件,因此也用于本书中的应用程序。

外部样式表

外部样式表允许我们在单独的文件中定义 CSS 规则,然后将其注入到必要的视图中。以这种方式在外部样式表中放置 CSS 样式曾经被认为是更好的做法,因为它强制执行了样式和内容的分离,允许重用,并且如果为每个组件创建单独的 CSS 文件,则还可以保持模块化。

然而,随着 Web 开发技术的不断发展,对更好的 CSS 组织和性能的需求不再适用于这种方法。例如,在使用 React 组件开发前端视图时,使用外部样式表会限制根据组件状态更新样式的控制。此外,为 React 应用程序加载外部 CSS 需要额外的 Webpack 配置,包括css-loaderstyle-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 组件修改了无状态子组件的状态,比如 FollowProfileButtonFollowGrid 组件。在将应用程序扩展到包含更多功能之前,应考虑将本书中开发的一些较大组件重构为更小、更自包含的组件。

可以应用于新组件设计或重构现有组件的主要要点是,随着 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中,我们定义了以下函数来生成哈希的passwordsalt值:

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

  • 定义测试,并在测试定义内部

  • 声明 postauth 变量

  • 将模拟的 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 应用程序。

posted @ 2024-05-16 14:50  绝不原创的飞龙  阅读(5)  评论(0编辑  收藏  举报