React-全栈项目第二版-四-

48 阅读30分钟

React 全栈项目第二版(四)

原文:zh.annas-archive.org/md5/35c59f78351aeb34721c43c78c53c92a

译者:飞龙

协议:CC BY-NC-SA 4.0

第十二章:向市场添加实时竞价功能

在比以往任何时候都更加紧密联系的世界中,即时通信和实时更新是任何允许用户之间互动的应用程序所期望的行为。向您的应用程序添加实时功能可以使您的用户保持参与,因此他们将在您的平台上花费更多时间。在本章中,我们将学习如何使用 MERN 堆栈技术以及 Socket.IO,轻松地将实时行为集成到全栈应用程序中。我们将通过在我们在第七章“使用在线市场锻炼 MERN 技能”和第八章“扩展市场以支持订单和支付”中开发的 MERN 市场应用程序中集成具有实时竞价功能的拍卖功能来实现这一点。在完成此拍卖和竞价功能的实现后,您将了解如何在 MERN 堆栈应用程序中利用套接字添加您选择的实时功能。

在本章中,我们将通过以下主题扩展在线市场应用程序:

  • 在 MERN 市场中引入实时竞价

  • 向市场添加拍卖

  • 显示拍卖视图

  • 使用 Socket.IO 实现实时竞价

在 MERN 市场中引入实时竞价

MERN 市场应用程序已经允许其用户成为卖家并维护有产品可供普通用户购买商店。在本章中,我们将扩展这些功能,允许卖家为其他用户创建在固定时间内可以出价的拍卖物品。拍卖视图将描述待售物品,并允许已登录用户在拍卖进行时进行出价。不同的用户可以放置自己的出价,并实时看到其他用户出价,视图将相应更新。完成的拍卖视图,其中拍卖处于活动状态,将呈现如下:

图片

完整的 MERN 市场应用代码可在 GitHub 上找到,网址为 github.com/PacktPublishing/Full-Stack-React-Projects-Second-Edition/tree/master/Chapter09/mern-marketplace-bidding。本章中讨论的实现可以在存储库的 bidding 分支中访问。您可以克隆此代码,并在阅读本章其余部分的代码解释时运行应用程序。

以下组件树图显示了构成整个 MERN 市场前端的所有自定义组件,包括将在本章其余部分实现拍卖和竞价相关功能的组件:

图片

本章将讨论的功能将修改一些现有组件,例如ProfileMenu,并添加新组件,例如NewAuctionMyAuctionsAuctionBidding。在下一节中,我们将通过集成将拍卖添加到平台的功能来扩展这个在线市场。

将拍卖添加到市场

在 MERN 市场,我们将允许已登录并拥有活跃卖家账户的用户为其他用户想要下注的物品创建拍卖。为了启用添加和管理拍卖的功能,我们需要定义如何存储拍卖详情并实现全栈切片,使用户能够在平台上创建、访问和更新拍卖。在以下章节中,我们将为应用程序构建这个拍卖模块。首先,我们将使用 Mongoose 模式定义拍卖模型,以存储每个拍卖的详情。然后,我们将讨论后端 API 和前端视图的实现,这些是实现创建新拍卖、列出由同一卖家创建并由同一用户下注的正在进行的拍卖以及通过编辑详情或从应用程序中删除拍卖来修改现有拍卖所需的。

定义拍卖模型

我们将实现一个 Mongoose 模型,该模型将定义一个用于存储每个拍卖详情的拍卖模型。此模型将在server/models/auction.model.js中定义,其实现将与我们在前几章中介绍的其他 Mongoose 模型实现类似,例如我们在第七章中定义的 Shop 模型,使用在线市场锻炼 MERN 技能。此模型中的 Auction 模式将包含存储拍卖详情的字段,例如拍卖物品的名称和描述、图片以及创建此拍卖的卖家引用。它还将包含指定此拍卖下注开始和结束时间的字段、下注的起始值以及为此拍卖已放置的下注列表。定义这些拍卖字段的代码如下:

  • 物品名称和描述:拍卖物品名称和描述字段将是字符串类型,其中itemName为必填字段:
itemName: {
    type: String,
    trim: true,
    required: 'Item name is required'
},
description: {
    type: String,
    trim: true
},
  • 项目图片image字段将存储代表拍卖物品的图片文件,以便用户可以上传并作为数据存储在 MongoDB 数据库中:
image: { 
    data: Buffer, 
    contentType: String 
},
  • 卖家seller字段将引用创建拍卖的用户:
seller: {
    type: mongoose.Schema.ObjectId, 
    ref: 'User'
},
  • 创建和更新时间createdupdated字段将是Date类型,其中created在添加新拍卖时生成,而updated在修改任何拍卖详情时更改:
updated: Date,
created: { 
    type: Date, 
    default: Date.now 
},
  • 拍卖开始时间bidStart字段将是一个Date类型,用于指定拍卖何时开始,以便用户可以开始下注:
bidStart: {
    type: Date,
    default: Date.now
},
  • 出价结束时间bidEnd字段将是一个Date类型,用于指定拍卖何时结束,之后用户将无法对此拍卖进行出价:
bidEnd: {
   type: Date,
   required: "Auction end time is required"
},
  • 起始出价startingBid字段将存储Number类型的值,并指定此次拍卖的起始价格:
startingBid: { 
    type: Number, 
    default: 0 
},
  • 出价列表bids字段将是一个包含对拍卖所出每个出价详情的数组。当我们将出价存储在这个数组中时,我们将最新的出价推送到数组的开头。每个出价将包含放置出价的用户的引用、用户提供的出价金额以及出价放置的时间戳:
bids: [{
    bidder: {type: mongoose.Schema.ObjectId, ref: 'User'},
    bid: Number,
    time: Date
}]

这些与拍卖相关的字段将使我们能够为 MERN Marketplace 应用程序实现拍卖和竞标相关功能。在下一节中,我们将通过实现全栈切片来开始开发这些功能,这将允许卖家创建新的拍卖。

创建新的拍卖

为了使卖家能够在平台上创建新的拍卖,我们需要集成一个全栈切片,允许用户在前端填写表单视图,然后将输入的详细信息保存到后端数据库中的新拍卖文档中。为了实现此功能,在接下来的章节中,我们将在后端添加创建拍卖 API,以及在前端获取此 API 的方法,以及一个创建新拍卖表单视图,该视图接受用户对拍卖字段的输入。

创建拍卖 API

为了实现允许我们在数据库中创建新拍卖的后端 API,我们将声明一个 POST 路由,如下面的代码所示。

mern-marketplace/server/routes/auction.routes.js:


router.route('/api/auctions/by/:userId')
  .post(authCtrl.requireSignin, authCtrl.hasAuthorization, 
        userCtrl.isSeller, auctionCtrl.create)

/api/auctions/by/:userId此路由发送 POST 请求将确保请求的用户已登录并且也已授权。换句话说,它是与路由参数中指定的:userId关联的同一用户。然后,在创建拍卖之前,将使用在用户控制器方法中定义的isSeller方法检查此给定用户是否为卖家。

为了处理:userId参数并从数据库中检索关联的用户,我们将利用用户控制器方法中的userByID方法。我们将在auction.routes.js中的Auction路由中添加以下内容,以便用户在request对象中作为profile可用。

mern-marketplace/server/routes/auction.routes.js:

router.param('userId', userCtrl.userByID) 

包含拍卖路由的auction.routes.js文件将与user.routes文件非常相似。为了在 Express 应用程序中加载这些新的拍卖路由,我们需要在express.js中挂载拍卖路由,就像我们为认证和用户路由所做的那样。

mern-marketplace/server/express.js:

app.use('/', auctionRoutes)

在卖家验证后调用的拍卖控制器中的create方法,使用formidable节点模块解析可能包含用户上传的物品图片的多部分请求。如果有文件,formidable将暂时将其存储在文件系统中,我们将使用fs模块读取它以检索文件类型和数据,以便我们可以将其存储在拍卖文档的image字段中。

create控制器方法将如下所示。

mern-marketplace/server/controllers/auction.controller.js:

const create = (req, res) => {
  let form = new formidable.IncomingForm()
  form.keepExtensions = true
  form.parse(req, async (err, fields, files) => {
    if (err) {
      res.status(400).json({
        message: "Image could not be uploaded"
      })
    }
    let auction = new Auction(fields)
    auction.seller= req.profile
    if(files.image){
      auction.image.data = fs.readFileSync(files.image.path)
      auction.image.contentType = files.image.type
    }
    try {
      let result = await auction.save()
      res.status(200).json(result)
    }catch (err){
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
  })
}

拍卖的物品图片文件由用户上传并存储在 MongoDB 中作为数据。然后,为了在视图中显示,它作为单独的 GET API 从数据库中检索出来,作为一个图片文件。这个 GET API 被设置为 Express 路由在/api/auctions/image/:auctionId,它从 MongoDB 获取图像数据并将其作为文件发送在响应中。文件上传、存储和检索的实现步骤在第五章“将骨架扩展成社交媒体应用”的上传个人照片部分中详细说明。

现在可以在前端使用这个创建拍卖 API 端点来发起 POST 请求。接下来,我们将在客户端添加一个 fetch 方法,从应用程序的客户端界面发起这个请求。

在视图中获取创建 API

在前端,为了向这个创建 API 发起请求,我们将在客户端设置一个fetch方法,向 API 路由发送 POST 请求,并在body中传递包含新拍卖详情的多部分表单数据。这个 fetch 方法将定义如下。

mern-marketplace/client/auction/api-auction.js:

const create = (params, credentials, auction) => {
  return fetch('/api/auctions/by/'+ params.userId, {
      method: 'POST',
      headers: {
        'Accept': 'application/json',
        'Authorization': 'Bearer ' + credentials.t
      },
      body: auction
    })
    .then((response) => {
      return response.json()
    }).catch((err) => console.log(err))
}

从服务器接收到的响应将返回给调用这个 fetch 方法的组件。我们将使用这个方法在新拍卖表单视图中发送用户输入的拍卖详情到后端并在数据库中创建一个新的拍卖。在下一节中,我们将实现这个新的拍卖表单视图在 React 组件中。

新拍卖组件

市场应用中的卖家将通过表单视图输入新拍卖的详情并创建新的拍卖。我们将在这个NewAuction组件中渲染这个表单,允许卖家通过输入项目名称和描述、从本地文件系统上传图片文件、指定起始出价值以及为这个拍卖的起始和结束出价创建日期时间值来创建拍卖。

这个表单视图将渲染如下:

这个 NewAuction 组件的实现与其他我们之前讨论过的创建表单实现类似,例如来自第七章 [03fd3b4a-b7fd-4b42-ad7e-5bc34b5612b0.xhtml],使用在线市场锻炼 MERN 技能NewShop 组件实现。在这个表单组件中不同的字段是拍卖开始和结束时间的日期时间输入选项。为了添加这些字段,我们将使用 Material-UI 的 TextField 组件,并将 type 设置为 datetime-local,如下面的代码所示。

mern-marketplace/client/auction/NewAuction.js:

<TextField
   label="Auction Start Time"
   type="datetime-local"
   defaultValue={defaultStartTime}
   onChange={handleChange('bidStart')}
/>
<TextField
   label="Auction End Time"
   type="datetime-local"
   defaultValue={defaultEndTime}
   onChange={handleChange('bidEnd')}
/>

我们还为这些字段分配了默认的日期时间值,格式与该输入组件期望的格式一致。我们将默认开始时间设置为当前日期时间,默认结束时间设置为当前日期时间后一小时,如下所示。

mern-marketplace/client/auction/NewAuction.js:

const currentDate = new Date()
const defaultStartTime = getDateString(currentDate)
const defaultEndTime = getDateString(new Date(currentDate.setHours(currentDate.getHours()+1)))

类型为 datetime-localTextFieldyyyy-mm-ddThh:mm 的格式接受日期。因此,我们定义了一个 getDateString 方法,该方法接受一个 JavaScript 日期对象并相应地格式化它。getDateString 方法的实现如下。

mern-marketplace/client/auction/NewAuction.js:

const getDateString = (date) => {
  let year = date.getFullYear()
  let day = date.getDate().toString().length === 1 ? '0' + date.getDate() : date.getDate()
  let month = date.getMonth().toString().length === 1 ? '0' + (date.getMonth()+1) : date.getMonth() + 1
  let hours = date.getHours().toString().length === 1 ? '0' + date.getHours() : date.getHours()
  let minutes = date.getMinutes().toString().length === 1 ? '0' + date.getMinutes() : date.getMinutes()
  let dateString = `${year}-${month}-${day}T${hours}:${minutes}`
  return dateString
}

为了确保用户正确地输入了日期,开始时间设置为早于结束时间的值,我们需要在将表单详情提交到后端之前添加一个检查。日期组合的验证可以通过以下代码确认。

mern-marketplace/client/auction/NewAuction.js:

if(values.bidEnd < values.bidStart){
   setValues({...values, error: "Auction cannot end before it starts"})
}

如果发现日期组合无效,则用户将被告知,并且不会将表单数据发送到后端。

这个 NewAuction 组件只能由登录且也是卖家的用户查看。因此,我们将在 MainRouter 组件中添加一个 PrivateRoute。这将在这个 MainRouter 组件中为经过身份验证的用户渲染 /auction/new 的表单。

mern-marketplace/client/MainRouter.js:

<PrivateRoute path="/auction/new" component={NewAuction}/>

这个链接可以添加到任何卖家可能访问的视图组件中,例如,在一个卖家在市场中管理他们的拍卖的视图中。现在,在市场中添加新的拍卖成为可能,在下一节中,我们将讨论如何从后端数据库中检索这些拍卖,以便它们可以在前端视图中列出。

列出拍卖

在 MERN 市场应用中,我们将向用户展示三个不同的拍卖列表。所有浏览平台的用户都将能够查看当前正在进行的拍卖,换句话说,即那些正在直播或将在未来某个日期开始的拍卖。卖家将能够查看他们创建的拍卖列表,而登录用户将能够查看他们投过标的拍卖列表。展示给所有用户的开放拍卖列表将如下渲染,提供每个拍卖的摘要,并有一个选项让用户可以在单独的视图中查看更多详细信息:

图片

在以下章节中,为了实现这些不同的拍卖列表以便在应用程序中显示,我们将分别定义三个单独的后端 API 来检索开放拍卖、卖家拍卖和出价者拍卖。然后,我们将实现一个可重用的 React 组件,该组件将接受作为属性提供的任何拍卖列表并将其渲染到视图中。这将允许我们在使用相同组件的同时显示所有三个拍卖列表。

开放式拍卖 API

为了从数据库中检索开放拍卖的列表,我们将定义一个后端 API,该 API 接受 GET 请求并查询拍卖集合,以便在响应中返回找到的开放拍卖。为了实现这个开放拍卖 API,我们将声明一个路由,如下所示。

mern-marketplace/server/routes/auction.routes.js:

router.route('/api/auctions')
  .get(auctionCtrl.listOpen)

当在/api/auctions路由上接收到 GET 请求时,将调用listOpen控制器方法,该方法将查询数据库中的拍卖集合,以便返回所有截至日期大于当前日期的拍卖。listOpen方法定义如下。

mern-marketplace/server/controllers/auction.controller.js:

const listOpen = async (req, res) => {
  try {
    let auctions = await Auction.find({ bidEnd: { $gt: new Date() }})
                                .sort('bidStart')
                                .populate('seller', '_id name')
                                .populate('bids.bidder', '_id name')
    res.json(auctions)
  } catch (err){
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

listOpen方法查询返回的拍卖将按起始日期排序,较早开始的拍卖将首先显示。这些拍卖还将包含卖家的 ID 和名称详情以及每个出价者的详情。结果拍卖数组将发送回请求客户端的响应。

为了在前端获取此 API,我们将在api-auction.js中添加相应的listOpen方法,类似于其他 API 实现。此获取方法将用于在前端组件中显示开放拍卖给用户。接下来,我们将实现另一个 API 来列出特定用户参与的所有拍卖。

按出价者拍卖 API

为了能够显示给定用户参与的所有拍卖,我们将定义一个后端 API,该 API 接受 GET 请求并查询拍卖集合,以便在响应中返回相关的拍卖。为了实现按出价者拍卖 API,我们将声明一个路由,如下所示。

mern-marketplace/server/routes/auction.routes.js

router.route('/api/auctions/bid/:userId')
  .get(auctionCtrl.listByBidder)

当在/api/auctions/bid/:userId路由上接收到 GET 请求时,将调用listByBidder控制器方法,该方法将查询数据库中的拍卖集合,以便返回所有包含与路由中指定的userId参数匹配的出价者的出价的拍卖。listByBidder方法定义如下。

mern-marketplace/server/controllers/auction.controller.js:

const listByBidder = async (req, res) => {
  try {
    let auctions = await Auction.find({'bids.bidder': req.profile._id})
                                .populate('seller', '_id name')
                                .populate('bids.bidder', '_id name')
    res.json(auctions)
  } catch (err){
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

此方法将向请求客户端返回结果拍卖,并且每个拍卖还将包含卖家和每个竞标者的 ID 和名称详情。为了在前端获取此 API,我们将在api-auction.js中添加相应的listByBidder方法,类似于其他 API 实现。此获取方法将用于显示与特定竞标者相关的拍卖的前端组件。接下来,我们将实现一个 API,该 API 将列出特定卖家在市场上创建的所有拍卖。

卖家拍卖 API

市场中的卖家将看到他们创建的拍卖列表。为了从数据库中检索这些拍卖,我们将定义一个后端 API,该 API 接受 GET 请求并查询拍卖集合,以便返回特定卖家的拍卖。为了实现此卖家拍卖 API,我们将声明一个路由,如下所示。

mern-marketplace/server/routes/auction.routes.js:

router.route('/api/auctions/by/:userId')
  .get(authCtrl.requireSignin, authCtrl.hasAuthorization, 
       auctionCtrl.listBySeller)

当在/api/auctions/by/:userId路由接收到 GET 请求时,将调用listBySeller控制器方法,该方法将查询数据库中的拍卖集合,以便返回所有与通过路由中userId参数指定的用户匹配的卖家拍卖。listBySeller方法定义如下。

mern-marketplace/server/controllers/auction.controller.js:

const listBySeller = async (req, res) => {
  try {
    let auctions = await Auction.find({seller: req.profile._id})
                                .populate('seller', '_id name')
                                .populate('bids.bidder', '_id name')
    res.json(auctions)
  } catch (err){
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

此方法将向请求客户端返回指定卖家的拍卖,并且每个拍卖还将包含卖家和每个竞标者的 ID 和名称详情。

为了在前端获取此 API,我们将在api-auction.js中添加相应的listBySeller方法,类似于其他 API 实现。此获取方法将用于显示与特定卖家相关的拍卖的前端组件。在下一节中,我们将查看拍卖组件的实现,该组件将接受任何这些拍卖列表并将其显示给最终用户。

拍卖组件

应用程序中的不同拍卖列表将通过一个 React 组件渲染,该组件接受拍卖对象数组作为属性。我们将实现这个可重用的Auctions组件,并将其添加到将检索和显示开放拍卖、竞标者拍卖或卖家拍卖的视图中。使用Auctions组件检索和渲染特定卖家创建的拍卖列表的视图将如下所示:

图片

Auctions组件将遍历作为属性接收的拍卖数组,并在 Material-UI ListItem组件中显示每个拍卖,如下面的代码所示。

mern-marketplace/client/auction/Auctions.js:

export default function Auctions(props){
    return (
     <List dense>
        {props.auctions.map((auction, i) => {
            return <span key={i}>
              <ListItem button>
                <ListItemAvatar>
                  <Avatar src={'/api/auctions/image/'+auction._id+"?" 
                                        + new Date().getTime()}/>
                </ListItemAvatar>
                <ListItemText primary={auction.itemName} 
                  secondary={auctionState(auction}/>
                <ListItemSecondaryAction>
                    <Link to={"/auction/" + auction._id}>
                      <IconButton aria-label="View" color="primary">
                        <ViewIcon/>
                      </IconButton>
                    </Link>
                </ListItemSecondaryAction>
              </ListItem>
              <Divider/>
            </span>})}
        </List>
    )
}

对于每个拍卖项目,除了显示一些基本拍卖详情外,我们还为用户提供了一个选项,可以在单独的链接中打开每个拍卖。我们还条件性地渲染了诸如拍卖何时开始、竞标是否已经开始或结束、剩余时间有多少以及最新的出价是多少等详情。每个拍卖状态的这些详情是通过以下代码确定和渲染的。

mern-marketplace/client/auction/Auctions.js:

const currentDate = new Date()  
const auctionState = (auction)=>{
    return ( <span>
      {currentDate < new Date(auction.bidStart) && 
        `Auction Starts at ${new Date(auction.bidStart).toLocaleString()}`}
      {currentDate > new Date(auction.bidStart) && 
        currentDate < new Date(auction.bidEnd) && <> 
            {`Auction is live | ${auction.bids.length} bids |`} 
            {showTimeLeft(new Date(auction.bidEnd))}
          </>}
      {currentDate > new Date(auction.bidEnd) && 
            `Auction Ended | ${auction.bids.length} bids `} 
      {currentDate > new Date(auction.bidStart) && auction.bids.length> 0 && ` 
        | Last bid: $ ${auction.bids[0].bid}`}
      </span>
    )
}

为了计算和渲染已开始的拍卖的剩余时间,我们定义了一个showTimeLeft方法,它接受结束日期作为参数,并使用calculateTimeLeft方法来构建在视图中渲染的时间字符串。showTimeLeft方法定义如下。

mern-marketplace/client/auction/Auctions.js:

const showTimeLeft = (date) => {
    let timeLeft = calculateTimeLeft(date)
    return !timeLeft.timeEnd && <span>
      {timeLeft.days != 0 && `${timeLeft.days} d `} 
      {timeLeft.hours != 0 && `${timeLeft.hours} h `} 
      {timeLeft.minutes != 0 && `${timeLeft.minutes} m `} 
      {timeLeft.seconds != 0 && `${timeLeft.seconds} s`} left
    </span>
}

此方法使用calculateTimeLeft方法来确定剩余时间的日、时、分、秒的分解。

calculateTimeLeft方法接受结束日期并与当前日期进行比较,以计算差异并创建一个timeLeft对象,该对象记录剩余的天数、小时、分钟和秒,以及一个timeEnd状态。如果时间已结束,则将timeEnd状态设置为 true。calculateTimeLeft方法定义如下。

mern-marketplace/client/auction/Auctions.js:

const calculateTimeLeft = (date) => {
  const difference = date - new Date()
  let timeLeft = {}

  if (difference > 0) {
    timeLeft = {
      days: Math.floor(difference / (1000 * 60 * 60 * 24)),
      hours: Math.floor((difference / (1000 * 60 * 60)) % 24),
      minutes: Math.floor((difference / 1000 / 60) % 60),
      seconds: Math.floor((difference / 1000) % 60),
      timeEnd: false
    }
  } else {
      timeLeft = {timeEnd: true}
  }
  return timeLeft
}

Auctions组件渲染了包含每个拍卖的详情和状态的列表,可以添加到其他视图中,以显示不同的拍卖列表。如果当前查看拍卖列表的用户恰好是列表中某个拍卖的卖家,我们还想为此用户渲染编辑或删除拍卖的选项。在下一节中,我们将学习如何将这些选项整合到从市场编辑或删除拍卖中。

编辑和删除拍卖

市场中的卖家将能够通过编辑或删除他们创建的拍卖来管理他们的拍卖。编辑和删除功能的实现需要构建后端 API,这些 API 将保存对数据库的更改并从集合中删除一个拍卖。这些 API 将在前端视图中使用,以允许用户使用表单编辑拍卖详情,并通过按钮点击来启动删除操作。在接下来的章节中,我们将学习如何有条件地将这些选项添加到拍卖列表中,并讨论全栈实现以完成这些编辑和删除功能。

更新列表视图

我们将更新拍卖列表视图的代码,以有条件地显示编辑和删除选项给卖家。在Auctions组件中,该组件迭代列表以渲染ListItem中的每个项目,我们将在ListItemSecondaryAction组件中添加两个额外的选项,如下面的代码所示。

mern-marketplace/client/auction/Auctions.js:

<ListItemSecondaryAction>
    <Link to={"/auction/" + auction._id}>
       <IconButton aria-label="View" color="primary">
          <ViewIcon/>
       </IconButton>
    </Link>
 { auth.isAuthenticated().user && 
         auth.isAuthenticated().user._id == auction.seller._id &&
 (<>
 <Link to={"/auction/edit/" + auction._id}>
 <IconButton aria-label="Edit" color="primary">
 <Edit/>
 </IconButton>
 </Link>}
 <DeleteAuction auction={auction} onRemove={props.removeAuction}/>
 </>)
 }
</ListItemSecondaryAction>

如果当前登录用户的 ID 与拍卖卖家的 ID 匹配,则条件性地渲染编辑视图链接和删除组件。编辑视图组件和删除组件的实现与我们在第七章中讨论的 EditShop 组件和 DeleteShop 组件类似,即使用在线市场锻炼 MERN 技能。这些相同的组件将调用后端 API 来完成编辑和删除操作。我们将在下一节中查看所需的 API。

编辑和删除拍卖 API

为了完成前端发起的编辑拍卖和删除拍卖操作,我们需要在后端有相应的 API。这些 API 端点的路由,将接受更新和删除请求,可以声明如下。

mern-marketplace/server/routes/auction.routes.js:

router.route('/api/auctions/:auctionId')
  .put(authCtrl.requireSignin, auctionCtrl.isSeller, auctionCtrl.update)
  .delete(authCtrl.requireSignin, auctionCtrl.isSeller, auctionCtrl.remove)
router.param('auctionId', auctionCtrl.auctionByID)

/api/auctions/:auctionId 路由 URL 中的 :auctionId 参数将调用 auctionByID 控制器方法,该方法与 userByID 控制器方法类似。它从数据库中检索拍卖并将其附加到请求对象中,以便在 next 方法中使用。auctionByID 方法定义如下。

mern-marketplace/server/controllers/auction.controller.js:

const auctionByID = async (req, res, next, id) => {
  try {
    let auction = await Auction.findById(id)
                               .populate('seller', '_id name')
                               .populate('bids.bidder', '_id name').exec()
    if (!auction)
      return res.status('400').json({
        error: "Auction not found"
      })
    req.auction = auction
    next()
  } catch (err) {
    return res.status('400').json({
      error: "Could not retrieve auction"
    })
  }
}

从数据库检索的拍卖对象还将包含卖家和竞标者的名称和 ID 详细信息,正如我们在 populate() 方法中指定的。对于这些 API 端点,使用 auction 对象来验证当前登录用户是否是创建此特定拍卖的卖家,通过调用在拍卖控制器中定义的 isSeller 方法。

mern-marketplace/server/controllers/auction.controller.js:

const isSeller = (req, res, next) => {
  const isSeller = req.auction && req.auth && req.auction.seller._id == req.auth._id
  if(!isSeller){
    return res.status('403').json({
      error: "User is not authorized"
    })
  }
  next()
}

一旦卖家被验证,next 方法将被调用以更新或删除拍卖,具体取决于是否收到了 PUT 或 DELETE 请求。更新和删除拍卖的控制器方法与我们在第七章中讨论的编辑商店 API 和删除商店 API 的先前实现类似,即使用在线市场锻炼 MERN 技能。这些相同的组件将调用后端 API 来完成编辑和删除操作。我们将在下一节中查看所需的 API。

我们已经准备好了市场使用的拍卖模块,包括用于存储拍卖和竞标数据的拍卖模型,以及用于创建新拍卖、显示不同拍卖列表和修改现有拍卖的后端 API 和前端视图。在下一节中,我们将进一步扩展此模块并实现单个拍卖的视图,用户不仅可以了解更多关于拍卖的信息,还可以看到实时的竞标更新。

显示拍卖视图

显示单个拍卖的视图将包含市场实时拍卖和竞标功能的核心功能。在进入实时竞标的实现之前,我们将设置一个全栈切片来检索单个拍卖的详细信息,并在一个将包含拍卖显示、计时器和竞标功能的 React 组件中显示这些详细信息。在接下来的章节中,我们将首先讨论用于获取单个拍卖的后端 API。然后,我们将查看 Auction 组件的实现,该组件将使用此 API 检索并显示拍卖详情以及拍卖的状态。为了给用户提供拍卖状态的实时更新,我们还将在此视图中实现一个计时器,以指示距离现场拍卖结束的时间。

读取拍卖 API

要在单独的视图中显示现有拍卖的详细信息,我们需要添加一个后端 API,该 API 将接收来自客户端的拍卖请求并返回其详细信息。因此,我们将在后端实现一个读取拍卖 API,该 API 将接受一个带有指定拍卖 ID 的 GET 请求,并从数据库中的Auction集合返回相应的拍卖文档。我们将通过声明以下代码中的 GET 路由来开始添加此 API 端点。

mern-marketplace/server/routes/auction.routes.js:

router.route('/api/auction/:auctionId')
  .get(auctionCtrl.read)

路由 URL 中的:auctionId参数在接收到此路由的 GET 请求时调用auctionByID控制器方法。auctionByID控制器方法从数据库中检索拍卖并将其附加到请求对象,以便在read控制器方法中访问,该方法随后被调用。返回此拍卖对象以响应客户端的read控制器方法定义如下。

mern-marketplace/server/controllers/auction.controller.js:

const read = (req, res) => {
  req.auction.image = undefined
  return res.json(req.auction)
}

在发送响应之前,我们将移除图像字段,因为图像将通过单独的路由作为文件检索。有了这个后端 API 就绪,我们现在可以在前端通过在api-auction.js中添加一个 fetch 方法来添加调用它的实现,类似于我们讨论的其他用于完成 API 实现的 fetch 方法。我们将使用 fetch 方法在 React 组件中调用读取拍卖 API,该组件将渲染检索到的拍卖详情。该 React 组件的实现将在下一节中讨论。

拍卖组件

我们将实现一个 Auction 组件来从后端获取并显示单个拍卖的详细信息给最终用户。此视图还将具有基于拍卖当前状态和查看页面的用户是否已登录的实时更新功能。例如,以下截图显示了当给定拍卖尚未开始时,Auction 组件如何渲染给访客。它仅显示拍卖的描述详情并指定拍卖开始的时间:

Auction 组件的实现将通过调用 useEffect 钩子中的读取拍卖 API 来检索拍卖详情。这部分组件实现与我们在第七章 使用在线市场锻炼 MERN 技能中讨论的 Shop 组件类似。

完成的 Auction 组件将通过浏览器中的 /auction/:auctionId 路由访问,该路由在 MainRouter 中定义如下。

mern-marketplace/client/MainRouter.js:

<Route path="/auction/:auctionId" component={Auction}/>

此路由可以用于任何组件来链接到特定的拍卖,就像我们在拍卖列表中所做的那样。此链接将用户带到带有加载的拍卖详情的相应拍卖视图。

在组件视图中,我们将通过考虑当前日期和给定拍卖的竞标开始和结束时间来渲染拍卖状态。可以添加以下代码来生成这些状态,这些状态将在视图中显示。

mern-marketplace/client/auction/Auction.js:

const currentDate = new Date()
...
<span>
    {currentDate < new Date(auction.bidStart) && 'Auction Not Started'}
    {currentDate > new Date(auction.bidStart) && currentDate < new Date(auction.bidEnd) && 'Auction Live'}
    {currentDate > new Date(auction.bidEnd) && 'Auction Ended'}
</span>

在前面的代码中,如果当前日期早于 bidStart 日期,我们将显示一条消息,表明拍卖尚未开始。如果当前日期在 bidStartbidEnd 日期之间,则拍卖正在进行。如果当前日期晚于 bidEnd 日期,则拍卖已结束。

Auction 组件还将根据当前用户是否已登录以及拍卖当前状态,有条件地渲染计时器和竞标部分。渲染这部分视图的代码如下。

mern-marketplace/client/auction/Auction.js:

<Grid item xs={7} sm={7}>
    {currentDate > new Date(auction.bidStart) 
    ? (<>
        <Timer endTime={auction.bidEnd} update={update}/> 
        { auction.bids.length > 0 && 
            <Typography component="p" variant="subtitle1">
                {` Last bid: $ ${auction.bids[0].bid}`}
            </Typography>
        }
        { !auth.isAuthenticated() && 
            <Typography>
                Please, <Link to='/signin'>
                    sign in</Link> to place your  bid.
            </Typography> 
        }
        { auth.isAuthenticated() && 
            <Bidding auction={auction} justEnded=
                 {justEnded} updateBids={updateBids}/> 
        }
      </>)
    : <Typography component="p" variant="h6"> 
        {`Auction Starts at ${new Date(auction.bidStart).toLocaleString()}`}
      </Typography>
    }
</Grid>

如果当前日期恰好晚于竞标开始时间,我们将显示计时器组件来显示竞标结束前剩余的时间。然后,我们显示最后出价金额,如果已经放置了一些出价,这将是在拍卖 bids 数组中的第一个项目。如果当前用户在拍卖处于此状态时已登录,我们还将渲染一个 Bidding 组件,允许他们出价并查看竞标历史。在下一节中,我们将学习如何实现我们在此视图中添加的计时器组件,以显示拍卖剩余时间。

添加计时器组件

当拍卖正在进行时,我们将向用户提供关于他们在此特定拍卖中竞标结束前剩余时间的实时更新。我们将实现一个 Timer 组件,并在 Auction 组件中有条件地渲染它以实现此功能。计时器将倒计时秒数,并显示观看直播拍卖的用户剩余时间。以下截图显示了当 Auction 组件向尚未登录的用户渲染直播拍卖时的外观:

当用户查看实时拍卖时,剩余时间每秒减少。我们将在 Timer 组件中实现这个倒计时功能,该组件被添加到 Auction 组件中。Auction 组件提供包含拍卖结束时间值的 props,以及一个在时间结束时更新拍卖视图的函数,如下面的代码所示。

mern-marketplace/client/auction/Auction.js:

<Timer endTime={auction.bidEnd} update={update}/> 

传递给 Timer 组件的 update 函数将帮助将 justEnded 变量的值从 false 设置为 true。这个 justEnded 值传递给 Bidding 组件,以便在时间结束时禁用下注选项。justEnded 值的初始化和 update 函数的定义如下。

mern-marketplace/client/auction/Auction.js:

const [justEnded, setJustEnded] = useState(false)
const updateBids = () => {
    setJustEnded(true)
}

这些 props 将在 Timer 组件中使用,以计算剩余时间并在时间结束时更新视图。

Timer 组件定义中,我们将使用从 Auction 组件传入的 props 中的结束时间值初始化 timeLeft 变量,如下面的代码所示。

mern-marketplace/client/auction/Timer.js:

export default function Timer (props) {
    const [timeLeft, setTimeLeft] = useState(calculateTimeLeft(new Date(props.endTime)))
    ...
}

为了计算距离拍卖结束的剩余时间,我们利用本章 拍卖组件 部分中讨论过的 calculateTimeLeft 方法。

为了实现倒计时功能,我们将在 Timer 组件的 useEffect 钩子中使用 setTimeout,如下面的代码所示。

mern-marketplace/client/auction/Timer.js:

useEffect(() => {
     let timer = null
     if(!timeLeft.timeEnd){
         timer = setTimeout(() => {
                     setTimeLeft(calculateTimeLeft(new Date(props.endTime)))
                 }, 1000)
     }else{
         props.update()
     }
     return () => {
         clearTimeout(timer)
    }
})

如果时间还没有结束,我们将使用 setTimeout 在 1 秒后更新 timeLeft 值。这个 useEffect 钩子将在每次由 setTimeLeft 状态更新引起的渲染后运行。

因此,timeLeft 值将每秒更新,直到 timeEnd 值变为 true。当 timeEnd 值确实变为 true,即时间到了,我们将执行从 Auctions 组件传入的 update 函数。

为了避免内存泄漏并在 useEffect 钩子中进行清理,我们将使用 clearTimeout 停止任何挂起的 setTimeout 调用。为了显示这个更新的 timeLeft 值,我们只需在视图中渲染它,如下面的代码所示。

mern-marketplace/client/auction/Timer.js:

    return (<div className={props.style}>
        {!timeLeft.timeEnd ? 
            <Typography component="p" variant="h6" >
              {timeLeft.days != 0 && `${timeLeft.days} d `} 
              {timeLeft.hours != 0 && `${timeLeft.hours} h `} 
              {timeLeft.minutes != 0 && `${timeLeft.minutes} m `} 
              {timeLeft.seconds != 0 && `${timeLeft.seconds} s`} left 
              <span style={{fontSize:'0.8em'}}>
                {`(ends at ${new Date(props.endTime).toLocaleString()})`}
              </span>
            </Typography> : 
            <Typography component="p" variant="h6">Auction ended</Typography>
        }
        </div>
    )

如果还有时间剩余,我们将使用 timeLeft 对象渲染距离拍卖结束的剩余天数、小时、分钟和秒。我们还会指出拍卖结束的确切日期和时间。如果时间到了,我们只需指出拍卖已经结束。

在我们迄今为止实现的Auction组件中,我们能够从后端获取拍卖详情并将其与拍卖状态一起渲染。如果一个拍卖处于直播状态,我们能够显示剩余时间直到结束。当拍卖处于这种直播状态时,用户也将能够对拍卖进行出价,并实时看到其他用户在平台上的出价。在下一节中,我们将讨论如何使用 Socket.IO 集成平台所有直播拍卖的实时竞标功能。

使用 Socket.IO 实现实时竞标

已登录市场平台的用户将能够参与直播拍卖。他们可以在同一视图中进行出价并获得实时更新,同时看到平台上的其他用户正在对他们的出价进行回应。为了实现这一功能,我们将在实现前端界面以允许用户出价并查看变化的出价历史之前,将 Socket.IO 集成到我们的全栈 MERN 应用中。

集成 Socket.IO

Socket.IO 将允许我们在市场应用中添加实时竞标功能。Socket.IO 是一个 JavaScript 库,包含一个在浏览器中运行的客户端模块和一个与 Node.js 集成的服务器端模块。将这些模块集成到我们的 MERN 应用中,将使客户端和服务器之间实现双向和实时通信。

Socket.IO 的客户端部分作为 Node 模块socket.io-client提供,而服务器端部分作为 Node 模块socket.io提供。您可以在socket.io了解更多关于 Socket.IO 的信息,并尝试他们的入门教程。

在我们可以在代码中使用socket.io之前,我们将通过在命令行中运行以下命令使用 Yarn 安装客户端和服务器库:

yarn add socket.io socket.io-client

将 Socket.IO 库添加到项目后,我们将更新我们的后端以将 Socket.IO 集成到服务器代码中。我们需要使用与我们的应用程序相同的 HTTP 服务器初始化一个新的socket.io实例。

在我们的后端代码中,我们使用 Express 启动服务器。因此,我们将更新server.js中的代码以获取我们的 Express 应用使用的 HTTP 服务器引用,如下面的代码所示。

mern-marketplace/server/server.js:

import bidding from './controllers/bidding.controller'

const server = app.listen(config.port, (err) => {
  if (err) {
    console.log(err)
  }
  console.info('Server started on port %s.', config.port)
})

bidding(server)

然后,我们将把这个服务器的引用传递给一个竞标控制器函数。这个bidding.controller函数将包含在服务器端实现实时功能所需的 Socket.IO 代码。bidding.controller函数将初始化socket.io,然后监听connection事件以接收来自客户端的 socket 消息,如下面的代码所示。

mern-marketplace/server/controllers/bidding.controller.js:

export default (server) => {
    const io = require('socket.io').listen(server)
    io.on('connection', function(socket){
        socket.on('join auction room', data => {
            socket.join(data.room);
        })
        socket.on('leave auction room', data => {
            socket.leave(data.room)
        })
    })
}

当新的客户端首次连接然后断开套接字连接时,我们将订阅和取消订阅客户端套接字到一个给定的频道。该频道将由客户端通过data.room属性传递的拍卖 ID 来识别。这样,我们将为每个拍卖有一个不同的频道或房间。

使用此代码,后端已准备好接收客户端通过套接字发送的通信,我们现在可以将 Socket.IO 集成到我们的前端。在前端,只有拍卖视图——特别是投标部分——将使用套接字进行实时通信。因此,我们只将在前端添加到拍卖组件的Bidding组件中集成 Socket.IO,如下面的代码所示。

mern-marketplace/client/auction/Auction.js:

<Bidding auction={auction} justEnded={justEnded} updateBids={updateBids}/>

投标组件从拍卖组件接收auction对象、justEnded值和updateBids函数作为属性,并在投标过程中使用这些属性。为了开始实现投标组件,我们将使用 Socket.IO 客户端库集成套接字,如下面的代码所示。

mern-marketplace/client/auction/Bidding.js:

const io = require('socket.io-client')
const socket = io() 

export default function Bidding (props) {
    useEffect(() => {
        socket.emit('join auction room', {room: props.auction._id})
        return () => {
            socket.emit('leave auction room', {
                room: props.auction._id
            })
        }
    }, [])
    ...
}

在前面的代码中,我们引入了socket.io-client库并初始化了socket为此客户端。然后,在我们的Bidding组件定义中,我们使用useEffect钩子和初始化的socket在组件挂载和卸载时分别发出auction room joiningauction room leaving套接字事件。我们通过这些发出的套接字事件传递当前拍卖的 ID 作为data.room值。

这些事件将由服务器套接字连接接收,导致客户端订阅或取消订阅给定的拍卖房间。现在,客户端和服务器能够通过套接字进行实时通信,在下一节中,我们将学习如何使用这种能力让用户对拍卖进行即时投标。

放置投标

当平台上的用户登录并查看当前正在进行的拍卖时,他们将看到一个选项来放置自己的投标。此选项将在Bidding组件中渲染,如下面的截图所示:

为了允许用户放置他们的投标,在接下来的章节中,我们将添加一个表单,让他们输入一个高于上一个投标的值,并通过套接字通信将其提交到服务器。然后,在服务器上,我们将处理通过套接字发送的新投标,以便将更改后的拍卖投标保存到数据库中,并在服务器接受此投标时立即更新所有连接用户的视图。

添加投标表单

我们将在上一节开始构建的Bidding组件中添加一个用于拍卖竞标的表单。在我们向视图中添加表单元素之前,我们将初始化状态中的bid值,为表单输入添加一个变更处理函数,并跟踪允许的最小竞标金额,如下面的代码所示。

mern-marketplace/client/auction/Bidding.js:

const [bid, setBid] = useState('')

const handleChange = event => {
        setBid(event.target.value)
}
const minBid = props.auction.bids && props.auction.bids.length> 0 
                ? props.auction.bids[0].bid 
                : props.auction.startingBid

最小竞标金额是通过检查最新放置的竞标来确定的。如果有竞标被放置,最小竞标金额需要高于最新的竞标;否则,它需要高于拍卖卖家设定的起始竞标金额。

放置竞标的表单元素只有在当前日期早于拍卖结束日期时才会渲染。我们还检查justEnded值是否为false,以便当计时器倒计时到 0 时,表单可以实时隐藏。表单元素将包含一个输入字段,提示应输入的最小金额,以及一个提交按钮,除非输入了有效的竞标金额,否则该按钮将保持禁用状态。以下是将这些元素添加到Bidding组件视图中的方式。

mern-marketplace/client/auction/Bidding.js:

{!props.justEnded && new Date() < new Date(props.auction.bidEnd) && <>
    <TextField label="Your Bid ($)" 
               value={bid} onChange={handleChange} 
               type="number" margin="normal"
               helperText={`Enter $${Number(minBid)+1} or more`}/><br/>
    <Button variant="contained" color="secondary" 
            disabled={bid < (minBid + 1)} 
            onClick={placeBid}>Place Bid
    </Button><br/>
</>}

当用户点击提交按钮时,将调用placeBid函数。在这个函数中,我们构建一个包含新竞标详情的竞标对象,包括竞标金额、竞标时间和竞标者的用户引用。这个新的竞标将通过为这个拍卖室已经建立的套接字通信发送到服务器,如下面的代码所示:

const placeBid = () => {
    const jwt = auth.isAuthenticated()    
      let newBid = {
            bid: bid,
            time: new Date(),
            bidder: jwt.user
      }
      socket.emit('new bid', {
            room: props.auction._id,
            bidInfo: newBid
      })
      setBid('')
}

一旦消息通过套接字发送出去,我们将使用setBid('')清空输入字段。然后,我们需要更新后端中的竞标控制器以接收和处理从客户端发送的这条新的竞标消息。在下一节中,我们将添加套接字事件处理代码以完成放置竞标的整个过程。

服务器接收竞标

当用户通过套接字连接放置新的竞标并发出后,它将在服务器上被处理,以便存储在数据库中相应的拍卖中。

在竞标控制器中,我们将更新套接字连接监听器代码中的套接字事件处理器,以添加一个用于新竞标套接字消息的处理程序,如下面的代码所示。

mern-marketplace/server/controllers/bidding.controller.js:

io.on('connection', function(socket){
    ...
    socket.on('new bid', data => {
        bid(data.bidInfo, data.room)
    })
})

在前面的代码中,当套接字接收到发出的新竞标消息时,我们使用附加的数据在名为bid的函数中更新指定的拍卖,以包含新的竞标信息。竞标函数定义如下。

mern-marketplace/server/controllers/bidding.controller.js:

const bid = async (bid, auction) => {
   try {
     let result = await Auction.findOneAndUpdate({_id:auction, $or: [{'bids.0.bid':{$lt:bid.bid}},{bids:{$eq:[]}} ]}, 
                            {$push: {bids: {$each:[bid], $position: 0}}}, 
                            {new: true})
                            .populate('bids.bidder', '_id name')
                            .populate('seller', '_id name')
                            .exec()
     io.to(auction).emit('new bid', result)
   } catch(err) {
     console.log(err)
   }
}

投标函数接受新的投标详情和拍卖 ID 作为参数,并在拍卖集合上执行findOneAndUpdate操作。为了找到要更新的拍卖,除了使用拍卖 ID 进行查询外,我们还确保新的投标金额大于此拍卖文档中bids数组在位置0的最后一个投标。如果找到与提供的 ID 匹配且满足最后一个投标小于新投标这一条件的拍卖,则通过将新投标推入bids数组的第一个位置来更新此拍卖。

在数据库中对拍卖进行更新后,我们通过socket.io连接向所有当前连接到相应拍卖房间的客户端发出新投标消息。在客户端,我们需要在 socket 事件处理程序代码中捕获此消息,并使用最新投标更新视图。在下一节中,我们将学习如何处理和显示所有查看实时拍卖的客户端的更新投标列表。

显示变化的投标历史

服务器接受新的投标并将其存储在数据库后,新的投标数组将在所有当前位于拍卖页面的客户端的视图中更新。在接下来的几节中,我们将扩展Bidding组件,使其能够处理更新的投标并显示给定拍卖的完整投标历史。

使用新投标更新视图状态

一旦服务器上处理了放置的投标,包含修改后的投标数组的更新拍卖将被发送到所有连接到拍卖房间的客户端。为了在客户端处理这些新数据,我们需要更新Bidding组件以添加对特定 socket 消息的监听器。

我们将使用useEffect钩子将此 socket 监听器添加到Bidding组件加载和渲染时。我们还将使用useEffect的清理中的socket.off()移除监听器。这个带有 socket 监听器以接收新投标数据的useEffect钩子将按以下方式添加。

mern-marketplace/client/auction/Bidding.js:

useEffect(() => {
   socket.on('new bid', payload => {
     props.updateBids(payload)
   })
   return () => {
     socket.off('new bid')
   }
})

当从服务器通过 socket 事件接收到新的带有更新投标的拍卖时,我们执行作为Auction组件属性发送的updateBids函数。updateBids函数在Auction组件中定义如下:

const updateBids = (updatedAuction) => {
    setAuction(updatedAuction)
}

这将更新设置在拍卖组件状态中的拍卖数据,并因此使用更新的拍卖数据重新渲染完整的拍卖视图。此视图还将包括投标历史表,我们将在下一节中讨论。

渲染投标历史

Bidding组件中,我们将渲染一个表格,显示给定拍卖的所有已放置的竞价的详情。这将通知用户已经放置的竞价以及他们正在实时查看直播拍卖时正在放置的竞价。拍卖的竞价历史将在视图中如下渲染:

图片

这个竞价历史视图将基本上遍历该拍卖的bids数组,并显示每个在数组中找到的竞价对象的竞价金额、竞价时间和竞价者姓名。渲染此表格视图的代码将如下添加:

<div>
   <Typography variant="h6"> All bids </Typography>
   <Grid container spacing={4}>
       <Grid item xs={3} sm={3}>
            <Typography variant="subtitle1" 
               color="primary">Bid Amount</Typography>
       </Grid>
       <Grid item xs={5} sm={5}>
            <Typography variant="subtitle1" 
                color="primary">Bid Time</Typography>
       </Grid>
       <Grid item xs={4} sm={4}>
          <Typography variant="subtitle1"
             color="primary">Bidder</Typography>
       </Grid>
   </Grid> 
   {props.auction.bids.map((item, index) => {
       return <Grid container spacing={4} key={index}>
                 <Grid item xs={3} sm={3}>
                    <Typography variant="body2">${item.bid}  </Typography>
                 </Grid>
                 <Grid item xs={5} sm={5}>
                    <Typography variant="body2">
                        {new Date(item.time).toLocaleString()}
                    </Typography></Grid>
                 <Grid item xs={4} sm={4}>
                    <Typography variant="body2">{item.bidder.name} </Typography>
                 </Grid>
              </Grid>
   })}
</div>

我们使用 Material-UI Grid组件添加了表头,然后在遍历bids数组以生成具有单个竞价详情的表格行之前。

当任何查看此拍卖的用户提交新的竞价,并且更新的拍卖在 socket 中接收并设置为状态时,这个包含竞价历史的表格将更新,并显示表格顶部的最新竞价。通过这种方式,它为拍卖室中的所有用户提供了竞价的实时更新。有了这个,我们就将完整的拍卖和实时竞价功能集成到了 MERN Marketplace 应用程序中。

摘要

在本章中,我们扩展了 MERN Marketplace 应用程序,并添加了一个具有实时竞价功能的拍卖特性。我们设计了一个用于存储拍卖和竞价详情的拍卖模型,并实现了全栈 CRUD 功能,允许用户创建新的拍卖、编辑和删除拍卖,以及查看不同的拍卖列表,包括单个拍卖的详情。

我们添加了一个表示单个拍卖的拍卖视图,用户可以在此视图中观看并参与拍卖。在视图中,我们计算并渲染了给定拍卖的当前状态,以及直播拍卖的倒计时计时器。在实现这个倒计时秒数计时器时,我们学习了如何在 React 组件中使用setTimeoutuseEffect钩子。

对于每个拍卖,我们使用了 Socket.IO 实现了实时竞价功能。我们讨论了如何在应用程序的客户端和服务器端集成 Socket.IO,以建立客户端和服务器之间的实时双向通信。通过这些方法扩展 MERN 堆栈以包含实时通信功能,你可以在自己的全栈应用程序中实现更多令人兴奋的实时功能,使用 socket 进行。

通过在这里构建 MERN Marketplace 应用程序的不同功能所获得的经验,你还可以扩展本章中涵盖的拍卖特性,并将其与该应用程序中现有的订单管理和支付处理功能集成。

在下一章中,我们将通过扩展 MERN 骨架构建一个带有数据可视化功能的支出跟踪应用程序,以扩展我们的 MERN 堆栈技术选项。

第十三章:进阶至复杂 MERN 应用

在本部分,我们探讨如何实现具有高级和复杂功能的 MERN 应用,包括数据可视化、媒体流和 VR 功能。

本节包括以下章节:

  • 第十章, 将数据可视化与支出跟踪应用集成

  • 第十一章, 构建媒体流应用

  • 第十二章, 定制媒体播放器并优化 SEO

  • 第十三章, 开发基于 Web 的 VR 游戏

  • 第十四章, 使用 MERN 使 VR 游戏动态化

第十四章:将数据可视化集成到支出跟踪应用程序中

这些天,收集和添加数据到互联网上的应用程序变得很容易。随着越来越多的数据变得可用,处理数据并将从这些数据中提取的见解以有意义和吸引人的可视化形式呈现给最终用户变得必要。在本章中,我们将学习如何使用 MERN 堆栈技术以及 Victory——一个用于 React 的图表库,以便轻松地将数据可视化功能集成到全栈应用程序中。我们将扩展 MERN 骨架应用程序来构建支出跟踪应用程序,该应用程序将包含用户随时间记录的支出数据的数据处理和可视化功能。

在了解了这些功能的实现之后,您应该掌握了如何利用 MongoDB 聚合框架和 Victory 图表库将您选择的数据可视化功能添加到任何全栈 MERN 网络应用程序中。

在本章中,我们将通过以下主题来构建一个集成了数据可视化功能的支出跟踪应用程序:

  • 介绍 MERN 支出跟踪器

  • 添加支出记录

  • 随时间可视化支出数据

介绍 MERN 支出跟踪器

MERN 支出跟踪器应用程序将允许用户跟踪他们的日常支出。登录账户的用户将能够添加他们的支出记录,包括支出描述、类别、金额以及给定支出发生或支付的时间。应用程序将存储这些支出记录并提取有意义的数据模式,以使用户能够看到他们的支出习惯随时间如何发展。以下截图显示了 MERN 支出跟踪器应用程序上登录用户的首页视图,并提供了用户当前月份支出的概述:

图片

完整的 MERN 支出跟踪器应用程序代码可在 GitHub 上找到:github.com/PacktPublishing/Full-Stack-React-Projects-Second-Edition/tree/master/Chapter10/mern-expense-tracker。您可以将此代码克隆并运行,在阅读本章剩余部分的代码解释时,您可以运行应用程序。

在本章中,我们将扩展 MERN 骨架以构建具有数据可视化功能的支出跟踪应用程序。这些支出跟踪和可视化功能所需的视图将通过扩展和修改 MERN 骨架应用程序中现有的 React 组件来开发。以下截图显示了本章中开发的 MERN 支出跟踪器前端的所有自定义 React 组件的组件树:

图片

我们将添加新的 React 组件来实现创建费用记录、列出和修改已记录的费用以及显示报告的视图,这些报告可以提供用户随时间产生的费用的洞察。我们还将修改现有的组件,如 Home 组件,以渲染用户当前费用的概览。在我们能够实现用户费用数据的可视化之前,我们需要首先添加记录日常费用的功能。在下一节中,我们将讨论如何实现这个功能,允许已登录的用户在应用程序中创建和修改他们的费用记录。

添加费用记录

在 MERN 费用追踪器应用程序中,已登录的用户将能够创建和管理他们的费用记录。为了启用添加和管理费用记录的功能,我们需要定义如何存储费用详情,并实现全栈切片,使用户能够创建新的费用、查看这些费用以及更新或删除应用程序中的现有费用。

在以下章节中,首先,我们将使用 Mongoose 模式定义费用模型以存储每个费用记录的详情。然后,我们将讨论实现后端 API 和前端视图的方法,这些方法允许用户创建新的费用、查看他们的费用列表以及通过编辑费用详情或从应用程序中删除费用来修改现有费用。

定义费用模型

我们将实现一个 Mongoose 模型来定义一个用于存储每个费用记录详情的费用模型。此模型将在server/models/expense.model.js中定义,其实现将与之前章节中覆盖的其他 Mongoose 模型实现类似,例如在第六章中定义的课程模型,构建基于 Web 的教室应用程序。此模型中的费用模式将具有简单的字段来存储关于每个费用的详情,例如标题、金额、类别以及费用发生时的日期,以及一个指向创建记录的用户引用。定义费用字段的代码及其解释如下:

  • 费用标题title字段将描述费用。它被声明为String类型,并且是一个必填字段:
title: { 
    type: String, 
    trim: true, 
    required: 'Title is required' 
},
  • 费用金额amount字段将存储费用的货币成本,作为Number类型的值,并且它将是一个必填字段,最小允许值为 0:
amount: { 
    type: Number, 
    min: 0,
    required: 'Amount is required' 
},
  • 费用类别category字段将定义费用类型,以便可以根据此值对费用进行分组。它被声明为String类型,并且是一个必填字段:
category: {
    type: String,
    trim: true,
    required: 'Category is required'
},
  • 发生日期incurred_on字段将存储费用发生或支付时的日期时间。它被声明为Date类型,如果没有提供值,则默认为当前日期时间:
incurred_on: {
    type: Date,
    default: Date.now
},
  • 注意事项notes字段,定义为String类型,将允许记录给定费用记录的额外详细信息或备注:
notes: {
    type: String,
    trim: true
},
  • 记录费用的人recorded_by字段将引用创建费用记录的用户:
recorded_by: {
    type: mongoose.Schema.ObjectId, 
    ref: 'User'
}
  • 创建和更新时间createdupdated字段将是Date类型,created字段在添加新费用时生成,而updated字段在修改任何费用详情时更改:
updated: Date,
created: { 
    type: Date, 
    default: Date.now 
},

添加到此模式定义中的字段将使我们能够实现 MERN 费用追踪器中的所有费用相关功能。在下一节中,我们将通过实现允许用户创建新费用记录的全栈切片来开始开发这些功能。

创建新的费用记录

为了在应用程序中创建新的费用记录,我们需要集成一个全栈切片,允许用户在前端填写表单视图,然后在后端将输入的详细信息保存到数据库中的新费用文档中。为了实现此功能,在以下章节中,我们将添加一个创建费用 API,以及在前端获取此 API 的方法,以及一个用于获取费用详情的用户输入的新费用表单视图。

创建费用 API

为了实现允许在数据库中创建新费用的创建费用 API,我们首先添加一个POST路由,如下所示。

mern-expense-tracker/server/routes/expense.routes.js

router.route('/api/expenses')
  .post(authCtrl.requireSignin, expenseCtrl.create)

/api/expenses路由发送POST请求将首先确保请求用户已通过auth控制器中的requireSignin方法登录,然后调用create方法在数据库中添加新的费用记录。此create方法在以下代码中定义。

mern-expense-tracker/server/controllers/expense.controller.js

const create = async (req, res) =  {
  try {
    req.body.recorded_by = req.auth._id
    const expense = new Expense(req.body)
    await expense.save()
    return res.status(200).json({
      message: "Expense recorded!"
    })
  } catch (err) {
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

在这个create方法中,我们将recorded_by字段设置为当前登录的用户,然后使用请求体中提供的费用数据在数据库中的费用集合中保存新的费用。

包含费用路由的expense.routes.js文件将与user.routes文件非常相似。为了在 Express 应用中加载这些新的费用路由,我们需要在express.js中挂载费用路由,如下所示,就像我们为 auth 和用户路由所做的那样。

mern-expense-tracker/server/express.js

app.use('/', expenseRoutes)

这个创建费用 API 端点现在已在后端准备好,可以在前端使用以发送POST请求。为了在前端获取此 API,我们将在api-expense.js中添加相应的create方法,类似于我们在前几章中讨论的其他 API 实现,例如来自第九章的创建一个新的拍卖部分,向市场添加实时竞标功能

此获取方法将在前端组件中使用,该组件将显示一个表单,用户可以在其中输入新费用的详细信息并将其保存到应用程序中。在下一节中,我们将实现一个 React 组件,该组件将渲染用于记录新费用的表单。

新增费用组件

在此费用跟踪应用程序上签到的用户将通过表单视图来输入新的费用记录的详细信息。此表单视图将在NewExpense组件中渲染,这将使用户能够通过输入费用标题、花费金额、费用类别、费用发生的时间以及任何附加说明来创建新的费用。

此表单将呈现如下:

截图

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

截图

为了实现此表单的日期时间选择器,我们将使用 Material-UI Pickers 以及一个日期管理库。在我们能够集成这些库之前,我们首先需要通过在命令行运行以下yarn命令来安装以下 Material-UI Pickers 和date-fns模块:

yarn add @material-ui/pickers @date-io/date-fns@1.x date-fns

一旦安装了这些模块,我们就可以在NewExpense组件中导入所需的组件和模块,并将日期时间选择器小部件添加到表单中,如下面的代码所示。

mern-expense-tracker/client/expense/NewExpense.js

import DateFnsUtils from '@date-io/date-fns'
import { DateTimePicker, MuiPickersUtilsProvider} from "@material-ui/pickers"
...
 <MuiPickersUtilsProvider utils={DateFnsUtils}>
        <DateTimePicker
           label="Incurred on"
           views={["year", "month", "date"]}
           value={values.incurred_on}
           onChange={handleDateChange}
           showTodayButton
       /> 
 </MuiPickersUtilsProvider> 

此小部件将渲染选择年、月、日和时间的选项,以及一个设置当前时间为选定值的 TODAY 按钮。当用户完成日期时间的选择后,我们将使用handleDateChange方法捕获值,并将其与其他从表单收集的费用相关值一起设置到状态中。handleDateChange方法定义如下。

mern-expense-tracker/client/expense/NewExpense.js

  const handleDateChange = date =  {
    setValues({...values, incurred_on: date })
  }

使用此功能,我们将为新的费用记录中的incurred_on字段设置一个date值。

NewExpense组件只能由已签到的用户查看。因此,我们将在MainRouter组件中添加一个PrivateRoute,这样只有在/expenses/new路径的认证用户才能渲染此表单。

mern-expense-tracker/client/MainRouter.js

 PrivateRoute path="/expenses/new" component={NewExpense}/ 

此链接可以添加到任何视图中,例如菜单组件,当用户登录时条件性地渲染。现在,由于可以在本费用跟踪应用程序中添加新的费用记录,在下一节中,我们将讨论从后端到前端视图的实现,以获取和列出这些费用。

列出费用

在 MERN 费用跟踪器中,用户将能够查看他们在应用程序中已记录并在提供的日期范围内产生的费用列表。在以下各节中,我们将通过实现后端 API 来检索当前已登录用户记录的费用列表,并添加一个前端视图,该视图将使用此 API 将返回的费用列表渲染给最终用户。

用户费用 API

我们将实现一个 API 来获取特定用户在提供的日期范围内记录的费用。对此 API 的请求将在'/api/expenses'接收,路由在expense.routes.js中定义如下。

mern-expense-tracker/server/routes/expense.routes.js:

router.route('/api/expenses')
  .get(authCtrl.requireSignin, expenseCtrl.listByUser)

对此路由的GET请求将首先确保请求用户已登录,然后调用控制器方法从数据库中检索费用。在这个应用程序中,用户只能查看他们自己的费用。在用户身份验证确认后,在listByUser控制器方法中,我们使用请求中指定的日期范围和已登录用户的 ID 在数据库中查询 Expense 集合。listByUser方法在以下代码中定义。

mern-expense-tracker/server/controllers/expense.controller.js:

const listByUser = async (req, res) =  {
  let firstDay = req.query.firstDay
  let lastDay = req.query.lastDay
  try {
    let expenses = await Expense.find({'$and': {'incurred_on': 
       { '$gte': firstDay, '$lte':lastDay }}, 
           {'recorded_by': req.auth._id } }).sort('incurred_on')
             .populate('recorded_by', '_id name')
    res.json(expenses)
  } catch (err){
    console.log(err)
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

在这种方法中,我们首先收集请求查询中指定的日期范围的起始日和最后一天。然后,我们从数据库中检索在此日期范围内由已登录用户产生的费用。已登录用户与recorded _by字段中引用的用户进行匹配。使用这些值对 Expense 集合执行find查询将返回按incurred_on字段排序的匹配费用,最近产生的费用将列在前面。

用于检索特定用户记录的费用 API 可以在前端使用,以检索和向最终用户显示费用。为了在前端获取此 API,我们将在api-expense.js中添加相应的listByUser方法,如下所示。

mern-expense-tracker/client/expense/api-expense.js:

  const listByUser = async (params, credentials, signal) =  {
    const query = queryString.stringify(params)
    try {
      let response = await fetch('/api/expenses?'+query, {
        method: 'GET',
        signal: signal,
        headers: {
          'Accept': 'application/json',
          'Authorization': 'Bearer ' + credentials.t
        }
      })
      return await response.json()
    }catch(err){
      console.log(err)
    }
  }

在这个方法中,在向列出费用 API 发出请求之前,我们使用queryString库形成包含日期范围的查询字符串。然后,将此查询字符串附加到请求 URL。

此获取方法将在Expenses组件中使用,以检索并向用户显示费用。我们将在下一节中查看Expenses组件的实现。

费用组件

从数据库检索到的支出列表将使用名为Expenses的 React 组件进行渲染。该组件在初始加载时,将渲染当前月份由已登录用户发生的支出。在这个视图中,用户还可以选择日期范围以检索特定日期内发生的支出,如图所示:

![图片

在定义Expenses组件时,我们首先使用useEffect钩子调用获取支出列表 API 的 fetch 调用,以检索初始支出列表。我们还初始化了进行此请求和渲染从服务器接收到的响应所必需的值,如图所示。

mern-expense-tracker/client/expense/Expenses.js:

export default function Expenses() {
    const date = new Date(), y = date.getFullYear(), m = date.getMonth()
    const [firstDay, setFirstDay] = useState(new Date(y, m, 1))
    const [lastDay, setLastDay] = useState(new Date(y, m + 1, 0))

    const jwt = auth.isAuthenticated()
    const [redirectToSignin, setRedirectToSignin] = useState(false)
    const [expenses, setExpenses] = useState([])

    useEffect(() =  {
        const abortController = new AbortController()
        const signal = abortController.signal
        listByUser({firstDay: firstDay, lastDay: lastDay}, 
                   {t: jwt.token}, signal)
        .then((data) =  {
           if (data.error) {
                setRedirectToSignin(true)
              } else {
                setExpenses(data)
              }
           })
        return function cleanup(){
          abortController.abort()
        }
    }, [])
...
}

我们首先确定当前月份的第一天和最后一天的日期。这些日期被设置为在搜索表单字段中渲染,并作为请求服务器的日期范围查询参数提供。因为我们只会获取与当前用户相关的支出,所以我们检索已登录用户的auth凭证,以便与请求一起发送。如果服务器请求导致错误,我们将用户重定向到登录页面。否则,我们将接收到的支出设置在状态中,以便在视图中渲染。

在“支出”组件的视图部分,我们将在遍历结果支出数组以渲染单个支出详情之前,添加一个表单以按日期范围搜索。在接下来的章节中,我们将查看组件视图中的搜索表单和支出列表的实现。

通过日期范围搜索

在“支出”视图中,用户将可以选择查看在特定日期范围内发生的支出列表。为了实现一个允许用户选择开始和结束日期范围的搜索表单,我们将使用来自 Material-UI Pickers 的DatePicker组件。

在视图中,我们将添加两个DatePicker组件来收集查询范围的开始和结束日期,并添加一个按钮来启动搜索,如图所示。

mern-expense-tracker/client/expense/Expenses.js:

 div className={classes.search} 
    <MuiPickersUtilsProvider utils={DateFnsUtils} 
        <DatePicker
          disableFuture
          format="dd/MM/yyyy"
          label="SHOWING RECORDS FROM"
          views={["year", "month", "date"]}
          value={firstDay}
          onChange={handleSearchFieldChange('firstDay')}
       />
        <DatePicker
          format="dd/MM/yyyy"
          label="TO"
          views={["year", "month", "date"]}
          value={lastDay}
          onChange={handleSearchFieldChange('lastDay')}
       /> 
    </MuiPickersUtilsProvider> 
    Button variant="contained" color="secondary" 
       onClick= {searchClicked} GO </Button>
 </div>

当用户与DatePicker组件交互以选择日期时,我们将调用handleSearchFieldChange方法来获取选定的date值。此方法获取date值并将其相应地设置为状态中的firstDaylastDay值。handleSearchFieldChange方法定义如下。

mern-expense-tracker/client/expense/Expenses.js:

const handleSearchFieldChange = name =  date =  {
    if(name=='firstDay'){
        setFirstDay(date)
    }else{
        setLastDay(date)
    }
}

在选择了两个日期并将它们设置在状态中之后,当用户点击搜索按钮时,我们将调用searchClicked方法。在这个方法中,我们使用新的日期作为查询参数再次调用列表支出 API。searchClicked方法定义如下。

mern-expense-tracker/client/expense/Expenses.js:

const searchClicked = () =  {
    listByUser({firstDay: firstDay, lastDay: lastDay},{t: jwt.token}).then((data) =  {
        if (data.error) {
          setRedirectToSignin(true)
        } else {
          setExpenses(data)
        }
    })
}

一旦从服务器接收到此新查询产生的费用,我们将将其设置到状态中以便在视图中渲染。在下一节中,我们将查看显示检索到的费用列表的实现。

渲染费用

Expenses 组件视图中,我们遍历从数据库检索到的费用列表,并在 Material-UI ExpansionPanel 组件中将每个费用记录显示给最终用户。在 ExpansionPanel 组件中,我们在 摘要 部分显示单个费用记录的详细信息。然后,在面板展开时,我们将给用户提供编辑费用详细信息或删除费用的选项,如下一节所述。

在搜索表单元素之后添加到视图代码中的以下代码,我们使用 map 来遍历 expenses 数组,并在 ExpansionPanel 组件中渲染每个 expense

mern-expense-tracker/client/expense/Expenses.js:

{expenses.map((expense, index) = {
  return  span key={index} 
     <ExpansionPanel className={classes.panel}>
        <ExpansionPanelSummary 
           expandIcon={ Edit / } >
          <div className={classes.info} 
            Typography className={classes.amount} $ {expense.amount} </Typography>
            <Divider style={{marginTop: 4, marginBottom: 4}}/>
            <Typography  {expense.category}  </Typography>
            <Typography className={classes.date} 
                {new Date(expense.incurred_on).toLocaleDateString()}
            </Typography>  
          </div> 
          <div> 
            <Typography className={classes.heading} {expense.title} </Typography> 
            <Typography className={classes.notes}  {expense.notes}  </Typography>
          </div> 
        </ExpansionPanelSummary>
        <Divider/>
        <ExpansionPanelDetails style={{display: 'block'}} 
           ...
        </ExpansionPanelDetails>
     </ExpansionPanel> 
    </span> 
 })
}

费用详情在 ExpansionPanelSummary 组件中渲染,使用户能够了解他们在应用程序中记录的费用概述。ExpansionPanelDetails 组件将包含修改给定费用和完成允许用户管理他们在应用程序中记录的费用功能的选项。在下一节中,我们将讨论实现这些修改记录费用的选项。

修改费用记录

MERN 费用追踪器的用户将能够通过更新费用详情或完全删除费用记录来修改他们在应用程序中已记录的费用。

在应用程序的前端,用户在展开查看列表中单个费用的详细信息后,将在费用列表中接收到这些修改选项,如下面的截图所示:

图片

为了实现这些费用修改功能,我们必须更新视图以渲染此表单和删除选项。此外,我们将在服务器上添加编辑和删除费用 API 端点。在以下章节中,我们将讨论如何在前端渲染这些编辑和删除元素,然后实现后端的编辑和删除 API。

渲染编辑表单和删除选项

我们将在 Expenses 组件视图中渲染编辑费用表单和删除选项。对于在此视图中以 Material-UI ExpansionPanel 组件渲染的每个费用记录,我们将在 ExpansionPanelDetails 部分添加表单字段,每个字段预先填充相应的费用详情值。用户将能够与这些表单字段交互以更改值,然后点击更新按钮将更改保存到数据库。我们将在视图中添加这些表单字段以及更新按钮和删除选项,如下面的代码所示。

mern-expense-tracker/client/expense/Expenses.js:

 <ExpansionPanelDetails style={{display: 'block'}}>
   <div> 
     <TextField label="Title" value={expense.title} 
               onChange={handleChange('title', index)}/> 
     <TextField label="Amount ($)" value={expense.amount} 
               onChange={handleChange('amount', index)} type="number"/>
   </div> 
   <div>
     <MuiPickersUtilsProvider utils={DateFnsUtils}> 
       <DateTimePicker
          label="Incurred on"
          views={["year", "month", "date"]}
          value={expense.incurred_on}
          onChange={handleDateChange(index)}
          showTodayButton
      />
     </MuiPickersUtilsProvider 
     <TextField label="Category" value={expense.category} 
        onChange={handleChange('category', index)}/>
   </div> 
   <TextField label="Notes" multiline rows="2"
      value={expense.notes}
      onChange={handleChange('notes', index)}
  />
   <div className={classes.buttons} 
    { error && ( Typography component="p" color="error" 
        <Icon color="error" className={classes.error} error </Icon> 
                    {error}
                 </Typography> )
    }
    { saved && Typography component="span" color="secondary" Saved </Typography>  }
     <Button color="primary" variant="contained" 
            onClick={()=  clickUpdate(index)} Update </Button> 
     DeleteExpense expense={expense} onRemove={removeExpense}/ 
   </div>  
 </ExpansionPanelDetails> 

在这里添加的表单字段与在 NewExpense 组件中添加的字段类似,用于创建新的费用记录。当用户与这些字段交互以更新值时,我们将使用给定费用在 expenses 数组中的相应索引、字段名称和更改值调用 handleChange 方法。handleChange 方法定义如下。

mern-expense-tracker/client/expense/Expenses.js:

const handleChange = (name, index) =  event =  {
    const updatedExpenses = [...expenses]
    updatedExpenses[index][name] = event.target.value
    setExpenses(updatedExpenses)
}

expenses 数组中给定索引处的费用对象更新为指定字段的更改值,并将其设置为状态。这将使用户在更新编辑表单时渲染带有最新值的视图。当用户完成更改并点击 Update 按钮时,我们将调用 clickUpdate 方法,该方法定义如下。

mern-expense-tracker/client/expense/Expenses.js:

const clickUpdate = (index) =  {
    let expense = expenses[index]
    update({
            expenseId: expense._id
        }, {
            t: jwt.token
        }, expense)
    .then((data) =  {
        if (data.error) {
           setError(data.error)
        } else {
           setSaved(true)
           setTimeout(()= {setSaved(false)}, 3000)
    }
}

在这个 clickUpdate 方法中,我们通过向编辑费用 API 发起一个 fetch 调用来将更新的费用发送到后端。这个编辑费用 API 的实现将在下一节中讨论。

DeleteExpense 组件添加到编辑表单中,它会渲染一个删除按钮,并使用作为属性传递的 expense 对象通过调用删除费用 API 从数据库中删除相关的费用。这个 DeleteExpense 的实现与在 第七章 中讨论的 DeleteShop 组件类似,即 使用在线市场锻炼 MERN 技能。在下一节中,我们将讨论编辑和删除费用 API 的实现,这些 API 由编辑表单使用,并将用户在数据库的 Expense 集合中做出的费用相关更新传递给删除选项。

在后端编辑和删除费用

为了完成由前端登录用户发起的编辑和删除费用操作,我们需要在后端有相应的 API。以下代码中可以声明这些 API 端点的路由,它们将接受更新和删除请求。

mern-expense-tracker/server/routes/expense.routes.js:

router.route('/api/expenses/:expenseId')
  .put(authCtrl.requireSignin, expenseCtrl.hasAuthorization, expenseCtrl.update)
  .delete(authCtrl.requireSignin, expenseCtrl.hasAuthorization, expenseCtrl.remove)
router.param('expenseId', expenseCtrl.expenseByID)

对此路由的 PUTDELETE 请求将首先确保当前用户已通过 requireSignin auth 控制器方法登录,然后检查授权并在数据库中执行任何操作。

路由 URL 中的 :expenseId 参数,/api/expenses/:expenseId,将调用 expenseByID 控制器方法,该方法类似于 userByID 控制器方法。它从数据库中检索费用并将其附加到请求对象中,以便在 next 方法中使用。expenseByID 方法定义如下。

mern-expense-tracker/server/controllers/expense.controller.js:

const expenseByID = async (req, res, next, id) =  {
    try {
      let expense = await Expense.findById(id).populate
           ('recorded_by', '_id name').exec()
      if (!expense)
        return res.status('400').json({
          error: "Expense record not found"
        })
      req.expense = expense
      next()
    } catch (err){
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
}

从数据库检索到的费用对象还将包含记录费用的用户的名称和 ID 详情,正如我们在populate()方法中指定的。对于这些 API 端点,接下来我们将使用hasAuthorization方法验证此费用对象是否确实是由已登录的用户记录的,该方法在费用控制器中如下定义。

mern-expense-tracker/server/controllers/expense.controller.js:

const hasAuthorization = (req, res, next) =  {
  const authorized = req.expense && req.auth && 
      req.expense.recorded_by._id == req.auth._id
  if (!(authorized)) {
    return res.status('403').json({
      error: "User is not authorized"
    })
  }
  next()
}

一旦确认尝试更新费用的用户是记录该费用的用户,并且如果是PUT请求,则接下来将调用update方法来更新费用文档,并在 Expense 集合中应用新的更改。update控制器方法在以下代码中定义。

mern-expense-tracker/server/controllers/expense.controller.js:

const update = async (req, res) =  {
    try {
      let expense = req.expense
      expense = extend(expense, req.body)
      expense.updated = Date.now()
      await expense.save()
      res.json(expense)
    } catch (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
}

该方法从req.expense检索费用详情,然后使用lodash模块将请求体中传入的更改扩展并合并到费用数据中,以更新费用数据。在将此更新后的费用保存到数据库之前,updated字段被填充为当前日期,以反映最后更新的时间戳。在成功保存此更新后,更新的费用对象将作为响应发送回。

如果是DELETE请求而不是PUT请求,则会调用remove方法来从数据库中的集合中删除指定的费用文档。remove控制器方法在以下代码中定义。

mern-expense-tracker/server/controllers/expense.controller.js:

const remove = async (req, res) =  {
    try {
      let expense = req.expense
      let deletedExpense = await expense.remove()
      res.json(deletedExpense)
    } catch (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
}

此方法中的remove操作将永久删除应用程序中的费用。

我们已经为应用程序上的用户提供了所有功能,以便他们开始记录和管理日常费用。我们定义了一个 Expense 模型来存储费用数据,以及后端 API 和前端视图来创建新的费用、显示特定用户的费用列表以及修改现有费用。我们现在准备实现基于用户在应用程序上记录的费用数据的数据可视化功能。我们将在下一节中讨论这些实现。

随时间可视化费用数据

除了允许用户记录他们的费用外,MERN 费用跟踪应用程序还将处理收集到的费用数据,以使用户能够了解他们的消费习惯随时间的变化。我们将实现简单的数据聚合和数据可视化功能,以展示 MERN 堆栈如何满足任何全栈应用程序的此类要求。为了启用这些功能,我们将利用 MongoDB 的聚合框架,以及由 Formidable 提供的基于 React 的图表和数据可视化库——Victory。

在接下来的章节中,我们首先将添加功能来总结用户当前月份的费用,并展示他们与之前月份相比的表现。然后,我们将添加不同的 Victory 图表,以提供他们在一个月、一年以及每个费用类别上的支出模式的可视化表示。

总结近期费用

当用户在应用程序上登录他们的账户时,他们将看到他们在当前月份到目前为止产生的费用预览。他们还将看到与之前月份的平均值相比,每个类别的花费更多或更少的比较。为了实现这些功能,我们必须添加后端 API,这些 API 将在数据库中的相关费用数据上运行聚合操作,并将计算结果返回到前端进行渲染。在接下来的章节中,我们将实现全栈切片——首先展示当前月份到目前为止产生的所有费用预览,然后展示与当前月份支出相比的每个类别的平均费用。

预览当前月份的费用

用户登录应用程序后,我们将展示他们当前费用的预览,包括他们当前月份的总支出以及他们在当前日期和前一天的花费。这个预览将显示给最终用户,如下面的截图所示:

图片

为了实现这个功能,我们需要添加一个后端 API 来处理现有的费用数据,以返回这三个值,以便在 React 组件中渲染。在接下来的章节中,我们将查看这个 API 的实现和与前端视图的集成,以完成预览功能。

当前月份预览 API

我们将在后端添加一个 API,该 API 将返回当前月份到目前为止产生的费用预览。为了实现这个 API,我们首先声明一个GET路由,如下面的代码所示。

mern-expense-tracker/server/routes/expense.routes.js:

router.route('/api/expenses/current/preview')
  .get(authCtrl.requireSignin, expenseCtrl.currentMonthPreview)

'/api/expenses/current/preview'这个路由发送一个GET请求,首先会确保请求客户端已经登录,然后它会调用currentMonthPreview控制器方法。在这个方法中,我们将使用 MongoDB 的聚合框架对费用集合执行三组聚合操作,以检索当前月份、当前日期以及前一天的总费用。

currentMonthPreview控制器方法将按照以下结构定义,我们首先确定查找匹配费用所需的日期,然后执行聚合操作,最后在响应中返回结果。

mern-expense-tracker/server/controllers/expense.controller.js:

const currentMonthPreview = async (req, res) =  {
  const date = new Date(), y = date.getFullYear(), m = date.getMonth()
  const firstDay = new Date(y, m, 1)
  const lastDay = new Date(y, m + 1, 0)

  const today = new Date()
  today.setUTCHours(0,0,0,0)

  const tomorrow = new Date()
  tomorrow.setUTCHours(0,0,0,0)
  tomorrow.setDate(tomorrow.getDate()+1)

  const yesterday = new Date()
  yesterday.setUTCHours(0,0,0,0)
  yesterday.setDate(yesterday.getDate()-1)

  try {
      /* ... Perform aggregation operations on the Expense collection 
             to compute current month's numbers ... */
      /* ... Send computed result in response ... */
  } catch (err){
    console.log(err)
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }

}

我们首先确定当前月份的第一天和最后一天的日期,然后确定今天、明天和昨天的日期,分钟和秒数设置为零。我们需要这些日期来指定查找当前月份、今天和昨天的匹配费用的范围。然后,使用这些值和已登录用户的 ID 引用,我们构建检索当前月份、今天和昨天的总费用的聚合管道。我们使用 MongoDB 聚合框架中的$facet阶段将这些三个不同的聚合管道分组,如下面的代码所示。

mern-expense-tracker/server/controllers/expense.controller.js:

let currentPreview = await Expense.aggregate([
  { $facet: { month: [
    { $match: { incurred_on: { $gte: firstDay, $lt: lastDay }, 
      recorded_by: mongoose.Types.ObjectId(req.auth._id)}},
    { $group: { _id: "currentMonth" , totalSpent: {$sum: "$amount"} }},
       ],
    today: [
      { $match: { incurred_on: { $gte: today, $lt: tomorrow }, 
        recorded_by: mongoose.Types.ObjectId(req.auth._id) }},
      { $group: { _id: "today" , totalSpent: {$sum: "$amount"} } },
        ],

    yesterday: [
      { $match: { incurred_on: { $gte: yesterday, $lt: today }, 
        recorded_by: mongoose.Types.ObjectId(req.auth._id) }},
      { $group: { _id: "yesterday" , totalSpent: {$sum: "$amount"} } 
        },
       ]
    }
  }])
let expensePreview = {month: currentPreview[0].month[0], today: currentPreview[0].today[0], yesterday: currentPreview[0].yesterday[0] }
res.json(expensePreview)

对于每个聚合管道,我们首先使用incurred_on字段的日期范围值匹配费用,以及与当前用户引用的recorded_by字段,因此聚合操作仅对当前用户记录的费用执行。然后,每个管道中匹配的费用被分组以计算总支出金额。

在分面聚合操作的结果中,每个管道在输出文档中都有自己的字段,结果作为文档数组存储。

聚合操作完成后,我们访问计算结果并组合响应以发送回请求客户端。这个 API 可以在前端使用 fetch 请求。你可以定义一个相应的 fetch 方法来发起请求,类似于其他 API 实现。然后,这个 fetch 方法可以在 React 组件中使用来检索和渲染这些聚合值给用户。在下文中,我们将讨论实现此视图以渲染用户当前支出预览的细节。

渲染当前支出的预览

我们可以在任何 React 组件中向用户提供当前支出的概览,该组件对已登录用户是可访问的,并添加到应用程序的前端。为了检索支出总额并在视图中渲染这些数据,我们可以在useEffect钩子中调用当前月份预览 API,或者在按钮被点击时调用。

在 MERN 支出跟踪应用程序中,我们使用 React 组件渲染这些详细信息,并将其添加到主页上。我们使用useEffect钩子,如下面的代码所示,来检索当前的支出预览数据。

mern-expense-tracker/client/expense/ExpenseOverview.js:

  useEffect(() =  {
      const abortController = new AbortController()
      const signal = abortController.signal
      currentMonthPreview({t: jwt.token}, signal).then((data) =  {
        if (data.error) {
          setRedirectToSignin(true)
        } else {
          setExpensePreview(data)
        }
      })
      return function cleanup(){
        abortController.abort()
      }
  }, [])

一旦从后端接收到数据,我们将它设置到名为expensePreview的状态变量中,以便在视图中显示信息。在组件的视图中,我们使用这个状态变量来组合一个界面,以显示这些详细信息。在下面的代码中,我们渲染了当前月份、当前日期和前一天的总支出。

mern-expense-tracker/client/expense/ExpenseOverview.js:

 <Typography variant="h4" color="textPrimary" You've spent </Typography> 
 <div>  
<Typography component="span" 
        ${expensePreview.month ? expensePreview.month.totalSpent : '0'} 
          span so far this month  </span> 
     </Typography>
     <div> 
       <Typography variant="h5" color="primary" 
         ${expensePreview.today ? expensePreview.today.totalSpent :'0'} 
             span today </span> 
         </Typography>
         <Typography variant="h5" color="primary" 
            ${expensePreview.yesterday 
               ? expensePreview.yesterday.totalSpent: '0'}     
             <span className={classes.day} yesterday  </span> 
         </Typography> 
         <Link to="/expenses/all"  Typography variant="h6"> See more            </Typography>  </Link> 
     </div> 
 </div> 

这些值只有在后端聚合结果中返回相应的值时才会渲染;否则,我们将渲染一个"0"。

通过实现当前支出预览功能,我们能够处理用户记录的支出数据,让他们了解他们当前的支出情况。在下一节中,我们将遵循类似的实现步骤,告知用户每个支出类别的支出状况。

按类别跟踪当前支出

在这个应用中,我们将向用户提供一个概述,展示他们目前在每个支出类别中的支出情况,并与之前的平均值进行比较。对于每个类别,我们将显示基于之前支出数据的月平均支出,展示当前月份到目前为止的总支出,并显示差异,以表明他们是否在本月额外支出或节省了钱。以下截图显示了最终用户将如何看到这个功能:

图片

要实现这个功能,我们需要添加一个后端 API,该 API 将处理现有的支出数据,以返回每个类别的月平均支出以及当前月份的总支出,以便可以在 React 组件中渲染。在接下来的章节中,我们将探讨这个 API 的实现和集成,以及前端视图的整合,以完成按类别跟踪支出的功能。

当前支出按类别 API

我们将在后端添加一个 API,该 API 将返回每个支出类别的平均月支出和当前月份的总支出。为了实现这个 API,我们首先声明一个GET路由,如下面的代码所示。

mern-expense-tracker/server/routes/expense.routes.js:

router.route('/api/expenses/by/category')
  .get(authCtrl.requireSignin, expenseCtrl.expenseByCategory)

'/api/expenses/by/category'这个路由的GET请求将首先确保请求客户端已登录,然后它将调用expenseByCategory控制器方法。在这个方法中,我们将使用 MongoDB 聚合框架的不同特性来分别计算每个类别的月平均支出和每个类别的当前月份总支出,然后将这两个结果合并,返回与每个类别相关联的这两个值给请求客户端。

expenseByCategory控制器方法将按照以下结构定义,我们首先确定查找匹配支出所需的日期,然后执行聚合操作,最后在响应中返回结果。

mern-expense-tracker/server/controllers/expense.controller.js:

const expenseByCategory = async (req, res) =  {
  const date = new Date(), y = date.getFullYear(), m = date.getMonth()
  const firstDay = new Date(y, m, 1)
  const lastDay = new Date(y, m + 1, 0)

  try {
    let categoryMonthlyAvg = await Expense.aggregate([/*... aggregation ... */]).exec()
    res.json(categoryMonthlyAvg)
  } catch (err) {
    console.log(err)
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

在这个方法中,我们将使用包含一个$facet和两个子管道的聚合管道,用于计算每个类别的月平均支出和当前月份的每个类别的总支出。然后,我们将从子管道中取这两个结果数组来合并结果。这个聚合管道的代码定义在下面的代码中。

mern-expense-tracker/server/controllers/expense.controller.js:

[
  { $facet: {
     average: [
      { $match: { recorded_by: mongoose.Types.ObjectId(req.auth._id) }},
      { $group: { _id: {category: "$category", month: {$month: "$incurred_on"}}, 
                  totalSpent: {$sum: "$amount"} } },
      { $group: { _id: "$_id.category", avgSpent: { $avg: "$totalSpent"}}},
      { $project: {
         _id: "$_id", value: {average: "$avgSpent"},
        }
      }
     ],
     total: [
      { $match: { incurred_on: { $gte: firstDay, $lte: lastDay }, 
                  recorded_by: mongoose.Types.ObjectId(req.auth._id) }},
      { $group: { _id: "$category", totalSpent: {$sum: "$amount"} } },
      { $project: {
         _id: "$_id", value: {total: "$totalSpent"},
        }
      }
     ]
    }
  },
  { $project: {
     overview: { $setUnion:['$average','$total'] },
   }
  },
  { $unwind: '$overview' },
  { $replaceRoot: { newRoot: "$overview" } },
  { $group: { _id: "$_id", mergedValues: { $mergeObjects: "$value" } } }
]

$facet阶段的子管道输出投影时,我们确保结果对象的键在两个输出数组中都是_idvalue,以便可以统一合并。一旦完成分面聚合操作,我们使用$setUnion对结果进行操作以合并数组。然后,我们将合并后的数组作为新的根文档,以便对其运行$group聚合以合并每个类别的平均值和总值。

从这个聚合管道的最终输出将包含一个数组,其中每个支出类别都有一个对象。这个数组中的每个对象都将具有类别名称作为_id值,以及一个包含该类别平均和总值的mergedValues对象。然后,这个由聚合生成的最终输出数组被发送回请求客户端的响应中。

我们可以在前端使用 fetch 请求使用这个 API。你可以定义一个相应的 fetch 方法来发起请求,类似于其他 API 实现。然后,这个 fetch 方法可以在 React 组件中使用来检索并渲染这些聚合值给用户。在下一节中,我们将讨论这个视图的实现,以展示用户在当前月份与上个月相比,每个类别的支出比较。

渲染每个类别的支出概览

除了告知用户他们当前的支出情况外,我们还可以给他们一个与之前支出相比的情况。我们可以告诉他们,在当前月份的每个类别中,他们是支出更多还是节省了钱。我们可以实现一个 React 组件,该组件调用当前按类别支出的 API 来渲染后端发送的平均和总值,并显示这两个值之间的计算差异。

API 可以通过useEffect钩子或点击按钮来获取。在 MERN 支出跟踪应用程序中,我们将在主页上添加的 React 组件中渲染这些详细信息。我们使用以下代码中的useEffect钩子来检索每个类别的支出数据。

mern-expense-tracker/client/expense/ExpenseOverview.js:

  useEffect(() =  {
    const abortController = new AbortController()
    const signal = abortController.signal
    expenseByCategory({t: jwt.token}, signal).then((data) =  {
      if (data.error) {
        setRedirectToSignin(true)
      } else {
        setExpenseCategories(data)
      }
    })
    return function cleanup(){
      abortController.abort()
    }
  }, [])

我们将把从后端接收到的值设置到expenseCategories状态变量中,并在视图中渲染其详细信息。这个变量将包含一个数组,我们将在视图代码中遍历这个数组,为每个类别显示三个值——每月平均数、当前月份的总支出,以及这两个值之间的差异,并指示是否节省了钱。

在以下代码中,我们使用map来遍历接收到的数据数组,并为数组中的每个项目生成视图以显示接收到的平均和总值。除此之外,我们还使用这两个值显示一个计算值。

mern-expense-tracker/client/expense/ExpenseOverview.js:

{expenseCategories.map((expense, index) =  {
    return( div key={index}  
        <Typography variant="h5" {expense._id} </Typography>
        <Divider style={{ backgroundColor: 
            indicateExpense(expense.mergedValues)}}/>
        <div> 
          <Typography component="span" past average </Typography>
          <Typography component="span" this month </Typography> 
          <Typography component="span"  {expense.mergedValues.total 
 && expense.mergedValues.total-
              expense.mergedValues.average > 0 ? "spent extra" : "saved" } 
          </Typography> 
        </div> 
        <div> 
          <Typography component="span" ${expense.mergedValues.average}         </Typography> 
          <Typography component="span" ${expense.mergedValues.total ? 
 expense.mergedValues.total : 0}
          </Typography> 
          <Typography component="span" ${expense.mergedValues.total ? 
 Math.abs(expense.mergedValues.total-
             expense.mergedValues.average) : 
                expense.mergedValues.average}
          </Typography>
        </div> 
        <Divider/> 
     </div> ) 
  })
}

对于数组中的每个项目,我们首先渲染类别名称,然后渲染我们将显示的三个值的标题。第三个标题根据当前总金额是否多于或少于月平均金额有条件地渲染。然后,在每个标题下,我们渲染月平均金额、当前总金额(如果没有返回值,则将为零)以及这个平均金额和总金额之间的差异。对于第三个值,我们使用Math.abs()函数渲染平均金额和总金额之间计算出的差异的绝对值。

根据这个差异,我们还在类别名称下方渲染不同颜色的分隔线,以指示是否节省了资金、额外花费了资金,或者花费了相同金额的资金。为了确定颜色,我们定义了一个名为indicateExpense的方法,如下面的代码所示:

const indicateExpense = (values) =  {
    let color = '#4f83cc'
    if(values.total){
      const diff = values.total - values.average
      if( diff   0){
        color = '#e9858b'
      }
      if( diff   0 ){
        color = '#2bbd7e'
      } 
    }
    return color
}

如果当前总金额多于、少于或等于月平均金额,将返回不同的颜色。这使用户能够快速直观地了解他们在当前月份按类别产生费用的表现。

我们通过利用 MERN 堆栈技术(如 MongoDB 中的聚合框架)的现有功能,向费用跟踪应用程序添加了简单的数据可视化功能。在下一节中,我们将演示如何通过集成外部图表库来向此应用程序添加更复杂的数据可视化功能。

显示费用数据图表

图表和图表是可视化复杂数据模式的经时间考验的机制。在 MERN 费用跟踪应用程序中,我们将通过图形表示向用户报告费用模式随时间的变化,并添加简单的图表使用 Victory。

Victory 是一个由 Formidable 开发的针对 React 和 React Native 的开源图表和数据可视化库。不同类型的图表作为模块化组件提供,可以自定义并添加到任何 React 应用程序中。要了解更多关于 Victory 的信息,请访问formidable.com/open-source/victory

在我们开始将 Victory 图表集成到代码中之前,我们需要通过在命令行中运行以下命令来安装模块:

yarn add victory

在费用跟踪应用程序中,我们将添加三个不同的图表,作为向用户展示的交互式费用报告的一部分。这三个图表将包括一个散点图,显示在给定月份发生的费用,一个条形图,显示在给定年份每月发生的总费用,以及一个饼图,显示在提供的日期范围内每个类别的平均支出。

对于每个图表,我们将添加相应的后端 API 来检索相关的支出数据,并在前端添加一个 React 组件,该组件将使用检索到的数据来渲染相关的 Victory 图表。在以下章节中,我们将实现添加一个月度支出散点图、展示一年每月支出的条形图以及显示特定时间段内平均按类别支出的饼图所需的全栈切片。

散点图中的一个月支出

我们将通过散点图展示用户在给定月份发生的支出。这将为他们提供一个关于其一个月内支出情况的视觉概述。以下截图显示了散点图如何使用户支出数据呈现:

图片

我们在 y 轴上绘制支出金额,在 x 轴上绘制该月支出发生的日期。将鼠标悬停在绘制的气泡上,将显示该特定支出记录在哪个日期花费了多少钱。在以下章节中,我们将通过首先添加一个后端 API 来实现此功能,该 API 将返回所需格式以在 Victory 散点图中渲染的给定月份的支出。然后,我们将添加一个 React 组件,该组件将从后端检索这些数据并在 Victory 散点图中渲染。

散点图数据 API

我们将在后端添加一个 API,该 API 将返回给定月份发生的支出,并使用前端渲染散点图所需的数据格式。为了实现此 API,我们首先声明一个GET路由,如下面的代码所示。

mern-expense-tracker/server/routes/expense.routes.js:

router.route('/api/expenses/plot')
  .get(authCtrl.requireSignin, expenseCtrl.plotExpenses)

'/api/expenses/plot'此路由的GET请求将首先确保请求客户端已登录,然后它将调用plotExpenses控制器方法。请求还将通过 URL 查询参数获取给定月份的值,该值将在plotExpenses方法中使用,以确定所提供月份的第一天和最后一天。我们需要这些日期来指定查找在指定月份发生的并记录在认证用户中的匹配支出的范围,并将支出汇总到图表所需的数据格式中。plotExpenses方法定义在以下代码中。

mern-expense-tracker/server/controllers/expense.controller.js:

const plotExpenses = async (req, res) =  {

    const date = new Date(req.query.month), y = date.getFullYear(), m =    date.getMonth()
    const firstDay = new Date(y, m, 1)
    const lastDay = new Date(y, m + 1, 0)

    try {

        let totalMonthly = await Expense.aggregate( [
        { $match: { incurred_on: { $gte : firstDay, $lt: lastDay }, 
                    recorded_by: mongoose.Types.ObjectId(req.auth._id) }},
        { $project: {x: {$dayOfMonth: '$incurred_on'}, y: '$amount'}}
        ]).exec()

        res.json(totalMonthly)

    } catch (err){
        console.log(err)
        return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
        })
    }
}

我们运行一个简单的聚合操作,找到匹配的支出,并返回一个包含散点图 y 轴和 x 轴所需格式的值的输出。聚合的最终结果包含一个对象数组,每个对象包含一个 x 属性和一个 y 属性。x 属性包含来自 incurred_on 日期的月份值。y 属性包含相应的支出金额。从聚合生成的最终输出数组被发送回请求客户端的响应。

我们可以使用此 API 在前端通过 fetch 请求。您可以定义一个相应的 fetch 方法来发出请求,类似于其他 API 实现。然后,fetch 方法可以在 React 组件中使用,以检索并渲染散点图中的 xy 值数组。在下一节中,我们将讨论此视图的实现,以渲染显示给定月份发生的支出的散点图。

MonthlyScatter 组件

我们将实现一个 React 组件,该组件调用散点图数据 API,以在 Victory Scatter 图表中渲染给定月份发生的支出数组。

API 可以通过 useEffect 钩子或点击按钮时获取。在 MERN 支出跟踪应用程序中,我们在名为 MonthlyScatter 的 React 组件中渲染这个散点图。当这个组件加载时,我们渲染当前月份的支出散点图。我们还添加了一个 DatePicker 组件,允许用户选择所需的月份,并通过点击按钮检索该月份的数据。在下面的代码中,当组件加载时,我们使用 useEffect 钩子检索初始散点图数据。

mern-expense-tracker/client/report/MonthlyScatter.js:

const [plot, setPlot] = useState([])
const [month, setMonth] = useState(new Date())
const [error, setError] = useState('')
const jwt = auth.isAuthenticated()
useEffect(() =  {
        const abortController = new AbortController()
        const signal = abortController.signal

        plotExpenses({month: month},{t: jwt.token}, signal).then((data) =  {
          if (data.error) {
            setError(data.error)
          } else {
            setPlot(data)
          }
        })
        return function cleanup(){
          abortController.abort()
        }
    }, [])

当从后端接收到绘制的数据并将其设置在状态中时,我们可以在 Victory Scatter 图表中渲染它。此外,我们可以在组件视图中添加以下代码以渲染带有标签的自定义散点图。

mern-expense-tracker/client/report/MonthlyScatter.js:

 <VictoryChart
    theme={VictoryTheme.material}
    height={400}
    width={550}
    domainPadding={40}

     <VictoryScatter
        style={{
            data: { fill: "#01579b", stroke: "#69f0ae", strokeWidth: 2 },
            labels: { fill: "#01579b", fontSize: 10, padding:8}
        }}
        bubbleProperty="y"
        maxBubbleSize={15}
        minBubbleSize={5}
        labels={({ datum }) =  `$${datum.y} on ${datum.x}th`}
        labelComponent={ VictoryTooltip/ }
        data={plot}
        domain={{x: [0, 31]}}
    />
     <VictoryLabel
        textAnchor="middle"
        style={{ fontSize: 14, fill: '#8b8b8b' }}
        x={270} y={390}
        text={`day of month`}
    /> 
     <VictoryLabel
        textAnchor="middle"
        style={{ fontSize: 14, fill: '#8b8b8b' }}
        x={6} y={190}
        angle = {270} 
        text={`Amount ($)`}
    />
 </VictoryChart>

我们将 VictoryScatter 组件放置在 VictoryChart 组件中,这给了我们自定义散点图包装器和将轴标签文本放置在散点图外的灵活性。我们向 VictoryScatter 传递数据,指出气泡属性基于哪个值,自定义样式,并指定每个气泡的大小范围和标签。

此代码根据提供的数据绘制并渲染散点图,其中金额支出与月份的某一天分别对应于 y 轴和 x 轴。在下一节中,我们将遵循类似的步骤添加柱状图,以图形方式显示给定年份的月度支出。

一年中的每月总支出

我们将向用户展示一个表示他们在给定年份内每月总费用的条形图。这将让他们了解他们的费用是如何在一年中分布的。以下截图显示了条形图将如何使用用户费用数据渲染:

图片

在这里,我们使用给定年份中每个月的总费用值填充条形图。我们将每月总价值作为标签添加到每个条形上。在 x 轴上,我们显示每个月的简称。在接下来的章节中,我们将通过首先添加一个后端 API 来实现这个功能,该 API 将返回给定年份每月发生的总费用,并且格式适合在前端渲染条形图。然后,我们将添加一个 React 组件,该组件将从后端检索这些数据并在 Victory Bar 图表中渲染。

年度费用 API

我们将在后端添加一个 API,该 API 将返回给定年份内每月发生的总费用,并且格式适合在前端渲染条形图。

要实现这个 API,我们首先将声明一个GET路由,如下面的代码所示。

mern-expense-tracker/server/routes/expense.routes.js:

router.route('/api/expenses/yearly')
  .get(authCtrl.requireSignin, expenseCtrl.yearlyExpenses)

'/api/expenses/yearly'这个路由的GET请求将首先确保请求客户端是一个已登录的用户,然后它将调用yearlyExpenses控制器方法。请求还将从 URL 查询参数中获取给定年份的值,该值将在yearlyExpenses方法中使用,以确定所提供年份的第一天和最后一天。我们需要这些日期来指定查找在指定年份发生并由认证用户记录的匹配费用的范围,并在将总月度费用聚合到图表所需的数据格式时使用。yearlyExpenses方法在下面的代码中定义。

mern-expense-tracker/server/controllers/expense.controller.js:

  const yearlyExpenses = async (req, res) =  {
  const y = req.query.year
  const firstDay = new Date(y, 0, 1)
  const lastDay = new Date(y, 12, 0)
  try {
    let totalMonthly = await Expense.aggregate( [
      { $match: { incurred_on: { $gte : firstDay, $lt: lastDay } }},
      { $group: { _id: {$month: "$incurred_on"}, totalSpent: {$sum: "$amount"} } },
      { $project: {x: '$_id', y: '$totalSpent'}}
    ]).exec()
    res.json({monthTot:totalMonthly})
  } catch (err){
    console.log(err)
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

我们运行一个聚合操作,找到匹配的费用,按月份分组费用以计算总和,并返回一个包含条形图 y 轴和 x 轴值所需格式的值的输出。聚合的最终结果包含一个对象数组,每个对象包含一个x属性和一个y属性。

x属性包含incurred_on日期的月份值。y属性包含该月的相应总费用金额。从聚合生成的最终输出数组将发送回请求客户端。

我们可以使用此 API 在前端使用 fetch 请求。您可以定义一个相应的 fetch 方法来发出请求,类似于其他 API 实现。然后,fetch 方法可以在 React 组件中使用来检索并渲染在柱状图中显示的xy值的数组。在下一节中,我们将讨论实现此视图以渲染显示给定年份总月度支出的柱状图。

年度柱状图组件

我们将实现一个 React 组件,该组件调用年度支出数据 API,以在 Victory Bar 图表中渲染给定年份每月发生的支出数组。

API 可以通过useEffect钩子或当按钮被点击时获取。在 MERN 支出跟踪应用程序中,我们在名为YearlyBar的 React 组件中渲染此柱状图。当此组件加载时,我们渲染当前年份的支出柱状图。我们还添加了一个DatePicker组件,允许用户选择所需的年份,并通过按钮点击检索该年份的数据。在下面的代码中,我们在组件加载时使用useEffect钩子检索初始年度支出数据。

mern-expense-tracker/client/report/YearlyBar.js:

const [year, setYear] = useState(new Date())
const [yearlyExpense, setYearlyExpense] = useState([])
const [error, setError] = useState('') 
const jwt = auth.isAuthenticated()
useEffect(() =  {
    const abortController = new AbortController()
    const signal = abortController.signal
    yearlyExpenses({year: year.getFullYear()},{t: jwt.token}, signal).then((data) =  {
        if (data.error) {
        setError(data.error)
        }
        setYearlyExpense(data)
    })
    return function cleanup(){
        abortController.abort()
    }
}, [])

我们可以使用从后端接收并设置在状态中的数据在 Victory Bar 图表中渲染。我们可以在组件视图中添加以下代码来渲染一个带有标签且仅显示x轴的自定义柱状图。

mern-expense-tracker/client/report/YearlyBar.js:

const monthStrings = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
 <VictoryChart
    theme={VictoryTheme.material}
    domainPadding={10}
    height={300}
    width={450} 
     <VictoryAxis/> 
     <VictoryBar
        categories={{
            x: monthStrings
        }}
        style={{ data: { fill: "#69f0ae", width: 20 }, labels: {fill: "#01579b"} }}
        data={yearlyExpense.monthTot}
        x={monthStrings['x']}
        domain={{x: [0, 13]}}
        labels={({ datum }) =  `$${datum.y}`}
    /> 
 </VictoryChart>

数据库返回的月份值是零基索引,因此我们定义了自己的月份名称字符串数组来映射这些索引。为了渲染柱状图,我们在VictoryChart组件中放置了一个VictoryBar组件,这使我们能够自定义柱状图包装器,并且还使用VictoryAxis组件添加了y轴,因为没有添加任何属性,所以y轴根本不会显示。

我们将数据传递给VictoryBar,并使用月份字符串定义x轴值的类别,以便在图表上显示整年的所有月份,即使尚未存在相应的总值。我们为每个柱状图渲染单独的标签,以显示每个月的总支出值。为了将x轴值与正确的月份字符串映射,我们在VictoryBar组件的x属性中指定它。

此代码根据提供的数据绘制并渲染柱状图,将每个月的支出总额映射到每个月。在下一节中,我们将遵循类似的步骤添加饼图,以图形方式显示给定日期范围内的平均支出类别。

饼图中的平均支出类别

我们可以渲染一个饼图,显示用户在给定时间段内平均在每个支出类别上花费的金额。这将帮助用户可视化哪些类别随着时间的推移消耗了更多或更少的财富。以下截图显示了饼图将如何使用用户支出数据渲染:

图片

我们用每个类别及其平均支出值填充饼图,显示相应的名称和金额作为标签。在接下来的章节中,我们将通过首先添加一个后端 API 来实现此功能,该 API 将返回给定日期范围内每个类别的平均支出以及用于在 Victory Pie 图表中渲染的格式。然后,我们将添加一个 React 组件,该组件将从后端检索这些数据并在 Victory Pie 图表中渲染。

按类别平均支出 API

我们将在后端添加一个 API,该 API 将返回在给定时间段内每个类别的平均支出以及用于在前端渲染饼图的所需数据格式。为了实现此 API,我们首先声明一个 GET 路由,如下面的代码所示。

mern-expense-tracker/server/routes/expense.routes.js:

router.route('/api/expenses/category/averages')
  .get(authCtrl.requireSignin, expenseCtrl.averageCategories)

'/api/expenses/category/averages' 路由的 GET 请求将首先确保请求客户端已登录,然后它将调用 averageCategories 控制器方法。请求还将通过 URL 查询参数获取给定日期范围的值,这些值将在 averageCategories 方法中使用,以确定提供的范围的起始日期和结束日期。我们需要这些日期来指定在指定日期范围内找到匹配的支出,这些支出由认证用户记录并在聚合每个类别的支出平均值到图表所需的数据格式时进行。averageCategories 方法在以下代码中定义。

mern-expense-tracker/server/controllers/expense.controller.js:

const averageCategories = async (req, res) =  {
  const firstDay = new Date(req.query.firstDay)
  const lastDay = new Date(req.query.lastDay)

  try {
    let categoryMonthlyAvg = await Expense.aggregate([
      { $match : { incurred_on : { $gte : firstDay, $lte: lastDay }, 
         recorded_by: mongoose.Types.ObjectId(req.auth._id)}},
      { $group : { _id : {category: "$category"}, 
         totalSpent: {$sum: "$amount"} } },
      { $group: { _id: "$_id.category", avgSpent: 
         { $avg: "$totalSpent"}}},
      { $project: {x: '$_id', y: '$avgSpent'}}
    ]).exec()
    res.json({monthAVG:categoryMonthlyAvg})
  } catch (err){
    console.log(err)
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

我们运行一个聚合操作,找到匹配的支出,按类别分组支出以首先计算总数然后计算平均值,并返回一个包含饼图 yx 值所需格式的输出。聚合的最终结果包含一个对象数组,每个对象包含一个 x 属性和一个 y 属性。x 属性包含类别名称作为值。y 属性包含该类别的相应平均支出金额。从聚合生成的最终输出数组将发送回请求客户端。

我们可以在前端使用 fetch 请求使用此 API。您可以定义一个相应的 fetch 方法来发送请求,类似于其他 API 实现。然后,fetch 方法可以在 React 组件中使用,以检索并渲染饼图中的 xy 值数组。在下一节中,我们将讨论此视图的实现,以渲染一个饼图,显示在给定日期范围内每个类别的平均支出。

CategoryPie 组件

我们将实现一个 React 组件,该组件调用按类别平均支出 API,以在 Victory 饼图中渲染接收到的每个类别平均支出的数组。

API 可以通过useEffect钩子或当按钮被点击时获取。在 MERN 支出跟踪应用程序中,我们在名为CategoryPie的 React 组件中渲染这个饼图。当这个组件加载时,我们渲染给定月份每个类别平均支出的饼图。我们还添加了两个DatePicker组件,允许用户选择所需的日期范围,并通过点击按钮检索该范围的数据。在下面的代码中,我们使用useEffect钩子在组件加载时检索初始平均支出数据。

mern-expense-tracker/client/report/CategoryPie.js:

const [error, setError] = useState('')
const [expenses, setExpenses] = useState([])
const jwt = auth.isAuthenticated()
const date = new Date(), y = date.getFullYear(), m = date.getMonth()
const [firstDay, setFirstDay] = useState(new Date(y, m, 1))
const [lastDay, setLastDay] = useState(new Date(y, m + 1, 0))
useEffect(() =  {
        const abortController = new AbortController()
        const signal = abortController.signal
        averageCategories({firstDay: firstDay, lastDay: lastDay}, 
        {t: jwt.token}, signal).then((data) =  {
          if (data.error) {
            setError(data.error)
          } else {
            setExpenses(data)
          }
        })
        return function cleanup(){
          abortController.abort()
        }
    }, [])

通过从后端接收并设置在状态中的数据,我们可以在 Victory 饼图中渲染它。我们可以在组件视图中添加以下代码来渲染一个带有每个切片的单独文本标签和图表中心标签的自定义饼图。

mern-expense-tracker/client/report/CategoryPie.js:

 <div style={{width: 550, margin: 'auto'}}>
     <svg viewBox="0 0 320 320">
         <VictoryPie standalone={false} data=
            {expenses.monthAVG}    innerRadius={50} 
              theme={VictoryTheme.material} 
                labelRadius={({ innerRadius }) =  innerRadius + 14 }
                labelComponent={ VictoryLabel angle={0} style={[{
                    fontSize: '11px',
                    fill: '#0f0f0f'
                },
                {
                    fontSize: '10px',
                    fill: '#013157'
                }]} text={( {datum} ) =  `${datum.x}\n $${datum.y}`}/ }
        />
         <VictoryLabel
              textAnchor="middle"
              style={{ fontSize: 14, fill: '#8b8b8b' }}
              x={175} y={170}
              text={`Spent \nper category`}
         /> 
     </svg> 
 </div> 

要渲染带有单独中心标签的饼图,我们将VictoryPie组件放置在一个svg元素中,这使我们能够自定义饼图包装,并使用VictoryLabel在饼图代码外部添加一个单独的圆形标签。

我们将数据传递给VictoryPie,为每个切片定义自定义的标签,并使饼图独立,以便中心标签可以放置在图表上。此代码根据提供的数据绘制并渲染饼图,每个类别显示平均支出。

我们已根据用户记录的支出数据添加了三个不同的 Victory 图表到应用程序中,这些数据经过必要的处理,并从后端数据库中检索。MERN 支出跟踪应用程序功能齐全,允许用户记录他们的日常支出,并可视化从记录的支出数据中提取的数据模式和支出习惯。

摘要

在本章中,我们将 MERN 骨架应用程序扩展为开发一个具有数据可视化功能的支出跟踪应用程序。我们设计了一个支出模型来记录支出细节,并实现了全栈CRUD创建读取更新删除)功能,允许已登录用户记录他们的日常支出,查看他们的支出列表,并修改现有的支出记录。

我们添加了数据处理和可视化功能,使用户能够了解他们的当前支出,并了解他们在每个支出类别上花费的更多或更少的金额。我们还集成了不同类型的图表,以显示用户在不同时间范围内的支出模式。

在实现这些功能的过程中,我们了解到了 MongoDB 中聚合框架的一些数据处理选项,并且还整合了一些来自 Victory 的可定制图表组件。您可以进一步探索聚合框架和 Victory 库,以便在您自己的全栈应用程序中整合更复杂的数据可视化功能。

在下一章中,我们将通过扩展 MERN 骨架来构建一个媒体流应用程序,我们将探索 MERN 堆栈技术的一些更高级的可能性。

第十五章:构建 Media Streaming 应用程序

上传和流式传输媒体内容,特别是视频内容,已经是一段时间来互联网文化中增长的部分。从个人分享个人视频内容到娱乐行业在在线流媒体服务上传播商业内容,我们都依赖于能够实现顺畅上传和流式传输的 Web 应用程序。MERN 技术栈中的功能可以用于构建和集成这些核心流媒体功能到任何基于 MERN 的全栈应用程序中。在本章中,我们将扩展 MERN 框架应用程序来构建一个媒体流媒体应用程序,同时展示如何利用 MongoDB GridFS 并将媒体流式传输功能添加到您的 Web 应用程序中。

在本章中,我们将涵盖以下主题,通过扩展 MERN 框架应用程序来实现基本的媒体上传和流式传输:

  • 介绍 MERN Mediastream

  • 将视频上传到 MongoDB GridFS

  • 存储和检索媒体详情

  • 从 GridFS 流式传输视频到基本媒体播放器

  • 列出、显示、更新和删除媒体

介绍 MERN Mediastream

我们将通过扩展框架应用程序来构建 MERN Mediastream 应用程序。这将是一个简单的视频流媒体应用程序,允许注册用户上传任何浏览应用程序的人都可以流式传输的视频。以下截图显示了 MERN Mediastream 应用程序的主页视图,以及平台上流行的视频列表:

图片

完整的 MERN Mediastream 应用程序的代码可在 GitHub 上找到:github.com/PacktPublishing/Full-Stack-React-Projects-Second-Edition/tree/master/Chapter11%20and%2012/mern-mediastream

您可以克隆此代码,并在阅读本章其余部分的代码解释时运行应用程序。

需要开发用于简单媒体播放器中媒体上传、编辑和流媒体功能的用户界面视图,这些视图将通过扩展和修改 MERN 框架应用程序中的现有 React 组件来实现。以下图表显示了将在本章中开发的 MERN Mediastream 前端的所有自定义 React 组件:

图片

我们将添加新的 React 组件来实现上传新视频、列出已发布的媒体、修改媒体帖子细节以及显示视频的视图。用户可以与视频内容进行交互以流式传输和观看视频。我们还将修改现有的组件,例如Home组件,以便我们可以渲染热门视频列表,以及Profile组件,以便我们可以列出特定用户发布的所有视频。应用程序中的上传和流媒体功能将依赖于用户上传视频内容的能力。在下一节中,我们将讨论如何允许已登录用户向应用程序添加媒体。

上传和存储媒体

MERN Mediastream 应用程序的注册用户将能够从本地文件上传视频,并使用 GridFS 直接在 MongoDB 上存储每个视频及其相关细节。为了使应用程序能够上传媒体内容,我们需要定义如何存储媒体细节和视频内容,并实现一个全栈切片,使用户能够创建新的媒体帖子并上传视频文件。在接下来的章节中,首先我们将定义一个媒体模型来存储每个媒体帖子的细节,并配置 GridFS 以存储相关的视频内容。然后,我们将讨论后端 API 的实现,该 API 将接收并存储视频内容以及其他媒体细节,以及前端表单视图,它将允许用户在应用程序上创建新的媒体帖子。

定义媒体模型

我们将实现一个 Mongoose 模型来定义一个媒体模型,用于存储发布到应用程序的每条媒体的细节。此模型将在server/models/media.model.js中定义,其实现将与我们在前几章中介绍的 Mongoose 模型实现类似,例如我们在第六章中定义的课程模型。该模型中的媒体模式将包含记录媒体标题、描述、类型、观看次数、媒体发布和更新的日期以及引用发布媒体的用户的字段。定义媒体字段的代码如下:

  • 媒体标题title字段被声明为String类型,并将是一个必填字段,用于介绍上传到应用程序的媒体:
title: {
    type: String,
    required: 'title is required'
}
  • 媒体描述和类型descriptiongenre字段将属于String类型,并将存储有关发布的媒体的其他细节。genre字段还将允许我们将上传到应用程序的不同媒体分组。
 description: String,
 genre: String,
  • 观看次数views字段定义为Number类型,并将跟踪上传的媒体在应用程序中被用户观看的次数:
views: {
    type: Number, 
    default: 0
},
  • 发布媒体的用户postedBy字段将引用创建媒体帖子的用户:

 postedBy: {
    type: mongoose.Schema.ObjectId, 
    ref: 'User'
 },
  • 创建和更新时间createdupdated字段将是Date类型,created在添加新媒体时生成,updated在修改任何媒体详细信息时更改:
updated: Date,
created: { 
    type: Date, 
    default: Date.now 
},

添加到模式定义中的字段将只存储关于每个发布到应用程序的视频的详细信息。为了存储视频内容本身,我们将使用 MongoDB GridFS。在下一节中,在讨论如何实现上传视频文件之前,我们将讨论 GridFS 如何使 MongoDB 中存储大文件成为可能,然后添加初始化代码以开始在这个流式应用中使用 GridFS。

使用 MongoDB GridFS 存储大文件

在前面的章节中,我们讨论了用户上传的文件可以直接作为二进制数据存储在 MongoDB 中;例如,在第五章的“上传个人照片”部分添加个人照片时。但这仅适用于小于 16 MB 的文件。为了在 MongoDB 中存储更大的文件,例如本流式应用所需的视频文件,我们需要使用 GridFS。

GridFS 是 MongoDB 中的一个规范,允许我们将一个给定的文件分割成几个块来存储在 MongoDB 中。每个块的大小最大为 255 KB,并作为单独的文档存储。当需要根据对 GridFS 的查询检索文件时,块会根据需要重新组装。这提供了只获取和加载文件所需部分而不是整个文件的功能。

在存储和检索 MERN Mediastream 应用程序的视频文件的情况下,我们将利用 GridFS 来存储视频文件,并根据用户跳转到的部分和开始播放的部分流式传输视频的相应部分。

您可以在官方 MongoDB 文档中了解更多关于 GridFS 规范及其功能的信息,请参阅docs.mongodb.com/manual/core/gridfs/

要从我们的后端代码访问和使用 MongoDB GridFS,我们将通过创建一个GridFSBucket并使用建立的数据库连接来使用 Node.js MongoDB 驱动程序的流式 API。

GridFSBucket 是 GridFS 流式接口,它为我们提供了访问流式 GridFS API 的权限。它可以用来与 GridFS 中的文件进行交互。您可以在 Node.js MongoDB 驱动程序 API 文档中了解更多关于 GridFSBucket 和流式 API 的信息,请参阅mongodb.github.io/node-mongodb-native/3.2/api/GridFSBucket.html

由于我们使用 Mongoose 与 MongoDB 数据库建立应用程序的连接,因此将在连接建立后添加以下代码来初始化一个新的GridFSBucket

mern-mediastream/server/controllers/media.controller.js:

import mongoose from 'mongoose'
let gridfs = null
mongoose.connection.on('connected', () => {
  gridfs = new mongoose.mongo.GridFSBucket(mongoose.connection.db)
})

我们在这里创建的gridfs对象将为我们提供访问 GridFS 功能,这些功能在创建新媒体时存储视频文件以及在媒体要流回用户时检索文件时是必需的。在下一节中,我们将添加创建媒体表单视图和后端 API,这些 API 将使用此gridfs对象来保存与前端表单视图发送的请求一起上传的视频文件。

创建新的媒体帖子

为了让用户能够在应用程序中创建新的媒体帖子,我们需要集成一个全栈切片,允许用户在前端填写表单,然后在后端将提供的媒体细节和相关的视频文件保存到数据库中。为了实现这个功能,在接下来的章节中,我们将在后端添加一个创建媒体 API,以及在前端获取此 API 的方法。然后,我们将实现一个创建新媒体表单视图,允许用户输入媒体细节并从他们的本地文件系统中选择视频文件。

创建媒体 API

我们将在后端实现一个创建媒体 API,允许用户在应用程序上创建新的媒体帖子。此 API 将在'/api/media/new/:userId'接收包含媒体字段和上传的视频文件的 multipart 请求体。首先,我们将声明创建媒体路由并利用用户控制器中的userByID方法,如下所示。

mern-mediastream/server/routes/media.routes.js:

router.route('/api/media/new/:userId')
        .post(authCtrl.requireSignin, mediaCtrl.create)
router.param('userId', userCtrl.userByID)

userByID方法处理 URL 中传递的:userId参数,并从数据库中检索相关的用户。用户对象将在请求对象中可用,以便在将要执行的下一种方法中使用。类似于用户和认证路由,我们将在express.js中将媒体路由挂载到 Express 应用上,如下所示。

mern-mediastream/server/express.js:

app.use('/', mediaRoutes)

向创建路由 URL /api/media/new/:userId 发送 POST 请求,将确保用户已登录,然后初始化媒体控制器中的create方法。create控制器方法将使用formidable节点模块来解析包含用户上传的媒体细节和视频文件的 multipart 请求体。您可以从命令行运行以下命令来安装此模块:

yarn add formidable

create方法中,我们将使用在表单数据中接收并使用formidable解析的媒体字段来生成一个新的媒体对象,并将其保存到数据库中。这个create控制器方法定义如下。

mern-mediastream/server/controllers/media.controller.js:

const create = (req, res) => {
  let form = new formidable.IncomingForm()
  form.keepExtensions = true
  form.parse(req, async (err, fields, files) => {
      if (err) {
        return res.status(400).json({
          error: "Video could not be uploaded"
        })
      }
      let media = new Media(fields)
      media.postedBy= req.profile
      if(files.video){
        let writestream = gridfs.openUploadStream(media._id, {
 contentType: files.video.type || 'binary/octet-stream'})
 fs.createReadStream(files.video.path).pipe(writestream)
      }
      try {
        let result = await media.save()
        res.status(200).json(result)
      }
      catch (err){
          return res.status(400).json({
            error: errorHandler.getErrorMessage(err)
          })
      }
    })
}

如果请求中有文件,formidable 将将其临时存储在文件系统中。我们将使用这个临时文件和媒体对象的 ID,通过 gridfs.openUploadStream 创建一个可写流。在这里,临时文件将被读取并写入 MongoDB GridFS,同时设置 filename 值为媒体 ID。这将生成与 MongoDB 中的相关块和文件信息文档,当需要检索此文件时,我们将使用媒体 ID 来识别它。

要在前端使用此创建媒体 API,我们将在 api-media.js 中添加相应的 fetch 方法,通过传递视图中的多部分表单数据向 API 发送 POST 请求。此方法将定义如下。

mern-mediastream/client/media/api-media.js:

const create = async (params, credentials, media) => {
  try {
    let response = await fetch('/api/media/new/'+ params.userId, {
    method: 'POST',
    headers: {
      'Accept': 'application/json',
      'Authorization': 'Bearer ' + credentials.t
    },
    body: media
  }) 
    return await response.json()
  } catch(err) {
    console.log(err)
  }
}

create 获取方法将获取当前用户的 ID、用户凭据和媒体表单数据,向后端创建媒体 API 发送 POST 请求。当用户提交新的媒体表单以上传新视频并在应用程序上发布时,我们将使用此方法。在下一节中,我们将查看前端中此表单视图的实现。

新媒体组件

在 MERN Mediastream 应用程序上注册的用户将通过表单视图输入新媒体帖子的详细信息。此表单视图将在 NewMedia 组件中渲染,允许已登录用户通过输入视频的标题、描述和类型以及从本地文件系统中上传视频文件来创建媒体帖子。

此表单视图将呈现如下:

我们将在这个名为 NewMedia 的 React 组件中实现此表单。对于视图,我们将使用 Material-UI 的 Button 和 HTML5 文件 input 元素添加文件上传元素,如下面的代码所示。

mern-mediastream/client/media/NewMedia.js:

<input accept="video/*" 
       onChange={handleChange('video')} 
       id="icon-button-file" 
       type="file"
       style={{display: none}}/>
<label htmlFor="icon-button-file">
    <Button color="secondary" variant="contained" component="span">
       Upload <FileUpload/>
    </Button>
</label> 
<span>{values.video ? values.video.name : ''}</span>

在文件 input 元素中,我们指定它接受视频文件,因此当用户点击上传并浏览其本地文件夹时,他们只有上传视频文件的选择。

然后,在视图中,我们添加了标题、描述和类型的表单字段,使用 TextField 组件,如下面的代码所示。

mern-mediastream/client/media/NewMedia.js:

<TextField id="title" label="Title" value={values.title} 
           onChange={handleChange('title')} margin="normal"/><br/>
<TextField id="multiline-flexible" label="Description"
           multiline rows="2"
           value={values.description}
           onChange={handleChange('description')}/><br/>
<TextField id="genre" label="Genre" value={values.genre} 
           onChange={handleChange('genre')}/><br/>

当用户与输入字段交互输入值时,这些表单字段更改将通过 handleChange 方法进行跟踪。handleChange 函数将定义如下。

mern-mediastream/client/media/NewMedia.js:

const handleChange = name => event => {
    const value = name === 'video'
      ? event.target.files[0]
      : event.target.value
    setValues({ ...values, [name]: value })
}

handleChange 方法通过更新状态来跟踪新值,包括如果用户上传了视频文件,则包括视频文件名。

最后,你可以通过添加一个提交按钮来完成此表单视图,当用户点击提交按钮时,应将表单数据发送到服务器。我们在这里定义一个 clickSubmit 方法,当用户点击提交按钮时将被调用。

mern-mediastream/client/media/NewMedia.js:

  const clickSubmit = () => {
    let mediaData = new FormData()
    values.title && mediaData.append('title', values.title)
    values.video && mediaData.append('video', values.video)
    values.description && mediaData.append('description',
       values.description)
    values.genre && mediaData.append('genre', values.genre)
    create({
      userId: jwt.user._id
    }, {
      t: jwt.token
    }, mediaData).then((data) => {
      if (data.error) {
        setValues({...values, error: data.error})
      } else {
        setValues({...values, error: '', mediaId: data._id, 
           redirect: true})
      }
    })
  }

clickSubmit函数将获取输入值并填充mediaData,这是一个FormData对象,确保数据以正确的格式存储在multipart/form-data编码类型中。然后,调用create fetch 方法使用此表单数据在后端创建新的媒体。在成功创建媒体后,用户可能会被重定向到不同的视图,例如,到一个包含新媒体详情的媒体视图,如下面的代码所示。

mern-mediastream/client/media/NewMedia.js:

if (values.redirect) {
    return (<Redirect to={'/media/' + values.mediaId}/>)
}

NewMedia组件只能由已登录用户查看。因此,我们将在MainRouter组件中添加一个PrivateRoute,这样它只会在/media/new为认证用户渲染此表单。

mern-mediastream/client/MainRouter.js:

<PrivateRoute path="/media/new" component={NewMedia}/>

此链接可以添加到任何视图,例如在菜单组件中,以便在用户登录时条件性地渲染。现在,由于可以在本媒体流应用中添加新的媒体帖子,在下一节中,我们将讨论检索和渲染与每个媒体帖子关联的视频内容的实现。这将使用户能够从应用程序的前端流式传输和查看存储在 MongoDB GridFS 中的视频文件。

检索和流式传输媒体

任何浏览 MERN Mediastream 应用程序的访客都将能够查看用户在应用程序上发布的媒体。实现此功能需要将存储在 MongoDB GridFS 中的视频文件流式传输到请求客户端,并在媒体播放器中渲染流。在以下章节中,我们将设置一个后端 API 来检索单个视频文件,然后我们将将其用作基于 React 的媒体播放器的源来渲染流式视频。

视频 API

要检索与单个媒体帖子关联的视频文件,我们将实现一个接受 GET 请求的 get 视频 API,请求地址为'/api/medias/video/:mediaId',并查询媒体集合和 GridFS 文件。我们将通过声明以下代码中的路由以及处理 URL 中的:mediaId参数的方式来实现此视频 API。

mern-mediastream/server/routes/media.routes.js:

router.route('/api/medias/video/:mediaId')
        .get(mediaCtrl.video)
router.param('mediaId', mediaCtrl.mediaByID)

路由 URL 中的:mediaId参数将在mediaByID控制器中处理,以从媒体集合和 GridFS 文件中检索相关文档和文件详情。然后,这些检索到的结果将附加到请求对象中,以便可以在video控制器方法中按需使用。此mediaByID控制器方法定义如下。

mern-mediastream/server/controllers/media.controller.js:

const mediaByID = async (req, res, next, id) => {
  try{
  let media = await Media.findById(id).populate('postedBy', 
      '_id name').exec()
    if (!media)
      return res.status('400').json({
        error: "Media not found"
      })
      req.media = media
     let files = await gridfs.find({filename:media._id}).toArray()
 if (!files[0]) {
 return res.status(404).send({
 error: 'No video found'
 })
 } 
 req.file = files[0]
        next()
    }catch(err) {
      return res.status(404).send({
        error: 'Could not retrieve media file'
      })
    }
}

要从 GridFS 检索相关文件详情,我们使用 MongoDB 流式 API 中的find。我们通过文件名值查询存储在 GridFS 中的文件,该值应与媒体集合中相应的媒体 ID 相匹配。然后,我们以数组形式接收匹配的文件记录,并将第一个结果附加到请求对象中,以便在下一个方法中使用。

当这个 API 接收到请求时,调用的下一个方法是video控制器方法。在这个方法中,根据请求是否包含范围头,我们发送回正确的视频块,并将相关内容信息设置为响应头。video控制器方法定义如下结构,响应的组成取决于请求中是否存在范围头。

mern-mediastream/server/controllers/media.controller.js:

const video = (req, res) => {
  const range = req.headers["range"]  
    if (range && typeof range === "string") {
      ...
      ... consider range headers and send only relevant chunks in response ...
      ...
  } else {
      res.header('Content-Length', req.file.length)
      res.header('Content-Type', req.file.contentType)

      let downloadStream = gridfs.openDownloadStream(req.file._id)
      downloadStream.pipe(res)
      downloadStream.on('error', () => {
        res.sendStatus(404)
      })
      downloadStream.on('end', () => {
        res.end()
      })
   }
}

在前面的代码中,如果请求不包含范围头,我们使用gridfs.openDownloadStream流回整个视频文件,这为我们提供了存储在 GridFS 中的相应文件的可读流。这个流与发送回客户端的响应一起管道传输。在响应头中,我们设置了文件的内容类型和总长度。

如果请求包含范围头——例如,当用户拖动到视频中间并从该点开始播放时——我们需要将接收到的范围头转换为起始和结束位置,这将与存储在 GridFS 中的正确块相对应,如下面的代码所示。

mern-mediastream/server/controllers/media.controller.js:

    const parts = range.replace(/bytes=/, "").split("-")
    const partialstart = parts[0]
    const partialend = parts[1]

    const start = parseInt(partialstart, 10)
    const end = partialend ? parseInt(partialend, 10) : req.file.length - 1
    const chunksize = (end - start) + 1

    res.writeHead(206, {
        'Accept-Ranges': 'bytes',
 'Content-Length': chunksize,
 'Content-Range': 'bytes ' + start + '-' + end + '/' + req.file.length,
 'Content-Type': req.file.contentType
    })

    let downloadStream = gridfs.openDownloadStream(req.file._id, {start, end: end+1})
    downloadStream.pipe(res)
    downloadStream.on('error', () => {
      res.sendStatus(404)
    })
    downloadStream.on('end', () => {
      res.end()
    })

我们将已从头部提取的起始和结束值作为范围传递给gridfs.openDownloadStream。这些起始和结束值指定了从 0 开始的字节数,以开始流式传输并在此之前停止流式传输。我们还设置了包含附加文件详情的响应头,包括内容长度、范围和类型。内容长度现在将是定义范围内的内容总大小。因此,返回给响应的可读流,在这种情况下,将只包含位于起始和结束范围内的文件数据块。

在接收到此获取视频 API 请求后,最终的可读流被管道传输到响应,可以直接在前端视图的基本 HTML5 媒体播放器或 React 风格的媒体播放器中渲染。在下一节中,我们将探讨如何在简单的 React 媒体播放器中渲染此视频流。

使用 React 媒体播放器渲染视频

在应用程序的前端,我们可以在媒体播放器中渲染从 MongoDB GridFS 流出的视频文件。对于 React 风格的媒体播放器,一个好的选择是作为节点模块提供的ReactPlayer组件,可以根据需要自定义。将视频流作为源提供给默认的ReactPlayer组件将渲染带有基本播放控件,如下面的截图所示:

图片

要开始在前端代码中使用 ReactPlayer,我们需要通过在命令行中运行以下 Yarn 命令来安装相应的节点模块:

yarn add react-player 

安装完成后,我们可以将其导入到任何 React 组件中,并将其添加到视图中。对于使用浏览器提供的默认控件的基本用法,我们可以在任何具有要渲染的媒体 ID 访问权限的应用程序中的任何 React 视图中添加它,如下面的代码所示:

<ReactPlayer url={'/api/media/video/'+media._id} controls/>

这将加载从获取视频 API 收到的视频流播放器,并为用户提供基本控制选项来与正在播放的流进行交互。ReactPlayer 可以进行自定义,以便提供更多选项。我们将在下一章中探讨一些用于自定义此 ReactPlayer 并使用我们自己的控件的高级选项。

要了解 ReactPlayer 可以实现的功能,请访问 cookpete.com/react-playe…

现在,可以检索存储在 MongoDB GridFS 中的单个视频文件并将其流式传输到前端媒体播放器,以便用户可以按需查看和播放视频。在下一节中,我们将讨论如何从后端获取并显示多个视频列表到流媒体应用程序的前端。

列出媒体

在 MERN Mediastream 中,我们将添加相关媒体的列表视图,每个视频都有一个快照,以便访客更容易访问并对应用程序中的视频有一个概述。例如,在下面的屏幕截图中,Profile 组件显示对应用户发布的媒体列表,显示每个媒体的预览和其他详细信息:

图片

我们将在后端设置列表 API 来检索不同的列表,例如单个用户上传的视频和应用程序中观看次数最高的最受欢迎的视频。然后,这些检索到的列表可以在可重用的 MediaList 组件中渲染,该组件将从获取特定 API 的父组件接收媒体对象列表作为属性。在以下章节中,我们将实现 MediaList 组件和后端 API,以从数据库中检索两种不同的媒体列表。

媒体列表组件

MediaList 组件是一个可重用的组件,它将接受一个媒体列表并遍历它,在视图中渲染每个媒体项。在 MERN Mediastream 中,我们使用它来渲染主页视图中最受欢迎的媒体列表以及特定用户在其个人资料中上传的媒体列表。

MediaList 组件的视图部分,我们将使用 map 通过 props 中接收到的 media 数组,如下面的代码所示。

mern-mediastream/client/media/MediaList.js:

<GridList cols={3}>
    {props.media.map((tile, i) => (
        <GridListTile key={i}>
          <Link to={"/media/"+tile._id}>
            <ReactPlayer url={'/api/media/video/'+tile._id} 
               width='100%' height='inherit' style=
                 {{maxHeight:   '100%'}}/>
          </Link>
          <GridListTileBar title={<Link 
              to={"/media/"+tile._id}> {tile.title} </Link>}
            subtitle={<span>
                        <span>{tile.views} views</span>
                        <span className={classes.tileGenre}>
                        <em>{tile.genre}</em>
                        </span>
                    </span>}
          />
        </GridListTile>
    ))}
</GridList>

这个MediaList组件使用 Material-UI 的GridList组件,在遍历传入 props 的对象数组时渲染列表中每个项目的媒体详情。它还包括一个ReactPlayer组件,该组件渲染视频 URL 而不显示任何控件。在视图中,这为访客提供了每件媒体的一个简要概述,以及视频内容的预览。

这个组件可以添加到任何可以提供媒体对象数组的视图中。在 MERN Mediastream 应用程序中,我们使用它来渲染两个不同的媒体列表:一个是热门媒体列表,另一个是特定用户发布的媒体列表。在下一节中,我们将探讨如何从数据库中检索热门媒体列表并在前端渲染它。

列出热门媒体

为了从数据库中检索特定的媒体列表,我们需要在服务器上设置相关的 API。对于热门媒体,我们将设置一个接收/api/media/popular的 GET 请求的路由。该路由的声明如下。

mern-mediastream/server/routes/media.routes.js:

 router.route('/api/media/popular')
          .get(mediaCtrl.listPopular)

对此 URL 的 GET 请求将调用listPopular方法。listPopular控制器方法将查询媒体集合并检索整个集合中观看次数最高的九个媒体文档。listPopular方法定义如下。

mern-mediastream/server/controllers/media.controller.js:

const listPopular = async (req, res) => {
  try{
    let media = await Media.find({})
    .populate('postedBy', '_id name')
    .sort('-views')
    .limit(9)
    .exec()
    res.json(media)
  } catch(err){
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

查询媒体集合返回的结果按观看次数降序排列,并限制为九个。列表中的每个媒体文档也将包含发布该文档的用户名称和 ID,因为我们调用populate来添加这些用户属性。

这个 API 可以用一个 fetch 请求在前端使用。你可以在api-media.js中定义一个相应的 fetch 方法来发起请求,类似于其他 API 实现。然后,可以在 React 组件中调用这个 fetch 方法,例如在本应用程序的Home组件中。在Home组件中,我们将使用useEffect钩子获取热门视频列表,如下面的代码所示。

mern-mediastream/client/core/Home.js:

  useEffect(() => {
    const abortController = new AbortController()
    const signal = abortController.signal
    listPopular(signal).then((data) => {
      if (data.error) {
        console.log(data.error)
      } else {
        setMedia(data)
      }
    })
    return function cleanup(){
      abortController.abort()
    }
  }, [])

在此钩子中从 API 获取的列表被设置在状态中,以便可以将其传递给视图中的MediaList组件。在主页视图中,我们可以添加MediaList,如下所示,将列表作为 prop 提供。

mern-mediastream/client/core/Home.js:

<MediaList media={media}/>

这将在 MERN Mediastream 应用程序的主页上渲染数据库中最受欢迎的前九个视频列表。在下一节中,我们将讨论一个类似的实现来检索和渲染特定用户发布的媒体列表。

按用户列出媒体

为了能够从数据库中检索特定用户上传的媒体列表,我们将设置一个 API,该 API 通过/api/media/by/:userId接受一个GET请求。该路由的声明如下。

mern-mediastream/server/routes/media.routes.js:

router.route('/api/media/by/:userId')
         .get(mediaCtrl.listByUser) 

对此路由的 GET 请求将调用listByUser方法。listByUser控制器方法将查询 Media 集合以找到具有与 URL 中附加的userId匹配的postedBy值的媒体文档。listByUser控制器方法定义如下。

mern-mediastream/server/controllers/media.controller.js:

const listByUser = async (req, res) => {
  try{
    let media = await Media.find({postedBy: req.profile._id})
      .populate('postedBy', '_id name')
      .sort('-created')
      .exec()
    res.json(media)
  } catch(err){
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
  }
}

从 Media 集合查询返回的结果按创建日期排序,最新帖子首先显示。此列表中的每个媒体文档也将包含发布该文档的用户名称和 ID,因为我们调用populate来添加这些用户属性。

此 API 可以通过前端使用 fetch 请求来使用。你可以在api-media.js中定义相应的fetch方法来发起请求,类似于其他 API 实现。然后,可以在 React 组件中调用fetch方法。在我们的应用程序中,我们使用Profile组件中的fetch方法,类似于我们在主页视图中使用的listPopular fetch 方法,来检索列表数据,将其设置到状态中,然后传递给MediaList组件。这将渲染一个包含相应用户发布的媒体列表的个人信息页面。

我们可以通过利用后端已实现的 API 来获取列表数据,在应用程序中检索和显示多个视频。我们还可以在前端视图中渲染列表时,使用无控制条的 ReactPlayer 组件,让用户对每个视频有一个大致的了解。在下一节中,我们将讨论将显示媒体帖子并允许授权用户在应用程序中更新和删除单个媒体帖子的全栈切片。

显示、更新和删除媒体

每位访问 MERN Mediastream 的访客都将能够查看媒体详细信息并流式传输视频,而只有注册用户才能编辑媒体详情并在发布后随时删除它。在以下章节中,我们将实现包括后端 API 和前端视图在内的全栈切片,以显示单个媒体帖子、更新媒体帖子的详情以及从应用程序中删除媒体帖子。

显示媒体

每位访问 MERN Mediastream 的访客都将能够浏览到单个媒体视图来播放视频并读取与之相关的详细信息。每次在应用程序中加载特定视频时,我们也将增加与媒体相关的观看次数。在以下章节中,我们将通过向后端添加读取媒体 API、从前端调用此 API 以及将在视图中显示媒体详细信息的相关 React 组件来实现单个媒体视图。

读取媒体 API

在后端实现读取媒体 API,我们首先通过添加一个GET路由来查询带有 ID 的Media集合,并在响应中返回媒体文档。该路由声明如下。

mern-mediastream/server/routes/media.routes.js:

router.route('/api/media/:mediaId')
    .get( mediaCtrl.incrementViews, mediaCtrl.read)

请求 URL 中的 mediaId 将导致 mediaByID 控制器方法执行,并将检索到的媒体文档附加到请求对象中,以便在下一个方法中访问。

向此 API 发送 GET 请求将执行 incrementViews 控制器方法,该方法将找到匹配的媒体记录,并将 views 值增加 1,然后将更新后的记录保存到数据库中。incrementViews 方法定义如下。

mern-mediastream/server/controllers/media.controller.js:

const incrementViews = async (req, res, next) => {
  try {
    await Media.findByIdAndUpdate(req.media._id, 
       {$inc: {"views": 1}}, {new: true}).exec()
    next()
  } catch(err){
      return res.status(400).json({
          error: errorHandler.getErrorMessage(err)
      })
  }
}

每次调用此读取媒体 API 时,此方法将增加给定媒体的观看次数 1。从 incrementViews 方法更新媒体后,将调用 read 控制器方法。read 控制器方法将简单地返回检索到的媒体文档作为对请求客户端的响应,如下所示。

mern-mediastream/server/controllers/media.controller.js:

const read = (req, res) => {
  return res.json(req.media)
}

要检索响应中发送的媒体文档,我们需要在前端使用 fetch 方法调用此读取媒体 API。我们将在 api-media.js 中设置相应的 fetch 方法,如下所示。

mern-mediastream/client/media/api-media.js:

const read = async (params, signal) => {
  try {
    let response = await fetch('/api/media/' + params.mediaId, {
    method: 'GET',
    signal: signal
  })
    return await response.json()
  } catch(err) {
    console.log(err)
  }
}

此方法获取要检索的媒体 ID,并使用 fetch 向读取 API 路由发送 GET 请求。

读取媒体 API 可以用于在视图中渲染单个媒体详细信息,或预先填充媒体编辑表单。在下一节中,我们将使用此 fetch 方法在 React 组件中调用读取媒体 API 以渲染媒体详细信息,以及将播放相关视频的 ReactPlayer

媒体组件

Media 组件将渲染单个媒体记录的详细信息,并使用基本 ReactPlayer 和默认浏览器控件进行视频流。完成的单个媒体视图将如下所示:

图片

Media 组件可以调用读取 API 来获取媒体数据本身,或者从调用读取 API 的父组件接收数据作为 props。在后一种情况下,父组件将在 useEffect 钩子中从服务器获取媒体,将其设置为状态,并将其添加到 Media 组件中,如下所示。

mern-mediastream/client/media/PlayMedia.js:

<Media media={media}/>

在 MERN Mediastream 中,我们将在 PlayMedia 组件中添加 Media 组件,该组件使用读取 API 在 useEffect 钩子中从服务器获取媒体内容,并将其作为 props 传递给 MediaPlayMedia 组件的组成将在下一章中更详细地讨论。

Media 组件将接受 props 中的数据,并在视图中渲染以显示详细信息,并在 ReactPlayer 组件中加载视频。媒体标题、类型和观看次数的详细信息可以在 Media 组件中的 Material-UI CardHeader 组件中渲染,如下所示。

mern-mediastream/client/media/Media.js:

<CardHeader 
   title={props.media.title}
   action={<span>
                {props.media.views + ' views'}
           </span>}
   subheader={props.media.genre}
/>

除了渲染这些媒体详情外,我们还将加载Media组件中的视频。视频 URL 基本上是我们后端设置的获取视频 API 路由,在ReactPlayer中以默认浏览器控件加载,如下面的代码所示。

mern-mediastream/client/media/Media.js:

const mediaUrl = props.media._id
          ? `/api/media/video/${props.media._id}`
          : null
            … 
<ReactPlayer url={mediaUrl} 
             controls
             width={'inherit'}
             height={'inherit'}
             style={{maxHeight: '500px'}}
             config={{ attributes: 
                        { style: { height: '100%', width: '100%'} } 
}}/>

这将渲染一个简单的播放器,允许用户播放视频流。

Media 组件还会渲染发布视频的用户的其他详细信息,视频描述以及创建日期,如下面的代码所示。

mern-mediastream/client/media/Media.js:

<ListItem>
    <ListItemAvatar>
      <Avatar>
        {props.media.postedBy.name && 
                        props.media.postedBy.name[0]}
      </Avatar>
    </ListItemAvatar>
    <ListItemText primary={props.media.postedBy.name} 
              secondary={"Published on " + 
                        (new Date(props.media.created))
                        .toDateString()}/>
</ListItem>
<ListItem>
    <ListItemText primary={props.media.description}/>
</ListItem>

在 Material-UI ListItem 组件中显示的详情中,我们还将根据当前登录用户是否是显示的媒体发布者有条件地显示编辑和删除选项。为了在视图中有条件地渲染这些元素,我们将在显示日期的ListItemText之后添加以下代码。

mern-mediastream/client/media/Media.js:

{(auth.isAuthenticated().user && auth.isAuthenticated().user._id) 
    == props.media.postedBy._id && (<ListItemSecondaryAction>
        <Link to={"/media/edit/" + props.media._id}>
          <IconButton aria-label="Edit" color="secondary">
            <Edit/>
          </IconButton>
        </Link>
        <DeleteMedia mediaId={props.media._id} mediaTitle=
       {props.media.title}/>
      </ListItemSecondaryAction>)}

这将确保只有在当前用户已登录并且是显示的媒体的上传者时,才会渲染编辑和删除选项。编辑选项链接到媒体编辑表单,而删除选项打开一个对话框,可以启动从数据库中删除此特定媒体文档的操作。在下一节中,我们将实现此选项的功能,以编辑已上传媒体帖子的详情。

更新媒体详情

注册用户将能够访问他们每个媒体上传的编辑表单。更新并提交此表单将保存对媒体集合中给定文档的更改。为了实现这一功能,我们需要创建一个后端 API,允许在确认请求用户已认证并授权后对给定媒体进行更新操作。然后,需要从前端调用此更新 API,并带上媒体更改的详细信息。在接下来的章节中,我们将构建这个后端 API 和 React 组件,以便用户能够修改他们在应用程序上已发布的媒体。

媒体更新 API

在后端,我们需要一个 API,允许我们在用户是请求的媒体帖子的授权创建者的情况下更新数据库中的现有媒体。首先,我们将声明 PUT 路由,该路由接受来自客户端的更新请求。

mern-mediastream/server/routes/media.routes.js:

router.route('/api/media/:mediaId')
        .put(authCtrl.requireSignin, 
                mediaCtrl.isPoster, 
                    mediaCtrl.update)

当接收到 'api/media/:mediaId' 的 PUT 请求时,服务器将确保已登录用户是媒体内容的原始发布者,通过调用isPoster控制器方法。isPoster控制器方法定义如下。

mern-mediastream/server/controllers/media.controller.js:

const isPoster = (req, res, next) => {
 let isPoster = req.media && req.auth 
    && req.media.postedBy._id == req.auth._id
 if(!isPoster){
 return res.status('403').json({
 error: "User is not authorized"
 })
 }
 next()
}

此方法确保认证用户的 ID 与给定媒体文档中postedBy字段引用的用户 ID 相同。如果用户被授权,则将调用update控制器方法next以使用更改更新现有的媒体文档。update控制器方法定义如下。

mern-mediastream/server/controllers/media.controller.js:

const update = async (req, res) => {
  try {
    let media = req.media
    media = extend(media, req.body)
    media.updated = Date.now()
    await media.save()
    res.json(media)
  } catch(err){
    return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
    })
  }
}

此方法通过在请求体中接收到的更改详情扩展现有的媒体文档,并将更新的媒体保存到数据库中。

为了在前端访问更新 API,我们将在api-media.js中添加一个相应的 fetch 方法,该方法在向此更新媒体 API 发出 fetch 调用之前,将必要的用户认证凭证和媒体详细信息作为参数传递,如下所示。

mern-mediastream/client/user/api-media.js:

const update = async (params, credentials, media) => {
  try {
    let response = await fetch('/api/media/' + params.mediaId, {
    method: 'PUT',
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ' + credentials.t
    },
    body: JSON.stringify(media)
  }) 
    return await response.json()
    } catch(err) {
      console.log(err)
    }
}

当用户进行更新并提交表单时,将使用此 fetch 方法在媒体编辑表单中。在下一节中,我们将讨论此媒体编辑表单的实现。

媒体编辑表单

允许授权用户更改媒体帖子详细信息的媒体编辑表单将与新媒体表单类似。然而,它将没有上传选项,字段将预先填充现有值,如下面的截图所示:

包含此表单的EditMedia组件将通过在useEffect钩子中调用读取媒体 API 来获取媒体的现有值,如下面的代码所示。

mern-mediastream/client/media/EditMedia.js:

  useEffect(() => {
    const abortController = new AbortController()
    const signal = abortController.signal

    read({mediaId: match.params.mediaId}).then((data) => {
      if (data.error) {
        setError(data.error)
      } else {
        setMedia(data)
      }
    })
    return function cleanup(){
      abortController.abort()
    }
  }, [match.params.mediaId])

获取的媒体详细信息被设置为状态,以便可以在文本字段中渲染值。表单字段元素将与NewMedia组件中的相同。当用户更新表单中的任何值时,通过调用handleChange方法,这些更改将在状态中的media对象中注册。handleChange方法定义如下。

mediastream/client/media/EditMedia.js:

  const handleChange = name => event => {
    let updatedMedia = {...media}
    updatedMedia[name] = event.target.value
    setMedia(updatedMedia)
  }

在这个方法中,表单中正在更新的特定字段会在状态中的媒体对象对应的属性中反映出来。当用户完成编辑并点击提交时,将调用带有所需凭证和更改后的媒体值的更新 API。这是通过调用以下定义的clickSubmit方法来完成的。

mediastream/client/media/EditMedia.js:

  const clickSubmit = () => {
    const jwt = auth.isAuthenticated()
    update({
      mediaId: media._id
    }, {
      t: jwt.token
    }, media).then((data) => {
      if (data.error) {
        setError(data.error)
      } else {
        setRedirect(true)
      }
    })
  }

调用更新媒体 API 将更新媒体集合中相应媒体文档的媒体详细信息,而与媒体关联的视频文件在数据库中保持不变。

这个EditMedia组件只能由已登录的用户访问,并将渲染在'/media/edit/:mediaId'。因此,我们将在MainRouter组件中添加一个PrivateRoute,如下所示。

mern-mediastream/client/MainRouter.js:

<PrivateRoute path="/media/edit/:mediaId" component={EditMedia}/>

此链接在Media组件中添加了一个编辑图标,允许发布媒体的用户访问编辑页面。在Media视图中,用户还可以选择删除他们的媒体帖子。我们将在下一节中实现此功能。

删除媒体

授权用户可以完全删除他们上传到应用程序的媒体,包括媒体集合中的媒体文档和在 MongoDB 中使用的 GridFS 存储的文件块。为了允许用户从应用程序中删除媒体,在以下章节中,我们将定义一个从数据库中删除媒体的后端 API,并实现一个 React 组件,当用户与前端交互以执行此删除操作时,该组件将使用此 API。

删除媒体 API

要从数据库中删除媒体,我们将在后端实现一个删除媒体 API,该 API 将接受客户端在/api/media/:mediaId上的 DELETE 请求。我们将为此 API 添加以下DELETE路由,这将允许授权用户删除他们上传的媒体记录。

mern-mediastream/server/routes/media.routes.js:

router.route('/api/media/:mediaId')
        .delete(authCtrl.requireSignin, 
                    mediaCtrl.isPoster, 
                        mediaCtrl.remove)

当服务器在'/api/media/:mediaId'接收到 DELETE 请求时,它将通过调用isPoster控制器方法来确保已登录用户是媒体的原帖发布者。然后,remove控制器方法将完全从数据库中删除指定的媒体。remove方法定义如下。

mern-mediastream/server/controllers/media.controller.js:

const remove = async (req, res) => {
  try {
    let media = req.media
    let deletedMedia = await media.remove()
    gridfs.delete(req.file._id)
    res.json(deletedMedia)
  } catch(err) {
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

除了从媒体集合中删除媒体记录外,我们还使用gridfs来删除存储在数据库中的相关文件详情和块。

要在前端访问此后端 API,您还需要一个具有此路由的 fetch 方法,类似于其他 API 实现。fetch 方法需要获取媒体 ID 和当前用户的认证凭证,以便使用这些值调用删除媒体 API。

当用户通过在前端界面中点击按钮执行删除操作时,将使用 fetch 方法。在下一节中,我们将讨论一个名为DeleteMedia的 React 组件,其中用户将通过此删除媒体操作执行。

DeleteMedia 组件

DeleteMedia组件被添加到Media组件中,并且只对添加了此特定媒体的已登录用户可见。此组件基本上是一个按钮,当点击时,会打开一个Dialog组件,提示用户确认删除操作,如以下截图所示:

图片

DeleteMedia组件在Media组件中添加时,它接受媒体 ID 和标题作为 props。其实现方式将与我们在第四章中讨论的DeleteUser组件类似,即添加 React 前端以完成 MERN。一旦添加了DeleteMedia组件,用户通过确认他们的操作,就能完全从应用程序中移除发布的媒体。

本章中我们开发的 MERN Mediastream 应用程序是一个完整的媒体流应用程序,具有将视频文件上传到数据库、流回存储的视频给观众、支持如媒体创建、更新、读取和删除等 CRUD 操作,以及支持按上传者或流行度列出媒体的功能。

摘要

在本章中,我们通过扩展 MERN 框架应用程序并利用 MongoDB GridFS 开发了媒体流应用程序。

除了为媒体上传添加基本的添加、更新、删除和列表功能外,我们还探讨了基于 MERN 的应用程序如何允许用户上传视频文件,将这些文件作为块存储到 MongoDB GridFS 中,并根据需要部分或全部流回给观众。我们还介绍了如何使用带有默认浏览器控制的ReactPlayer来流式传输视频文件。您可以将这些流式传输功能应用于可能需要从数据库中存储和检索大型文件的任何全栈应用程序。

在下一章中,我们将学习如何自定义ReactPlayer以包含我们自己的控件和功能,以便用户有更多的选择,例如播放列表中的下一个视频。此外,我们还将讨论如何通过实现带有媒体视图数据的服务器端渲染来提高媒体详情的 SEO。