Click here to Skip to main content
14,332,103 members

Transforming existing MVC application to work as Single Page Application (SPA)

Rate this:
4.83 (16 votes)
Please Sign up or sign in to vote.
4.83 (16 votes)
26 May 2016CPOL
Transforming existing MVC application to work as Single Page Application (SPA)

Download MvcSpaStart.zip

Download MvcSpaFinish.zip

Introduction

Traditional MVC applications that make full HTTP requests might suffer from multiple issues:

  • Layout is generated and sent with every request
  • All javascript libraries get initialised on client for every request

Not sending page layout with every request can decrease page size and network usage drastically, especially when layout contains a lot of HTML. Layout has mostly static data, dynamic content could be changed/injected using javascript.

Nowadays, many applications are using a lot of javascript libraries like jQuery, bootstrap, datatables, select2, signalR, date pickers, some graph components and others. However adding a lot of startup scripts delays page specific script execution. This delay might be 500-1000 ms - that effectively makes your page blink or flash, when ui controls get initialized. But all of this initialisation time on client’s machine can be eliminated, if each page would reuse javascript object state from previously visited page - no more heavy library initializations.

Usually, Single Page Applications (SPA) don’t have such issues, they don’t reload pages, because they use ajax calls instead, to retrieve pieces of content. The challenge here is to transform existing MVC application into SPA application without changing any code in your controllers, views and other pieces of code. However, some code will have to be fixed in order to remove its dependencies from full page reloads.

Final result can be achieved by intercepting navigation events inside application and replace them with ajax calls. Sample code solution will include all known cases that have to be handled to transform it to SPA. There’re some cases that we can’t control: user entering url & user initiating page refresh. In these cases a full HTTP request will be issued every time.

Sample solutions

This article contains two MVC solutions: ‘Start’ and ‘Finish’. I strongly advise to download ‘Start’ solution and work it out throughout article. ‘Start’ solution is based on default MVC template without authentication, all requests are served as typical full HTML requests. The main page contains use cases that must be handled to make application work as SPA:

  • Link navigation
  • Javascript navigation
  • GET form
  • POST form
  • Submitting button value
  • Loading page with explicit layout
  • Global javascript events
  • Javascript global variables/state reset

Image 1

Then, we have ‘About’ page, that contains dataTable. As there’s artificial javascript startup delay of 500ms, table rendering can be observed - unstyled to styled dataTable. That’s the 2nd issue that we’re aiming to resolve.

Image 2

Some noticeable rendering delays can be observed when navigating from ‘Home’ page to ‘About’ page, but it will be resolved along an article.

Image 3

Link navigation

Image 4

This is the simplest case to handle. All we have to do is to detect anchor click, prevent it and do an ajax call with its url. The first step is to modify _Layout.cshtml file:

<div class="container body-content">
        <div id="spa-content">
            <div id="body">
                @RenderBody()
            </div>
        </div>

RenderBody() method is nested inside two divs. The outer div called #spa-content will always be rendered on our html page, only it’s content with id #body will be swapped out on application navigation.

Next, _SpaLayout.cshtml layout file is created in same directory as _Layout.cshtml file:

<div id="body">
    <div style="display:none">
        <div id="title-div">@ViewBag.Title</div>
        <div id="username-div">@DateTime.Now</div>
    </div>

    @* Breadcrumb here or whatever belongs to view *@

    @RenderBody()
    @RenderSection("scripts", false)
</div>

You can notice that div has id #body, this HTML will be injected into main pages #spa-content div. This layout contains page title and some dynamic information, so that some part of main layout could be updated with it (for example username). Scripts section must be included directly under RenderBody().

Next, logic for selecting one or another layout file should be written to _ViewStart.cshtml file:

@if (IsAjax)
{
    Layout = "~/Views/Shared/_SpaLayout.cshtml";
}
else
{
    Layout = "~/Views/Shared/_Layout.cshtml";
}

If we receive ajax request, we render our view with _SpaLayout.cshtml, but if it was normal request - _Layout.cshtml will be used.

As ajax is going to be used extensively, we should turn its cache off. It will help with IE a lot as it is caching way too aggressively. The following code should be added to core.js file, that is our custom javascript file:

$(function () {
    $.ajaxSetup({ cache: false });

    initControls('body');
});

Next, we create spa.js file with following content:

$(function() {
    $(document).on('click', 'a', function (e) {
        linkClick(e, $(this));
    });
});

Globally detecting anchor (link) clicks and calling custom function that will be added to same spa.js file:

function linkClick(e, link) {
    if (e.isDefaultPrevented()) {
        return;
    }
    var action = link.attr('href');
    // '/#' is breadcrumb - left menu navigation
    if (action == null || action[0] != '/' || action.startsWith('/#')) {
        return;
    }
    e.preventDefault();
    spaLoad(action, "GET");
};

First, isDefaultPrevented is checked to see if it was prevented already. If we can handle it, href is extracted and checked for local url. If it’s a local url, we prevent default action (so it doesn’t navigate out as a full HTTP request) and call custom function with action url and “GET” parameter. spaLoad function is added to spa.js file:

function spaLoad(action, type, data) {
    $.ajax({
        url: action,
        type: type,
        dataType: 'html',
        data: data
    }).done(function (response, status, xhr) {
        renderPage(xhr);
    });
}

Next, ajax call is made to action url and upon response (that executed using _SpaLayout file, because of ajax request) another function is called with xhr parameter. It’s also added to spa.js file:

function renderPage(xhr) {
        $('#spa-content').html(xhr.responseText);
        // init new page
        initControls($('#spa-content'));
        // set page title
        var titleDiv = $('#title-div', '#spa-content');
        document.title = titleDiv.text();
        // update username in navbar
        var usernameDiv = $('#username-div', '#spa-content');
        $('#username').text(usernameDiv.text());
}

Response text is assigned to #spa-content. It effectively replaces previous HTML content on screen, in addition, all jQuery events that were assigned to previous content get unregistered and new scripts from response text are executed. Immediately, after html swap, controls on page are initialized (initControls() function from core.js, in our case it just changes footer color to red). Page title is updated and response dateTime is set to element of main layout.

The only step left is to include spa.js file into _Layout.cshtml file:

        @RenderSection("scripts", false)
        <script src="~/Scripts/spa.js"></script>
</body>

At this point, launching application & trying out a link should navigate to About page with a table. Table should be rendered immediately (also footer color should turn red). Also, all links in navbar should start working. The easy way to make sure it works as expected is to open browser’s developer tools using F12 and inspect network for xhr requests.

Image 5

Right now, as each page can be rendered with main or spa layout, view caching using OutputCache should start misbehave (not covered in this article) - 2 different outputs should be cached. The solution for it, would be to use varyByCustom property and have 2 cached versions: one for full request and another for ajax request.

Javascript navigation

Image 6

To navigate using javascript, window.location is called. Unfortunately, it makes a full HTTP request, so all such places in the code must be discovered and replaced with custom function.

Let’s add such a function to core.js file:

function navigate(url) {
    var anchor = $('<a href="' + url + '"></a>');
    anchor.click(function (e) {
        if (linkClick != null) {
            linkClick(e, $(this));
        }
    });
    anchor[0].click();
}

All it does, is creating a temporary anchor with specified url. It’s being clicked programmatically, that activates attached event, in turn calling linkClick function from spa.js file.

As we have only one place in ‘Start’ project, that calls window.location, we replace code in Index.cshtml file:

$('#navigate-button').click(function () {
    window.navigate('@Url.Action("About", "Home")');
});

After this change, clicking button will navigate to ‘About’ page with dataTable. Make sure to press Ctrl + F5 if browser didn’t reload core.js file.

Updating URL in address bar

So far, navigating to ‘About’ page didn’t change url in address bar. Url can be received from the server as response header. Update to spa.js file is required:

function renderPage(xhr) {
    var location = xhr.getResponseHeader('Location');
    history.pushState('', '', location);

    $('#spa-content').html(xhr.responseText);
    // init new page
    initControls($('#spa-content'));
    // set page title
    var titleDiv = $('#title-div', '#spa-content');
    document.title = titleDiv.text();
    // update username in navbar
    var usernameDiv = $('#username-div', '#spa-content');
    $('#username').text(usernameDiv.text());
}

As ‘Location’ entry in response header is a custom one, it needs to be populated by server. Firstly, new folder called Infrastructure is added, inside it, a new SpaResponseAttribute.cs custom action filter is created:

namespace MvcSpaStart.Infrastructure
{
    public class SpaResponseAttribute : ActionFilterAttribute
    {
        public override void OnActionExecuted(ActionExecutedContext filterContext)
        {
            if (filterContext.IsChildAction || !filterContext.HttpContext.Request.IsAjaxRequest())
            {
                return;
            }
            var httpContext = filterContext.HttpContext;
            var pathAndQuery = httpContext.Request.Url != null ? httpContext.Request.Url.PathAndQuery : string.Empty;
            // remove timestamp added by ajax calls
            var index = pathAndQuery.IndexOf("_=", StringComparison.Ordinal);
            if (index > 0)
            {
                pathAndQuery = pathAndQuery.Substring(0, index - 1);
            }
            httpContext.Response.AddHeader("Location", pathAndQuery);
        }
    }
}

It executes after action is executed and proceeds only if that action was directly initiated by ajax call. Local action url is extracted and if it contains underscore parameter (added by ajax cache off), it get’s stripped - we don’t want timestamp in browser’s url bar. Finally, url is added to response header.

Our custom attribute gets registered in FilterConfig.cs file:

public static void RegisterGlobalFilters(GlobalFilterCollection filters)
{
    filters.Add(new HandleErrorAttribute());
    filters.Add(new SpaResponseAttribute());
}

At this point, navigating between ‘Index’ and ‘About’ pages - url gets updated.

Image 7

Enabling GET & POST form submits

Image 8

Overriding form submits is very similar to approach of overriding link navigation. The only update is done to spa.js file:

$(function() {
    $(document).on('click', 'a', function (e) {
        linkClick(e, $(this));
    });

    $(document).on('submit', 'form', function (e) {
        if (e.isDefaultPrevented()) {
            return;
        }
        e.preventDefault();
        var form = $(this);
        if (!form.valid()) {
            return;
        }
        var action = form.attr('action');
        var method = form.attr('method');
        var data = form.serialize();

        spaLoad(action, method, data);
    });
});

Form submits are intercepted globally and only those that were not prevented by another piece of script. Form gets validated using jQuery validate plugin and if there are no errors it will be submitted using ajax call. Form action (url) and method (GET or POST) are acquired from form element. Form data gets serialized to string and existing spaLoad() function is called. Now, submitting GET and POST forms would work in sample project, passed parameters would be seen in query string.

Image 9

Image 10

In addition, post action is using redirect, and as we can see, ajax was able to handle that easily.

Submitting submit button value

Image 11

In cases, when form has more than one submit button, its value usually gets submitted too. But, when form gets serialized using jQuery serialize method, it has no clue of which button was actually clicked. To solve this problem, form must track the submit button that was clicked last. Some code must be updated and added to core.js file:

function initControls(context) {
    $('footer', context).css('color', 'red');
    initForms(context);
}

function initForms(context) {
    var forms = $('form', context);
    forms.each(function () {
        var form = $(this);
        var submits = form.find('input[type=submit]');
        submits.click(function () {
            var submit = $(this);
            form.data('submit', submit.val());
        });
    });
}

initForms() function call is added to initControls() function. For each form in context it finds all submit buttons within and assigns click handler to them. On submit click, its value gets written to form’s data attribute.

Next, this metadata needs to be extracted and used in custom form submit pipeline. Updated code for spa.js is shown below:

$(function() {
    $(document).on('click', 'a', function (e) {
        linkClick(e, $(this));
    });

    $(document).on('submit', 'form', function (e) {
        if (e.isDefaultPrevented()) {
            return;
        }
        e.preventDefault();
        var form = $(this);
        if (!form.valid()) {
            return;
        }
        var action = form.attr('action');
        var method = form.attr('method');
        var data = form.serialize();

        // submit clicked button value
        var submitValue = form.data('submit');
        if (submitValue != null) {
            var submit = $('input[type=submit][value="' + submitValue + '"]', form);
            if (submit.length == 1) {
                var submitName = submit.attr('name');
                if (submitName != null) {
                    data += '&' + encodeURI(submitName) + '=' + encodeURI(submit.attr('value'));
                }
            }
        }

        spaLoad(action, method, data);
    });
});

Before calling spaLoad function, form’s data is examined, if submit data is found - submit button gets selected. Finally, name and value of submit button are appended to serialized form data. Clicking ‘First’ or ‘Second’ submit button will  submit their values, in turn updating query string in url.

Image 12

Handling full HTML responses

Image 13

Sometimes, ajax response html will be full HTML page, not just content part. It can happen, when view explicitly defines its layout or there’re more possible layouts in _ViewStart.cshtml - like unauthorized page layout. In sample application, such page is ‘Contact’. No matter how it will requested it will always use custom layout. To correctly handle it, spa.js file has to be updated:

function renderPage(xhr) {
    var location = xhr.getResponseHeader('Location');
    history.pushState('', '', location);

    // load document
    if (xhr.responseText.startsWith('<!DOCTYPE html>')) {
        // whole document is refreshed
        var newDocument = document.open('text/html');
        newDocument.write(xhr.responseText);
        newDocument.close();
    } else {
        $('#spa-content').html(xhr.responseText);
        // init new page
        initControls($('#spa-content'));
        // set page title
        var titleDiv = $('#title-div', '#spa-content');
        document.title = titleDiv.text();
        // update username in navbar
        var usernameDiv = $('#username-div', '#spa-content');
        $('#username').text(usernameDiv.text());
    }
}

If response html starts with special symbols, we know that it contains full HTML page - so whole content of the page needs to be replaced. After this change, when navigating to ‘Contact’ page, you shouldn’t see footer from default layout anymore.

Image 14

Generally, this feature should be used for special layouts, that differ from default one a lot. A good use case would be unauthorized user layout, that would be selected when user logs out or his authentication cookie expires.

Global events handling

Image 15

When elements get dynamically added to screen, the best way to attach event handlers for them is to use global events. Event is attached to ancestor element (usually window or ‘body’) with a filter to the target element. It works great for general full HTTP requests, but in our case it starts to misbehave. Each time you visit ‘Home’ page a new handler gets added to layout element - clicking test button adds that much buttons, but original intention was to always add only one. As we know, the outermost element of ajax html response has id ‘#body’, so it should be used everywhere instead of global window or ‘body’. We can fix it with a simple change in Index.cshtml file:

$('#body').on('click', '#clone-button', function() {
    $(this).after($(this).clone());
});

After change, clicking button will execute event only once.

3rd party global events handling

Image 16

For this example, we are using shortcut.js library, which allows to add keyboard shortcuts to a web page. The problem here is that event is added to a whole document, so it is still active after navigating to another spa page. If you press 'a' in home page and then try to enter 'a' in 'About' page filter, it actually will execute link event handler again. After spa page navigation, previous content got detached from the page, but it still exists and it's possible to programmatically click that link. When click happens, it reloads whole page as spa.js is not intercepting that click (it's not in current DOM). We need a way to detect when link was removed from document. New jQuery special event is added to core.js file:

$.event.special.destroyed = {
    remove: function (e) {
        if (e.handler) {
            e.handler.apply(this, arguments);
        }
    }
}

This event gets called when element gets detached from document and content swap happens using html() function. At this point, we have an event hook for removing global event listerners. Next, initShortcuts() function is updated in core.js file:

function initShortcuts(context) {
    var elements = $('[data-shortcut]', context);
    elements.each(function () {
        var elem = $(this);
        var keys = elem.data('shortcut');
        shortcut.add(keys, function () {
            if (elem.is('a')) {
                elem[0].click();
            }
        });
        elem.on('destroyed', function() {
            shortcut.remove(keys);
        });
    });
}

Once element gets destroyed, it immediately removes shortcut event. Now, after navigation from 'Home' to 'About' page, you can type 'a' in a search filter.

History management

Right now, navigating between pages doesn’t create any history entries, because you’re still in same first page - only content of it was swapped. But it can be manipulated using javascript by storing content or reloading content by url. In this case, content will be reloaded on history navigation (back or forward). Some code needs to be appended to spa.js file:

// history management
$(function () {
    window.onpopstate = function () {
        var action = window.location.pathname + window.location.search;
        spaLoad(action, "GET", null, false);
    };

    $(document).ajaxSend(function (e, xhr) {
        xhrPool.push(xhr);
    });
    $(document).ajaxComplete(function (e, xhr) {
        xhrPool = $.grep(xhrPool, function (x) { return x != xhr; });
    });
});

var xhrPool = [];
var abortAjax = function () {
    $.each(xhrPool, function (idx, xhr) {
        xhr.abort();
    });
};

On history navigation (back or forward) - ajax request will get generated. Also it contains mechanism to abort all pending ajax calls. A good example is if user pressed back two times - we no longer care about content of the first page, only of the second.

Next update to spa.js file is passing pushHistory parameter, as navigating by using history buttons doesn’t need to push any new states to it. Also all pending ajax requests get aborted here:

function spaLoad(action, type, data, pushHistory) {
    abortAjax();
    $.ajax({
        url: action,
        type: type,
        dataType: 'html',
        data: data
    }).done(function (response, status, xhr) {
        renderPage(xhr, pushHistory);
    });
}

New parameter is also added to renderPage function in spa.js file:

function renderPage(xhr, pushHistory) {
    if (pushHistory == null || pushHistory == true) {
        // location will be null by custom asp.net error page
        var location = xhr.getResponseHeader('Location');
        history.pushState('', '', location);
    }

    // load document
    if (xhr.responseText.startsWith('<!DOCTYPE html>')) {

Navigating between normal spa pages like ‘Home’ and ‘About’ will work well. However,  if user navigates to ‘Contact’ page (it uses document.write to update state), history stops working here as this page doesn't handle history in same way as spa pages.

Resetting javascript state

Image 17

At the moment, global javascript variables don’t get defaulted to initial values after successful navigation. In the sample we have two global variables:
clickCount - that counts how many mouse clicks are done in ‘Home’ page. After revisiting page, it should default to 0, however it starts with last value
times - that shows how many times per second function inside setInterval gets executed. On each ‘Home’ page revisit it registers a new interval event, but it should clear previous events (always stay at 1). Imagine calling ajax function every 60 seconds, after visiting page 3 times, it would make 3 ajax calls every 60 seconds, but our intention is to call it only once.

Let’s add a new function to spa.js file:

function resetGlobalVariables() {
    window.clickCount = 0;
    window.times = [];
}

And then modify same spa.js file::

function renderPage(xhr, pushHistory) {
    resetGlobalVariables();

Right now, after revisiting page, clickCounter starts at 0. However interval counter still increases from 0 to a number of page visits (after the change, it doesn’t start with that number immediately anymore). Default setInterval function must be overridden, so that we have control over it. Overridden function is added to core.js file:

// clear interval timers
var intervals = [];
var oldSetInterval;
// is not null after document.write
if (oldSetInterval == null) {
    oldSetInterval = window.setInterval;
    window.setInterval = function (func, interval) {
        var id = oldSetInterval(func, interval);
        intervals.push(id);
    }
}

Next step is to add a new function to spa.js that would clear all registered intervals:

function clearIntervals() {
    for (var i = 0; i < window.intervals.length; i++) {
        window.clearInterval(window.intervals[i]);
    }
    window.intervals = [];
}

And update to renderPage() function is applied to the same spa.js file:

function renderPage(xhr, pushHistory) {
    // clear/reset js state
    clearIntervals();
    resetGlobalVariables();

Interval count will always stay at 1, as previous interval events get cleared after navigation. Similar approach should be used to handle setTimeout and other functions that register global listeners.

It should be noted, that we don’t have to unregister any jQuery events (from previous ajax response content, that’s inside #body) as jQuery does it for us when page content gets replaced using html() function.

Error handling

When server error happens (not covered in sample project), custom error page should be returned, but http status code will be set to 404, 500 or something similar. Even if http status code is not 200, we still want to display returned html page. It can be handled by adding fail() handler to ajax call for spaLoad() function inside spa.js file:

function spaLoad(action, type, data, pushHistory) {
    abortAjax();
    $.ajax({
        url: action,
        type: type,
        dataType: 'html',
        data: data
    }).done(function (response, status, xhr) {
        renderPage(xhr, pushHistory);
    }).fail(function (xhr) {
        // unauthorized or access denied
        if (xhr.status === 401 || xhr.status === 403) {
            return;
        }

        // ignore aborted requests
        if (xhr.status === 0 || xhr.readyState === 0) {
            return;
        }
        // mark status as handled/aborted
        xhr.status = 0;
        renderPage(xhr, pushHistory);
    });
}

In this case, it does nothing when unauthorized or access denied http status code is received - it could be handled by navigating to Login page of your application. Aborted requests are also ignored, as we don’t care about those responses anymore (they could be produced by abortAjax() function call in spaLoad() function). Else, we set status to aborted (http status code 0) as we don’t want errors to be handled further on pipeline and finally we’re rendering error page.

Conclusion

As you can see, we were able to transform existing MVC application (‘Start’ solution) to work as SPA application (‘Finish’ solution) without changing any code in controllers, views (some updates to javascript and layouts) or business logic. It was only one time infrastructure update, that resulted in a much more responsive application. It doesn’t force you to develop application in a new way, you still continue to develop it as usual MVC application, with some considerations about global javascript variables, global listeners and javascript based navigation.

However, this article shouldn’t be taken as totally correct and 100% reliable approach to do this transformation as there might be some subtle unconsidered use cases, that might break it. But there is a very simple way to make application to work as before, by commenting out spa.js script include in _Layout.cshtml page.

History

25 May 2016 - initial version.

26 May 2016 - added '3rd party global events handling' section. Updated related pictures & sample solutions.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

Aurimas
Unknown
No Biography provided

Comments and Discussions

 
GeneralBy Far the best solution Pin
iean40629-Jan-19 7:20
memberiean40629-Jan-19 7:20 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

Article
Posted 24 May 2016

Stats

18.5K views
726 downloads
30 bookmarked