初心者のためのExtJS入門

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

ボタンの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キーワード使えるようになるのかな?

suspendLayouts・resumeLayouts

今回はExt.suspendLayouts、Ext.resumeLayoutsでレンダリングのチューニングを試してみます。

これを使うとDOMのレンダリングをまとめられます。

Ext.suspendLayoutsを呼び出すと、フレームワーク内部で持っているレイアウト中断のカウンタがインクリメントされ、

Ext.resumeLayoutsを引数無しで呼び出すと、フレームワーク内部で持っているレイアウト中断のカウンタがデクリメントされます。

このカウンタが0になると、レンダリングが実行されるという仕組みです(そういえばCOMでこんなの(参照カウンタ)あったなー、と思いました)。

Ext.resumeLayoutsにtrueを渡して呼び出すと、カウンタが強制的に0になります。

試してみる

検証のため1000個のボタンを追加してみます。

A. Ext.suspendLayouts、Ext.resumeLayoutsを使わない場合

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

    onClickBulkAddButton: function () {
        var me = this,
            view = me.getView();

        console.time('normal');

        for (var n = 1; n <= 1000; n++) {
            view.add({
                xtype: 'button',
                text: '追加されたボタン' + n
            });
        }

        console.timeEnd('normal');
    }
});

B. Ext.suspendLayouts、Ext.resumeLayoutsを使う場合

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

    onClickBulkAddButton: function () {
        var me = this,
            view = me.getView();

        console.time('suspendAndResume');

        // MEMO: Ext.suspendLayoutsとExt.resumeLayoutsで挟む
        Ext.suspendLayouts();

        for (var n = 1; n <= 1000; n++) {
            view.add({
                xtype: 'button',
                text: '追加されたボタン' + n
            });
        }

        Ext.resumeLayouts(true);

        console.timeEnd('suspendAndResume');
    }
});

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

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

A.は約1.8秒, B.は約0.6秒という結果になり、Ext.suspendLayouts、Ext.resumeLayoutsを使った方が速いことがわかります。