经过之前的文章管理和分类管理两个功能的开发过程,我们发现基本上都是相同的套路,而且对于任何一个业务最终都还是这些基础的增删改查。
这里的评论管理我们就不在按照之前的实现方式(传统的动态网站方式)去完成了,取而代之的是 AJAX 方式。
咱们这个过程的设计就体现了 Web 技术的大致发展历史,了解并掌握这些有助于我们更好的使用当下最新最优的方案完成当下应用的开发。
这里我们最后一次对比一下传统的动态网站方式和 AJAX 方式之间的差异:
传统方式
AJAX 方式
早在开始开发的时候,我们就已经将评论管理的静态页面整合进来了,这里我们只需要简单调整一下,包括:
既然是返回空页面,那么页面执行过程中就不需要有 PHP 代码的执行了,那么 PHP 对于这个过程就没有意义了。
提问:这里的评论管理页是应该用 html 文件,还是 php 文件?
🚩 源代码: step-58
既然是通过 AJAX 获取数据,然后在通过 DOM 操作渲染数据,我们首先第一前提就是要有一个可以获取评论数据的接口,那么接下来我们就需要开发一个可以返回评论数据的接口。
从使用者的角度考虑每一个所需功能,反推出来对接口的要求,然后具体实现每个要求,这就是所谓的逆向工程。
对于评论管理页面,我们的需求是:
根据需求得知,这个功能开发过程中需要三个的接口(endpoint),我们创建三个 php 文件:
名词解释:对于 Web API,我们把每一个接入点称之为 endpoint
分页查询数据的逻辑可以参考文章管理模块的数据加载
1// 处理分页参数2// ========================================34// 页码5$page = isset($_GET['p']) && is_numeric($_GET['p']) ? intval($_GET['p']) : 1;67// 检查页码最小值8if ($page <= 0) {9 header('Location: /admin/comment-list.php?p=1');10 exit;11}1213// 页大小14$size = isset($_GET['s']) && is_numeric($_GET['s']) ? intval($_GET['s']) : 20;1516// 查询总条数17$total_count = intval(xiu_query('select count(1) from comments18inner join posts on comments.post_id = posts.id')[0][0]);1920// 计算总页数21$total_pages = ceil($total_count / $size);2223// 检查页码最大值24if ($page > $total_pages) {25 // 跳转到最后一页26 header('Location: /admin/comment-list.php?p=' . $total_pages);27 exit;28}2930// 查询数据31// ========================================3233// 分页查询评论数据34$comments = xiu_query(sprintf('select35 comments.*, posts.title as post_title36from comments37inner join posts on comments.post_id = posts.id38order by comments.created desc39limit %d, %d', ($page - 1) * $size, $size));4041// 响应 JSON42// ========================================4344// 设置响应类型为 JSON45header('Content-Type: application/json');4647// 输出 JSON48echo json_encode(array(49 'success' => true,50 'data' => $comments,51 'total_count' => $total_count52));🚩 源代码: step-59
参考分类删除或者文章删除。
1// 设置响应类型为 JSON2header('Content-Type: application/json');34if (empty($_GET['id'])) {5 // 缺少必要参数6 exit(json_encode(array(7 'success' => false,8 'message' => '缺少必要参数'9 )));10}1112// 拼接 SQL 并执行13$affected_rows = xiu_execute(sprintf('delete from comments where id in (%s)', $_GET['id']));1415// 输出结果16echo json_encode(array(17 'success' => $affected_rows > 018));🚩 源代码: step-60
1// 设置响应类型为 JSON2header('Content-Type: application/json');34// 不是说 POST 方式就不能使用 GET 传参数,不要固化思维5if (empty($_GET['id']) || empty($_POST['status'])) {6 // 缺少必要参数7 exit(json_encode(array(8 'success' => false,9 'message' => '缺少必要参数'10 )));11}1213// 拼接 SQL 并执行14$affected_rows = xiu_execute(sprintf("update comments set status = '%s' where id in (%s)", $_POST['status'], $_GET['id']));1516// 输出结果17echo json_encode(array(18 'success' => $affected_rows > 019));🚩 源代码: step-61
有了接口过后,我们就可以通过在页面中执行 AJAX 操作调用这些接口,实现相对应的功能了。
页面加载完成过后,发送异步请求获取评论数据
1$.get('/admin/comment-list.php', { p: 1, s: 30 }, function (res) {2 console.log(res)3 // => { success: true, data: [ ... ], total_count: 100 }4})将数据渲染(客户端渲染)到表格中:
1var $alert = $('.alert')2var $tbody = $('tbody')34// 页面加载完成过后,发送异步请求获取评论数据5$.get('/admin/comment-list.php', { p: 1, s: 30 }, function (res) {6 console.log(res)7 // => { success: true, data: [ ... ], total_count: 100 }8 if (!res.success) {9 // 加载失败 提示消息 并结束运行10 return $alert.text(res.message)11 }1213 // 将数据渲染到表格中14 $(res.data).each(function (i, item) {15 // 每一个数据对应一个 tr16 $tbody.append('<tr class="' + '' + '">' +17 ' <td class="text-center"><input type="checkbox"></td>' +18 ' <td>' + item.author + '</td>' +19 ' <td>' + item.content + '</td>' +20 ' <td>《' + item.post_title + '》</td>' +21 ' <td>' + item.created + '</td>' +22 ' <td>' + item.status + '</td>' +23 ' <td class="text-center">' +24 ' <a href="javascript:;" class="btn btn-info btn-xs">批准</a>' +25 ' <a href="javascript:;" class="btn btn-danger btn-xs">删除</a>' +26 ' </td>' +27 '</tr>')28 })29})🚩 源代码: step-62
不用往下写了,一旦涉及到这种数据加载渲染的问题,就会涉及到大量的字符串拼接问题,费劲还容易错,总之很不爽。
之前在服务端渲染数据的时候,没有太多这种感觉,而现在到了客户端渲染就十分恶心,根本原因是因为我们的方法过于原始,对于简单的数据渲染还是可以接受的,但是一旦数据复杂了,结构复杂了,过程就十分恶心,而后端使用的实际上是一种“套模板”过程。
脑子稍微灵光一点的同学就应该想得明白:作为一个天天骑自行车上下班的人看着旁边的人都骑摩托车,久而久之这个骑自行车的也会换成摩托车。
当然这里只是打个比方,可以体会我想表达的意思:作为一个积极的人,在明知道有更好方案的情况下是不会一直使用自己以前的老旧方案的,这是社会进步的核心,也是技术进步的核心。
前端也有模板引擎,而且从使用上来说,更多更好更方便。
换而言之,模板引擎的本质其实就是各种恶心的字符串操作。 而这里我为什么扯了这么多,主要目的是希望你能够有所体会,有所感悟,不要荒废了你的思考能力。
这里我们借助一个非常轻量的模板引擎 jsrender 解决以上问题,模板引擎的使用套路也都类似:
载入模板引擎库:
1<script src="/static/assets/vendors/jsrender/jsrender.js"></script>准备模板:
1<script id="comment_tmpl" type="text/x-jsrender">2 <tr class="danger">3 <td class="text-center"><input type="checkbox"></td>4 <td>大大</td>5 <td>楼主好人,顶一个</td>6 <td>《Hello world》</td>7 <td>2016/10/07</td>8 <td>未批准</td>9 <td class="text-center">10 <a href="javascript:;" class="btn btn-info btn-xs">批准</a>11 <a href="javascript:;" class="btn btn-danger btn-xs">删除</a>12 </td>13 </tr>14</script>提问:一般模板引擎都要求把模板定义在
<script>标签中,为什么?
调用模板方法:
1var html = $('#comment_tmpl').render(data)2// html => 渲染后的结果3// 设置到页面中4$tbody.html(html)借助模板语法输出变量:
1<script id="comment_tmpl" type="text/x-jsrender">2 {{if success}}3 {{for data}}4 <tr class="{{: status === 'held' ? 'warning' : status === 'rejected' ? 'danger' : '' }}">5 <td class="text-center"><input type="checkbox" data-id="{{: id }}"></td>6 <td>{{: author }}</td>7 <td>{{: content }}</td>8 <td>《{{: post_title }}》</td>9 <td>{{: created}}</td>10 <td>{{: status === 'held' ? '待审' : status === 'rejected' ? '拒绝' : '准许' }}</td>11 <td class="text-center">12 {{if status === 'held'}}13 <button class="btn btn-info btn-xs" data-id="{{: id }}">批准</button>14 <button class="btn btn-warning btn-xs" data-id="{{: id }}">拒绝</button>15 {{/if}}16 <button class="btn btn-danger btn-xs" data-id="{{: id }}">删除</button>17 </td>18 </tr>19 {{/for}}20 {{else}}21 <tr>22 <td colspan="7">{{: message }}</td>23 </tr>24 {{/if}}25</script>🚩 源代码: step-63
我们之前写的生成分页 HTML 是在服务端渲染分页组件,只能作用在服务端渲染数据的情况下。但是当下的情况我们采用的是客户端渲染的方案,自然就用不了之前的代码了,但是思想是相通的,我们仍然可以按照之前的套路来实现,只不过是在客户端,借助于 JavaScript 实现。
✏️ 作业: 实现一个 JavaScript 版本的分页组件
这里我们就不自己再写了,前端行业最大的特点就是轮子多,实际开发过程中我们多是使用已有的轮子。
思考: 为什么不要造轮子,造轮子有什么优点,又有什么缺点?
这里使用的是 twbs-pagination,使用方法就不在这里赘述了。
1// 页面加载完成过后,发送异步请求获取评论数据2$.get('/admin/comment-list.php', { p: 1, s: size }, function (res) {3 // 通过模板引擎渲染数据4 var html = $tmpl.render(res)56 // 设置到页面中7 $tbody.html(html)89 // 分页组件10 $pagination.twbsPagination({11 totalPages: Math.ceil(res.total_count / size),12 onPageClick: function (event, page) {13 // 页码发生变化时执行14 console.log(page)15 }16 })17})🚩 源代码: step-64
我们后续都会在 onPageClick 加载指定页的数据:
1// 页面加载完成过后,发送异步请求获取评论数据2$.get('/admin/comment-list.php', { p: 1, s: size }, function (res) {3 // 通过模板引擎渲染数据4 var html = $tmpl.render(res)56 // 设置到页面中7 $tbody.html(html)89 // 分页组件10 $pagination.twbsPagination({11 initiateStartPageClick: false, // 否则 onPageClick 第一次就会触发12 totalPages: Math.ceil(res.total_count / size),13 onPageClick: function (e, page) {14 $.get('/admin/comment-list.php', { p: page, s: size }, function (res) {15 // 通过模板引擎渲染数据16 var html = $tmpl.render(res)17 // 设置到页面中18 $tbody.html(html)19 })20 }21 })22})🚩 源代码: step-65
如果是采用同步的方式,则与文章或分类管理的删除相同,但是此处我们的方案是采用 AJAX 方式。
万变不离其宗,想要删除掉评论,客户端肯定是做不到的,因为数据在服务端。可以通过客户端发送一个请求(信号)到服务端,服务端执行删除操作,服务端业务已经实现,现在的问题就是客户端发请求的问题。
常规思路:
btn-delete 的 classbtn-delete 注册点击事件 1$('.btn-delete').on('click', function () {2 console.log('btn delete clicked')3})但是经过测试发现,在点击删除按钮后控制台不会输出任何内容,也就是说按钮的点击事件没有执行。
提问:为什么按钮的点击事件不会执行
问题的答案也非常简单:当执行注册事件代码时,表格中的数据还没有初始化完成,那么通过 $('.btn-delete') 就不会选择到后来界面上的删除按钮元素,自然也就没办法注册点击事件了。
解决办法:
1// 删除评论2$tbody.on('click', '.btn-delete', function () {3 console.log('btn delete clicked')4})🚩 源代码: step-66
点击事件执行 -> 发送异步请求 -> 移除当前点击按钮所属行
1$tbody.on('click', '.btn-delete', function () {2 var $tr = $(this).parent().parent()3 var id = parseInt($tr.data('id'))4 $.get('/admin/comment-delete.php', { id: id }, function (res) {5 res.success && $tr.remove()6 })7})🚩 源代码: step-67
个人认为删除成功过后,不应该单单从界面上的表格中移除当前行,而是重新加载当前页数据。
我们重新调整一下代码:
1var $alert = $('.alert')2var $tbody = $('tbody')3var $tmpl = $('#comment_tmpl')4var $pagination = $('.pagination')56// 页大小7var size = 308// 当前页码9var currentPage = 11011/**12 * 加载指定页数据13 */14function loadData () {15 $.get('/admin/comment-list.php', { p: currentPage, s: size }, function (res) {16 // 通过模板引擎渲染数据17 var html = $tmpl.render(res)18 // 设置到页面中19 $tbody.html(html)20 })21}2223// 页面加载完成过后,发送异步请求获取评论数据24$.get('/admin/comment-list.php', { p: 1, s: size }, function (res) {25 console.log(res)26 // => { success: true, data: [ ... ], total_count: 100 }2728 // 通过模板引擎渲染数据29 var html = $tmpl.render(res)3031 // 设置到页面中32 $tbody.html(html)3334 // 分页组件35 $pagination.twbsPagination({36 initiateStartPageClick: false, // 否则 onPageClick 第一次就会触发37 totalPages: Math.ceil(res.total_count / size),38 onPageClick: function (e, page) {39 currentPage = page40 loadData()41 }42 })43})4445// 删除评论46$tbody.on('click', '.btn-delete', function () {47 var $tr = $(this).parent().parent()48 var id = parseInt($tr.data('id'))49 $.get('/admin/comment-delete.php', { id: id }, function (res) {50 res.success && loadData()51 })52})🚩 源代码: step-68
1$tbody.on('click', '.btn-edit', function () {2 var id = parseInt($(this).parent().parent().data('id'))3 var status = $(this).data('status')4 $.post('/admin/comment-status.php?id=' + id, { status: status }, function (res) {5 res.success && loadData()6 })7})🚩 源代码: step-69
当选中了一个或一个以上的行时,显示批量操作按钮:
1var $btnBatch = $('.btn-batch')23// 选中项集合4var checkedItems = []56// 批量操作按钮7$tbody.on('change', 'td > input[type=checkbox]', function () {8 var id = parseInt($(this).parent().parent().data('id'))9 if ($(this).prop('checked')) {10 checkedItems.push(id)11 } else {12 checkedItems.splice(checkedItems.indexOf(id), 1)13 }14 checkedItems.length ? $btnBatch.fadeIn() : $btnBatch.fadeOut()15})🚩 源代码: step-70
点击表头中的复选框,切换表格中全部数据选中状态
1// 全选 / 全不选2$('th > input[type=checkbox]').on('change', function () {3 var checked = $(this).prop('checked')4 $('td > input[type=checkbox]').prop('checked', checked).trigger('change')5})🚩 源代码: step-71
点击不同按钮,执行不同请求
1// 批量操作2$btnBatch3 // 批准4 .on('click', '.btn-info', function (e) {5 $.post('/admin/comment-status.php?id=' + checkedItems.join(','), { status: 'approved' }, function (res) {6 res.success && loadData()7 })8 })9 // 拒绝10 .on('click', '.btn-warning', function (e) {11 $.post('/admin/comment-status.php?id=' + checkedItems.join(','), { status: 'rejected' }, function (res) {12 res.success && loadData()13 })14 })15 // 删除16 .on('click', '.btn-danger', function (e) {17 $.get('/admin/comment-delete.php', { id: checkedItems.join(',') }, function (res) {18 res.success && loadData()19 })20 })🚩 源代码: step-72
✏️ 作业: 解决刷新过后继续加载指定页码下的数据
🚩 源代码: step-73