📜 ⬆️ ⬇️

Implementing a single-page application using the History API in ASP.NET MVC

Good afternoon, dear Khabarovsk. The site has repeatedly raised the issue of creating single-page ajax applications. I faced this problem some time ago. However, I wondered why, having the capabilities of html5 and the power of MVC, I have to prescribe so much by hand, and even with the help of js .

Perhaps it was [holywar = on] hostility to the js language [holywar = off] that prompted me to create a simple solution based on ASP.NET MVC features. Next, I will describe in detail the problems that arise when trying to create a one-page ajax application, and will gradually consider creating a complete solution.

If it became interesting - welcome under the cat (code and pictures are attached).

So, we will experiment with the standard MVC 3 template application. We will remove all unnecessary from it to get the project configuration, approximately as in the figure below and we will start implementing the loading step by step.
image
')

Stage 1 - Problem Analysis


When I first met Ajax.ActionLink I thought - Aha! What could be easier, to implement the entire application in this way! The main block in the page template (_Layout.cshtml) is identified as main and we load all the other pages into it. However, problems immediately arise:
  1. The user moved to a new page and expects that the "Back" button will return it to the previous one, but this does not happen - the user returns not to the previous one, but to the first fully loaded page. The "Forward" button also does not work.
  2. The user moved to a new page and wanted to refresh it (for example, using F5), but instead of updating the current page, the first fully loaded page is loaded again.
  3. User opened link to new page in new tab / window. However, instead of opening the normal page, PartialView loaded without the main template, styles and scripts.
  4. The user walked through the pages of the site, then became distracted and wanted by the name of the tab (window) to find the page with which he worked before. But the name of the page is in _Layout.cshtml and therefore also has not changed in the process of navigation.
  5. Distract from navigation. The user went to the data entry page he needed, but ... client data validation on the page that ajax returned does not work!
  6. And now the user followed the link, which pulls out 100,500 records from the database and performs operations on them. And then once again I followed the same link, as I decided that “something wasn’t working for me again”. And then another. And after that, it would be better if the user did not wait for the end of all these requests, this will start happening on the page ... =)

There were not enough problems. And not all of them are solved by simple js code. Here we need an integrated approach to architecture. And if so, then immediately add a couple more requirements:
  1. Suppose that when loading a new page, the current one is simply blocked and a message is displayed to the user about what is happening in the system right now.
  2. Let the navigation through the history be simple and intuitive, as if the application works in the usual multi-page mode.
  3. As I said, I do not like js. Let the maximum amount of code be written in c #. And on js we will write only about 10 lines.

Finally adopted technical limitations:
  1. We will use html5 chips.
  2. We will not engage in layout and design. Just make our portal function properly.
  3. Our solution should be as easy to use as the portal becomes more complex. Immediately we will put in it support of areas, the main part of the functionality will be rendered in Ajax / Html helpers.
  4. To simplify the decision, let's take a few magical naming conventions. These agreements will be discussed below.

Problems are described, goals set, go!

Stage 2 - Implementing the Controller


Let's start with the controller. Obviously, in order to load both parts of pages using ajax, as well as pages completely (in case the user follows the link or refreshes the page), actions that would return both PartialViewResult and ViewResult are required.

Therefore, in the controller for each logical action we place two physical ones - one of the SomeAction: ViewResult , the second of AjaxSomeAction: PartialViewResult . The Ajax prefix in this case is the first magical naming convention. Let us continue to use this fact.
Total we get:
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"); } } 

The GenerateIndexPage () method in this simple way emulates some long operations, such as accessing a database.

Stage 3 - _Layout.cshtml


Now let's do a general template. I quote his code right away:
 <!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> 

So, first of all we connect the jquery libraries. We load them all at once, so as not to create unnecessary problems for ourselves with manual reloading during ajax navigation. We will write the ajaxnavigation.js script to write further and this file will be very small ~ 10 lines of code, as I mentioned earlier.

The title of the page has quite specific content - the name of our application. In principle, you can leave it empty - we will assign it with js each time the page is loaded manually.

The loadLayout and loadMessage elements are defined to show the loading process. The elements themselves are designed in such a way as to block the user from clicking anywhere without hitting while the new page is loading.

The block named main contains the body of the page itself. We will update its contents using ajax. Together with the two previous blocks (loadLayout and loadMessage), the main block also has a magic name, we will use it later.

Ajax.ActionLinkTo (...) creates a link with all associated parameters. Consider it in detail below.

Stage 4 - Ajax.ActionLinkTo (...)


This helper is a small extension over the standard 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)); } } 

Helper creates the necessary route taking into account the area (after all, many very real projects have areas), and it turned out to be extremely convenient to set them together with the action and the controller. We will need this route in order to insert it into the address bar of the browser during ajax navigation through pages, as well as for keeping navigation history.

The AjaxOptions object will replace the body of the main block with the received content and execute two js functions (more about them later):

It should also be noted that in fact, even when we called the helper, we specified the name of the action, as Index / About , the helper will substitute the above-mentioned Ajax prefix and will cause exactly the action intended for the Ajax request, i.e. AjaxIndex / AjaxAbout .

Etam 5 - magic script ajaxnavigation.js


It is time for a script that contains only three functions. First, the elementary function of changing the label that appears when loading:
 // ,       Ajax function changeLoadMesage(message) { //      $("#loadMessage").empty(); if (message != null) $("#loadMessage").append(message); } 

Secondly, the function called after loading part of the page:
 // ,       Ajax function onPageLoaded() { //    var url = $("#pageUrl").html().replace("&", "&"); window.history.pushState("ajax", document.title, url); //     document.title = $("#pageTitle").html(); //    $.validator.unobtrusive.parse($("#main")); } 

First of all, we need to get the url of the loaded page to substitute this url in the browser line, and also save it in the history of visits. To do this, each page is placed hidden block pageUrl. After taking the link to the page, we add a new state to the stack of window states, marking it with the code word " ajax ". This is done in order to navigate through the history (when the user pokes forward / back) to force the browser to reload the page.
We also need to change the page title and update the embedded client validation.

The third function is called when the page loads:
 //      $(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(); }); 

It also changes the title of the document, and also signs for loading any state from the history. If the state is marked as " ajax ", then reload the page, if not, then simply mark it in such a way for subsequent reloading.

Stage 6 - Presentation and Html.PageInfo


A typical representation in our case looks like this:
 @Html.PageInfo(" ","/Home/Index") <h2>Home</h2> <p> Home page </p> 

The Html.PageInfo (...) helper adds hidden blocks with the title of the document and a link to the current page. As we saw earlier, these hidden blocks are used in scripts. The helper is very simple:
 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)); } } 

Stage 7 - Intermediate Summary


Now you can run our application, not forgetting to connect the helpers created by html / ajax in the web.config of the Views folder:
 <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> 

We can quickly test the application with such a sequence, I illustrated it with screenshots:
  1. Download page / Home / Index;
  2. Make sure that the About link leads to / Home / AjaxAbout;
  3. Follow the link and make sure that it is loaded using ajax and the link / Home / About appears in the address bar;
  4. Follow the Index link, which also indicates the action / Home / AjaxIndex;
  5. Make sure that during a long-term operation a message is displayed and it is impossible to follow any other link;
  6. After loading the Index page, you will make sure that the history has been preserved and navigation is available (“Back” and “Forward”);
  7. Make sure that after switching to any page, updating the page works correctly.

image
image
image
There is one harmful problem. The opening of our link in a new tab / window still does not work:
image

Stage 8 and the last - Open in a new tab ...


Let's try to understand what the problem is. Obviously, when the browser tries to open the / Home / AjaxIndex link in a new tab, no ajax will be executed. In this case, we need to return the full view of the page, and not just a part of it. But how to determine that the browser has opened a new window?

For this we need some kind of diagnostic tool, I use Fiddler , a very simple and fast traffic analyzer. Run it and compare two requests that the browser sends to the server:

image
image

Now it is clear that in the case of a real ajax request, the browser appends the X-Requested-With header. It remains to write a filter of incoming requests, which would redirect us from a partial page to the full.
No sooner said than done:
 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); } } 

Well, do not forget, to inherit all of our controller from ControllerBase .

Source code and useful links


It is possible with ifolder.ru or with zalil.ru .


Thanks for attention! Successful codding.

Source: https://habr.com/ru/post/131556/


All Articles