Добрый день уважаемые хабаровчане. На сайте уже не раз поднимался вопрос о проблеме создания одностраничных ajax приложений. С такой задачей некоторое время назад столкнулся и я. Однако я недоумевал, почему обладая возможностями html5 и мощью MVC я должен столько всего прописывать вручную, да еще и с помощью js.
Возможно именно [holywar=on]неприязнь к языку js[holywar=off] побудили меня создать простое решение, опирающиеся на возможности ASP.NET MVC. Далее, я подробно опишу проблемы, которые возникают при попытке создать одностраничное ajax приложение, и поэтапно рассмотрю создание полноценного решения.
Если стало интересно — добро пожаловать под кат (код и картинки прилагаются).
Итак, экспериментировать будем со стандартным шаблонным приложением MVC 3. Удалим из него все лишнее, чтобы получилась конфигурация проекта, примерно как на рисунке ниже и начнем поэтапно реализовывать загрузку.
Когда я только познакомился с Ajax.ActionLink я подумал — Ага! Что может быть проще, реализовать все приложение таким образом! Главный блок в шаблоне страниц (_Layout.cshtml) идентифицируем как main и загружаем в него все остальные страницы. Однако, сразу возникают проблемы:
Проблем набралось не мало. И не все из них решаются простым js кодом. Тут нужен комплексный подход к архитектуре. А раз так, то сразу добавим еще парочку требований:
Наконец принятые технические ограничения:
Проблемы описаны, цели поставлены, поехали!
Начнем с контроллера. Очевидно, что для того, чтобы загружать как части страниц с помощью ajax, так и страницы полностью (в случае, если пользователь непосредственно перешел по ссылке, либо обновил страницу) необходимы действия, которые возвращали бы как PartialViewResult, так и ViewResult.
Поэтому в контроллер для каждого логического действия помещаем два физических — один вида SomeAction: ViewResult, второй AjaxSomeAction: PartialViewResult. Приставка Ajax в этом случае — первое магическое соглашение об именовании. Запомним и далее будем использовать этот факт.
Итого получаем:
Метод GenerateIndexPage() таким нехитрым способом эмулирует какие-то долгие операции, например обращение к БД.
Теперь займемся общим шаблоном. Привожу сразу его код:
Итак, прежде всего подключаем библиотеки jquery. Загружаем их все сразу, чтобы не создавать себе лишних проблем с ручной дозагрузкой при ajax навигации. Скрипт ajaxnavigation.js мы напишем напишем далее и этот файл будет очень маленьким ~ 10 строк кода, как я упоминал ранее.
Заголовок страницы имеет вполне определенное содержание — название нашего приложения. В принципе, можно оставить его пустым — мы будем назначать его с помощью js при каждой загрузке страницы вручную.
Элементы loadLayout и loadMessage определены для отображения процесса загрузки. Сами элементы выполнены таким образом, чтобы блокировать пользователю возможность щелкать куда не попадя, пока идет загрузка новой страницы.
Блок с именем main содержит в себе непосредственно тело страницы. Именно его содержимое мы будем обновлять, используя ajax. Вкупе с двумя предыдущими блоками (loadLayout и loadMessage), блок main также имеет магическое название, его мы будем использовать далее.
Ajax.ActionLinkTo(...) создает ссылку со всеми сопутствующими параметрами. Подробно рассмотрим его далее.
Данный хелпер является небольшим расширением над стандартным Ajax.ActionLink(...):
Хелпер создает необходимый маршрут с учетом области (ведьвсе очень многие реальные проекты имеют области), и задавать их вместе с действием и контроллером оказалось чрезвычайно удобно. Этот маршрут нам понадобится, чтобы вставлять его в адресную строку браузера при ajax-навигации по страницам, а также для ведения истории навигации.
Объект AjaxOptions заменит тело блока main на полученный контент и выполнит две js функции (о них подробно чуть позже):
Также следует отметить, что на самом деле, хоть при вызове хелпера мы и указали имя действия, как Index/About, хелпер подставит упомянутую выше приставку Ajax и вызовет именно действие, предназначенное для Ajax запроса, т.е. AjaxIndex/AjaxAbout.
Настало время и для скрипта, который содержит в себе всего три функции. Во-первых элементарная функция смены надписи, которая отображается при загрузке:
Во-вторых функция, вызываемая после загрузки части страницы:
Первым делом мы должны получить url загруженной страницы, чтобы подставить эту url в строку браузера, а также сохранить в истории посещений. Для этого на каждой странице помещается скрытый блок pageUrl. После взятия ссылки на страницу, добавляем новое состояние к стеку состояний окна, помечая его кодовым словом "ajax". Это сделано для того, чтобы при навигации по истории (когда пользователь тыкает вперед/назад) дать принудительную команду браузеру перезагрузить страницу.
Также мы должны сменить заголовок страницы и обновить встроенную клиентскую валидацию.
Третья функция вызывается при загрузке страницы:
Она также меняет заголовок документа, а также подписывается за загрузку какого-либо состояния из истории. Если состояние помечено, как "ajax", то перезагружаем страницу, если же нет — то просто помечаем его таким образом для последующей перезагрузки.
Типичное представление в нашем случае выглядит таким образом:
Хелпер Html.PageInfo(...) добавляет скрытые блоки с заголовком документа и с ссылкой на текущую страницу. Как мы видели ранее, эти скрытые блоки используются в скриптах. Хелпер очень простой:
Теперь можно запустить наше приложение, не забыв подключить созданные html/ajax хелперы в web.config папки Views:
Быстро протестировать приложение мы можем такой последовательностью, я проиллюстрировал ее скриншотами:
Осталась одна вредная проблема. По-прежнему не работает открытие нашей ссылки в новой вкладке/окне:
Попытаемся понять в чем же проблема. Очевидно, что при попытке браузера открыть ссылку /Home/AjaxIndex в новой вкладке, никакой ajax не выполнится. В этом случае, нам необходимо возвращать полное представление страницы, а не только ее часть. Но как определить, что браузер открыл новое окно?
Для этого нам понадобится какое-нибудь диагностическое средство, я использую Fiddler, очень простой и быстрый анализатор трафика. Запускаем его и сравниваем два запроса, которые браузер посылает на сервер:
Теперь понятно, что в случае реального ajax запроса, браузер присоединяет заголовок X-Requested-With. Осталось написать фильтр входящих запросов, который бы переадресовывал нас с частичной страницы на полную.
Сказано — сделано:
Ну и не забываем, отнаследоватьвсе наш контроллер от ControllerBase.
Можно c ifolder.ru или с zalil.ru.
Спасибо за внимание! Удачного коддинга.
Возможно именно [holywar=on]неприязнь к языку js[holywar=off] побудили меня создать простое решение, опирающиеся на возможности ASP.NET MVC. Далее, я подробно опишу проблемы, которые возникают при попытке создать одностраничное ajax приложение, и поэтапно рассмотрю создание полноценного решения.
Если стало интересно — добро пожаловать под кат (код и картинки прилагаются).
Итак, экспериментировать будем со стандартным шаблонным приложением MVC 3. Удалим из него все лишнее, чтобы получилась конфигурация проекта, примерно как на рисунке ниже и начнем поэтапно реализовывать загрузку.
Этап 1 — Анализ проблем
Когда я только познакомился с Ajax.ActionLink я подумал — Ага! Что может быть проще, реализовать все приложение таким образом! Главный блок в шаблоне страниц (_Layout.cshtml) идентифицируем как main и загружаем в него все остальные страницы. Однако, сразу возникают проблемы:
- Пользователь перешел на новую страницу и ожидает что кнопка «Назад» вернет его на предыдущую, однако этого не происходит — пользователя возвращает не на предыдущую, а на первую полностью загруженную страницу. Кнопка «Вперед» также не работает.
- Пользователь перешел на новую страницу и захотел обновить ее (например, с помощью F5), однако вместо обновления текущей страницы, загружается вновь та первая полностью загруженная страница.
- Пользователь открыл ссылку на новую страницу в новой вкладке/окне. Однако, вместо открытия нормальной страницы загрузился PartialView без основного шаблона, стилей и скриптов.
- Пользователь прошелся по страницам сайта, потом отвлекся и захотел по названию вкладки(окна) найти ту страницу, с которой он работал до этого. Но название страницы лежит в _Layout.cshtml и поэтому также не изменилось в процессе навигации.
- Отвлечемся от навигации. Пользователь таки перешел на нужную ему страницу ввода данных, но… клиентская валидация данных на странице, которую вернул ajax не работает!
- А теперь пользователь перешел по ссылке, которая вытаскивает из базы данных 100500 записей и выполняет над ними операции. А потом еще раз зашел по этой же самой ссылке, так как решил, что «что-то у меня опять не нажалось». А потом еще. И после этого, лучше бы пользователь не дожидался окончания всех этих запросов, такое начнет творится на странице… =)
Проблем набралось не мало. И не все из них решаются простым js кодом. Тут нужен комплексный подход к архитектуре. А раз так, то сразу добавим еще парочку требований:
- Пусть при загрузке новой страницы, текущая просто блокируется и пользователю выводится сообщение о том, что именно сейчас происходит в системе.
- Пусть навигация по истории будет простой и интуитивной, как-будто приложение работает в обычном многостраничном режиме.
- Как я уже говорил, я не люблю js. Пусть максимальное количество кода будет написано на c#. А на js мы напишем всего около 10 строк.
Наконец принятые технические ограничения:
- Будем использовать фишки html5.
- Не будем заниматься версткой и дизайном. Просто заставим наш портал правильно функционировать.
- Наше решение должно быть максимально простым в использовании при усложнении портала. Сразу заложим в него поддержку областей, основную часть функционала вынесем в Ajax/Html хелперы.
- Чтобы упростить решение, примем несколько магических соглашений об именовании. Об этих соглашениях речь пойдет далее.
Проблемы описаны, цели поставлены, поехали!
Этап 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));
}
}
Хелпер создает необходимый маршрут с учетом области (ведь
Объект 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>
Быстро протестировать приложение мы можем такой последовательностью, я проиллюстрировал ее скриншотами:
- Загрузить страницу /Home/Index;
- Убедится, что ссылка About ведет на страницу /Home/AjaxAbout;
- Перейти по ссылке и убедится, что та загрузится с помощью ajax и в адресной строке появится ссылка /Home/About;
- Перейти по ссылке Index, которая также указывает на действие /Home/AjaxIndex;
- Убедится, что при длительной операции отображается сообщение и невозможно перейти по какой-либо другой ссылке;
- После загрузки страницы Index, убедится что история сохранилась и по ней доступна навигация («Назад» и «Вперед»);
- Убедится, что после перехода на какую-либо страницу, обновление страницы работает корректно.
Осталась одна вредная проблема. По-прежнему не работает открытие нашей ссылки в новой вкладке/окне:
Этап 8 и последний — Открыть в новой вкладке…
Попытаемся понять в чем же проблема. Очевидно, что при попытке браузера открыть ссылку /Home/AjaxIndex в новой вкладке, никакой ajax не выполнится. В этом случае, нам необходимо возвращать полное представление страницы, а не только ее часть. Но как определить, что браузер открыл новое окно?
Для этого нам понадобится какое-нибудь диагностическое средство, я использую Fiddler, очень простой и быстрый анализатор трафика. Запускаем его и сравниваем два запроса, которые браузер посылает на сервер:
- при обычном переходе по ссылке
- при попытке открыть ссылку в новом окне
Теперь понятно, что в случае реального 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);
}
}
Ну и не забываем, отнаследовать
Исходный код и полезные ссылки
Можно c ifolder.ru или с zalil.ru.
- Создание одностраничного ajax-приложения с поддержкой History API (и без нее) — habrahabr.ru/blogs/webdev/123972
- Введение в HTML5 History API — habrahabr.ru/blogs/javascript/123106
- Загрузка страницы с помощью Ajax как ВКонтакте — habrahabr.ru/blogs/javascript/128552
- Поддержка старых браузеров — plugins.jquery.com/project/history-js
- Интересный рабочий пример ajax-навигации, многое из него подчерпнул — www.asual.com/jquery/address/samples/tabs/#Overview
- Про клиентскую валидацию — www.jasoncavett.com/2011/03/using-unobtrusive-jquery-validation.html
Спасибо за внимание! Удачного коддинга.