AJAX и все, все, все

В предыдущей серии мы делали простенькое Grails-приложение с использованием jQuery, а также решили для себя, что использовать jQuery в Grails можно и даже нужно. Обсудим более серьезные вещи, которые можно сделать с такой связкой.

Нетрудно заметить, что все больше сайтов используют AJAX и частичные обновления страниц, причем в невероятном количестве. В частности, «начиненные» AJAX ссылки могут использоваться для внутренней навигации по странице, переключения каких-то вкладок. Это хорошо тем, что
А) меньше данных нужно перегонять от сервера — только нужный кусок страницы и
Б) веб-страницы часто загружают просто гигантские CSS и JavaScript-файлы, которые при AJAX-обновлении можно повторно не загружать.

Итак, очень распространено построение приложений по сценарию: одна большая «стартовая» страница, загружающая весь JavaScript-код и CSS и более мелкие «внутренние» функциональные блоки, загружаемые через AJAX. С этим есть ряд проблем:

  1. В результате AJAX-действий внутреннее состояние страницы не отражено в адресной строке браузера.
  2. Как следствие, внутренние страницы не могут быть запомнены в закладки, нельзя «отправить ссылку другу».
  3. Не работает 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. У нашего приложения будет три вкладки:anchor1

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

Для начала нарисуем 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, получим полный вариант. Теперь это выглядит так:anchor2

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
anchor3 anchor4 anchor5

Все очень здорово, но, как выясняется, наши «динамические» ссылки нельзя поместить в закладки. Дело в том, что они содержат 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-навигацией, которое:

  1. Перезагружает только внутреннее содержимое страницы без перегрузки всей страницы.
  2. Правильно сохраняет состояние страницы в адресной строке, т.е. годится для закладок и передачи ссылки знакомым.
  3. Правильно реагирует на переходы Back/Forward в браузере.
  4. Позволяет разрабатывать серверный код «по-старому», не заморачиваясь новыми правилами игры, т.к. вся логика загрузки страниц сделана прозрачным (для контроллеров и GSP-страниц) образом.
  5. Ссылки можно открывать в новом окне и они будут работать.
  6. Отмечу, что и ссылки в нашем приложении ничем не отличаются от обычных ссылок, за исключением решетки(#) в начале URL! Ведь мы заботливо унесли всю логику работы ссылок в jQuery-код.

Конечно, в реальном приложении приведенный здесь код придется улучшить, в частности, более аккуратно учитывать ситуации отсутствия якоря, использовать более сложную схему построения ссылок и т.п. Однако надеюсь, что он успешно демонстрирует идею и решает большую часть проблем с anchor-навигацией.

Ссылки:

  1. http://yensdesign.com/2008/11/creating-ajax-websites-based-on-anchor-navigation/
  2. GWT Tutorial – Managing History and Hyperlinks