自定义评论组件

前段时间开发了 MyBlog 个人博客项目,耗费了两个月的时间(其实真正的开发时间没这么久,因为后来实习就只能下班后再开发),本篇博客来介绍一下项目中封装的评论组件。

基本技术栈

vue2 + element ui

效果

在这里插入图片描述

分析

简单需求分析

咱们先来看看一个评论组件需要满足什么需求?

  • 评论文章:既然是评论组件,那么首先就应该满足对文章内容进行评论,(也就是一级评论)。并且需要获得文章的 id,这样才能使评论与文章之间建立联系。
  • 回复一级评论:能够对一级评论的内容进行回复(也就是二级评论)。
  • 回复二级评论:能够对二级评论内容进行回复,这里就需要注意了,回复二级评论的评论还是二级评论而不是三级评论。原因如下:
    1. 类型:对一篇文章的评论来说只需要区分该评论是直接对文章内容的评论还是回复别人的评论,即使你回复二级评论也依然是一条回复,所以他应该与二级评论是一类。
    2. 实现:如果回复一次别人的评论就加一级嵌套,这样就会导致如果别人一直评论就会一直嵌套,那么当嵌套层级过深必然会影响页面布局。并且如果使用这样的方式该怎么设计数据库来做数据持久化呢,显而易回复一次别人的评论就加一级嵌套的设计并不合理。
  • 点赞:如果你觉得该评论深得你心,那么可以对评论进行点赞。
  • 删除评论:如果你觉得刚刚的评论没有表达好,那么你可以删除该评论。当然每个人只能删除自己的评论,不能删除别人的评论。
  • 上传头像:用户能够上传自己的头像。

数据库设计

由于博主这里采用的是mongodb数据库,这一个nosql的数据库,他是介于关系型数据库与非关系型数据库之间的一种数据库,它的特点就是可以直接存储数组。不了解的小伙伴可以去了解一下哦。

数据模型

//创建评论模型
const CommentSchema = new mongoose.Schema({
  date: { type: Date, require: true }, //一级评论创建日期
  articleId: { type: String, require: true }, // 评论的文章id
  articleTitle: { type: String, require: true },//评论文章的标题
  favour: [
    {
      type: String,
    },
  ],// 点赞数据,点赞数据,存的是点赞的用户唯一标识
  content: { type: String, default: "" },//评论内容
  replyInfo: [
    {
      date: { type: Date, require: true }, //二级评论的创建日期
      replyName: { type: String, require: true },//二级评论回复的用户名(本条回复是回复谁的)
      favour: [
        {
          type: String,
        },
      ],//点赞数据,存的是点赞的用户唯一标识
      reply: { type: String, default: "" },//回复内容
    },
  ],
});

实现

评论输入框的控制逻辑

在页面布局时,我想要达到的效果是,评论文章的输入框一直显示,是如下这一部分内容
在这里插入图片描述

接下来是回复输入框,这里需要区分当我点击回复一级评论时,二级评论回复框会隐藏,使用isShowSec状态控制,同时在点击回复是会传入该评论的 id ,并将 id 赋值给isShowSec通过比对id来判断哪一条评论的输入框需要显示。
在这里插入图片描述
在这里插入图片描述

然后,当连续两次点击同一评论的回复按钮时,能够隐藏该输入框。当某一评论的输入框正在显示时,又点击另一评论的输入框时,能够关闭当前正在显示的输入框并显示刚点击评论的输入框,这部分逻辑如下。

isShowSecReply(id) {
  if (id) {
    this.isShowSec = id;//保存当前点击回复的评论id
    if (this.isClickId === this.isShowSec) {//判断当前点击回复的评论id与正在显示输入框的评论id是否相同,若相同则将 isShowSec的值置空,即隐藏输入框,若不同则修改isShowSec值,即切换显示的输入框。
      this.isShowSec = "";
    } else {
      this.isShowSec = id;
    }
    this.isClickId = this.isShowSec;//保存当前正在显示输入框的评论id
  } else {
    this.isShowSec = this.isClickId = "";
  }
},

这里做了一个优化,每次将评论信息提交到后端后,返回提交的评论数据,并将数据 push 进组件的评论状态数据中,而不是添加一次就重新从后端获取一次全部的评论信息。这样减少了请求,但是下面的代码中没有提交数据到后端的功能,你需要根据自己的接口逻辑添加。这里为了能使组件正常运行,模拟了返回的数据。

async addComment(id, replyName) {
  let res = {};
  // 评论添加成功,返回的数据
  //本地更新评论列表
  if (replyName) {
    // 添加二级评论
    if (!this.replyContext) {
      this.$message.warning("评论或留言不能为空哦!");
      return;
    }
    // 模拟数据提交成功后返回数据
    res.data = {
      username: this.username,
      userId: this.userId,
      avatarUrl: this.avatarUrl,
      _id: "sec" + this.secIdx++, // 评论id
      replyName,
      date: "2022.09.01", //创建日期
      favour: [], //点赞的用户id
      content: this.replyContext //评论内容
    };
    // 提交成功后更新本地评论列表
    const comment = this.comments.find(item => item._id == id);
    if (!comment.replyInfo) {
      comment.replyInfo = [];
    }
    comment.replyInfo.push(res.data);
    this.replyContext = "";
  } else {
    // 添加一级评论,提交数据到后端
    if (!this.context) {
      this.$message.warning("评论或留言不能为空哦!");
      return;
    }
    // 模拟数据提交成功后返回数据
    res.data = {
      username: this.username,
      avatarUrl: this.avatarUrl,
      userId: this.userId,
      _id: "first" + this.firstIdx++, // 评论id
      date: "2022.09.01", //创建日期
      articleId: this.articleId, // 评论的文章id
      favour: [], //点赞的用户id
      content: this.context //评论内容
    };
    // 提交成功后更新本地评论列表
    this.comments.push(res.data);
    this.context = "";
  }
  this.isShowSec = this.isClickId = "";
}

这里需要从后端拿到上传数据的原因是,我需要拿到新增评论的 _id ,它是由mongodb数据库自动生成的。

获取某文章的所有评论

async getCommentList() {
  try {
    this.comments = [];
    let id = "";
    if (this.articleId == "messageBoard") {
      id = "messageBoard";
    } else {
      id = this.articleId;
    }
    // 获取某篇文章下的所有评论
    const res = await this.$api.getCommentsOfArticle({ id });
    this.comments = res.data.comments; //评论列表
    this.username = res.data.user?.username;
    this.avatarUrl = res.data.user?.avatarUrl;
  } catch (err) {
    this.$message.error(err);
  }
},

点赞和删除

点赞和删除逻辑就很简单了,只需要判断点赞或删除的是二级评论还是一级评论就好了,并且不能重复点赞。
注意:这里区分是一级评论还是二级评论的原因是因为我是采用mongodb数据库,并且二级评论数据保存在一级评论的replyInfo数组里,所以操作有些不同,如果你是 mysql 或其它关系数据库可能不需要区分,具体的逻辑需要你根据自己的数据库更改

// 评论点赞逻辑
giveALike(item, _id) {
  try {
    // 不允许同一个人重复点赞
    if (item.favour?.includes(this.userId)) {
      this.$message.info("您已经点过赞啦!");
      return;
    }
    //判断是给一级评论点赞还是二级评论,只有二级评论会有replyName
    if (item.replyName) {
      // 给二级评论点赞,向后台提交数据
    } else {
      // 一级评论点赞,向后台提交数据
    }
    // 点赞成功后更新本地评论列表
    item.favour.push(this.userId);
  } catch (err) {
    this.$message.error(err);
  }
},
// 评论删除逻辑
deleteComment(_id, replyId) {
  if (replyId) {
    // 删除二级评论,提交请求到后端

    // 成功后从本地记录中删除该评论
    const temp = this.comments.find(item => item._id == _id).replyInfo;
    for (let i = 0; i < temp.length; i++) {
      if (temp[i]._id == replyId) {
        temp.splice(i, 1);
        break;
      }
    }
  } else {
    // 删除一级评论,提交请求到后端

    // 成功后从本地记录中删除该评论
    for (let i = 0; i < this.comments.length; i++) {
      if (this.comments[i]._id == _id) {
        this.comments.splice(i, 1);
      }
    }
  }
},

头像上传

这里需要注意,因为原始文件选择器的样式太丑了,所以我将其隐藏掉,并通过事件调用的方式触发文件选择。

// 唤起文件选择
handleClick() {
  this.$refs.avatar.click();
},
处理选择的图片
// 对选择上传的图片进行处理再上传
 dealWithdAvatar(e) {
  const maxSize = 2 * 1024 * 1024;
  const file = Array.prototype.slice.call(e.target.files)[0];// 拿到选择的图片
  // 可以在这里对选择的图片进行处理
  console.log(file);
},

拓展

  • 不同场景的复用自定义:由于我的博客中留言板也是复用的本组件,所以我需要父组件传来一些数据,这样就能在不同的应用场景下显示不同的内容了。对应源码中props内容。
  • 图片压缩上传:分析一下选择的头像,因为头像都是很小的,所以一张高分辨率的图片和一张低分辨率的图片对于我们肉眼来说并无区别,但一张高分辨率的图片的上传对于资源的消耗是明显高于低分辨率图片的,所以可以在上传前对图片进行压缩处理。但是这里没有实现。

完整源码

<template>
  <div class="comment">
    <div class="comment-header">
      <el-tooltip class="item" effect="dark" content="点我更换头像" placement="top-start">
        <div @click="handleClick">
          <input type="file" style="display: none" @change="dealWithdAvatar" ref="avatar" />
          <el-avatar
            :src="
              avatarUrl
                ? avatarUrl
                : 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png'
            "
            :size="40"
          ></el-avatar>
        </div>
      </el-tooltip>
      <el-input
        :placeholder="placeholderText"
        v-model="context"
        class="input"
        type="textarea"
        resize="none"
        size="mini"
        :maxlength="contentLength"
        @focus="isShowSecReply(undefined)"
      ></el-input>
      <el-button
        type="info"
        style="height: 40px"
        @click="addComment(articleId, undefined)"
      >{{ buttonText }}</el-button>
    </div>
    <div class="comment-body" v-for="(item, index) in comments" :key="item._id + '' + index">
      <!-- 一级评论 -->
      <div class="first-comment">
        <el-avatar :size="40" :src="item.avatarUrl"></el-avatar>
        <div class="content">
          <!-- 一级评论用户昵称 -->
          <h3>{{ item.username }}</h3>
          <!-- 一级评论发布时间 -->
          <span>{{ item.date }}</span>
          <!-- 一级评论评论内容 -->
          <p>{{ item.content }}</p>
          <!-- 一级评论评论点赞 -->
          <div class="comment-right">
            <i
              class="el-icon-trophy"
              @click="giveALike(item, item._id)"
              :class="item.favour.includes(userId) ? 'active' : ''"
            ></i>
            {{ item.favour.length || 0 }}
            <i
              class="el-icon-chat-dot-round"
              @click="isShowSecReply(item._id)"
            >回复</i>
            <i
              class="el-icon-delete"
              @click="deleteComment(item._id, undefined)"
              v-if="userId === item.userId"
            >删除</i>
          </div>
          <!-- 回复一级评论 -->
          <div class="reply-comment" v-show="isShowSec === item._id">
            <el-input
              :placeholder="placeholderText"
              class="input"
              v-model.trim="replyContext"
              :maxlength="contentLength"
            ></el-input>
            <el-button
              type="info"
              size="mini"
              class="reply-button"
              @click="addComment(item._id, item.username)"
            >回复</el-button>
          </div>
          <!-- 次级评论 -->
          <div
            class="second-comment"
            v-for="(reply, index) in item.replyInfo"
            :key="reply._id + '' + index"
          >
            <!-- 次级评论头像,该用户没有头像则显示默认头像 -->
            <el-avatar :size="40" :src="reply.avatarUrl"></el-avatar>
            <div class="content">
              <!-- 次级评论用户昵称 -->
              <h3>{{ reply.username }}</h3>
              <!-- 次级评论评论时间 -->
              <span>{{ reply.date }}</span>
              <span class="to_reply">{{ reply.username }}</span>
              回复
              <span class="to_reply">{{ reply.replyName }}</span>:
              <p>{{ reply.content }}</p>
              <!-- 次级评论评论点赞 -->
              <div class="comment-right">
                <i
                  class="el-icon-trophy"
                  @click="giveALike(reply, item._id)"
                  :class="reply.favour.includes(userId) ? 'active' : ''"
                ></i>
                {{ reply.favour ? reply.favour.length : 0 }}
                <i
                  class="el-icon-chat-dot-round"
                  @click="isShowSecReply(reply._id)"
                >回复</i>
                <i
                  class="el-icon-delete"
                  @click="deleteComment(item._id, reply._id)"
                  v-if="userId === reply.userId"
                >删除</i>
              </div>
              <div class="reply-comment" v-show="isShowSec === reply._id">
                <el-input
                  :placeholder="placeholderText"
                  class="input"
                  v-model.trim="replyContext"
                  :maxlength="contentLength"
                ></el-input>
                <el-button
                  type="info"
                  size="mini"
                  class="reply-button"
                  @click="addComment(item._id, reply.username)"
                >回复</el-button>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
    <!-- 暂无评论的空状态 -->
    <el-empty :description="emptyText" v-show="comments.length === 0"></el-empty>
  </div>
</template>
<script>
export default {
  props: {
    articleId: {
      //评论所属文章 id
      type: String
    },
    emptyText: {
      // 评论为空的时候显示的文字
      type: String,
      default: "期待你的评论!"
    },
    buttonText: {
      // 按钮文字
      type: String,
      default: "评论"
    },
    contentLength: {
      // 评论长度
      type: Number,
      default: 150
    },
    placeholderText: {
      // 默认显示文字
      type: String,
      default: "请输入最多150字的评论..."
    }
  },
  data() {
    return {
      comments: [
        {
          _id: "first0", // 评论id
          date: "2022.09.01", //创建日期
          username: "孤城浪人", //评论人
          userId: "1",
          avatarUrl:
            "https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png", //头像地址
          favour: ["1", "2", "3"], //点赞的用户id
          content: "666", //评论内容
          replyInfo: [
            //回复的内容
            {
              _id: "sec0", // 当前此条回复的id
              date: "2022.09.01", //创建日期
              replyName: "孤城浪人", //回复的对象
              username: "孤城浪人", //评论人
              userId: "1",
              favour: ["2", "3", "4"],
              avatarUrl:
                "https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png",
              content: "博主厉害了" //回复的内容
            }
          ]
        }
      ], // 获取得到的评论
      context: "", // 评论内容
      replyContext: "", //一级评论回复
      isShowSec: "", //是否显示次级回复框
      isClickId: "", //记录点击回复的评论id
      userId: "1", // 浏览器指纹
      username: "孤城浪人", //你的用户名
      firstIdx: 1,
      secIdx: 1,
      avatarUrl:
        "https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png"
    };
  },
  created() {
    // 获取评论数据
    // this.getCommentList();
  },
  methods: {
    // 唤起文件选择
    handleClick() {
      this.$refs.avatar.click();
    },
    dealWithdAvatar(e) {
      const maxSize = 2 * 1024 * 1024;
      const file = Array.prototype.slice.call(e.target.files)[0];
      console.log(file);
    },
    // 获取本篇文章所有评论
    async getCommentList() {
      try {
        this.comments = [];
        let id = "";
        if (this.articleId == "messageBoard") {
          id = "messageBoard";
        } else {
          id = this.articleId;
        }
        // 获取某篇文章下的所有评论
        const res = await this.$api.getCommentsOfArticle({ id });
        this.comments = res.data.comments; //评论列表
        this.username = res.data.user?.username;
        this.avatarUrl = res.data.user?.avatarUrl;
      } catch (err) {
        this.$message.error(err);
      }
    },
    // 评论点赞
    giveALike(item, _id) {
      try {
        // 不允许同一个人重复点赞
        if (item.favour?.includes(this.userId)) {
          this.$message.info("您已经点过赞啦!");
          return;
        }
        //判断是给一级评论点赞还是二级评论,只有二级评论会有replyName
        if (item.replyName) {
          // 给二级评论点赞,向后台提交数据
        } else {
          // 一级评论点赞,向后台提交数据
        }
        item.favour.push(this.userId);
      } catch (err) {
        this.$message.error(err);
      }
    },
    isShowSecReply(id) {
      if (id) {
        this.isShowSec = id;
        if (this.isClickId === this.isShowSec) {
          this.isShowSec = "";
        } else {
          this.isShowSec = id;
        }
        this.isClickId = this.isShowSec;
      } else {
        this.isShowSec = this.isClickId = "";
      }
    },
    deleteComment(_id, replyId) {
      if (replyId) {
        // 删除二级评论,提交请求到后端

        // 成功后从本地记录中删除该评论
        const temp = this.comments.find(item => item._id == _id).replyInfo;
        for (let i = 0; i < temp.length; i++) {
          if (temp[i]._id == replyId) {
            temp.splice(i, 1);
            break;
          }
        }
      } else {
        // 删除一级评论,提交请求到后端

        // 成功后从本地记录中删除该评论
        for (let i = 0; i < this.comments.length; i++) {
          if (this.comments[i]._id == _id) {
            this.comments.splice(i, 1);
          }
        }
      }
    },
    async addComment(id, replyName) {
      let res = {};
      // 评论添加成功,返回的数据
      //本地更新评论列表
      if (replyName) {
        // 添加二级评论
        if (!this.replyContext) {
          this.$message.warning("评论或留言不能为空哦!");
          return;
        }
        // 模拟数据提交成功后返回数据
        res.data = {
          username: this.username,
          userId: this.userId,
          avatarUrl: this.avatarUrl,
          _id: "sec" + this.secIdx++, // 评论id
          replyName,
          date: "2022.09.01", //创建日期
          favour: [], //点赞的用户id
          content: this.replyContext //评论内容
        };
        const comment = this.comments.find(item => item._id == id);
        if (!comment.replyInfo) {
          comment.replyInfo = [];
        }
        comment.replyInfo.push(res.data);
        this.replyContext = "";
      } else {
        // 添加一级评论,提交数据到后端
        if (!this.context) {
          this.$message.warning("评论或留言不能为空哦!");
          return;
        }
        // 模拟数据提交成功后返回数据
        res.data = {
          username: this.username,
          avatarUrl: this.avatarUrl,
          userId: this.userId,
          _id: "first" + this.firstIdx++, // 评论id
          date: "2022.09.01", //创建日期
          articleId: this.articleId, // 评论的文章id
          favour: [], //点赞的用户id
          content: this.context //评论内容
        };
        this.comments.push(res.data);
        this.context = "";
      }
      this.isShowSec = this.isClickId = "";
    }
  }
};
</script>

<style lang="less" scoped>
.comment {
  min-height: 26vh;
  border-radius: 5px;
  margin-top: 2px;
  overflow: hidden;
  .active {
    color: rgb(202, 4, 4);
  }
  .comment-header {
    position: relative;
    height: 50px;
    padding: 10px 5px;
    display: flex;
    align-items: center;

    .input {
      margin-left: 10px;
      margin-right: 20px;
      flex: 1;
      /deep/.el-input__inner:focus {
        border-color: #dcdfe6;
      }
    }
  }

  .comment-body {
    min-height: 70px;
    padding: 10px 20px;
    font-size: 14px;
    .first-comment {
      display: flex;
      .input {
        /deep/.el-input__inner:focus {
          border-color: #dcdfe6;
        }
      }
      i {
        margin-right: 5px;
        margin-left: 1vw;
        cursor: pointer;

        &:nth-child(3) {
          color: rgb(202, 4, 4);
        }
      }

      .content {
        margin-left: 10px;
        position: relative;
        flex: 1;

        & > span {
          font-size: 12px;
          color: rgb(130, 129, 129);
        }

        .comment-right {
          position: absolute;
          right: 0;
          top: 0;
        }

        .reply-comment {
          height: 60px;
          display: flex;
          align-items: center;

          .reply-button {
            margin-left: 20px;
            height: 35px;
          }
        }

        .second-comment {
          display: flex;
          padding: 10px 0 10px 5px;
          border-radius: 20px;
          background: #ffffff;
          .to_reply {
            color: rgb(126, 127, 128);
          }
        }
      }
    }
  }
}
</style>

总结

好了,一个评论组件就此封装好了,该有的功能都有了,在我感觉评论组件的逻辑还是比较复杂的,特别是有很多细节部分处理,如输入框的显示、与后端进行联调等等。此组件来自项目myBlog

我是孤城浪人,一名正在前端路上摸爬滚打的菜鸟,欢迎你的关注。

Logo

前往低代码交流专区

更多推荐