Pull to refresh

Grails, jQuery, AJAX: делаем anchor-навигацию. Часть 1

Groovy & Grails *

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 можно получить прямую ссылку на письмо, причем ссылка будет bookmarkable. Ссылка будет выглядеть примерно вот так:

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')}" />
        <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').each(function() { $(this).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'
      }
    }
}


Однако этого недостаточно. Есть проблемы: нашу клиентскую и серверную часть надо доработать, о чем напишу в следующей части.
Tags:
Hubs:
Total votes 43: ↑37 and ↓6 +31
Views 16K
Comments Comments 51