В этой статье я хочу поделиться опытом избавления от жуткой головной боли, возникшей при разработке веб-приложения для одного маленького, но весьма могучего устройства. Но сначала несколько слов об источнике этой боли — о дизайне.

Дизайн сегодня один из необходимых компонентов любого продукта, а для сайтов и веб-приложений — это самая важная часть. Всё, что находится под капотом, скрыто от глаз клиента. Пользователя не интересует гениальность исполнения движка или уникальность архитектуры: подразумевается само собой, что программа должна работать надежно и безопасно. Современному пользователю необходим стильный графический интерфейс.

Раньше создание такого интерфейса вызывало серьёзную головную боль у программистов, но теперь для избавления от неё выпущено большое количество различных фреймворков и библиотек. Казалось бы — ура, проблема решена! Однако, теперь перед нами встаёт другой вопрос: какой препарат выбрать — пенталгин или панадол? 

Вопрос нелёгкий, и решать, в итоге, вам. Я же расскажу о своём лекарстве: библиотеке EasyUI, предназначенной для создания полноценных одностраничных веб-приложений (SPA) и основанной на jQuery, Angular, Vue и React.

Моё знакомство с EasyUI началось около двух лет назад, когда наша команда приступила к разработке софта для системы управления питанием и его интеллектуального распределения между потребителями. Управляющее устройство имело на борту Linux и кроме распределения питания должно было обмениваться данными с различными периферийными устройствами, уметь контролировать их, а также принимать показания от большого количества (до нескольких сотен) датчиков. Правила управления могли изменяться, позволяя пользователю настроить работу всего комплекса для выполнения необходимых задач. 

Настройка и мониторинг устройства могли вестись через различные протоколы: ssh, snmp, redfish, BACnet, но основным способом общения с ним был http, то есть всем комплексом можно было управлять через обыкновенный веб-браузер. Это широко используемое решение, и оно не сулило никаких проблем. Однако, дьявол всё же основательно порылся в деталях. 

Веб-страница должна была отражать состав комплекса, его внутренние и внешние компоненты и их содержимое в виде дерева разнотипных объектов. Показания датчиков должны были выводиться в виде таблицы («как в Excel'е», — безмятежно улыбался заказчик), причём часть данных (показания, состояния датчиков и т.п.) требовалось непрерывно обновлять, а часть могла изменяться пользователем непосредственно в таблице. Для настройки комплекса было необходимо использовать многоуровневое меню и большое количество модальных диалоговых окон. 

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

В итоге наших поисков, фреймворк EasyUI показался нам наиболее подходящим для решения наших задач. Несколько забегая вперёд, я могу сказать, что это был хороший выбор, несмотря на обнаруженные в ходе реализации проекта недостатки этой библиотеки. 

Итак, что же такое такое EasyUI?

Как я уже упоминал выше, EasyUI представляет собой набор компонентов пользовательского интерфейса, основанных на jQuery, Angular, Vue и React. Мы использовали библиотеку, базирующуюся на jQuery. 

С самого начала мне понравилась возможность создания макета приложения без программирования на javascript. EasyUI для jQuery имеет встроенный парсер, который расширяет HTML-разметку и ассоциирует её с библиотечным кодом. Для этого в классе HTML-элемента достаточно указать наименование компонента, который необходимо применить.  

Например, эта разметка создаст на странице приложения пять зон: шапку и подвал высотой 100 пикселей, и разделённую на три колонки среднюю часть. Левая и правая колонки имеют ширину 100 пикселей, а центральная часть имеет серый фон и занимает всё оставшееся место. Кроме этого, высоту шапки и подвала, ширину левой и правой колонок можно изменять мышью во время работы приложения. Для этого EasyUI создаст на странице приложения специальные разделители.

<body class="easyui-layout">
  <div data-options="region:'north',title:'North Title',split:true"
       style="height:100px;"></div>
  <div data-options="region:'south',title:'South Title',split:true"
       style="height:100px;"></div>
  <div data-options="region:'east',title:'East',split:true"
       style="width:100px;"></div>
  <div data-options="region:'west',title:'West',split:true"
       style="width:100px;"></div>
  <div data-options="region:'center',title:'center title'"
       style="padding:5px;background:#eee;"></div>
</body> 

Конечно, EasyUI позволяет сделать то же самое при помощи javascript

$('body').layout({fit: true}).layout('add', {
  region: 'north', title: 'North Title', split: true, height: 100
}).layout('add', {
  region: 'south', title: 'South Title', split: true, height: 100
}).layout('add', {
  region: 'east', title: 'East Title', split: true, width: 100
}).layout('add', {
  region: 'west', title: 'West Title', split: true, width: 100
}).layout('add', {
  region: 'center', title: 'сenter Title', split: true, widht:100,
  style: {padding: 5, background: '#eee'}
}); 

В результате EasyUI создаст вот такую страницу: 

Пока всё easy, не так ли? Мы можем создать предварительный макет без написания javascript кода, что очень удобно на начальных этапах проектирования приложения. 

А что ещё она умеет? 

Для беглой оценки возможностей EasyUI, достаточно посмотреть на её конструктор тем:

Здесь показаны не все возможности EasyUI, но для первого впечатления вполне достаточно и этого: разметка (layout), панели (panel), многоуровневое меню (menu, menubutton), вкладки (tab), аккордеоны (accordion), календарь (calendar), таблица (datagrid), набор конфигурационных параметров (propertygrid), список (datalist), дерево (tree), диалоги (dialog), формы (form) и их элементы (validatebox, textbox, passwordbox, maskedbox, combobox, tagbox, numberbox, datetimebox, spinner, slider, filebox, checkbox, radiobutton) — и этот перечень далеко не полон. При более глубоком погружении в возможности библиотеки выясняется, что эти компоненты можно расширять и создавать на их основе новые. На сайте проекта есть раздел Extention, на котором представлены некоторые расширения, например, всем известная лента (Ribbon): 

Для демонстрации всех компонентов, реализованных EasyUI, на странице проекта есть специальный раздел с демонстрацией их работы. 

И снова о дизайне 

EasyUI поддерживает изменение стиля оформления пользователем из набора готовых тем прямо во время выполнения приложения. На сайте проекта предлагается собственная коллекция стилей. К сожалению, решение из такого набора не всегда может удовлетворить заказчика. Это, скорее, набор стилей для начала работы, который впоследствии потребует корректировки. Для этого на сайте проекта имеется конструктор тем. Разумеется, всегда можно настроить тему приложения в соответствующей таблице стилей, которые находятся в каталоге themes проекта. Для нашего проекта поддержка возможности изменения темы приложения не требовалась, однако, таблицы стилей были существенно переработаны, чтобы UI соответствовал корпоративному стилю заказчика. 

Создание диалога при помощи EasyUI 

В качестве примера использования библиотеки в проекте я хочу привести код (под катом) для создания диалога настроек HTTP. Параметром функции открытия диалога является иконка, назначенная тому пункту меню, из которого вызывается диалог. 

Код для создания диалога настроек HTTP
(function($) { 
  $.fn.httpConfDlg = function(icon) { 
    var title = _("HTTP Configuration"), me; 
    var succ = _( 
      "HTTP properties have been changed. " + 
      "You need to re-connect your browser " + 
      "according to the new properties." 
    ); 
    var errcode = "System returned error code %1." 
    var errset = _( 
      "Can't set HTTP configuration. " + errcode 
    ); 
    var errget = _( 
      "Can't get HTTP configuration. " + errcode 
    ); 
    var allowed = $.SMR_PRIVILEGE.CHECK( 
      $.SMR_PRIVILEGE.CHANGE_NETWORK_CONFIGURATION 
    ); 
    var buttons = []; 
    if (allowed) { 
      buttons.push({ 
        okButton: true, 
        handler: function() { 
          var ho = $(this.parentElement).api({ 
            fn: $.WAPI.FN_SET_HTTP_PROPERTIES, 
            param: { 
              httpPort: parseInt($('#httpPort').textbox('getValue')), 
              httpsPort: parseInt($('#httpsPort').textbox('getValue')), 
              forceHttps: $.HpiBool($('#forceHttp')[0].checked) 
            }, 
            before: function() { 
              $('body').css('cursor', 'wait'); 
            }, 
            done: function() { 
              $('body').css('cursor', 'default'); 
              me.dialog('close'); 
            }, 
            error: function(err) { 
              if (err.RC == $.WAPI.RC_BAD_RESPONSE) { 
                $.messager.alert( 
                  title, 
                  $.fstr(errset, err.IC), 
                  'error' 
                ); 
                return false; 
              } else if (err.RC == 1003) { 
                ho.api('drop'); 
                $.messager.alert(title, succ, 'info', function() { 
                  $('#sinfo').session('logout'); 
                }); 
                return false; 
              } 
              return true; 
            } 
          }); 
        } 
      }); 
    } 
    buttons.push({cancelButton: true}); 
    return this.each(function() { 
      document.body.appendChild(this); 
      me = $(this).append( 
        '<div id="httpSetting" style="padding: 10px 30px">' + 
        $.fitem('httpPort', _("HTTP port")) + 
        $.fitem('httpsPort', _("HTTPS port")) + 
        $.fcheck('forceHttp', _("Force HTTPS for Web Access")) + 
        '</div>' 
      ); 
      $('#httpPort').textbox({ 
        type: 'text', width: 60, disabled: !allowed 
      }); 
      $('#httpsPort').textbox({ 
        type: 'text', width: 60, disabled: !allowed 
      }); 
      if (!allowed) $('#forceHttp').attr('disabled', 'disabled'); 
        me.mdialog({ 
          title: title, 
          iconCls: icon, 
          width: 320, 
          height: 180, 
          modal: true, 
          buttons: buttons, 
          onOpen: function() { 
            var ho = $(this).api({ 
              fn: $.WAPI.FN_GET_HTTP_PROPERTIES, 
              receive: function(res) { 
                $('#httpPort').textbox('setValue', res.httpPort); 
                $('#httpsPort').textbox('setValue', res.httpsPort); 
                if (res.forceHttps == 1) { 
                  $('#forceHttp').attr('checked', 'checked') 
                } else { 
                  $('#forceHttp').removeAttr('checked')} 
              }, 
              error: function(err) { 
                if (err.RC == $.WAPI.RC_BAD_RESPONSE) { 
                  $.messager.alert( 
                    _("HTTP"), 
                    $.fstr( 
                      errget, 
                      err.IC 
                    ), 
                  'error' 
                ); 
                me.dialog('close'); 
                return false; 
              } 
              me.dialog('close'); 
              return true; 
            } 
          }); 
        } 
      }); 
    }); 
  }; 
})(jQuery); 

Поскольку компоненты EasyUI реализованы в виде коллекций jQuery (в нашем случае это $('div').httpConfDlg(http_icon)), инициализация диалога производится через метод this.each().  

В начале активируются кнопки диалога: OK и Cancel. Это можно сделать непосредственно при инициализации диалога, но кнопка OK создается только для обеспечения привилегированного доступа. Таким образом, для пользователя, не имеющего достаточных прав для изменения параметров HTTP протокола, диалог будет отображать только кнопку Cancel (Конечно, EasyUI допускает установку и снятие запрета нажатия на кнопки во время инициализации диалога, а также во время его работы — кнопка при запрете использования изменяет стиль и не реагирует на нажатия. Однако, для сохранения общего стиля, неиспользуемые кнопки в диалогах нами не отображаются). Обработчик кнопки Cancel по умолчанию закрывает окно диалога без дополнительных действий. У кнопки OK есть обработчик, который выполняет AJAX-запрос. В качестве параметра запросу передаётся JSON структура, содержащая номер функции для бэкенда, набор параметров для самой функции и обработчики результатов выполнения (callback). 

Затем родительский элемент, переданный через параметр this, заполняется контентом: двумя полями для указания номеров портов и одного флажка, устанавливающего принудительное использование защищённого протокола. Далее поля активируются как EasyUI textbox компоненты. Если пользователь не имеет привилегий для их изменения, текстовые поля и флажок будут недоступны для изменения.  

И наконец, производится активация самого диалога: устанавливаются его размеры, заголовок, иконка, модальный режим, активируются кнопки диалога и их обработчики (указанные явно или по умолчанию). После открытия диалога запускается функция, выполняющая AJAX запрос для получения текущих параметров HTTP. Если при выполнении запроса происходит ошибка, генерируется сообщение об ошибке и диалог закрывается. Если запрос выполнен успешно, текстовые поля и флажок устанавливаются в соответствии с полученными результатами запроса. 

При нажатии на кнопку OK вызывается функция-обработчик, которая считывает текущие значения текстовых полей и состояние флажка, формирует соответствующий AJAX-запрос и выполняет его. При любом результате выполнения этого запроса производится закрытие окна диалога (параметр done). В случае возникновения ошибки, в зависимости от её типа, выдаётся сообщение об ошибке. В нашем случае, даже при успешном выполнении запроса происходит ошибка, поскольку изменение настроек протокола приводит к разрыву соединения. Для его восстановления производится закрытие сеанса и вызов процедуры новой регистрации. 

Несколько, пояснений к коду. 

  • Вызов $.fitem('httpPort', _("HTTP port")) создаёт набор связанных HTML элементов, реализующих типовое для нашего приложения поле ввода с идентификатором httpPort и меткой (label) HTTP port. Функция _() обеспечивает использование языка, указанного пользователем в настройках. Последующий вызов компонента EasyUI $('#httpPort').textbox({type: 'text', width: 60, disabled: !allowed}); регистрирует поле ввода как EasyUI textbox. Вызов $('#httpPort').textbox('setValue', res.httpPort); устанавливает значение для текстового поля в соответствие результату AJAX запроса. И наконец, parseInt($('#httpPort').textbox('getValue')) в обработчике OK-кнопки возвращает текущее значение текстового поля. 

  • Компонент mdialog() является нашим собственным расширением от базового компонента EasyUI dialog() для автоматического закрытия диалога при наступлении определённых событий, а также для создания типовых кнопок с обработкой нажатия по умолчанию. В данном случае это кнопка Cancel, которая создаётся короткой инструкцией buttons.push({cancelButton: true}); 

  • Функция $.messager вызывает окно предупреждения, которое также является компонентом EasyUI, производным от компонента Dialog

В итоге диалог выглядит так: 

EasyUI диалог для настройки HTTP

Ложка дёгтя

Как видно из приведенного выше примера, для создания диалога требуется довольно много кода. Большое количество диалогов и их компонентов (например, выпадающих списков с одинаковым содержимым) ощутимо раздуло наш проект однообразными «велосипедными» конструкциями. 

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

И всё-таки, почему EasyUI?

Работа с большими массивами данных, представленными в виде таблиц и деревьев — это та фишка, которая определила выбор EasyUI. 

Для нас критически важно было отображать большой и постоянно обновляемый набор данных как в виде иерархической структуры, так и в виде таблицы. И EasyUI предоставила нам такую возможность. Для этого в составе библиотеки есть специальные компоненты: tree и datagrid

EasyUI tree отображает иерархические данные в виде древовидной структуры. Узлы дерева можно наполнять различной информацией и извлекать её по событиям, разворачивать, сворачивать, перетаскивать, изменять стиль оформления по содержимому или другим условиям.  

Компонент datagrid отображает данные в табличном формате и предлагает богатую поддержку для выбора, сортировки и группировки данных. Слияние ячеек, многоколоночные заголовки, замороженные столбцы и нижние колонтитулы — вот лишь некоторые из его особенностей. Кроме этого, для datagrid есть специальные расширения: datagrid-scrollview, которое манипулирует полным набором данных, сохраняя в DOM-е лишь отображаемую их часть (а это существенно повышает скорость работы приложения), и datagrid-filter позволяющее накладывать фильтры на используемый набор данных. 

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

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

В завершение хочу ещё раз сказать, что библиотека EasyUI обеспечивает все потребности пользовательского интерфейса, оставаясь при этом лёгкой в использовании. Я думаю, эта библиотека может быть интересна разработчику веб-приложений, ориентированных на управление различными устройствами. В этой нише её использование, на мой взгляд, может быть предпочтительнее Bootstrap-а. По крайней мере, это хорошая альтернатива панадолу.