AJAX и все, все, все
В предыдущей серии мы делали простенькое Grails-приложение с использованием jQuery, а также решили для себя, что использовать jQuery в Grails можно и даже нужно. Обсудим более серьезные вещи, которые можно сделать с такой связкой.
Нетрудно заметить, что все больше сайтов используют AJAX и частичные обновления страниц, причем в невероятном количестве. В частности, «начиненные» AJAX ссылки могут использоваться для внутренней навигации по странице, переключения каких-то вкладок. Это хорошо тем, что
А) меньше данных нужно перегонять от сервера — только нужный кусок страницы и
Б) веб-страницы часто загружают просто гигантские CSS и JavaScript-файлы, которые при AJAX-обновлении можно повторно не загружать.
Итак, очень распространено построение приложений по сценарию: одна большая «стартовая» страница, загружающая весь JavaScript-код и CSS и более мелкие «внутренние» функциональные блоки, загружаемые через AJAX. С этим есть ряд проблем:
- В результате AJAX-действий внутреннее состояние страницы не отражено в адресной строке браузера.
- Как следствие, внутренние страницы не могут быть запомнены в закладки, нельзя «отправить ссылку другу».
- Не работает Back/Forward навигация в браузере, т.к. AJAX-ссылки не попадают в историю браузера.
Однако крупные сайты нашли некое «хакерское» решение, которое мы сейчас рассмотрим и напишем небольшой свой собственный аналог на Grails и jQuery.
Anchor-навигация
Дело в том, что на самом деле можно изменить адресную строку браузера без перезагрузки страницы, если менять только якорь (anchor), т.е. последнюю часть адресной строки, следующей за решеткой — #
. Браузер воспринимает это переход внутри страницы, причем спокойно игнорирует ситуацию, когда нужного якоря на странице нет, просто обновляя адрес и историю. Это как раз нам и нужно. Если сохранять состояние страницы внутри якоря, тогда можно будет к нему вернуться через закладку и можно пользоваться Back/Forward переходами (!). При этом базовый URL страницы не изменится и перезагрузки страницы не произойдет.
За примерами реализации подобного решения далеко ходить не нужно. Такая схема применяется в Facebook, Gmail, Google Picasa Web Albums, в значительном объеме это можно увидеть на odnoklassniki.ru. Библиотека Google Web Toolkit целиком базируется на anchor-навигации.
Скажем, в Gmail можно получить прямую ссылку на письмо. Ссылка будет выглядеть примерно вот так:
https://mail.google.com/mail/?shva=1#inbox/12c5f14c01a5473c
Ежу ясно, что 12c5f14c01a5473c
— это какой-то внутренний ID письма.
Пишем приложение
Подумаем на тему реализации такого подхода. Адресная строка меняется просто:
document.location.hash = '#myAnchor'; |
(либо напрямую через ссылку <a href="#myAnchor">My Link</a>
).
Начнем писать Grails-приложение my-app
с навигацией, целиком основанной на AJAX. У нашего приложения будет три вкладки:
Внешне это выглядит как обычная страница, но мы хотим добиться, чтобы обновлялась только внутренняя часть страницы без полной перезагрузки.
Для начала нарисуем SiteMesh layout примерно такого вида:
grails-app/views/layouts/main.gsp
<html> <head> <title><g:layoutTitle default="Grails" /></title> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> <link rel="stylesheet" href="${resource(dir:'css',file:'main.css')}" /> <link rel="shortcut icon" href="${resource(dir:'images',file:'favicon.ico')}" type="image/x-icon" /> <g:layoutHead /> <g:javascript library="jquery"/> <g:javascript library="application" /> </head> <body> ... %{-- Навигационные ссылки --}% <div class="navbar"> <div class="navitem"> <a href="#do/receipts" class="navlink">Рецепты</a> <div class="spinner" /> </div> <div class="navitem"> <a href="#do/buy" class="navlink">Где купить</a> <div class="spinner" /> </div> <div class="navitem"> <a href="#do/feedback" class="navlink">Отзывы</a> <div class="spinner" /> </div> </div> %{-- Тело страницы --}% <div id="pageContent"> <g:layoutBody /> </div> ... </body> </html> |
Как видим, ссылки ничем не отличаются от обычных anchor-ссылок. Как же они работают? Для этого напишем такой код на jQuery:
web-app/js/application.js
// Здесь сохраняем текущее состояние страницы var currentState = ''; function buildURL(anchor) { return document.location.toString().substr(0, document.location.toString().indexOf('#')) + anchor.substr(1); } function clickNavLink() { // Уже там? var href = $(this).attr('href'); // Игнорируем переход на уже загруженную страницу if (href == currentState) { return false; } if (document.location.hash != href) { document.location.hash = href; } // Загружаем страницу var link = this; // Показываем индикатор загрузки $(this).parent().find('.busy').show(); $(this).hide(); var targetURL = buildURL(href); currentState = href; // сразу поменяем состояние, чтобы избежать повторных кликов $.ajax({ context:$('#pageContent'), url:targetURL, dataType:'html', method:'GET', complete: function() { // Отмечаем активную ссылку. $(link).show(); updateNavLinks(); }, success: function(data) { // Обновляем "динамическую" часть страницы. $('#pageContent').html(data); } }); return true; } // Обновляем состояние ссылок, чтобы отметить активные/неактивные function updateNavLinks() { $('a.navlink').each(function(i) { var href = $(this).attr('href'); $(this).parent().find('.busy').hide(); if (href == currentState) { $(this).addClass('disabled'); } else { $(this).removeClass('disabled'); } }); } // Финал. Вешаем события на навигационные ссылки. jQuery(document).ready(function() { $('a.navlink').click(clickNavLink); }); |
Здесь все довольно просто: текущее состояние страницы мы храним в JavaScript-переменной currentState
. Внутреннюю страницу при клике на ссылку загружаем через AJAX, результат AJAX-вызова сохраняем в div#pageContent
. При этом URL загружаемой страницы формируется путем добавления anchor-пути к базовому адресу страницы, т.е.
/my-app/#do/receipts => /my-app/do/receipts
Это простое правило сразу помогает нам понять, что делает ссылка. Для того, чтобы обознать ссылку как «текущую», мы назначаем ей класс disabled
. В CSS (который я приводить не буду) этот класс будет отображаться другим цветом, чтобы было видно, какая ссылка является текущей (visited).
Серверная часть
Теперь хорошо бы сделать серверную начинку. Я написал простейший контроллер для обработки всех трех ссылок:
grails-app/controllers/DoSomethingController.groovy
class DoSomethingController { def receipts = { [receipts:['Курица с мандаринами', 'Пельмешки']] } def buy = { [places:['Ларёк у метро', 'Чебуречная №1']] } def feedback = { [feedback:['нравится','не нравится','не нравится, но ем!']] } } |
и к нему сделал три простенькие страницы. Приведу только одну из них:
grails-app/views/doSomething/receipts.gsp
<%-- Список рецептов --%> <%@ page contentType="text/html;charset=UTF-8" %> <html> <head> <meta name="layout" content="main" /> </head> <body> <h1>Чего и как приготовить поесть</h1> <ul> <g:each in="${receipts}" var="receipt"> <li>${receipt.encodeAsHTML()}</li> </g:each> </ul> </body> </html> |
Теперь повесим контроллер на наши ссылки /do/*
:
grails-app/conf/UrlMappings.groovy
class UrlMappings { static mappings = { "/do/$action?/$id?" { controller = 'doSomething' } } } |
Полная и неполная страницы
Есть одна тонкость. Очень хочется, чтобы страница могла быть показана как в полном варианте (с шапкой, навигацией и т.п.), так и в сокращенном (для AJAX-вызовов). Однако набрав /my-app/do/receipts
, получим полный вариант. Теперь это выглядит так:
Oops! Надо как-то различать ситуацию, когда страница является главной и когда она внутренняя. Для этого напишем небольшой фильтр:
grails-app/conf/PartialPageLoadFilters.groovy
def filters = { partial(controller: "*", action: "*") { before = { // Это AJAX-запрос? if (request.xhr) { // Нужно показывать как внутреннюю страницу. request.partialPage = true } true } } } |
Теперь я могу везде использовать флаг request.partialPage
. Можно сделать отдельный layout для внутренних страниц, но я предпочел просто тупо сделать вот такие вставки в основной layout:
grails-app/views/layouts/main.gsp
<g:if test="${request.partialPage}"> %{-- Частичный вариант --}% <g:layoutBody /> </g:if> <g:else> %{-- Полный вариант страницы --}% <html> <head> ... </head> <body> ... <div id="pageContent"> <g:layoutBody /> </div> ... </body> </html> </g:else> |
Мы добились полной прозрачности (для контроллера) в том, какой из вариантов страницы показывать.
Закладки и история
Итак, имеем три экрана, переключаемые через AJAX:
/my-app/#do/receipts | /my-app/#do/buy | /my-app/#do/feedback |
---|---|---|
![]() |
![]() |
![]() |
Все очень здорово, но, как выясняется, наши «динамические» ссылки нельзя поместить в закладки. Дело в том, что они содержат anchor-хвост, который на сервер не передается. Поэтому сервер при загрузке такого URL не знает, какую из внутренних страниц показывать.
Про anchor знает только JavaScript. Напрашивается такое решение: сначала загрузить базовую страницу с JavaScript, затем запустить код, определяющий текущий anchor и загружающий внутреннюю часть при помощи AJAX. Особо не заморачиваясь, я написал такой код:
web-app/js/application.js
$.ready(function() { $('#pageContent').html('Загружаем...').load(buildURL(document.location.hash), function() { // Перерисовываем ссылки updateNavLinks(); }); }); |
Этот код лишь демонстрирует идею. При желании этот код можно допилить до более красивого состояния, однако суть не изменится: сначала придется загрузить базовую обертку, затем пользователю придется ждать загрузки внутренней части. Например, Facebook обычно вообще ничего не показывает, пока внутренняя часть не загрузится — дело вкуса.
А как быть с историей? При переходах Back/Forward наше событие $.ready
не сработает. Такие переходы браузер считает «перескоком» от одного якоря к другому, т.е. просто пролистыванием страницы. Никаких уникально идентифицируемых JavaScript-событий при этом не возникает. Что делать?
Один из способов решить это (сам по себе довольно брутальный) — периодически проверять свойство document.location.hash
на предмет изменений. Если вдруг текущий якорь изменился, нужно перегрузить внутреннюю часть страницы.
function checkLocalState() { if (document.location.hash && document.location.hash != currentState) { currentState = document.location.hash; $('#pageContent').html('Ссылка изменилась, загружаем...').load(buildURL(currentState), function() { // Перерисовываем ссылки updateNavLinks(); }); } } |
Теперь проверяем изменение якоря каждые 500 миллисекунд:
$.ready(function() { setInterval(checkLocalState(), 500); }); |
Теперь наше приложение будет реагировать на переходы Back/Forward, но с задержкой в полсекунды. Такую задержку (иногда раздражающую пользователя) можно увидеть на многих крупных сайтах, использующих сходную схему навигации. Если уменьшить интервал, фоновый JavaScript будет есть больше ресурсов. Если интервал увеличить, возрастет время реакции. В общем, за все надо платить.
Резюме
Мы создали Grails + jQuery приложение с AJAX-навигацией, которое:
- Перезагружает только внутреннее содержимое страницы без перегрузки всей страницы.
- Правильно сохраняет состояние страницы в адресной строке, т.е. годится для закладок и передачи ссылки знакомым.
- Правильно реагирует на переходы Back/Forward в браузере.
- Позволяет разрабатывать серверный код «по-старому», не заморачиваясь новыми правилами игры, т.к. вся логика загрузки страниц сделана прозрачным (для контроллеров и GSP-страниц) образом.
- Ссылки можно открывать в новом окне и они будут работать.
- Отмечу, что и ссылки в нашем приложении ничем не отличаются от обычных ссылок, за исключением решетки(
#
) в начале URL! Ведь мы заботливо унесли всю логику работы ссылок в jQuery-код.
Конечно, в реальном приложении приведенный здесь код придется улучшить, в частности, более аккуратно учитывать ситуации отсутствия якоря, использовать более сложную схему построения ссылок и т.п. Однако надеюсь, что он успешно демонстрирует идею и решает большую часть проблем с anchor-навигацией.
Ссылки: