読者です 読者をやめる 読者になる 読者になる

初心者のためのExtJS入門

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

モデルのアソシエーション

ExtJS

ExtJSのモデルは「1 対 1」「1 対 複数」といったデータモデル上の関係を持つことができます(アソシエーション機能)。

例えば、下のようなデータをモデルで取り扱う場合に使えます。

{
  "id": 100,
  "created": "2017/03/15 15:20:25",
  "user": {
    "id": 1,
    "name": "山田太郎",
    "kana": "やまだたろう"
  },
  "details": [
    {
      "id": 1,
      "num": 1,
      "item": {
        "id": 1,
        "name": "ジャケット",
        "price": 5000
      }
    },
    {
      "id": 2,
      "num": 2,
      "item": {
        "id": 2,
        "name": "Tシャツ",
        "price": 1000
      }
    }
  ]
}

モデル作成

さきほどのJSONデータは注文データを表しています。

userは購入者、detailsは注文の詳細(どの商品をいくつ注文したのか)を意味しています。

それを踏まえてモデルを作成します。

/**
 * ユーザモデルクラス。
 *
 * @class Sample.model.User
 * @extend Ext.data.Model
 */
Ext.define('Sample.model.User', {
    extend: 'Ext.data.Model',

    fields: [
        {
            // ユーザID
            name: 'id',
            type: 'int'
        },
        {
            // 氏名
            name: 'name',
            type: 'string'
        },
        {
            // かな
            name: 'kana',
            type: 'string'
        }
    ]
});

/**
 * 商品モデルクラス。
 *
 * @class Sample.model.Item
 * @extend Ext.data.Model
 */
Ext.define('Sample.model.Item', {
    extend: 'Ext.data.Model',

    fields: [
        {
            // 商品ID
            name: 'id',
            type: 'int'
        },
        {
            // 商品名
            name: 'name',
            type: 'string'
        },
        {
            // 単価
            name: 'price',
            type: 'int'
        }
    ]
});

/**
 * 注文詳細モデルクラス。
 *
 * @class Sample.model.OrderDetail
 * @extend Ext.data.Model
 */
Ext.define('Sample.model.OrderDetail', {
    extend: 'Ext.data.Model',

    requires: [
        'Sample.model.Item'
    ],

    fields: [
        {
            // 注文詳細ID
            name: 'id',
            type: 'int'
        },
        {
            // 注文数
            name: 'num',
            type: 'int'
        }
    ],

    hasOne: [
        {
            // 注文モデル
            name: 'item',
            model: 'Sample.model.Item'
        }
    ]
});

/**
 * 注文モデルクラス。
 *
 * @class Sample.model.Order
 * @extend Ext.data.Model
 */
Ext.define('Sample.model.Order', {
    extend: 'Ext.data.Model',

    requires: [
        'Sample.model.User',
        'Sample.model.OrderDetail'
    ],

    fields: [
        {
            // 注文ID
            name: 'id',
            type: 'int'
        },
        {
            // 作成日時(注文日時)
            name: 'created',
            type: 'date',
            dateFormat: 'Y/m/d H:i:s'
        }
    ],

    hasOne: [
        {
            name: 'user',
            model: 'Sample.model.User'
        }
    ],

    hasMany: [
        {
            name: 'details',
            model: 'Sample.model.OrderDetail'
        }
    ]
});

hasOneとhasManyでアソシエーションを設定しています。

ひとつのデータであればhasOne、複数のデータであればhasManyで表現できます。

nameに指定する文字列は、JSONのプロパティ名と合わせます。

modelには、モデルクラス名をフルネームで指定します。

(Application.jsなどでモデルをrequiresするのをお忘れなく)

モデルにプロキシを設定

今回は非同期でデータをロードすることにしました。

モデルにExt.data.proxy.Ajaxプロキシを設定します。

/**
 * 注文モデルクラス。
 *
 * @class Sample.model.Order
 * @extend Ext.data.Model
 */
Ext.define('Sample.model.Order', {
    extend: 'Ext.data.Model',

    requires: [
        'Sample.model.User',
        'Sample.model.OrderDetail',
        'Ext.data.proxy.Ajax',
        'Ext.data.reader.Json'
    ],

    fields: [
        {
            // 注文ID
            name: 'id',
            type: 'int'
        },
        {
            // 作成日時(注文日時)
            name: 'created',
            type: 'date',
            dateFormat: 'Y/m/d H:i:s'
        }
    ],

    hasOne: [
        {
            // ユーザ
            name: 'user',
            model: 'Sample.model.User'
        }
    ],

    hasMany: [
        {
            // 注文詳細
            name: 'details',
            model: 'Sample.model.OrderDetail'
        }
    ],

    proxy: {
        type: 'ajax',
        url: '/data/order.json',
        reader: {
            type: 'json',
            rootProperty: 'data'
        }
    }
});

これでSample.model.Order.loadを実行したときに、proxyコンフィグに設定した内容でロードしてくれます。

ダミーデータを作成

ダミーデータを作成します。

プロキシのURLを/data/order.jsonとしているので、このファイルをプロジェクトに作成します。

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

{
  "success": true,
  "data": {
    "id": 100,
    "created": "2017/03/15 15:20:25",
    "user": {
      "id": 1,
      "name": "山田太郎",
      "kana": "やまだたろう"
    },
    "details": [
      {
        "id": 1,
        "num": 1,
        "item": {
          "id": 1,
          "name": "ジャケット",
          "price": 5000
        }
      },
      {
        "id": 2,
        "num": 2,
        "item": {
          "id": 2,
          "name": "Tシャツ",
          "price": 1000
        }
      }
    ]
  }
}

これで http://localhost:1841/data/order.json にアクセスできるようになりました。

ロードしてみる

試しにロードしてみます。

Developer Toolsなどのコンソールから下を実行すると、ログが出力されればOKです。

Sample.model.Order.load(100, {
    success: function (record) {
        console.log(record);
        console.log(record.getUser());
        console.log(record.details());
    }
});

record変数に、ロードしたデータを保持したSample.model.Orderのインスタンスが設定されます。

注文モデルからユーザモデルを参照するには自動的に定義された「getUser」メソッドを使います。

また、注文詳細は自動的に定義された「details」メソッドを使います。detailsメソッドの戻り値は注文詳細モデルのストアになっています。

(loadメソッドの第1引数には注文IDを指定しますが、レスポンスデータのidと一致しないとエラーとなるので注意してください)

ロードしたデータを画面に表示する

最後に、ロードしたデータを画面に出力してみます。

ビューモデルがゴチャゴチャしてるのが嫌な感じ。もっと良い書き方がありそう。

/**
 * メインパネル。
 *
 * @class Sample.view.main.Panel
 * @extend Ext.panel.Panel
 */
Ext.define('Sample.view.main.Panel', {
    extend: 'Ext.panel.Panel',
    xtype: 'main_panel',

    requires: [
        'Sample.view.order.detail.Content',
        'Sample.view.main.ViewController',
        'Sample.view.main.ViewModel'
    ],

    controller: 'main',
    viewModel: 'main',

    layout: 'fit',

    items: [
        {
            xtype: 'order_detail_content'
        }
    ],

    listeners: {
        afterrender: 'onAfterRender'
    }

});

/**
 * 注文内容クラス。
 *
 * @class Sample.view.order.detail.Content
 * @extend Ext.Component
 */
Ext.define('Sample.view.order.detail.Content', {
    extend: 'Ext.Component',
    xtype: 'order_detail_content',

    cls: 'order-detail-content',

    bind: {
        data: {
            order: '{order}',
            user: '{user}',
            orderDetails: '{orderDetails}'
        }
    },

    tpl: [
        '<h1>#{order.id} 注文情報</h1>',
        '<table>',
            '<tbody>',
                '<tr>',
                    '<th>注文者</th>',
                    '<td>{user.name}</td>',
                '</tr>',
                '<tr>',
                    '<th>注文日時</th>',
                    '<td>{[Ext.Date.format(values.order.created, "Y/m/d H:i")]}</td>',
                '</tr>',
            '</tbody>',
        '</table>',
        '<h2>注文詳細</h2>',
        '<table>',
            '<tbody>',
                '<tr>',
                    '<th>商品名</th>',
                    '<th class="right">単価</th>',
                    '<th class="right">数量</th>',
                    '<th class="right">小計</th>',
                '</tr>',
                '<tpl for="orderDetails">',
                    '<tr>',
                        '<td>{item.name}</td>',
                        '<td class="right">{item.price}円</td>',
                        '<td class="right">{num}</td>',
                        '<td class="right">{[values.item.price * values.num]}円</td>',
                    '</tr>',
                '</tpl>',
            '</tbody>',
        '</table>',
        '<div class="total">合計: {[this.getTotal(values.orderDetails)]}円</div>',
        {
            getTotal: function (orderDetails) {
                var total = 0;
                Ext.Array.each(orderDetails, function (detail) {
                    total += detail.item.price * detail.num;
                });
                return total;
            }
        }
    ]

});

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

    /**
     * afterrenderイベント時の処理。
     */
    onAfterRender: function () {
        var me = this;

        Sample.model.Order.load(100, {
            success: function (record) {
                me.getViewModel().set('record', record);
            }
        });
    }
});

/**
 * ビューモデルクラス。
 *
 * @class Sample.view.main.ViewModel
 * @extend Ext.app.ViewModel
 */
Ext.define('Sample.view.main.ViewModel', {
    extend: 'Ext.app.ViewModel',
    alias: 'viewmodel.main',

    data: {
        /**
         * @cfg {Sample.model.Order} 注文モデル。
         */
        record: null
    },

    formulas: {
        /**
         * 注文情報を返す。
         *
         * @param {Function} get
         * @returns {Object} 注文情報
         */
        order: function (get) {
            var record = get('record');
            return record ? record.getData() : {};
        },

        /**
         * ユーザ情報を返す。
         *
         * @param {Function} get
         * @returns {Object} ユーザ情報
         */
        user: function (get) {
            var record = get('record');
            return record ? record.getUser().getData() : {};
        },

        /**
         * 注文詳細情報を返す。
         * @param {Function} get
         * @returns {Array} 注文詳細情報
         */
        orderDetails: function (get) {
            var record = get('record');

            if (record) {
                var data = [];
                record.details().each(function (detail) {
                    var item = detail.getItem();
                    data.push(Ext.apply(detail.getData(), {
                        item: item.getData()
                    }));
                });
                return data;
            } else {
                return [];
            }
        }
    }
});
@charset "UTF-8";

.order-detail-content {
  background-color: #f9f9f9;
  padding: 20px;

  h1, h2 {
    font-weight: normal;
    line-height: 1.2;
    margin: 0 0 10px 0;
  }

  table {
    width: 100%;
    border-collapse: collapse;
    margin-bottom: 15px;

    th, td {
      padding: 10px;
      border: 1px solid #ddd;
    }

    th {
      background-color: #009688;
      color: #fff;
      font-weight: normal;
      text-align: left;
    }

    td {
      background-color: #fff;
    }
  }

  .total {
    font-size: 2.5em;
    line-height: 1.2;
    text-align: right;
    text-decoration: underline;
  }

  .right {
    text-align: right;
  }
}

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

こんな感じになりました。

アソシエーションの機能はなかなか便利です。サーバ側のレスポンスも、あらかじめアソシエーションを考慮した形式にしておくと良いでしょう。