Сложные интерфейсы на javascript вместе Yahoo UI. Часть 14

December 22, 2008

Сегодняшняя статья лишь формально продолжает серию, рассказывающую о библиотеке javascript компонентов Yahoo UI. Разработка сложного интерфейса веб-страницы активно использующего идеи ajax, поднимает вопрос о том, как визуализировать данные, загруженные с сервера. В отдельных ситуациях можно обойтись подходом, когда на стороне сервера формируется полный фрагмент html-представления страницы. В других случаях YUI компоненты диктуют правила как должны выглядеть отображаемые в них данные. Я расскажу о том, как быть когда ни один из этих двух подходов нам не подходит.

Чем плох подход, когда серверный скрипт в ответ на ajax-запрос от браузера клиента формирует фрагмент html-кода, который можно сразу же поместить внутрь какого-либо из существующих элементов html-страницы, да хоть присвоив новое значение свойству innerHtml. Первый недостаток очевиден – это лишние затраты на трафик. Т.е. по сети передается не только информация, но и теги html, фрагменты css-стилей, которые управляют внешним видом этих данных. В разных ситуациях, процентное соотношение между “чистыми” данными и их форматированием меняется в широких диапазонах. Причем фрагменты тегов форматирования склонны к повторению: например, каждый элемент списка с перечнем имен сотрудников имеет одинаковую структуру: “li”, “a”, “img”, “span”. Возможным решением, чтобы уменьшить объем трафика, а, следовательно, скорость отклика приложения на запрос клиента будет выполнять gzip¬-сжатие html-фрагмента, который сформировал http-сервер. Естественно, что это повлечет за собой дополнительную нагрузку на сервер: работа шаблонификатора и последующее сжатие данных не бесплатно. Можно было бы переложить эту работу на клиента. Т.е. серверный скрипт отдает “чистые” данные, в виде xml или json. А javascript-код на стороне клиента эти данные интерпретирует и строит dom-дерево. Простого ответа на вопрос какой подход выбрать нет. Здесь участвуют факторы технические, экономические, организационные и многое другое. Например, вы выбираете то, куда “переложить нагрузку”: на сервер или на машину клиента. С одной стороны сервера легче масштабировать, т.е. когда серверная часть не станет справляться с нагрузкой, то вы добавляете в стойку пару серверов. Клиентскую часть вы не контролируете, не знаете каким “железом” пользуется клиент, насколько у него тормозит, и не можете заставить его сделать апгрейд (хотя производители игр только этим и занимаются). С другой стороны, может быть так, что для сохранения приемлемой производительности по мере роста количества клиентов, увеличением количества серверов уже не обойтись: затраты на покупку или аренду серверов, их обслуживание становятся большими чем получаемая прибыль с каждого подписчика. Более того, выбираемый подход может меняться во времени. Так мы можем начать с того, что сделаем “быстрый и грязный” прототип, который выходит на рынок, опережая своих конкурентов. А затем по мере появления достаточных ресурсов и уточнения понимания того, как проект должен работать и приносить прибыль, мы начинаем его переделывать. Больше в философию “как быть” я вдаваться не буду, и перехожу к практическому вопросу “как сделать”.

Предположим, что нужно разработать приложение календарь. Это приложение получает данные с сервера (в формате xml или json). Затем эти данные трансформируются и визуализируются. Почему визуализация будет идти на стороне клиента? Предполагается, что календарь может иметь несколько представлений одних и тех же данных. Например, после загрузки сведений о запланированных делах на месяц, вы можете захотеть увидеть эту информацию сразу целиком, или по отдельным неделям, или дням. Можно придумать задачу анализа расписания, например, в таблице по строкам расположить названия запланированных работ, а по столбцам вывести дни недели с подбивкой итогов, сколько суммарно мы тратим времени на каждый из видов работ. Здесь очевидно, что нет никакой необходимости обращаться к серверу при переключении “видов” календаря: данные одни и те же – меняется только правила их визуализации. Обращаясь к паттерну MVC, мы говорим, что меняется V и C (внешний вид и контроллер, обрабатывающий действия пользователя). Что касается данных, то они неизменны. Более того, я советовал бы вам обратиться к моим статьям, посвященным google gears. С помощью gears мы можем хранить содержимое календаря не только на сервере, но и на вашей локальной машине в форме реляционной базы данных sqlite. Относительно выбора между json и xml, я могу сказать, что хотя мне очень нравится xml и xslt, у меня есть опыт работы с этими технологиями уже несколько лет. Но применительно к веб-разработке, а точнее задачам трансформации xml с помощью xsl на стороне именно клиента (браузера), xsl лучше не использовать. Т.к. нет единого поведения у всех основных браузеров, плюс написание правил трансформации достаточно сложно. Например, когда я рассказывал своим знакомым о том, как с помощью xslt сделать цикл на пять повторений (с помощью рекурсии), то они смотрели на меня непонимающими глазами и спрашивали: “почему так сложно?”. С другой стороны, если вам нужно делать запросы на отбор именно информации, циклы по существующим данным, то можно компактно записать с помощью xpath выражение вроде “найти и вывести все отделы, у которых количество сотрудников мужского пола составляет менее половины общего числа”. Сложности составляет и процесс отладки xsl-выражений, хотя есть отличный инструментарий вроде altova xmlxpy или intellij idea с плагином xslt debugger, но пользоваться ими (по своему опыту общения с начинающим веб-разработчиками) сложно. Так, что я выбираю как основной формат данных загружаемых с сервера именно json. Теперь полагая, что у меня есть следующий входной массив, задумаемся над тем как эти данные визуализировать:
  1. var kadry = {
  2.   title : "Рога и копыта",
  3.   main_departments : [ {title : "Отдел продаж", employees: [
  4.      {fio: "Jim", age: 12, sex : "male"},
  5.      {fio: "Janet", age: 13, sex : "female"}	],
  6.      subdepartments: [
  7.       {title: "Рекламщики"},
  8.       {title: "PR"}
  9.      ]  } , { -- описание еще одного отдела -- } ]
  10. };
В переменной kadry хранится информация о структуре некоей фирмы “Рога и копыта” (название фирмы задано как свойство title). Затем есть вложенный объект main_departments (список главных отделов) со свойствами title (название главного отдела), перечнем сотрудников (массив employees) и список отделов подчиненных главному (subdepartments). Я хочу вывести на странице как заголовок первого уровня (h1) название “Рога и копыта”, затем в виде таблички список названий главных отделов. Рядом с которым выводится с небольшим отступом и список сотрудников. Информация же об подотделах сведется просто к выводу рядом с названием главного отдела в скобках количества вложенных в него подчиненных отделов. Теперь первое приближение алгоритма:
  1. var main_deps = document.createElement( "div" );
  2. main_deps.className = "style_for_deps";
  3. for (var i = 0; i < kadry.main_departments.length; i++){
  4.   var h1 = document.createElement("h1");
  5.   h1.appendChild (document.createTextNode( kadry.main_departments[i].title ));
  6.   h1.setAttribute ('class', 'heading_department');
  7.   main_deps.appendChild (h1);
  8. }
  9.  
  10. document.body.appendChild (main_deps);
Довольно громоздко, и ведь здесь я только создал блок div, назначил ему имя css-класса “style_for_kadry”, затем организовал цикл по списку главных отделов. Для каждого из них создал элемент h1, с содержимым в виде названия отдела, назначаю затем css-класс, и вкладываю h1 внутрь main_deps. Завершая работу, я присоединил блок с перечнем названий отделов к содержимому html-документа (document.body). Есть ли какая-то альтернатива этому подходу, который становится все более и более громоздким по мере роста количества вложенных уровней html-тегов. Конечно, есть: любой тег html содержит свойство innerHTML, которому можно присвоить как значение строку с полноценным фрагментом html, который предварительно “соберем” в таком цикле:
  1. var main_deps = '<div class="style_for_deps">';
  2. for (var i = 0; i < kadry.main_departments.length; i++)
  3.    main_deps += '<h1 class="heading_department">' + kadry.main_departments[i].title  + '</h1>' ;
  4. main_deps += '</div>'
  5.  
  6. document.body.innerHTML = main_deps;
Кода здесь меньше, а вот лучше ли такой подход? Увы, нет, т.к. содержимое этих длинных строк, конструирующих html, никак не проверяется, то легко забыть закрыть тег, пропустить экранирующий символ для кавычки. Более того, первый способ при условии использования отступов является более удобочитаемым, чем второй. Еще проблемы возникают тогда, когда созданные html-теги нужно “тонко настроить”. Например, элемент в зависимости от ряда сложных условий получает тот или иной css-класс. Записать это условие в конструируемой строке не реально, разве что “похоронить” остатки удобочитаемости кода под грудой тернарных операторов. Как возможное решение, мы в ходе конструирования строки html назначаем всем интересующих нас тегам уникальные идентификаторы. Затем, после вставки строки html в дерево DOM, используем старый добрый вызов document.getElementById, чтобы получить ссылку на один из сгенерированных элементов, и начинаем менять ему css-стили, настраивать. Единственное преимущество innerHTML перед множеством вызовов функций манипулирующих dom (createElement) - это скорость работы. Так для internet explorer-а эта цифра измеряется порядками! В других A-grade браузерах разница в скорости незначительна. Какие еще есть варианты формирования сложного html-фрагмента? Мы попробовали и нам понравилось задавать в формате json, собственно, информацию. А что если подобным же образом задать и сведения об том, какие html-элементы нужно создать, какие у них должны быть атрибуты и вложенные, дочерние теги. Например, в следующем примере я создаю тег div со стилевым оформлением шрифта красным цветом и размером в 16px. Этому тегу я назначил css-класс ‘class_a’ и функцию, обрабатывающую событие “click” по элементу. Обратите внимание на то, что имя атрибута ‘class’ я поместил в кавычки (слова class, for являются зарезервированными для javascript). Содержимое тега div сложное: в нем есть и фрагмент текста (значение свойства text), и перечень вложенных внутрь div-а тегов. Значением свойства children является либо массив с перечнем вложенных тегов (их форма записи идентична использованной для родительского тега), либо одиночный объект тег. Итак, пример описания html-дерева:
  1. var e = html (
  2.   { tagName : 'div', 
  3.     style:  {color: 'red', fontSize: '16px'}, 
  4.     'text': 'Hello from javascript', 
  5.     onclick : 'alert(this.innerHTML)', 
  6.     'class' : 'class_a',
  7.     children: [
  8.       {
  9.         tagName: 'span', 
  10.         text: 'Hello from HTML',  
  11.         children:  
  12.           {tagName: 'b', text: 'bold text'}   
  13.       } 
  14.     ] 
  15.   }
  16. );
  17. document.body.appendChild (e.tag);
  18.  
  19. alert ('второй тег внутри div равен ' + e.children[1].tag.innerHTML);
Как видите, результат вызова функции не просто ссылка на созданный тег, а сложный объект, состоящий из двух компонент: свойство tag задает ссылку на html-элемент верхнего уровня (div). А внутри свойства children хранится массив с перечнем вложенных внутрь div тегов span. Так, я решил проблему, возникшую в способе #2 (c innerHTML), когда мне нужен был способ назначения уникальных идентификаторов для элементов с последующими вызовами document.getElementById, чтобы получить ссылку на какой-либо из созданных элементов. JSON-строку описания создаваемого dom-дерева можно аккуратно отформатировать с помощью отступов. А если вы еще пользуетесь для написания javascript-кода каким-нибудь умным редактором, который не только подсвечивает ключевые слова javascript, но и проверяет JSON выражение на корректность, то можно еще на стадии разработки избежать многих ошибок и опечаток. К сожалению, встроенной в javascript функции html нет, но создать ее очень легко:
  1. function html( attrs ) {
  2.  
  3.  var children = [];
  4.  
  5.  // создаем элемент
  6.  var e = document.createElement( attrs.tagName);
  7.  
  8.  for ( var atName in attrs ) {
  9.   var atValue = attrs[atName];	
  10.   if (atName == 'tagName') continue;
  11.   // если один из атрибутов это массив стилей
  12.   if (atName == 'style')
  13.    for (var stName in atValue)
  14.       e.style[stName] = atValue[stName];
  15.   else if (atName == 'text')
  16.   // если текстовое содержимое элемента
  17.    e.appendChild( document.createTextNode( atValue ) );
  18.   else if (atName == 'children'){
  19.      if (! atValue.length)  atValue = [atValue];
  20.  
  21.      // если список дочерних тегов
  22.      for (var i = 0; i < atValue.length; i++){
  23.        var innerEl = html (atValue[i]);
  24.        children.push (innerEl);
  25.        e.appendChild (innerEl.tag);  }
  26.      }
  27.   else
  28.      // если обычный атрибут
  29.     e.setAttribute (atName, atValue); 
  30. }// конец цикла
  31.  
  32. return { tag: e, children: children};
  33.  
  34. }
Надо сказать, что идея записи DOM дерева в форме JSON не нова, и уже реализована во многих javascript-библиотеках, например, в jquery, mootools. Следующий фрагмент кода полностью идентичен приведенному выше (создающему div со стилями, вложенными элементами и привязанными функциями обработки событий). Код, использующий jquery, также компактен, как и использующий мою функцию html, но jquery – это стандарт (путь и де-факто), да и возможностей у jquery все же больше:
  1. function doSomeThing (){
  2.    alert (this);
  3. }
  4.  
  5.  
  6. $('<div>')
  7.  .addClass('class_a')
  8.  .css({color: 'red', fontSize: '24px'})
  9.  .click (doSomeThing)
  10.  .append( $('<span>')
  11.    .append('Hello From HTML')
  12.    .append( $('<b>')
  13.    .append('bold text')
  14.   )
  15.  ).appendTo('#box');
Вся беда в том, что в YUI нет столь удобного способа записи сценария создания html-дерева, и приходится, по крайней мере, до выхода третьей версии yui пользоваться самоделками.

Завершающим сегодняшнюю статью будет рассказ о template engines в javascript. Шаблонные движки широко известны и применяются на стороне сервера для внедрения информации внутрь шаблона html-страницы. Так обязательным требованием при приеме на работу php программиста является знание smarty. Для javascript выбор поменьше и какого-то де-факто стандарта нет. Я расскажу о trimpath (сайт проекта http://code.google.com/p/trimpath/). Код самой javascript-библиотеки порядка 20 кб. Итак, полагаю, что вы загрузили и подключили к своему html-файлу библиотеку trimpath-template-1.0.38.js. В качестве исходных данных для template engine я буду использовать описанную ранее переменную kadry. Осталось только определить шаблон. Разработчики trimpath рекомендуют хранить текст шаблона внутри тега textarea (естественно, что сама textarea должна быть невидимой). Это удобно т.к. textarea может содержать как значение произвольный фрагмент html-кода, даже не корректный, перемешанный со специфическими для trimpath командами проверки условий, организации циклов. Для того, чтобы подставить в шаблон значение поля из переменной kadry, например, title делаем так (синтаксис похож и на smarty и на jstl):
  1. <textarea id="template" style="display:none;">
  2.    ${main_departments[0].title}
  3. </textarea>
А как использовать этот шаблон? Результатом вызова метода parseDOMTemplate является “скомпилированный” шаблон, так что вы можете многократно использовать его для трансформации различных данных без потери времени на повторный анализ шаблона.
  1. var compiled = TrimPath.parseDOMTemplate("template");
  2. YAHOO.util.Dom.get('box').innerHTML = compiled.process(kadry);
Когда мы обращаемся в шаблоне к переменной, то после ее имени (до закрывающей фигурной скобки) можно перечислить так называемые модификаторы. Т.е. функции, которые получают в качестве параметра значение переменной, которую они должны модифицировать. Например, убрать лишние пробелы по краям, выполнить форматирование даты по некоторому шаблону. Следующий пример показывает все (их действительно всего три) модификаторы, поддерживаемые trimpath. Default с параметром (параметры отделяются от данных символом двоеточия, а между собой разделяются запятой) проверяет, что если значение переменной title2 равно null (отличайте null от undefined), то вместо title2 возвращается строка ‘substitution value’. Затем она подвергается экранированию спец. символов и последним шагом преобразуем строку к верхнему регистру.
  1. Hello ${title2|default:'substitution value'|escape|capitalize}
Если же мы хотим создать собственную функцию модификатор, то это можно сделать так (функция выравнивает число d слева символами “0” до numDigits знаков):
  1. function lpad  (d, numDigits){
  2.  d = ""+d;
  3.  while (d.length < numDigits) d = "0"+d;
  4.  return d; 
  5. }
Теперь созданную функцию нужно зарегистрировать внутри trimpath и ее можно использовать наравне с другими модификаторами:
  1. TrimPath.parseTemplate_etc.modifierDef.lpad = lpad;
  2. // И пример использования:
  3. Hello ${title|lpad:20|capitalize}
В шаблонах trimpath можно использовать условные проверки и циклы, например, так (и циклы и условия можно многократно комбинировать и вкладывать друг внутрь друга):
  1. {for dep in main_departments}
  2.   ${dep.title}
  3. {forelse}
  4.  Список отделов пуст 
  5. {/for}
  6. {if title == ‘Менеджеры’}
  7.  Наш отдел
  8. {else}  Их отдел 
  9. {/if}
Возможности trimpath на этом не ограничиваются, так есть секция {minify}, которая из своего содержимого выкинет лишние символы переносов – это удобно при записи очень длинных шаблонов, которые для удобочитаемости разделены на множество строк, но конечный результат этих лишних переводов каретки содержать не должен. Содержимое внутри секции {cdata} не интерпретируется trimpath, а просто выводится на экран “как есть”. Секция {macro} позволяет определить некоторое подобие функции или макроса (идея с подставкой тела макроса во всех местах, где мы его используем, взята из c|c++). Если же возможностей trimpath вам не хватает, то можно писать небольшие вставки на javascript внутри шаблона (поместите их внутрь секции “{eval}”). В примерах выше шаблон хранится внутри html-страницы – это не всегда возможно. Так вы может задать шаблон как как содержимое огромной-огромной строки в js-файле. В этом случае делайте так (во втором случае я сохраняю результат “компиляции” шаблона):
  1. var template = "Company ${title}";
  2. var s = "Hello ${title}";
  3. YAHOO.util.Dom.get('box_1').innerHTML = s.process(kadry);
  4. var compiled = TrimPath.parseTemplate(s);
  5. YAHOO.util.Dom.get('box_2').innerHTML = compiled.process(kadry);