导读:
如果您正在看这篇文章,想必您正在开发一个多人交互的网站,而踌躇自己写一个评论组件?
纵观BAT以及各种电子商务或博客等网站,无一不使用了评论这个功能。那么你能自己实现吗?
一、先欣赏一下各大网站的评论区图片
【CSDN】
【百度】
【知乎】
暂时先列举三个网站的评论区设计,其它还有很多网站的评论区设计都很经典,比如:GitHub、YouTube等等。由于网络原因我就不一一截图列举了。
二、分析与应用:主要从结构和源码入手
-
回复者与作者的关系
如图所示:我们把文章作者定义为(Root),评论者定义为(Leaf),而评论者具有多层关系,因此分为第一第二等层级。而每个层级又拥有多个子节点
-
分析与应用
实际应用其实并没有图片中那么复杂,而是更符合大众的直观感受。复杂体现在评论树的层级关系以及它们的层级关系表现上。所以为了更直观表达这种关系,各网站也是从传统的无限递归形式,转为表现两层关系,即评论者与回复者的关系即可。A级为评论者,直接对文章进行评论,那么对A的评论进行回复或者对回复A的评论的人进行回复的所有层级都被称为回复者。搞清了这个关系,那么设计起来就简单多了。
CSDN的网站的评论模块就采用两层关系设计,一方面是直观,一方面是操作简单,不用一层一层点开回复内容,同时减少了开发者的负担,而且维护起来也容易。百度也是两层关系设计,但以前不是。特别是以前看过YouTube网站,那层级关系忒复杂,等你把七八层的回复内容挨个点开时,你会发现主要内容已经消失不见。
-
f12源码(CSDN为例)
两层关系的源码被一个div包裹,而里面有若干个ul标签,而ul标签里是n个li标签。让我们进一步看看这两个li标签分别渲染了评论区哪些部分。
第一个li标签渲染的是评论者模块,第二个li标签渲染的是回复者模块。(这里有一个小插曲,第二个li的class名字是不是有问题,确定是replay-box吗,是不是应该为reply-box?)
两个li标签其实属于同级关系,但却渲染了不同级别的内容。这种设计更利于维护和实现。第二个li标签相对于它的父节点也就是第一个li标签而言,只是将margin的左外边距右偏32px(相对于右边的外边距而言,源码大家自己可以再网页使用f12查看,不同的浏览器快捷键可能不一样)
三、思考
从源码来看,层级结构大致是div中嵌套多个ul,而ul的多少代表有多少个评论者。
每个ul中嵌套两个li标签,分别渲染评论内容和回复内容两部分,回复内容在渲染上只要不与评论内容处于一个纵线即可直观区分它们之间的关系。具体如何实现均可看源码学习。
那么这样一个数据结构应该是什么样的呢?
我指的是前端的。首先肯定不是KVP的Map,最外层是多个ul,应该选择用数组,而数组里是不是应该还要用数组呢?
当然不是,虽然ul中嵌套多个li标签,根据行业经验前端不做过多的逻辑处理,如果用数组,就无法表达嵌套数组元素与外层数组元素的关系了,且无法携带描述信息。因此我想到了使用对象。对象可以封装描述每个评论单位的描述信息,同时可以携带一个数组,用来封装回复内容。这里要注意一点,回复内容从实现角度来说没有层级结构,他们只有关系,这样更直观。所以回复内容的父节点只有一个——父级评论者。如果后续考虑到数据结构的变化,我们可以在数据库中为每条回复内容增设两个字段,一个是超父节点ID,一个是直属父级节点ID,这样后续无论是改回递归形式呈现还是两级关系呈现,都不影响数据库的数据修改。
红色内容的数据库设计就好比算法中的并查集。我们在处理[[5, 6], [6, 7], [7, 8], [8, 9]] 这一类的 n 层的父子关系时(i0 是 i1 的子元素),假设要求找到子元素的超父级元素,在每一次寻找过程中,若找到了其超父元素应当将 i0 直接指向超父元素,比如 5 指向 9,这样就节省了下一次查找的时间。
四、实现
代码我已经写好了,有不妥的地方请指正(代码注释后续我会抽时间加上)。
-
CSS 代码部分
body, html{
background-color: rgb(0, 0, 0, 0.05);
position: absolute;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
}
dt, dd, dl{
margin: 0;
padding: 0;
}
dt, dd{
font-weight: 500;
display: inline-block;
}
button {
cursor: pointer;
}
textarea{
width: 820px; height: 50px; resize: none
}
.confirm_btn{
border: none;
background: red;
color: white;
padding: 4px 10px;
}
#button_submit_div{
text-align: right;
}
.time{
padding: 0 10px;
color: lightskyblue;
}
ul{
list-style: none;
padding: 0;
}
ul > li:hover{
background: rgba(0, 0, 0, 0.1);
}
ul > li{
padding: 10px 10px;
border-bottom: solid 1px lightgray;
display: block;
}
ul > li:last-child{
border-bottom: none;
}
.cmt_img, .cmt_reply, .cmt_info{
display: inline-block;
vertical-align: middle;
}
.cmt_reply {
float: right;
}
.cmt_reply_btn {
border: none;
display: none;
margin-right: 10px;
cursor: pointer;
}
.cmt_reply_checker {
border: none;
cursor: pointer;
}
.thumb_up:after {
content: '\2764';
font-family: "Segoe Fluent Icons", sans-serif;
width: 40px;
height: 40px;
cursor: pointer;
margin-left: 4px;
}
.avatar{
display: block;
}
aside, main{
background: white;
vertical-align: top;
margin-top: 50px;
box-shadow: 2px 0 2px 0 lightgray;
display: inline-block;
}
aside{
margin-left: 20px;
text-align: center;
width: 300px;
}
main{
padding: 20px;
text-align: left;
width: 900px;
}
.form_item{
padding: 10px 0;
}
.menu > div{
display: inline-block;
padding: 10px 10px;
}
.menu > div:hover{
background: rgba(0, 0, 0, 0.1);
cursor: pointer;
}
.menu{
margin: 0;
padding: 0;
background: white;
position: absolute;
width: 100%;
text-align: center
}
textarea{
border: solid 1px lightgray;
border-radius: 3px;
}
textarea{
padding: 10px;
height: 60px;
}
a {
color: dodgerblue;
}
.comment {
padding: 0 0 0 30px;
display: block; margin-top: 8px
}
.role_info {
float: right;
}
.role_pic, .role_name {
display: inline-block;
margin-left: 5px;
}
.avatar {
border-radius: 100%;
vertical-align: top;
}
.role_input_box, .role_confirm_btn {
display: inline-block;
}
.role_confirm_btn {
height: inherit;
border: none;
width: 80px;
background: red;
color: white;
border-top-right-radius: 2px;
border-bottom-right-radius: 2px;
}
#commentator {
border: none;
outline-style: none;
border-radius: 3px;
height: inherit;
width: 363px;
}
.fake_input_box {
border: solid 1px lightgrey;
border-right-style: none;
width: 500px;
padding: 0 0 0 3px;
margin: 0;
height: 50px;
position: relative;
border-radius: 3px;
}
.emoji_trigger_btn {
border: none;
background: none;
font-size: 20px;
vertical-align: bottom;
}
.emoji_board {
display: none;
position: absolute;
top: 0;
right: 105px;
font-size: 25px;
padding: 0 15px;
background: lightgrey;
border-radius: 3px;
}
.emoji_item {
cursor: pointer;
}
.emoji_board:after {
content: "";
position: absolute;
background-color: white;
width: 0;
height: 0;
top: 4px;
right: -20px;
border-top: 10px solid transparent;
border-left: 10px solid lightgrey;
border-right: 10px solid transparent;
border-bottom: 10px solid transparent;
}
-
H5 + JS 代码 部分
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Comment component implementation</title>
<link type="text/css" rel="stylesheet" href="comment.css">
</head>
<body>
<div class="menu">
<div>首页</div>
<div>博客</div>
<div>下载</div>
<div>视频</div>
<div>学习</div>
<div class="role_info">
<img alt="角色" width="25px" class="avatar role_pic" src="img/avt.jpg">
<span id="role_info_name" class="role_name">未登录</span>
</div>
</div>
<aside>
<ul>
<li>所有</li>
<li>Java</li>
<li>Python</li>
<li>C++</li>
<li>JavaScript</li>
</ul>
</aside>
<main onclick="mainCLick(event)" style="text-align: left">
<div class="form_item">
<div class="fake_input_box">
<label class="role_input_box">用户:<input id="commentator" placeholder="请输入您此时希望扮演的角色名称,确定后右上角会展示"/></label>
<button onclick="login()" class="role_confirm_btn">登录</button>
</div>
</div>
<hr/>
<div class="form_item">
<label class="label_img">
<img src="img/avt.jpg" alt="头像" width="50px" style="border-radius: 100%; vertical-align: top">
</label>
<textarea id="comment_space" onkeyup="readKey(event)" placeholder="请输入您要评论的内容"></textarea>
</div>
<div id="button_submit_div">
<div style="position: relative">
<span style="color: lightgray; margin-right: 20px">
还可以输入 <span id="char_count">1000</span> 个字符
</span>
<button class="confirm_btn" id="cancel_reply" onclick="cancelReply(event, this)" style="margin-right: 20px; display: none">取消回复</button>
<button onclick="emojiTrigger(event)" class="emoji_trigger_btn">🙂</button>
<button class="confirm_btn" id="submit_button" onclick="submitTree(event)">提交</button>
<div id="emoji_board" class="emoji_board">
</div>
</div>
</div>
<hr/>
<div id="comment_view">
</div>
</main>
<script type="text/javascript">
getRandomId = function () {
return 'id'.concat(Math.floor((Math.random() * 10000000000000000)).toString());
};
getCurrentTime = function () {
return new Date().toLocaleString();
};
var curArr = null;
var commentList = [];
window.onload = function (ev) {
// the function will be called at first while page loads
initTreeComment();
loadEmoji();
};
function loadEmoji() {
const dom = domById('emoji_board');
// 😜
const fragment = document.createDocumentFragment();
let num = 128512;
for (let i = 0; i < 6; ++i) {
const p = document.createElement('p');
p.className = 'emoji_row';
const pf = document.createDocumentFragment();
for (let j = 0; j < 9; ++j) {
const span = document.createElement('span');
span.className = 'emoji_item'
span.innerHTML = '&#' + num + ';';
span.onclick = function () {
setEmojiToContent(this.innerText);
}
pf.append(span);
++num;
}
p.appendChild(pf);
fragment.append(p);
}
dom.appendChild(fragment);
}
function setEmojiToContent(emo) {
domById('comment_space').value += emo;
}
function emojiTrigger(ev) {
ev.stopPropagation();
const dom = domById('emoji_board');
const block = dom.style.display;
if (block === 'block') {
dom.style.display = 'none';
} else {
dom.style.display = 'block';
}
}
function login() {
const commentator = domById('commentator').value.trim();
if (commentator.length > 0) {
domById('role_info_name').innerText = commentator;
}
initTreeComment();
}
/**
* 初始化评论模块
*/
initTreeComment = function () {
const commentView = domById('comment_view');
if (commentList.length === 0) {
noComment(commentView)
} else {
haveComment(commentView, commentList)
}
};
noComment = function (commentView) {
commentView.style.textAlign = 'center';
commentView.innerText = 'No comments'
};
/**
* 点击 “提交” 按钮调用的函数
* @param ev
*/
function submitTree (ev) {
const textareaEle = domById('comment_space');
ev.stopPropagation();
const text = textareaEle.value;
const commentator = domById('role_info_name').innerText;
if (text.length === 0 || commentator === '未登录') {
alarmIfEmpSpace();
return;
}
let plcHolder = textareaEle.placeholder;
if(plcHolder.charAt(0) === '回') {
plcHolder = plcHolder.substring(plcHolder.indexOf(' ') + 1, plcHolder.length)
} else {
curArr = commentList;
plcHolder = "anonymous";
}
if (commentator === plcHolder) {
cancelReply(event, domById('cancel_reply'));
alert("你不能评论或回复自己!");
return;
}
/*
* hash表用于保存当前评论的点赞角色名称,默认值位 false
* */
const admirers = new Map();
admirers.set(commentator, false);
const commentTree = {
id: getRandomId(),
commentator: commentator,
author: plcHolder,
comment: text,
time: getCurrentTime(),
thumbUp: 0,
admirers: admirers,
reply: []
};
curArr.push(commentTree);
initTreeComment();
curArr = null;
cancelReply(event, domById('cancel_reply'));
}
alarmIfEmpSpace = function () {
alert("The comment content or name are empty!")
};
/**
* 取消回复
* @param ev 事件
* @param th 回复按钮自身的 DOM 属性
*/
function cancelReply (ev, th) {
ev.stopPropagation();
th.style.display = 'none';
domById('comment_space').value = "";
countTextareaLength();
fillPlaceholderOfCommentSpace("请输入您要评论的内容");
}
/**
* 计算输入框的内容长度
*/
function countTextareaLength () {
const space = domById('comment_space');
document.getElementById('char_count').innerText = (1000 - space.value.length).toString();
}
/**
* 输入框触发按键事件,并执行相关操作,比如记录输入内容的长度
* @param ev 事件
*/
function readKey (ev) {
const space = domById('comment_space'), len = space.value.length;
document.getElementById('char_count').innerText = (1000 - len).toString();
handleCancelButton(space, len > 0 ? 'inline-block' : 'none')
}
function handleCancelButton (textArea, status) {
domById('cancel_reply').style.display = status;
}
/**
* 评论加载
* @param commentView
* @param arr
*/
haveComment = function (commentView, arr) {
commentView.style.textAlign = 'left';
let htmlText = '';
arr.forEach(function (value) {
htmlText +=
`<ul id="${value.id}" class="comment_ulist" style="">`+
` <li class="comment_line_box" id="${value.id.substring(0, 6)}">` +
` <div class="cmt_img">` +
` <img alt="头像" class="avatar" src="img/avt.jpg" width="30px" style="border-radius: 100%">` +
` </div>` +
` <div class="cmt_info">` +
` <a class="commentator">${value.commentator}</a>` +
` <span class="time">${value.time}</span>` +
` </div>` +
` <div class="cmt_reply">` +
` <span id="${value.id.substring(0, 9)}">` +
` <a class="cmt_reply_btn" id="${value.id.substring(0, 7)}">回复</a>` +
` </span>`;
if(value.reply.length > 0) {
htmlText += `<a id="${value.id.substring(0, 8)}" class="cmt_reply_checker">查看回复(${value.reply.length})</a>`
}
// 初始化当前角色是否给该条留言点赞了
const commentator = domById('role_info_name').innerText;
const isAdmirer = value.admirers.get(commentator);
let color = 'rgba(0,0,0,0.9)';
if (!isAdmirer) {
color = 'rgba(0,0,0,0.4)';
}
htmlText += "" +
` <span>` +
` <a style="color:${color}" id="${value.id.substring(0, 10)}" class="thumb_up">${(value.thumbUp > 0 ? value.thumbUp : "")}</a>` +
` </span>` +
`</div>`;
htmlText += ` <div style="display: block; margin-top: 8px" class="comment">${value.comment}</div>`;
htmlText += `</li></ul>`;
});
commentView.innerHTML = htmlText;
showButton(arr, 1)
};
function thumbUp(value) {
const dom = domById(value.id.substring(0, 10));
dom.onclick = function (ev) {
const cnt = value.thumbUp;
const commentator = domById('role_info_name').innerText;
const isAdmired = value.admirers.get(commentator);
if (!value.admirers.has(commentator)) {
value.admirers.set(commentator, false);
}
if (isAdmired) {
value.thumbUp -= 1;
dom.innerText = value.thumbUp === 0 ? '' : (cnt - 1).toString();
dom.style.color = 'rgba(0,0,0,0.4)';
} else {
value.thumbUp += 1;
dom.innerText = value.thumbUp.toString();
dom.style.color = 'rgba(0,0,0,0.9)';
}
value.admirers.set(commentator, !isAdmired);
// TODO 发送请求到后端,并更新数据库
/**
* @Tip 这里需要注意一点,点赞功能会存在恶意点击的情况,就是用户对当前评论频繁且快速点赞或取消点赞,如果每次点击我们都请求后端更新数据
* 那么势必会影响服务器的性能,因此我们需要避免这一点,这里不做解释,大家有兴趣可以评论区留言探讨
*/
}
}
showButton = function (arr, sign) {
arr.forEach(function (value) {
const parent = domById(value.id);
const broEle = domById(value.id.substring(0, 6));
const reply = domById(value.id.substring(0, 7));
broEle.onmouseover = function (ev) {
reply.style.display = 'inline-block';
};
thumbUp(value);
reply.onclick = function (ev) {
ev.stopPropagation();
curArr = sign === 1 ? value.reply : arr;
domById('cancel_reply').style.display = 'inline-block';
const str = "回复 ".concat(value.commentator);
fillPlaceholderOfCommentSpace(str)
};
const replyLen = value.reply.length;
if (replyLen > 0) {
handleReply(replyLen, value, broEle, parent);
}
broEle.onmouseleave = function (ev) {
reply.style.display = 'none'
};
});
};
function handleReply(len, value, broEle, parent) {
const checkReply = domById(value.id.substring(0, 8));
checkReply.onclick = (ev) => {
ev.stopPropagation();
if(checkReply.innerText.trim().charAt(0) === '查'){
ifHaveReply(parent, value.reply, broEle);
checkReply.innerText = "收起回复";
} else {
toggleBackReplies(parent);
checkReply.innerText = "查看回复("+ len +")";
}
};
}
toggleBackReplies = function (parentTag) {
const nodes = parentTag.childNodes;
parentTag.removeChild(nodes[nodes.length - 1]);
};
ifHaveReply = function (parentTag, arr, broEle) {
const li = document.createElement("li");
li.className = "reply_list";
li.style.marginLeft = '42px';
li.style.borderLeft = 'solid 5px lightgray';
let htmlText = '<ul class="comment_ulist">';
arr.forEach(function (value) {
// 初始化当前角色是否给该条留言点赞了
const commentator = domById('role_info_name').innerText;
const isAdmirer = value.admirers.get(commentator);
let color = 'rgba(0,0,0,0.9)';
if (!isAdmirer) {
color = 'rgba(0,0,0,0.4)';
}
htmlText +=
`<li class="comment_line_box" id="${value.id.substring(0, 6)}">` +
` <div class="cmt_img" style="margin-left: 10px">` +
` <img alt="头像" class="avatar" src="img/avt.jpg" width="30px" style="border-radius: 100%">` +
` </div>` +
` <div class="cmt_info">` +
` <a class="commentator">${value.commentator}</a>` +
` <span>回复</span>` +
` <a class="author">${value.author}</a>` +
` <span class="time">${value.time}</span>` +
` </div>` +
` <div class="cmt_reply">` +
` <span id="${value.id.substring(0, 9)}">` +
` <a class="cmt_reply_btn" id="${value.id.substring(0, 7)}">回复</a>` +
` </span>` +
` <span>` +
` <a style="color:${color}" id="${value.id.substring(0, 10)}" class="thumb_up">${(value.thumbUp > 0 ? value.thumbUp : "")}</a>` +
` </span>` +
` </div>` +
` <div class="comment">${value.comment}</div>` +
`</li>`;
});
htmlText += '</ul>';
li.innerHTML = htmlText;
parentTag.insertBefore(li, broEle.nextSibling);
showButton(arr, 2)
};
fillPlaceholderOfCommentSpace = function (str) {
document.getElementById('comment_space').placeholder = str
};
mainCLick = function (ev) {
ev.stopPropagation();
domById('emoji_board').style.display = 'none';
};
function len(id) {
return domById(id).value.trim().length;
}
function domById(id) {
return document.getElementById(id);
}
</script>
</body>
</html>
五、更新-点赞系统(2023.12.24)
此次更新的主要内容为【点赞功能】和【页面布样式】整体优化。
- 点赞系统核心就是控制每个角色对每一个对象的赞或踩的总次数只能是 1。意味着,若此次为赞,那么确保以前该对象没有被当前角色赞过。踩同理。
- 关于数据库设计的一些建议:数据库可以单独建立一张附属表,主键表是评论内容表,外键表也就是附属表存储的是点赞角色的相关关键属性,比如ID,Name等等。若当前角色取消点赞,可以去数据库删除这条纪录即可,反之保存。
- 避免恶意点击。对于点赞功能,很可能会面临用户的恶意点击,使得数据库压力过大。当然更好的设计可以避免这一点,比如限制用户每分钟的点击次数,或者点击间隔时间。说到这里,需要们做额外的设计了。为了让程序更加稳健,这种工作麻烦也得做,不然一旦出事,孰轻孰重大家都知道。点赞系统也可以对用户不做数量限制,也就是每个用户对每个对象无限点赞,这样看似可以省略一些工作,但我们怎么能允许每个用户每天对一个单独的对象点赞 1000+ 次呢?这样也会出现对象注水的情况。
具体代码,我们第四部分的代码区域。我这里采用的是单个用户对单个对象的赞只能是 1 次。
六、更新-表情包子(2023.12.30)
此次更新的内容,主要为添加表情包子以及增加 es6 新语法 "``" 模板字符串。具体可以拷贝代码并查看。
下面主要说说表情包子的实现方式。
- 表情包子从哪里获取
表情包子大家可以使用 H5 自带的 Emoji,数量很多,完全够用。如果想自定义,也可以去阿里巴巴的 iconfont 库看看。我这里使用的是 H5 自带的。
- 表情包子在代码中使用的一些细节
H5 自带的表情包子自身有相应且唯一的 code,比如 "😜"。注意到这个 code 的后缀部分为 Int 范围以内的数字,因此我们在使用时,设一个数字基数,比如 128540,然后每次累加 1 选取一定数量的表情供用户选择。
- 表情包子如何解释
表情包子为符号+数字组成,在展示板中我们看到的是解释后的,那么当我们点击时,出现在评论盒子中的是未被解释的。当我们将这些由其他内容和表情 code 组成的评论内容保存数据库后,再次获取出来,就需要对这些表情 code 进行解释。
一个好的解决办法是:
将表情包子的 code 码,用一个伪开闭标签对 code 码进行 wrap,比如 “[face] 表情code码 [/face]” ,在解释时我们只需要全局替换掉 [face] 和 [/face] 为正确的标签即可进行解释。
七、代码运行截图
八、结语
谢谢大家的欣赏,我会在编程和编辑这条路上越走越好,送给仍然青年的自己!