前段时间开发了 MyBlog 个人博客项目,耗费了两个月的时间(其实真正的开发时间没这么久,因为后来实习就只能下班后再开发),本篇博客来介绍一下项目中封装的评论组件。
vue2 + element ui
咱们先来看看一个评论组件需要满足什么需求?
由于博主这里采用的是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
我是孤城浪人,一名正在前端路上摸爬滚打的菜鸟,欢迎你的关注。