【Mithril.js】fuelphpとmithril.jsで簡単なSPAアプリを作ってみた

はじめに

fuelphpとmithril.jsで入門がてらにSPAを作ってみたのでそのときのメモ。
mithril.jsのチュートリアルのtodoアプリみたいなものですが、実際に自分で手を動かしてみないとわからないことが多いので簡単なメモアプリを作ったのでそのときのメモ

1)一覧画面
f:id:yoppy0066:20151102002556p:plain

2)編集画面
f:id:yoppy0066:20151102002648p:plain

メモの新規登録、編集、削除ができるありがちな機能です。画面は上記の2つのみです

クライアント側の実装

方針

基本的な構成は、ココに書いた形になります。

.
├── entrypoint.js(エントリーポイント、ルーティングなどはココに)
├── component(ControllerとViewはココに)
│   ├── Edit.js
│   └── List.js
└── model(Modelはココに)
    └── Memo.js

ドキュメントには、ビューモデル作れとかいてありますが、今回は練習なのでとりあえず作らなかったです。(というよりあまりわかってない、、、)
・entrypoint.jsにはurlからコントローラを振り分ける処理の記述
・componentにはmvcのcontrollerとviewの記述(html部分もここに)
・modelにはデータの定義とサーバー側のAPIアクセス処理などの記述

で、クライアント側の基本方針としては初回アクセス時に、APIからデータを取得してローカルに保存。
保存といっても、ローカルDBに保存とかではなくて、次回アクセス時には破棄されるメモリ上です。

で、これ以降に画面に表示するためのデータはこのローカルのデータを取得する形です。
なので、データを更新する際にはAPIにリクエストしてOKが返ってきたらローカルのデータにも反映させてやるという形にしました。

db構成

>memoテーブル
id(PK、オートインクリメント)
title
content

クライアント側実装

entrypoint.js
※urlによるコントローラの振り分けなどルーティング処理

m.route.mode = "pathname";

m.route(
    document.getElementById("root"), "/list/init", {
        "/list/:mode": ComponentList,
        "/edit/:id": ComponentEdit,
    }
);
コンポーネント(ControllerとView)

component/List.js
※一覧への表示、登録ボタンをおされたとき、削除ボタンをおされたとき、編集ボタンをおされたときの処理

ComponentList = {

    controller: function() {

    var self = this;

    // 新規登録項目用バッファ
    this.title = m.prop("");
    this.content = m.prop("");

    // init: 初期画面、return: 別画面から戻ってきた場合
    var mode = m.route.param("mode");

    // APIからデータを取得
    if (mode == "init")
    {
        this.list = ModelMemoManager.getInstance().getByApi();
    }
    // ローカルの保存データからデータを取得
    else if (mode == "return")
    {
        this.list = function() {
            return ModelMemoManager.getInstance().getAll();
        }
    } 

    // 登録ボタン
    this.onRegist = function() {
        var data = {
            "title": self.title(),
            "content": self.content(),
        }
        ModelMemoManager.getInstance().registByApi(
            data,
            function(id) {
                data.id = id
                ModelMemoManager.getInstance().set(data);
            },
            function() {
                alert("エラーが発生しました");
            }
        ); 
    }

    // 削除ボタン
    this.onDelete = function(e) { 
        var id = e.target.getAttribute("data-id");
            ModelMemoManager.getInstance().deleteByApi(
                id,
                function() {
                    ModelMemoManager.getInstance().delete(id); 
                },
                function() {
                    alert("エラーが発生しました");   
                    }
            );
        }
    },
	
    view: function(ctrl) { 
        return [
            m("a", {href: "#", onclick: ctrl.onRegist}, "登録"),
                m("input", {type: "text", placeholder: "タイトル", onchange: m.withAttr("value", ctrl.title)}),
                m("input", {type: "text", placeholder: "内容", onchange: m.withAttr("value", ctrl.content)}),
                m("hr"),
                m("table", [
                    m("tr", [
                        m("th", "id"),
                            m("th", "title"),
                            m("th", "編集"),
                            m("th", "削除"),
                        ]),
                        ctrl.list().map(function(value) {
                            return m("tr", [
                                m("td", value.id()),
                                m("td", value.title()),
                                m("td", m("a", {href: "/edit/" + value.id(), config:m.route}, "編集")),
                                m("td", m("a", {href: "#", onclick: ctrl.onDelete, "data-id": value.id()}, "削除")),
                            ])
                        })
                    ])
              ];
        }
}

component/Edit.js
※指定されたidのデータを取得して画面に表示。登録ボタンを押されたときの処理

ComponentEdit = {

    controller: function() {

        var self = this;

        // 保存データ取得
        this.input = ModelMemoManager.getInstance().get(m.route.param("id"));

        // 登録ボタン
        this.onRegist = function() {
            ModelMemoManager.getInstance().update({
                    id: self.input.id(),
                    title: self.input.title(),
                    content: self.input.content(),
                },
                function() {
                    m.route("/list/return");  
                },
                function() {
                    alert("エラーが発生しました");
                }
            );
        } 
    },
	
    view: function(ctrl) {
        return m("form", [
            m("input", {type: "text", name: "title", value: ctrl.input.title(), onchange: m.withAttr("value", ctrl.input.title)}),
            m("br"),
            m("input", {type: "textarea", name: "content", value: ctrl.input.content(), onchange: m.withAttr("value", ctrl.input.content)}),
            m("br"),
            m("input", {type: "button", value: "登録", onclick: ctrl.onRegist}),
        ]);
    }
}
Model(データの保持とAPIへのリクエスト)

Model/Memo.js

var ModelMemo = function(data) {
    this.id = m.prop(data.id);
    this.title = m.prop(data.title);
    this.content = m.prop(data.content);
};

var ModelMemoManager = (function() {
    
    var instance = null;

    // ローカルデータを永続化するためにシングルトンに  
    return {
        getInstance: function() {
            if (!instance) {
                instance = init();
            }
            return instance;
        }
    }

    function init() {
        return {

            /**
             * APIから取得したデータをローカルに保存するようの配列
             */
            list: [],

            /**
             * ローカル配列にデータを追加
             * @param array
             */
            set: function(data) { 
                list.push(new ModelMemo(data)); 
            },

            /**
             * ローカルデータからID指定でデータを取得
             * @param id int
             * @return object
             */
            get: function(id) {
                for (var i = 0; i < list.length; i++) {
                    if (list[i].id() == id) {
                        return list[i];
                    }
                }
            },

            /**
             * ローカルデータをすべてかえす
             * @return array
             */
            getAll: function() {
                return list;
            },

            /**
             * ローカルデータからID指定で削除
             * @param int
             */
            delete: function(id) {
                for (var i = 0; i < list.length; i++) {
                    if (list[i].id() == id) {
                        list.splice(i, 1);  
                    }
                } 
            },
            
            /**
             * リモートデータから全データを取得
             * @return array
             */
            getByApi: function(callbackError) {
                return m.request({
                    method: "GET",
                    url: "http://example.com/list", 
                    type: ModelMemo,
                    unwrapSuccess: function(response) {
                        return (response.status == "OK") ? response.result : false; 
                    }
                }).then(function(result) {
                    this.list = result;
                    return this.list;
                });
            },

            /**
             * リモートデータからID指定でデータを削除
             */
            deleteByApi: function(id, callbackSuccess, callbackError) {
                return m.request({
                    method: "GET",
                    url: "http://example.com/delete",
                    data: {"id": id},
                    unwrapSuccess: function(response) {
                        return (response.status == "OK") ? response.result : false;
                    }
                }).then(function(result) {
                    return (result == false) ? callbackError() : callbackSuccess();
                });
            },

            /**
             * リモートデータへ新規データを1件登録
             * @param array
             */
            registByApi: function(data, callbackSuccess, callbackError) {
                return m.request({
                    method: "POST",
                    url: "http://example.com/regist",
                    data: data,
                    unwrapSuccess: function(response) {
                        return (response.status == "OK") ? response.result : false;
                    }
                }).then(function(result) {
                    return (result == false) ? callbackError() : callbackSuccess(result.id);
                });
            },

            /**
             * リモートデータをID指定で1件更新
             * @param array
             */
            update: function(data, callbackSuccess, callbackError) {
                return m.request({
                    method: "POST",
                    url: "http://example.com/update",
                    data: data,
                    unwrapSuccess: function(response) {
                        return (response.status == "OK") ? response.result : false;
                    }
                }).then(function(result) {
                    return (result == false) ? callbackError() : callbackSuccess();
                });
            },
        }
    }
})();

ModelMemoでデータ定義して、
ModelMemoManagerでデータを実際に作って、データの乖離がおきないように管理しているような形にしました。
配列グルグルまわしてるところとかが、きれいじゃないけどJavascriptにあまり精通してないためとりあえずこの形にしました。
そのうち修正したいと思います。

サーバー側実装

基本的にOK、NGをかえしてOKの場合はクライアントに返したい値をresultに格納するようにしました。
Controlle_Restを継承すると簡単にjsonを返せるのですごい便利でした。

コントローラー

class Controller_Api extends Controller_Rest
{
    protected $format = 'json';

    public function action_list()
    {
        $result = Model_Memo::getList();

        return $this->response(array(
            "status" => "OK",
            "result" => $result,
        ));
    }

    public function action_regist()
    {
        $id = Model_Memo::insert(array(
            "title" => Input::get("title"),
            "content" => Input::get("content"),
        ));

        $result = array(
            "status" => "OK",
            "result" => array(
                "id" => $id,
            )
        );

        return $this->response($result);
    }
    public function action_update()
    {
        $data = array(
            "title" => Input::get("title"),
            "content" => Input::get("content"),
        );

        Model_Memo::update(Input::get("id"), $data);

        $result = array(
            "status" => "OK",
        );

        return $this->response($result);
    }

    public function action_delete()
    {
        Model_Memo::update(Input::get("id"),array("flg_delete" => 1));

        $result = array(
            "status" => "OK",
        );

        return $this->response($result);
    }
}

モデル

class Model_Memo extends Model_Common
{
    public static function getList()
    {
        $query = "SELECT * FROM memo WHERE flg_delete = 0"; 
        return parent::getAll($query, array());
    }

    public static function insert($data)
    {
        return parent::execute_insert('memo',$data);
    }

    public static function update($id,$data)
    {
        return parent::execute_update('memo',$id,$data);
    }
}
?>                                                                     

実際にSQL実行している部分は自前の共通関数的な感じです。
なんとなく何やってるかはわかると思うので省略します
いちおう、共通関数はここで書いてあります

おわりに

いちおう自分なりのポイントとしては毎回データの取得にAPIを経由するのではなくて、
初回でデータを取得したあとの表示ではローカルのデータを表示する形にしたことでしょうか。

まだまだ勉強中なのでおかしいところがあるかもしれませんが、、、
とりあえず以上です