.. / Шаг 4. Шаблон проектирования Observer (Обозреватель) для «большого» приложения ExtJS

  1. ExtJS-large-application

В реально больших приложениях (Rich Applications) заранее неизвестно сколько и каких окон, закладок, табличек и т.п. будет открыто - может быть сто, а может тысяча. И все эти виджеты должны друг с другом как-то взаимодейсвовать. Например, если у меня есть CMS построенная в том интерфейсе который я стырил из ExtJS и в ней открыто куча вкладок с табличками, детализацией, формами редактирования при удалении статьи все открытые виджеты должны отреагировать на произошедшее изменение - всюду где отображается информация об удаленной статье эта информация должна быть удалена. Одним из наиболее часто используемых в таких случаях приемов является введение шаблонов проектирования - Observer (обозреватель).

Основная идея этого шаблона состоит в ослаблении связи между объектами. Т.е. один объект о другом не должен ничего знать (как на у меня на предыдущем шаге табличка знает что-то о редакторе записей). Вместо того чтобы вызывать другой объект и выполнять какие-то его методы, объекты подписываются на получение извещений об определенных событий, которые происходят в других объектах. Подписчик называется наблюдателем, а объект который должен выработать событие называется издателем. Издатель оповещает всех подписчиков о наступлении некоего события и передает необходимые данные. В достоинствах этого шаблона прячется его главный недостаток - когда событие не обрабатывается непонятно на кого грешить - на издателя, который не оповестил о событии, на подписчика, который не принял событие или на обозревателя, который где-то ступил. Но в любом случае минусы перевешивают плюсы. Подробнее теорию и различные варианты реализации можно почитать здесь:

  1. The Observer Pattern in Essential JavaScript Design Patterns
  2. Слабое связывание компонентов в JavaScript. Произвольные события
  3. Свои события или observer на Javascript

Я планирую сделать один общий для всех компонентов глобальный обозреватель, который будет разруливать взаимодействие между всеми компонентами моего "большого" приложения. Аналогичные примеры реализации в ExtJS и их обсуждение можно найти на форуме ExtJS здесь:

  1. Poor-man´s Ext.ux.MsgBus
  2. Application Event (Message) Bus
  3. Ext.ux.BroadcastEvents -application level events (aka broadcasting) v0.5
  4. Mediator Pattern and Observer Pattern: alternative architecture to manage events

Поскольку обозреватель глобальный, и кроме него позже появится еще несколько похожих объектов, я введу в самом начале в индексном файле глобальный объект app, а обозреватель прилеплю к нему.

Код обозревателя, я обозвал его App.Event, очень простой и частично я приведу его код ниже:

app.Event =       /* в index.htm я ввожу глобальный объект var app = {};   */
{
    _observers: [],  // массив подписчиков
    // подписка на событие event объекта obj 
    subscribe: function (event,obj) 
    {
        var c = obj || null;
        if (c){
              this._observers.push({event:event, obj: obj, id:obj.id }); // упаковываю подписчика в массив
        },
    // оповещение подписчиков о наступлении события event
    // при этом подписчикам в обработчик события передаются все необходимые данные.
    fire: function (event, data)
    {   var item;
        for (var i in this._observers) { // перебираю массив подписчиков
           if (this._observers[i].event == event ) // проверяю подписчиков на тип события
            {
                item = this._observers[i];
                item.obj.fireEvent('Ev_'+event, item.obj, data); // вызываю событие с префиксом Ev_
            }
        }
    }
}

Я не стал здесь приводить механизм удаления подписки , если он интересен, можно заглянуть в исходники. На словах поясню принцип действия - удаление unsubsribe я привязал к событию, которое вызывается при удалении любого компонента ExtJS beforedestroy. К примеру как только, когда окно редактирования закрывается и удаляется, перед событием удаления будет вызвано его событие'beforedestroy. При составлении массива подписчиков я создаю обработчик этого события для каждого подписчика. Этот обработчик заставляет каждого подписчика самого себя удалять из списка подписчиков.

Как это все будет работать применительно к моему "большому" приложению? Рассмотрю на примере связи таблицы с редактором, а заодно и со всеми прочими обработчиками action привязанными к табличке. Для начала я вообще выкину код, в котором я вызывал редактор в таблице и сделаю обработчик кликов универсальным - заточеным под абсолютно любой обработчик, который только взбредет в голову клиенту и который будет работать независимо от редактора:

Ext.define("App.Common.CommonGrid",{
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .   
      listeners: { // события
            /* клик мышкой по ячейке таблицы */
            cellclick : function(
                grid,  cell,  columnIndex, record,  node ,  rowIndex , evt){
                col = this.conf.grid.columns[columnIndex];
                if (col.action) { 
                  App.Event.fire(col.action,{
                    grid:grid, rec:record.data, url: grid.store.proxy.url, conf:this.conf, itemId:this.itemId, family:this.family});
                }
            },
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .   

В этом фрагменте кода я переопределил событие нажатие на ячейку таблицы. Я проверяю нет ли привязки какого-нибудь действия к кликнутой ячейке и если это действие есть, я кидаю его глобальному обозревателю. Если это событие кому-то нужно, тот кому оно нужно сам разберется что с этим делать. Что там делается табличке знать необязательно. Таким образом я убил сразу двух зайцев - я сделал практически законченный компонент CommonGrid, в который теперь не нужно пихать код вызова обработчиков, и с другой - связал его со всеми существующими обработчиками которые может быть понадобятся когда-нибудь.

Хотя если подумать, я здесь немного погорячился. Для удаления записи какой-то специальный класс наверное делать не нужно. Но чтобы не думать лишнего, я подпишу класс CommonGrid на получение собственного события удаления записи из таблицы. А заодно подпишу его на событие которое будет сгенерировано после успешного удаления записи и изменения записи: и добавлю его в конструктор initComponent:

initComponent: function() { 
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 
  App.Event.subscribe('del',this);  // Подписка на событие о том что запись надо удалить 
  App.Event.subscribe('deleted',this);  // Подписка на оповещение о том что запись удалена 
  App.Event.subscribe('changed',this);  // Подписка на событие о том что запись изменена. 
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 

Тут может возникнуть вопрос - нахрена две подписки на удаление записи и почему в самом CommonGrid нельзя отработать получение сообщения сервера об удалении записи? А дело в том что одна запись может встречаться сразу в нескольких таблицах-списках на разных вкладках. Например, если у нас в дереве навигации есть рубрикатор статей, то одна и та же статья может выводиться сразу в нескольких рубриках, каждая из которых может быть выведена в разных вкладках. И если на одной из вкладок удалить строку, результат удаления должен отработаться по всем вкладкам сразу. Каждая вкладка подписана на оповещение об удалении и отработает эту команду, если такая строка там будет присутствовать. И далее я приведу код, который должен отрабатывать удаление строки в таблице. Он практически полностью совпадает с предыдущим, но с поправкой что он будет вызван через общий обозреватель:

      listeners: { // события
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 
            /* удаление записи. Подтверждение + отправка данных на сервер
               после этого идет отправка сообщений об удалении другим компонентам */
            Ev_del:function(obj,data){
                  . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .    
                 подготовка данных для запроса сервера. код совпадает с предыдущим шагом
                  . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .    

                  Ext.MessageBox.show({  // диалог подтверждения удаления 
                  . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .    
                          fn: function(btn) {
                                 if (btn == 'yes') {
                                                 Ext.Ajax.request({
                                                    method: 'GET', url : url,
                                                        success : function(response){
                                                            var res = Ext.JSON.decode(response.responseText);
                                                  if (res.success) {
                                                      // Если получено подтверждение об удалении - оповещаю всех через общий обозреватель о том, что запись удалена
                                                      App.Event.fire('deleted',fireParams);
                                                      Ext.MessageBox.alert(
                                                            {    title:'OK!', icon: Ext.MessageBox.INFO,msg: 'Запись удалена',buttons: Ext.Msg.OK}
                                                      );
                                                      return;
                                                  }
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 
            },
            Ev_deleted: function(obj,data) {  //  перерисовка таблицы при удалении
                var rec = this.store.findRecord('id',data.id);
                if (rec) { // если в таблице есть запись с переданным id соответствующая строка будет удалена
                this.store.remove(rec);}
            },

И сразу здесь же, немного опережая события отработаю перерисовку измененных в редакторе строк таблицы, в предположении что такое событие кто-нибудь пришлет.

 Ev_changed: function(obj,data) {  // перерисовка измененной записи
               // нахожу запись в таблице с переданным ID
               var rec = this.store.findRecord('id',data.id);
               // заменяю данные в строке
               if (rec){
               rec.set(data.data);}
            }}

Теперь перейду к редакторам записей. В предыдущем примере окна с редакторами создавались непосредственно в обработчике редактирования в CommonGrid. Теперь такого обработчика нет. Чтобы это исправить, в файле с классом ComoonEditor я создам объект, который будет ловить событие 'edit' сгенерированное в CommonGrid (или еще где-то).

app.Editor = Ext.create('Ext.Component', {    
       initComponent: function() {
                    this.family = 'common';
                    App.Event.subscribe('edit',this);  // Подписка на событие редактирования записи через общий обозреватель приложения
                    Ext.Component.superclass.initComponent.apply(this, arguments);
        },
        listeners :
        {
            Ev_edit  :function(obj,data){
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 
здесь код создания окна с редактором полностью совпадает с тем что было в предыдущем примере, 
за исключением способа вызова - вместо метода CommonGrid обработка события от общего обозревателя
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .                
            },
        }

});

И далее нужно внести два очевидных дополнения в код CommonEditor. Во-первых - отработка события удаления записи. Если получено сообщение о том что текущая запись удалена нужно закрыть окно. Для этого в конструкторе initComponent CommonEditor'а ввожу подписку на события 'deleted'

        initComponent: function() {
                    this.items[0].items=this.form;
                    App.Event.subscribe('deleted',this);  // Подписка на событие удаления записи через общий обозреватель приложения
                    App.Common.CommonEditor.superclass.initComponent.apply(this, arguments);
        },

и реализация удаления самого себя, если запись удалена:

        listeners:{
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 
                Ev_deleted: function(obj,data) {  // удаление окна после удаления записи и подтверждения с сервера
                    // удаление происходит если id записи в редакторе совпадает с тем что прислано.  
                    if (this.data.id == data.id) this.destroy();
                },
        }

Ну и во-вторых, когда после того как запись будет сохранена на сервере и в приложение придет отклик с отредактированными данными (возможно что на сервере в отправленные данные будут внесены исправления типа типографской правки) эти данные должны быть отправлены во все таблицы-списки через событие 'changed':

form.submit({
                                    url:url,
                                    params:{save:1,id:p.conf.id}, // дополнительные данные
                                    success: function(form, action) { // нормальное сохранение
                                      App.Event.fire('changed',{data:action.result.data,id:this.params.id,family:p.family})
                                      var ok=Ext.Msg.alert('Ok!', 'Внесенные изменения сохранены');
                                      myMask.destroy();
                                    },
                                });}

И на этом этот шаг посвященный Обозревателю и переделки говнокода можно считать законченным. Вообще-то и весь этот цикл можно считать законченным, потому что как делать все остальное - создавать и вызывать новые компоненты, и связывать между собой достаточно ясно. Но как-то хочется закончить на красивой цифре. Может быть семерке? Поэтому я продолжу и на следующем шаге сделаю просмотрщики - детализацию записей

Все что получилось аккуратно упаковываю и кладу в архив my_big_application_ExtJS4.zip в директорию step5

  1. 2012-03-22
  2. ExtJS-large-application
  1. Шаг 3. Добавляем функциональность таблицы — delete,edit,view (detail)
  2. Шаг 2. Первое большое приложение
  3. Шаг 1. Зарождение собственного компонента в ExtJS
Go Index Test