本文详细讲解如何使用jQuery封装一个通用的前端留言板组件,并结合PHP与MySQL实现后台数据读写。文章从数据库表结构设计入手,解析如何通过belong字段区分不同页面评论,利用parent字段实现嵌套回复。前端部分重点演示了Comment类的实例化、DOM节点初始化、Ajax异步获取与提交评论,以及列表渲染与分页逻辑,提供了一套完整的前后端交互方案。
首先,展示个人网站留言页面的实际效果,大家可以访问留言板查看运行状态。

前端开发为简化流程采用jQuery编写,后端则通过PHP脚本对MySQL数据库进行简单的读写操作。
数据库设计与实现思路
接下来探讨数据库的设计方案及整体实现思路。
数据库中创建了一个名为comments的数据表,具体结构参考下图。

所有类型的评论(包括文章下方的评论回复以及独立的留言板内容)均存储在同一张表中,通过belong字段来区分不同的评论区归属。
在同一个评论区内部,parent字段值为0时表示这是一条独立评论;若parent字段为其他数值,则代表这是对某条特定评论的回复,整体逻辑并不复杂。
请注意,本文不涉及CSS样式设计,读者可根据实际需求自行定制样式,现在开始进行代码封装。
明确功能需求。
根据实际业务需求确定功能范围,由于当前网站并未实现消息提醒或即时通讯功能,因此评论回复不会向站长或用户发送通知,仅在前端留言区产生视觉效果。基于此,我们只需实现以下核心功能:
1、显示评论列表
2、支持提交新评论
3、支持对已有评论进行回复
jQuery评论类封装实现
评论类设计。
我们将评论功能封装为一个JavaScript类,通过实例化该类即可创建不同的评论区实例,因此可以推测出以下实现方式:
在实例化对象时,我们需要传入的参数可能包括:评论区的唯一id、用于获取评论数据的PHP接口地址,以及提交评论的PHP接口地址。
因此,实例化评论区的代码可能类似于以下形式:
var oCmt = new Comment({
parent: $('#box'), //你想要将这个评论放到页面哪个元素中
id: 0,
getCmtUrl: './php/getcomment.php',
setCmtUrl: './php/comment.php'
})
当然,我是在Comment类上定义了一个静态方法来实现。
Comment.allocate({
parent: $('#box'),
id: 0,
getCmtUrl: './php/getcomment.php',
setCmtUrl: './php/comment.php'
})
两者大同小异,主要区别仅在于初始化的位置不同。
构造函数部分。
function Comment(options){
this.belong = options.id;
this.getCmtUrl = options.getCmtUrl;
this.setCmtUrl = options.setCmtUrl;
this.lists = [];
this.keys = {};
this.offset = 5;
}
var fn = Comment.prototype;
Comment.allocate = function(options){
var oCmt = new Comment(options);
if (oCmt.belong == undefined || !oCmt.getCmtUrl || !oCmt.setCmtUrl) {
return null;
};
oCmt.init(options);
return oCmt;
};
其中的变量和方法我们将逐步解释,如果不定义allocate方法,代码可以写成:
function Comment(options){
this.belong = options.id;
this.getCmtUrl = options.getCmtUrl;
this.setCmtUrl = options.setCmtUrl;
this.lists = [];
this.keys = {};
this.offset = 5;
if (this.belong == undefined || !this.getCmtUrl || !this.setCmtUrl) {
return null;
};
this.init(options)
}
var fn = Comment.prototype;
变量部分暂不展开,像我习惯先编写功能函数,随后再回头添加属性变量,我们只需关注构造函数最后执行了:
this.init(options)
从命名可以看出,这是一个初始化函数。
init函数详解。
fn.init = function (options) {
//初始化node
this.initNode(options);
//将内容放进容器
this.parent.html(this.body);
//初始化事件
this.initEvent();
//获取列表
this.getList();
};
这里的fn指代Comment.prototype,下文不再赘述。
初始化的工作包含四个方面,从代码注释中可以清晰看出,现在逐一进行讲解。
initNode函数。
从函数名可以看出,其主要作用是初始化节点或缓存DOM元素。
fn.initNode = function(options){
//init wrapper box
if (!!options.parent) {
this.parent = options.parent[0].nodeType == 1 ? options.parent : $('#' + options.parent);
};
if (!this.parent) {
this.parent = $('div');
$('body').append(this.parent);
}
//init content
this.body = (function(){
var strHTML = '<div class="m-comment">' +
'<div class="cmt-form">' +
'<textarea class="cmt-text" placeholder="欢迎建议,提问题,共同学习!"></textarea>' +
'<button class="u-button u-login-btn">提交评论</button>' +
'</div>' +
'<div class="cmt-content">' +
'<div class="u-loading1"></div>' +
'<div class="no-cmt">暂时没有评论</div>' +
'<ul class="cmt-list"></ul>' +
'<div class="f-clear">' +
'<div class="pager-box"></div>' +
'</div>' +
'</div>' +
'</div>';
return $(strHTML);
})();
//init other node
this.text = this.body.find('.cmt-text').eq(0);
this.cmtBtn = this.body.find('.u-button').eq(0);
this.noCmt = this.body.find('.no-cmt').eq(0);
this.cmtList = this.body.find('.cmt-list').eq(0);
this.loading = this.body.find('.u-loading1').eq(0);
this.pagerBox = this.body.find('.pager-box').eq(0);
};
从代码中可以观察到:
this.parent:保存的是评论所在的容器节点。 this.body:保存的是评论区的HTML结构。 this.text:保存的是用于输入评论的textarea元素。 this.cmtBtn:保存的是提交按钮。 this.noCmt:保存的是没有评论时显示的提醒文字。 this.cmtList:保存的是评论列表的容器。 this.loading:保存的是加载列表时显示的Loading GIF图片。 this.pagerBox:保存的是需要分页时的分页器容器。
JavaScript部分没有难点,主要调用了一些jQuery的方法。
将内容放入容器。
this.parent.html(this.body)
这部分逻辑很简单,此时我们的评论组件应该已经显示在页面上了,只是目前尚未加载评论列表,也不能进行评论操作,下面先讲解如何加载评论列表。
列表获取与渲染逻辑
getList函数。
首先需要初始化列表,清空现有内容,显示加载中的GIF图,并隐藏无评论的提醒文字,做好准备后发起Ajax请求。
思路是利用PHP将该评论区的留言全部获取,在前端进行整理,Ajax请求代码如下:
fn.resetList = function(){
this.loading.css('display', 'block')
this.noCmt.css('display', 'none');
this.cmtList.html('');
};
fn.getList = function(){
var self = this;
this.resetList();
$.ajax({
url: self.getCmtUrl,
type: 'get',
dataType: 'json',
data: { id: self.belong },
success: function(data){
if(!data){
alert('获取评论列表失败');
return !1;
};
//整理评论列表
self.initList(data);
self.loading.css('display', 'none');
//显示评论列表
if(self.lists.length == 0){
//暂时没有评论
self.noCmt.css('display', 'block');
}else{
//设置分页器
var total = Math.ceil(self.lists.length / self.offset);
self.pager = new Pager({
index: 1,
total: total,
parent: self.pagerBox[0],
onchange: self.doChangePage.bind(self),
label:{
prev: '<',
next: '>'
}
});
}
},
error: function(){
alert('获取评论列表失败');
}
});
};
采用get方式传输,并传递id参数,期望返回的数据格式为列表数组。
PHP后端代码此处省略,下面直接给出SQL语句:
$id = $_GET['id'];
$query = "select * from comments where belong=$id order by time";
...
$str = '[';
foreach ($result as $key => $value) {
$id = $value['id'];
$username = $value['username'];
$time = $value['time'];
$content = $value['content'];
$parent = $value['parent'];
$str .= <<<end
{
"id" : "{$id}",
"parent" : "{$parent}",
"username" : "{$username.'",
"time" : "{$time}",
"content" : "{$content}",
"response" : []
}
end;
}
$str = substr($str, 0, -1);
$str .= ']';
echo $str;
获取到的是JSON字符串,jQuery的Ajax可以将其自动转换为JSON数据,获得的数据格式如下:

如果加载成功,我们将得到一组数据,此时处于success回调函数中,由于所有评论和回复目前都属于同一层级,因此需要对数据进行整理才能显示。
initList函数。
fn.initList = function (data) {
this.lists = []; //保存评论列表
this.keys = {}; //保存评论id和index对应表
var index = 0;
//遍历处理
for(var i = 0, len = data.length; i < len; i++){
var t = data[i],
id = t['id'];
if(t['parent'] == 0){
this.keys[id] = index++;
this.lists.push(t);
}else{
var parentId = t['parent'],
parentIndex = this.keys[parentId];
this.lists[parentIndex]['response'].push(t);
}
};
};
我的思路是:将parent为0的留言(即独立评论)放入this.lists数组中;如果parent不为0,则表示这是一条回复,需要找到对应的父评论,并将该回复push进该父评论的response数组中。
但存在一个问题,由于id是递增生成的,中间可能存在被删除的评论,导致id与数组索引不匹配,因此借助this.keys来保存id和index的对应关系。
遍历一遍即可整理好所有数据,并全部存储在this.lists中,接下来只需将数据转换为HTML并放入页面即可。
//显示评论列表
if(self.lists.length == 0){
//暂时没有评论
self.noCmt.css('display', 'block');
}else{
//设置分页器
var total = Math.ceil(self.lists.length / self.offset);
self.pager = new Pager({
index: 1,
total: total,
parent: self.pagerBox[0],
onchange: self.doChangePage.bind(self),
label:{
prev: '<',
next: '>'
}
});
}
这是刚才Ajax的success回调函数的一部分,在整理完数据后,如果数据为空,则显示“暂时没有评论”。
否则,设置分页器,分页器直接复用了之前封装的代码,有兴趣的读者可以参考我之前的文章:
面向对象:分页器封装
简单来说,它会执行一遍onchange函数,默认页数为1,保存在参数obj.index中。
fn.doChangePage = function (obj) {
this.showList(obj.index);
};
showList函数。
fn.showList = (function(){
/* 生成一条评论字符串 */
function oneLi(_obj){
var str1 = '';
//处理回复
for(var i = 0, len = _obj.response.length; i < len; i++){
var t = _obj.response[i];
t.content = t.content.replace(/\<\;/g, '<');
t.content = t.content.replace(/\>\;/g, '>');
str1 += '<li class="f-clear"><table><tbody><tr><td>' +
'<span class="username">' + t.username + ':</span></td><td>' +
'<span class="child-content">' + t.content + '</span></td></tr></tbody></table>' +
'</li>'
}
//处理评论
var headImg = '';
if(_obj.username == "kang"){
headImg = 'kang_head.jpg';
}else{
var index = Math.floor(Math.random() * 6) + 1;
headImg = 'head' + index + '.jpg'
}
_obj.content = _obj.content.replace(/\<\;/g, '<');
_obj.content = _obj.content.replace(/\>\;/g, '>');
var str2 = '<li class="f-clear">' +
'<div class="head g-col-1">' +
'<img src="./img/head/' + headImg + '" width="100%"/>' +
'</div>' +
'<div class="content g-col-19">' +
'<div class="f-clear">' +
'<span class="username f-float-left">' + _obj.username + '</span>' +
'<span class="time f-float-left">' + _obj.time + '</span>' +
'</div>' +
'<span class="parent-content">' + _obj.content + '</span>' +
'<ul class="child-comment">' + str1 + '</ul>' +
'</div>' +
'<div class="respone-box g-col-2 f-float-right">' +
'<a href="javascript:void(0);" class="f-show response" data-id="' + _obj.id + '">[回复]</a>' +
'</div>' +
'</li>';
return str2;
};
return function (page) {
var len = this.lists.length,
end = len - (page - 1) * this.offset,
start = end - this.offset < 0 ? 0 : end - this.offset,
current = this.lists.slice(start, end);
var cmtList = '';
for(var i = current.length - 1; i >= 0; i--){
var t = current[i],
index = this.keys[t['id']];
current[i]['index'] = index;
cmtList += oneLi(t);
}
this.cmtList.html(cmtList);
};
})();
该函数的参数为page,即页数,我们根据页数截取this.lists中的数据,然后遍历生成HTML。
HTML模板我是通过字符串拼接实现的,具体可根据个人喜好调整。
生成后执行this.cmtList.html(cmtList),这样列表就显示出来了,效果图参见文章开头。
现在剩余的功能还有评论回复,而init函数中也只剩下最后一个initEvent需要处理。
交互事件与回复处理
initEvent函数。
fn.initEvent = function () {
//提交按钮点击
this.cmtBtn.on('click', this.addCmt.bind(this, this.cmtBtn, this.text, 0));
//点击回复,点击取消回复,点击回复中的提交评论按钮
this.cmtList.on('click', this.doClickResponse.bind(this));
};

上面的截图来自我的个人网站,当我们点击回复时,希望有一个地方可以书写回复内容,支持提交和取消操作。由于这些元素是动态添加的,因此我们将事件行为都委托给评论列表这个元素。
下面先介绍提交评论的事件处理函数。
addCmt函数。
fn.addCmt = function (_btn, _text, _parent) {
//防止多次点击
if(_btn.attr('data-disabled') == 'true') {
return !1;
}
//处理提交空白
var value = _text.val().replace(/^\s+|\s+$/g, '');
value = value.replace(/[\r\n]/g,'<br >');
if(!value){
alert('内容不能为空');
return !1;
}
//禁止点击
_btn.attr('data-disabled','true');
_btn.html('评论提交中...');
//提交处理
var self = this,
email, username;
username = $.cookie('user');
if (!username) {
username = '游客';
}
email = $.cookie('email');
if (!email) {
email = 'default@163.com';
}
var now = new Date();
$.ajax({
type: 'get',
dataType: 'json',
url: this.setCmtUrl,
data: {
belong: self.belong,
parent: _parent,
email: email,
username: username,
content: value
},
success: function(_data){
//解除禁止点击
_btn.attr('data-disabled', '');
_btn.html('提交评论');
if (!_data) {
alert('评论失败,请重新评论');
return !1;
}
if (_data['result'] == 1) {
//评论成功
alert('评论成功');
var id = _data['id'],
time = now.getFullYear() + '-' + (now.getMonth() + 1) + '-' + now.getDate() + ' ' +
now.getHours() + ':' + now.getMinutes() + ':' + now.getSeconds();
if (_parent == 0) {
var index = self.lists.length;
if (!self.pager) {
//设置分页器
self.noCmt.css('display', 'none');
var total = Math.ceil(self.lists.length / self.offset);
self.pager = new Pager({
index: 1,
total: total,
parent: self.pagerBox[0],
onchange: self.doChangePage.bind(self),
label:{
prev: '<',
next: '>'
}
});
}
self.keys[id] = index;
self.lists.push({
"id": id,
"username": username,
"time": time,
"content": value,
"response": []
});
self.showList(1);
self.pager._$setIndex(1);
}else {
var index = self.keys[_parent],
page = self.pager.__index;
self.lists[index]['response'].push({
"id": id,
"username": username,
"time": time,
"content": value
});
self.showList(page);
}
self.text.val('');
} else {
alert('评论失败,请重新评论');
}
},
error: function () {
alert('评论失败,请重新评论');
//解除禁止点击
_btn.attr('data-disabled', '');
_btn.html('提交评论');
}
});
}
该函数有3个参数:_btn, _text, _parent。之所以需要这三个参数,是因为评论和回复可以使用同一个函数处理,从而避免分开编写代码。
点击后执行常见的防止多次提交逻辑,检查Cookie中是否存在username、email等用户信息,若不存在则使用游客身份,然后处理内容,去除空白、将\n替换为<br>等,检验通过后再发起Ajax请求。
成功后将新评论添加到this.lists,然后执行this.showList(1)以刷新显示。
PHP部分仍然省略,SQL语句如下:
$parent = $_GET['parent'];
$belong = $_GET['belong'];
$content = htmlentities($_GET['content']);
$username = $_GET['username'];
$email = $_GET['email'];
$query = "insert into comments (parent,belong,content,time,username,email) value ($parent,$belong,'$content',NOW(),'$username','$email')";
doClickResponse 函数
fn.doClickResponse = function(_event){
var target = $(_event.target);
var id = target.attr('data-id');
if (target.hasClass('response') && target.attr('data-disabled') != 'true') {
//点击回复
var oDiv = document.createElement('div');
oDiv.className = 'cmt-form';
oDiv.innerHTML = '<textarea class="cmt-text" placeholder="欢迎建议,提问题,共同学习!"></textarea>' +
'<button class="u-button resBtn" data-id="' + id + '">提交评论</button>' +
'<a href="javascript:void(0);" class="cancel">[取消回复]</a>';
target.parent().parent().append(oDiv);
oDiv = null;
target.attr('data-disabled', 'true');
} else if (target.hasClass('cancel')) {
//点击取消回复
var ppNode = target.parent().parent(),
oRes = ppNode.find('.response').eq(0);
target.parent().remove();
oRes.attr('data-disabled', '');
} else if (target.hasClass('resBtn')) {
//点击评论
var oText = target.parent().find('.cmt-text').eq(0),
parent = target.attr('data-id');
this.addCmt(target, oText, parent);
}else{
//其他情况
return !1;
}
};
根据target.class来判断点击的是哪个按钮。
如果点击回复,生成HTML,并放到这条评论的后面。
var oDiv = document.createElement('div');
oDiv.className = 'cmt-form';
oDiv.innerHTML = '<textarea class="cmt-text" placeholder="欢迎建议,提问题,共同学习!"></textarea>' +
'<button class="u-button resBtn" data-id="' + id + '">提交评论</button>' +
'<a href="javascript:void(0);" class="cancel">[取消回复]</a>';
target.parent().parent().append(oDiv);
oDiv = null;
target.attr('data-disabled', 'true'); //阻止重复生成html
点击取消,则移除刚才生成的元素。
var ppNode = target.parent().parent(),
oRes = ppNode.find('.response').eq(0);
target.parent().remove();
oRes.attr('data-disabled', ''); //让回复按钮重新可以点击
点击提交,获取需要处理的元素,直接调用addCmt函数。
var oText = target.parent().find('.cmt-text').eq(0),
parent = target.attr('data-id');
this.addCmt(target, oText, parent);
注意:parent在生成HTML时已存储在提交按钮的data-id属性上。
至此,所有功能均已实现,希望对大家的学习有所启发。