初心者のためのExtJS入門

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

ExtJS6.6の新機能

最後の更新からかなり間が空きました。

ようやくExtJS6.6を触ったので追加機能のうち大き目っぽい2つだけ取り上げます(執筆の途中でExtJS6.7が出たようで、ちょっと今更感ありますが。。)

ext-genコマンド

ext-genというNodeモジュールでアプリケーションの作成などができるようになったみたいです。 まとめてくださっている方がいらっしゃるので、詳しくはリンク先を参照ください。

https://qiita.com/martini3oz/items/5c94e11322c33697c8b3

私はWindows64bitで開発してますが、ext-genのインストールになぜか失敗したところで挫折しました。 nodeやnpmのバージョンを上げてみたりしたのですが、これまで通りSenchaコマンドで作成することも可能だったので「じゃあ別にいいや」と。

ただし、ExtJS6.7はどうなのかちょっと分からない状況です(ちなみにExtJS6.7のトライアル版ではext-genでのアプリケーション作成に成功しました)。 ExtJS6.6まではトライアル版をダウンロードしてSenchaコマンドでの作成を試せましたが、ExtJS6.7からはトライアル版は完全にNodeモジュールでしか提供されていないようなので。。 だいぶライセンス周りの対応が厳しくなってきたように感じます。

ルーティングの指定が柔軟になった

ルーティングの書き方が柔軟になりました。

Ext.define('MyApp.view.main.MainController', {
    extend: 'Ext.app.ViewController',
    alias: 'controller.app-main',

    routes: {
        'user/:{id:num}': {
            action: 'onUser',
            before: 'onBeforeUser',
            name: 'user'
        }
    },

    onBeforeuser: function (values) {
        console.log(values.id);
    }

上記のように、以前は「user/:id」と書いていたところを「user/:{id}」と指定できるようになりました。 どうなるかというと、コールバック関数の引数にオブジェクトとして渡され「values.id」のように値を参照できるようになりました。 以前は「user/:id」であれば、コールバック関数の第1引数がそのまま:idの値になっていたのが異なるところです。 複数のパラメータが出てくると、オブジェクトのほうが扱いやすそうです。

また、以前よりも簡潔にパラメータに制限をかけられるようになりました。 例えば「user/:{id:num}」とすることで、idの部分を数値のみに制限できます。

さいごに

エンバカデロに買収されて以降、正直ExtJS大丈夫かいな?という気持ちはずっとあります。 ExtJS自体は評価していますし、SPAの業務アプリケーションを作成するという場合は非常に優秀なフロントエンドライブラリであることは間違いないので、ひとまずExtJS7までは見守っていくつもりです。仕事なんかでも参考にできる機能や実装なんかもあるだろうし。

でも、Vue.jsのVuetifyあたりはUIコンポーネントがなかなか良さげなので、試した感触が良さそうなら乗り換える気持ちもあります。ライブラリの選定が難しい時期にきてます。

いずれにしても、ライセンス周りの対応でここまでガチガチにされてくると、さらに普及の障害になるのではないかと考えています。

=> 追伸

ExtJSには完全に見切りを付け、現在(2022年12月)はQuasar Frameworkを使うようになりました。

ボタンのUIにバインディングできるように工夫する

ボタンのテキストやUIをデータバインディングで変更したいときがあります。

テキストははじめからできるようになっているのですが、なぜかUIはできるようになっていません(注意:ExtJS6.5.2時点までの話です)。

根本的なところはsetUiメソッドが定義されていないからです(代わりにsetUIメソッドが存在しています)。

なので、下記のようなオーバーライドクラスを定義しておくことで対応できます。

/**
 * Ext.button.Buttonのオーバーライドクラス。
 *
 * @class Common.overrides.button.Button
 * @override Ext.button.Button
 */
Ext.define('Common.overrides.button.Button', {
    override: 'Ext.button.Button',

    setUi: function (ui) {
        this.setUI(ui);
    }
});

これで下記のようにuiへのバインディングが可能になります。

{
    xtype: 'button',
    ui: 'primary',
    bind: {
        text: '{buttonText}',
        ui: '{buttonUI}'
    }
}

ミックスインの作り方

今回はミックスインを作ってみます。

ミックスインは、利用するクラスにメソッド、プロパティを追加して利用できるようにするもので、同じような機能が必要になる場合に有用です。

プラグインに似ていますが、下記の違いがあります。

1. プラグインの場合はpluginsプロパティ指定時にパラメータを指定できるが、ミックスインはできない。

2. プラグインの場合はあくまで内部にプラグインのインスタンスを保持しているだけで、
プラグインクラスで定義しているメソッド等が利用する側のクラスに影響しないが、
ミックスインで定義したメソッド等は利用する側のクラスに定義される。

ミックスインクラス作成

ミックスインクラスを作ってみましょう。

Ext.define('Sample.mixin.HelloWorldOutput', {
    mixinId: 'helloworld',

    count: 0,

    greet: function () {
        Ext.Msg.alert('', 'Hello, World!![count = ' + (++this.count) + ']');
    }
});

ミックスインとして定義するクラスは、特に継承する必要はありません。ただ、作法としてmixinIdプロパティを指定するようにしましょう。

あとは必要なプロパティやメソッドを定義するだけです。

ミックスインクラスを使う

次に作成したミックスインクラスを使ってみます。

Ext.define('Sample.view.sample.Panel', {
    extend: 'Ext.panel.Panel',
    xtype: 'sample_panel',

    cls: 'sample-panel',

    mixins: [
        'Sample.mixin.HelloWorldOutput'
    ],

    items: {
        xtype: 'button',
        text: 'Push',
        handler: function () {
            this.up('sample_panel').greet();
        }
    }
});

ミックスインクラスを使うようにするには、mixinsプロパティでクラス名を指定します。

これでミックスインクラスで定義されたプロパティやメソッドを呼び出せるようになったので、ボタンを押してもgreetメソッドが解決できます。

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

実際に確認しても、利用する側のクラスにメソッドやプロパティが定義されていることが分かります。

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

正直なところ、私自身、継承・プラグインミックスインの厳密な使い分けまでは理解できていません。

これだという答えをお持ちの方はご教示ください( )

プラグインの作り方

今回はプラグインを作ってみます(まあ、プラグインを自作しないといけない場面はあまり無いと思いますが...)。

プラグインは、コンポーネントに機能を追加するものです。

プラグインのクラスはExt.plugin.Abstractを継承して作成します。

作成したプラグインは、pluginsコンフィグにエイリアス名を指定して使用できます。

↓のサンプルは、指定した回数Hello, Worldを追記するというプラグイン(?)です。

Ext.define('Sample.view.plugin.HelloWorldOutput', {
    extend: 'Ext.plugin.Abstract',
    alias: 'plugin.helloworld',

    /**
     * 初期処理。
     *
     * @param {*} cmp コンポーネント
     */
    init: function (cmp) {
        var me = this,
            html = cmp.html || '',
            count = me.pluginConfig.count || 1;

        me.setCmp(cmp);

        for (var n = 0; n < count; n++) {
            html += '<div>Hello, World!!</div>';
        }

        cmp.setHtml(html);
    },

    // @override
    destroy: function () {
        var me = this;

        // MEMO: 何か破棄が必要な場合はこの辺りに書く

        me.callParent();
    }

});

Ext.define('Sample.view.sample.Panel', {
    extend: 'Ext.panel.Panel',
    xtype: 'sample_panel',

    plugins: {
        helloworld: {
            // プラグイン側ではplatformConfigから参照できる
            count: 2
        }
    },

    html: 'sample'
});

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

プラグインの初期処理はinitメソッド、終了処理はdestroyメソッドに記述します。

initメソッドでイベントを割り当てているような場合は、ちゃんと解除するように気を付けましょう。

プラグイン作成はめんどくさいと感じることもありますが、1つ作れば使い回せるので開発を続ける場合は結構便利です。

私の場合は、どの案件でも使えるのでファイルをドラッグ&ドロップでアップロードする機能をプラグイン化しています。

ビューモデルのデータにモデルを設定して使用する

ビューモデルに、データのモデルを設定することがあります。

今回は、その際の使い方や注意点を取り上げます。

使用するモデルやデータは下記の通りです。

Ext.define('Sample.model.Company', {
    extend: 'Ext.data.Model',

    fields: [
        {
            name: 'id',
            type: 'int'
        },
        {
            name: 'name',
            type: 'string'
        },
        {
            name: 'addressId',
            reference: 'Sample.model.Address'
        }
    ],

    hasMany: [
        {
            name: 'children',
            model: 'Sample.model.Company'
        }
    ],

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

Ext.define('Sample.model.Address', {
    extend: 'Ext.data.Model',

    fields: [
        {
            name: 'id',
            type: 'int'
        },
        {
            name: 'postalCode',
            type: 'string'
        },
        {
            name: 'state',
            type: 'string'
        }
    ]
});

Ext.define('Sample.view.sample.PanelModel', {
    extend: 'Ext.app.ViewModel',
    alias: 'viewmodel.sample_panel',

    data: {
        /**
         * @cfg {Sample.model.Company}
         */
        record: null
    }
});

Ext.define('Sample.view.sample.PanelController', {
    extend: 'Ext.app.ViewController',
    alias: 'controller.sample_panel',

    control: {
        '#': {
            afterrender: 'onAfterRender'
        }
    },

    onAfterRender: function () {
        var me = this,
            viewModel = me.getViewModel();

        // データをロードして、ビューモデルに設定
        Sample.model.Company.load(null, {
            success: function (record) {
                viewModel.set('record', record);
            }
        });
    }
});
{
  "success": true,
  "data": {
    "id": 1,
    "name": "XXX会社",
    "address": {
      "id": 1000,
      "postalCode": "1234567",
      "state": "鹿児島県"
    },
    "children": [
      {
        "id": 2,
        "name": "YYY会社"
      },
      {
        "id": 3,
        "name": "ZZZ会社"
      }
    ]
  }
}

モデルのプロパティをバインディング

{record.name}のように記述することで、モデルのプロパティをバインディングできます。

アソシエーションのモデルでも同様です。

Ext.define('Sample.view.sample.Panel', {
    extend: 'Ext.panel.Panel',
    xtype: 'sample_panel',

    cls: 'sample-panel',

    controller: 'sample_panel',
    viewModel: 'sample_panel',

    bodyPadding: 15,

    layout: 'anchor',

    defaults: {
        anchor: '100%'
    },

    items: [
        {
            xtype: 'displayfield',
            fieldLabel: '会社名',
            bind: '{record.name}'
        },
        {
            xtype: 'displayfield',
            fieldLabel: '郵便番号',
            bind: '{record.address.postalCode}'
        }
    ]
});

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

モデルの値が変更されても、ちゃんとビューに反映されます。

onClickChangeButton: function () {
    var me = this,
        viewModel = me.getViewModel(),
        record = viewModel.get('record');

    record.set('name', 'ABC会社');
    record.getAddress().set('postalCode', '9999999');
}

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

アソシエーションのストアをバインディング

プロパティと同じようにして、アソシエーションのストアもバインディングできます。

Ext.define('Sample.view.sample.Panel', {
    extend: 'Ext.panel.Panel',
    xtype: 'sample_panel',

    cls: 'sample-panel',

    controller: 'sample_panel',
    viewModel: 'sample_panel',

    bodyPadding: 15,

    layout: 'anchor',

    defaults: {
        anchor: '100%'
    },

    items: [
        {
            xtype: 'dataview',
            bind: {
                store: '{record.children}'
            },
            itemTpl: '<div class="child-company">{name}</div>',
            itemSelector: '.child-company'
        }
    ]
});

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

注意点

formulasを使うときに注意することがあります。

例えば、下記のようなコードを書いたとします。

Ext.define('Sample.view.sample.PanelModel', {
    extend: 'Ext.app.ViewModel',
    alias: 'viewmodel.sample_panel',

    data: {
        /**
         * @cfg {Sample.model.Company}
         */
        record: null
    },

    formulas: {
        // bad...
        escapeName: function (get) {
            var record = get('record');
            return record ? Ext.String.htmlEncode(record.get('name')) : null;
        },

        // bad...
        fullAddress: function (get) {
            var record = get('record'),
                address, text;

            if (record) {
                address = record.getAddress();
                text = address.get('state') + address.get('cities') + address.get('apartment');
            }

            return text;
        }
    }
});

Ext.define('Sample.view.sample.Panel', {
    extend: 'Ext.panel.Panel',
    xtype: 'sample_panel',

    cls: 'sample-panel',

    controller: 'sample_panel',
    viewModel: 'sample_panel',

    bodyPadding: 15,

    layout: 'anchor',

    defaults: {
        anchor: '100%'
    },

    dockedItems: {
        xtype: 'button',
        text: 'change',
        handler: 'onClickChangeButton'
    },

    items: [
        {
            xtype: 'displayfield',
            fieldLabel: '会社名',
            bind: '{escapeName}'
        },
        {
            xtype: 'displayfield',
            fieldLabel: '郵便番号',
            bind: '{record.address.postalCode}'
        },
        {
            xtype: 'displayfield',
            fieldLabel: '住所',
            bind: '{fullAddress}'
        }
    ]
});

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

これは一見すると良さそうですが、下記のような値の変更でビューが更新されません。

onClickChangeButton: function () {
    var me = this,
        viewModel = me.getViewModel(),
        record = viewModel.get('record');

    record.set('name', 'ABC会社');
    record.getAddress().set('state', '東京都');
}

get('record.address.state')のように、直接対象のプロパティを取得するように書き直すことで改善します。

Ext.define('Sample.view.sample.PanelModel', {
    extend: 'Ext.app.ViewModel',
    alias: 'viewmodel.sample_panel',

    data: {
        /**
         * @cfg {Sample.model.Company}
         */
        record: null
    },

    formulas: {
        // good!!
        escapeName: function (get) {
            return Ext.String.htmlEncode(get('record.name') || '');
        },

        // good!!
        fullAddress: function (get) {
            var state = get('record.address.state'),
                cities = get('record.address.cities'),
                apartment = get('record.address.apartment');

            return state + cities + apartment;
        }
    }
});

うっかり間違えてしまう典型的なパターンなので注意するようにしましょう。

Ext.Deferredで非同期処理をまとめる

ExtJSでもDeferred, Promiseによる非同期処理をまとめる機能が存在します。

複数のロードが必要な場合にコールバック地獄とならないようにする、最近ではお馴染みの手法ですね。

ロード終了まで画面をマスクしたり、ロードのタイミングを調整したりするのに重宝します。

ExtJSでは特に複数データのロードが必要になるので、使う場面が多いです。

サンプル

ExtJSでは、Ext.Deferredクラスを使います。

必要なExt.Deferredのインスタンスを用意して、Ext.Deferred.allメソッドに渡し、thenメソッドに完了時の処理を記述します。

あとは各処理が終わったタイミングで、Ext.Deferredのインスタンスのresolveメソッドまたはrejectメソッドを呼び出します。

thenの第1引数が全てresolveとなった場合に呼び出されます。1つでもrejectとなった場合は第2引数の処理が呼び出されます。

testDeferred: function () {
    var d1 = Ext.create('Ext.Deferred'),
        d2 = Ext.create('Ext.Deferred');

    Ext.Deferred.all([d1, d2])
        .then(
            function () {
                console.log('all resolve');
            },
            function () {
                console.log('any reject');
            }
        );

    setTimeout(function () {
        console.log('d1 resolve');
        d1.resolve();
    }, 3000);

    setTimeout(function () {
        console.log('d2 resolve');
        d2.resolve();
    }, 1000);
}

上記の場合、実行結果は下記のようになります。

d2 resolve
d1 resolve
all resolve

ちなみに一部rejectの場合はどうなるでしょうか?

testDeferred: function () {
    var d1 = Ext.create('Ext.Deferred'),
        d2 = Ext.create('Ext.Deferred');

    Ext.Deferred.all([d1, d2])
        .then(
            function () {
                console.log('all resolve');
            },
            function () {
                console.log('any reject');
            }
        );

    setTimeout(function () {
        console.log('d1 resolve');
        d1.resolve();
    }, 3000);

    setTimeout(function () {
        console.log('d2 reject');
        d2.reject();
    }, 1000);
}
d2 reject
any reject
d1 resolve

1つでもrejectとなった場合、thenメソッドの第2引数の処理が呼ばれているのが分かります。

残りの処理が終わるのを待たずに呼び出されることに注意が必要です。

ES6で書いてみる

ExtJS6のコードを、ES6を使って書いてみました。

まだclassキーワードではクラス宣言できないので(もし出来るようになっているならぜひ教えてください!!)、そこはExt.defineを使います。

メソッド定義などはES6の形式で記述できます。他、あえていくつかES6の機能を使って書いてみました。

Ext.define('Sample.view.sample.PanelController', {
    extend: 'Ext.app.ViewController',
    alias: 'controller.sample_panel',

    onClickPushButton() {
        this.greet();

        const message = 'hello, yamada';

        this.greet(message);

        const taro = {
            'name': 'yamada taro',
            [this.getAgeKey()]: 35
        };

        console.log(taro[this.getAgeKey()]);

        Ext.Array.each([1, 2, 3], (n) => {
            console.log(n);
        });

        var [name, age] = this.getPair(taro);
        console.log(name, age);
    },

    greet(message = 'hello, world') {
        console.log(message);
    },

    getAgeKey() {
        return 'age';
    },

    getPair(user) {
        return [user['name'], user[this.getAgeKey()]];
    }
});

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

ExtJS7になったらclassキーワード使えるようになるのかな?