はじめに
最近、『スケッチブック』というページをサイトに追加しました。Pixiv等に上げるほどでもないラフなスケッチ(というよりも落書きか??)を保存するために作ったのものです。
個々のスケッチは描いた月ごとに分類され、グリッドレイアウトで一覧表示されます。また、矢印ボタンをクリックすることで、グリッドレイアウト内が更新され、他の年に描かれたスケッチの一覧表示に切り替えることができます。


このように、ボタンをクリックすることでページの一部分のみを画面遷移なしで更新する、という機能を実現するために、Fetch APIによる非同期通信を初めて利用しました。そこで、今回はその詳細について解説していきます。
ソースコードは関連するファイル(php, js, cssファイル)だけを集めてGitHubに公開しています。VSCodeのPHP Serverなどを使えば簡単に動作を確認できると思います。↓
mitaka1962/sketchbook (GitHub)
非同期通信
ブラウザ上でサイトにアクセスすると、サーバにリクエストが送られ、そのレスポンスとしてHTMLファイル(+画像データやJSファイルなど)が返ってきます。そして、それらをもとにブラウザがページが描画するというのが従来の通信方法(同期通信)です。
ただし、この方法の場合、ページの画面全体が更新されてしまうため、今回のようにページ内の一部分のみを変更したい場合には、変更の度にページがリロードされる&不必要な再描画が発生するといったデメリットがあります。
何とか部分的な更新を実現したいと思い、Google先生(とチューターのChatGPTくん)に教えを乞うたところ、「非同期通信」という方法を取ればいいことがわかりました。
非同期通信はサーバにリクエストを送った後も、レスポンスを待たずにブラウザ側の処理が継続できる通信方法です。これにより、レスポンスが返ってくるまでの間もブラウザを操作できる他、ページの一部分だけを更新できるといったメリットがあります。
(コチラのサイトなどでわかりやすく解説されています→【JavaScript】初めて学ぶ!fetch()メソッドと非同期通信)
この非同期通信を使えば、ボタンのクリック時にサーバにリクエストを送り、ページの更新のために必要な差分データ(今回はスケッチ画像に関するデータ)だけを取り寄せて、それをもとにページを書き換えることができそうです。(ちなみに、このようなデータはJSON形式で渡すことが多いようです)
Fetch API
調べたところ、Javascriptで非同期通信を行う方法には主に以下の2つがありました。
- XMLHttpRequest(XMLHttpRequest - Web API | MDN)
- Fetch API(フェッチ API - Web API | MDN)
JSによる非同期通信が登場した頃は、XMLHttpRequestを利用したAjax(Asynchronous JavaScript And XML)という手法を使うのが主流だったようです(Google Mapなど)。
ただ、現在はFetch APIのほうが新しい技術であり、公式ドキュメント上でも『XMLHttpRequestをより強力かつ柔軟に置き換えたものです。』との記載があるので、今回の実装ではFetch APIを使うことにしました。
処理の流れ
全体の処理の流れを、クライアント側(JS)とサーバ側(PHP)、そしてデータベースの間のやり取りに注目して、シーケンス図っぽくまとめてみました。(それっぽくまとめただけなので厳密にはシーケンス図とは違うかもしれません…)

上図は最初にスケッチブックのページ(index.php)にアクセスした際の処理の流れです。
index.phpはページ全体のHTMLを生成し、クライアントに返します。このとき、後にURLパラメータの指定に必要となるため、スケッチの画像データが存在する年の配列をデータベースから取得して、html内に埋め込んでいます(①, ②)。
reload_sketches()関数でまとめた部分が、非同期通信とその結果によるページの部分更新を行っています。最初にページにアクセスした際は、最新の年の画像を一覧表示したいので、fetch()の際のURLパラメータに最新年(この場合は2023)を設定して、sketchbook_server.phpにリクエストを送っています(③)。
sketchbook_server.phpはパラメータに指定された年の画像データをデータベースから取得し、そのデータをJSON形式に整形してクライアントに返します。そしてデータが返ってくると、クライアントはそれをもとに画面を更新します。(⑤, ⑥)
さらに、画面上の矢印ボタンにイベントリスナを追加し、クリックの際にreload_sketches()関数が呼び出されるようにすれば(かつ、fetch()の際のURLパラメータを適切に設定すれば)、ボタンのクリックでスケッチ画像の一覧表示を更新させることができます。(下図)

ページの生成(PHPからJSにデータを渡す)
ここからはソースコード本体と共に、実装の詳細を説明していきます。
まずは、index.phpのソースコードの詳細です。
最初のPHP部分が先ほどのシーケンス図の①に当たる、年の配列を取得する部分です。その後、表示するページのHTMLを通常通り記述しています。
<?php
// スケッチデータが存在する年の配列を取得し、json形式に整形 (例)[2017, 2020, 2023]
$json_text = "[";
$err_text = "";
$db_path = 'sketches.db';
try {
// PDOを用いてデータベースへ接続
$mydb = new PDO('sqlite:'.$db_path);
// エラー発生時に例外をスローする
$mydb->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$sql_1 = "SELECT DISTINCT year FROM sketches ORDER BY year ASC";
// SQLクエリを実行し、結果を配列で受け取る
$rows = $mydb->query($sql_1)->fetchAll();
// 末尾のカンマはつけない
for ($i = 0; $i < count($rows); $i++) {
if ($i != 0) $json_text .= ",";
$json_text .= $rows[$i]['year'];
}
} catch (PDOException $e) {
$err_text = 'Connection failed: '.$e->getMessage();
}
$json_text .= "]";
?>
<!-- 以下、ページのHTML -->
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>スケッチブック</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=2, minimum-scale=1, user-scalable=yes">
<link rel="stylesheet" href="sketchbook_style.css" media="all">
<!-- luminous lightbox css -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/luminous-lightbox@2.4.0/dist/luminous-basic.min.css">
</head>
<body>
<div class="main">
<h1 class="main_heading">スケッチブック</h1>
<?php
// データベースと接続失敗の際、エラー出力
if ($err_text) echo $err_text;
?>
<div class="sketchbook_main">
<div class="sketchbook_year_container">
<button id="sketchbook_previous_year_btn"><</button>
<h2 id="sketchbook_year"></h2>
<button id="sketchbook_next_year_btn">></button>
</div>
<div id="sketchbook_grid"></div>
</div>
</div>
<?php
// <script>でjsonを埋め込み
echo "<script id=\"sketchbook_years_data\" type=\"application/json\">{$json_text}</script>";
?>
<!-- luminous lightbox js -->
<script src="https://cdn.jsdelivr.net/npm/luminous-lightbox@2.4.0/dist/luminous.min.js"></script>
<!-- sketchbook_fetch.jsを実行 -->
<script src="sketchbook_fetch.js"></script>
</body>
</html>
データベースから取得した年の配列データは、PHP側からJS側に渡さなければなりません。幸い、PHPはHTMLを自由に生成できるので、scriptタグを生成してその中にデータを記述すれば、JS側にデータを渡すことができます。記述の仕方は色々あると思いますが、今回はMDNのドキュメントを参考に、scriptタグ内にデータをJSON形式で埋め込むというやり方を選択しました。idも設定しているため、JSプログラム内でgetElementById()を使えばデータを取得可能です(次の章のコード参照)。
注意点としては、JSON形式の場合、末尾のカンマを入れないようにすることです。(JSオブジェクトの場合は許容されているのですが…)
また、画像の表示に使うための外部ライブラリであるLuminous(GitHub)も読み込んでいます。
ちなみに、sketchbook_fetch.jsはページ内の要素を取得して処理を行うため、全てのHTML要素が読み込まれた後に実行されるように、bodyタグの終わり直前に配置しなければなりません。(恥ずかしながら、今回の実装までscriptの記述位置についてちゃんと理解できていませんでした…)
Fetch APIでリクエスト送信
index.phpが生成したHTMLがブラウザに返されると、前章の説明通り、HTML要素が読み込まれた後にsketchbook_fetch.jsが実行されます。
以下がそのコードです。(長くなるのでグリッドレイアウト内の画面更新の部分は省略しています。たぶん次回の記事でやります)
ちなみに、変数のスコープを設定するために即時実行関数式という仕組みを使ってみました。((function () { ... })();でプログラム全体を囲っています)
(function () {
// フェッチを中断するためのコントローラ
let controller;
// <script>タグに埋め込まれたJSONデータテキストを配列として取得 (例)[2017, 2020, 2023]
const years_arr = JSON.parse(document.getElementById("sketchbook_years_data").text);
// 年を表示する<h2>要素
const current_year_elm = document.getElementById("sketchbook_year");
// 画像一覧表示用のグリッドレイアウト
const sketchbook_grid_elm = document.getElementById("sketchbook_grid");
// 前年ボタン
const previous_year_btn = document.getElementById("sketchbook_previous_year_btn");
// 翌年ボタン
const next_year_btn = document.getElementById("sketchbook_next_year_btn");
// 画像データがない場合、両方のボタンを無効化
if (years_arr.length == 0) {
previous_year_btn.disabled = true;
next_year_btn.disabled = true;
return;
}
// 現在の年の配列内でのインデックス(初期値は最後の要素=最新年)
let current_year_idx = years_arr.length - 1;
reload_sketches();
// 前年ボタンにイベントリスナを追加
previous_year_btn.addEventListener("click", () => {
current_year_idx--;
reload_sketches();
});
// 翌年ボタンイベントリスナを追加
next_year_btn.addEventListener("click", () => {
current_year_idx++;
reload_sketches();
});
async function reload_sketches() {
// 既に実行中のフェッチを中止 & コントローラを新たに用意
controller?.abort();
controller = new AbortController();
const signal = controller.signal;
// ボタンとラベルの更新
const current_year = years_arr[current_year_idx];
current_year_elm.textContent = current_year;
previous_year_btn.disabled = (current_year_idx == 0) ? true : false;
next_year_btn.disabled = (current_year_idx == years_arr.length - 1) ? true : false;
// グリッドレイアウト内をクリア
while (sketchbook_grid_elm.firstChild) {
sketchbook_grid_elm.removeChild(sketchbook_grid_elm.firstChild);
}
try {
// サーバから画像データを取得するためにGETリクエスト
const response = await fetch(`sketchbook_server.php?year=${current_year}`, {signal});
if (!response.ok) {
throw new Error("response is not ok");
}
const image_data_arr = await response.json();
// グリッドレイアウト内の更新
update_grid_layout(image_data_arr);
} catch (error) {
if (error.name == "AbortError") {
/* pass */
} else {
console.error(error);
}
}
}
function update_grid_layout(image_data_arr) {
/* 中略 */
}
})();
まず、必要なDOM要素をgetElementById()で取得しています。
このとき、index.phpで埋め込んだ年の配列データもyears_arrとして取得しています。そして、配列のインデックスを表す変数current_year_idxも用意しています。これを変更することで、表示する年の変更を行うことができます(ボタンに追加したイベントリスナ内の処理を参照)。
シーケンス図のところで説明したように、最初にこのコードが呼び出されたとき(初めてページが読み込まれたとき)は、current_year_idxを配列内の最新の年が表示されるように設定して、reload_sketches()を呼び出しています。
fetchの中断
reload_sketches()関数内では、DOM要素の書き換えとfetch()によるリクエストの送信を行っています。
最初のAbortController周りの処理は、reload_sketches()が連続で呼ばれたときに、既に実行中のfetch()を中断するためのものです。これがないと、前年ボタンを連続クリックしたときなどに、それまでリクエストしたすべての画像が一斉に表示されてしまいます。
AbortControllerを用意して、そのsignalをfetch()の第2引数として渡すことで、リクエストを適切に中断(abort())することができます。(最初の呼び出しのときはcontrollerがundefinedなので、エラーにならないようにオプショナルチェーン(?.)を使用しています)
非同期処理
fetch()でリクエストを送っているのが以下の部分になります。URLパラメータには年を指定するため、テンプレート文字列を使用しています。
const response = await fetch(`sketchbook_server.php?year=${current_year}`, {signal});
if (!response.ok) {
throw new Error("response is not ok");
}
const image_data_arr = await response.json();
基本的には公式ドキュメント通りの書き方で、awaitを利用した非同期処理を行っています。また、関数内でawaitを使うため、reload_sketches()はasync関数にしなければなりません。
awaitの付いた式が渡されると、以降の処理は現在の処理(この場合、fetch()によるリクエスト)が完了するまで一時停止となります。ただし、これはasync関数内だけの話で、プログラム全体が止まることはありません(非同期処理)。これにより、時間がかかる処理(あるいは、かかるかもしれない処理)の間もユーザはブラウザを自由に操作できます。そして処理が完了すると(この場合、サーバからレスポンスが返ってくると)その後の処理の実行が再開される、という流れです。
(ちなみにawaitが付いた式はPromiseというオブジェクトを返します。PromiseはJSの非同期処理を理解するうえで重要な概念なのですが、まだ理解が曖昧なので説明は割愛します…。公式ドキュメントを要チェックです)
いずれにせよ、awaitを利用することで、非同期通信によるデータの取り寄せが可能となります。サーバから帰ってきたJSONデータはimage_data_arrにJSオブジェクトとして格納され(ここの変換処理もawaitによる非同期処理です)、画面更新のための関数(次回説明)に渡されます。
PHPでJSON生成
sketchbook_server.phpは、URLパラメータで年を指定すると、その年のスケッチ画像に関するデータをまとめたJSONを生成してクライアント側に返すPHPプログラムです。(PHPはHTMLページを返さなければならないと思い込んでいたので、JSONをechoで出力してそのまま返すというのは意外でした)
(2024/10/19追記 : URLパラメータが不正な値の場合の処理を追加しました。)
<?php
// レスポンスヘッダの設定
header("Content-Type: application/json; charset=utf-8");
// URLパラメータの取得
if (isset($_GET['year'])) { $year = $_GET['year']; } else { $year = ""; }
// 不正な値の場合エラーを返す (※)
if (!is_numeric($year)) {
http_response_code(500);
echo '{ "error": "Invalid value" }';
return;
}
$db_path = 'sketches.db';
try {
$mydb = new PDO('sqlite:'.$db_path);
$mydb->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$sql_1 = "SELECT month, image_src, desc FROM sketches WHERE year = {$year} ORDER BY month DESC, day DESC, id DESC";
$rows = $mydb->query($sql_1)->fetchAll(PDO::FETCH_ASSOC);
// json(形式の文字列)で返す
echo json_encode($rows);
} catch (PDOException $e) {
http_response_code(500);
$error_msg = array("error" => 'Connection failed: ' . $e->getMessage());
echo json_encode($error_msg);
}
JSONを返すので、一応レスポンスヘッダの中身をheader()関数で変更しています。(ちなみにこの関数はすべての出力(echoやHTMLタグ)の前に書かないとエラーになるそうです)
URLパラメータはPHPの$_GET[]配列に含まれているため、そこから取り出しています。その値を使ってデータベースにアクセスし、指定された年のスケッチ画像のmonth, image_src, desc(月、画像リンク、画像の説明)といったデータを取り出しています(具体的な中身は以下のようになります)。
[
{
"month": "10",
"image_src": "/images/image-from-rawpixel-id-3283786-jpeg.jpg",
"desc": "落ち葉"
},
{
"month": "3",
"image_src": "/images/image-from-rawpixel-id-3285646-jpeg.jpg",
"desc": "海"
},
{
"month": "3",
"image_src": "/images/image-from-rawpixel-id-3305755-jpeg.jpg",
"desc": "ゾウの親子"
}
]
最後にこれをjson_encode()でJSON形式の文字列に変換し、出力します。
おわりに
次回、グリッドレイアウトや画面の更新処理の解説をしていこうと思います。
思ったより記事が長くなって疲れました…。
次回の記事↓
