Хочу открыть небольшой цикл статей посвященный проблеме создания custom-компонентов в ExtJS. В них хочу поделится с читателями Хабра своим опытом в данной области, опишу подробно все тонкости данного процесса, на что следует всегда обращать внимание, какие ошибки подстерегают начинающих программистов и как их можно избежать.
Рано или поздно приходит время, когда стандартные компоненты ExtJS не могут удовлетворить потребностей разработчика. Или же, в процессе рефакторинга приложения возникает необходимость вынести часть интерфейса (несколько компонентов, форму, таблицу, вкладку) в отдельный компонент. В обоих случаях приходится прибегать к созданию custom-компонентов.
Азы данного процесса уже много раз обсуждались и описывались, расписывать их я не буду, а изображу схематически:
Но за кажущейся простотой скрывается множество нюансов. В первую очередь – как подобрать подходящего прародителя? Начинающие разработчики пользуются следующим подходом – подбирают наследуемый компонент так, чтобы написать в итоге как можно меньше кода, причем – исключительно в рамках знакомых им конструкций. Их пугают onRender, создание элементов, навешивание обработчиков событий. Не спрою, в определенных случаях такой подход, конечно правильный и оправдывает свою простоту. Надо поле с кнопкой рядом – наследуйте Ext.form.TriggerField, надо поле с выпадающим списком – наследуйте Ext.from.Combobox, надо встроить визуальный редактор – наследуйте Ext.form.TextArea. Но бывают и совсем «нештатные» ситуации, в которых выбор наследуемого компонента надо выполнять тщательно и обдуманно.
Рассмотрим следующий практический пример. Для админ-панели одного сайта с видео галереей мне необходимо было создать элемент управления для ввода длительности ролика. Он должен был содержать три по��я ввода (часы, минуты, секунды) в одной строке и содержать единый вход/выход (методы setValue/getValue), которые бы оперировали с длительностью в секундах.
Полтора года назад, когда я был еще начинающим ExtJS-разработчиком, я бы решал данную проблему так:
Да, компонент бы работал, отдавал/устанавливал значения. Правда, его код был бы до ужаса некрасивый, а метод getValue () постоянно бы обращался к полям и пересчитывал общую длительность (даже если бы значения в полях не менялись). Но это – еще полбеды. В будущем, когда возникла бы необходимость сделать валидацию формы или воспользоваться методами сериализации/загрузки форм (getValues/setValues, loadRecord/updateRecord), я бы неизбежно столкнулся с проблемами. Форма просто бы «забывала» про существование компонента как такового, упорно не признавала бы его своим полем. В итоге пришлось бы писать еще кучу «костылей», копи-пастить код из Ext.form.Field, чтобы заставить компонент вменяемо работать как поле формы.
Поэтому в настоящее время я придерживаюсь следующего принципа: любой компонент, который должен будет работать как поле формы и участвовать в процессах сериализации и валидации, обязан быть унаследованным исключительно от Ext.form.Field или любого из его потомков.
Для начала создадим новый компонент, унаследовав Ext.form.Field:
Каждое поле формы по умолчанию рендерит свой элемент. В стандартных компонентах-полях формы это либо поле ввода либо checkbox. Элемент поля ввода сохраняется в свойстве el после рендера. Так же он автоматически меняет размер при изменении размеров контейнера компонента.
Поскольку у нас компонент содержит внутри сразу три поля, мы будем создавать в качестве элемента по умолчанию div, в который будут «завернуты» наши три поля и их label'ы. Чтобы изменить тэг и свойства элемента по умолчанию, предопределим свойство defaultAutoCreate:
Теперь можно создать внутреннюю структуру («каркас») нашего поля ввода. Выведем в ряд 6 div'ов. Три из них будут контейнерами для элементов управления типа spinner (для ввода часов минут и секунд), а другие три – содержать соответствующие label'ы. Для наглядности будем создавать их не при помощи Ext.DomHelper, а с использованием шаблонизатора Ext.XTemplate. Весь пользовательский рендер помещается в унаследованном методе onRender, после вызова родительского метода:
Чтобы «каркас» компонента располагался так как нам надо – в одной строке, напишем и подключим следующую таблицу css:
Для простоты реализации размеры полей я взял фиксированные – 50 пикселов.
«Каркас» компонента готов. Для завершения процедуры рендера остается только создать и отобразить компоненты полей. Сначала найдем при помощи Ext.query DOM-элементы их контейнеров, а потом создадим экземпляры компонентов, указав им делать рендер в соответствующие контейнеры:
Заметим, что сами компоненты после рендеринга сохраняются в свойствах this.xxxField, что позволяет нам легко и удобно к ним обращаться (вместо зубодробительных конструкций, описанный парой абзацев выше).
Визуальная часть компонента готова, осталось доделать функциональную – методы getValue/setValue и поддержку валидации/сериализации.
Чтобы метод getValue не пересчитывал каждый раз количество секунд, поступим следующим образом:
Добавим в компонент методы
а при создании полей ввода установим onTimeFieldsChanged обработчиком всех возможных событий изменения:
Как види��, при обновлении значения мы так же ретрансилируем полученное от полей ввода событие изменения. Это нам еще пригодится для поддержки валидации.
Для установки значения напишем метод setValue. Мне доводилось работать с многими custom компонентами от сторонних разработчиков и в реализации большинства из них приходилось исправлять один и тот же глюк: ошибку при попытке вызвать setValue если компонент еще не совершил рендер. Разработчики просто забывали проверять это и сразу обращались к свойству this.el (которое еще не было создано). В нашем компоненте мы учтем это, а так же будем дополнительно инициализировать значение нулем, если при создании оно не было указано:
Как видите, при попытке установить значение компоненту до рендера оно будет только сохранено в свойстве this.value, а фактическая подстановка нужных значений в поля ввода будет отложена до окончательного рендера компонента (путем установки одноразового обработчика события afterrender)
И для придания компоненту «товарного вида» остается только позаботится о валидации и сериализации.
Для реализации валидации мы пойдем стандартным путем Ext.from.Field, а именно:
При мониторинге события применяется буферизация. Если мы меняем значение быстрее чем за this.validationDelay (по умолчанию — 250) мсек, то произойдет только один вызов обработчика (на последнее событие серии). Это — стандартный подход к мониторингу событий валидации, он используется во всех компонентах.
Чтобы заставить компонент нормально сериализироватся, придется идти на ухищрения. На данный момент загрузка значений в него будет производится нормально, без проблем будут работать и методы get/setValue. Но при сериализации он будет отдавать вместо одногозначения с кол-вом секунд сразу три значения. Это происходит потому, что в расчете на совместимость со стандартным submit'ом сериализация форм происходит не путем обращения к getValue-методам, а путем выборки из отрендереного HTML-кода элементов форма (<input, <textarea и т.п.) и чтения к их свойства value. Поэтому нам придется создавать и обновлять при любых изменениях значения компонента скрытое поле. Такой же подход, кстати, используется и в реализации Ext.form.Combobox
Вот и все. Как видите, создание даже нестандартных компонентов путем наследования Ext.form.Field – не такая сложная задача, как Вам могло казаться на первый взгляд. Созданный нами компонент уместился всего в 99 строчек кода.
Скачать архив с примером можно по ссылке (альтернативная ссылка без дистрибутива 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:
- Ext.ux.TimeField = Ext.extend(Ext.form.Field, {
-
-
- });
-
- Ext.reg('admintimefield', Ext.Admin.TimeField);
* This source code was highlighted with Source Code Highlighter.Каждое поле формы по умолчанию рендерит свой элемент. В стандартных компонентах-полях формы это либо поле ввода либо checkbox. Элемент поля ввода сохраняется в свойстве el после рендера. Так же он автоматически меняет размер при изменении размеров контейнера компонента.
Поскольку у нас компонент содержит внутри сразу три поля, мы будем создавать в качестве элемента по умолчанию div, в который будут «завернуты» наши три поля и их label'ы. Чтобы изменить тэг и свойства элемента по умолчанию, предопределим свойство defaultAutoCreate:
- Ext.ux.TimeField = Ext.extend(Ext.form.Field, {
-
- defaultAutoCreate : {tag: 'div', 'class' : 'time-field-wrap'},
-
- .................................................................
* This source code was highlighted with Source Code Highlighter.Теперь можно создать внутреннюю структуру («каркас») нашего поля ввода. Выведем в ряд 6 div'ов. Три из них будут контейнерами для элементов управления типа spinner (для ввода часов минут и секунд), а другие три – содержать соответствующие label'ы. Для наглядности будем создавать их не при помощи Ext.DomHelper, а с использованием шаблонизатора Ext.XTemplate. Весь пользовательский рендер помещается в унаследованном методе onRender, после вызова родительского метода:
- Ext.Admin.TimeField = Ext.extend(Ext.form.Field, {
- timeFieldTpl : new Ext.XTemplate(
- '<div class="hours-ct"></div><div class="timeunittext-ct">ч</div>',
- '<div class="minutes-ct"></div><div class="timeunittext-ct">м</div>',
- '<div class="seconds-ct"></div><div class="timeunittext-ct">с</div>'
- ),
- .................................................................
-
- onRender : function(ct, position){
- Ext.Admin.TimeField.superclass.onRender.call(this, ct, position);
- this.el.update(this.timeFieldTpl.apply(this));
-
- .................................................................
* This source code was highlighted with Source Code Highlighter.Чтобы «каркас» компонента располагался так как нам надо – в одной строке, напишем и подключим следующую таблицу css:
- div.hours-ct,
- div.minutes-ct,
- div.seconds-ct,
- div.timeunittext-ct {
- display: inline-block;
- width: 10px;
- }
-
- div.hours-ct,
- div.minutes-ct,
- div.seconds-ct {
- width: 50px;
- }
* This source code was highlighted with Source Code Highlighter.Для простоты реализации размеры полей я взял фиксированные – 50 пикселов.
«Каркас» компонента готов. Для завершения процедуры рендера остается только создать и отобразить компоненты полей. Сначала найдем при помощи Ext.query DOM-элементы их контейнеров, а потом создадим экземпляры компонентов, указав им делать рендер в соответствующие контейнеры:
- onRender : function(ct, position){
- Ext.Admin.TimeField.superclass.onRender.call(this, ct, position);
- this.el.update(this.timeFieldTpl.apply(this));
- Ext.each(['hours', 'minutes', 'seconds'], function (i) {
- this[i+'Ct'] = Ext.query('.' + i + '-ct', this.el.dom)[0];
- this[i+'Field'] = Ext.create({
- xtype: 'spinnerfield',
- minValue: 0,
- maxValue: i=='hours' ? 23 : 59,
- renderTo : this[i+'Ct'],
- width: 45,
- value: 0
- });
- }, this);
- .................................................................
* This source code was highlighted with Source Code Highlighter.Заметим, что сами компоненты после рендеринга сохраняются в свойствах this.xxxField, что позволяет нам легко и удобно к ним обращаться (вместо зубодробительных конструкций, описанный парой абзацев выше).
Визуальная часть компонента готова, осталось доделать функциональную – методы getValue/setValue и поддержку валидации/сериализации.
Чтобы метод getValue не пересчитывал каждый раз количество секунд, поступим следующим образом:
- кол-во секунд будет хранится в свойстве value
- это свойство будет пересчитываться и обновляться тогда и только тогда, когда мы меняем значения в полях ввода
- метод getValue будет просто отдавать значение свойства value
Добавим в компонент методы
- .................................................................
- getValue : function(){
- return this.value;
- },
- getRawValue : function () {
- return this.value;
- },
- onTimeFieldsChanged : function () {
- this.value = this.hoursField.getValue() * 3600 + this.minutesField.getValue() * 60 + this.secondsField.getValue();
- this.fireEvent('change', this, this.value);
- },
- .................................................................
* This source code was highlighted with Source Code Highlighter.а при создании полей ввода установим onTimeFieldsChanged обработчиком всех возможных событий изменения:
- .................................................................
- this[i+'Field'] = Ext.create({
- xtype: 'spinnerfield',
- minValue: 0,
- maxValue: i=='hours' ? 23 : 59,
- renderTo : this[i+'Ct'],
- width: 45,
- value: 0,
- enableKeyEvents: true,
- listeners : {
- keyup: this.onTimeFieldsChanged,
- spinup: this.onTimeFieldsChanged,
- spindown: this.onTimeFieldsChanged,
- scope: this
- }
-
- .................................................................
* This source code was highlighted with Source Code Highlighter.Как види��, при обновлении значения мы так же ретрансилируем полученное от полей ввода событие изменения. Это нам еще пригодится для поддержки валидации.
Для установки значения напишем метод setValue. Мне доводилось работать с многими custom компонентами от сторонних разработчиков и в реализации большинства из них приходилось исправлять один и тот же глюк: ошибку при попытке вызвать setValue если компонент еще не совершил рендер. Разработчики просто забывали проверять это и сразу обращались к свойству this.el (которое еще не было создано). В нашем компоненте мы учтем это, а так же будем дополнительно инициализировать значение нулем, если при создании оно не было указано:
- .................................................................
- initComponent: function () {
- if (!Ext.isDefined(this.value)) this.value = 0;
- Ext.Admin.TimeField.superclass.initComponent.call(this);
- },
-
- setValue : function (v) {
- var setFn = function (v) {
- var h = Math.floor(v / 3600),
- m = Math.floor((v % 3600) / 60),
- s = v % 60;
- this.hoursField.setValue(h);
- this.minutesField.setValue(m);
- this.secondsField.setValue(s);
- };
- this.value = v;
- if (this.rendered) {
- setFn.call(this, v);
- } else {
- this.on('afterrender', setFn.createDelegate(this, [v]), {single:true});
- }
- },
- .................................................................<
* This source code was highlighted with Source Code Highlighter.Как видите, при попытке установить значение компоненту до рендера оно будет только сохранено в свойстве this.value, а фактическая подстановка нужных значений в поля ввода будет отложена до окончательного рендера компонента (путем установки одноразового обработчика события afterrender)
И для придания компоненту «товарного вида» остается только позаботится о валидации и сериализации.
Для реализации валидации мы пойдем стандартным путем Ext.from.Field, а именно:
- укажем событие при котором поле будет ревалидироватся (change)
- установим мониторинг валидации по нужному нам события в initEvents
- преопределим метод validateValue
- внесем изменения в CSS
- ................................................
- validationEvent : 'change',
- ................................................
- initEvents : function () {
- Ext.ux.TimeField.superclass.initEvents.call(this);
- if (this.validationEvent !== false && this.validationEvent != 'blur'){
- this.mon(this, this.validationEvent, this.validate, this, {buffer: this.validationDelay});
- }
- },
- ................................................
- validateValue : function(value) {
- if (this.allowBlank !== false) {
- return true;
- } else {
- if (Ext.isDefined(value) && value != '' && value != '0' && value > 0) {
- this.clearInvalid();
- return true;
- } else {
- this.markInvalid(this.blankText);
- return false;
- }
- }
- },
* This source code was highlighted with Source Code Highlighter.
- .time-field-wrap.x-form-invalid {
- background: none;
- border: 0px none;
- }
-
- .time-field-wrap.x-form-invalid .x-form-text {
- background-color:#FFFFFF;
- background-image:url(../../resources/images/default/grid/invalid_line.gif);
- background-position: left bottom;
- border-color:#CC3300;
- }
* This source code was highlighted with Source Code Highlighter.При мониторинге события применяется буферизация. Если мы меняем значение быстрее чем за this.validationDelay (по умолчанию — 250) мсек, то произойдет только один вызов обработчика (на последнее событие серии). Это — стандартный подход к мониторингу событий валидации, он используется во всех компонентах.
Чтобы заставить компонент нормально сериализироватся, придется идти на ухищрения. На данный момент загрузка значений в него будет производится нормально, без проблем будут работать и методы get/setValue. Но при сериализации он будет отдавать вместо одногозначения с кол-вом секунд сразу три значения. Это происходит потому, что в расчете на совместимость со стандартным submit'ом сериализация форм происходит не путем обращения к getValue-методам, а путем выборки из отрендереного HTML-кода элементов форма (<input, <textarea и т.п.) и чтения к их свойства value. Поэтому нам придется создавать и обновлять при любых изменениях значения компонента скрытое поле. Такой же подход, кстати, используется и в реализации Ext.form.Combobox
- var setFn = function (v) {
- ................................................................
- this.hiddenField.value = v;
- };
- ....................................................
- onTimeFieldsChanged : function () {
- ..............................................................................
- this.hiddenField.value = this.value;
- this.fireEvent('change', this, this.value);
- },
-
- onRender : function(ct, position){
- Ext.ux.TimeField.superclass.onRender.call(this, ct, position);
- ............................................................................................................
- this.hiddenField = this.el.insertSibling({
- tag:'input',
- type:'hidden',
- name: this.name || this.id,
- id: (this.id+'hidden')
- }, 'before', true);
- if (this.value) this.setValue(this.value);
- }
* This source code was highlighted with Source Code Highlighter.Вот и все. Как видите, создание даже нестандартных компонентов путем наследования Ext.form.Field – не такая сложная задача, как Вам могло казаться на первый взгляд. Созданный нами компонент уместился всего в 99 строчек кода.
Скачать архив с примером можно по ссылке (альтернативная ссылка без дистрибутива ExtJS), а посмотреть демо – здесь.