PHP
HTML
CSS
JavaScript
SPA

SPA(Single Page Application)の学習、そこに高度なスキルなどいらない

前書き

 普及しないSPA

 これだけ時代が進んだのに、Webでは未だにガチャガチャページが切り替わるシステムが多い。SPAの普及を阻止しようとしている勢力がいるとしか思えない状況だ。「そんな悪の組織みたいな勢力がいるんだね、へぇしょっかぁ」とかギリギリのラインでネタをぶち込んでも仕方が無い。

 今回はWebシステムの基本といえる掲示板を作成し、SPAの動作について確認していきたい

 Webアプリケーションとは九割九分、掲示板の変形である

 Webアプリケーションの基本は掲示板だ。DBからデータを抽出し出力、データを受け取りDBに保存、保存後の状態を再出力。たったこれだけのことだが、あらゆるシステムで利用される基本なのである。世の中のシステムは掲示板をベースにデータの種類を増やしたり、カテゴリ分けがあったり、ほとんどがちょっとした派生でしかないのだ

 掲示板を作ることができれば、大抵のシステムはなんとかなる。これからWeb開発系のスキルを身につけたいと思ったら、どんな言語でも良いのでまずは掲示板を作るべきだ。ただしフレームワークは用いてはいけない。なぜならスキルが身につくよりも先に、なんだかよく分からず完成してしまうからだ

二種類の掲示板

 今回はPHPを用いて見た目がそっくりの掲示板を作成する。見た目はそっくりだが片方はSPAだ。有名どころのフレームワークやライブラリの類を使うとサンプルとして用をなさないので、今回は一切使わない。いや、いつも使っていないので、今回も使わないが正しい

 ソースコードは最小限になるように、余計なものを削ぎ落としてある

 通常の掲示板

 データ送信時にすべて書き直していることがわかる。ページ遷移は開発者側の都合でしかなく、ユーザ側に何のメリットももたらさない
ae4x9-gsplh.gif

 SPAの掲示板

 送信した部分だけ挿入されていることがわかる。サーバ側もクライアント側も、負荷は最小限ですむのだ
8mbxr-67ddz.gif

共通で使うスタイルシート

 あえてセレクタにIDとCLASSは使用していない
 メッセージ挿入時にアニメーションを入れてあるが、これだけでSPAの動作がわかりやすくなる優れものだ

BBSTest.css
HTML{
    height: 100%;
}
BODY{
    display: flex;
    flex-direction: column;
    overflow: hidden;
    height: 100%;
}
INPUT{
    box-sizing: border-box;
}
FORM{
    text-align: center;
    margin: 2em;
}
FORM > DIV{
    border-bottom: solid 1px;
}
FORM > DIV> DIV{
    display: inline-block;
    width: 10em;
}
FORM > DIV> INPUT[type=text]{
    width: 20em;
}
FORM > INPUT[type=submit]{
    width: 32em;
}

@keyframes MsgShow {
    0%   {opacity: 0.1;transform: scaleY(0.0)}
    100% {opacity: 1;transform: scaleY(1.0)}
}
DIV[data-type=msg]{
    flex: 1;
    overflow: auto;
}
DIV[data-type=msg] > DIV{
    display:flex;
    margin: 0.1em;
    padding: 0.2em;
    border-bottom: dashed 1px;
    animation: MsgShow 0.5s ease 0s 1 normal;
}
DIV[data-type=msg] > DIV> DIV:nth-child(1){
    width: 10em;
}
DIV[data-type=msg] > DIV> DIV:nth-child(2){
    flex: 1;
}

共通で使うDB関数

 サンプル用に削りまくった

Database.php
<?php
//データベース接続処理
function openDB($path){
    $pdo = null;
    try{
        $pdo = new PDO("sqlite:$path");
        if($pdo){
            $pdo->setAttribute(PDO::ERRMODE_WARNING, PDO::ERRMODE_EXCEPTION);
            $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        }
    }catch(Exception $e){}
    return $pdo;
}
//データベース操作処理
function execDB($pdo,$sql,...$params){
    try{
        if($pdo === null)
            return false;
        if($params !== null && count($params)){
            $stmt = $pdo->prepare($sql);
            foreach($params as $index => $param){
            if(gettype($param) == "resource")
                $stmt->bindValue($index+1,$param,PDO::PARAM_LOB);
            else
                $stmt->bindValue($index+1,$param);
            }
            $stmt->execute();
            return $stmt->rowCount();
        }
        return $pdo->exec($sql);

    }catch(PDOException $exception){}
    return null;
}
//クエリー結果取得処理
function queryDB($pdo,$sql,...$params){
    try{
        if($pdo === null)
            return false;
        $stmt = $pdo->prepare($sql);
        if($params !== null){
            foreach($params as $index => $param){
                $type = gettype($param) ;
                if($type == "resource")
                    $stmt->bindValue($index+1,$param,PDO::PARAM_LOB);
                else
                    $stmt->bindValue($index+1,$param);
            }
        }
        $stmt->execute();
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }catch(PDOException $exception){}
    return null;
}

ページ遷移する掲示板、書き込んだらページをリロードだ!

 サーバ側のソース

BBSTest01.php
<?php
    require "Database.php";
?>
<!DOCTYPE html>
<HTML lang="ja">
    <HEAD>
        <META charset="UTF-8" />
        <TITLE>PHPテストプログラム</TITLE>
        <link rel='stylesheet' href='BBSTest.css' />
    </HEAD>
    <BODY>
<?php
    //DBを開く
    $pdo = openDB("data/bbs.db");
    if(!$pdo){
        echo "DBのオープンに失敗";
        return 0;
    }
    //テーブルが存在しなかったら作成
    execDB($pdo,"create table IF NOT EXISTS bbs(id integer primary key,name text,msg text,bbs_date timestamp)");
    //名前の保存
    $name = isset($_POST['name'])?$_POST['name']:"";
    //メッセージの挿入
    if(isset($_POST['msg']) && strlen($_POST['msg'])){
        execDB($pdo,"insert into bbs values(null,?,?,current_timestamp)",$name,$_POST['msg']);
    }
    //フォームの出力
?>
    <FORM method="post">
    <div><div>名前</div><input type="text" name="name" value="<?php echo htmlentities($name);?>"></div>
    <div><div>メッセージ</div><input type="text" name="msg"></div>
    <input type="submit">
    </FORM>
<?php
    //メッセージの抽出
    $result = queryDB($pdo,"select id,name,msg,strftime('%Y-%m-%dT%H:%M:%SZ',bbs_date) as bbs_date from bbs order by id desc limit 100");
    //タイムゾーン決め打ち
    date_default_timezone_set('Asia/Tokyo');
    //結果を出力
    echo "<div data-type='msg'>";
    if($result){
        foreach($result as $value){
            printf("<div data-id='%d'><div>%s</div><div>%s</div><div>%s</div></div>",
                $value['id'],htmlentities($value['name']),htmlentities($value['msg']),date('r', strtotime($value["bbs_date"])));
        }
    }
    echo "</div>";
?>
    </BODY>
</HTML>

 何の変哲も無い、ページ遷移するただの掲示板である

 DBにはSQLiteを使用しており、dataフォルダに対して書き込み権限が必要となる。そしてテーブルは無ければ勝手に作る

 何の変哲も無いことは確かだが、Webシステムを作る上で必要なDBのテーブル作成、挿入、抽出を網羅している。また、DBから抽出した文字列をHTMLとして出力する際に、特殊文字を変換するなど最低限やるべきこと入っているのだ

 日付の扱いも重要だ。SQLiteは日時を内部的にUTCで記憶する。一般的な解説だと抽出時にlocaltimeに変換してしまうが、それはおすすめできない
 ISO8601のタイムゾーン付きのフォーマットで取り出しておいて、扱いは後で決定した方が良い。なぜISO8601なのかというと、PHPもJavaScriptもこの形式をパースできるからである。タイムゾーンがデータに含まれていないとPHPでdate_default_timezone_setを使用しても、strtotimeで正確な時間が計算されないのだ。出力結果もタイムゾーンごと出しているが、これをやらないと日本以外の人は本当の書き込み時間が何時なのか分からない

 SPA以前に、DBの入出力やHTMLのエンコード、日付の扱いなどはきちんと知識として頭に入れておく必要がある

SPAのページ遷移しない掲示板

 サーバ側ソース

BBSTest02.php
<?php
require "Database.php";
//パラメータの取り出し
$params = json_decode(file_get_contents('php://input'),true);

//DBを開く
$pdo = openDB("data/bbs.db");
if(!$pdo){
    echo "DBのオープンに失敗";
    return 0;
}
//テーブルが存在しなかったら作成
execDB($pdo,"create table IF NOT EXISTS bbs(id integer primary key,name text,msg text,bbs_date timestamp)");
//パラメータが送られていなければ初期HTMLデータを出力
if(!isset($params["functions"])){
    outputHTML();
    return 0;
}
//要求された命令を実行
$results = [];
foreach($params["functions"] as $key => $function){
    if(!isset($function["cmd"]))
        continue;
    switch($function["cmd"]){
        case 'setData': //書き込み処理
            $results[$key] = 0;
            if(isset($function["name"],$function["msg"]) && strlen($function["msg"])){
                $count = execDB($pdo,"insert into bbs values(null,?,?,current_timestamp)",$function["name"],$function["msg"]);
                if($count === 1)
                    $results[$key] = 1;
            }
            break;
        case 'getData': //読み込み処理
            $id = isset($function["id"])?(int)$function["id"]:0;
            $results[$key] = queryDB($pdo,"select id,name,msg,strftime('%Y-%m-%dT%H:%M:%SZ',bbs_date) as bbs_date from bbs where id>? order by id limit 100",$id);
            break;
    }
}
//JSONデータの出力
header("Content-type: application/json");
echo json_encode($results, JSON_UNESCAPED_UNICODE);
return;

//初期HTMLデータの出力
function outputHtml(){
?>
<!DOCTYPE html>
<HTML lang="ja">
    <HEAD>
        <META charset="UTF-8" />
        <TITLE>PHPテストプログラム</TITLE>
        <link rel='stylesheet' href='BBSTest.css' />
        <script type='text/javascript' src='BBSTest.js'></script>
    </HEAD>
    <BODY></BODY>
</HTML>
<?php
}

 パラメータが送られてこなければ初期HTMLを返し、パラメータが来た場合は、それに対する処理を行う。初期ページのbodyの中身は空だがこの理由は後で説明する

 データの受け取りfile_get_contents('php://input')を使い、生のPOSTデータをそのままJSONのデコーダに放り込んでいる。ファイルのアップロードなど、これでは対応できない処理もあるので、必要とあらば処理を分けなければならない

 書き込み内容を返す際、日付はISO8601の形でそのまま返している。これをJavaScriptに喰わせれば、各地のクライアント側のローカルゾーンに変換することが簡単にできるのだ

 入力も出力もJSON形式でやりとりする。これが一番楽だからだ。サーバサイドの開発言語を選ぶ際はJSONと相性の悪いものを選ぶと、無駄な苦労をすることになる

 クライアント側ソース

BBSTest.js
//AjaxでサーバとJSONデータをやりとりする
function sendJson(url, data, proc) {
    var xmlHttp = new XMLHttpRequest();
    xmlHttp.onreadystatechange = function () {
        if (xmlHttp.readyState == 4) {
            try {
                //データを受信したら命令をコールバック
                proc(JSON.parse(xmlHttp.response));
            } catch (e) {
                proc(null); //失敗したらnullを渡す
            }
        }
    }
    xmlHttp.open('POST', url, true);
    xmlHttp.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
    try {
        //JSONに変換してデータを送信する
        xmlHttp.send(JSON.stringify(data));
    } catch (e) {
        proc(null);
    }
}
//メッセージ表示処理
function drawMessage(msgArea,values){
    if (!values)
        return;
    for (var i in values) {
        var value = values[i];
        //一行用のdivタグを生成
        var line = document.createElement("div");
        line.dataset.id = value['id'];
        lastId = Math.max(lastId, value['id']);
        //各項目を生成する
        ["name", "msg", "bbs_date"].forEach(function (name) {
            var v = document.createElement("div");
            if (name === "bbs_date")
                v.textContent = (new Date(value[name])).toLocaleString();
            else
                v.textContent = value[name];
            line.appendChild(v);
        });
        //メッセージの一番上に出力
        msgArea.insertBefore(line, msgArea.childNodes[0]);
    }
}
var lastId = 0; //受信した最終メッセージのID
//メッセージの受信要求
function loadMessage(msgArea){
    var functions = { functions: [{ "cmd": "getData", "id": lastId}]};
    sendJson("?", functions,function(values){
        if (!values)
            return;
        drawMessage(msgArea, values[0]);
    });
}
//メッセージの送信要求と受信(書き込んだメッセージをその場で受信)
function writeMessage(msgArea,name,msg){
    var functions = { functions: [
        { "cmd": "setData", "name": name,"msg":msg },
        { "cmd": "getData", "id": lastId }] };
    sendJson("?", functions, function (values) {
        if (!values)
            return;
        drawMessage(msgArea, values[1]);
    });
}
//コンテンツロードとともに最初に実行される
function Main(){
    //フォームの生成
    document.body.innerHTML =
        '<FORM>\
        <div><div>名前</div><input type="text" name="name"></div>\
        <div><div>メッセージ</div><input type="text" name="msg"></div>\
        <input type="button" value="送信">\
        </FORM>';
    //送信ボタンを押した場合のイベント処理
    var input = document.querySelectorAll("input");
    input[2].addEventListener("click",function(){
        writeMessage(msgArea,input[0].value,input[1].value);
        input[1].value = "";
    });
    //メッセージ表示エリアの生成
    var msgArea = document.createElement("div");
    msgArea.dataset.type="msg";
    document.body.appendChild(msgArea);
    //初期メッセージの読み出し
    loadMessage(msgArea);
}
//コンテンツのロード完了とともにMainを実行
document.addEventListener("DOMContentLoaded", Main);

 JavaScriptのソースをどこに書くか迷っている人がいるが、基本はhead内に書くか、そこから別ファイルとして読み出すかである。bodyの中に書くことはお勧めできない。headの位置に書いたとしても、DOMContentLoadedを使えばコンテンツが出そろったところでスクリプトが実行可能だからだ。bodyの中途半端な場所で書くと、タイミングの制御が難しくなる

 また、SPAを前提にするなら、HTMLファイルを普通に記述しようなどという甘い考えは捨てた方が良い。システムで画面全体を切り替えるような処理が必要なときに、お荷物にしかならない。そのため、bodyタグの中には何も書かないという習慣を付けておくべきだろう。SPAにおいては、必要な内容は全てJavaScriptで書くべきなのだ

 今回はあえてMainファンクションの中に二種類のノード生成方法を入れてある。HTMLタグを使う方法と、ひたすらcreateElementする方法だ。createElementの方が動作は速いが、頑張ったところでどうせ実効的には誤差程度の差しか生まれないので好きな方を使えば良い

まとめ

 SPAについて検索をかけるとデメリットとして、初期表示に時間がかかるという説明がされている。はっきり言えばそれは間違いだ。逆にそれが正しい結果になってしまっているなら、単純にプログラムの組み方が悪いか、使用しているフレームワークが腐っているだけである。HTML文書にDIVタグを1万回記述するよりも、JavaScriptで動的にDIVタグを1万回生成した方が速いのだ

 実際にいくつもSPAでシステムを作っているが、初期表示が遅くなったことはない。この技術情報掲載システムもSPAで作ったけれど、おそらく他の似たようなサイトよりも表示は速いはずだ

 その他のデメリットとして開発者が少ないというのが挙げられている。これは事実だがその後の解説が間違っていることが多い。SPAの実現には高度な技術がいるという部分である

 いらない、高度な技術などいらない

 「データを受け渡し、結果を表示する」だけの作業のどこに高度な技術が入り込む余地があるのだろうか?難しいという変な先入観を植え付けられてはSPAの普及を阻む結果となってしまう。必要なのは慣れだ。慣れてしまえばいくらでも書ける。そこに高度なスキルはいらない

 さあ、今日からみんなで梅干し食べてSPAマンだ!