最小限のSSR付きSPAフレームワークを作ってみた

# 概要

PHPのテンプレートライブラリでいろいろ考えていた時にふとこうすればSSRっぽいことができるんじゃないかと思ったので試しに作ってみた記録。
どうせ需要なんてないのはわかりきってるので適当。

https://github.com/uniras/microssr

# PHPコードとテンプレートの両立

別記事で書きましたが、意外と簡単に1ファイルでPHPコードとテンプレートの両立が出来たので、これもっとPHP側でごにょごにょすればSSRっぽくなるんじゃね？って気づきました。

# Mustacheの採用

テンプレートエンジンはMustacheを採用した、なぜかというと探した限り公式でPHPにもJavaScriptにも対応したテンプレートエンジンはこれくらいだった。
また、PHPも(composerとかで)インストールしなくてもファイルをフォルダごとアップロードするだけで使えるようになるのも決め手になった。

# サーバーサイド部分

こんな感じのコンテンツファイルを作る。

```php
<?php

//ここでデータの取得・生成をする

//ルーティング、テンプレートの適用等の処理
require_once(dirname(__FILE__).'/settings.php');
contents_router::router(__FILE__, $contents_data);
?>
<div id="foo">
ここにテンプレートを書く。
{{data}}
</div>
```

GETのクエリストリングに応じてPHP側でレンダリング、テンプレートだけを渡す、(JSON)データだけを渡す処理をPHPで振り分ける。

```php
//サーバールーター
class contents_router
{
    public static function router($contents_file, $contents_data = null)
    {
        //GET以外またはgetcontents=trueでない場合(getcontents=trueの付いたGETはコンテンツテンプレートの取得)
        if (empty($_GET['__getcontents']) || $_GET['__getcontents'] != 'true') {
            //GETかつgatdata=trueでない場合(GET以外またはgatdata=trueの付いたGETはJSONデータの取得)
            if ($_SERVER['REQUEST_METHOD'] == 'GET' && (empty($_GET['__getdata']) || $_GET['__getdata'] != 'true')) {
                //ベースファイルとコンテンツのレンダリング(SSR)
                contents_render::base_render($contents_file, $contents_data);
            } else {
                //データJSONの出力(SPA用)
                contents_render::data_render($contents_data);
            }
            exit();
        }
        //コンテンツテンプレートの出力(SPA用) => 何もせずそのままrequire元ファイルの下にあるMustacheテンプレート(HTML)を出力
    }
}

```

あらかじめこんな感じのベースとなるHTMLを用意しておき、初回アクセスはPHPによってベース含めすべて組み立てられた(SSR)HTMLを返し、
後は必要に応じてJavaScriptでコンテンツ部分だけ書き換えてページ遷移しなくて済むようにする感じに。

```base.html
<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8">
    <title>MicroSSR</title>
    {{{contents_base_js}}}
</head>

<body>
    {{{contents_data}}}
</body>

</html>
```

PHPによるレンダリングはこんな感じ

```php
//サーバーレンダー
class contents_render
{
    public static function base_render($contents_file, $contents_data = null)
    {
        //Mustacheクラスのロード
        if (MUSTACHEDIR != '') {
            //composerやautoloadの設定をしている場合は不要
            require_once(MUSTACHEDIR.'/Autoloader.php');
            Mustache_Autoloader::register();
        }

        //Mustacheエンジンのロード
        $template_engine = new Mustache_Engine(
            array(
                'loader' => new Mustache_Loader_StringLoader(),
                'partials_loader' => new Mustache_Loader_FilesystemLoader(dirname($contents_file)),
            ),
            array('entity_flags' => ENT_QUOTES)
        );

        //コンテンツ部分のコンパイル($contents_dataがnullの場合はテンプレートではないということでPHP部分を除いたファイルを出力)
        $contents_source = file_get_contents($contents_file);
        $contents_source = preg_replace('/\<\?(.*?)[?]>/s', '', $contents_source); //PHPコード部分を削除
        if ($contents_data != null) {
            //コンパイル
            $contents_render = $template_engine->render($contents_source, $contents_data);
            $contents_data['contents_data'] = '<div id="'.CONTENTID.'">'."\n".$contents_render."\n".'</div>';
        } else {
            //コンパイルしなくていいので取得したソースそのまま適用。
            $contents_data['contents_data'] = '<div id="'.CONTENTID.'">'."\n".$contents_source."\n".'</div>';
        }

        //ベース部分のコンパイル
        if (BASEHTMLFILE != '') {
            //ベースHTMLの取得
            $base_source = file_get_contents(BASEHTMLFILE);
            //JavaScriptの取得と埋め込み。直接埋め込みすればディレクトリの位置気にしなくていいよね。
            $base_js_source = file_get_contents(BASEJSFILE);
            $contents_data['contents_base_js'] =
                '<script type="text/javascript" src="'.BASEJSMUSTACHE.'"></script>'."\n".
                '<script>'."\n".$base_js_source."\n".
                'contents_render.contents_id = '."'".CONTENTID."';\n".
                '</script>';
            //コンパイル
            $contents_render = $template_engine->render($base_source, $contents_data);
        }

        //最終結果の変換(__FILE__変数が使えるように・・・でも要らないっぽい？)
        $contents_render = preg_replace('/__FILE__/', basename($contents_file), $contents_render); //__FILE__をテンプレートPHPのファイル名に

        //出力
        echo $contents_render;
    }


    //JSONデータの出力
    public static function data_render($contents_data = null)
    {
        //単にjson_encodeした結果を返すだけ、特に何もない。
        header('Content-Type: application/json');
        if ($contents_data != null) {
            echo json_encode($contents_data);
        } else {
            //データがない場合は空の連想配列を返しておこう。
            echo '{}';
        }
    }
}
```

このフレームワークの唯一の利点は、他のSPAだと実体がないURLにアクセスしないようWebサーバーの設定でリダイレクトが必要ですが、
このフレームワークのコンテンツには必ず実体となるPHPファイルがあるのでそういった設定が必要ないことくらい。


# クライアント部分

クライアント部分は目新しい部分は何もないんだけど、調べた感じHistry APIでSPAするのがすごく面倒そうなことだけはよくわかった。
あとはまさかMustacheのinclude(partial)を自分で実装しないといけないとは思わなかった。

```javascript
//クライアントルーター
class content_router {
    //初期化
    static init() {
        //イベントハンドラの設定
        window.addEventListener('popstate', () => {
            content_router.onBack(location.href);
        });

        document.querySelectorAll('a').forEach(a => {
            a.onclick = event => {
                event.preventDefault();
                window.history.pushState(null, '', a.href);
                content_router.onClick(a.href);
            };
        });
    }

    //クライアントレンダリングしたページのaタグにイベントを設定
    static setRenderPageEvent(id)
    {
        document.querySelectorAll(`#${id} > a`).forEach(a => {
            a.onclick = event => {
                event.preventDefault();
                window.history.pushState(null, '', a.href);
                content_router.onClick(a.href);
            };
        });
    }

    //ブラウザバックした時の動作
    static onBack(href) {
        contents_render.render(href);
    }

    //アンカーリンクをクリックしたときの動作
    static onClick(href) {
        contents_render.render(href);
    }
}

//クライアントレンダー
class contents_render {
    static contents_source = {};
    static contents_is_template = {};
    static contents_id = 'contents'; //サーバーレンダーにより設定したidに再代入される

    //データの取得
    static async get_request(url, type, param) {
        let data;
        let addchr = '';
        if (param !== '') addchr = url.indexOf('?') === -1 ? '?' : '&'
        const response = await fetch(`${url}${addchr}${param}`);
        if (type === 'json') {
            data = response.json();
        } else {
            data = response.text();
        }
        return data;
    }

    //コンテンツテンプレートの取得
    static async get_contents(contents_url) {
        const data = await contents_render.get_request(contents_url, 'text', '__getcontents=true');
        return data;
    }

    //JSONデータの取得
    static async get_data(contents_url) {
        const data = await contents_render.get_request(contents_url, 'json', '__getdata=true');
        return data;
    }

    //pertialでincludeするための処理
    static get_partial(file) {
        let data = '';
        const getfile = `./${file}.mustache`;

        //mustacheテンプレートの取得(すでに取得済みのテンプレートは取得しない)
        if (typeof (contents_render.contents_source[getfile]) !== 'string') {
            //非同期関数はうまく動かないので同期のXHRを使う        
            let request = new XMLHttpRequest();
            request.open('GET', getfile, false);
            request.send(null);
            if (request.status == 200) {
                data = request.responseText;
            }
            contents_render.contents_source[getfile] = data;
        } else {
            data = contents_render.contents_source[getfile];
        }

        return data;
    }

    //コンテンツHTMLの描画
    static async render(contents_url) {
        let template;
        let data = null;
        let output = '';
        let get_url;

        //テンプレート取得配列用にクエリストリングを除いたURLを取得
        const query = contents_url.indexOf('?');
        if(query !== -1) {
            get_url = contents_url.substring(0, query);
        } else {
            get_url = contents_url;
        }

        //コンテンツテンプレートの取得(すでに取得済みのテンプレートは取得しない)
        if (typeof (contents_render.contents_source[get_url]) !== 'string') {
            contents_render.contents_source[get_url] = await contents_render.get_contents(contents_url);
            //'{{'がどこかに存在すればテンプレートとみなす。
            template = contents_render.contents_source[get_url].indexOf('{{') === -1 ? false : true;
            contents_render.contents_is_template[get_url] = template;
        } else {
            template = contents_render.contents_is_template[get_url];            
        }

        //テンプレートであればデータの取得
        if (template) data = await contents_render.get_data(contents_url);

        //出力
        const element = document.getElementById(contents_render.contents_id);
        if (element !== null) {
            if (template) {
                output = Mustache.render(contents_render.contents_source[get_url], data, contents_render.get_partial);
            } else {
                output = contents_render.contents_source[get_url];
            }
            element.innerHTML = output;
            content_router.setRenderPageEvent(contents_render.contents_id);
        }
    }
}

document.addEventListener('DOMContentLoaded', () => {
    content_router.init();
});
```

SPAルーター部分はとりあえずここのサイトを~~丸パ~~盛大に参考にして実装した。

https://zenn.dev/gagaga/articles/vanilla-spa-yattarude?utm_source=pocket_mylist#%E3%83%AB%E3%83%BC%E3%82%BF%E3%83%BC%E3%81%AE%E5%87%A6%E7%90%86%E3%82%92%E5%AE%9F%E8%A3%85

# 完走した感想

素直にNext.jsなりNuxt.jsなり使おう。と思いました。