Объекты страницы: описание одной техники

  • Tutorial
Здравствуйте, хабровчане.

Хочу поделиться с вами одной техникой организации кода при массивной работе с DOM-элементами. Несколько лет назад, когда еще вовсе не было бэкбона и MVVC, мы писали старый добрый джаваскрипт без фреймворков: создавали объекты и заставляли их плясать на странице в общем танце. Такая практика, без сомнения, до сих пор оправдывает себя, и техника, о которой пойдет речь, применима именно к ней.

Мой рассказ — о маленькой библиотечке PageObject.js (текущая версия v0.14, 2.6K) и о том, как с ее помощью можно упростить себе жизнь.

Это не плагин jQuery, хотя, уверен, многие захотят ее назвать именно так. Это всего лишь функция, которая использует селекторы и кое-какие утилиты из jQuery, чтобы создать удобство в чтении и написании кода. По сути, это простая jQuery утилитка.

Суть техники


В создании объектов, которые манипулируют DOM-элементами, и их подключении на страницу ничего нового не придумаешь: создаем конструктор, сперва рендерим в нем определенный шаблон, затем разбираем его результат на части, «вешаем» на эти части обработчики событий, программируем остальную логику и встраиваем все это добро в наше приложение.

function Calculator() {
  if (this.constructor.name !== 'Calculator') { throw “No way, buddy!”; }
  // 1. отрендерить шаблон
  // 2. разобрать на части
  // 3. повесить обработчики
  // 4. остальная логика
}

PageObject.js помогает с первыми двумя шагами.

function Calculator() {
  if (this.constructor.name !== 'Calculator') { throw “No way, buddy!”; }
  var calc = this;
  $.turnToPageObject(calc, {
    template: $('#tmplCalculator').html(),
    containerClass: 'calc',
    context: {
      caption: "Calculator"
    },
    selectors: {
      buttons: [ ':button', Calculator.getButtonName ],
      led: 'p'
    }
  });
  // 3. повесить обработчики
  // 4. другая логика
}
var calc = new Calculator;
$('body').append(calc.DOM.container);

После того, как отработает функция $.turnToPageObject (“превратить в объект страницы”), у объекта calc появится свойство calc.DOM — неймспейс, заполненый DOM-элементами, которые будут соответствовать указанным селекторам, и еще появится calc.DOM.container — именно та легко встраиваемая в приложение часть объекта — контейнер всего-всего.

Вот, пожалуйста, полный работающий пример с калькулятором. Мне же осталось рассказать подробнее о всех возможностях утилитки.

$.turnToPageObject


Первый аргумент должен быть объектом, у которого в последствии появится заполненый составными HTML-елементами неймспейс DOM. Второй аргумент — опции.

Если НЕ указать опцию container, то контейнер будет создан (без него никак), и это будет такой же элемент, как и containerElement (по умолчанию — DIV).

Если указать containerClass, то контейнеру будет присвоен класс.

Если указать template, то будет отрендерен шаблон и его результат будет помещен внутрь контейнера.

Если НЕ указать context, то шаблон будет отрендерен с пустым контекстом {}.

template может быть либо строкой, либо функцией (интеграция с Jammit JST).

Когда template — функция, он принимает только контекст и должен возвратить строку.

Когда template — строка, шаблон рендерится при помощи templateEngine, которая автоматически настроится по умолчанию на использование шаблонизатора _.template, если underscore присутствует.

Если в вашем проекте нет underscore, вам нужно сконфигурировать templateEngine.

// вот так меняются умолчания:
$.turnToPageObject.configure({
  templateEngine: window.tmpl, // http://ejohn.org/blog/javascript-micro-templating
  containerElement: 'strong'
});

templateEngine принимает два аргумента — строку шаблона и контекст — и также должен возвратить строку.

Если указана опция hide, то контейнер будет спрятан, что часто бывало удобно.

И главное — селекторы. Если указать опцию selectors (объект), то соответствующие селекторам найденые в содержимом контейнера HTML-элементы будут по аналогичным ключам помещены в неймспейс DOM. Если опция template была указана, то селекторы будут искать в уже отрендеренном шаблоне.

Если по селектору не будет найдено ни одного элемента — будет крик.
Если по селектору будет найдено более одного элемента — также будет исключение.
Если все же нужно, чтобы по селектору были найдены и помещены в массив более одного элемента, нужно к значению селектора дописать [] (пустые квадратные скобки) — такое выделение себя оправдывает.

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

Стоит отметить, что дополнительные неймспейсы в селекторах образуют неймспейсы с теми же именами внутри свойства DOM.

Пожалуй, на этом все.

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

Буду рад, если вы найдете данную технику и описанную выше конвенцию применимой и в ваших проектах. Особенно буду рад вашим пожеланиям и идеям, с радостью отвечу на вопросы.
Ads
AdBlock has stolen the banner, but banners are not teeth — they will be back

More

Comments 22

    +1
    Это нехорошо:
    if (this.constructor.name !== 'Calculator') { throw “No way, buddy!”; }
    

    Если уж так делать, то можно:
    function Calculator() {
    if (this.constructor !== Calculator) { throw “No way, buddy!”; }
    

    А лучше так:
    function Calculator() {
    if (! (this instanceof Calculator)) { throw new TypeError(“No way, buddy!”); }
    
      0
      У всех трех способов есть свои уязвимости. Вот смотрите.
        +1
        Для начала давайте для ясности определимся какая цель была у этого кода.
        Я понял его, как желание определить, вызывается ли функция в качестве конструктора. Все три способа так или иначе делают это, но первый полагается на некоторое строковое значение, в то время как второй и третий на ссылку. С моей точки зрения лучше полагаться на объекты выполняемого приложения, доступные по ссылкам, нежели на строки, которые являются данными.

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

        Кстати, в вашем примере вот эта строка:
        NamedFunction.name = "Good bye";
        
        не сработает, потому как name это readonly-свойство функции.

          0
          Спасибо, вы правы. Действительно read-only. Но мы подошли к тому, что мой способ и поломать-то нельзя. (фиддл подправил).
            0
            Можно создавать сколько угодно функций с заданным именем.
            jsfiddle.net/66Xdu/
              0
              Сенсей, вы не находите, что тогда все три способа тождественны, и ни один из них ни хуже, ни лучше?
                0
                У instanceof есть преимущества:
                • проходится по цепочке прототипов
                • работает даже когда this == null
                  +1
                  Сенсей, вы не находите, что тогда все три способа тождественны, и ни один из них ни хуже, ни лучше?


                  Нет, я не нахожу. Объявление надо врапнуть в анонимную функцию, тогда точно никто не переопределит.

                  var IsInstance = (function () {
                    return function IsInstance() { 
                     console.log(1);
                      if (! (this instanceof IsInstance)) throw "No way!";
                    }
                  })();
                  
                  var A = IsInstance;
                  function IsInstance() {};
                  var a = new A;
                  


                  Способ с именем — дурацкий.
                    –1
                    Ништяк. Я срочно побежал везде оборачивать все конструкторы лямбдами. Теперь никто из команды точно не сможет ни случайно пропустить кейворд new, ни нацистким способом похачить и без того обернутые в лямбду модули.
                      +1
                      Основная ваша проблема в том, что вы не соблюдаете DRY. Каждый раз повторять проверку — явный признак быдлокодинга.

                      Кстати, бросать строку вместо эксепшина — это плохая практика. Никакого стектрейса, просто голая ошибка. Кто так делает вообще?
                        –1
                        Как мило. Разговор, вижу, пошел по понятиям. Самоутверждатесь, товарищ? — Предлагаю минусануть еще и этот коментарий и успокоиться.
                          +2
                          О чём вы? Какие понятия? Просто лёгкий ликбез.
                          К слову, у вас будет ещё проблем — когда захотите унаследоваться, то не сможете вызвать конструктор:

                          function Child () {
                            Parent.call(this); // fail
                          }
                          
                      0
                      Кстати, есть ещё интересная конструкция, которая позволяет вызывать функцию как конструктор без ключевого слова new (она тоже основана на проверке instanceof).
                      Быстрым загугливанием нашёл здесь: js-bits.blogspot.ru/2010/08/constructors-without-using-new.html
                      Может быть и на хабре освещался этот вопрос.

                      Это своеобразный «иной» подход к той же проблеме: вместо ошибки мы просто позволяем вызывать функцию без слова new с тем же эффектом.
                0
                Защита нужна не от вторжения, а от невнимательности.
            0
            Не могли бы вы объяснить, почему же так нехорошо одно и чем же так прекрасно другое и третье?
              0
              Промахнулся веткой. Этот вопрос был к StreetStrider.
              0
              Для выборки нескольких элементов приходится в начале строки указывать нестандартный селектор "[]", возвращающий массив, а не jQuery-объект. В обычных селекторах тоже возвращается DOM element. Возможно я не прав, но если уж работать с jQuery, то почему бы не использовать все возможности этой библиотеки?

              Чем плодить кучу проверок и exception-ов, лучше переложить большую часть на плечи jQuery, а обработку самих ошибок оставить пользователю. Это даст намного больше гибкости и возможностей.

              Например с теми же селекторами: пользователь указывает любые селекторы, которые в скрипте пытаются найтись внутри элемента-контейнера jQuery('selector', container). Если селектор неправильный, либо такого элемента не существует — вернётся пустой объект jQuery, а не ошибка. И программист, при необходимости, сам проверит есть ли элемент. Тогда вместо кода

              Скрытый текст
                // Extract jQuery DOM parts from a container using a map of selectors.
                priv.extractParts = function (sourceContainer, selectors) {
                  var domParts = {};
              
                  if (!priv.isElement(sourceContainer)) {
                    throw "POE10: arguments should be 1st — HTML DOM container and 2nd — selectors map";
                  }
              
                  if (!$.isPlainObject(selectors)) {
                    selectors = {};
                  }
              
              
                  $.each(selectors, function (name, selector) {
                    var found,
                      findMultiple = false;
              
                    if (!/^[A-z\.]+$/.test(name)) {
                      throw "POE12: incorrect selector name `" + name + "`";
                    }
              
                    if ($.isArray(selector) &&
                      selector.length === 2 &&
                      $.type(selector[0]) === 'string' &&
                      $.isFunction(selector[1])) {
              
                      domParts[name] = {};
                      $(sourceContainer).find(selector[0]).each(function () {
                        var id = selector[1].call(this, this);
                        if (id) {
                          if (domParts[name][id]) {
                            throw "POE13: duplicate identifier `" + id + "` in DOM part namespace `"+ name +"`";
                          }
                          domParts[name][id] = this;
                        }
                      });
                    }
              
                    else if ($.isPlainObject(selector)) {
                      domParts[name] = priv.extractParts(sourceContainer, selector);
                    }
              
                    else if (typeof selector === 'string') {
                      findMultiple = selector.indexOf('[]') === 0;
                      if (findMultiple) {
                        selector = selector.replace('[]', '');
                      }
                      found = $(sourceContainer).find(selector);
                      if (found.length === 0) {
                        throw "POE14: DOM parts weren't found for selector `" + name + "`";
                      } else if (found.length > 1) {
                        if (findMultiple) {
                          found = Array.prototype.slice.call(found);
                        } else {
                          throw "POE15: multiple DOM parts found for selector `" + name + "`";
                        }
                      } else {
                        found = found[0];
                      }
                      domParts[name] = found;
                    }
              
                    else {
                      throw "POE16: invalid selector value";
                    }
              
                  });
              
                  return domParts;
                };
              



              можно легко использовать

              Скрытый текст
              priv.extractParts = function(container, selectors){
                  var domParts = {};
                  $.each(selectors, function(name, selector){
                      domParts[name] = (typeof name == 'string') ?
                          $(selector, container) : priv.extractParts(container, selector);
                  });
                  return domParts;
              };
              



              То же самое и с проверками $.isPlainObject — достаточно использовать $.extend, чтобы гарантировать, что в результате всегда будет объект.

              Небольшая переделка Demo
              <script type="text/html" id="tmplCalculator">
                <table cellspacing="0" cellpadding="0">
                    <caption><%= caption %></caption>
                    <tr>
                        <td colspan="5"><p></p></td>
                    </tr>
                    <tr>
                        <td><input class="calc-button" type="button" value="1"/></td>
                        <td><input class="calc-button" type="button" value="2"/></td>
                        <td><input class="calc-button" type="button" value="3"/></td>
                        <td><input class="calc-button" type="button" value="/"/></td>
                        <td><input class="calc-func calc-cancel" type="button" title="Cancel" value="C"/></td>
                    </tr>
                    <tr>
                        <td><input class="calc-button" type="button" value="4"/></td>
                        <td><input class="calc-button" type="button" value="5"/></td>
                        <td><input class="calc-button" type="button" value="6"/></td>
                        <td><input class="calc-button" type="button" value="*"/></td>
                        <td><input class="calc-func calc-undo" type="button" title="Undo" value="←"/></td>
                    </tr>
                    <tr>
                        <td><input class="calc-button" type="button" value="7"/></td>
                        <td><input class="calc-button" type="button" value="8"/></td>
                        <td><input class="calc-button" type="button" value="9"/></td>
                        <td><input class="calc-button" type="button" value="-"/></td>
                        <td rowspan="2"><input class="calc-func calc-equals" type="button" value="="/></td>
                    </tr>
                    <tr>
                        <td colspan="2"><input class="calc-button calc-0" type="button" value="0"/></td>
                        <td><input class="calc-button" type="button" value="."/></td>
                        <td><input class="calc-button" type="button" value="+"/></td>
                    </tr>
                </table>
              </script>
              


              ;
              (function ($) {
              
                  function Calculator() {
              
                      $.turnToPageObject(this, {
                          template: $('#tmplCalculator').html(),
                          containerClass: 'calc',
                          context: {
                              caption: "Demo Calculator"
                          },
                          selectors: {
                              "cancel": '.calc-cancel',
                              "equals": '.calc-equals',
                              "undo": '.calc-undo',
                              "led": 'p'
                          }
                      });
              
                      var history = [],
                      led = $(this.DOM.led);
              
                      $(this.DOM.container).on('click', '.calc-button', function(){
                          history.push(led.text());
                          led.append(' ' + $(this).val());
                      });
              
                      $(this.DOM.cancel).click(function(){
                          history = [];
                          led.empty();
                      });
              
                      $(this.DOM.equals).click(function(){
                          history.push(led.text());
                          try {
                              led.text((eval(led.text().replace(/ /g, '')) + '').replace(/./g, "$& "));
                          } catch(e) {
                              led.text(history.pop());
                          }
                      });
              
                      $(this.DOM.undo).click(function(){
                          led.text(history.pop() || '');
                      });
                  }
                  window.Calculator = Calculator;
              
              })(jQuery);
              

                0
                небольшая опечатка:

                Скрытый текст
                priv.extractParts = function(container, selectors){
                    var domParts = {};
                    $.each(selectors, function(name, selector){
                        domParts[name] = (typeof selector == 'string') ?
                            $(selector, container) : priv.extractParts(container, selector);
                    });
                    return domParts;
                };
                

                  0
                  Я понял вашу идею. Смотрите, extractParts сделана такой для того, чтобы 1) возвращать не jQuery объекты, а DOM-элементы и 2) не допустить невалидные селекторы, чтобы разработчик как-раз не возился с обработкой ошибок. То есть, в текущей реализации результат предсказуем.

                  Недостаток способа, который вы предлагаете также в том, что в нем нет фичи selectorName: [ realSelector, getKeyFromElementFunction ].
                    0
                    1) возвращать не jQuery объекты, а DOM-элементы

                    а чем плохи jQuery объект? Всё равно используется библиотека jQuery, так зачем обращаться к DOM элементам? К тому же объект jQuery может работать и как массив — в большинстве случаев работа происходит не с единичными объектами, а именно с множеством объектов, полученных по определённым селекторам. Даже в вашем демо калькулятора работу с кнопками удобнее было бы организовать, если бы возвращался jQuery объект.

                    2) не допустить невалидные селекторы, чтобы разработчик как-раз не возился с обработкой ошибок

                    хотя я придерживаюсь другого взгляда, но это тоже несложно. достаточно внутри кода сделать проверку:

                    Скрытый текст
                    priv.extractParts = function(container, selectors){
                        var domParts = {};
                        $.each(selectors, function(name, selector){
                            if(typeof selector == 'string'){
                                domParts[name] = $(selector, container);
                                if(domParts[name].length == 0){
                                    throw 'Incorrect selector "'+selector+'" or element "'+name+'" is not found';
                                }
                            } else {
                                domParts[name] = priv.extractParts(container, selector);
                            }
                        });
                        return domParts;
                    };
                    


                    Обычно, все баги фронт-енда выявляются ещё на этапе разработки. На своей практике я редко сталкивался с ошибками в хардкодном хтмл-е и селекторах для него.

                    нет фичи selectorName: [ realSelector, getKeyFromElementFunction ]

                    а какой смысл в этой функции?
                      0
                      а чем плохи jQuery объект?
                      Скажу вам, что изначально, когда придумалась extractParts, она возвращала именно jQ объекты, но постепенно мы пришли к тому, что у этого есть недостатки.

                      jQ объекты вовсе неплохи, но все же они занимают больше памяти. Да и мы сами можем в любой момент обернуть дом-элемент в jQ (имеем выбор) и выполнить требуемые действия. Когда же функция, в которой это происходит, добегает до конца, то память освобождается. Так экономнее.

                      Еще, мы пришли к тому, что форма записи $(calc.DOM.container).show() лучше, так как явно указывает человеку, вникающему в код, что вот он обернутый в jQuery дом-элемент.

                      а какой смысл в этой функции?
                      Это инструмент для определенного случая. Например, нужно создать обновляемую таблицу-виджет, столбцы которой — года, а строки — значения метрик.

                      <script type="text/html" id="tmplTableWidget">
                      <table>
                        <tr>
                          <th></th>
                          <th>2011</th>
                          <th>2012</th>
                          <th>2013</th>
                        </tr>
                        <tr data-metric="newUsers">
                          <th>New Users</th>
                          <td data-year="2011"></td>
                          <td data-year="2012"></td>
                          <td data-year="2013"></td>
                        </tr>
                        <tr data-metric="uniqueUsers">
                          <th>Unique Users</th>
                          <td data-year="2011"></td>
                          <td data-year="2012"></td>
                          <td data-year="2013"></td>
                        </tr>
                        <!-- и т.д. -->
                      </table>
                      </script>
                      

                      После превращения

                      $.turnToPageObject(this, {
                        template: $('#tmplTableWidget').html(),
                        selectors: {
                          newUsers: [ 'tr[data-metric=newUsers]>td', function () { return $(this).attr('data-year'); } ],
                          uniqueUsers: [ 'tr[data-metric=uniqueUsers]>td', function () { return $(this).attr('data-year'); } ]
                        }
                      });
                      

                      this.DOM будет выглядет вот так:

                      this.DOM.newUsers.2011
                      this.DOM.newUsers.2012
                      this.DOM.newUsers.2013
                      this.DOM.uniqueUsers.2011
                      this.DOM.uniqueUsers.2012
                      this.DOM.uniqueUsers.2013
                      

                      Когда понадобится обновить метрики согласно какому-то вновь прибывшему json, мы все-равно пробежимся по нему циклом, но вместо jQuery-пасты а-ля $(this).find('tr[data-metric='+metric+']>td[data-year='+year+']).text(value) запишем $(this.DOM[metric][year]).text(value), что отработает куда быстрее.
                  0
                  Возможно Ваш способ действительно удобнее, но, имхо, не стоит подобных усилий. Проверять на существование элемента/ключа массива придётся в любом случае, иначе Ваш вариант кода сгенерирует exception. Так что я бы не стал лишний раз усложнять код. Однако моё дело лишь подсказать возможное «другое» решение.

                  Сорри, ответил не в ту ветку.

                  Only users with full accounts can post comments. Log in, please.