序
これも前回のJavaScript - 文字数カウンターのリライトと同じく、元々自分用に貯めてあったメモをQiitaに公開していこうという動きの一環で投稿しています。
そういえばこれ書いたおかげでかなり勉強になったな~~ということを思い出したので反復がてら投稿してみます。
経緯
元々自分のサイトで、非公開の「掲示板のお気に入りスレッド登録・リスト化システム」を自分用に作って使っていたのですが、今回のページ分け・ページャ表示機能を追加するまではどれだけ登録件数が多くなっても全件をリストにベタ貼りしている状態だったため、「ページ分けしたいなあ」と考えていました。
加えて、自サイトにJavaScriptで書いて置いてあったページネーションがわりとそのまま転用できそうだったため、PHPで実装してみよう、という流れになりました。
結論から言うと10件ごとにページを分けてページャを表示する以下の画像のようなものができました。
以下から何をどう書いて、何が勉強になったのかということを書き記していこうと思います。
ソース全文
まずこの機能に関わる部分のソースが以下になります。
<?php
require_once 'database.php'; // データベース接続情報
require 'functions.php'; // ここにページャ描画用の関数を入れてある
$total_sum = $pdo->query('SELECT COUNT(*) FROM favorites')->fetchColumn();
$max_per_page = 10;
$total_pages = intval(ceil($total_sum / $max_per_page));
$current = intval($_GET['page_id'] ?? 1);
if ($current < 1) $current = 1;
if ($current > $total_pages) $current = $total_pages;
$offset = ($current - 1) * $max_per_page;
$base_url = 'index.php?page_id=';
$stmt = $pdo->prepare (
'SELECT id, thread_url, title, my_comment, thumbnail_url
FROM my_database
ORDER BY created_at ASC
LIMIT :limit OFFSET :offset'
);
$stmt->bindValue(':limit', $max_per_page, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
$disp_data = $stmt->fetchAll();
?>
<!-- ... htmlソース ... -->
<!-- ... ページャを描画 ... -->
<div class="pagination-container">
<?php pagination_render($current, $total_pages, $base_url) ?>
</div>
<!-- ... htmlソース ... -->
以上はデータベース接続・データの10件ごとの切り出し・データの一覧表示を行うindex.phpになります。そして以下がページャ描画機能の本体が置いてあるfunctions.phpです。
<?php
// $b は ベースurl"index.php?page_id="を想定
function page_button ($p, $c, $b) {
if ($p === $c) {
return "<div class=\"paginations current\"><p>{$p}</p></div>";
}
return "<div class=\"paginations\"><a href=\"{$b}{$p}\">{$p}</a></div>";
}
function prev_button ($c, $b) {
if ($c === 1) return '<button class="previous-button" disabled></button>';
return "<button class=\"previous-button\" onClick=\"location.href='".$b.($c - 1)."'\"></button>";
}
function next_button ($c, $t, $b) {
if ($c === $t) return '<button class="next-button" disabled></button>';
return "<button class=\"next-button\" onClick=\"location.href='".$b.($c + 1)."'\"></button>";
}
function pagination_render ($c, $t, $b, $m = 9) {
$omitted = '<div class="omitted"></div>';
$out = '';
$out .= prev_button($c, $b);
$out .= '<div class="paginations-row">';
if ($t <= $m) {
for ($i = 0; $i < $t; $i++) {
$p = $i + 1;
$out .= page_button($p, $c, $b);
}
$out .= '</div>';
$out .= next_button($c, $t, $b);
echo $out;
return;
}
$l = $c - 2;
$r = $c + 2;
if ($r <= $m - 2) {
for ($i = 0; $i < $m - 2; $i++) {
$p = $i + 1;
$out .= page_button($p, $c, $b);
}
$out .= $omitted;
$out .= page_button($t, $c, $b);
} else if ($l >= 4 && $r <= $t - 3) {
$out .= page_button(1, $c, $b);
$out .= $omitted;
for ($i = $l; $i < $r + 1; $i++) {
$out .= page_button($i, $c, $b);
}
$out .= $omitted;
$out .= page_button($t, $c, $b);
} else if ($l > $t - 7) {
$out .= page_button(1, $c, $b);
$out .= $omitted;
for ($i = $t - 6; $i < $t + 1; $i++) {
$out .= page_button($i, $c, $b);
}
}
$out .= '</div>';
$out .= next_button($c, $t, $b);
echo $out;
}
?>
以上が今回のページ分け・ページャ表示に関わる部分のソース全文になります。次からメモになります。
index.phpの処理
説明
ここでは:
- 10件ごとのデータの取得
- 現在ページ・総ページ数の取得と計算
- 現在ページ・総ページ数・ベースURLを引数として
pagination_render()を呼び出し
という処理を行っています。
「10件ごとのデータの取得」は、はじめ全件取得してからarray.sliceで切り出しなどという(アホすぎる)ことをしていたのですが、流石にこれでは総件数が多くなったときの負荷が高すぎるということできちんとLIMIT / OFFSET句を書いて必要な分だけ取得するようにしました。
その他、$currentが1以下の場合、総ページ数以上になってしまった場合は調整する、など細かい調整を行っています。
functions.phpの処理
説明
記述がそこそこ長ったらしいですが、ここでは:
- ページボタン・「次へ」ボタン・「前へ」ボタン描画の関数化
- それらを取りまとめてページャを描画する関数
pagination_render ($c, $t, $b, $m = 9)の定義
要するにこの2つの処理を行っています。
ページボタン・「次へ」ボタン・「前へ」ボタン描画の処理はあえて書き残すほどのことはしていないので、pagination_render ($c, $t, $b, $m = 9)の処理について残しておきます。
pagination_render ($c, $t, $b, $m = 9)の処理
そもそも何がしたくてこんなに長ったらしくなっているかと言うと、要するに現在ページの位置により以下のようなページャの出し分けをしたかったからです:
総ページ数が指定の数(今回の場合は9ページ。デフォルト引数として$m = 9で与えてある)を超えると現在見ているページにより省略記号"…"が出しわけて描画される、みたいなことがしたかったのでこのような記述となっています。
処理を最初から追っていくと、まず省略記号のための<div></div>要素を後からいつでも使えるように格納します。
echoを無限に書くことを避けるため、出力する要素は$outにまとめて貯めて、関数の最後でecho $outと出力する構造にしています。
そしてprev_button()関数で「前へ」ボタン、ページャ格納用コンテナ<div class="paginations-row"></div>の前半部分を作成します。
if ($t <= $m)の条件分岐は、総ページ数が描画限度(今回の場合は9ページ)以下の場合はそのまま< 123456789 >と省略記号無しでベタ貼りするという条件分岐です。ここに引っかかった場合は描画を終えて一足先にreturnで関数を抜けます。
以降の処理は、まず$l、$rを定義しています。これは:
< 1 ... 56⑦89 ... $t >
みたいなときの左端と右端を定義するための計算です。その後の条件分岐にも使っています。
条件分岐の中身は:
-
$r <= $m - 2の場合
これはつまり< 1234567 ... $t >と描画する場合です
右端が描画限度 - 2以下の場合は< 1234567 ... $t >と描画します
今回の場合は現在ページが6ページ以上の場合に次の状態に遷移します -
$l >= 4 && $r <= $t - 3の場合
これはつまり< 1 ... ($c-2)($c-1)$c($c+1)($c+2) ... $t >と描画する場合です
左端が4以上、右端が総ページ数 - 3以下である間は< 1 ... (c-2)(c-1)c(c+1)(c+2) ... $t >が描画されます
総ページ数20ページとした場合、現在ページが16ページ以上の場合に次の状態に遷移します -
$l > $t - 7の場合
これはつまり< 1 ... ($t-6)($t-5)($t-4)($t-3)($t-2)($t-1)$t >と描画する場合です
左端が総ページ数 - 7より大きければ< 1 ... ($t-6)($t-5)($t-4)($t-3)($t-2)($t-1)$t >が描画されます
だいたいこのような分岐でページボタン(と省略記号)を生成した後、ページャ格納用コンテナ<div class="paginations-row"></div>の閉じタグを生成して、最後にnext_button()関数で「次へ」ボタンを生成したら、echo $outで今までに生成したページャを描画して処理が完了します。
苦労したこと・学んだことなど
データベースからのデータ取得(SQL)
今回LIMIT / OFFSET句を始めて使ったので、これは大きな学びの一つでした。
ページャを付ける前は全件表示だったため、当然全fetchでやっていました。今回も全件取得してからarray_slice()で切り取るということをしそうになったのですが、ノアから「それ総データ数多くなると要らないのに毎回全fetchしてるわけですから無駄な負荷がすごいですよ」というご指摘を受けたので、「そもそもデータベースから必要な分だけ持ってくる」という方向に転換したわけです。
まだまだSQLをバリバリ使えるわけではもちろんないですが、データの扱い方、データの扱い方を実現するためのSQL構文などはこういった実践での学習の積み重ねで使えるようになっていくものだと思っていますので、今回実践の形で書き、使い、学ぶことができたのは非常に重要な経験でした。
返り値についての諸々
今回、処理を書いている途中に基本的に普段JavaScriptばかりいじっていた弊害が出ました(とはいえ別の言語も触ってないわけではないので私がアホなだけである可能性が高い)。
問題の原因について先に記すと、ceil()の返り値がfloat型で返ってくることを知らなかったということになります。
そして問題とは何かというと、最終ページに入っても「次へ」ボタンが生きているという問題でした。
functions.phpのnext_button ($c, $t, $b)関数を見ていただけると話が早いかと思うのですが、「次へ」ボタンを無効にするかどうかは現在ページ$cが最終ページ$tと等しいかどうかで分岐しています。そしてここの比較を厳密等価でやっていたのが問題の原因でした。
実際の利用において、この総ページ数$tというのは:
-
index.php内でceil($total_sum / $max_per_page)とすることで計算して$total_pagesに格納 -
index.php内でpagination_render($c, $t, $b)の$t引数として渡す -
pagination_render($c, $t, $b)関数が関数内でnext_button($c, $t, $b)に$t引数として渡す
という流れで渡されるのですが、はじめは 1. の段階で明示的にint型へ変換していませんでした。そのため、$tがceil()関数の返り値のfloat型のままずっと流れて行き、最終的にnext_button($c, $t, $b)関数ではintとfloatの比較を行うことになるため厳密等価でイコールにならなかった = 「次へ」ボタンを無効にする処理が想定したタイミングで走らなかったというわけです。
JavaScriptではMath.ceil()の返り値はnumber型であるため、このようなケースで厳密等価比較を行っても問題なく等価になります。そのため、ここの原因の解明にはなかなか苦労しました。
JavaScriptを書いているときも別に型を気にしないわけではないのですが、バグが起こったタイミングでしか意識しないという場合が多いので、関数やメソッドを利用する際、特に初めて使うものである場合には「返り値が何型で返ってくるのか」ということを調べて把握してから使うのがよいかと感じました。
今回この問題に直面し、ちょうど良い機会だと思ったので、他のプログラミング言語でceil()と同様の関数・メソッドがどのような記述でどの型の値が返ってくるのか(本当にごく少数ですが)調べてまとめてみました:
| 言語 | 構文 | 返り値 |
|---|---|---|
| C++ | ceil (double a) |
double |
| Java | Math.ceil (double a) |
double |
| PHP | ceil (a) |
float |
| C# |
Math.Ceiling (decimal a) / (double a)
|
decimal / double
|
| JavaScript | Math.ceil(a) |
Number |
C#は本当にミリだけ触ったことがあるのですが、decimal型の存在は知りませんでした。調べてみたところによるとdecimal型はC#標準の10進数型浮動小数点数で、doubleよりは計算が遅いというものらしいです。
なんの目的で使う型なのかは知りませんが、C#を本格的に触る気が起きたときにでも改めて調べ直そうかと思います(カス)。
まとめ
ページャはこのお気に入り登録・リスト表示システムを作って以来、かねてから実装してみたい機能の一つでした。
JavaScriptでページャを書いてみたときは「どうせ実装はPHPになるだろうし、JavaScriptでの経験が活きるかはわからないな」などと考えていましたが、思ったより処理の核心部分は使い回すことができたので、先に書いといてよかった~~~となりました。
SQL構文でデータを引っ張ってくるやりかたと、データの型への意識が強まったこと、この2つが今回の実装で学ぶことができた大きな成果かなと思います。
このシステムには検索機能とかタグ付け機能とか、追加したい機能はまだあるので、追加機能を実装した際にはまたメモを残し、期を見てQiitaに記事として持って来る予定です。
まだまだ駆け出しのペーペーですし、バリバリ前線で戦っているITエンジニア・プログラマの方々におかれましては「低い所でもがいているなあ」と思われるでしょうが、生暖かい目で見てくれれば幸いです。
ここまで読んでくださり、誠にありがとうございました。またお会いしましょう~~~👋



