31 October 2011

Реализация одностраничного приложения средствами History API в ASP.NET MVC

ASP
Sandbox
Добрый день уважаемые хабаровчане. На сайте уже не раз поднимался вопрос о проблеме создания одностраничных ajax приложений. С такой задачей некоторое время назад столкнулся и я. Однако я недоумевал, почему обладая возможностями html5 и мощью MVC я должен столько всего прописывать вручную, да еще и с помощью js.

Возможно именно [holywar=on]неприязнь к языку js[holywar=off] побудили меня создать простое решение, опирающиеся на возможности ASP.NET MVC. Далее, я подробно опишу проблемы, которые возникают при попытке создать одностраничное ajax приложение, и поэтапно рассмотрю создание полноценного решения.

Если стало интересно — добро пожаловать под кат (код и картинки прилагаются).

Итак, экспериментировать будем со стандартным шаблонным приложением MVC 3. Удалим из него все лишнее, чтобы получилась конфигурация проекта, примерно как на рисунке ниже и начнем поэтапно реализовывать загрузку.
image

Этап 1 — Анализ проблем


Когда я только познакомился с Ajax.ActionLink я подумал — Ага! Что может быть проще, реализовать все приложение таким образом! Главный блок в шаблоне страниц (_Layout.cshtml) идентифицируем как main и загружаем в него все остальные страницы. Однако, сразу возникают проблемы:
  1. Пользователь перешел на новую страницу и ожидает что кнопка «Назад» вернет его на предыдущую, однако этого не происходит — пользователя возвращает не на предыдущую, а на первую полностью загруженную страницу. Кнопка «Вперед» также не работает.
  2. Пользователь перешел на новую страницу и захотел обновить ее (например, с помощью F5), однако вместо обновления текущей страницы, загружается вновь та первая полностью загруженная страница.
  3. Пользователь открыл ссылку на новую страницу в новой вкладке/окне. Однако, вместо открытия нормальной страницы загрузился PartialView без основного шаблона, стилей и скриптов.
  4. Пользователь прошелся по страницам сайта, потом отвлекся и захотел по названию вкладки(окна) найти ту страницу, с которой он работал до этого. Но название страницы лежит в _Layout.cshtml и поэтому также не изменилось в процессе навигации.
  5. Отвлечемся от навигации. Пользователь таки перешел на нужную ему страницу ввода данных, но… клиентская валидация данных на странице, которую вернул ajax не работает!
  6. А теперь пользователь перешел по ссылке, которая вытаскивает из базы данных 100500 записей и выполняет над ними операции. А потом еще раз зашел по этой же самой ссылке, так как решил, что «что-то у меня опять не нажалось». А потом еще. И после этого, лучше бы пользователь не дожидался окончания всех этих запросов, такое начнет творится на странице… =)

Проблем набралось не мало. И не все из них решаются простым js кодом. Тут нужен комплексный подход к архитектуре. А раз так, то сразу добавим еще парочку требований:
  1. Пусть при загрузке новой страницы, текущая просто блокируется и пользователю выводится сообщение о том, что именно сейчас происходит в системе.
  2. Пусть навигация по истории будет простой и интуитивной, как-будто приложение работает в обычном многостраничном режиме.
  3. Как я уже говорил, я не люблю js. Пусть максимальное количество кода будет написано на c#. А на js мы напишем всего около 10 строк.

Наконец принятые технические ограничения:
  1. Будем использовать фишки html5.
  2. Не будем заниматься версткой и дизайном. Просто заставим наш портал правильно функционировать.
  3. Наше решение должно быть максимально простым в использовании при усложнении портала. Сразу заложим в него поддержку областей, основную часть функционала вынесем в Ajax/Html хелперы.
  4. Чтобы упростить решение, примем несколько магических соглашений об именовании. Об этих соглашениях речь пойдет далее.

Проблемы описаны, цели поставлены, поехали!

Этап 2 — Реализация контроллера


Начнем с контроллера. Очевидно, что для того, чтобы загружать как части страниц с помощью ajax, так и страницы полностью (в случае, если пользователь непосредственно перешел по ссылке, либо обновил страницу) необходимы действия, которые возвращали бы как PartialViewResult, так и ViewResult.

Поэтому в контроллер для каждого логического действия помещаем два физических — один вида SomeAction: ViewResult, второй AjaxSomeAction: PartialViewResult. Приставка Ajax в этом случае — первое магическое соглашение об именовании. Запомним и далее будем использовать этот факт.
Итого получаем:
public class HomeController : Controller
    {
        private Object GenerateIndexPage()
        {
            Object model = null;
            for (int i = 0; i < 1000000000; i++)
            { }
            return model;
        }
        [HttpGet]
        public ViewResult Index()
        {
            return View("Index", GenerateIndexPage());
        }
        [HttpGet]
        public PartialViewResult AjaxIndex()
        {
            return PartialView("Index", GenerateIndexPage());
        }
        [HttpGet]
        public ViewResult About()
        {
            return View("About");
        }
        [HttpGet]
        public PartialViewResult AjaxAbout()
        {
            return PartialView("About");
        }
    }

Метод GenerateIndexPage() таким нехитрым способом эмулирует какие-то долгие операции, например обращение к БД.

Этап 3 — _Layout.cshtml


Теперь займемся общим шаблоном. Привожу сразу его код:
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>Приложение AjaxNavigation</title>
    <link href="@Url.Content("~/Content/Site.css")" rel="stylesheet" type="text/css" />
    <script src="@Url.Content("~/Scripts/jquery-1.5.1.min.js")" type="text/javascript"></script>
    <script src="@Url.Content("~/Scripts/jquery.validate.min.js")" type="text/javascript"></script>
    <script src="@Url.Content("~/Scripts/jquery.validate.unobtrusive.min.js")" type="text/javascript")></script>
    <script src="@Url.Content("~/Scripts/jquery.unobtrusive-ajax.min.js")" type="text/javascript")></script>
    <script src="@Url.Content("~/Scripts/ajaxnavigation.js")" type="text/javascript")></script>
</head>
<body>
    <div id="loadLayout" style="display: none; position: fixed; z-index: 20; top: 0px;
        left: 0px; width: 100% !important; height: 100% !important;">
        <div style="margin-left: -24px; margin-top: -24px; position: relative; top: 50%;
            left: 50%; z-index: 20;">
            <div id="loadMessage">
            </div>
            <div>
                <img src="@Url.Content("~/Content/progress.gif")" alt="Загрузка..." />
            </div>
        </div>
    </div>
    <div class="page">
        <header>
            <nav>
                <ul id="menu">
                    <li>@Ajax.ActionLinkTo("Домашняя страница", "Index", "Home", "", "Загрузка домашней страницы...")</li>
                    <li>@Ajax.ActionLinkTo("Страница поддержки", "About", "Home", "", "Загрузка страницы поддержки...")</li>
                </ul>
            </nav>
        </header>
        <div id="main">
            @RenderBody()
        </div>
    </div>
</body>
</html>

Итак, прежде всего подключаем библиотеки jquery. Загружаем их все сразу, чтобы не создавать себе лишних проблем с ручной дозагрузкой при ajax навигации. Скрипт ajaxnavigation.js мы напишем напишем далее и этот файл будет очень маленьким ~ 10 строк кода, как я упоминал ранее.

Заголовок страницы имеет вполне определенное содержание — название нашего приложения. В принципе, можно оставить его пустым — мы будем назначать его с помощью js при каждой загрузке страницы вручную.

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

Блок с именем main содержит в себе непосредственно тело страницы. Именно его содержимое мы будем обновлять, используя ajax. Вкупе с двумя предыдущими блоками (loadLayout и loadMessage), блок main также имеет магическое название, его мы будем использовать далее.

Ajax.ActionLinkTo(...) создает ссылку со всеми сопутствующими параметрами. Подробно рассмотрим его далее.

Этап 4 — Ajax.ActionLinkTo(...)


Данный хелпер является небольшим расширением над стандартным Ajax.ActionLink(...):
public static class AjaxHelpers
    {
        /// <summary>
        /// Метод создает ссылку, которая с помощью Ajax запроса загружает в контейнер с именем main 
        ///     требуемый PartialView
        /// </summary>
        public static MvcHtmlString ActionLinkTo(this AjaxHelper ajaxHelper, String linkText, String actionName, String controllerName = null, String areaName = null, String loadMessage = null, Object routeValues = null, Object htmlAttributes = null)
        {
            // Создаем маршрут
            RouteValueDictionary routeValueDictionary = new RouteValueDictionary(routeValues);
            if (!String.IsNullOrEmpty(actionName) && !routeValueDictionary.ContainsKey("action"))
            {
                routeValueDictionary.Add("action", actionName);
            }
            if (!String.IsNullOrEmpty(controllerName) && !routeValueDictionary.ContainsKey("controller"))
            {
                routeValueDictionary.Add("controller", controllerName);
            }
            if (!routeValueDictionary.ContainsKey("area"))
            {
                if (!String.IsNullOrEmpty(areaName))
                    routeValueDictionary.Add("area", areaName);
                else
                    routeValueDictionary.Add("area", "");
            }

            // Создаем параметры Ajax            
            AjaxOptions ajaxOptions = new AjaxOptions()
            {
                UpdateTargetId = "main",
                InsertionMode = InsertionMode.Replace,
                HttpMethod = "GET",
                LoadingElementId = "loadLayout",
                OnBegin = "changeLoadMesage('" + loadMessage + "')",
                OnSuccess = "onPageLoaded()"
            };

            // Возвращаем строку
            String ajaxActionName = "Ajax" + actionName;
            return ajaxHelper.ActionLink(linkText, ajaxActionName, null,
                routeValueDictionary, ajaxOptions, HtmlHelper.AnonymousObjectToHtmlAttributes(htmlAttributes));
        }
    }

Хелпер создает необходимый маршрут с учетом области (ведь все очень многие реальные проекты имеют области), и задавать их вместе с действием и контроллером оказалось чрезвычайно удобно. Этот маршрут нам понадобится, чтобы вставлять его в адресную строку браузера при ajax-навигации по страницам, а также для ведения истории навигации.

Объект AjaxOptions заменит тело блока main на полученный контент и выполнит две js функции (о них подробно чуть позже):
  • changeLoadMesage — меняет текст, который будет отображается пользователю в процессе загрузки новой страницы.
  • onPageLoaded — вызывается после успешной загрузки страницы и производит некоторые действия, связанные с адресной строкой и историей.

Также следует отметить, что на самом деле, хоть при вызове хелпера мы и указали имя действия, как Index/About, хелпер подставит упомянутую выше приставку Ajax и вызовет именно действие, предназначенное для Ajax запроса, т.е. AjaxIndex/AjaxAbout.

Этам 5 — Магический скрипт ajaxnavigation.js


Настало время и для скрипта, который содержит в себе всего три функции. Во-первых элементарная функция смены надписи, которая отображается при загрузке:
// Функция, вызываемая при старте загрузки страницы через Ajax
function changeLoadMesage(message) {
    // Назначаем поясняющий текст окну загрузки
    $("#loadMessage").empty();
    if (message != null)
        $("#loadMessage").append(message);
}

Во-вторых функция, вызываемая после загрузки части страницы:
// Функция, вызываемая при окончании загрузки страницы через Ajax
function onPageLoaded() {
    // Запоминаем новое состояние
    var url = $("#pageUrl").html().replace("&", "&");
    window.history.pushState("ajax", document.title, url);
    // Устанавливаем новый заголовок страницы
    document.title = $("#pageTitle").html();
    // Пересчитываем клиентскую валидацию
    $.validator.unobtrusive.parse($("#main"));
}

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

Третья функция вызывается при загрузке страницы:
// Метод выполняется при загрузке документа
$(document).ready(function () {
    // Подписываемся на навигацию браузера по страницам
    window.onpopstate = function (event) {
        if (event.state == "ajax")
            window.location.reload();
        else
            window.history.replaceState("ajax", document.title, window.location.href);
        event.preventDefault();
    };
    // Устанавливаем новый заголовок страницы
    document.title = $("#pageTitle").html();
});

Она также меняет заголовок документа, а также подписывается за загрузку какого-либо состояния из истории. Если состояние помечено, как "ajax", то перезагружаем страницу, если же нет — то просто помечаем его таким образом для последующей перезагрузки.

Этап 6 — Представление и Html.PageInfo


Типичное представление в нашем случае выглядит таким образом:
@Html.PageInfo("Домашняя страница","/Home/Index")
<h2>Home</h2>
<p>
    Home page
</p>

Хелпер Html.PageInfo(...) добавляет скрытые блоки с заголовком документа и с ссылкой на текущую страницу. Как мы видели ранее, эти скрытые блоки используются в скриптах. Хелпер очень простой:
public static class HtmlHelpers
    {
        /// <summary>
        /// Создает информационные блоки:
        ///     1) "pageTitle" - с названием страницы
        ///     2) "pageUrl" - с адресом страницы
        /// </summary>
        public static MvcHtmlString PageInfo(this HtmlHelper helper, String title, String url)
        {
            // Create title
            TagBuilder pageTitle = new TagBuilder("div");
            pageTitle.SetInnerText("Приложение AjaxNavigation - " + title);
            pageTitle.MergeAttribute("id", "pageTitle");
            pageTitle.MergeAttribute("style", "display: none;");
            // Create url
            TagBuilder pageUrl = new TagBuilder("div");
            pageUrl.SetInnerText(url);
            pageUrl.MergeAttribute("id", "pageUrl");
            pageUrl.MergeAttribute("style", "display: none;");
            return new MvcHtmlString(pageTitle.ToString(TagRenderMode.Normal) + pageUrl.ToString(TagRenderMode.Normal));
        }
    }

Этап 7 — Промежуточное подведение итогов


Теперь можно запустить наше приложение, не забыв подключить созданные html/ajax хелперы в web.config папки Views:
<system.web.webPages.razor>
    ...
      <namespaces>
        <add namespace="System.Web.Mvc" />
        <add namespace="System.Web.Mvc.Ajax" />
        <add namespace="System.Web.Mvc.Html" />
        <add namespace="System.Web.Routing" />
        <add namespace="AjaxNavigation.Core"/>
      </namespaces>
    </pages>
  </system.web.webPages.razor>

Быстро протестировать приложение мы можем такой последовательностью, я проиллюстрировал ее скриншотами:
  1. Загрузить страницу /Home/Index;
  2. Убедится, что ссылка About ведет на страницу /Home/AjaxAbout;
  3. Перейти по ссылке и убедится, что та загрузится с помощью ajax и в адресной строке появится ссылка /Home/About;
  4. Перейти по ссылке Index, которая также указывает на действие /Home/AjaxIndex;
  5. Убедится, что при длительной операции отображается сообщение и невозможно перейти по какой-либо другой ссылке;
  6. После загрузки страницы Index, убедится что история сохранилась и по ней доступна навигация («Назад» и «Вперед»);
  7. Убедится, что после перехода на какую-либо страницу, обновление страницы работает корректно.

image
image
image
Осталась одна вредная проблема. По-прежнему не работает открытие нашей ссылки в новой вкладке/окне:
image

Этап 8 и последний — Открыть в новой вкладке…


Попытаемся понять в чем же проблема. Очевидно, что при попытке браузера открыть ссылку /Home/AjaxIndex в новой вкладке, никакой ajax не выполнится. В этом случае, нам необходимо возвращать полное представление страницы, а не только ее часть. Но как определить, что браузер открыл новое окно?

Для этого нам понадобится какое-нибудь диагностическое средство, я использую Fiddler, очень простой и быстрый анализатор трафика. Запускаем его и сравниваем два запроса, которые браузер посылает на сервер:
  • при обычном переходе по ссылке
  • при попытке открыть ссылку в новом окне

image
image

Теперь понятно, что в случае реального ajax запроса, браузер присоединяет заголовок X-Requested-With. Осталось написать фильтр входящих запросов, который бы переадресовывал нас с частичной страницы на полную.
Сказано — сделано:
public abstract class ControllerBase : Controller
    {
        protected override void Execute(RequestContext requestContext)
        {
            // Если браузер запрашивает метод с именем Ajax{Something}, значит он ожидает получить PartialView
            //  однако, если при этом не задан заголовок X-Requested-With, значит пользователь попытался отобразить ссылку в новом окне/вкладке
            //  и, следовательно, его нужно перенаправить на полную страницу.
            Boolean isAjaxRequest = requestContext.HttpContext.Request.QueryString["X-Requested-With"] != null;
            Boolean urlRequestPartialView = requestContext.HttpContext.Request.RawUrl.ToLower().Contains("ajax");
            if ((urlRequestPartialView) && (!isAjaxRequest))
            {
                String newUrl = requestContext.HttpContext.Request.RawUrl.ToLower().Replace("ajax", "");
                requestContext.HttpContext.Response.Redirect(newUrl);
            }
            base.Execute(requestContext);
        }
    }

Ну и не забываем, отнаследовать все наш контроллер от ControllerBase.

Исходный код и полезные ссылки


Можно c ifolder.ru или с zalil.ru.


Спасибо за внимание! Удачного коддинга.
Tags:asp.net mvc 3ajaxhtml5history apipushstateнавигацияодностраничный сайт
Hubs: ASP
+21
9.3k 89
Comments 19
Popular right now
Top of the last 24 hours