Pull to refresh

ExtJS – учимся правильно писать компоненты

Reading time11 min
Views25K
Хочу открыть небольшой цикл статей посвященный проблеме создания custom-компонентов в ExtJS. В них хочу поделится с читателями Хабра своим опытом в данной области, опишу подробно все тонкости данного процесса, на что следует всегда обращать внимание, какие ошибки подстерегают начинающих программистов и как их можно избежать.

Рано или поздно приходит время, когда стандартные компоненты ExtJS не могут удовлетворить потребностей разработчика. Или же, в процессе рефакторинга приложения возникает необходимость вынести часть интерфейса (несколько компонентов, форму, таблицу, вкладку) в отдельный компонент. В обоих случаях приходится прибегать к созданию custom-компонентов.

Азы данного процесса уже много раз обсуждались и описывались, расписывать их я не буду, а изображу схематически:

ищем подходящий компонент-прародитель –-> наследуем его при помощи Ext.extend –-> регистрируем xtype при помощи Ext.reg

Но за кажущейся простотой скрывается множество нюансов. В первую очередь – как подобрать подходящего прародителя? Начинающие разработчики пользуются следующим подходом – подбирают наследуемый компонент так, чтобы написать в итоге как можно меньше кода, причем – исключительно в рамках знакомых им конструкций. Их пугают onRender, создание элементов, навешивание обработчиков событий. Не спрою, в определенных случаях такой подход, конечно правильный и оправдывает свою простоту. Надо поле с кнопкой рядом – наследуйте Ext.form.TriggerField, надо поле с выпадающим списком – наследуйте Ext.from.Combobox, надо встроить визуальный редактор – наследуйте Ext.form.TextArea. Но бывают и совсем «нештатные» ситуации, в которых выбор наследуемого компонента надо выполнять тщательно и обдуманно.

Рассмотрим следующий практический пример. Для админ-панели одного сайта с видео галереей мне необходимо было создать элемент управления для ввода длительности ролика. Он должен был содержать три поля ввода (часы, минуты, секунды) в одной строке и содержать единый вход/выход (методы setValue/getValue), которые бы оперировали с длительностью в секундах.

Полтора года назад, когда я был еще начинающим ExtJS-разработчиком, я бы решал данную проблему так:
  • унаследовал бы компонент от Ext.Panel
  • используя ColumnLayout вывел бы в нем три поля в трех колонках
  • написал бы методы getValue/setValue, обращаясь к полям через зубодробительные конструкции вроде this.items.items[0].items.items[0].getValue() …


Да, компонент бы работал, отдавал/устанавливал значения. Правда, его код был бы до ужаса некрасивый, а метод getValue () постоянно бы обращался к полям и пересчитывал общую длительность (даже если бы значения в полях не менялись). Но это – еще полбеды. В будущем, когда возникла бы необходимость сделать валидацию формы или воспользоваться методами сериализации/загрузки форм (getValues/setValues, loadRecord/updateRecord), я бы неизбежно столкнулся с проблемами. Форма просто бы «забывала» про существование компонента как такового, упорно не признавала бы его своим полем. В итоге пришлось бы писать еще кучу «костылей», копи-пастить код из Ext.form.Field, чтобы заставить компонент вменяемо работать как поле формы.

Поэтому в настоящее время я придерживаюсь следующего принципа: любой компонент, который должен будет работать как поле формы и участвовать в процессах сериализации и валидации, обязан быть унаследованным исключительно от Ext.form.Field или любого из его потомков.

Для начала создадим новый компонент, унаследовав Ext.form.Field:

  1. Ext.ux.TimeField = Ext.extend(Ext.form.Field, {
  2.  
  3.  
  4. });
  5.  
  6. Ext.reg('admintimefield', Ext.Admin.TimeField);
* This source code was highlighted with Source Code Highlighter.


Каждое поле формы по умолчанию рендерит свой элемент. В стандартных компонентах-полях формы это либо поле ввода либо checkbox. Элемент поля ввода сохраняется в свойстве el после рендера. Так же он автоматически меняет размер при изменении размеров контейнера компонента.

Поскольку у нас компонент содержит внутри сразу три поля, мы будем создавать в качестве элемента по умолчанию div, в который будут «завернуты» наши три поля и их label'ы. Чтобы изменить тэг и свойства элемента по умолчанию, предопределим свойство defaultAutoCreate:

  1. Ext.ux.TimeField = Ext.extend(Ext.form.Field, {
  2.  
  3.   defaultAutoCreate : {tag: 'div', 'class' : 'time-field-wrap'},
  4.  
  5. .................................................................
* This source code was highlighted with Source Code Highlighter.


Теперь можно создать внутреннюю структуру («каркас») нашего поля ввода. Выведем в ряд 6 div'ов. Три из них будут контейнерами для элементов управления типа spinner (для ввода часов минут и секунд), а другие три – содержать соответствующие label'ы. Для наглядности будем создавать их не при помощи Ext.DomHelper, а с использованием шаблонизатора Ext.XTemplate. Весь пользовательский рендер помещается в унаследованном методе onRender, после вызова родительского метода:

  1. Ext.Admin.TimeField = Ext.extend(Ext.form.Field, {
  2. timeFieldTpl : new Ext.XTemplate(
  3.    '<div class="hours-ct"></div><div class="timeunittext-ct">ч</div>',
  4.    '<div class="minutes-ct"></div><div class="timeunittext-ct">м</div>',
  5.    '<div class="seconds-ct"></div><div class="timeunittext-ct">с</div>'  
  6.   ),
  7. .................................................................
  8.  
  9. onRender : function(ct, position){
  10.   Ext.Admin.TimeField.superclass.onRender.call(this, ct, position);
  11.   this.el.update(this.timeFieldTpl.apply(this));
  12.  
  13. .................................................................
* This source code was highlighted with Source Code Highlighter.


Чтобы «каркас» компонента располагался так как нам надо – в одной строке, напишем и подключим следующую таблицу css:

  1. div.hours-ct,
  2. div.minutes-ct,
  3. div.seconds-ct,
  4. div.timeunittext-ct {
  5.        display: inline-block;
  6.        width: 10px;
  7. }
  8.  
  9. div.hours-ct,
  10. div.minutes-ct,
  11. div.seconds-ct {
  12.        width: 50px;
  13. }
* This source code was highlighted with Source Code Highlighter.


Для простоты реализации размеры полей я взял фиксированные – 50 пикселов.

«Каркас» компонента готов. Для завершения процедуры рендера остается только создать и отобразить компоненты полей. Сначала найдем при помощи Ext.query DOM-элементы их контейнеров, а потом создадим экземпляры компонентов, указав им делать рендер в соответствующие контейнеры:

  1. onRender : function(ct, position){
  2.   Ext.Admin.TimeField.superclass.onRender.call(this, ct, position);
  3.   this.el.update(this.timeFieldTpl.apply(this));
  4.   Ext.each(['hours', 'minutes', 'seconds'], function (i) {
  5.            this[i+'Ct'] = Ext.query('.' + i + '-ct', this.el.dom)[0];
  6.            this[i+'Field'] = Ext.create({
  7.              xtype: 'spinnerfield',
  8.              minValue: 0,
  9.              maxValue: i=='hours' ? 23 : 59,
  10.              renderTo : this[i+'Ct'],
  11.              width: 45,
  12.              value: 0
  13.            });         
  14.     }, this);
  15. .................................................................
* This source code was highlighted with Source Code Highlighter.


Заметим, что сами компоненты после рендеринга сохраняются в свойствах this.xxxField, что позволяет нам легко и удобно к ним обращаться (вместо зубодробительных конструкций, описанный парой абзацев выше).

Визуальная часть компонента готова, осталось доделать функциональную – методы getValue/setValue и поддержку валидации/сериализации.

Чтобы метод getValue не пересчитывал каждый раз количество секунд, поступим следующим образом:

  • кол-во секунд будет хранится в свойстве value
  • это свойство будет пересчитываться и обновляться тогда и только тогда, когда мы меняем значения в полях ввода
  • метод getValue будет просто отдавать значение свойства value


Добавим в компонент методы

  1. .................................................................
  2. getValue : function(){
  3.    return this.value;
  4. },
  5. getRawValue : function () {
  6.               return this.value;
  7. },
  8. onTimeFieldsChanged : function () {
  9.           this.value = this.hoursField.getValue() * 3600 + this.minutesField.getValue() * 60 + this.secondsField.getValue();
  10.           this.fireEvent('change', this, this.value);
  11. },
  12. .................................................................
* This source code was highlighted with Source Code Highlighter.


а при создании полей ввода установим onTimeFieldsChanged обработчиком всех возможных событий изменения:
  1. .................................................................
  2. this[i+'Field'] = Ext.create({
  3.   xtype: 'spinnerfield',
  4.   minValue: 0,
  5.   maxValue: i=='hours' ? 23 : 59,
  6.   renderTo : this[i+'Ct'],
  7.   width: 45,
  8.   value: 0,
  9.   enableKeyEvents: true,
  10.   listeners : {
  11.          keyup: this.onTimeFieldsChanged,
  12.          spinup: this.onTimeFieldsChanged,
  13.          spindown: this.onTimeFieldsChanged,
  14.         scope: this
  15. }
  16.  
  17. .................................................................
* This source code was highlighted with Source Code Highlighter.


Как видим, при обновлении значения мы так же ретрансилируем полученное от полей ввода событие изменения. Это нам еще пригодится для поддержки валидации.

Для установки значения напишем метод setValue. Мне доводилось работать с многими custom компонентами от сторонних разработчиков и в реализации большинства из них приходилось исправлять один и тот же глюк: ошибку при попытке вызвать setValue если компонент еще не совершил рендер. Разработчики просто забывали проверять это и сразу обращались к свойству this.el (которое еще не было создано). В нашем компоненте мы учтем это, а так же будем дополнительно инициализировать значение нулем, если при создании оно не было указано:
  1. .................................................................
  2. initComponent: function () {
  3.           if (!Ext.isDefined(this.value)) this.value = 0;
  4.           Ext.Admin.TimeField.superclass.initComponent.call(this);
  5. }, 
  6.  
  7. setValue : function (v) {
  8.            var setFn = function (v) {
  9.                   var h = Math.floor(v / 3600),
  10.                     m = Math.floor((v % 3600) / 60),
  11.                     s = v % 60;
  12.                   this.hoursField.setValue(h);
  13.                   this.minutesField.setValue(m);
  14.                   this.secondsField.setValue(s);
  15.            };
  16.     this.value = v;
  17.     if (this.rendered) {
  18.       setFn.call(this, v);
  19.     } else {
  20.            this.on('afterrender', setFn.createDelegate(this, [v]), {single:true});
  21.     }
  22.   },
  23. .................................................................<
* This source code was highlighted with Source Code Highlighter.


Как видите, при попытке установить значение компоненту до рендера оно будет только сохранено в свойстве this.value, а фактическая подстановка нужных значений в поля ввода будет отложена до окончательного рендера компонента (путем установки одноразового обработчика события afterrender)

И для придания компоненту «товарного вида» остается только позаботится о валидации и сериализации.
Для реализации валидации мы пойдем стандартным путем Ext.from.Field, а именно:

  • укажем событие при котором поле будет ревалидироватся (change)
  • установим мониторинг валидации по нужному нам события в initEvents
  • преопределим метод validateValue
  • внесем изменения в CSS


  1. ................................................
  2. validationEvent : 'change',
  3. ................................................
  4.   initEvents : function () {
  5.      Ext.ux.TimeField.superclass.initEvents.call(this);
  6.      if (this.validationEvent !== false && this.validationEvent != 'blur'){
  7.      this.mon(this, this.validationEvent, this.validate, this, {buffer: this.validationDelay});
  8.    }
  9.   }, 
  10. ................................................
  11.   validateValue : function(value) {
  12.     if (this.allowBlank !== false) {
  13.        return true;
  14.     } else {
  15.      if (Ext.isDefined(value) && value != '' && value != '0' && value > 0) {
  16.         this.clearInvalid();
  17.         return true;     
  18.      } else {
  19.         this.markInvalid(this.blankText);
  20.         return false;
  21.      }
  22.     }
  23.   },
* This source code was highlighted with Source Code Highlighter.


  1. .time-field-wrap.x-form-invalid {
  2.   background: none;
  3.   border: 0px none;
  4. }
  5.  
  6. .time-field-wrap.x-form-invalid .x-form-text {
  7.   background-color:#FFFFFF;
  8.   background-image:url(../../resources/images/default/grid/invalid_line.gif);
  9.   background-position: left bottom;
  10.   border-color:#CC3300;
  11. }
* This source code was highlighted with Source Code Highlighter.


При мониторинге события применяется буферизация. Если мы меняем значение быстрее чем за this.validationDelay (по умолчанию — 250) мсек, то произойдет только один вызов обработчика (на последнее событие серии). Это — стандартный подход к мониторингу событий валидации, он используется во всех компонентах.

Чтобы заставить компонент нормально сериализироватся, придется идти на ухищрения. На данный момент загрузка значений в него будет производится нормально, без проблем будут работать и методы get/setValue. Но при сериализации он будет отдавать вместо одногозначения с кол-вом секунд сразу три значения. Это происходит потому, что в расчете на совместимость со стандартным submit'ом сериализация форм происходит не путем обращения к getValue-методам, а путем выборки из отрендереного HTML-кода элементов форма (<input, <textarea и т.п.) и чтения к их свойства value. Поэтому нам придется создавать и обновлять при любых изменениях значения компонента скрытое поле. Такой же подход, кстати, используется и в реализации Ext.form.Combobox

  1. var setFn = function (v) {
  2. ................................................................
  3.         this.hiddenField.value = v;
  4.       };
  5. ....................................................
  6.   onTimeFieldsChanged : function () {
  7. ..............................................................................
  8.      this.hiddenField.value = this.value;
  9.      this.fireEvent('change', this, this.value);
  10.   },
  11.  
  12.   onRender : function(ct, position){
  13.     Ext.ux.TimeField.superclass.onRender.call(this, ct, position);
  14. ............................................................................................................
  15.     this.hiddenField = this.el.insertSibling({
  16.       tag:'input',
  17.       type:'hidden',
  18.       name: this.name || this.id,
  19.       id: (this.id+'hidden')
  20.     }, 'before', true);
  21.     if (this.value) this.setValue(this.value);
  22.   }
* This source code was highlighted with Source Code Highlighter.


Вот и все. Как видите, создание даже нестандартных компонентов путем наследования Ext.form.Field – не такая сложная задача, как Вам могло казаться на первый взгляд. Созданный нами компонент уместился всего в 99 строчек кода.

Скачать архив с примером можно по ссылке (альтернативная ссылка без дистрибутива ExtJS), а посмотреть демо – здесь.
Tags:
Hubs:
+18
Comments12

Articles