Vue2封装评论组件详细讲解
自定义评论组件
前段时间开发了 MyBlog 个人博客项目,耗费了两个月的时间(其实真正的开发时间没这么久,因为后来实习就只能下班后再开发),本篇博客来介绍一下项目中封装的评论组件。
基本技术栈
vue2 + element ui
效果
分析
简单需求分析
咱们先来看看一个评论组件需要满足什么需求?
- 评论文章:既然是评论组件,那么首先就应该满足对文章内容进行评论(也就是一级评论)。
- 回复一级评论:能够对一级评论的内容进行回复(也就是二级评论)。
- 回复二级评论:能够对二级评论内容进行回复,这里就需要注意了,回复二级评论的评论还是二级评论而不是三级评论。原因如下:
- 类型:对一篇文章的评论来说只需要区分该评论是直接对文章内容的评论还是回复别人的评论,即使你回复二级评论也依然是一条回复,所以他应该与二级评论是一类。
- 实现:如果回复一次别人的评论就加一级嵌套,这样就会导致如果别人一直评论就会一直嵌套,那么当嵌套层级过深必然会影响页面布局。并且如果使用这样的方式该怎么设计数据库来做数据持久化呢,显而易回复一次别人的评论就加一级嵌套的设计并不合理。
- 点赞:如果你觉得该评论深得你心,那么可以对评论进行点赞。
- 删除评论:如果你觉得刚刚的评论没有表达好,那么你可以删除该评论。当然每个人只能删除自己的评论,不能删除别人的评论。
- 上传头像:用户能够上传自己的头像。
数据库设计
由于博主这里采用的是mongodb数据库,这一个nosql的数据库,他是介于关系型数据库与非关系型数据库之间的一种数据库,它的特点就是可以直接存储数组。不了解的小伙伴可以去了解一下哦。
数据模型
//创建评论模型
const CommentSchema = new mongoose.Schema({
date: { type: Date, require: true }, //一级评论创建日期
keyId: { type: String, require: true }, // 评论的文章id
articleTitle: { type: String, require: true },//评论文章的标题
favour: [
{
type: String,
},
],// 点赞数据,点赞数据,存的是点赞的用户唯一标识
content: { type: String, default: "" },//评论内容
murmur: { type: String, require: true },//用户的唯一标识
replyInfo: [
{
date: { type: Date, require: true }, //二级评论的创建日期
replyName: { type: String, require: true },//二级评论回复的用户名(本条回复是回复谁的)
favour: [
{
type: String,
},
],//点赞数据,存的是点赞的用户唯一标识
reply: { type: String, default: "" },//回复内容
murmur: { type: String, require: true }, // 当前此条回复的指纹
},
],
});
实现
评论
在页面布局时,我想要达到的效果是,评论文章的输入框一直显示,是如下这一部分内容
接下来是回复输入框,这里需要区分当我点击回复一级评论时,二级评论回复框会隐藏,使用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 = "";
}
},
提交数据预处理逻辑,这里用到的是 element ui 的$prompt
,因为如果你是第一次访问网站并评论需要你给自己起一个名字,这是必须的。
// 提交数据预处理
submitInfo(id, replyName) {
if (!this.username) {
this.$prompt("请输入你的名字", {
confirmButtonText: "确定",
cancelButtonText: "取消",
})
.then((username) => {
if (!username.value) {//如果为输入用户名,则抛出错误
throw new Error();
}
this.$api
.addMurmur({
murmur: this.murmur,
username: username.value,
})
.then((res) => {
// 将评论信息传给后端
this.addComment(id, replyName);
this.username = res.data.username;
});
})// 将输入的用户名传给后端保存
.catch((err) => {
if (err == "cancel") {
this.$message({
type: "info",
message: "取消输入",
});
} else {
this.$message.warning({
message: "名字不能为空哦!",
});
}
});
} else {
// 将评论信息传给后端
this.addComment(id, replyName);
}
},
最后,是提交数据到后端逻辑,这里做了一个小优化,当我提交评论数据到后端后会将当前提交数据返回,然后将数据 push 进组件的评论状态数据中,而不是添加一次就重新从后端获取一次全部的评论信息。
// 真正的将评论数据提交到后端
async addComment(id, replyName) {
let res = null;
// 判断是一级评论还是二级评论
if (replyName) {
if (!this.replyContext) {
this.$message.warning("评论或留言不能为空哦!");
return;
}
// 将二级评论数据提交到后端
res = await this.$api.addsecondcomment({
_id: id,
reply: this.replyContext,
replyName,
murmur: this.murmur,
});
} else {
if (!this.context) {
this.$message.warning("评论或留言不能为空哦!");
return;
}
// 将一级评论数据提交到后端
res = await this.$api.addfirstcomment({
keyId: id,
content: this.context,
murmur: this.murmur,
articleTitle: this.articleTitle,
});
}
// 处理数据刷新
if (res.status === 200) {
this.$message.success(res.msg);
res.data.username = this.username;
res.data.avatarUrl = this.avatarUrl;
if (replyName) {
const comment = this.comments.find((item) => item._id == id);
if (!comment.replyInfo) {
comment.replyInfo = [];
}
comment.replyInfo.push(res.data);
this.replyContext = "";
} else {
this.comments.push(res.data);
this.context = "";
}
} else {
this.$message.error(res.msg);
}
this.isShowSec = this.isClickId = "";
},
这里需要从后端拿到上传数据的原因是,我需要拿到新增评论的 id ,它是由mongodb数据库自动生成的。
点赞和删除
点赞和删除逻辑就很简单了,只需要判断点赞或删除的是二级评论还是一级评论就好了,并且不能重复点赞。
注意:这里区分是一级评论还是二级评论的原因是因为我是采用mongodb数据库,并且二级评论数据保存在一级评论的replyInfo
数组里,所以操作有些不同,如果你是 mysql 或其它关系数据库可能不需要区分,具体的逻辑需要你根据自己的数据库更改。
// 评论点赞逻辑
giveALike(item, _id) {
try {
let res = null;
if (item.favour?.includes(this.murmur)) {// 判断手否已经点过赞了
this.$message.info("您已经点过赞啦!");
return;
}
if (item.replyName) {//判断是给一级评论点赞还是给二级评论点赞
this.$api
.addsecondfavour({
replyId: item._id,
_id,
favourMurmur: this.murmur,
})
.then((res) => {
this.$message.success(res.msg);
item.favour.push(this.murmur);
})
.catch(() => {
this.$message.error(res.msg);
});
} else {
this.$api
.addfirstfavour({
_id,
favourMurmur: this.murmur,
})
.then((res) => {
this.$message.success(res.msg);
item.favour.push(this.murmur);
})
.catch(() => {
this.$message.error(res.msg);
});
}
} catch (err) {
this.$message.error(err);
}
},
// 评论删除逻辑
deleteComment(_id, replyId) {
let res = null;
if (replyId) {
res = this.$api
.deletesecondcomment({ replyId, _id })
.then((res) => {
this.$message.success(res.msg);
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;
}
}
})
.catch(() => {
this.$message.error(res.msg);
});
} else {
res = this.$api
.deletefirstcomment({ _id })
.then((res) => {
this.$message.success(res.msg);
for (let i = 0; i < this.comments.length; i++) {
if (this.comments[i]._id == _id) {
this.comments.splice(i, 1);
}
}
})
.catch(() => {
this.$message.error(res.msg);
});
}
},
头像上传
这里需要注意,因为原始文件选择器的样式太丑了,所以我将其隐藏掉,并通过事件调用的方式触发文件选择。
// 唤起文件选择
handleClick() {
this.$refs.avatar.click();
},
图片压缩
还需要分析一下选择的头像,因为头像都是很小的,所以一张高分辨率的图片和一张低分辨率的图片对于我们肉眼来说并无区别,但一张高分辨率的图片的上传对于资源的消耗是明显高于低分辨率图片的,所以在上传前需要对图片进行压缩处理,这里是使用canvas的能力进行图片压缩的。
// 对选择上传的图片进行处理再上传
dealWithdAvatar(e) {
this.currentUpdateAvatar = Date.now();
const maxSize = 2 * 1024 * 1024;
const file = Array.prototype.slice.call(e.target.files)[0];//拿到图片数据
const render = new FileReader();
render.readAsDataURL(file);
render.onload = async (e) => {
let blob = null;
if (file.size > maxSize) {//判断是否需要压缩
let img = new Image();
img.src = e.target.result;
img.onload = async () => {
const data = pressImg(img);
if (data.length > maxSize) {
this.$message.error("上传图片过大");
} else {
blob = toBolb(data, "image/jpeg", file.name);
this.updateAvatar(blob);
}
};
} else {
blob = toBolb(e.target.result, file.type, file.name);
this.updateAvatar(blob);
}
};
},
压缩图片,这里我将两个方法封装在工具文件中并暴露出来的。
// 压缩图片
export const pressImg = img => {
// 用于压缩图片的canvas
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// 瓦片canvas
const tCanvas = document.createElement('canvas');
const tctx = tCanvas.getContext('2d');
// const initSize = img.src.length;
let width = img.width;
let height = img.height;
// 如果图片大于四百万像素,计算压缩比并将大小压至4万以下
let ratio;
if ((ratio = (width * height) / 40000) > 1) {
ratio = Math.sqrt(ratio);
width /= ratio;
height /= ratio;
} else {
ratio = 1;
}
canvas.width = width;
canvas.height = height;
// 铺底色
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 如果图片像素大于100万则使用瓦片绘制
let count;
if ((count = (width * height) / 10000) > 1) {
count = ~~(Math.sqrt(count) + 1); // 计算要分成多少块瓦片
// 计算每块瓦片的宽和高
const nw = ~~(width / count);
const nh = ~~(height / count);
tCanvas.width = nw;
tCanvas.height = nh;
for (let i = 0; i < count; i++) {
for (let j = 0; j < count; j++) {
tctx.drawImage(
img,
i * nw * ratio,
j * nh * ratio,
nw * ratio * 2,
nh * ratio * 2,
0,
0,
nw,
nh
);
ctx.drawImage(tCanvas, i * nw, j * nh, nw * 2, nh * 2);
}
}
} else {
ctx.drawImage(img, 0, 0, width * 2, height * 2);
}
// 进行最小压缩
const pressImgData = canvas.toDataURL('image/jpeg', 0.5);
// console.log('压缩前:' + initSize);
// console.log('压缩后:' + pressImgData.length);
// console.log('压缩率:' + ~~((100 * (initSize - pressImgData.length)) / initSize) + '%');
return pressImgData;
};
// 图片转为二进制
export const toBolb = (basestr, type) => {
const text = window.atob(basestr.split(',')[1]);
const buffer = new ArrayBuffer(text.length);
const ubuffer = new Uint8Array(buffer);
for (let i = 0; i < text.length; i++) {
ubuffer[i] = text.charCodeAt(i);
}
const Builder = window.WebKitBlobBuilder || window.MozBlobBuilder;
let blob;
if (Builder) {
const builder = new Builder();
builder.append(buffer);
blob = builder.getBlob(type);
} else {
blob = new window.Blob([buffer], { type: type });
}
return blob;
};
拓展
- 不同场景的复用自定义:由于我的博客中留言板也是复用的本组件,所以我需要父组件传来一些数据,这样就能在不同的应用场景下显示不同的内容了。对应源码中
props
内容。 - 暗黑主题:本博客项目实现了暗黑主题,思路是将主题的样式数据保存在 store 中,不同的主题返回不同的样式。对应源码中
computed
中的内容,如果不需要,可以将computed
部分删除,并在删除模板代码中的部分样式,类似:style="
${mainBg}"
即可。
完整源码
<template>
<div class="comment" :style="`${mainBg}`">
<div class="comment-header" :style="`${cardBg}`">
<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"
:class="!isLight ? 'input-night' : ''"
@focus="isShowSecReply(undefined)"
>
</el-input>
<el-button
type="info"
style="height: 40px"
@click="submitInfo(keyId, undefined)"
>{{ buttonText }}</el-button
>
</div>
<div
class="comment-body"
v-for="(item, index) in comments"
:key="item.murmur + '' + 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 style="padding-right: 30px">
{{ item.content }}
</p>
<!-- 一级评论评论点赞 -->
<div class="comment-right">
<i
class="iconfont icon-icon"
@click="giveALike(item, item._id)"
:class="item.favour.includes(murmur) ? '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="murmur === item.murmur"
>
删除</i
>
</div>
<!-- 回复一级评论 -->
<div class="reply-comment" v-show="isShowSec === item._id">
<el-input
placeholder="请输入最多150字的评论...."
class="input"
v-model.trim="replyContext"
:maxlength="150"
:class="!isLight ? 'input-night' : ''"
>
</el-input>
<el-button
type="info"
size="mini"
class="reply-button"
@click="submitInfo(item._id, item.username)"
>回复
</el-button>
</div>
<!-- 次级评论 -->
<div
class="second-comment"
v-for="(reply, index) in item.replyInfo"
:key="reply.murmur + '' + index"
:style="`${cardBg}`"
>
<!-- 次级评论头像,该用户没有头像则显示默认头像 -->
<el-avatar :size="40" :src="reply.avatarUrl"></el-avatar>
<div class="content">
<!-- 次级评论用户昵称 -->
<h3>{{ reply.username }}</h3>
<!-- 次级评论评论时间 -->
<span>{{ reply.date }} </span>
<!-- 次级评论内容 xxx回复xxx:评论内容-->
<span class="to_reply">{{ reply.username }}</span>
回复
<span class="to_reply">{{ reply.replyName }}</span
>:
<p style="padding-right: 20px">
{{ reply.reply }}
</p>
<!-- 次级评论评论点赞 -->
<div class="comment-right">
<i
class="iconfont icon-icon"
@click="giveALike(reply, item._id)"
:class="reply.favour.includes(murmur) ? '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="murmur === reply.murmur"
>删除</i
>
</div>
<div class="reply-comment" v-show="isShowSec === reply._id">
<el-input
placeholder="请输入最多150字的评论...."
class="input"
v-model.trim="replyContext"
:maxlength="150"
:class="!isLight ? 'input-night' : ''"
>
</el-input>
<el-button
type="info"
size="mini"
class="reply-button"
@click="submitInfo(item._id, reply.username)"
>回复
</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 页码 -->
<el-pagination
background
layout="prev, pager, next"
:total="10"
:hide-on-single-page="true"
:page-size="pageSize"
>
</el-pagination>
<!-- 暂无评论的空状态 -->
<el-empty
:description="emptyText"
v-show="comments.length === 0"
></el-empty>
</div>
</template>
<script>
import { pressImg, toBolb, deepCommentAvatar } from "../util";
import { mapGetters, mapState } from "vuex";
export default {
props: {
keyId: {
type: String,
},
articleTitle: {
type: String,
},
emptyText: {
type: String,
},
buttonText: {
type: String,
},
contentLength: {
type: Number,
},
placeholderText: {
type: String,
},
},
computed: {
...mapState({
isLight: (state) => state.theme.isLight,
}),
...mapGetters(["mainBg", "cardBg"]),
},
data() {
return {
comments: [], // 获取得到的评论
context: "", // 评论内容
replyContext: "", //一级评论回复
isShowSec: "", //是否显示次级回复框
pageSize: 11,
isClickId: "",
replyName: "",
murmur: localStorage.getItem("browserId"),
// 页数
page: 1,
// 当前分页开始
pageStart: 0,
username: "",
avatarUrl: "",
firstUpdateAvatar: "",
currentUpdateAvatar: "",
};
},
mounted() {
this.getCommentList();
},
methods: {
// 换页回调
handleCurrentChange(val) {
this.pageStart = this.pageSize * (val - 1);
this.getCommentList(this.pageStart, this.pageSize);
},
// 唤起文件选择
handleClick() {
this.$refs.avatar.click();
},
dealWithdAvatar(e) {
this.currentUpdateAvatar = Date.now();
// if (this.currentUpdateAvatar - this.firstUpdateAvatar < 24 * 3600000) {
// return;
// } else {
// this.f
// }
const maxSize = 2 * 1024 * 1024;
const file = Array.prototype.slice.call(e.target.files)[0];
const render = new FileReader();
render.readAsDataURL(file);
render.onload = async (e) => {
let blob = null;
if (file.size > maxSize) {
let img = new Image();
img.src = e.target.result;
img.onload = async () => {
const data = pressImg(img);
if (data.length > maxSize) {
this.$message.error("上传图片过大");
} else {
blob = toBolb(data, "image/jpeg", file.name);
this.updateAvatar(blob);
}
};
} else {
blob = toBolb(e.target.result, file.type, file.name);
this.updateAvatar(blob);
}
};
},
async updateAvatar(blob) {
const formdata = new FormData();
formdata.append("avatar", blob);
formdata.append("murmur", this.murmur);
const res = await this.$api.uploadAvatar(formdata);
if (res.status == 200) {
// this.firstUpdateAvatar = Date.now();
this.avatarUrl = res.avatarUrl;
deepCommentAvatar(this.murmur, res.avatarUrl, this.comments);
this.$message.err(res.msg);
} else {
this.$message.err(res.msg);
}
},
submitInfo(id, replyName) {
// if (!this.username) {
this.$prompt("请输入你的名字", {
confirmButtonText: "确定",
cancelButtonText: "取消",
})
.then((username) => {
if (!username.value) {
throw new Error();
}
this.$api
.addMurmur({
murmur: this.murmur,
username: username.value,
})
.then((res) => {
this.addComment(id, replyName);
this.username = res.data.username;
});
})
.catch((err) => {
if (err == "cancel") {
this.$message({
type: "info",
message: "取消输入",
});
} else {
this.$message.warning({
message: "名字不能为空哦!",
});
}
});
// } else {
this.addComment(id, replyName);
// }
},
// 获取本篇文章所有评论
async getCommentList(pageStart, pageSize) {
try {
this.comments = [];
let id = "";
if (this.keyId == "messageBoard") {
id = "messageBoard";
} else {
id = this.keyId;
}
const res = await this.$api.getCommentsOfArticle({
id,
pageSize,
pageStart,
murmur: this.murmur,
});
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 {
let res = null;
if (item.favour?.includes(this.murmur)) {
this.$message.info("您已经点过赞啦!");
return;
}
if (item.replyName) {
this.$api
.addsecondfavour({
replyId: item._id,
_id,
favourMurmur: this.murmur,
})
.then((res) => {
this.$message.success(res.msg);
item.favour.push(this.murmur);
})
.catch(() => {
this.$message.error(res.msg);
});
} else {
this.$api
.addfirstfavour({
_id,
favourMurmur: this.murmur,
})
.then((res) => {
this.$message.success(res.msg);
item.favour.push(this.murmur);
})
.catch(() => {
this.$message.error(res.msg);
});
}
} 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;
// this.replyName = name;
} else {
this.isShowSec = this.isClickId = "";
}
},
deleteComment(_id, replyId) {
let res = null;
if (replyId) {
res = this.$api
.deletesecondcomment({ replyId, _id })
.then((res) => {
this.$message.success(res.msg);
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;
}
}
})
.catch(() => {
this.$message.error(res.msg);
});
} else {
res = this.$api
.deletefirstcomment({ _id })
.then((res) => {
this.$message.success(res.msg);
for (let i = 0; i < this.comments.length; i++) {
if (this.comments[i]._id == _id) {
this.comments.splice(i, 1);
}
}
})
.catch(() => {
this.$message.error(res.msg);
});
}
},
async addComment(id, replyName) {
let res = null;
if (replyName) {
if (!this.replyContext) {
this.$message.warning("评论或留言不能为空哦!");
return;
}
res = await this.$api.addsecondcomment({
_id: id,
reply: this.replyContext,
replyName,
murmur: this.murmur,
});
} else {
if (!this.context) {
this.$message.warning("评论或留言不能为空哦!");
return;
}
res = await this.$api.addfirstcomment({
keyId: id,
content: this.context,
murmur: this.murmur,
articleTitle: this.articleTitle,
});
}
if (res.status === 200) {
this.$message.success(res.msg);
res.data.username = this.username;
res.data.avatarUrl = this.avatarUrl;
if (replyName) {
const comment = this.comments.find((item) => item._id == id);
if (!comment.replyInfo) {
comment.replyInfo = [];
}
comment.replyInfo.push(res.data);
this.replyContext = "";
} else {
this.comments.push(res.data);
this.context = "";
}
} else {
this.$message.error(res.msg);
}
this.isShowSec = this.isClickId = "";
},
},
};
</script>
<style lang="less" scoped>
.comment {
min-height: 26vh;
border-radius: 5px;
margin-top: 2px;
overflow: hidden;
transition: background-color 0.6s;
.active {
color: rgb(202, 4, 4);
}
.input-night {
/deep/.el-textarea__inner,
/deep/.el-input__inner {
background-color: rgb(50, 50, 50);
color: #ffffff;
}
}
.comment-header {
position: relative;
height: 50px;
padding: 10px 5px;
display: flex;
align-items: center;
transition: background-color 0.6s;
.input {
margin-left: 10px;
margin-right: 20px;
flex: 1;
transition: background-color 0.6s;
/deep/.el-input__inner:focus {
border-color: #dcdfe6;
}
}
}
.comment-body {
min-height: 70px;
padding: 10px 20px;
font-size: 14px;
transition: background-color 0.6s;
.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;
transition: background-color 0.6s;
.to_reply {
color: rgb(126, 127, 128);
}
}
}
}
}
}
</style>
总结
好了,一个评论组件就此封装好了,该有的功能都有了,在我感觉评论组件的逻辑还是比较复杂的,在压缩图片那一块还是卡了很久的,因为以前没有接触过这方面的内容,后边了解了一下canvas的知识,发现真的很强大,你也可以去学一学哦。如果本篇博客的内容有帮助到你的话,留个赞吧!