初心者のためのExtJS入門

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

プラグインの作り方

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

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

プラグインのクラスは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を使った方が速いことがわかります。

アイコンフォントのパッケージ作成

前回はアイコンフォントの使い方を取り上げましたが、今回はアイコンフォントのパッケージを作成してみます。

標準のアイコンフォントだと思っているものが見つからないことがあります。

その場合、デザイナーに作ってもらったり、自分で適当なアイコン画像を探したりすることになるでしょう。

追加した画像を一つのフォントファイルにまとめてパッケージとして用意できれば、管理もしやすいし、アイコン画像の複数回読み込みがフォントファイル1回で済んだりと非常に便利になります。

あと、ベクタ画像になります。

フォントファイルの作成

フォントの作成には、IcoMoonのサービスを利用しました。

https://icomoon.io/app/

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

IcoMoonで標準で用意されているアイコンセットを使えたり、IcoMoonライブラリから有料でアイコンセットを購入して使えたり、自分でsvgファイルとして用意したファイルを使えたりと、

とにかく便利です。

自分でsvgファイルを用意した場合は、画面左上の「Import icons」ボタンからファイルをインポートすると、アイコンとして登録されます。

あとは、フォントファイルに含めたいアイコンを選択し、画面右下の「Generate Font」ボタンから作成し、ダウンロードできます。

ダウンロードしたファイルを展開すると、下記のようなセットになっています。

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

パッケージ作成

次にパッケージを作成します。

sencha generate package font-icomoon

パッケージ名は何でも良いですが、今回はfont-icomoonにしました。

フォントファイルとスタイルシートの設置

パッケージのresourcesにフォントを設置します。

既存のフォントパッケージに合わせて、resources/fontsに配置しました。

さらにstyle.css => style.scssとリネームして、sass/srcに配置します。

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

style.scssを一部修正し、フォントファイルをロードできるようにします。

@charset "UTF-8";

$icomoon-fonts-path: "font-icomoon/fonts";

@font-face {
  font-family: 'icomoon';
  src:  url('#{$icomoon-fonts-path}/icomoon.eot?hj34vj');
  src:  url('#{$icomoon-fonts-path}/icomoon.eot?hj34vj#iefix') format('embedded-opentype'),
    url('#{$icomoon-fonts-path}/icomoon.ttf?hj34vj') format('truetype'),
    url('#{$icomoon-fonts-path}/icomoon.woff?hj34vj') format('woff'),
    url('#{$icomoon-fonts-path}/icomoon.svg?hj34vj#icomoon') format('svg');
  font-weight: normal;
  font-style: normal;
}

最後にpackage.jsonを修正します。

"sass" : {
    "src": [
        "${package.dir}/sass/src",
        "${package.dir}/sass/src/style.scss"  // 追記
    ]
}

使ってみる

さっそく使ってみます。

まずはパッケージなので、app.jsonのrequiresにfont-icomoonを追記しましょう。

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

    cls: 'sample-panel',

    padding: 50,

    items: {
        xtype: 'button',
        iconCls: 'icon-insomnia'
    },

    html: '<i class="icon-insomnia"></i>'
});

セレクタ名はstyle.scssに載っています。

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

追加したアイコンがうまく表示されました。

アイコンフォント

ExtJS6.5では、FontAwesomeとPictosがパッケージに含まれています。

今回はそれらの使い方を取り上げます。

まずはapp.jsonでアイコンフォントのパッケージをrequiresに追記します。

"requires": [
  "font-awesome",
  "font-pictos"
]

あとはclass属性に使用したいフォントのセレクタ名を正しく指定するだけです。

例えばHTMLタグであればiタグに、ボタンのアイコンならiconClsに指定します。

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

    cls: 'sample-panel',

    padding: 50,
    bodyPadding: '15 0 0 0',

    tbar: {
        items: [
            {
                xtype: 'button',
                tooltip: '編集',
                iconCls: 'pictos pictos-pencil',
                scale: 'large'
            },
            {
                xtype: 'button',
                tooltip: '編集',
                iconCls: 'x-fa fa-pencil',
                scale: 'large'
            }
        ]
    },

    html: [
        '<i class="pictos pictos-pencil"></i>',
        '<i class="x-fa fa-pencil"></i>'
    ]
});

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

同じようなアイコンでもフォントで微妙に違いがでますね。

FontAwesomeの場合はx-fa fa-xxxx、Pictosの場合はpictos pictos-xxxxという形式になります。

FontAwesomeの場合は、http://fontawesome.io/icons/ を見るのが良いと思います。気を付けないといけないのは、ExtJSパッケージのフォントのバージョンが古い場合があるということです。

Pictosはいまいち一覧でセレクタ名までわかるページが無いんですよね。http://pictos.cc/classic/font を参照しつつ、セレクタ名は ext/packages/font-pictos/all.scss を直接みるのが早いかも。

 

 

 

ところでclassicの場合、ボタンではglyphコンフィグというものを使うこともできます(ExtJS5では結構使いました)。

が、modernには無いこととiconClsで同じUIで表示できることから今はほとんど使う必要がなくなっています。