自定義評論元件
前段時間開發了 MyBlog 個人部落格項目,耗費了兩個月的時間(其實真正的開發時間沒這麼久,因為後來實習就隻能下班後再開發),本篇部落格來介紹一下項目中封裝的評論元件。
基本技術棧
vue2 + element ui
效果
分析
簡單需求分析
咱們先來看看一個評論元件需要滿足什麼需求?
- 評論文章:既然是評論元件,那麼首先就應該滿足對文章内容進行評論,(也就是一級評論)。并且需要獲得文章的 id,這樣才能使評論與文章之間建立聯系。
- 回複一級評論:能夠對一級評論的内容進行回複(也就是二級評論)。
- 回複二級評論:能夠對二級評論内容進行回複,這裡就需要注意了,回複二級評論的評論還是二級評論而不是三級評論。原因如下:
- 類型:對一篇文章的評論來說隻需要區分該評論是直接對文章内容的評論還是回複别人的評論,即使你回複二級評論也依然是一條回複,是以他應該與二級評論是一類。
- 實作:如果回複一次别人的評論就加一級嵌套,這樣就會導緻如果别人一直評論就會一直嵌套,那麼當嵌套層級過深必然會影響頁面布局。并且如果使用這樣的方式該怎麼設計資料庫來做資料持久化呢,顯而易回複一次别人的評論就加一級嵌套的設計并不合理。
- 點贊:如果你覺得該評論深得你心,那麼可以對評論進行點贊。
- 删除評論:如果你覺得剛剛的評論沒有表達好,那麼你可以删除該評論。當然每個人隻能删除自己的評論,不能删除别人的評論。
- 上傳頭像:使用者能夠上傳自己的頭像。
資料庫設計
由于部落客這裡采用的是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
我是孤城浪人,一名正在前端路上摸爬滾打的菜鳥,歡迎你的關注。