📜 ⬆️ ⬇️

MVVM nuances in Ext JS when developing components

Hello. A lot of time passed since the release of Ext JS 5, where they presented the possibility of developing applications using the MVVM pattern. During this time I managed to face some difficulties that I would like to talk about.

To begin with, in Ext JS 4 (and previously in Sencha Touch) when creating components, their configuration properties were declared in the config object, for each of which a getter and setter were automatically created. Although it might be a bit tedious to manually write all the handlers, this was the standard approach.

In the fifth version of Ext JS, using MVVM, one could easily get rid of a good part of the routine: remove the configuration properties and their handlers, and instead bind to the desired property or formula ViewModel'and. The code became much smaller, and readability better.
')
But I was worried about the issue of encapsulation. What if during the development process I want to bring some of the functionality into a separate component for reuse? Do I need to create my own ViewModel? How to change the state of a component: contact its ViewModels directly, or is it worth using the configuration properties and their public setters?

Thoughts about this and other issues, as well as examples with a file - under the cut.

Part 1. Use the ViewModel


Let's try to create, for example, a table of some users. So that she can add and delete records, but if necessary, switch to read only mode. I also want the delete button to contain the name of the selected user.

Example 1. Standard approach


How would we do this without using MVVM?



View in 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')); } } }); 


Setting Read only mode
 readOnlyButton_click: function (self) { this.down('usersgrid').setReadOnly(self.pressed); } 


Pretty verbose, but understandable: the entire logic of the component is inside. It is necessary to make a reservation that you can use ViewControllers, and this will also be considered part of the component, but in the examples I will do without them.

Example 2. Add MVVM


Remove code handlers and replace them with bind bindings.

View in 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' }] }); 


Setting Read only mode
 readOnlyButton_click: function (self) { this.down('usersgrid').getViewModel().set('readOnly', self.pressed); } 


Looks much better, right? Especially if you imagine that the input parameters other than readOnly can be much larger - then the difference will be enormous.

Comparing these examples, some questions arise:

Question 1. Where were we supposed to create the ViewModel? Was it possible to describe it in an external container?

“On the one hand, it is possible, but then we get a strong connection: every time we transfer this component to another place, we will have to remember to add the readOnly property in the ViewModel and the new container. It is so easy to make a mistake and in general the parent container does not need to know about the internals of the components that are added to it.

Question 2. What is the reference? Why did we register it inside the component?

- Reference is the analog component id in the ViewModel'and. We registered it because for the Remove button there is a binding to the name of the selected user, and without specifying the reference it will not work.

Question 3. Is it right to do this? What if I want to add two instances to one container - will they have one reference?

- Yes, and this is of course wrong. We need to think about how to solve it.

Question 4. Is it correct to access the ViewModel'i component from the outside?

- In general, it will work, but this is again an appeal to the internals of the component. I, in theory, should not be interested in whether it has a ViewModel or not. If I want to change its state, then I have to call the appropriate setter as it was once intended.

Question 5. Is it possible to still use the configuration properties, and at the same time bind to their values? After all, the documentation for this case is the publishes property?

- You can and this is a good idea. Except, of course, problems with explicitly specifying the reference in the binding. Setting the readOnly mode in this case will be the same as in Example 1 — via the public setter:

Example 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' }] }); 


View in Sencha Fiddle

Something else


This concerns the last question. If we bind from the external container to the property of the internal component (for example, to the selected row of the table), the binding will not work ( proof ). This happens as soon as the internal component has its own ViewModel - changes in properties are published only to it (or, more precisely, first in the hierarchy). At the official forum, this question was raised several times - and while silence, there is only a registered requester (EXTJS-15503). Ie, if you look at the picture from the KDPV from this point of view, you get this:



Those. container 1 can bind to all internal components except container 2. That, in turn, is the same, except container 3. Because all components publish property changes only in the first ViewModel hierarchy, starting with its own.

Too much information? Let's try to figure it out.




Part 2. For work!




A WARNING. The solutions described below are experimental. Use them with care, because backward compatibility is not guaranteed in all cases. Comments, corrections and other help are welcome. Go!

So, for starters, I would like to formulate my vision of developing components with MVVM:

  1. To change the state of a component, use configuration properties and their public setters.
  2. Have the ability to bind to their own configuration properties (inside the component).
  3. Have the ability to bind to the properties of the component outside, regardless of whether it has its own ViewModel or not.
  4. Do not think about the uniqueness of names within the data hierarchy of ViewModels.


Fix number 1. Post changes up


Let's start with something simpler, for example, from point 3. Here it’s about the Ext.mixin.Bindable impurity Ext.mixin.Bindable and its publishState method. If you look inside, we will see that the changes are published in the ViewModel, which is the first in the hierarchy. Let's make sure that the parent ViewModel knows about this too:

 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); } 


BeforeAfter

Demo on Sencha Fiddle .

Fix number 2. We bind to our own configuration properties


With regards to paragraph 2 . It seems unfair that there is an opportunity to attach to the properties of the component from the outside, but not from the inside. Rather, with reference
reference
- it is possible, but since we decided that this is not a very beautiful option, then at least manually we can do better:

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 demo

Looks better, right? Outside, we bind with the reference , and from the inside - without. Now whatever it is, the component code does not change. Moreover, now we can add two components to one container, give them our own names reference
reference
- and everything will work!

Automate? Add to the previous publishState method:

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

That's all. Assess how concise bindings have become to their configuration properties:

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]); }); }); 


Demo on Sencha Fiddle .

Fix number 3. Separate ViewModels and Components


The most difficult: point 4 . For the purity of the experiment, the previous fixes are not used. Given: two nested components with the same configuration property - color . Everyone uses the ViewModel to bind to this value. Required: bind the property of the internal component to the property of the external. Let's try?

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); } }) 


Demo on Sencha Fiddle .



It looks simple, but does not work. Why? Because if you look closely, the following forms of recording are absolutely identical:

Option 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}' }] // ... }) 



Option 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}' }] // ... }) 



Attention, question! To the color property of whose ViewModel and we are in the internal container? Oddly enough, in both cases - to the inside. At the same time, judging by the documentation and the picture from the header, the data of the ViewModel and external container are the prototype for the data of the ViewModel and internal. And since the latter has the color value redefined, then when the prototype value changes, the heir has the old value ( null ). Those. In principle, there is no glitch - it should be so.

How can you get out of the situation? The most obvious thing is to remove the color from the internal ViewModel'i. Then we also have to remove the updateColor handler. And the configuration property is also in the firebox! Let's hope that the parent container will always have a ViewModel with the color property.

Or not? Hope is not what we are dealing with. Another option is to reassign all configuration properties (and ViewModel fields) so that there is no duplication (in theory): outerContainerColor and innerContainerColor . But this is also unreliable. In big projects there are so many names, and generally it’s not very beautiful.

Here it would be great, when describing an external container, to specify a binding somehow like this:

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


I will not torment, it can also be done:

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 }); 



Now we write like this (only another symbol instead of a dot, since it is reserved):

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

Demo on Sencha Fiddle .



Those. we wrote a more specific bind with the name of the ViewModel'and. When rendering the ViewModel code in a separate file, the name can be omitted - it will be taken from alias . Everything, no more changes are required. You can attach to your ViewModel in the old fashioned way without a prefix. We specify it for nested components that have (or may appear) their own ViewModel.

Under the hood of this extension, the prefix consisting of its name ( name or alias ) and a unique id (as for components) is added to the fields of the ViewModel. Then, at the time of initialization of the components, it is added to the names of all the bindings.

What does this give?


ViewModels data will be divided by hierarchy. In the bindings it will be concretely visible, on the property of whose ViewModel'and they refer. Now you can not worry about duplicating properties within the hierarchy of ViewModels. You can write reusable components without looking at the parent container. In conjunction with the previous fixes in complex components, the amount of code is reduced drastically.

The last example with fixes №№ 1-3

But at this stage backward compatibility is partially lost. Those. if you, developing components, relied on the presence of some properties in the ViewModel and parent component, then the last fix will break everything for you: you will need to add a prefix to the binding corresponding to the name / alias of the parent ViewModel.

Total


The source code for the extensions is on GitHub, welcome:
github.com/alexeysolonets/extjs-mvvm-extensions

We used them in several projects - the flight is more than normal. Besides the fact that we write less code, a clearer understanding of how the components are connected appeared - everything became crystal clear, the head no longer hurts and dandruff disappeared .

For myself, there is one question: leave the last extension in the form of a global one, which affects all ViewModels and override , or render it as a class from which to inherit? The second solution seems to be more democratic, but will it not cause more confusion? In general, this question is still open.

What were your nuances when developing c MVVM? Discuss?

Source: https://habr.com/ru/post/266773/


All Articles