コンポーネントを開発する際のExt JSのMVVMのニュアンス

みなさんこんにちは。 Ext JS 5のリリースから多くの時間が経過し、MVVMパターンを使用してアプリケーションを開発する可能性が示されました。 この間、私は話をしたいいくつかの困難に遭遇しました。



そもそも、Ext JS 4(および以前はSencha Touch)でコンポーネントを作成するとき、その構成プロパティはconfigオブジェクトで宣言され、それぞれに対してゲッターとセッターが自動的に作成されました。 すべてのハンドラを手動で記述するのは多少面倒ですが、これは標準的なアプローチでした。



MVVMを使用したExt JSの5番目のバージョンでは、ルーチンの大部分を簡単に取り除くことができます。構成プロパティとそのハンドラーを削除し、代わりに目的のプロパティまたは式ViewModelにバインドします。 コードがはるかに小さくなり、読みやすさが向上しました。



しかし、カプセル化の問題が心配でした。 開発プロセス中に、機能の一部を別のコンポーネントに入れて再利用したい場合はどうなりますか? 独自のViewModelを作成する必要がありますか? コンポーネントの状態を変更する方法:ViewModelに直接アクセスするか、構成プロパティとパブリックセッターを使用する価値がありますか?



この問題やその他の問題についての考え、およびファイルの例-カットの下で。



パート1. ViewModelの使用



たとえば、一部のユーザーのテーブルを作成してみましょう。 彼女はエントリを追加および削除できますが、必要に応じて読み取り専用モードに切り替えます。 また、強調表示されたユーザーの名前を削除ボタンに含めるようにします。



例1.標準的なアプローチ



MVVMを使用せずにこれを行うにはどうすればよいでしょうか?







Sencha Fiddleでの眺め



Fiddle.view.UsersGrid
Ext.define('Fiddle.view.UsersGrid', { extend: 'Ext.grid.Panel', xtype: 'usersgrid', config: { /** @cfg {Boolean} Read only mode */ readOnly: null }, defaultListenerScope: true, tbar: [{ text: 'Add', itemId: 'addButton' }, { text: 'Remove', itemId: 'removeButton' }], columns: [{ dataIndex: 'id', header: 'id' }, { dataIndex: 'name', header: 'name' }], listeners: { selectionchange: 'grid_selectionchange' }, updateReadOnly: function (readOnly) { this.down('#addButton').setDisabled(readOnly); this.down('#removeButton').setDisabled(readOnly); }, grid_selectionchange: function (self, selected) { var rec = selected[0]; if (rec) { this.down('#removeButton').setText('Remove ' + rec.get('name')); } } });
      
      







読み取り専用モードの設定
 readOnlyButton_click: function (self) { this.down('usersgrid').setReadOnly(self.pressed); }
      
      







かなり冗長ですが、それは明らかです。コンポーネントのすべてのロジックは内部にあります。 ViewControllersを使用できるように予約する必要があり、これもコンポーネントの一部と見なされますが、例ではそれらを使用せずに実行できます。



例2. MVVMの追加



コードハンドラーを削除し、バインディング(バインド)に置き換えましょう。



Sencha Fiddleでの眺め



Fiddle.view.UsersGrid
 Ext.define('Fiddle.view.UsersGrid', { extend: 'Ext.grid.Panel', xtype: 'usersgrid', reference: 'usersgrid', viewModel: { data: { readOnly: false } }, tbar: [{ text: 'Add', itemId: 'addButton', bind: { disabled: '{readOnly}' } }, { text: 'Remove', itemId: 'removeButton', bind: { disabled: '{readOnly}', text: 'Remove {usersgrid.selection.name}' } }], columns: [{ dataIndex: 'id', header: 'id' }, { dataIndex: 'name', header: 'name' }] });
      
      







読み取り専用モードの設定
 readOnlyButton_click: function (self) { this.down('usersgrid').getViewModel().set('readOnly', self.pressed); }
      
      







ずっと良く見えますよね? 特に、readOnly以外にさらに多くの入力パラメーターが存在する可能性があると想像する場合、その差は非常に大きくなります。



これらの例を比較すると、いくつか質問があります:



質問1. ViewModelを作成する場所はどこですか? 外部コンテナに記述できますか?



-一方では可能ですが、強力な接続を取得します。このコンポーネントを別の場所に移動するたびに、新しいコンテナのViewModelにreadOnlyプロパティを追加する必要があります。 間違いを犯すのは非常に簡単であり、一般に、親コンテナは、それに追加されるコンポーネントの内部を知るべきではありません。



質問2.リファレンスとは何ですか? なぜコンポーネント内に登録したのですか?



-参照は、ViewModelのidコンポーネントに類似しています。 [削除]ボタンには選択したユーザーの名前へのバインドがあるため、登録しましたが、参照を指定しないと機能しません。



質問3.これを行うのは正しいですか? 1つのコンテナに2つのインスタンスを追加したい場合-参照が1つになりますか?



-はい、これは間違いです。 これを解決する方法について考える必要があります。



質問4.外部からコンポーネントのViewModelにアクセスするのは正しいですか?



-一般に、それは機能しますが、これはコンポーネントの内部へのアピールです。 私は、理論的には、彼がViewModelを持っているかどうかに興味があるべきではありません。 その状態を変更したい場合は、対応するセッターをかつて意図したとおりに呼び出す必要があります。



質問5.構成プロパティを使用し、同時にそれらの値にバインドすることは可能ですか? 結局のところ、このケースのドキュメントにはpublishesプロパティがありますか?



-あなたはできます、それは良いアイデアです。 もちろん、バインディングで参照を明示的に指定することに関する問題を除きます。 この場合のreadOnlyモード設定は、例1と同じになります-パブリックセッターを使用:



例3. Fiddle.view.UsersGrid
 Ext.define('Fiddle.view.UsersGrid', { extend: 'Ext.grid.Panel', xtype: 'usersgrid', reference: 'usersgrid', viewModel: { }, config: { readOnly: false }, publishes: ['readOnly'], tbar: [{ text: 'Add', itemId: 'addButton', bind: { disabled: '{usersgrid.readOnly}' } }, { text: 'Remove', itemId: 'removeButton', bind: { disabled: '{usersgrid.readOnly}', text: 'Remove {usersgrid.selection.name}' } }], columns: [{ dataIndex: 'id', header: 'id' }, { dataIndex: 'name', header: 'name' }] });
      
      







Sencha Fiddleでの眺め



他の何か



これは最後の質問に関係します。 外部コンテナから内部コンポーネントのプロパティ(テーブルの選択された行など)にバインドする場合-バインドは機能しません( 証明 )。 これは、内部コンポーネントが独自のViewModelを持つとすぐに発生します-プロパティの変更はその内部でのみ(より正確には、最初の階層で)公開されます。 公式フォーラムでは、この質問が何度も提起されました。沈黙しながら、登録されたリクエスト(EXTJS-15503)のみがあります。 つまり、この観点からKDPVの写真を見ると、次のことがわかります。







つまり コンテナ1は、コンテナ2を除くすべての内部コンポーネントにバインドできます。これは、コンテナ3と同じです。すべてのコンポーネントは、独自に始まるViewModel階層の最初のプロパティの変更のみを発行します。



情報が多すぎる? それを理解してみましょう。








パート2。







警告 以下に説明するソリューションは実験的なものです。 下位互換性はすべての場合に保証されるわけではないため、注意して使用してください。 コメント、修正、その他のヘルプを歓迎します。 行こう!



したがって、MVVMを使用したコンポーネント開発のビジョンを最初に定式化したいと思います。



  1. コンポーネントの状態を変更するには、構成プロパティとそのパブリックセッターを使用します。
  2. 独自の構成プロパティ(コンポーネント内)にバインドする機能があります。
  3. 独自のViewModelがあるかどうかに関係なく、外部からコンポーネントのプロパティにバインドする機能があります。
  4. データViewModel'eyの階層内での名前の一意性について考えないでください。




修正番号1。 変更を公開します



たとえば、ポイント3からより簡単なものから始めましょう。ここでのポイントは、 Ext.mixin.Bindable



不純Ext.mixin.Bindable



とそのpublishStateメソッドです。 内部を見ると、変更がViewModelで公開されていることがわかります。ViewModelは階層の最初です。 親ViewModelにもこれを認識させましょう:



 publishState: function (property, value) { var me = this, vm = me.lookupViewModel(), parentVm = me.lookupViewModel(true), path = me.viewModelKey; if (path && property && parentVm) { path += '.' + property; parentVm.set(path, value); } Ext.mixin.Bindable.prototype.publishState.apply(me, arguments); }
      
      









Sencha Fiddleのデモ



修正番号2。 独自の構成プロパティに関連付けられています



パラグラフ2に関して。 外部からコンポーネントのプロパティにアタッチする機会があるが、内部からアタッチする機会がないことは不公平に思えます。 むしろ、 reference





示す reference





-可能ですが、これはあまり美しいオプションではないと判断したので、少なくとも手動でより良い方法を実行できます。



Fiddle.view.UsersGrid
 Ext.define('Fiddle.view.UsersGrid', { extend: 'Ext.grid.Panel', xtype: 'usersgrid', viewModel: { data: { readOnly: false, selection: null } }, config: { readOnly: false }, tbar: [{ text: 'Add', itemId: 'addButton', bind: { disabled: '{readOnly}' } }, { text: 'Remove', itemId: 'removeButton', bind: { disabled: '{readOnly}', text: 'Remove {selection.name}' } }], // ... updateReadOnly: function (readOnly) { this.getViewModel().set('readOnly', readOnly); }, updateSelection: function (selection) { this.getViewModel().set('selection', selection); } });
      
      







Sencha Fiddleデモ



良く見えますよね? 外側では指示reference



とバインドし、内側-なしでバインドします。 現在、コンポーネントコードは変更されていません。 さらに、2つのコンポーネントを1つのコンテナに追加し、名前をreference





reference





-そして、すべてが動作します!



自動化する? 前のpublishState



メソッドに追加します。



 if (property && vm && vm.getView() == me) { vm.set(property, value); }
      
      





以上です。 構成プロパティへのバインディングがどの程度簡潔になったかを評価します。



Fiddle.view.UsersGrid
 Ext.define('Fiddle.view.UsersGrid', { extend: 'Ext.grid.Panel', xtype: 'usersgrid', viewModel: { }, config: { readOnly: false }, publishes: ['readOnly'], tbar: [{ text: 'Add', itemId: 'addButton', bind: { disabled: '{readOnly}' } }, { text: 'Remove', itemId: 'removeButton', bind: { disabled: '{readOnly}', text: 'Remove {selection.name}' } }], columns: [{ dataIndex: 'id', header: 'id' }, { dataIndex: 'name', header: 'name' }] });
      
      







Ext.ux.mixin.Bindable
 /* global Ext */ /** * An override to notify parent ViewModel about current component's published properties changes * and to make own ViewModel contain current component's published properties values. */ Ext.define('Ext.ux.mixin.Bindable', { initBindable: function () { var me = this; Ext.mixin.Bindable.prototype.initBindable.apply(me, arguments); me.publishInitialState(); }, /** Notifying both own and parent ViewModels about state changes */ publishState: function (property, value) { var me = this, vm = me.lookupViewModel(), parentVm = me.lookupViewModel(true), path = me.viewModelKey; if (path && property && parentVm) { path += '.' + property; parentVm.set(path, value); } Ext.mixin.Bindable.prototype.publishState.apply(me, arguments); if (property && vm && vm.getView() == me) { vm.set(property, value); } }, /** Publish initial state */ publishInitialState: function () { var me = this, state = me.publishedState || (me.publishedState = {}), publishes = me.getPublishes(), name; for (name in publishes) { if (state[name] === undefined) { me.publishState(name, me[name]); } } } }, function () { Ext.Array.each([Ext.Component, Ext.Widget], function (Class) { Class.prototype.initBindable = Ext.ux.mixin.Bindable.prototype.initBindable; Class.prototype.publishState = Ext.ux.mixin.Bindable.prototype.publishState; Class.mixin([Ext.ux.mixin.Bindable]); }); });
      
      







Sencha Fiddleのデモ



修正番号3。 ViewModelとコンポーネントの分離



最も難しい: 段落4 。 実験の純度のために、以前の修正は使用されません。 指定:同じ構成プロパティを持つ2つのネストされたコンポーネント-color それぞれがViewModelを使用してこの値にバインドします。 必須:内部コンポーネントのプロパティを外部のプロパティにバインドします。 やってみますか?



Fiddle.view.OuterContainer
 Ext.define('Fiddle.view.OuterContainer', { // ... viewModel: { data: { color: null } }, config: { color: null }, items: [{ xtype: 'textfield', fieldLabel: 'Enter color', listeners: { change: 'colorField_change' } }, { xtype: 'displayfield', fieldLabel: 'Color', bind: '{color}' }, { xtype: 'innercontainer', bind: { color: '{color}' } }], colorField_change: function (field, value) { this.setColor(value); }, updateColor: function (color) { this.getViewModel().set('color', color); } })
      
      







Fiddle.view.InnerContainer
 Ext.define('Fiddle.view.InnerContainer', { // ... viewModel: { data: { color: null } }, config: { color: null }, items: [{ xtype: 'displayfield', fieldLabel: 'Color', bind: '{color}' }], updateColor: function (color) { this.getViewModel().set('color', color); } })
      
      







Sencha Fiddleのデモ







シンプルに見えますが、機能しません。 なんで? よく見ると、次の記録形式はまったく同じだからです。



オプション1
 Ext.define('Fiddle.view.OuterContainer', { // ... viewModel: { data: { color: null } }, items: [{ xtype: 'innercontainer', bind: { color: '{color}' } }] // ... })
      
      





 Ext.define('Fiddle.view.InnerContainer', { // ... viewModel: { data: { color: null } }, config: { color: null }, items: [{ xtype: 'displayfield', fieldLabel: 'Color', bind: '{color}' }] // ... })
      
      









オプション2
 Ext.define('Fiddle.view.OuterContainer', { // ... viewModel: { data: { color: null } }, items: [{ xtype: 'innercontainer' }] // ... })
      
      





 Ext.define('Fiddle.view.InnerContainer', { // ... viewModel: { data: { color: null } }, config: { color: null }, bind: { color: '{color}' }, items: [{ xtype: 'displayfield', fieldLabel: 'Color', bind: '{color}' }] // ... })
      
      









注意、質問! 内部コンテナでバインドするViewModelのcolor



プロパティに? 奇妙なことに、両方の場合-内部へ。 同時に、ドキュメントとヘッダーの図から判断すると、ViewModelデータと外部コンテナーは、ViewModelデータと内部コンテナーのプロトタイプです。 そして以来 最後のcolor



値が再定義され、プロトタイプの値が変更されても、継承者( null



)の値は同じままです。 つまり 原則として、グリッチはありません-そうすべきです。



どうすれば状況から抜け出すことができますか? 最も明白なことは、内部ViewModelからcolor



を削除することです。 次に、 updateColor



ハンドラーも削除する必要があります。 そして、構成プロパティも炉内にあります! 親コンテナが常にcolor



プロパティを持つViewModelを持つことを期待しましょう。



かどうか? 希望は私たちが扱っているものではありません。 別のオプションは、すべての構成プロパティ(およびViewModelフィールド)の名前を変更して、重複がないようにすることです(理論上): outerContainerColor



およびinnerContainerColor



。 しかし、これも信頼できません。 大規模なプロジェクトでは、非常に多くの名前があり、実際にはあまりうまく機能しません。



外部コンテナを記述するときに、なんらかの方法でバインディングを指定するのは素晴らしいことです。



 Ext.define('Fiddle.view.OuterContainer', { viewModel: { data: { color: null } }, items: [{ xtype: 'innercontainer', bind: { color: '{outercontainer.color}' //   } }] })
      
      







私は苦しむことはありません、これも行うことができます



Ext.ux.app.SplitViewModel + Ext.ux.app.bind.Template
 /** An override to split ViewModels data by their instances */ Ext.define('Ext.ux.app.SplitViewModel', { override: 'Ext.app.ViewModel', config: { /** @cfg {String} ViewModel name */ name: undefined, /** @cfg {String} @private name + sequential identifer */ uniqueName: undefined, /** @cfg {String} @private uniqueName + nameDelimiter */ prefix: undefined }, nameDelimiter: '|', expressionRe: /^(?:\{[!]?(?:(\d+)|([a-z_][\w\-\.|]*))\})$/i, uniqueNameRe: /-\d+$/, privates: { applyData: function (newData, data) { newData = this.getPrefixedData(newData); data = this.getPrefixedData(data); return this.callParent([newData, data]); }, applyLinks: function (links) { links = this.getPrefixedData(links); return this.callParent([links]); }, applyFormulas: function (formulas) { formulas = this.getPrefixedData(formulas); return this.callParent([formulas]); }, bindExpression: function (path, callback, scope, options) { path = this.getPrefixedPath(path); return this.callParent([path, callback, scope, options]); } }, bind: function (descriptor, callback, scope, options) { if (Ext.isString(descriptor)) { descriptor = this.getPrefixedDescriptor(descriptor); } return this.callParent([descriptor, callback, scope, options]); }, linkTo: function (key, reference) { key = this.getPrefixedPath(key); return this.callParent([key, reference]); }, get: function (path) { path = this.getPrefixedPath(path); return this.callParent([path]); }, set: function (path, value) { if (Ext.isString(path)) { path = this.getPrefixedPath(path); } else if (Ext.isObject(path)) { path = this.getPrefixedData(path); } this.callParent([path, value]); }, applyName: function (name) { name = name || this.type || 'viewmodel'; return name; }, applyUniqueName: function (id) { id = id || Ext.id(null, this.getName() + '-'); return id; }, applyPrefix: function (prefix) { prefix = prefix || this.getUniqueName() + this.nameDelimiter; return prefix; }, /** Apply a prefix to property names */ getPrefixedData: function (data) { var name, newName, value, result = {}; if (!data) { return null; } for (name in data) { value = data[name]; newName = this.getPrefixedPath(name); result[newName] = value; } return result; }, /** Get a descriptor with a prefix */ getPrefixedDescriptor: function (descriptor) { var descriptorParts = this.expressionRe.exec(descriptor); if (!descriptorParts) { return descriptor; } var path = descriptorParts[2]; // '{foo}' -> 'foo' descriptor = descriptor.replace(path, this.getPrefixedPath(path)); return descriptor; }, /** Get a path with a correct prefix Examples: foo.bar -> viewmodel-123|foo.bar viewmodel|foo.bar -> viewmodel-123|foo.bar viewmodel-123|foo.bar -> viewmodel-123|foo.bar (no change) */ getPrefixedPath: function (path) { var nameDelimiterPos = path.lastIndexOf(this.nameDelimiter), hasName = nameDelimiterPos != -1, name, isUnique, vmUniqueName, vm; if (hasName) { // bind to a ViewModel by name: viewmodel|foo.bar name = path.substring(0, nameDelimiterPos + this.nameDelimiter.length - 1); isUnique = this.uniqueNameRe.test(name); if (!isUnique) { // replace name by uniqueName: viewmodel-123|foo.bar vm = this.findViewModelByName(name); if (vm) { vmUniqueName = vm.getUniqueName(); path = vmUniqueName + path.substring(nameDelimiterPos); } else { Ext.log({ level: 'warn' }, 'Cannot find a ViewModel instance by a specifed name/type: ' + name); } } } else { // bind to this ViewModel: foo.bar -> viewmodel-123|foo.bar path = this.getPrefix() + path; } return path; }, /** Find a ViewModel by name up by hierarchy @param {String} name ViewModel's name @param {Boolean} skipThis Pass true to ignore this instance */ findViewModelByName: function (name, skipThis) { var result, vm = skipThis ? this.getParent() : this; while (vm) { if (vm.getName() == name) { return vm; } vm = vm.getParent(); } return null; } }); /** This override replaces tokenRe to match a token with nameDelimiter */ Ext.define('Ext.ux.app.bind.Template', { override: 'Ext.app.bind.Template', tokenRe: /\{[!]?(?:(?:(\d+)|([a-z_][\w\-\.|]*))(?::([a-z_\.]+)(?:\(([^\)]*?)?\))?)?)\}/gi });
      
      









ここで、これを書きます(予約されているため、ポイントではなく異なる文字のみ):



 Ext.define('Fiddle.view.OuterContainer', { viewModel: { name: 'outercontainer', data: { color: null } }, items: [{ xtype: 'innercontainer', bind: { color: '{outercontainer|color}' } }] })
      
      





Sencha Fiddleのデモ







つまり より具体的なbind



をViewModelの名前で登録しました。 ViewModelコードを別のファイルにする場合、名前は省略できます-aliasから取得されます。 すべて、これ以上の変更は必要ありません。 プレフィックスなしで、古い方法でViewModelにバインドできます。 独自のViewModelを持つ(または表示される)ネストされたコンポーネントに対して指定します。



この拡張機能の内部では、名前( name



またはalias



)と一意のid



(コンポーネントの場合)で構成されるプレフィックスがViewModelフィールドに追加されます。 次に、コンポーネントの初期化時に、すべてのバインディングの名前に追加されます。



それは何を与えますか?



ViewModelデータは階層的に分割されます。 バインディングでは、ViewModelが参照しているプロパティで具体的に表示されます。 これで、ViewModel階層内でのプロパティの複製について心配する必要がなくなりました。 親コンテナを見なくても、再利用可能なコンポーネントを作成できます。 複雑なコンポーネントの以前の修正に関連して、コードの量は大幅に削減されます。



№№1-3を修正した最後の例



ただし、この段階では、下位互換性は部分的に失われます。 つまり コンポーネントを開発するときに、親コンポーネントのViewModelにあるプロパティの存在に依存している場合、最後の修正によりすべてが壊れます。親ViewModelの名前/エイリアスに対応するバインディングにプレフィックスを追加する必要があります。



合計



拡張機能のソースコードはGitHubにあります。ようこそ:

github.com/alexeysolonets/extjs-mvvm-extensions



それらはいくつかのプロジェクトで使用されています-飛行は通常よりも多くなっています。 より少ないコードを書くことに加えて、コンポーネントがどのように接続されているかについての明確な理解があります。



私にとっては、1つの質問がありoverride



すべてのViewModelに作用するグローバルな形式で最後の拡張機能を残す( override



)か、それを継承元のクラスとしてレンダリングするか? 2番目の解決策はより民主的なもののようですが、それはさらに混乱を招くでしょうか? 一般的に、この質問は未解決のままです。



MVVMで開発する際の微妙な違いは何ですか? 話し合いますか?



All Articles