上次做完了 PHP API 和 留言板 SPA:
實作 PHP API & 留言板 SPA(上)
實作 PHP API & 留言板 SPA(下)
但其實 DEMO 裡面有一個功能沒有講到,就是「載入更多」的功能,我們不希望一打開留言板就看到全部的貼文,而是只顯示最新的 5 則留言,每次按下載入更多按鈕的時候,再往前讀取 5 則,直到所有留言都顯示出來為止。
首先要回去看一下原本的架構,分為載入留言和新增留言兩部份:
$(document).ready(() => {
const commentsDOM = $('.comments');
getComments(commentsDOM);
$('.add-comment-form').submit((e) => {
e.preventDefault();
addComment(commentsDOM);
});
});
現在我們想要在每次點擊 Read More 的時候,都透過 getComments 拿 5 筆資料。可以先把架構寫好,再來思考怎麼修改 getComments,讓每次都可以往前拿到 5 筆資料:
$('.load-more').click(() => {
getComments(commentsDOM);
});
留言板只要顯示最新的 5 則留言,所以要在後端 api_comments.php 和資料庫拿資料 SQL 加上 LIMIT 5 的限制
api_comments.php
$sql = "SELECT nickname, content, created_at, id FROM nicolakacha_discussion WHERE site_key =? ORDER BY id DESC LIMIT 5";
$stmt = $conn->prepare($sql);
$stmt->bind_param('s', $siteKey);
這樣每次就只會拿到最後 5 則留言,但是,但是,要怎麼每次點擊都往前推回 5 個呢?這就是這次的重點啦 —— Cursor based pagination!
Cursor based pagination
把畫面上最早的那則留言當作指標,每次載入小於指標的 5 則留言
因為我們每則留言都有自動增加(auto increment) 所產生的 id,我們可以把畫面上尾端的留言的 id 當作指標,並再每次拿到這個指標之前的 5 則留言。
舉例來說,假設現在有 15 則留言,畫面上的最舊的一筆留言的 id 是 11,就把它當作指標,載入 id < 11 的 5 則留言,也就是 id 6~10 的留言。
載入 6~10 的留言之後,現在畫面上最早的一則留言變成了 id 6 的留言,再次把它當成指標,指標指到了 6,就載入 id < 6 的 5 則留言,也就是 id 1~5 的留言。
透過這種方法來做到的分頁機制,就是 Cursor based pagination,簡單來說,就是把最前一個資料當成 Cursor,每次往前查詢的做法。
如果更前面沒有留言可以拿了,就隱藏 read more 的按紐,這樣就完成了載入更多的功能呢。
但...但是,事情總是沒這麼單純,要怎麼知道前面還有沒有資料可以拿???
往前打聽一下
把後端從資料庫拿留言的筆數改成 6 筆 (LIMIT 6),讓前端每次可以拿 6 則留言,但我們只顯示 5 則,指標一樣是畫面上的最後一則,多拿的那則就是為了偷偷打聽一下前面還有沒有留言可以拿。
狀況一:如果前端拿到了 6 筆,代表我們至少還有一則留言是沒有被載入的,可以繼續讓 read more 按鈕顯示在畫面上。
狀況二:如果前端拿到的留言小於 6 筆,代表更前面已經沒有留言可以拿了,則隱藏 read more 按鈕。
透過這樣的方式,我們就可以在即使不知道留言總數的情況之下,也能正確顯示 read more 按鈕,接下來就來把概念實作成程式碼吧:
一開始我們並不需要 cursor,只要知道第一次是拿到的留言數是不是小於 6,來判斷要不要有 read more 按鈕,如果是拿到 6,代表需要 read more 按鈕,我們就把最後一則留言的 id 當作 cursor。如果一開始拿到的留言數就小於 6,我們也不需要 read more 按鈕了。
如果有 cursor,代表還有留言可以載入,所以每次點擊 read more 的時候,就把 cursor 傳給後端以修改 SQL query。寫成程式碼的樣子就會像這樣:
if (!empty($_GET['cursor'])) {
$cursor = $_GET['cursor'];
}
// 點擊 read more 的載入
if (!empty($_GET['cursor'])) {
$sql = "SELECT nickname, content, created_at, id FROM nicolakacha_discussion WHERE (site_key =? AND id < ?) ORDER BY id DESC LIMIT 6";
$stmt = $conn->prepare($sql);
$stmt->bind_param('si', $siteKey, $cursor);
// 一開始的載入
} else {
$sql = "SELECT nickname, content, created_at, id FROM nicolakacha_discussion WHERE site_key =? ORDER BY id DESC LIMIT 6";
$stmt = $conn->prepare($sql);
$stmt->bind_param('s', $siteKey);
};
而前端的 JavaScript,就是要幫我們在每次拿到留言時,檢查留言的筆數是不是小於六則,如果小於 6 則我們就不藏了,不管拿到的是一則兩則還是五則留言,全部都顯示出來吧!,如果等於六 則,就只顯示前五則,最後一則用來偵測,順便把 cursor 這個變數重新賦值成畫面上最前一筆(第五則)留言的 id。
function getComments(commentsDOM) {
getCommentsAPI(cursor, (data) => {
if (!data.ok) {
console.log(data.message);
return;
}
// Get 6 comments at first but don't render the last one. The last one is for checking purpose.
const comments = data.discussion;
// If comments < 6, there is no more to get
// then render all comments and also hide the load more button.
if (comments.length < 6) {
for (let i = 0; i < comments.length; i += 1) {
appendCommentToDOM(commentsDOM, comments[i]);
}
$('.load-more').hide();
// if comments >= 6, we only render the first 5 of them.
} else {
for (let i = 0; i < comments.length - 1; i += 1) {
appendCommentToDOM(commentsDOM, comments[i]);
}
// Set the 2nd last comment's id as cursor.
cursor = comments[comments.length - 2].id;
console.log(cursor);
}
});
}
如果有 cursor,點擊 read more 跟後端要資料的時候,那麼就把 cursor 告訴後端吧:
function getCommentsAPI(cursorDefault, cb) {
let url = `${APIUrl}/api_comments.php?site_key=nicolas`;
if (cursorDefault) {
url += `&cursor=${cursor}`;
}
$.ajax({ url })
.done(data => cb(data))
.fail(err => console.log(err));
}
總結
在前面還有留言的時候,顯示 read more 按鈕,在沒有留言的時候則隱藏,這樣一來我們 cursor based pagination 的載入更多功能就完成啦!這種分頁做法的優點是查詢起來效能較好,增加和新增資料也不會影響查詢結果。但是就不能像一般的頁碼一樣跳到指定的某一頁,因為根本沒有固定的頁,一定要從頭或從尾開始遍歷才行。
完整 script.js 參考
const APIUrl = 'http://mentor-program.co/mtr04group1/Nicolakacha/week12/board';
let cursor = null;
function encodeHTML(s) {
return s.replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
}
function addComment(commentsDOM) {
const nickname = $('input[name=nickname]').val().trim();
const content = $('textarea[name=content]').val().trim();
if (nickname === '' || content === '') {
console.log('not completed');
$('.alert').remove();
const remindMsg = '<div class="alert alert-danger mt-5" role="alert">Please complete both nickname and content!</div>';
$('.main').prepend(remindMsg);
return;
}
const newComment = {
site_key: '123',
nickname,
content,
};
$.ajax({
type: 'POST',
url: `${APIUrl}/api_add_comments.php`,
data: newComment,
})
.done((data) => {
console.log(data);
newComment.created_at = data.created_at;
appendCommentToDOM(commentsDOM, newComment, true);
$('.form-control').val('');
$('.alert').remove();
})
.fail(err => console.log(err));
}
function appendCommentToDOM(container, comment, isPrepend) {
const html = `
<div class="card m-2">
<div class="card-body">
<div class="card-top d-flex">
<h5 class="card-title"><i class="fa fa-user" aria-hidden="true"></i> ${encodeHTML(comment.nickname)}</h5>
<p class="card-text time">${comment.created_at}</p>
</div>
<p class="card-text content">${encodeHTML(comment.content)}</p>
<input hidden value="${comment.id}"/>
</div>
</div>`;
if (isPrepend) {
container.prepend(html);
} else {
container.append(html);
}
}
function getCommentsAPI(cursorDefault, cb) {
let url = `${APIUrl}/api_comments.php?site_key=nicolas`;
if (cursorDefault) {
url += `&cursor=${cursor}`;
}
$.ajax({ url })
.done(data => cb(data))
.fail(err => console.log(err));
}
function getComments(commentsDOM) {
getCommentsAPI(cursor, (data) => {
if (!data.ok) {
console.log(data.message);
return;
}
// Get 6 comments at first but don't render the last one. The last one is for checking purpose.
const comments = data.discussion;
// If comments < 6, there is no more to get
// then render all comments and also hide the load more button.
if (comments.length < 6) {
for (let i = 0; i < comments.length; i += 1) {
appendCommentToDOM(commentsDOM, comments[i]);
}
$('.load-more').hide();
// if comments >= 6, we only render the first 5 of them.
} else {
for (let i = 0; i < comments.length - 1; i += 1) {
appendCommentToDOM(commentsDOM, comments[i]);
}
// Set the 2nd last comment's id as cursor.
cursor = comments[comments.length - 2].id;
console.log(cursor);
}
});
}
$(document).ready(() => {
const commentsDOM = $('.comments');
getComments(commentsDOM);
$('.add-comment-form').submit((e) => {
e.preventDefault();
addComment(commentsDOM);
});
$('.load-more').click(() => {
getComments(commentsDOM);
});
});