Поделиться через


Delivering the SPA enhancements

patterns & practices Developer Center

On this page: Download:
Defining the single page application (SPA) requirements | Frameworks | Pros | Cons | Choosing a library or framework - Actual usage in Mileage Stats, Issues, gotchas and things we learned | Building the single page application | The architecture of Mileage Stats SPA | Deeper in the SPA - Altering the markup for SPA, Bootstrapping the JavaScript, Routing and transitioning, Client-Side templates | Summary Download code samples

Single page applications, also referred to as SPAs, are ideally suited to handle many of the constraints of mobile browsers. A single page application behaves more like a traditional desktop app, treating the browser like a runtime environment. Instead of requesting a full page from the server on every interaction, it will generally load all of its assets up front and minimize further communication with the server. There are a number of ways to implement a SPA, and Mileage Stats illustrates just one of them. For more information on the pattern in general see http://en.wikipedia.org/wiki/Single_page_application.

Defining the single page application (SPA) requirements

As discussed in Mobilizing the Mileage Stats Application, the default (or lowest common denominator) web app experience is essentially a traditional, http-request based web page experience. User actions such as clicking a link or submitting a form cause a full-page refresh, and are not dependent on client-side scripting to request content or deliver additional functionality In Mileage Stats, we referred to this experience as Works, implying that it will work on nearly any browser.

In contrast, we referred to the SPA for Mileage Stats as the Wow experience. The Wow experience is designed to enhance the Works experience in accordance with each browser's capabilities. The Wow technology requirements therefore begin with the same list of features required for the Works experience.

  • Strong HTML 4.01 support to define app and components structure.
  • Basic CSS 2.n support to provide styling and enhance information design.
  • Strong HTML form support to enable data input (this will typically go hand in hand with good HTML support)

These are augmented with an additional series of requirements, which will be used to implement the enhanced, SPA experience:

  • Support for XHR and JSON
  • Support for the hashchange event

Note

The hashchange event is fired when the hash portion of the URL changes. The hash is more formally referred to the fragment identifier. The hash portion of a URL is part that comes after a # character. This portion is not sent to the server and does not cause the browser to load a page.

See Detecting Devices and their Features for more information about the process we used to detect these browser capabilities.

Frameworks

There are many tools you can use when building mobile web experiences. And, as always, there are advantages and disadvantages to each of the tools. Frameworks and libraries such as jQuery Mobile and Sencha Touch cater specifically to building web apps for mobile devices. We took a different approach, but it's important to understand the advantages and disadvantages of the various options. You should carefully consider the benefits these sorts of frameworks and libraries can potentially bring to your project.

Pros

  • Frameworks are typically designed to get you started quickly. It's therefore often possible to get something running in a short amount of time.
  • Many mobile frameworks are subsets of a popular desktop framework. The ramp-up time may therefore be relatively quick, and you may be able to use existing skills and workflows. Due to the desktop component, these frameworks may also simplify your initial development and testing using desktop tools.
  • Many frameworks have large development communities that can provide support and code samples.
  • Many frameworks offer components or patterns that have been specifically designed for modern smartphones. Frameworks may be particularly useful if you plan to target these devices exclusively.

Cons

  • Many libraries and frameworks may not yet have been extensively tested on mobile browsers or devices. Others may focus exclusively on only the newest and most modern versions of these devices. This may limit your ability to adapt the code to expand this level of support.
  • Each framework or library you use becomes yet another aspect of your implementation that you'll need to test, debug, and maintain across all your chosen devices. The amount of support required may also not become apparent until well into development, or until you begin to combine frameworks and have the opportunity to test them in a real device environment.
  • Some frameworks are exclusively for mobile devices. They do not support a "mobile first" approach where one web site adapts to all possible clients.

Choosing a library or framework

Choosing a library or framework before you understand the basic needs of your app can lead to difficulties if the selection turns out to be a poor match. Read the documentation, look at the code, and ask questions from the community on sites such as StackOverflow and Twitter. In addition, consider using the candidate libraries to prototype the essential portions of your app.

Look for libraries that have been extensively tested on the devices you need to support (and not just the most popular devices, or latest versions of a platform). Before you decide which to use, write tests for the specific functionality you plan to use, and test these on your target browsers and device hardware.

Remember as well that mobile devices have much slower CPUs and significantly less RAM than their desktop counterparts. You may therefore not be able to use a library in the same way as you would have on the desktop. Just because the library supports an animation, effect, or transition, doesn't mean it will make sense to use it in a production environment. This is particularly true of animations and transitions performed using JavaScript (as opposed to those using CSS).

In summary, consider the following when selecting a library or framework:

  • Does it satisfy the technical requirements of your app?
  • Does it fit well with your coding style?
  • How long has it been around? Is the community active and helpful?
  • Is the licensing compatible with your app?

Actual usage in Mileage Stats

During the Mileage Stats project we explored the use of many frameworks and libraries, eventually settling on the following:

  • jQuery: We chose jQuery because of its popularity, maturity, and high level of distribution. During our testing, the jQuery features we used worked as expected on all the devices. (With the exception of setting the Accept header during an XHR request as mentioned below, but that was not jQuery specific.) Nevertheless, we had some hesitation in choosing jQuery due to the fact that we only use a portion of jQuery's functionality and due to its non-trivial size (32KB, which minified and gzipped over a 2G connection might take 12 or more seconds to download).
  • Mustache: We chose Mustache to handle templating on the client side. Mustache is a very lightweight library, describing itself as "logic-less templates." It is small, has no additional dependencies, and could easily be combined and minified with all of our custom JavaScript.

Issues, gotchas and things we learned

One of the challenges when working in this space is the diversity and variations in browsers, especially in places where specifications are unclear. With this in mind, we strongly recommend that you test early and test often. Ensure you get something running on a device as soon as possible, in order to evaluate whether or not it behaves as expected. This is particularity important, as feature detection is often not binary. That means that just because something says it's supported doesn't mean it will work as it does on other devices.

As an example, we encountered a problem on certain devices (ones that had passed the feature tests for XHR), that did not include any custom HTTP headers such as Accept when making an XHR request. This problem was discovered late in the project and caused us to redesign the way we handled content negotiation.

In another case, we tested on a physical device that passed the check for the GeoLocation API, but never returned a value when we invoked the API. We discovered later that this problem only occurred when there was no SIM card in the device.

Building the single page application

The purpose of implementing a single page interface is primarily to improve the responsiveness of the app. This ultimately leads to a better user experience. Improved responsiveness is generally achieved by making fewer network requests and by having smaller payloads for the requests that are made.

In the case of Mileage Stats, our plan for implementing the SPA experience can be summarized as follows:

On the initial request, after a user has authenticated, the entire HTML, CSS, and JavaScript necessary to run the app would be downloaded at one time. Subsequent requests would only be for data. In addition, we would cache data in memory to reduce the number of requests. Navigation in the SPA would make use of the hashchange event and reflect the corresponding URL as much as possible. Likewise, we would employ a model view controller (MVC)-like pattern on the client side that would map the hash to a corresponding controller. These controllers would employ client-side templating to produce HTML fragments and update the document object model (DOM). In addition, these controllers would retrieve any necessary data from the cache or using XHR.

An alternative to this approach is to just load a minimal set of assets initially in order to make the startup time as short as possible. The choice is primarily driven by the desired user experience. In this scenario, the remaining assets are either loaded in the background immediately after startup or loaded just in time as they are needed. In the case of Mileage Stats, we felt this added complexity without a significant improvement in the user experience.

Note

Single page applications have become very popular. As a consequence, there are many JavaScript frameworks and libraries that are specifically intended to support this pattern. In general, they are often referred to as "JavaScript MVC" and searching the web for this term yields dozens of potential frameworks. Even so, the label "MVC" can be somewhat misleading, as many of these frameworks are not strictly concerned with implementing the pattern exactly. Some popular frameworks at the time of writing are:

  • Backbone.js
  • Batman.js
  • Ember.js
  • Knockout.js
  • SproutCore
  • Sammy.js
Some attempt to provide an end-to-end solution, while others focus on solving specific problems. Some can even be used in tandem.

The architecture of Mileage Stats SPA

The Mileage Stats app is built on the idea of progressive enhancement. This means that if we detect the minimum set of features necessary for enabling the SPA experience, we send additional markup to the client browser. Specifically, we render all of the necessary client-side templates and we include all of the necessary JavaScript.

Our custom JavaScript is partitioned in modules, where each module is a property on the global object mstats. Some modules are part of a general framework and other modules are specific to certain views in the app. The file app.js is the entry point for our client-side logic. It is responsible for configuring and bootstrapping the modules. We'll refer to modules by their file name, but with the .js extension omitted.

The router module is responsible for listening to changes in the hash. The hash is the portion of the URL beginning with the character # until the end of the URL. The hash is never sent back to the server. When a change in the hash occurs, the router looks for a matching route and then delegates to the transition module.

Note

Those of you following the HTML5 specification may ask why we choose to use the hashchange event instead of the newer (and better) pushstate. Our reason was simply that some of our targeted devices did not support pushstate, whereas they all supported hashchange. There are libraries such as history.js that will attempt to use pushstate and fall back to hashchange only as necessary. However, we wanted to avoid taking another external dependency. In general though, using pushstate is preferred over using hashchange because it allows URLs to be consistent between SPAs and their more traditional counterparts.

The transition module coordinates the retrieval of any necessary data, the application of the client template, the invocation of custom logic related to the new view, and finally updating the DOM. This coordination is governed by configuration registered for a route in app.js.

The ajax module is the gateway for all communication with the server. Internally, it makes use of jQuery's Ajax helper. It also handles caching of data.

This code also makes heavy use of convention over configuration. For example, it is assumed that a hash of #/vehicle/1/details should request JSON from /vehicle/1/details. Likewise, it assumed that the client-side template to be used is named vehicle_details.

Deeper in the SPA

Let's dive deeper into how the SPA was implemented.

Altering the markup for SPA

We begin by detecting whether the minimum set of features necessary for enabling the SPA experience are present. This occurs at the beginning of the layout for mobile browsers. (See Detecting Devices and their Features for more information on how the extension method IsWow is implemented.)

<!DOCTYPE HTML>
@{
    var shouldEnableSpa = (Request.Browser.IsWow() && User.Identity.IsAuthenticated);
    var initialMainCssClass = shouldEnableSpa ? "swapping" : string.Empty;
}
...

If shouldEnableSpa is set to true, then we make two changes to what is sent to the browser.

First, we render all of the client-side templates.

...
<body>
    @if (shouldEnableSpa)
    {
        Html.RenderPartial("_spaTemplates");
    }
...

All of the client side templates are referenced in a partial view in order to make the source of _Layout.Mobile.cshstml easier to read.

Second, we emit the necessary JavaScript to support the SPA.

...
<script type="text/javascript" src="@Url.Action("ProfileScript", "MobileProfile")"></script> 
@if (shouldEnableSpa)
{
    <script type="text/javascript">
        // allowing the server to set the root URL for the site, 
        // which is used by the client code for server requests.
        (function (mstats) {
            mstats.rootUrl = '@Url.Content("~")';
        } (this.mstats = this.mstats || {}));
    </script>
    <script src="//ajax.aspnetcdn.com/ajax/jQuery/jquery-1.7.1.min.js"></script>
    <script src="//ajax.aspnetcdn.com/ajax/jquery.validate/1.9/jquery.validate.min.js"></script>
    <script src="//ajax.aspnetcdn.com/ajax/mvc/3.0/jquery.validate.unobtrusive.js"></script>
    <script src="//ajax.aspnetcdn.com/ajax/mvc/3.0/jquery.unobtrusive-ajax.min.js"></script>
    <script src="~/Scripts/mustache.js"></script>
            
if (HttpContext.Current.IsDebuggingEnabled)
{
<script src="~/Scripts/mobile-debug/ajax.js"></script>
<script src="~/Scripts/mobile-debug/app.js"></script>
<script src="~/Scripts/mobile-debug/charts.js"></script>
<script src="~/Scripts/mobile-debug/dashboard.js"></script>
<script src="~/Scripts/mobile-debug/expander.js"></script>
<script src="~/Scripts/mobile-debug/formSubmitter.js"></script>
<script src="~/Scripts/mobile-debug/fillup-add.js"></script>
<script src="~/Scripts/mobile-debug/reminder-add.js"></script>
<script src="~/Scripts/mobile-debug/reminder-fulfill.js"></script>
<script src="~/Scripts/mobile-debug/router.js"></script>
<script src="~/Scripts/mobile-debug/transition.js"></script>
<script src="~/Scripts/mobile-debug/vehicle.js"></script>
<script src="~/Scripts/mobile-debug/notifications.js"></script>
} else {
<script src="~/Scripts/MileageStats.Mobile.min.js"></script>
}         
}
...

Note that we always emit the profiling script regardless of the value of shouldEnableSpa. This is the script mentioned in Detecting Devices and their Features used to detect features.

We also set a variable, mstats.rootUrl, which contains the root URL of the site. This variable is used to distinguish which parts of the URL are relevant to our routing logic. We'll discuss it more later.

Next we reference the various scripts such as jQuery that are available on content delivery networks. The scheme is intentionally omitted for these URLs. This allows the browser to reuse whatever scheme the page itself was requested with. A number of popular scripts are available on the Microsoft Ajax Content Delivery Network.

Finally, we emit references to our custom JavaScript. If we are running with a debugger attached, then we load the individual files. Otherwise, we reference the minified version that is generated at compile time.

Bootstrapping the JavaScript

The entry point for the JavaScript is app.js. Here we register the "routes," in other words, the hash values that will be used for navigation. The app.js file is responsible for bootstrapping the client-side portion of the app.

In addition, app.js defines a function, require, that is responsible for resolving dependencies. The require function is passed into each module when it is initialized and provides a means for modules to declare external dependencies. This approached is inspired by NodeJS and CommonJS, but is very simple in its implementation and not meant to imply compatibility with any other module loading systems or specifications.

...
function require(service) {

    if (service in global) return global[service];

    if (service in app) {
        if (typeof app[service] === 'function') {
            app[service] = app[service](require);
        }
        return app[service];
    }

    throw new Error('unable to locate ' + service);
}
...

Note that this implementation does not check for circular dependencies.

After the DOM is loaded, app.js uses require to initialize all of the modules.

...
var registration,
    module;

for (registration in app) {

    module = app[registration];

    if (typeof module === 'function') {
        app[registration] = module(require);
    }
}
...

In this context, the variable app points to the global object mstats. It is the responsibility of each module to register itself with mstats before the DOM is loaded. Again, our implementation is very simple. We iterate over the properties of app (that is, of mstats), and if the property is a function, then we invoke it passing in the require function. The property is then overwritten with the result of the function.

The assumption here is that each module sets a property on mstats to a function that will create an instance of the module. If the property happens to be an object already, we simply leave it alone.

After all of the modules have been initialized, app.js begins the configuration of the router module.

...
app.router.setDefaultRegion('#main');
var register = app.router.register;
...

The router module is now accessible to us as app.router. The router expects there to be a single element in the DOM that will act as a container for all of the views. We refer to this element as the default region and we set it using the CSS selector "#main". This means that our markup must contain an element with an id of "main".

...
<div id="main" class="@initialMainCssClass">
    @RenderBody()
</div>
...

We also create a convenience variable for referencing the register function.

The following snippet contains three different route registrations:

...
register('/Vehicle/:vehicleId/Details');
register('/Dashboard/Index', app.dashboard);
register('/Vehicle/:vehicleId/Fillup/List', { postrender: function (res, view) { app.expander.attach(view); } });
...

Let's examine these one at a time, beginning with:

register('/Vehicle/:vehicleId/Details');

This registration has only the route. Internally, the router will convert this string into a regular expression for matching the hash value. Notice that we identify the parameter of vehicleId by prefixing it with a colon. It will also extract the actual value of vehicleId from any matching route and make it available for binding to the template.

Since we don't pass a second argument, the router makes the following assumptions:

It needs to fetch data to bind to a template.

The data should be requested from /Vehicle/vehicleId/Details (where vehicleId matches the actual vehicle id found in the hash).

Once the data is retrieved, it should be bound to a template with an id of "vehicle_details".

This is an example of convention over configuration. The app makes assumptions based on these conventions as opposed to requiring explicit configuration for each route.

register('/Dashboard/Index', app.dashboard);

The second registration takes a route and an object. In this case, it takes an instance of the dashboard module. The second parameter can have the following properties, all are optional:

fetch – (true or false) Should it request data before navigating to the view? Defaults to true.

route – (a URL) The URL to use to request data. Defaults to the first argument.

prerender – (function) Custom logic to be executed before the data is bound to the view. The function receives an instance of the model. By model, we mean the JavaScript Object Notation (JSON) representation of the data received from the server.

postrender – (function) Custom logic to be executed after the template has been rendered and the DOM has been updated. This function receives the model, the jQuery-wrapped element that was added to the DOM, and metadata about the selected route.

region – (css selector) Identifies the container whose content will be replaced when transitioning to the new view.

Let's examine the dashboard module:

(function (mstats) {
    mstats.dashboard = function (require) {

        var expander = require('expander');

        function highlightImminentReminders(res, view) {
        // omitted for brevity
        }

        return {
            postrender: function (res, view) {
                highlightImminentReminders(res, view);
                expander.attach(view);
            }
        };
    };
} (this.mstats = this.mstats || {}));

When dashboard is initialized, it returns an object with a single property called postrender. This function is invoked whenever the user navigates to the dashboard, generally, by clicking Dashboard on the main menu. First, it executes a helper function that cycles through the reminders highlighting corresponding elements in the view. Second, it invokes the expander module and attaches it to the view. The expander module finds any collapsible lists on the view and wires up the necessary logic for expanding and collapsing the lists. Notice also that we use the require function to retrieve the instance of the expander module.

register('/Vehicle/:vehicleId/Fillup/List', { postrender: function (res, view) { app.expander.attach(view); } });

This registration takes an object literal as the second argument instead of a module. In the case of this route, it felt like overkill to create an entire module to expose such a simple function. Instead, we chose to simply provide the function inline. The router doesn't know the difference; as far as JavaScript is concerned they are all just objects. In this case, we simply wanted to attach the expander module to view after rendering.

Note

There were a few enhancements that we considered but did not have time to explore. We considered generating the route registrations on the server, using the routing data for ASP.NET MVC. Likewise, we thought about having the registration automatically associate modules to routes based on naming. This would have taken the convention over configuration even further. For example, if we had used this approach, we might have named the dashboard module "dashboard_index" and then we could have omitted it from the registration.

After all of the routes have been configured, we start the app with

...
app.router.initialize();
...

Routing and transitioning

When we initialize the router, it searches the entire DOM for any links that match registered routes. When it finds a link, it modifies the href to use the SPA version of the URL.

...
function overrideLinks() {

    $('a[href]', document).each(function (i, a) {
        var anchor = $(a);
        var match;
        var href = anchor.attr('href').replace(rootUrl, '/');
        if (href.indexOf('#') === -1 && (match = matchRoute(href))) {
            anchor.attr('href', rootUrl + '#' + match.url);
        }
    });
}
...

For example, if we assume the app is running at /MileageStats, then a URL such as /MileageStats/Vehicle/1/Details would be modified to /MileageStats /#/Vehicle/1/Details.

JJ149689.AFE5E2C8B18C758683DFD06139E2E4D4(en-us,PandP.10).png

The interaction of the modules for the SPA

This means that instead of requesting a new page from the server whenever the user selects an anchor, the hashchange event will fire. When this event fires, the router finds a match and delegates to the transition module.

...
window.onhashchange = function () {
    var route = window.location.hash.replace('#', ''),
        target = matchRoute(route);

    if (target) {
        transition.to(target, defaultRegion, namedParametersPattern, function () {
            overrideLinks();
        });
    } 
    ...
};
...

The transition module exports**the function to. Inside the module however, the function is named transitionTo. This function is at the heart of the SPA.

...
// this function is exported as mstats.transition.to
function transitionTo(target, defaultRegion, namedParametersPattern, callback) {

    var registration = target.registration,
        region = registration.region || defaultRegion,
        host = $(region, document),
        route = registration.route;

    var template = getTemplateFor(route, namedParametersPattern);
    var onSuccess = success(host, template, target, callback);

    host.removeClass(cssClassForTransition).addClass(cssClassForTransition);

    if (registration.fetch && !mstats.initialModel) {

        ajax.request({
            dataType: 'json',
            type: 'GET',
            url: appendJsonFormat(makeRelativeToRoot(target.url)),
            success: onSuccess,
            cache: false,
            error: error(host)
        });

    } else {
        var response = {
            Model: app.initialModel
        };
        app.initialModel = null;
        onSuccess(response);
    }
} 
...

Let's examine how this function works. The variable region is a CSS selector for identifying the container that will host the view. We use jQuery to find the region and store it in host. Next, we use getTemplateFor to locate the DOM element that contains the corresponding template. All of the templates are stored in the DOM as script elements with a type of text/html. The type attribute prevents the script from being executed, and we can treat the content as simply text.

Here's an example of a client-side template being included. We'll examine this more later.

...
<script id="dashboard-index" type="text/html">
    @{ Html.RenderAsMustacheTemplate("~/Views/Dashboard/Index.Mobile.cshtml"); }
</script>
...

The function getTemplateFor derives the id of the script element from the route and then returns the contents of the script element as text.

Next, we call another helper function, success, that returns a function itself. This allows us to create a closure capturing the necessary state we'll need after our Ajax request completes. For more information on closures in JavaScript see Use Cases for JavaScript Closures.

We remove the CSS class to trigger an animation. In this context, cssClassForTransition has a value of "swapping". The CSS transition only works for browsers that support the transition property. We establish a rule saying "if the opacity property is changed, it should take 1 second to transition from its current value to the new value, interpolating any intermediate values along the way." We define this rule on the host element and then add and remove the "swapping" class to trigger the animation.

...
#main {
    -moz-transition: opacity 1s;
    -webkit-transition: opacity 1s;
    -o-transition: opacity 1s;
    -ms-transition: opacity 1s;
    transition: opacity 1s
}

.swapping {
    opacity: 0.2
}
...

Next, we check to see if the route has been configured to fetch data from the server. If so, we use the ajax module to request the data. If we don't need to fetch the data, then we manually invoke the onSuccess function we created earlier.

Note

We also have a special condition checking for and using initialModel. This is an optimization that only applies for the very first view that was loaded. Rather than having the server load all of the initial assets and then immediately send a new request for the data of the first view, we simply include the data on the initial load and have the server look for it.

Let's examine the helper function, success.

...
function success(host, template, target, callback) {

    var registration = target.registration;

    return function (model, status, xhr) {

        var view, el;

        model.__route__ = target.params;

        if (registration.prerender) {
            model = registration.prerender(model);
        }

        view = templating.to_html(template, model);
        host.empty();
        host.append(view);
        el = host.children().last();

        if (registration.postrender) {
            registration.postrender(model, el, target);
        }

        notifications.renderTo(host);

        host.removeClass(cssClassForTransition);

        if (callback) callback(model, el);
    };
}
...

This function returns another function. However, it uses a closure to capture several important variables in order to have the appropriate state.

The resulting function first copies over the parameters extracted from the route. It sets these to a well-known variable __route__. This name was chosen because it's unlikely that it will collide with other properties on the response sent from the server.

Next, we check for the presence of a prerender function and execute it if found.

After that, we invoke the templating engine to produce a DOM element by binding the model to our template. In this context, the variable templating points to Mustache. We then replace the contents of the host element with the new view we've just created.

Next, we check for the presence of a postrender function and execute it if found.

After the view has been added to the DOM, we render any notifications. These are messages such as "vehicle successfully updated." We also remove the CSS class, which triggers the reciprocal animation for the transition. Finally, we check for and invoke a callback.

Client-Side templates

Let's turn our attention to the client-side templates for a moment. As we've mentioned, we decided to use Mustache for templating. Mustache templates are very simple. They are standard HTML with placeholders for values identified in the template using double curly braces.

<p>{{person.name}}</p>

We could combine this template with the JSON data:

{ person: { name: 'Antonio Bermejo' } }

The result would be the following markup:

<p>Antonio Bermejo</p>

Additionally, Mustache has support for conditionals and loops. Both use the same syntax.

{{#has_items}}
<ul>
  {{#items}
    <li>{{#name}}</li>
  {{/items}}
</ul>
{{/has_ items }}

The template above could be combined with:

{ 
    has_items: true,
    items: [ 
        { name: 'ninja' },
        { name: 'pirate' }
    ]
}

The result would be:

<ul>
    <li>ninja</li>
    <li>pirate</li>
</ul>

By this point, you may have noticed that Mustache templates differ significantly from Razor templates. This presented us with an interesting problem. We wanted the client-side templates to match the server-side templates exactly. In addition, we wanted to avoid having to maintain two sets of templates. That is, one set for the Works experience and another for the Wow experience.

We decided to create a set of HTML helpers that would allow us to build a template with Razor and render either a Mustache template or simple markup. This allowed us to have one source template to maintain while providing us with both client-side and server-side templates.

Note

This was an experimental aspect of our project. The result worked well for us, but we had to create Mustache-aware helpers to mirror many of the standard helpers. We only implemented the helpers that we needed.

Below is a snippet from one of the templates. Notice that any value that would potentially be rendered as a placeholder in a Mustache template uses a custom helper. The custom helpers all begin with Mustache.

...
@{
    var today = DateTime.Now;
}
<ul>
    <li>
        @Html.LabelFor(model => model.Title)
        @Html.ValidationMessageFor(model => model.Title)
        @Mustache.TextBoxFor(model => model.Title, new { id = "ReminderTitle", maxlength = "50" })
    </li>
    <li>
        @Html.LabelFor(model => model.Remarks)
        @Html.ValidationMessageFor(model => model.Remarks)
        @Mustache.TextAreaFor(model => model.Remarks, new { wrap = "soft" })
    </li>
    <li class="psuedo-date">
        <label for="DueDate">Date</label>
        @Html.ValidationMessageFor(model = >model.DueDate)
        @Html.DropDownListFor(model => model.DueDateMonth, SelectListFor.Month(i => today.Month == i))
        @Html.DropDownListFor(model => model.DueDateDay, SelectListFor.Date(i => today.Day == i))
        @Html.DropDownListFor(model => model.DueDateYear, SelectListFor.Year(i => today.Year == i))
    </li>
    <li>
        @Html.LabelFor(model => model.DueDistance)
        @Html.ValidationMessageFor(model => model.DueDistance)
        @Mustache.InputTypeFor(model => model.DueDistance)
    </li>
</ul>
...

This template can be rendered as the result of a controller action just as you would render any view. However, when we want to render this as a Mustache template we would need to use another helper, as demonstrated below:

@{ Html.RenderAsMustacheTemplate("~/Views/Reminder/ReminderForm.Mobile.cshtml"); }

Note

In the actual source for Mileage Stats, ReminderForm.Mobile.cshtml is only rendered using Html.Partial. However, this occurs in Add.Mobile.cshtml, which in turn is rendered with Html.RenderAsMustacheTemplate. The rendering context is passed on the partial so that the complete view renders as expected.

It is worth mentioning that we had to be careful to handle the differences between Mustache and Razor with respect to iterating over enumerables. We had to introduce helpers that could emit the closing identifier required by Mustache for rendering lists. Consider this snippet from \Views\Reminder\List.Mobile.cshtml:

...
@foreach (var item in Mustache.Loop(m => m))
{
<dl class="fillup widget">
    <dt><h2><a href="#">@Mustache.Value(item, m => m.StatusName)</a></h2></dt>
    <dd>
        <table>
            <-- omitted for brevity -->
        </table>
    </dd>
</dl>
}
...

In this snippet, the model's type is List<ReminderListViewModel>. Our helper, Mustach.Loop, returns an instance of a custom class MileageStats.Web.Helpers.Mustache.DisposableEnumerable<T>. This class wraps any enumeration that we give and emits the necessary Mustache identifiers before and after the code block inside the foreach loop. It's able to do this because it's automatically disposed by the foreach statement. We emit the opening identifier in its constructor and the closing identifier when it's disposed.

One of the most beneficial side effects of using Razor to produce the Mustache templates is that it forced us to simplify our views. We found that there were lots of places where presentation logic (such as formatting values) had bled into the views. Since we didn't want to implement this same logic in JavaScript, we moved it into the corresponding view models. In the end, this made the views easier to maintain without having a negative impact on the view models. Additionally, we were able to reuse the built-in ASP.NET MVC validation (driven by the DataAnnotation attributes).

Summary

Use of the Single Page Application pattern can produce fast and responsive apps. However, you should always be aware of which browsers your app needs to support and test extensively on actual devices to ensure that the features supporting you SPA work as expected. If you are considering a third-party framework or library to support your SPA, you should prototype the types of features you anticipate your app will need and test on actual devices.

Next Topic | Previous Topic | Home | Community

Last built: June 5, 2012