初心者のためのExtJS入門

ExtJSを使うので、ついでにまとめていきます

チュートリアル:ルーティングを使って、SPA(シングルページアプリケーション)にする(2)

今回はSPAの続きです。

ルーティングで画面を切り替える部分は実装しましたが、残りの部分を修正していきます。

新規作成、編集のURLパターンを分ける

登録画面のパターンとしては、新規作成と編集の2つがあります。

そこで、下記のようにしてみます。

#regist => 新規登録
#regist/(メモID) => 編集

メモモデルにIDを追加して(メモID)、編集はそのIDを使って行います。

/**
 * メモモデルクラス。
 *
 * @class Memo.model.Memo
 * @extend Ext.data.Model
 */
Ext.define('Memo.model.Memo', {
    extend: 'Ext.data.Model',

    fields: [
        {
            name: 'id',
            type: 'int'
        },
        {
            name: 'title',
            type: 'string'
        },
        {
            name: 'body',
            type: 'string'
        }
    ]
});

/**
 * メモストアクラス。
 *
 * @class Memo.store.Memo
 * @extend Ext.data.Store
 */
Ext.define('Memo.store.Memo', {
    extend: 'Ext.data.Store',

    requires: [
        'Memo.model.Memo'
    ],

    model: 'Memo.model.Memo',

    proxy: 'memory',

    data: [
        { id: 1, title: 'タイトル1', body: '本文1' },
        { id: 2, title: 'タイトル2', body: '本文2' },
        { id: 3, title: 'タイトル3', body: '本文3' }
    ]
});

ビューコントローラに編集用のルーティングを追加してみます。

/**
 * ビューポートのビューコントローラクラス。
 *
 * @class Memo.view.main.ViewController
 * @extend Ext.app.ViewController
 */
Ext.define('Memo.view.main.ViewController', {
    extend: 'Ext.app.ViewController',
    alias: 'controller.app_main',

    requires: [
        'Memo.view.list.Panel',
        'Memo.view.regist.Panel'
    ],

    routes: {
        'list': 'showList',
        'regist': 'showRegist',
        'regist/:id': 'showRegist'
    },

    /**
     * 一覧画面を表示する。
     */
    showList: function () {
        this.switchScreen('list_panel');
    },

    /**
     * 登録画面を表示する。
     * @param {String} [id] メモID
     */
    showRegist: function (id) {
        var me = this,
            params = {};

        if (Ext.isDefined(id)) {
            params.id = +id;
        }

        me.switchScreen('regist_panel', params);
    },

    /**
     * 画面を切り替える。
     *
     * @param {String} screenXType 画面のxtype
     * @param {Object} [params] パラメータ
     */
    switchScreen: function (screenXType, params) {
        var me = this,
            view = me.getView(),
            screen;

        params = params || {};

        // 画面の存在チェック
        screen = view.down(screenXType);

        if (!screen) {
            // 画面を生成
            screen = Ext.widget(screenXType);

            // ビューポートに追加
            view.add(screen);
        }

        view.getLayout().setActiveItem(screen);

        screen.fireEvent('showscreen', params);
    }

});

すこしメソッド名を変えてます(これは私の感覚的なものですw)。

さて、編集用に下記を追加しました。

'regist/:id': 'showRegist'

:idという部分がメモIDに対応します。このようにコロンを付けて定義することで、動的な値に対応することができるようになっています。

:idの値は、showRegistの引数に渡されます。:idのような値が複数ある場合、第1引数、第2引数、・・・と順番に渡されます。

あとはshowRegistメソッド内で受け取るようにしました。

最終的には下記の部分でパラメータとしてメモIDが渡っていきます。

screen.fireEvent('showscreen', params);

fireEventというのは、イベントを発生させるためのメソッドです。これまでlistenersでイベント発生時の処理を設定してきましたが、自前のイベントを追加したい場合などは、このfireEventを使ってイベント発生させます。

今回は、画面が切り替わったらshowscreenというイベントを発火させることで、SPAとして画面が切り替わった場合にはこのイベントが発生するというルールを定義付けました。

一覧画面から登録画面へ遷移する

一覧画面から登録画面に遷移する部分を実装してみます。

イメージとしては、↓のようなかんじです。

一覧画面に新規作成ボタンを設定して、それをクリックしたら新規登録画面へ遷移する。
メモをダブルクリックしたら編集画面に遷移する。

まずは一覧画面です。

/**
 * メモ一覧パネルクラス。
 *
 * @class Memo.view.list.Panel
 * @extend Ext.Panel
 */
Ext.define('Memo.view.list.Panel', {
    extend: 'Ext.Panel',
    xtype: 'list_panel',

    requires: [
        'Memo.view.list.View',
        'Memo.view.list.ViewController'
    ],

    controller: 'list',

    layout: 'fit',

    bodyPadding: 15,

    scrollable: true,

    title: 'メモ一覧',

    tools: [
        {
            xtype: 'button',
            text: '新規作成',
            handler: 'onClickCreateButton'
        }
    ],

    items: {
        xtype: 'list_dataview'
    }
});

/**
 * メモ一覧データビュークラス。
 *
 * @class Memo.view.list.View
 * @extend Ext.view.View
 */
Ext.define('Memo.view.list.View', {
    extend: 'Ext.view.View',
    xtype: 'list_dataview',

    cls: 'list-dataview',

    tpl: [
        '<tpl for=".">',
            '<div class="item">',
                '<h3 class="title">{title}</h3>',
                '<p class="body">{body}</p>',
            '</div>',
        '</tpl>'
    ],

    itemSelector: 'div.item',

    store: 'Memo',

    listeners: {
        itemdblclick: 'onItemDblClick'
    }

});

/**
 * メモ一覧のビューコントローラクラス。
 *
 * @class Memo.view.list.ViewController
 * @extend Ext.app.ViewController
 */
Ext.define('Memo.view.list.ViewController', {
    extend: 'Ext.app.ViewController',
    alias: 'controller.list',

    /**
     * 新規作成ボタンクリック時の処理。
     */
    onClickCreateButton: function () {
        this.redirectTo('regist', true);
    },

    /**
     * データビューitemdblclickイベント時の処理。
     *
     * @param {Memo.view.list.View} dataview データビュー
     * @param {Memo.model.Memo} record メモモデル
     */
    onItemDblClick: function (dataview, record) {
        this.redirectTo('regist/' + record.getId(), true);
    }

});

新規作成ボタンを設置してクリックしたらonClickCreateButtonが呼ばれるようにしました。あと、データビューのitemdblclickイベントが発生したらonItemDblClickが呼ばれるようにしています。

で、それらのメソッドではredirectToを呼び出すようにしています。これによって指定されたハッシュにURLを切り替えることができます。

なお、第2引数に設定しているtrueですが、これは強制するかどうかを示すフラグです。現在のURLが、既にredirecToで指定されたURLになっている場合、第2引数がfalseだとルーティング部分の処理が発生しません。同じURLであったとしても画面切り替えの処理をしてほしい場合はtrueを設定します。私は基本的にtrueを設定しておけば良いと思っています。

登録画面の処理を実装

あとは登録画面の実装です。

ここでは編集の場合は画面表示のタイミングで、メモのデータをロードするというかんじにします。

他は以前とだいたい同じです(プロパティ名とかは適宜変えてます)。

/**
 * メモ登録フォームパネルクラス。
 *
 * @class Memo.view.regist.Panel
 * @extend Ext.form.Panel
 */
Ext.define('Memo.view.regist.Panel', {
    extend: 'Ext.form.Panel',
    xtype: 'regist_panel',

    requires: [
        'Memo.view.regist.ViewController',
        'Memo.view.regist.ViewModel'
    ],

    controller: 'regist',
    viewModel: 'regist',

    bodyPadding: 15,

    title: 'メモ入力フォーム',

    config: {
        /**
         * @cfg {Memo.model.Memo} メモモデル。
         */
        record: null
    },

    bind: {
        title: 'メモ入力フォーム({labelFormMode})',
        record: '{record}'
    },

    listeners: {
        showscreen: 'onShowScreen'
    },

    defaults: {
        anchor: '100%'
    },

    items: [
        {
            name: 'title',
            xtype: 'textfield',
            emptyText: 'タイトルを入力してください',
            allowBlank: false
        },
        {
            name: 'body',
            xtype: 'textarea',
            emptyText: '本文を入力してください'
        },
        {
            xtype: 'button',
            text: '保存',
            handler: 'onClickSaveButton'
        }
    ],

    /**
     * recordコンフィグ設定時の処理。
     * @param {Memo.model.Memo} record メモモデル
     */
    setRecord: function (record) {
        var me = this;

        me.loadRecord(record);

        me.callParent(arguments);
    }

});

/**
 * メモ登録のビューコントローラクラス。
 *
 * @class Memo.view.regist.ViewController
 * @extend Ext.app.ViewController
 */
Ext.define('Memo.view.regist.ViewController', {
    extend: 'Ext.app.ViewController',
    alias: 'controller.regist',

    /**
     * showscreenイベント時の処理。
     * @param {Object} params パラメータ
     */
    onShowScreen: function (params) {
        var me = this,
            viewModel = me.getViewModel(),
            store = Ext.getStore('Memo'),
            record = null;

        if (Ext.isNumber(params.id)) {
            record = store.getById(params.id);
        }

        if (!record) {
            record = Ext.create('Memo.model.Memo');
        }

        viewModel.set('record', record);
    },

    /**
     * 保存ボタンクリック時の処理。
     */
    onClickSaveButton: function () {
        var me = this,
            form = me.getView(),
            store = Ext.getStore('Memo');

        if (form.isValid()) {
            var record = form.getRecord();

            if (record.phantom) {
                // 新規
                record = store.add(form.getValues())[0];
            } else {
                // 更新
                record.set(form.getValues());
            }

            record.commit();

            Ext.Msg.alert('完了', '保存しました', function () {
                me.redirectTo('list');
            });
        }
    }

});

/**
 * メモ登録のビューモデルクラス。
 *
 * @class Memo.view.regist.ViewModel
 * @extend Ext.app.ViewModel
 */
Ext.define('Memo.view.regist.ViewModel', {
    extend: 'Ext.app.ViewModel',
    alias: 'viewmodel.regist',

    data: {
        /**
         * @cfg {Memo.model.Memo} 選択中のメモモデル。
         */
        record: null
    },

    formulas: {
        /**
         * フォームのモード(新規か編集か)のラベルを返す。
         *
         * @param {Function} get
         * @returns {string} フォームのモード(新規か編集か)のラベル
         */
        labelFormMode: function (get) {
            var record = get('record');
            return record ? '編集' : '新規作成';
        }
    }
});

showscreenイベント時にonShowScreenが実行されるようにして、その中でデータを取得するようにしています。他の画面でも、画面表示時の処理は同じように定義できるようになりました(私はよくこのようにしておきます)。

他は大きく変えていません。保存したときに完了メッセージを出して、一覧画面に戻るようにしたぐらいですかね。ここではredirectTo('list')で戻るようにしていますが、history.backでも良いと思います。

defaultTokenを設定しておく

最後に、Ext.app.ApplicationのdefaultTokenを設定しておきます。

/**
 * アプリケーションクラス。
 *
 * @class Memo.Application
 * @extend Ext.app.Application
 */
Ext.define('Memo.Application', {
    extend: 'Ext.app.Application',
    
    name: 'Memo',

    stores: [
        'Memo'
    ],

    defaultToken : 'list'
});

これを付けておくと、http://localhost:1841にアクセスした場合にhttp://localhost:1841/#listにリダイレクトしてくれます。デフォルトのルートを設定できるわけです。

最終的に、↓のようになりました(スタイルは調整しています)。

f:id:sham-memo:20170118135145p:plain

f:id:sham-memo:20170118135152p:plain

どうにか、当初の予定はクリアできているかと思います。

ちなみに、実際の案件ではこれだけでは駄目で、ルーティングに存在しないURLにアクセスされた場合への対応を入れたり、もっと汎用的なルーティング処理に変更したりと考えることは増えていきます。正直言って手間のかかる対応です。しかし、SPAにすることで、ユーザの操作感が各段に向上するのでお勧めです。

次回はストアのプロキシを変更するのと、削除できるようにする予定です。