MVVM nuances in Ext JS when developing components

    Hello. A lot of time has 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 encounter 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 was automatically created. Although manually writing all the handlers could have been somewhat tedious, this was a standard approach.

    In the fifth version of Ext JS using MVVM, you could easily get rid of a good part of the routine: delete configuration properties and their handlers, and instead bind to the desired property or formula ViewModel. The code became much smaller, and readability was better.

    But I was worried about the issue of encapsulation. What if during the development process I want to put part 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: access its ViewModel directly, or is it worth using configuration properties and their public setters?

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

    Part 1. Using ViewModel


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

    Example 1. Standard approach


    How would we do this without using MVVM?



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


    Quite verbose, but it is clear: all the logic of the component is inside. You need to make a reservation that you can use ViewControllers, and this will also be considered part of the component, but in the examples I can do without them.

    Example 2. Adding MVVM


    Let's remove the code handlers and replace them with bindings (bind).

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


    It looks much better, right? Especially if you imagine that there can be much more input parameters besides readOnly - then the difference will be enormous.

    Comparing these examples, some questions beg:

    Question 1. Where should we create the ViewModel? Could it be described in an external container?

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

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

    - Reference is an analogue of the id component in the ViewModel. We registered it because the Remove button has a binding to the name of the selected user, and without specifying the reference this will not work.

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

    - Yes, and this is certainly wrong. We need to think about how to solve this.

    Question 4. Is it right to access the ViewModel of the component from the outside?

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

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

    - You can and that's a good idea. Except, of course, problems with explicitly specifying reference in the binding. The readOnly mode setting in this case will be the same as in Example 1 - through 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 at 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 - property changes are published only in it (or, more precisely, in the first hierarchy). At the official forum, this question was raised several times - and while silence, there is only a registered request (EXTJS-15503). That is, if you look at the picture from the KDPV from this point of view, it turns out this:



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

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




    Part 2. To work!




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

    So, for a start I would like to formulate my vision of component development with MVVM:

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


    Fix number 1. We publish changes up


    Let's start with something simpler, for example, from point 3. The point here is the impurity class Ext.mixin.Bindableand 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 the parent ViewModel aware of 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 are attached to our own configuration properties


    Regarding paragraph 2 . It seems unfair that there is an opportunity to get attached to the properties of the component from the outside, but not from the inside. Rather, with an indication - it is possible, but since we decided that this is not a very beautiful option, then at least we can do better manually: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 Demo

    Looks Better, Right? Outside we attach ourselves with an indication reference, and inside - without. Now, whatever it may be, the component code does not change. Moreover, now we can add two components to one container, give them our names - and everything will work! Automate? Add to the previous method :reference


    publishState

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

    That's all. Evaluate how concise the bindings to your configuration properties have become:

    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. Separating ViewModels and Components


    The most difficult: paragraph 4 . For the purity of the experiment, the previous fixes are not used. Given: two nested components with the same configuration property - color. Each uses a ViewModel to bind to this value. Required: to bind the property of the internal component to the property of the external. Will we 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 recording forms 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, a question! To the property colorwhose ViewModel we bind 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 ViewModel data and the external container are the prototype for the ViewModel data and the internal one. And since the latter has a value overridden color, then when the value of the prototype changes, it remains the same for the heir ( null). Those. In principle, there is no glitch - it should be so.

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

    Or not? Hope is not what we are dealing with. Another option is to rename all configuration properties (and ViewModel fields) so that there is no duplication (in theory): outerContainerColorand innerContainerColor. But this is also unreliable. In large projects, there are so many names, and indeed it doesn’t work very well.

    It would be great, when describing the external container, to specify the binding somehow like this:

    Ext.define('Fiddle.view.OuterContainer', {
        viewModel: {
            data: {
                color: null
            }
        },
        items: [{
            xtype: 'innercontainer',
            bind: {
                color: '{outercontainer.color}' // с префиксом
            }
        }]
    })
    


    I will not torment, this 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 this (only a different character instead of a point, because 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 registered a more specific one bindwith the name of the ViewModel. When making ViewModel’s code in a separate file, the name can be omitted - it will be taken from alias. All, no more changes are required. You can bind to your ViewModel in the old fashion without a prefix. We specify it for nested components that have (or may appear) their own ViewModel.

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

    What does it give?


    ViewModel data will be hierarchically divided. In the bindings it will be specifically seen on the property whose ViewModel they are referencing. Now you don’t have to worry about duplicating properties inside the ViewModel hierarchy. You can write reusable components without looking at the parent container. In conjunction with previous fixes in complex components, the amount of code is drastically reduced.

    The last example with fixes No. 1-3.

    But at this stage, backward compatibility is partially lost. Those. if you, when developing components, relied on the presence of some properties in the ViewModel of the 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 of the extensions is on GitHub, welcome:
    github.com/alexeysolonets/extjs-mvvm-extensions

    They are used in several projects - the flight is more than normal. In addition to writing less code, a clearer understanding of how the components are connected has appeared - everything has become crystal clear, the head no longer hurts and dandruff has disappeared .

    For myself, there is one question: to leave the last extension in the form of a global one, which acts on all ViewModel'i ( override), or make it as a class from which to inherit? The second solution seems to be more democratic, but will it bring more confusion? In general, this question is still open.

    What were your nuances when developing with MVVM? Will we discuss it?

    Also popular now: