Поддержка журнала и кнопки "Назад"

Илайджа Менор (Elijah Manor) | 13 мая 2010 г.

Разработка интерактивных веб-сайтов замечательна для удобства использования, но одна общая проблема современных веб-сайтов — недостаточная поддержка кнопок "Журнал" и "Назад" при использовании библиотек JavaScript и технологии AJAX.

Пользователи предполагают, что после нажатия кнопки "Назад" вернутся в предыдущее состояние веб-страницы, однако в типичном приложении с использованием технологии AJAX нажатие кнопки "Назад" может привести пользователя в состояние, которое было несколько шагов назад. Это не только запутывает пользователей; весьма вероятно, что они подумают, что в данном программном обеспечении имеется ошибка, и будут менее удовлетворены взаимодействием в целом.

Недостаток такой поддержки частично объясняется тем, что разработчики должны добавлять дополнительную логику для перехвата кнопки "Назад" и соответствующей реакции на ее нажатие. Поскольку веб-браузер не знает, что за кулисами выполняются операции с моделью DOM, он не отслеживает их.

Для устранения этой проблемы создано несколько проектов, таких как  разработанный Микаге Саватари (Mikage Sawatari) подключаемый модуль jQuery History, разработанный компанией Asual подключаемый модуль jQuery Addres, разработанный Джимом Палмером (Jim Palmer) подключаемый модуль jQuery jHistory и разработанный Беном Алманом (Ben Alman) подключаемый модуль jQuery BBQ. Все эти библиотеки предоставляют технологии для поддержки кнопки "Назад" и разрешают ссылки с возможностью создания закладок, но некоторые из них оказываются лучше, если учесть простоту использования, поддержку в разных браузерах и активную разработку.

Подключаемый модуль jQuery BBQ

В этой статье я сосредоточусь на подключаемом модуле jQuery BBQ, написанном Беном Алманом (Ben Alman). Имя подключаемого модуля, BBQ, представляет сокращение слов Back Button (кнопка "Назад") и Query library (библиотека запросов). С этого момента я буду называть подключаемый модуль jQuery BBQ просто BBQ.

Бен приложил много усилий, чтобы его подключаемый модуль мог поддерживаться в разных браузерах. По его словам, этот подключаемый модуль был проверен с jQuery 1.3.2, 1.4.1, 1.4.2 в Internet Explorer 6–8, Firefox 2–3.7, Safari 3–4, Chrome 4–5, Opera 9.6–10.1 и Mobile Safari 3.1.1.

Один из многих моментов, которые я принимаю во внимание, прежде чем потратить время и рискнуть добавить библиотеку в свой проект, это активное участие автора. Я хочу знать, что будут исправляться ошибки и будут вноситься улучшения. Меньше всего мне бы хотелось, чтобы библиотека оказалась застоявшейся и мешала выполнению обновления до новой версии jQuery.

Бен прилагает сознательные усилия, чтобы сохранить актуальность BBQ для последней версии jQuery, предоставляя полную документацию по своему подключаемому модулю, и написал набор из более трехсот модульных тестов. Вы можете проверить развитие его проекта на странице проекта GitHub.

Навигация с помощью хэша URL-адреса

Прежде чем углубиться в детали BBQ, давайте кратко рассмотрим текущее состояние браузера и идентификаторы фрагментов. В настоящее время браузеры поддерживают синтаксис #hash в URL-адресе для переходов вверх и вниз на одной и той же странице. Например, я уверен, что вы иногда делали что-то подобное (вы можете просматривать, выполнять и изменять этот пример кода с сайта jsFiddle):

<!-- Вы можете просматривать, выполнять и изменять этот пример кода с http://jsfiddle.net/elijahmanor/rn7As/ -->
<!-- Нерекомендуемый метод использует именованную привязку -->
<a name="top">Top of Page</a>
 
<!-- Рекомендуемый подход использует элемент с атрибутом id  -->
<div id="top">Top of Page</div>
 
<!-- Остаток вашего HTML -->
 
<!-- Привязка к фрагменту
    (нерекомендуемая именованная привязка или элемент с атрибутом id) -->
<a href="#top">Go To Top</a>

Если вы нажмете тег привязки "Go To Top", то страница перейдет в расположение элемента с атрибутом id элемента "top" (или именованной привязки с атрибутом name элемента "top").

Происходит не только отправка вас в фрагмент "Top of Page", но также и URL-адрес дополняется идентификатором фрагмента "#top". Если создать закладку этой страницы и вернуться на нее позже, то вы окажетесь в том же самом положении.

С помощью этого синтаксиса разработчики могут помечать URL-адреса специальными данными и затем отвечать с использованием JavaScript, что собственно и делается в нашем первом примере.

Событие hashchange jQuery

Внутри BBQ использует событие hashchange HTML5 для ответа, когда изменяется идентификатор фрагмента (#hash) в URL-адресе. Бен предоставляет поддержку hashchange в разных браузерах внутри его события hashchange jQuery. В настоящее время только браузеры Internet Explorer 8, Firefox 3.6 и Chrome 5 предоставляют встроенную поддержку. Бен использовал некоторые специальные приемы, чтобы добиться работы события hashchange в других браузерах и в более старых версиях, и если вас это заинтересовало, вы можете узнать об этом более подробно на его веб-сайте.

Пример использования события hashchange

В зависимости от приложения, можно выйти из положения с помощью только события hashchange jQuery, без использования всего подключаемого модуля BBQ. Если в приложении уже используется #hash для обслуживания состояния внутри веб-страницы, то можно воспользоваться самим по себе событием hashchange jQuery. В последнем примере я покажу, как использовать весь подключаемый модуль BBQ, и опишу, когда это необходимо делать.

Я разместил вместе простой пример вкладки jQuery (см. рисунок 1), в котором используются значения #hash в атрибуте href каждого элемента привязки вкладки.


Рисунок 1. Простой пример вкладки jQuery, в котором используются ссылки #hash в атрибуте привязки href

<!-- Вы можете просматривать, выполнять и изменять этот пример кода с http://jsfiddle.net/elijahmanor/BWrQP/ -->
<div class="tabs">
<ul>
<li class="tab"><a href="#div1">Tab 1</a></li>
<li class="tab"><a href="#div2">Tab 2</a></li>
<li class="tab"><a href="#div3">Tab 3</a></li>
</ul>
<div id="div1" class="content">Div 1</div>
<div id="div2" class="content">Div 2</div>
<div id="div3" class="content">Div 3</div>
</div>
//При нажатии вкладки она передается в метод updateTabs
$(".tabs .tab a").live("click", function(e) {
updateTabs($($(this).attr("href")));
});
//Захват URL-адреса для отсутствующего хэша (по умолчанию это первая вкладка) и обновление
$(window).bind("hashchange", function(e) {
var anchor = $(location.hash);
if (anchor.length === 0) {
anchor = $(".tabs div:eq(0)");
}
updateTabs(anchor);
});
//Подача вкладки и отображение соответствующего контента
function updateTabs(tab) {
$(".tabs .tab a")
.removeClass("active")
.filter(function(index) {
return $(this).attr("href") === '#' + tab.attr("id");
}).addClass("active");
$(".tabs .content").hide();
tab.show();
}
//Возникновение события hashchange при первой загрузке страницы
$(window).trigger('hashchange');

В этом примере я использовал стандартную методику #hash для обслуживания состояния в браузере, подобно показанному выше примеру "Back To Top". Мне не потребовалось делать что-то специальное, чтобы получить значение #hash в URL-адресе. Браузер делает это сам, поскольку он распознает эту методику. Я использовал событие hashchange не для перехода в другое место страницы, а для ответа на изменение.

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

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

Один — это наличие $(window).bind("hashchange", function() {});. Это ключевая точка входа для события hashchange. Мы прослушиваем любое изменение location.hash и отвечаем на него в обработчике событий.

Это событие захватывает location.hash, который является href в атрибуте href привязки вкладки. location.hash соответствует атрибуту id содержимого div вкладки. Если #hash отсутствует, то по умолчанию устанавливается первая вкладка.

Второе, что следует отметить, это оператор $(window).trigger('hashchange'); в конце примера кода. Единственная причина для этого события-триггера — загрузка начальной страницы. Если кто-либо переходит на страницу с #hash в качестве части URL-адреса, требуется принять на обработку этот запрос и отобразить соответствующую вкладку, соответствующую этому #hash.

Общие сведения об API подключаемого модуля jQuery BBQ

Прежде чем показать другой пример кода, давайте еще немного изучим подключаемый модуль jQuery BBQ и выделим часть его API.

При начале использования подключаемого модуля jQuery BBQ необходимо знать три основных метода. Эти три основных действия — помещение текущего "состояния" (думайте о нем как о снимке момента времени) страницы, получение этого состояния снова, чтобы можно было на него ответить, и удаление состояния, когда оно не используется.

jQuery.bbq.pushState

Добавляет текущее состояние в журнал браузера.

Обновляет location.hash.

Вызывает событие hashchange.

jQuery.bbq.getState

Извлекает текущее состояние из журнала браузера.

Возвращает либо конкретный ключ из location.hash, либо полное состояние.

jQuery.bbq.removeState

Удаляет один или несколько ключей из текущего журнала браузера.

Создает новое состояние путем обновления location.hash.

Вызывает событие hashchange.

Пример использования подключаемого модуля jQuery BBQ

Давайте рассмотрим немного другой подход из предыдущего примера, и на этот раз атрибут href не будет использоваться для включения значения #hash. Поскольку мы не используем значения #hash в качестве части нашего атрибута href привязки, нам необходимо обработать помещение значений #hash в URL-адрес, получение их и удаление вручную.

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


Рисунок 2. Простой пример вкладки jQuery, в котором подключаемый модуль jQuery BBQ используется для управления историческим состоянием вкладок.

<!-- Вы можете просматривать, выполнять и изменять этот пример кода с http://jsfiddle.net/elijahmanor/cskSw/ -->
<div class="tabs">
   <ul>
      <li class="tab">
         <span class="{contentId: '#div1'}">Tab 1</span>
      </li>
      <li class="tab">
         <span class="{contentId: '#div2'}">Tab 2</span>
      </li>
      <li class="tab">
         <span class="{contentId: '#div3'}">Tab 3</span>
      </li>
   </ul>
   <div id="div1" class="content">Div 1</div>
   <div id="div2" class="content">Div 2</div>
   <div id="div3" class="content">Div 3</div>
</div>

Предыдущий фрагмент HTML предоставляет метаданные в атрибуте class вкладки для сопоставления id содержимого div, которое должно отображаться. Эти метаданные можно извлечь с помощью подключаемого модуля jQuery Metadata. Для тех, кто не знаком с подключаемым модулем jQuery Metadata, он предоставляет способ внедрения сведений JSON в атрибут и извлечения десериализованной версии во время выполнения.

//Помещение состояния tabIndex в BBQ и обработка логики в hashchange
$(".tabs .tab span").live("click", function(e) {
    $.bbq.pushState({ tabIndex: $(this).parent().index() });
    return false;
});
 
//Получение состояния tabIndex из BBQ и обновление в зависимости от выбора
$(window).bind("hashchange", function(e) {
    var tabIndex = $.bbq.getState("tabIndex") || "0",
        tab = $('.tabs .tab').eq(tabIndex);
    
    updateTabs(tab);
});
 
//Подача вкладки и отображение соответствующего контента
function updateTabs(tab) {
    var title = tab.find('span');
    $(".tabs .tab span").removeClass("active");
    title.addClass("active");
    $(".tabs .content").hide();
    $(title.metadata().contentId).show();         
}
 
//Возникновение события hashchange при первой загрузке страницы
$(window).trigger('hashchange');

На этот раз вместо вызова обоими обработчиками событий click вкладки и hashchange окна метода updateTabs я удалил его из обработчика событий вкладки, и теперь он только помещает ее состояние в подключаемый модуль jQuery BBQ. Это позволяет обработчику событий hashchange обрабатывать логику, чтобы соответствующим образом обновлять пользовательский интерфейс.

Обработчику событий click вкладки необходимо поместить ее состояние в подключаемый модуль jQuery BBQ, поскольку мы не используем ту методику #hash, которую понимает браузер. (См. пример "Back To Top" в начале этой статьи.) За кулисами подключаемый модуль jQuery BBQ обновляет URL-адрес, так что браузер понимает, что вы обновили состояние страницы, и сохраняет эти сведения в журнале.

Расширенный пример подключаемого модуля jQuery BBQ

Показанные в последних двух примерах фрагменты кода вкладок — не самые сложные сценарии в мире. Давайте рассмотрим что-нибудь посложнее.

Теперь мы собираемся построить сортируемый список, который можно переупорядочивать, перетаскивая элементы вверх или вниз. Если дважды щелкнуть один из элементов, появляется диалоговое окно, показывающее сведения, полученные с помощью технологии AJAX. После некоторого взаимодействия пользователя с приложением (переупорядочивания элементов, открытия или закрытия диалоговых окон и т. п.) задача состоит в том, чтобы вернуть состояние до действия при нажатии в браузере кнопки "Назад".

Давайте сначала взглянем на HTML, с которым работаем. У нас есть неупорядоченный список с рядом элементов списка, каждый из которых содержит имя пользователя в Твиттере и число записей в Твиттере для извлечения. Также имеется элемент divdialog, в котором отображается результат запроса AJAX. (См. рисунок 3.)


Рисунок 3. Сложный пример переупорядочивания и использования диалоговых окон с поддержкой кнопки "Назад" с помощью BBQ

<!-- Вы можете просматривать, выполнять и изменять этот пример кода с http://jsfiddle.net/elijahmanor/YLDGX/ -->
<div class="demo">
   <ul id="sortable">
      <li id="1" class="ui-state-default { userName : 'jquery', count : '1' }"><span class="ui-icon ui-icon-arrowthick-2-n-s"></span>jQuery</li>
      <li id="2" class="ui-state-default { userName : 'jeresig', count : '2' }"><span class="ui-icon ui-icon-arrowthick-2-n-s"></span>John Resig</li>
      <!-- Другие элементы списка -->
      <li id="7" class="ui-state-default { userName : 'elijahmanor', count : '3' }"><span class="ui-icon ui-icon-arrowthick-2-n-s"></span>Elijah Manor</li>
   </ul>
</div>
 
<div id="dialog" title="Tweets" style="display: none;">
   <div id="output"></div>
</div>

При упорядочивании элемента списка код создает уникальный ключ (число миллисекунд, прошедших с полночи 1 января 1970 г.) и использует его для кэширования порядка элементов списка в массиве. Затем этот уникальный ключ последовательности помещается в подключаемый модуль jQuery BBQ.

Аналогично, когда выполняется двойной щелчок элемента списка, id элемента помещается в BBQ. Затем эти сведения используются для открытия диалогового окна. Также следует упомянуть, что при щелчке диалогового окна его сведения удаляются из BBQ. (Вы можете просматривать, выполнять и изменять следующий пример кода с jsFiddle.)

//Установка упорядочиваемого списка и сохранение массива последовательности в кэше
var sortable = $("#sortable").sortable({
   delay: 500,
   update: function(event, ui) {
      //Создание уникального id и сохранение идентификаторов элементов  списка в кэше
      var indexes = $(this).sortable("toArray");
      var sequenceId = new Date().valueOf().toString();
      $(this).data('sequence').cache[sequenceId] = indexes;
      $.bbq.pushState({ sequenceId: sequenceId });
   }
}).disableSelection();
 
//При щелчке элемента списка идентификатор диалога помещается в BBQ
$("#sortable li").live('dblclick', function(e) {
   $.bbq.pushState({ dialogId : $(this).attr('id')});
   return false;
});
 
//Установка начального состояния последовательности при первой загрузке страницы
var initialSequence = sortable.sortable("toArray");
sortable.data('sequence', {
   cache: {
      '': initialSequence
   }
});
 
//Установка диалога и при закрытии удаление id диалога из BBQ
var dialog = $('#dialog').dialog({
   autoOpen: false,
   width: 400,
   modal: true,
   buttons: {
      "Close": function() {
         $(this).dialog("close");
      }
   },
   close : function(event, ui) {
      $.bbq.removeState("dialogId");
   }
});
 
//Открытие диалога и сортировка элементов списка, если BBQ содержит состояние
$(window).bind("hashchange", function(e) {
      //sequenceId: ключ в кэше для индексов последовательности
   var sequenceId = e.getState("sequenceId") || '',
      //dialogId: id элемента списка, используемого в диалоге
      dialogId = e.getState("dialogId") || '',
      //metadata: метаданные, подключенные к элементу списка
      metadata = dialogId ? 
         $('#' + dialogId).metadata() : null,
      //indexes: массив последовательности идентификаторов элементов списка
      indexes = sortable.data('sequence').cache[sequenceId];
    
   //Открытие диалога с помощью id элемента списка или закрытие при отсутствии
   if(dialogId) {
      appendTweets(metadata.userName, 
         metadata.count, $("#output")); 
      dialog.dialog('open');
   } else {
      dialog.dialog('close');
   }
    
   //Сортировка элементов списка на основе индексов, сохраненных в кэше
   $("#sortable li").reorder(indexes);
});
 
//Извлечение числа X записей для userName и добавление в selection
function appendTweets(userName, count, selection) {
   var url = 'http://twitter.com/status/user_timeline/' + 
      userName + '.json?count=' + count + '&callback=?';
    
   dialog.dialog('option', 'title', '@' + userName);
   selection.empty();
   $.getJSON(url, function(data) {
      console.dir(data);
      var list = $('<ol />');
      $.each(data, function(i, item) {
         console.log(item.text);
         $('<li />', { text : item.text }).appendTo(list);
      });
      list.appendTo(selection);
   });
}
 
//Переупорядочивание элементов на основе идентификаторов в параметре indexes
$.fn.reorder = function(indexes) {
   if (!indexes) { return this; }
    
   $.each(indexes, function(index, id) {
      var item = $('#' + id);
      if (item.attr("id") === id) {
         var parent = item.parent();
         item.remove().appendTo(parent);
      }
   });
    
   return this;
};
 
//Возникновение события hashchange при первой загрузке страницы
$(window).trigger('hashchange');

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

В обработчике событий hashchange мы извлекаем id элемента списка из BBQ. Если id существует, значит должно быть отображено диалоговое окно. К элементу списка подключаются метаданные со сведениями Твиттера, которые используются контентом AJAX для отображения в диалоговом окне.

Обработчик событий hashchange также захватывает идентификатор последовательности, который является ключом для кэшированного списка упорядоченных элементов из конкретного состояния (момента времени). Затем код переупорядочивает этот неупорядоченный список с помощью написанного мной небольшого подключаемого модуля jQuery.

Более глубокий обзор документации

Предыдущие примеры в этой статье так или иначе связаны с тремя методами pushState, getState и removeState.

Однако подключаемый модуль jQuery BBQ предоставляет гораздо больше функций, которые модно использовать в приложениях. Многие из этих методов используются закулисно при вызове трех методов состояния.

Например, возможно вы знаете о методе jQuery.param, который предоставляется корневой библиотекой jQuery. Этот метод сериализует массив или объект в формат, подходящий для строки запроса URL-адреса. Однако корневая библиотека jQuery не предоставляет метод для десериализации строки запроса URL-адреса. Здесь вступает в действие BBQ.

BBQ предоставляет метод jQuery.deparam для десериализации строки запроса URL-адреса в объект. Кроме того, Бен предоставляет методы jQuery.deparam.querystring и jQuery.deparam.fragment, которые выполняют десериализацию определенных частей URL-адреса. Как можно понять, эти методы жизненно важны для внутренней работы подключаемого модуля, но особо впечатляет то, что они также подходят и для использования вами!

Если вас заинтересовали эти и другие методы, см. расширенную документацию по BBQ и сопутствующие примеры.

Кто такой Бен Алман (Ben Alman)?

Кроме подключаемого модуля jQuery BBB, Бен Алман принимал участие во многих других полезных проектах, включая следующие.

jQuery equalizeBottoms — позволяет устанавливать одинаковую высоту нескольких элементов.

jQuery outside events — позволяет вызывать событие, когда пользователь осуществляет взаимодействие вне конкретного элемента.

jQuery replaceText — позволяет заменять текст в выделенной области, не путая все элементы и значения атрибутов.

jQuery resize event — позволяет выполнять привязку к событию изменения размера конкретного элемента модели DOM.

jQuery throttle / debounce — позволяет ограничить методы по времени. Это может быть полезно, если вы не хотите бомбардировать сервер тоннами запросов.

Бен не только разработал много высококачественных проектов, но также принимал участие во внесении существенных изменений в корневую библиотеку jQuery. Если вы серьезно заинтересовались jQuery, то можете разобраться, посетив Бена Алмана в его блоге, Твиттере или на сайте GitHub.

Заключение

В сегодняшних решениях на основе JavaScript и технологии AJAX с высокой степенью интерактивности не следует терять функциональность, которую использует и ожидает пользователь, такую как поддержка журнала и кнопки "Назад", а также закладки. С помощью подключаемого модуля jQuery BBQ можно предоставить эти функции пользователям, не ставя под угрозу высокий уровень их удовлетворенности.

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