實作 PHP API & 留言板 SPA (番外篇:實作載入更多功能)


Posted by Nicolakacha on 2020-09-12

上次做完了 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, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
}

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>&nbsp;&nbsp;${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);
  });
});

API做翻页的两种思路
How to do Pagination?


#PHP #API #cursor based pagination







Related Posts

用 JavaScript 學習資料結構和演算法:佇列(Queue)篇

用 JavaScript 學習資料結構和演算法:佇列(Queue)篇

【Git】批量修改已提交之 username 與 email

【Git】批量修改已提交之 username 與 email

BootStrap5 第一章 : 載入

BootStrap5 第一章 : 載入


Comments