五、从一个简单的社交媒体应用开始

如今,社交媒体已成为 web 不可或缺的一部分,我们构建的许多以用户为中心的 web 应用最终都需要社交组件来推动用户参与。

对于我们的第一个真实世界的 MERN 应用,我们将修改和扩展上一章中开发的 MERN 骨架应用,以构建一个简单的社交媒体应用。

在本章中,我们将介绍以下社交媒体特色的实现:

  • 带有说明和照片的用户配置文件
  • 用户相互跟踪
  • 谁来遵循这些建议
  • 发布带有照片的消息
  • 带有跟踪用户帖子的新闻提要
  • 按用户列出帖子
  • 喜欢的帖子
  • 评论帖子

梅恩社会

MERN Social 是一款社交媒体应用,其基本功能源自 Facebook 和 Twitter 等现有社交媒体平台。此应用的主要目的是演示如何使用 MERN 堆栈技术实现允许用户通过内容连接和交互的功能。您可以根据需要进一步扩展这些实现,以实现更复杂的功能:

Code for the complete MERN Social application is available on GitHub in the repository at github.com/shamahoque/mern-social. You can clone this code and run the application as you go through the code explanations in the rest of this chapter.

MERN 社交应用所需的视图将通过扩展和修改 MERN 骨架应用中现有的 React 组件来开发。我们还将添加新的自定义组件来组合视图,包括一个 Newsfeed 视图,用户可以在其中创建新帖子,还可以浏览他们关注 MERN Social 的人的所有帖子的列表。下面的组件树显示了组成 MERN Social 前端的所有自定义 React 组件,还公开了我们将用于构建其余部分中的视图的合成结构第章:

更新用户配置文件

骨架应用只支持用户名、电子邮件和密码。但在 MERN Social 中,我们允许用户添加关于自己的描述,并在注册后编辑个人资料时上传个人资料照片:

添加关于描述

为了存储用户在about字段中输入的描述,我们需要在server/models/user.model.js中的用户模型中添加一个about字段:

about: {
    type: String,
    trim: true
  }

然后,为了从用户那里获得作为输入的描述,我们在EditProfile表单中添加了一个多行TextField,并以与用户名输入相同的方式处理值更改。

mern-social/client/user/EditProfile.js

  <TextField
      id="multiline-flexible"
      label="About"
      multiline
      rows="2"
      value={this.state.about}
      onChange={this.handleChange('about')}
   />

最后,为了显示添加到用户配置文件页面about字段的描述文本,我们可以将其添加到现有的配置文件视图中。

mern-social/client/user/Profile.js

<ListItem> <ListItemText primary={this.state.user.about}/> </ListItem>

通过对 MERN 骨架代码中用户特性的修改,用户现在可以添加和更新关于他们自己的描述,以显示在他们的配置文件中。

上传个人资料照片

允许用户上传个人资料照片需要我们存储上传的图像文件,并根据请求将其检索到视图中加载。考虑到不同的文件存储选项,有多种实现此上载功能的方法:

  • 服务器文件系统:将文件上传并保存到服务器文件系统,并将 URL 存储到 MongoDB
  • 外部文件存储:将文件保存到 Amazon S3 等外部存储,并将 URL 存储在 MongoDB 中
  • 在 MongoDB 中存储为数据:将小文件(小于 16MB)作为缓冲区类型的数据保存到 MongoDB 中

对于 MERN Social,我们将假设用户上传的照片文件大小较小,并演示如何将这些文件存储在 MongoDB 中,以实现个人资料照片上传功能。在第 8 章构建流媒体应用中,我们将讨论如何使用 GridFS 在 MongoDB 中存储较大的文件。

更新用户模型以在 MongoDB 中存储照片

为了将上传的个人资料照片直接存储在数据库中,我们将更新用户模型,添加一个photo字段,该字段将文件存储为Buffer类型的data及其contentType

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

photo: {
    data: Buffer,
    contentType: String
}

从编辑表单上载照片

用户可以在编辑配置文件时从本地文件上载图像文件。我们将使用上传照片选项更新client/user/EditProfile.js中的EditProfile组件,然后将用户选择的文件附加到提交给服务器的表单数据中。

使用物料界面进行文件输入

我们将利用 HTML5 文件输入类型,让用户从本地文件中选择图像。当用户选择文件时,文件输入将在更改事件中返回文件名。

mern-social/client/user/EditProfile.js

<input accept="image/*" type="file"
       onChange={this.handleChange('photo')} 
       style={{display:'none'}} 
       id="icon-button-file" />

为了将此文件input与物料 UI 组件集成,我们应用display:none从视图中隐藏input元素,然后在标签内添加物料 UI 按钮,用于此文件输入。这样,视图将显示 Material UI 按钮,而不是 HTML5 文件输入元素。

mern-social/client/user/EditProfile.js

<label htmlFor="icon-button-file">
   <Button variant="raised" color="default" component="span">
      Upload <FileUpload/>
   </Button>
</label>

Button的组件属性设置为span时,Button组件呈现为label元素内部的span元素。点击Upload跨距或标签时,文件输入会以与标签相同的 ID 注册,因此,文件选择对话框会打开。一旦用户选择了一个文件,我们可以在调用handleChange(...)时将其设置为状态,并在视图中显示名称。

mern-social/client/user/EditProfile.js

<span className={classes.filename}>
    {this.state.photo ? this.state.photo.name : ''}
</span>

随附文件的表格提交

与上一个实现中发送的stringed对象不同,使用表单将文件上载到服务器需要提交多部分表单。我们将修改EditProfile组件,使用FormDataAPI 以编码类型multipart/form-data所需的格式存储表单数据。

首先,我们需要在componentDidMount()中初始化FormData

mern-social/client/user/EditProfile.js

this.userData = new FormData() 

接下来,我们将更新输入handleChange函数,将文本字段和文件输入的输入值存储在FormData中。

mern-social/client/user/EditProfile.js

handleChange = name => event => {
  const value = name === 'photo'
    ? event.target.files[0]
    : event.target.value
  this.userData.set(name, value)
  this.setState({ [name]: value })
}

然后在提交时,this.userData与 fetch API 调用一起发送,以更新用户。由于发送到服务器的数据的内容类型不再是'application/json',我们还需要修改api-user.js中的updatefetch 方法,将Content-Typefetch调用的头中删除。

mern-social/client/user/api-user.js

const update = (params, credentials, user) => {
  return fetch('/api/users/' + params.userId, {
    method: 'PUT',
    headers: {
      'Accept': 'application/json',
      'Authorization': 'Bearer ' + credentials.t
    },
    body: user
  }).then((response) => {
    return response.json()
  }).catch((e) => {
    console.log(e)
  })
}

现在,如果用户在编辑配置文件时选择上载配置文件照片,服务器将收到一个请求,其中包含附加的文件以及其他字段值。

Learn more about the FormData API at developer.mozilla.org/en-US/docs/Web/API/FormData.

处理包含文件上载的请求

在服务器上,为了处理对更新 API 的请求,现在可能包含一个文件,我们将使用formidablenpm 模块:

npm install --save formidable

强大将允许我们读取multipart表单数据,允许访问字段和文件(如果有的话)。如果有文件,formidable将临时存储在文件系统中。我们将从文件系统中读取它,使用fs模块检索文件类型和数据,并将其存储到用户模型中的 photo 字段中。formidable代码将进入user.controller.js中的update控制器中,如下所示。

mern-social/server/controllers/user.controller.js

import formidable from 'formidable'
import fs from 'fs'
const update = (req, res, next) => {
  let form = new formidable.IncomingForm()
  form.keepExtensions = true
  form.parse(req, (err, fields, files) => {
    if (err) {
      return res.status(400).json({
        error: "Photo could not be uploaded"
      })
    }
    let user = req.profile
    user = _.extend(user, fields)
    user.updated = Date.now()
    if(files.photo){
      user.photo.data = fs.readFileSync(files.photo.path)
      user.photo.contentType = files.photo.type
    }
    user.save((err, result) => {
      if (err) {
        return res.status(400).json({
          error: errorHandler.getErrorMessage(err)
        })
      }
      user.hashed_password = undefined
      user.salt = undefined
      res.json(user)
    })
  })
}

这将上传的文件作为数据存储在数据库中。接下来,我们将设置文件检索,以便能够在前端视图中访问和显示用户上传的照片。

检索个人资料照片

要检索存储在数据库中的文件并在视图中显示它,最简单的方法是设置一个路由,该路由将获取数据并将其作为图像文件返回给请求的客户端。

个人资料照片 URL

我们将为每个用户设置一个到数据库中存储的照片的路由,并且还将添加另一个路由,如果给定用户没有上传个人资料照片,该路由将获取默认照片。

mern-social/server/routes/user.routes.js

router.route('/api/users/photo/:userId')
  .get(userCtrl.photo, userCtrl.defaultPhoto)
router.route('/api/users/defaultphoto')
  .get(userCtrl.defaultPhoto)

我们将在photo控制器方法中查找照片,如果找到,则在照片路由的请求响应中发送,否则调用next()返回默认照片。

mern-social/server/controllers/user.controller.js

const photo = (req, res, next) => {
  if(req.profile.photo.data){
    res.set("Content-Type", req.profile.photo.contentType)
    return res.send(req.profile.photo.data)
  }
  next()
}

从服务器的文件系统检索并发送默认照片。

mern-social/server/controllers/user.controller.js

import profileImage from './../../client/iimg/profile-pic.png'
const defaultPhoto = (req, res) => {
  return res.sendFile(process.cwd()+profileImage)
}

在视图中显示照片

通过设置照片 URL 路由来检索照片,我们只需在img元素的src属性中使用这些路由即可将照片加载到视图中。例如,在Profile组件中,我们从 state 获取用户 ID,并使用它来构建照片 URL。

mern-social/client/user/Profile.js

const photoUrl = this.state.user._id
          ? `/api/users/photo/${this.state.user._id}?${new Date().getTime()}`
          : '/api/users/defaultphoto'

为了确保在编辑中更新照片后,img元素重新加载到Profile视图中,我们还向照片 URL 添加了一个时间值,以绕过浏览器的默认图像缓存行为

然后,我们可以将photoUrl设置为材质 UIAvatar组件,该组件在视图中渲染链接图像:

  <Avatar src={photoUrl}/>

MERN Social 中更新的用户配置文件现在可以显示用户上传的配置文件照片和about描述:

在 MERN Social 中跟踪用户

在 MERN Social 中,用户将能够相互跟踪。每个用户都会有一个追随者列表和他们关注的人列表。用户还可以看到他们可以关注的用户列表;换句话说,MERN Social 中的用户还没有开始关注。

随波逐流

为了跟踪哪个用户在跟踪哪个其他用户,我们必须为每个用户维护两个列表。当一个用户跟随或取消跟随另一个用户时,我们将更新一个用户的following列表和另一个用户的followers列表。

更新用户模型

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

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

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

这些引用将指向集合中由给定用户跟随或跟随的用户。

更新 userByID 控制器方法

当从后端检索单个用户时,我们希望user对象包含followingfollowers数组中引用的用户的名称和 ID。要检索这些详细信息,我们需要更新userByID控制器方法来填充返回的用户对象。

mern-social/server/controllers/user.controller.js

const userByID = (req, res, next, id) => {
  User.findById(id)
    .populate('following', '_id name')
    .populate('followers', '_id name')
    .exec((err, user) => {
    if (err || !user) return res.status('400').json({
      error: "User not found"
    })
    req.profile = user
    next()
  })
}

我们使用 Mongoosepopulate方法指定查询返回的用户对象应该包含followingfollowers列表中引用的用户的名称和 ID。当我们使用 read API 调用获取用户时,这将为我们提供followersfollowing列表中用户引用的名称和 ID。

要遵循和展开的 API

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

我们将在user.routes.js中设置followunfollow路线,如下所示。

mern-social/server/routes/user.routes.js

router.route('/api/users/follow')
  .put(authCtrl.requireSignin, userCtrl.addFollowing, userCtrl.addFollower)
router.route('/api/users/unfollow')
  .put(authCtrl.requireSignin, userCtrl.removeFollowing, userCtrl.removeFollower)

用户控制器中的addFollowing控制器方法将通过将后续用户的引用推送到数组中来更新当前用户的'following'数组。

mern-social/server/controllers/user.controller.js

const addFollowing = (req, res, next) => {
  User.findByIdAndUpdate(req.body.userId, {$push: {following: req.body.followId}}, (err, result) => {
    if (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
    next()
  })
}

成功更新后续数组后,执行addFollower方法将当前用户的引用添加到后续用户的'followers'数组中。

mern-social/server/controllers/user.controller.js

const addFollower = (req, res) => {
  User.findByIdAndUpdate(req.body.followId, {$push: {followers: req.body.userId}}, {new: true})
  .populate('following', '_id name')
  .populate('followers', '_id name')
  .exec((err, result) => {
    if (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
    result.hashed_password = undefined
    result.salt = undefined
    res.json(result)
  })
}

对于 unfollowing,实现类似。removeFollowingremoveFollower控制器方法通过使用$pull而不是$push删除用户引用来更新相应的'following''followers'阵列。

mern-social/server/controllers/user.controller.js

const removeFollowing = (req, res, next) => {
  User.findByIdAndUpdate(req.body.userId, {$pull: {following: req.body.unfollowId}}, (err, result) => {
    if (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
    next()
  })
}
const removeFollower = (req, res) => {
  User.findByIdAndUpdate(req.body.unfollowId, {$pull: {followers: req.body.userId}}, {new: true})
  .populate('following', '_id name')
  .populate('followers', '_id name')
  .exec((err, result) => {
    if (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
    result.hashed_password = undefined
    result.salt = undefined
    res.json(result)
  })
}

在视图中访问跟随和取消跟随 API

为了访问视图中的这些 API 调用,我们将使用followunfollow获取方法更新api-user.jsfollowunfollow方法将类似,使用当前用户的 ID 和凭证以及跟随或未跟随的用户 ID 调用各自的路由。follow方法将如下所示。

mern-social/client/user/api-user.js

const follow = (params, credentials, followId) => {
  return fetch('/api/users/follow/', {
    method: 'PUT',
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ' + credentials.t
    },
    body: JSON.stringify({userId:params.userId, followId: followId})
  }).then((response) => {
    return response.json()
  }).catch((err) => {
    console.log(err)
  }) 
}

unfollowfetch 方法与此类似,它获取未跟随的用户 ID 并调用unfollowAPI

mern-social/client/user/api-user.js

const unfollow = (params, credentials, unfollowId) => {
  return fetch('/api/users/unfollow/', {
    method: 'PUT',
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ' + credentials.t
    },
    body: JSON.stringify({userId:params.userId, unfollowId: unfollowId})
  }).then((response) => {
    return response.json()
  }).catch((err) => {
    console.log(err)
  })
}

跟随和取消跟随按钮

允许用户followunfollow其他用户的按钮将根据当前用户是否已经跟随该用户有条件地出现:

FollowProfileButton 组件

我们将为 follow 按钮创建一个名为FollowProfileButton的单独组件,该组件将添加到Profile组件中。此组件将显示FollowUnfollow按钮,具体取决于当前用户是否已经是配置文件中用户的跟随者。FollowProfileButton部分如下所示。

mern-social/client/user/FollowProfileButton.js

class FollowProfileButton extends Component {
  followClick = () => {
    this.props.onButtonClick(follow)
  }
  unfollowClick = () => {
    this.props.onButtonClick(unfollow)
  }
  render() {
    return (<div>
      { this.props.following
        ? (<Button variant="raised" color="secondary" onClick=
       {this.unfollowClick}>Unfollow</Button>)
        : (<Button variant="raised" color="primary" onClick=
       {this.followClick}>Follow</Button>)
      }
    </div>)
  }
}
FollowProfileButton.propTypes = {
  following: PropTypes.bool.isRequired,
  onButtonClick: PropTypes.func.isRequired
}

FollowProfileButton被添加到配置文件中时,将确定'following'值,并将其作为道具从Profile组件发送到FollowProfileButton,以及将要调用的特定followunfollow获取 API 作为参数的点击处理程序:

更新配置文件组件

Profile视图中,只有当用户查看其他用户的配置文件时才会显示FollowProfileButton,所以我们需要修改查看配置文件时显示EditDelete按钮的条件,如下所示:

{auth.isAuthenticated().user && auth.isAuthenticated().user._id == this.state.user._id 
    ? (edit and delete buttons) 
    : (follow button)
}

Profile组件中,在componentDidMount上成功抓取用户数据后,我们会检查登录用户是否已经跟随配置文件中的用户,并将following值设置为状态。

mern-social/client/user/Profile.js

let following = this.checkFollow(data) 
this.setState({user: data, following: following}) 

为了确定在following中设置的值,checkFollow方法会检查被抓取用户的追随者列表中是否存在登录用户,如果找到则返回match,否则如果没有找到匹配则返回undefined

mern-social/client/user/Profile.js

checkFollow = (user) => {
    const jwt = auth.isAuthenticated()
    const match = user.followers.find((follower)=> {
      return follower._id == jwt.user._id
    })
    return match
}

Profile组件还将定义FollowProfileButton的点击处理程序,因此Profile的状态可以在后续或取消后续操作完成时更新。

mern-social/client/user/Profile.js

clickFollowButton = (callApi) => {
    const jwt = auth.isAuthenticated()
    callApi({
      userId: jwt.user._id
    }, {
      t: jwt.token
    }, this.state.user._id).then((data) => {
      if (data.error) {
        this.setState({error: data.error})
      } else {
        this.setState({user: data, following: !this.state.following})
      }
    })
}

单击处理程序定义将 fetch API 调用作为参数,并在将其添加到Profile视图时作为道具与following值一起传递给FollowProfileButton

mern-social/client/user/Profile.js

<FollowProfileButton following={this.state.following} onButtonClick={this.clickFollowButton}/>

列出追随者和追随者

在每个用户的个人资料中,我们将添加他们的追随者和他们关注的人的列表:

followingfollowers列表中引用的用户的详细信息已经在加载概要文件时使用readAPI 获取的用户对象中。为了呈现这些单独的关注者和关注者列表,我们将创建一个名为FollowGrid的新组件

跟随网格组件

FollowGrid组件将用户列表作为道具,显示用户的头像及其姓名,并链接到每个用户的个人资料。我们可以根据需要将该组件添加到Profile视图中以显示followingsfollowers

mern-social/client/user/FollowGrid.js

class FollowGrid extends Component {
  render() {
    const {classes} = this.props
    return (<div className={classes.root}>
      <GridList cellHeight={160} className={classes.gridList} cols={4}>
        {this.props.people.map((person, i) => {
           return <GridListTile style={{'height':120}} key={i}>
              <Link to={"/user/" + person._id}>
                <Avatar src={'/api/users/photo/'+person._id} className=
               {classes.bigAvatar}/>
                <Typography className={classes.tileText}>{person.name}
               </Typography>
              </Link>
            </GridListTile>
        })}
      </GridList>
    </div>)
  }
}

FollowGrid.propTypes = {
  classes: PropTypes.object.isRequired,
  people: PropTypes.array.isRequired
}

要将FollowGrid组件添加到Profile视图中,我们可以根据需要将其放置在视图中,并将followersfollowings列表作为people道具传递:

<FollowGrid people={this.state.user.followers}/>
<FollowGrid people={this.state.user.following}/>

如前所示,在 MERN Social 中,我们选择在Profile组件的选项卡中显示FollowGrid组件。我们使用材质 UI 选项卡组件创建了一个单独的ProfileTabs组件,并将其添加到Profile组件中。此ProfileTabs组件包含两个FollowGrid组件,其中包含以下和关注者列表,以及一个PostList组件,该组件显示用户发布的帖子。这将在本章后面讨论。

寻找要跟随的人

“关注谁”功能将向登录用户显示 MERN Social 中当前未关注的人的列表,并提供关注他们或查看其个人资料的选项:

正在获取未跟踪的用户

我们将在服务器上实现一个新的 API,以查询数据库并获取当前用户未跟踪的用户列表。

mern-social/server/routes/user.routes.js

router.route('/api/users/findpeople/:userId')
   .get(authCtrl.requireSignin, userCtrl.findPeople)

findPeople控制器方法中,我们将查询数据库中的用户集合,找到不在当前用户following列表中的用户。

mern-social/server/controllers/user.controller.js

const findPeople = (req, res) => {
  let following = req.profile.following
  following.push(req.profile._id)
  User.find({ _id: { $nin : following } }, (err, users) => {
    if (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
    res.json(users)
  }).select('name')
}

要在前端使用此用户列表,我们将更新api-user.js以添加此 find people API 的获取。

mern-social/client/user/api-user.js

const findPeople = (params, credentials) => {
  return fetch('/api/users/findpeople/' + params.userId, {
    method: 'GET',
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ' + credentials.t
    }
  }).then((response) => {
    return response.json()
  }).catch((err) => console.log(err))
}

FindPeople 组件

为了显示跟随者功能,我们将创建一个名为FindPeople的组件,它可以添加到任何视图中,也可以自己渲染。在这个组件中,我们将首先获取用户,然后调用componentDidMount中的findPeople方法。

mern-social/client/user/FindPeople.js

componentDidMount = () => {
   const jwt = auth.isAuthenticated()
   findPeople({
     userId: jwt.user._id
   }, {
     t: jwt.token
   }).then((data) => {
     if (data.error) {
       console.log(data.error)
     } else {
       this.setState({users: data})
     }
   })
}

获取的用户列表将被迭代并呈现在材质 UIList组件中,每个列表项包含用户的化身、名称、到配置文件页面的链接和Follow按钮。

mern-social/client/user/FindPeople.js

<List>{this.state.users.map((item, i) => {
          return <span key={i}>
             <ListItem>
                <ListItemAvatar className={classes.avatar}>
                   <Avatar src={'/api/users/photo/'+item._id}/>
                </ListItemAvatar>
                <ListItemText primary={item.name}/>
                <ListItemSecondaryAction className={classes.follow}>
                  <Link to={"/user/" + item._id}>
                    <IconButton variant="raised" color="secondary" 
                     className={classes.viewButton}>
                      <ViewIcon/>
                    </IconButton>
                  </Link>
                  <Button aria-label="Follow" variant="raised" 
                    color="primary" 
                    onClick={this.clickFollow.bind(this, item, i)}>
                    Follow
                  </Button>
                </ListItemSecondaryAction>
             </ListItem>
          </span>
        })
      }
</List>

点击Follow按钮将调用 follow API,并通过拼接出新跟踪的用户来更新要跟踪的用户列表。

mern-social/client/user/FindPeople.js

clickFollow = (user, index) => {
    const jwt = auth.isAuthenticated()
    follow({
      userId: jwt.user._id
    }, {
      t: jwt.token
    }, user._id).then((data) => {
      if (data.error) {
        this.setState({error: data.error})
      } else {
        let toFollow = this.state.users
 toFollow.splice(index, 1)
 this.setState({users: toFollow, open: true, followMessage: 
       `Following ${user.name}!`})
      }
    })
}

我们还将添加一个 Material UISnackbar组件,当用户被成功跟踪时,该组件将临时打开,告诉用户他们开始跟踪这个新用户。

mern-social/client/user/FindPeople.js

<Snackbar
  anchorOrigin={{ vertical: 'bottom', horizontal: 'right'}}
  open={this.state.open}
  onClose={this.handleRequestClose}
  autoHideDuration={6000}
  message={<span className={classes.snack}>{this.state.followMessage}</span>}
/>

Snackbar将在页面右下角显示消息,并在设置的持续时间后自动隐藏:

MERN 社交用户现在可以互相关注,查看每个用户的关注者和追随者列表,还可以查看他们可以关注的人列表。在 MERN Social 中跟踪另一个用户的主要目的是跟踪他们的社交帖子,因此接下来我们将研究帖子功能的实现。

帖子

MERN Social 中的发布功能允许用户在 MERN Social 应用平台上共享内容,并通过评论或喜欢帖子的方式在内容上相互交流:

Post 的 Mongoose 模式模型

为了存储每个帖子,我们将首先在server/models/post.model.js中定义 Mongoose 模式。帖子模式将存储帖子的文本内容、照片、对发布用户的引用、创建时间、用户对帖子的喜好以及用户对帖子的评论:

  • 帖子文本text将是用户在新建帖子时从以下视图提供的必填字段:
text: {
  type: String,
  required: 'Name is required'
}
  • 帖子照片photo将在帖子创建过程中从用户本地文件上传,并存储在 MongoDB 中,类似于用户档案照片上传功能。每个帖子的照片都是可选的:
photo: {
  data: Buffer,
  contentType: String
}
  • 发帖人:创建发帖需要用户先登录,因此我们可以在postedBy字段中存储对发帖用户的引用:
postedBy: {type: mongoose.Schema.ObjectId, ref: 'User'}
  • 创建时间:在数据库中创建后期时自动生成created时间:
created: { type: Date, default: Date.now }
  • 喜欢:对喜欢特定帖子的用户的引用将存储在likes数组中:
likes: [{type: mongoose.Schema.ObjectId, ref: 'User'}]
  • 评论:帖子上的每条评论都将包含文本内容、创建时间以及对发布评论的用户的引用。每个帖子将有一个comments数组:
comments: [{
    text: String,
    created: { type: Date, default: Date.now },
    postedBy: { type: mongoose.Schema.ObjectId, ref: 'User'}
  }]

这个模式定义将使我们能够在 MERN Social 中实现所有与 post 相关的特性。

新闻源组件

在深入研究 MERN Social 中发布功能的实现之前,我们将查看 Newsfeed 视图的组成,以展示如何设计共享状态的嵌套 UI 组件的基本示例。Newsfeed组件将包含两个主要子组件—一个新的帖子表单和来自以下用户的帖子列表:

Newsfeed组件的基本结构如下,包括NewPost组件和PostList组件。

mern-social/client/post/Newsfeed.js

<Card>
   <Typography type="title"> Newsfeed </Typography>
   <Divider/>
   <NewPost addUpdate={this.addPost}/>
   <Divider/>
   <PostList removeUpdate={this.removePost} posts={this.state.posts}/>
</Card>

作为父组件,Newsfeed将控制子组件中呈现的帖子数据的状态。它将提供一种在子组件内修改 post 数据时跨组件更新 post 状态的方法,例如在NewPost组件中添加新 post,或从PostList组件中删除 post。

具体来说,Newsfeed中的loadPosts函数最初会调用服务器,从当前登录用户关注的人那里获取帖子列表,并将其设置为PostList组件中呈现的状态。Newsfeed组件为NewPostPostList提供addPostremovePost功能,当创建新帖子或删除现有帖子时,将使用该功能更新处于Newsfeed状态的帖子列表,并最终反映在PostList

Newsfeed组件中定义的addPost函数将获取NewPost组件中创建的新帖子,并将其添加到状态中的帖子中。

mern-social/client/post/Newsfeed.js

addPost = (post) => {
    const updatedPosts = this.state.posts
    updatedPosts.unshift(post)
    this.setState({posts: updatedPosts})
}

Newsfeed组件中定义的removePost功能将从PostList中的Post组件中获取已删除的帖子,并将其从状态中的帖子中移除。

mern-social/client/post/Newsfeed.js

removePost = (post) => {
    const updatedPosts = this.state.posts
    const index = updatedPosts.indexOf(post)
    updatedPosts.splice(index, 1)
    this.setState({posts: updatedPosts})
}

由于帖子以这种方式更新为Newsfeed的状态,PostList将向查看者呈现更改后的帖子列表。这种将状态更新从父组件转发到子组件并返回的机制将应用于其他功能,例如帖子中的注释更新,以及为Profile组件中的单个用户呈现PostList时。

列名职位

在 MERN Social 中,我们将在Newsfeed和每个用户的个人资料中列出帖子。我们将创建一个通用的PostList组件,它将呈现提供给它的任何帖子列表,我们可以在NewsfeedProfile组件中使用它。

mern-social/client/post/PostList.js

class PostList extends Component {
  render() {
    return (
      <div style={{marginTop: '24px'}}>
        {this.props.posts.map((item, i) => {
            return <Post post={item} key={i} 
                         onRemove={this.props.removeUpdate}/>
          })
        }
      </div>
    )
  }
}
PostList.propTypes = {
  posts: PropTypes.array.isRequired,
  removeUpdate: PropTypes.func.isRequired
}

PostList组件将遍历作为道具从NewsfeedProfile传递给它的帖子列表,并将每个帖子的数据传递给Post组件,该组件将呈现帖子的细节。PostList还将把作为道具从父组件发送到Post组件的removeUpdate功能传递给Post组件,因此删除单个帖子时可以更新状态

新闻提要中的列表

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

帖子的新闻提要 API

此特定于新闻源的 API 将通过server/routes/post.routes.js中定义的以下路径接收请求:

router.route('/api/posts/feed/:userId')
  .get(authCtrl.requireSignin, postCtrl.listNewsFeed)

我们在这个路由中使用:userID参数来指定当前登录的用户,我们将使用user.controller中的userByID控制器方法来获取用户详细信息,就像我们之前做的那样,并将它们附加到listNewsFeedpost 控制器方法中访问的请求对象中。因此,在mern-social/server/routes/post.routes.js中也添加以下内容:

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

post.routes.js文件将非常类似于user.routes.js文件,要在 Express 应用中加载这些新路由,我们需要在express.js中装载 post 路由,就像我们对 auth 和 user 路由所做的那样。

mern-social/server/express.js

app.use('/', postRoutes)

post.controller.js中的listNewsFeed控制器方法将查询数据库中的帖子集合,以获得匹配的帖子。

mern-social/server/controllers/post.controller.js

const listNewsFeed = (req, res) => {
  let following = req.profile.following
  following.push(req.profile._id)
  Post.find({postedBy: { $in : req.profile.following } })
   .populate('comments', 'text created')
   .populate('comments.postedBy', '_id name')
   .populate('postedBy', '_id name')
   .sort('-created')
   .exec((err, posts) => {
     if (err) {
       return res.status(400).json({
         error: errorHandler.getErrorMessage(err)
       })
     }
     res.json(posts)
   })
}

在对帖子集合的查询中,我们找到了所有具有postedBy用户引用的帖子,这些用户引用与当前用户的以下内容和当前用户匹配。

在视图中获取新闻提要帖子

为了在前端使用此 API,我们将在client/post/api-post.js中添加一个获取方法:

const listNewsFeed = (params, credentials) => {
  return fetch('/api/posts/feed/'+ params.userId, {
    method: 'GET',
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ' + credentials.t
    }
  }).then(response => {
    return response.json()
  }).catch((err) => console.log(err))
}

这是将加载在PostList中呈现的帖子的 fetch 方法,该帖子作为子组件添加到Newsfeed组件中。因此需要在Newsfeed组件中的loadPosts方法中调用此提取。

mern-social/client/post/Newsfeed.js

 loadPosts = () => {
    const jwt = auth.isAuthenticated()
    listNewsFeed({
      userId: jwt.user._id
    }, {
      t: jwt.token
    }).then((data) => {
      if (data.error) {
        console.log(data.error)
      } else {
        this.setState({posts: data})
      }
    })
 }

将在Newsfeed组件的componentDidMount中调用loadPosts方法,以初始加载状态,并在PostList组件中呈现帖子:

在配置文件中按用户列出

获取特定用户创建的帖子列表并在Profile中显示的实现类似于上一节的讨论。我们将在服务器上设置一个 API,用于查询帖子集合,并将特定用户的帖子返回到Profile视图。

用户发布帖子的 API

mern-social/server/routes/post.routes.js中增加接收特定用户回帖查询的路由:

router.route('/api/posts/by/:userId')
    .get(authCtrl.requireSignin, postCtrl.listByUser)

post.controller.js中的listByUser控制器方法将查询帖子集合,以查找在postedBy字段中与路由中userId参数中指定的用户具有匹配引用的帖子。

mern-social/server/controllers/post.controller.js

const listByUser = (req, res) => {
  Post.find({postedBy: req.profile._id})
  .populate('comments', 'text created')
  .populate('comments.postedBy', '_id name')
  .populate('postedBy', '_id name')
  .sort('-created')
  .exec((err, posts) => {
    if (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
    res.json(posts)
  })
}

获取视图中的用户帖子

为了在前端使用此 API,我们将在mern-social/client/post/api-post.js中添加一个获取方法:

const listByUser = (params, credentials) => {
  return fetch('/api/posts/by/'+ params.userId, {
    method: 'GET',
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ' + credentials.t
    }
  }).then(response => {
    return response.json()
  }).catch((err) => console.log(err))
}

fetch方法将加载添加到Profile视图的PostList所需的帖子。我们将更新Profile组件以定义一个调用listByUser获取方法的loadPosts方法。

mern-social/client/user/Profile.js

loadPosts = (user) => {
    const jwt = auth.isAuthenticated()
    listByUser({
      userId: user
    }, {
      t: jwt.token
    }).then((data) => {
      if (data.error) {
        console.log(data.error)
      } else {
        this.setState({posts: data})
      }
    })
}

Profile组件中,在init()函数中从服务器获取用户详细信息后,将使用加载配置文件的用户的用户 ID 调用loadPosts方法。为特定用户加载的帖子设置为状态,并在添加到Profile组件的PostList组件中呈现。Profile组件还提供了removePost功能,类似于Newsfeed组件,作为PostList组件的支柱,因此如果删除帖子,可以更新帖子列表:

创建新帖子

“创建新帖子”功能将允许登录用户发布消息,并可以通过从本地文件上载图像来选择性地向帖子添加图像。

创建 PostAPI

在服务器上,我们将定义一个 API 在数据库中创建 post,首先在mern-social/server/routes/post.routes.js中的/api/posts/new/:userId处声明一条接受 post 请求的路由:

router.route('/api/posts/new/:userId')
  .post(authCtrl.requireSignin, postCtrl.create)

post.controller.js中的create方法将使用formidable模块访问字段和图像文件(如果有),就像我们对用户配置文件照片更新所做的那样。

mern-social/server/controllers/post.controller.js

const create = (req, res, next) => {
  let form = new formidable.IncomingForm()
  form.keepExtensions = true
  form.parse(req, (err, fields, files) => {
    if (err) {
      return res.status(400).json({
        error: "Image could not be uploaded"
      })
    }
    let post = new Post(fields)
    post.postedBy= req.profile
    if(files.photo){
      post.photo.data = fs.readFileSync(files.photo.path)
      post.photo.contentType = files.photo.type
    }
    post.save((err, result) => {
      if (err) {
        return res.status(400).json({
          error: errorHandler.getErrorMessage(err)
        })
      }
      res.json(result)
    })
  })
}

检索帖子的照片

为了检索上传的照片,我们还将设置一个photo路由 URL,返回带有特定帖子的照片。

mern-social/server/routes/post.routes.js

router.route('/api/posts/photo/:postId').get(postCtrl.photo)

photo控制器将返回存储在 MongoDB 中的photo数据作为图像文件

mern-social/server/controllers/post.controller.js

const photo = (req, res, next) => {
    res.set("Content-Type", req.post.photo.contentType)
    return res.send(req.post.photo.data)
}

由于 photo route 使用了:postID参数,我们将设置一个postByID控制器方法,在返回 photo 请求之前,通过其 ID 获取特定帖子。我们将把 param 调用添加到post.routes.js

mern-social/server/routes/post.routes.js

  router.param('postId', postCtrl.postByID)

postByID将类似于userByID方法,它将从数据库检索到的 post 附加到请求对象,通过next方法访问。此实现中附带的 post 数据还将包含postedBy用户引用的 ID 和名称。

mern-social/server/controllers/post.controller.js

const postByID = (req, res, next, id) => {
  Post.findById(id).populate('postedBy', '_id name').exec((err, post) => {
    if (err || !post)
      return res.status('400').json({
        error: "Post not found"
      })
    req.post = post
    next()
  })
}

在视图中获取 CreatePostAPI

我们将更新api-post.js以添加create方法来调用fetch创建 API。

mern-social/client/post/api-post.js

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

此方法与用户editfetch 一样,将使用一个FormData对象发送一个多部分表单提交,该对象可以包含文本字段和图像文件。

NewPost 组件

Newsfeed组件中添加的NewPost组件将允许用户编写包含文本消息和可选图像的新帖子:

NewPost组件将是一个标准表单,具有EditProfile中实现的物料 UITextField和文件上传按钮,该按钮获取值并将其设置在FormData对象中,以便在提交后调用create获取方法时传递。

mern-social/client/post/NewPost.js

clickPost = () => {
    const jwt = auth.isAuthenticated()
    create({
      userId: jwt.user._id
    }, {
      t: jwt.token
    }, this.postData).then((data) => {
      if (data.error) {
        this.setState({error: data.error})
      } else {
        this.setState({text:'', photo: ''})
        this.props.addUpdate(data)
      }
    })
}

NewPost组件作为子组件添加到Newsfeed中,并作为道具给出addUpdate方法。成功创建帖子后,表单视图被清空并执行addUpdate,因此Newsfeed中的帖子列表将用新帖子更新。

后组件

每个帖子中的帖子细节将在Post组件中呈现,该组件将从PostList组件接收作为道具的帖子数据,以及删除帖子时要应用的onRemove道具。

布局

Post组件布局将有一个标题,显示海报的详细信息、帖子的内容、一个带有喜欢和评论计数的操作栏,以及评论部分:

标题

标题将包含诸如姓名、头像、发布用户的个人资料链接以及发布日期等信息。

mern-social/client/post/Post.js

<CardHeader
  avatar={<Avatar src={'/api/users/photo/'+this.props.post.postedBy._id}/>}
       action={this.props.post.postedBy._id ===   
           auth.isAuthenticated().user._id &&
           <IconButton onClick={this.deletePost}>
             <DeleteIcon />
           </IconButton>
          }
         title={<Link to={"/user/" + this.props.post.postedBy._id}>
            {this.props.post.postedBy.name}
         </Link>}
    subheader={(new Date(this.props.post.created)).toDateString()}
  className={classes.cardHeader}
/>

如果登录用户正在查看自己的帖子,标题也会有条件地显示一个delete按钮。

所容纳之物

内容部分将显示文章的文本和图像(如果文章包含照片)。

mern-social/client/post/Post.js

<CardContent className={classes.cardContent}>
  <Typography component="p" className={classes.text}> 
    {this.props.post.text} 
  </Typography>
  {this.props.post.photo && 
    (<div className={classes.photo}>
       <img className={classes.media}
            src={'/api/posts/photo/'+this.props.post._id}/>
    </div>)
  }
</CardContent>

行动

“操作”部分将包含一个交互式"like"选项,其中包含帖子上的喜欢总数,以及一个评论图标,其中包含帖子上的评论总数。

mern-social/client/post/Post.js

<CardActions>
  { this.state.like
    ? <IconButton onClick={this.like} className={classes.button}
     aria-label="Like" color="secondary">
        <FavoriteIcon />
      </IconButton>
    :<IconButton onClick={this.like} className={classes.button}
     aria-label="Unlike" color="secondary">
        <FavoriteBorderIcon />
      </IconButton> 
  } <span> {this.state.likes} </span>
  <IconButton className={classes.button}
   aria-label="Comment" color="secondary">
     <CommentIcon/>
  </IconButton> <span>{this.state.comments.length}</span>
</CardActions>

评论

comments 部分将包含Comments组件中所有与评论相关的元素,并将获得props数据,如postIdcomments数据,以及state更新方法,在Comments组件中添加或删除评论时可以调用该更新方法。

mern-social/client/post/Post.js

<Comments postId={this.props.post._id} 
          comments={this.state.comments} 
          updateComments={this.updateComments}/>

删除帖子

delete按钮仅在登录用户和postedBy用户对于呈现的特定帖子相同时可见。对于要从数据库中删除的帖子,我们必须设置一个 delete post API,该 API 在前端也将有一个 fetch 方法,以便在单击delete时应用。

mern-social/server/routes/post.routes.js

router.route('/api/posts/:postId')
    .delete(authCtrl.requireSignin, 
              postCtrl.isPoster, 
                  postCtrl.remove)

删除路由在调用 post 上的remove之前会检查授权,确保认证用户和postedBy用户是相同的用户。isPoster方法在执行next方法之前会检查登录用户是否是 post 的原始创建者。

mern-social/server/controllers/post.controller.js

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

使用remove控制器方法的 delete API 的其余实现和前端的 fetch 方法与其他 API 实现相同。这里的重要区别在于 delete post 特性,当 delete 成功时,调用Post组件中的onRemove更新方法。onRemove方法作为道具从NewsfeedProfile发送,以在删除成功时更新状态中的帖子列表。

当点击帖子上的delete按钮时,将调用Post组件中定义的以下deletePost方法。

mern-social/client/post/Post.js

deletePost = () => {
    const jwt = auth.isAuthenticated()
    remove({
      postId: this.props.post._id
    }, {
      t: jwt.token
    }).then((data) => {
      if (data.error) {
        console.log(data.error)
      } else {
        this.props.onRemove(this.props.post)
      }
    })
}

此方法对 delete post API 进行 fetch 调用,并在成功时通过执行从父组件作为道具接收的onRemove方法来更新处于该状态的帖子列表。

喜欢

Post组件的操作栏部分中的 like 选项将允许用户喜欢或不喜欢某篇文章,并显示该文章的喜欢总数。要记录一个 like,我们必须设置可以在视图中调用的 like 和 inspect API。

像 API

like API 将是更新Post文档中likes数组的 PUT 请求。该请求将在路由api/posts/like处接收。

mern-social/server/routes/post.routes.js

  router.route('/api/posts/like')
    .put(authCtrl.requireSignin, postCtrl.like)

like控制器方法中,请求体中接收到的 post ID 将被用于查找 post 文档,并通过将当前用户的 ID 推送到likes数组进行更新。

mern-social/server/controllers/post.controller.js

const like = (req, res) => {
  Post.findByIdAndUpdate(req.body.postId,
 {$push: {likes: req.body.userId}}, {new: true})
  .exec((err, result) => {
    if (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
    res.json(result)
  })
}

要使用此 API,将在api-post.js中添加一个名为like的获取方法,当用户单击like按钮时将使用该方法。

mern-social/client/post/api-post.js

const like = (params, credentials, postId) => {
  return fetch('/api/posts/like/', {
    method: 'PUT',
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ' + credentials.t
    },
    body: JSON.stringify({userId:params.userId, postId: postId})
  }).then((response) => {
    return response.json()
  }).catch((err) => {
    console.log(err)
  })
}

与 API 不同

unlikeAPI 的实现方式与同类 API 类似,在mern-social/server/routes/post.routes.js有自己的路由:

  router.route('/api/posts/unlike')
    .put(authCtrl.requireSignin, postCtrl.unlike)

控制器中的unlike方法将通过其 ID 找到帖子,并通过使用$pull而不是$push删除当前用户的 ID 来更新likes数组。

mern-social/server/controllers/post.controller.js

const unlike = (req, res) => {
  Post.findByIdAndUpdate(req.body.postId, {$pull: {likes: req.body.userId}}, {new: true})
  .exec((err, result) => {
    if (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
    res.json(result)
  })
}

与之不同的 API 还将具有与api-post.js中的like方法类似的相应获取方法。

检查是否喜欢并计算喜欢

当呈现Post组件时,我们需要检查当前登录的用户是否喜欢该帖子,以便显示相应的like选项。

mern-social/client/post/Post.js

checkLike = (likes) => {
    const jwt = auth.isAuthenticated()
    let match = likes.indexOf(jwt.user._id) !== -1
    return match
}

Post组件的componentDidMountcomponentWillReceiveProps期间可以调用checkLike函数,在检查当前用户是否在 post 的likes数组中被引用后,为 post 设置like状态:

在使用checkLike方法的状态下设置的like值可用于渲染心脏轮廓按钮或完整心脏按钮。如果用户不喜欢该帖子,则会显示一个心形轮廓按钮,单击该按钮将调用likeAPI,显示完整的心形按钮,并增加likes计数。全心按钮将表示当前用户已经喜欢此帖子,单击此按钮将调用unlikeAPI,呈现心脏轮廓按钮,并减少likes计数。

通过将likes值设置为this.props.post.likes.length状态,在Post组件安装和接收道具时,也会初始设置likes计数。

mern-social/client/post/Post.js

componentDidMount = () => {
    this.setState({like:this.checkLike(this.props.post.likes), 
                   likes: this.props.post.likes.length, 
                   comments: this.props.post.comments})
}
componentWillReceiveProps = (props) => {
    this.setState({like:this.checkLike(props.post.likes), 
                   likes: props.post.likes.length, 
                   comments: props.post.comments})
}

当发生相似或不相似的操作时,likes相关值将再次更新,更新后的 post 数据将从 API 调用返回。

像咔哒声一样处理

为了处理对likeunlike按钮的点击,我们将设置一个like方法,该方法将根据是否是相似操作调用相应的获取方法,并更新帖子的likelikes计数状态。

mern-social/client/post/Post.js

like = () => {
    let callApi = this.state.like ? unlike : like 
    const jwt = auth.isAuthenticated()
    callApi({
      userId: jwt.user._id
    }, {
      t: jwt.token
    }, this.props.post._id).then((data) => {
      if (data.error) {
        console.log(data.error)
      } else {
        this.setState({like: !this.state.like, likes: 
       data.likes.length})
      }
    }) 
  }

评论

每个帖子中的评论部分将允许登录用户添加评论、查看评论列表以及删除自己的评论。对注释列表的任何更改(如新添加或删除)都将更新注释以及Post组件的操作栏部分中的注释计数:

添加评论

当用户添加注释时,数据库中的 post 文档将使用新注释进行更新。

注释 API

为了实现 addcomment API,我们将设置如下的PUT路径来更新帖子。

mern-social/server/routes/post.routes.js

router.route('/api/posts/comment')
    .put(authCtrl.requireSignin, postCtrl.comment)

comment控制器方法将通过其 ID 找到要更新的相关帖子,并将请求正文中接收到的评论对象推送到帖子的comments数组中。

mern-social/server/controllers/post.controller.js

const comment = (req, res) => {
  let comment = req.body.comment
  comment.postedBy = req.body.userId
  Post.findByIdAndUpdate(req.body.postId,
 {$push: {comments: comment}}, {new: true})
  .populate('comments.postedBy', '_id name')
  .populate('postedBy', '_id name')
  .exec((err, result) => {
    if (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
    res.json(result)
  })
}

在响应中,更新后的 post 对象将被发回,其中包含在 post 和评论中填充的postedBy用户的详细信息。

为了在视图中使用此 API,我们将在api-post.js中设置一个获取方法,该方法从视图中获取当前用户 ID、post ID 和comment对象,并与添加注释请求一起发送。

mern-social/client/post/api-post.js

const comment = (params, credentials, postId, comment) => {
  return fetch('/api/posts/comment/', {
    method: 'PUT',
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ' + credentials.t
    },
    body: JSON.stringify({userId:params.userId, postId: postId, 
    comment: comment})
  }).then((response) => {
    return response.json()
  }).catch((err) => {
    console.log(err)
  })
}

在视图中写东西

Comments组件中的添加注释部分将允许登录用户键入注释文本:

它将包含一个带有用户照片的化身和一个文本字段,当用户按下回车键时,该字段将添加注释。

mern-social/client/post/Comments.js

<CardHeader
   avatar={<Avatar className={classes.smallAvatar} 
              src={'/api/users/photo/'+auth.isAuthenticated().user._id}/>}
   title={<TextField
             onKeyDown={this.addComment}
             multiline
             value={this.state.text}
             onChange={this.handleChange('text')}
             placeholder="Write something ..."
             className={classes.commentField}
             margin="normal"/>}
   className={classes.cardHeader}
/>

当值改变时,文本将以状态存储,在onKeyDown事件中,如果按下Enter键,addComment方法将调用commentfetch 方法。

mern-social/client/post/Comments.js

addComment = (event) => {
    if(event.keyCode == 13 && event.target.value){
      event.preventDefault()
      const jwt = auth.isAuthenticated()
      comment({
        userId: jwt.user._id
      }, {
        t: jwt.token
      }, this.props.postId, {text: this.state.text}).then((data) => {
        if (data.error) {
          console.log(data.error)
        } else {
          this.setState({text: ''})
          this.props.updateComments(data.comments)
        }
      })
    }
}

Comments组件从Post组件接收updateComments方法(在上一节中讨论)作为道具。这将在添加新注释时执行,以便更新 Post 视图中的注释和注释计数。

列表注释

Comments组件从Post组件接收特定帖子的评论列表作为道具,然后迭代各个评论以呈现评论人的详细信息和评论内容。

mern-social/client/post/Comments.js

{this.props.comments.map((item, i) => {
                return <CardHeader
                      avatar={
                        <Avatar src=  
                     {'/api/users/photo/'+item.postedBy._id}/>
                      }
                      title={commentBody(item)}
                      className={classes.cardHeader}
                      key={i}/>
              })
}

commentBody呈现内容,包括链接到其个人资料的评论人的姓名、评论文本和评论创建日期。

mern-social/client/post/Comments.js

const commentBody = item => {
  return (
     <p className={classes.commentText}>
        <Link to={"/user/" + item.postedBy._id}>{item.postedBy.name}
        </Link><br/>
        {item.text}
        <span className={classes.commentDate}>
          {(new Date(item.created)).toDateString()} |
          {auth.isAuthenticated().user._id === item.postedBy._id &&
            <Icon onClick={this.deleteComment(item)} 
                  className={classes.commentDelete}>delete</Icon> }
        </span>
     </p>
   )
}

如果注释的postedBy引用与当前登录的用户匹配,commentBody还将为注释提供删除选项。

删除评论

点击评论中的删除按钮将通过从comments数组中删除评论来更新数据库中的帖子:

取消注释 API

我们将在下面的 PUT 路径上实现一个uncommentAPI。

mern-social/server/routes/post.routes.js

router.route('/api/posts/uncomment')
    .put(authCtrl.requireSignin, postCtrl.uncomment)

uncomment控制器方法将通过 ID 找到相关帖子,然后从帖子中的comments数组中提取带有已删除评论 ID 的评论。

mern-social/server/controllers/post.controller.js

const uncomment = (req, res) => {
  let comment = req.body.comment
  Post.findByIdAndUpdate(req.body.postId, {$pull: {comments: {_id: comment._id}}}, {new: true})
  .populate('comments.postedBy', '_id name')
  .populate('postedBy', '_id name')
  .exec((err, result) => {
    if (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
    res.json(result)
  })
}

更新后的帖子将在回复中返回,就像在 commentapi 中一样。

为了在视图中使用此 API,我们还将在api-post.js中设置一个 fetch 方法,类似于 addcommentfetch 方法,该方法使用当前用户 ID、post ID 和删除的comment对象与uncomment请求一起发送。

从视图中删除注释

当注释者点击注释的删除按钮时,Comments组件将调用deleteComment方法获取uncommentAPI,并在注释成功从服务器上删除时更新注释和注释计数。

mern-social/client/post/Comments.js

deleteComment = comment => event => {
    const jwt = auth.isAuthenticated()
    uncomment({
      userId: jwt.user._id
    }, {
      t: jwt.token
    }, this.props.postId, comment).then((data) => {
      if (data.error) {
        console.log(data.error)
      } else {
        this.props.updateComments(data.comments)
      }
    })
  }

注释计数更新

updateComments方法在Post组件中定义,并作为道具传递给Comments组件,该方法将在添加或删除注释时更新comments和注释计数。

mern-social/client/post/Post.js

updateComments = (comments) => {
    this.setState({comments: comments})
}

此方法将更新的注释列表作为参数,并更新保存视图中呈现的注释列表的状态。当Post组件挂载时,设置 Post 组件中注释的初始状态,并将 Post 数据作为道具接收。此处设置的注释作为道具发送到Comments组件,还用于在帖子布局的操作栏中呈现 likes 操作旁边的注释计数,如下所示。

mern-social/client/post/Post.js

<IconButton aria-label="Comment" color="secondary">
  <CommentIcon/>
</IconButton> <span>{this.state.comments.length}</span>

Post组件中的注释计数与Comments组件中呈现和更新的注释之间的这种关系,再次简单演示了如何在 React 中的嵌套组件之间共享不断变化的数据,以创建动态的交互式用户界面

MERN 社交应用包含了我们之前为该应用定义的一组功能。用户可以用照片和描述更新他们的个人资料,在应用上相互跟踪,用照片和文本创建帖子,以及对帖子进行评论。这里显示的实现可以进一步调整和扩展,以添加更多特性,利用所揭示的使用 MERN 堆栈的机制。

总结

本章中开发的 MERN 社交应用演示了如何将 MERN 堆栈技术结合起来,构建一个具有社交媒体功能的功能齐全的 web 应用。

我们首先更新了 skeleton 应用中的用户功能,允许在 MERN Social 上拥有帐户的任何人添加关于自己的描述,并从本地文件上传个人资料图片。在上传配置文件图片的实现中,我们探索了如何从客户端上传多部分表单数据,然后在服务器上接收数据,将文件数据直接存储在 MongoDB 数据库中,然后能够检索回来进行查看。

接下来,我们进一步更新了用户功能,以允许用户在 MERN 社交平台上相互关注。在用户模型中,我们添加了维护用户引用数组的功能,以表示每个用户的关注者和关注者列表。为了扩展这一功能,我们在视图中加入了 follow 和 unfollow 选项,并显示了关注者列表、关注者列表,甚至还显示了尚未关注的用户列表。

然后,我们添加了允许用户发布内容的功能,并通过喜欢或评论文章来与内容进行交互。在后端,我们建立了 Post 模型和相应的 api,能够存储可能包含或不包含图像的 Post 内容,并维护任何用户对 Post 的喜欢和评论记录。

最后,在实现发布、喜欢和评论功能的视图时,我们探索了如何使用组件组合并在组件之间共享不断变化的状态值,以创建复杂的交互式视图。  

在下一章中,我们将进一步扩展 MERN 堆栈中的这些功能,并在通过扩展 MERN 骨架应用开发在线市场应用时释放新的可能性。