Introduction
Async views in webpages are becoming more common with JavaScript heavy apps and even more so with single page apps. Post-backs are starting to feel pretty old-school.
The myriad frameworks (Durandal, Angular, Ember, etc.) and techniques being used in these applications generally do a great job, often by injecting JSON data from AJAX calls into view templates and provide a very snappy user experience.
But What If SEO is Important?
Sure, you can take advantage of AJAX enabled web crawlers ala 'escaped-fragment', but then you need to have two rendering pipelines and server code to recognize the special Ajax-enabled crawlers so that it can send the server rendered code to them.
- Why would you want to write twice as much code?
Another great length people have gone to satisfy the shortcomings of search crawlers is to setup a 'Snapshot Server'. This may be an acceptable method if you care about not having to write a bunch of special rendering code but still don't mind writing your server logic to be aware of the escaped-fragment mechanism. You will also be introducing another layer of maintenance to keep your page crawling list in sync with your site as pages are added. Oh and you'll also need plenty of additional server resources to setup a dedicated, JavaScript enabled crawler just for your site so that it can write the results to HTML files that then get served to the crawlers...
There is Another Solution
Having worked on projects employing both of these techniques as well as many others and variations, I decided to take a step back. It seems that the simplest solutions are often the best, and a solution that can dynamically swap out only the portions of a page that are actually different using 100% server rendering that also falls back to normal web page behavior (complete loads) for older browsers and standard search crawlers is a big win for my projects.
Background
AHAH (Async HTML and HTTP)
Seems so simple, right? (And it is!)
Using the Code
Example code is available on Github.
To enable AHAH in your MVC application, there are two components you need to setup:
- An
ActionFilter
to modify the View's Layout - A small JavaScript to wire things up on the page
Server Side
[AhahLayout]
public ActionResult Index()
{
return View();
}
All the Attribute does is check to see if the browser is requesting AHAH via a header parameter, and either setting the MasterName(Layout)
to a view specified in the web.config or null
if not specified:
public override void OnResultExecuting(ResultExecutingContext filtercontext)
{
if (filtercontext != null &&
filtercontext.HttpContext != null &&
filtercontext.HttpContext.Request != null &&
filtercontext.HttpContext.Request.Headers!= null)
{
var trigger = filtercontext.HttpContext.Request.Headers[AhahTrigger];
if (!String.IsNullOrWhiteSpace(trigger))
{
var viewresult = filtercontext.Result as ViewResult;
if (viewresult != null)
{
viewresult.MasterName = AhahLayout;
}
}
}
}
On the Client
(function () {
var _activeLinks = [];
var _ahahLinkClass = 'ahah';
var _ahahContainerDataAttrib = 'data-container';
var _ahahDefaultContainer = 'content';
function supportsHistoryAPI() {
return !!(window.history && history.pushState);
}
function updateContent(state) {
var req = new XMLHttpRequest();
req.open('GET', state.href, false);
req.setRequestHeader("AHAH", "true");
req.send(null);
if (req.status == 200) {
var container = document.getElementById(state.container);
if (container) {
container.innerHTML = req.responseText;
initAhahLinks();
return true;
}
}
return false;
}
function addLinkClickHandler(link) {
link.addEventListener("click", function (e) {
var state = {
href: link.href,
container: link.getAttribute(_ahahContainerDataAttrib) || _ahahDefaultContainer
};
if (updateContent(state)) {
history.pushState(state, null, state.href);
e.preventDefault();
}
}, true);
}
function initAhahLinks() {
var links = document.getElementsByClassName(_ahahLinkClass);
if (links) {
for (var i = 0; i < links.length; i++) {
if (_activeLinks.indexOf(links[i]) < 0) {
addLinkClickHandler(links[i]);
_activeLinks.push(links[i]);
}
}
}
}
window.onload = function () {
if (!supportsHistoryAPI()) {
return;
}
initAhahLinks();
window.setTimeout(function () {
window.addEventListener("popstate", function (e) {
if (e.state) {
updateContent(e.state);
} else {
var state = {
href: location.href,
container: _ahahDefaultContainer
};
updateContent(state);
}
}, false);
}, 1);
}
})();
And that's it!
The end effect is that requests to the link's hrefs are responded to with full renderings wrapped in the layout pages as normal. However, if the HTTP header is present, then the exact same action method is post processed to remove the layout reference and only the called view's content is sent down the wire.
This ensures that older browsers can use your app/site in the same standard way they always have without the need to shim and hack new web standard's functionality into them. Perhaps more importantly, search engine crawlers can also index your app/site in the traditional manner.
Points of Interest
There are some obvious economies to be realized by reducing the payload between pages to only what needs to change. However, the largest savings are gained by removing the need to download, parse and execute the many JavaScript libraries and CSS files every time a user navigates to a "page".
Caveat
If you are using JavaScript plugins (photo galleries, data-bound widgets, etc.), you will need to design the sections you want to apply an AHAH workflow to in such a way that they will be able to reinitialize themselves after loading. In other words, you will need to reapply the plugins to the newly added DOM fragment.
I believe this caveat promotes modular design, and maybe that is not such a bad thing after all.
Performance
Full Page Load Transfer Time= 844ms transfer time
AHAH Page Load Transfer Time= 145ms transfer time
The payload savings alone come to around 700ms, not to mention the significant benefits achieved from avoiding multiple requests and loading of all your many framework level CSS and JavaScript files.
History
-
3/11/2014- Initial commit
- 3/13/2014- Moved the trigger logic to use headers instead of query string