Всем привет. Прошло немало времени с момента выхода Ext JS 5, где представили возможность разработки приложений с использованием паттерна MVVM. За это время я успел столкнуться с некоторыми трудностями, о которых хотел бы рассказать.Начну с того, что в Ext JS 4 (а предварительно в Sencha Touch) при создании компонентов их конфигурационные свойства объявлялись в объекте config, для каждого из которых автоматически создавался свой getter и setter. Несмотря на то, что вручную писать все обработчики могло быть несколько утомительно, это был стандартный подход.
В пятой же версии Ext JS используя MVVM можно было легко избавиться от доброй части рутины: удалить конфигурационные свойства и их обработчики, а вместо этого привязаться к нужному свойству или формуле ViewModel'и. Кода становилось значительно меньше, а читаемость — лучше.
Но меня беспокоил вопрос инкапсуляции. Что если в процессе разработки часть функциональности я захочу вынести в отдельный компонент для повторного использования? Нужно ли при этом создавать собственную ViewModel? Как изменять состояние компонента: обращаться напрямую к его ViewModel'и или всё-таки стоит использовать конфигурационные свойства и их публичные setter'ы?
Мысли об этом и других вопросах, а также примеры с напильником — под катом.
Часть 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')); } } });
Установка режима Read only
readOnlyButton_click: function (self) { this.down('usersgrid').setReadOnly(self.pressed); }
Довольно многословно, зато понятно: вся логика работы компонента находится внутри. Нужно оговориться, что можно использовать ViewController'ы, и это тоже будет считаться частью компонента, но в примерах я обойдусь без них.
Пример 2. Добавляем MVVM
Уберём обработчики кода и заменим их привязками (bind).
Посмотреть в 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' }] });
Установка режима Read only
readOnlyButton_click: function (self) { this.down('usersgrid').getViewModel().set('readOnly', self.pressed); }
Выглядит значительно лучше, правда? Особенно, если представить, что входных параметров кроме readOnly может быть гораздо больше — тогда разница будет колоссальной.
Сравнивая эти примеры, напрашиваются некоторые вопросы:
Вопрос 1. Где мы должны были создавать ViewModel? Можно ли было описать её во внешнем контейнере?
— С одной стороны, можно, но тогда мы получаем сильную связанность: каждый раз, когда мы переносим этот компонент в другое место, мы будем обязаны не забыть добавить свойство readOnly во ViewModel'и нового контейнера. Так легко ошибиться и вообще родительский контейнер не должен знать о внутренностях компонентов, которые в него добавляются.
Вопрос 2. Что такое reference? Почему мы прописали его внутри компонента?
— Reference — это аналог id компонента во ViewModel'и. Мы прописали его потому что для кнопки Remove стоит привязка к имени выделенного пользователя, а без указания reference это работать не будет.
Вопрос 3. А правильно ли так делать? Что если я захочу добавить два экземпляра в один контейнер — у них будет один reference?
— Да, и это конечно же неправильно. Нужно подумать, как это решить.
Вопрос 4. Правильно ли обращаться к ViewModel'и компонента извне?
— Вообще, работать оно будет, но это опять обращение к внутренностям компонента. Меня, по идее, не должно интересовать, есть у него ViewModel или нет. Если я хочу изменить его состояние, то я должен вызвать соответствующий setter как это и было когда-то задумано.
Вопрос 5. Можно ли всё-таки использовать конфигурационные свойства, и при этом привязываться к их значениям? Ведь в документации для этого случая есть свойство publishes?
— Можно и это хорошая идея. Кроме, конечно, проблемы с явным указанием reference в привязке. Установка режима readOnly в данном случае будет такой же, как и в Примере 1 — через публичный setter:
Пример 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). Т.е, если взглянуть на картинку из КДПВ с этой точки зрения, то получается вот что:

Т.е. контейнер 1 может привязаться ко всем внутренним компонентам, кроме контейнера 2. Тот в свою очередь так же, кроме контейнера 3. Потому что все компоненты публикуют изменения свойств только в первую по иерархии ViewModel, начиная со своей.
Слишком много информации? Попробуем разобраться.
Часть 2. За работу!

ПРЕДУПРЕЖДЕНИЕ. Решения, описанные ниже, носят статус экспериментальных. Используйте их с осторожностью, потому что обратная совместимость гарантируется не во всех случаях. Замечания, исправления и другая помощь приветствуются. Поехали!
Итак, для начала я бы хотел сформулировать своё видение разработки компонентов с MVVM:
- Для изменения состояния компонента использовать конфигурационные свойства и их публичные setter'ы.
- Иметь возможность привязываться к собственным конфигурационным свойствам (внутри компонента).
- Иметь возможность привязываться к свойствам компонента снаружи вне зависимости, есть у него своя ViewModel или нет.
- Не задумываться об уникальности имён внутри иерархии данных ViewModel'ей.
Фикс №1. Публикуем изменения вверх
Начнём с чего-нибудь попроще, например, с пункта 3. Здесь дело в классе-примеси
Ext.mixin.Bindable и его методе publishState. Если заглянуть внутрь, то мы увидим, что изменения публикуются во 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
— можно, но раз мы решили, что это не очень красивый вариант, то как минимум вручную можем сделать лучше: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, а изнутри — без. Теперь каким бы он ни был, код компонента не меняется. Более того, теперь мы можем добавить два компонента в один контейнер, дать им свои названия 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. Для чистоты эксперимента предыдущие фиксы не используем. Дано: два вложенных компонента с одинаковым конфигурационным свойвтвом —
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.
|
|
Вариант 2.
|
|
Внимание, вопрос! К свойству
color чьей ViewModel'и мы биндимся во внутреннем контейнере? Как ни странно, в обоих случаях — к внутренней. При этом, судя по документации и картинке из шапки, данные ViewModel'и внешнего контейнера являются прототипом для данных ViewModel'и внутреннего. А т.к. у последнего переопределено значение color, то при изменении значения у прототипа, у наследника оно остаётся старым (null). Т.е. в принципе, глюка нет — так и должно быть.Как можно выйти из ситуации? Самое очевидное — убрать
color из внутренней ViewModel'и. Тогда нам также придётся убрать обработчик updateColor. И конфигурационное свойство — тоже в топку! Будем надеяться, что в родительском контейнере всегда будет ViewModel со свойством color. Или нет? Надежда — это не то, с чем мы имеем дело. Другой вариант — это переназвать все конфигурационные свойства (и поля 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.Под капотом этого расширения к полям ViewModel'и добавляется префикс, состоящий из её имени (
name или alias) и уникального id (как для компонентов). Затем, в момент инициализации компонентов, он добавляется к названиям всех привязок.Что это даёт?
Данные ViewModel'ей будут разделены по иерархии. В привязках будет конкретно видно, на свойство чьей ViewModel'и они ссылаются. Теперь можно не беспокоиться за дублирование свойств внутри иерархии ViewModel'ей. Можно писать повторно используемые компоненты без оглядки на родительский контейнер. В связке с предыдущими фиксами в сложных компонентах объём кода сокращается радикально.
Последний пример с фиксами №№1-3
Но на этом этапе частично теряется обратная совместимость. Т.е. если вы, разрабатывая компоненты, полагались на присутствие каких-то свойств во ViewModel'и родительского компонента, то последний фикс вам всё сломает: необходимо будет добавить в привязку префикс, соответствующий имени/alias'у родительской ViewModel'и.
Итого
Исходный код расширений лежит на GitHub, добро пожаловать:
github.com/alexeysolonets/extjs-mvvm-extensions
Они применены у нас в нескольких проектах — полёт более чем нормальный. Помимо того, что кода пишем меньше, появилось более чёткое понимание, как связаны компоненты — всё стало кристально ясно, голова уже не болит
Для себя есть один вопрос: оставить последнее расширение в виде глобального, которое действует на все ViewModel'и (
override), или вынести как класс, от которого наследоваться? Второе решение вроде более демократично, но не внесёт ли оно большей путаницы? В общем, пока этот вопрос открытый.Какие у вас были нюансы при разработке c MVVM? Обсудим?


