Хочу открыть небольшой цикл статей посвященный проблеме создания 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), а посмотреть демо – здесь.