ASP.Net MVC and RegisterClientScriptInclude
I like developing with ASP.Net MVC, but there's one functionality that I really miss, compared to ASP.Net Web Forms. In Web Forms you can use The RegisterClientScriptInclude and RegisterClientScriptBlock methods of the ClientScriptManager class, to register JavaScript includes / blocks of JavaScript code, to the head element of the generated HTML (from within code, aspx, ascx, etc.). I have found a way to accomplish the same with ASP.Net MVC, which I will describe in the remainder of this post.
First a small ASP.net Web Forms code-behind snippet, to show how the ClientScriptManager class that we are trying to mimic, is used to register a JavaScript file to the head element.
protected void Page_Load(object sender, EventArgs e) {
var key = "SampleKey";
var javaScriptFile = this.ResolveUrl("~/Scripts/SampleJavaScript.js");
if(!this.Page.ClientScript.IsClientScriptIncludeRegistered(key))
{
this.Page.ClientScript.RegisterClientScriptInclude(key, javaScriptFile);
}
}
From this point on, we will focus on mimicking this functionality in ASP.Net MVC. After which we will be able to use the following code snippet in our (partial) views, to register a JavaScript file to the head element of the generated HTML.
Html.RegisterClientScriptInclude("SampleKey", "~/Scripts/SampleJavaScript.js");
First we will start with the creation of a new static class, that we will call ScriptExtensions. This class will contain two HtmlHelper extension methods (IsClientScriptIncludeRegistered and RegisterClientScriptInclude), that we will use to register JavaScript includes. We will use the HttpContext.Current.Items collection, to store a Dictionary<string, string> collection. This collection will contain the JavaScript files to include in the current http request. We can safely use the HttpContext.Current.Items collection from within a static class/method, because it only exists for the current HTTP request. With the IsClientScriptIncludeRegistered method we can find out if a JavaScript file is already included or not, and with the RegisterClientScriptInclude we can include it. Aside from the two HtmlHelper extension methods, we also have a public method called GetClientScriptIncludes, which we will use later on to retrieve the registered JavaScript includes. Below you can find the listing of this class.
namespace MyAssemblyName.Mvc.HtmlHead.Helpers
{
using System.Collections.Generic;
using System.Globalization;
using System.Web.Mvc;
using System.Web;
public static class ScriptExtensions
{
private const string MyAssemblyNameScriptIncludes = "MyAssemblyNameScriptIncludes";
public static bool IsClientScriptIncludeRegistered(this HtmlHelper helper, string key)
{
return GetScriptIncludes().ContainsKey(key);
}
public static void RegisterClientScriptInclude(this HtmlHelper helper, string key, string scriptInclude)
{
var scriptIncludes = GetScriptIncludes();
if (!scriptIncludes.ContainsKey(key))
{
scriptIncludes.Add(key, scriptInclude);
}
}
private static Dictionary<string, string> GetScriptIncludes()
{
var scriptIncludes = HttpContext.Current.Items[MyAssemblyNameScriptIncludes] as Dictionary<string, string>;
if (scriptIncludes == null)
{
scriptIncludes = new Dictionary<string, string>();
HttpContext.Current.Items[MyAssemblyNameScriptIncludes] = scriptIncludes;
}
return scriptIncludes;
}
public static IList<string> GetClientScriptIncludes()
{
var scriptIncludes = GetScriptIncludes();
var scripts = new List<string>();
foreach (string scriptInclude in scriptIncludes.Values)
{
scripts.Add(ResolveUrl(scriptInclude));
}
return scripts;
}
private static string ResolveUrl(string url)
{
if (!string.IsNullOrEmpty(url) && url.StartsWith("~"))
{
url = string.Format(CultureInfo.InvariantCulture, "{0}{1}", HttpContext.Current.Request.ApplicationPath, url.Substring(1)).Replace("//", "/");
}
return url;
}
}
}
Now that we have build functionality to store the JavaScript includes in a collection, we still need to add them to the head element of the HTML. This is more difficult than the previous step, because most of the time the view that registers the JavaScript include is rendered after the view (usually the master page) that renders the head element of the HTML. I have solved this problem by building a HttpModule. In this HttpModule (that I've called HtmlHeadModule) we will 'catch' all html output, just before it is sent to the browser. We are using the Filter property of the HttpResponse to do this. When a Stream is set to this property, all HTTP output sent by HttpResponse.Write passes through the filter. We will create a new instance of the HtmlHeadStream class that we will assign to the Filter. This Stream is a custom Stream, which we will discus, after the code listing of the HtmlHeadModule below. Please note that we will not do this if we are dealing with a .axd or .ashx, because we are not allowed to adjust the output of these requests.
namespace MyAssemblyName.Mvc.HtmlHead
{
using System;
using System.Web;
using MyAssemblyName.Mvc.HtmlHead.Helpers;
public class HtmlHeadModule : IHttpModule
{
#region IHttpModule Members
public void Dispose()
{
//clean-up code here.
}
public void Init(HttpApplication context)
{
context.BeginRequest += new EventHandler(context_BeginRequest);
}
#endregion
private void context_BeginRequest(object sender, EventArgs e)
{
try
{
var httpContext = new HttpContextWrapper(HttpContext.Current);
if (!(httpContext.Request.Url.AbsolutePath.EndsWith(".axd") || httpContext.Request.Url.AbsolutePath.EndsWith(".ashx")))
{
httpContext.Response.Filter = new HtmlHeadStream(httpContext.Response.Filter, httpContext);
}
}
catch (Exception ex)
{
MyLogger.Error("MyAssemblyName.Mvc.HtmlHead.HtmlHeadModule.context_BeginRequest()", ex);
}
}
}
}
The real 'magic' is happening in the HtmlHeadStream. In this stream, we are inserting the JavaScript includes, in the head element of the generated HTML. We are not outputting anything to the real output stream, until the Close method is called. When this method is called, we can retrieve all HTML, and add our JavaScript includes to it. I have used the Html Agility Pack from CodePlex to manipulate the generated HTML, which is an easy to use library for parsing and updating/manipulating HTML. Of course we are only doing this, if we are dealing with the content type text/html, and if there is at least one JavaScript file included, via our Html extension method. Below you can find the code listing of the HtmlHeadStream.
namespace MyAssemblyName.Mvc.HtmlHead
{
using System.IO;
using System.Text;
using System.Web;
using System.Web.UI;
using HtmlAgilityPack;
using MyAssemblyName.Mvc.HtmlHead.Helpers;
public class HtmlHeadStream : MemoryStream
{
private readonly Stream outputStream;
private readonly HttpContextBase httpContext;
private bool closing;
public HtmlHeadStream(Stream outputStream, HttpContextBase httpContext)
{
this.outputStream = outputStream;
this.httpContext = httpContext;
}
public override void Close()
{
// Using a StreamReader to read this will cause Close to be called again
if (this.closing)
{
return;
}
this.closing = true;
byte[] buffer = null;
if (ScriptExtensions.GetClientScriptIncludes().Count > 0 && this.httpContext.Response.ContentType == "text/html" && this.httpContext.Server.GetLastError() == null)
{
var html = this.ReadOriginalHtml();
if (!string.IsNullOrEmpty(html))
{
var doc = new HtmlDocument();
doc.LoadHtml(html);
if (doc.DocumentNode != null)
{
this.UpdateHtmlHead(doc);
html = doc.DocumentNode.OuterHtml;
}
}
buffer = this.httpContext.Response.ContentEncoding.GetBytes(html);
}
else
{
this.Position = 0;
buffer = this.GetBuffer();
}
this.outputStream.Write(buffer, 0, buffer.Length);
base.Close();
}
private string ReadOriginalHtml()
{
var html = string.Empty;
Position = 0;
using (var reader = new StreamReader(this))
{
html = reader.ReadToEnd();
}
return html;
}
private void UpdateHtmlHead(HtmlDocument doc)
{
var head = doc.DocumentNode.SelectSingleNode("//head");
if (head != null)
{
var scriptIncludes = ScriptExtensions.GetClientScriptIncludes();
foreach (var scriptInclude in scriptIncludes)
{
var node = CreateClientScriptInclude(doc, scriptInclude);
head.AppendChild(node);
}
}
}
private static HtmlNode CreateClientScriptInclude(HtmlDocument doc, string scriptInclude)
{
var node = doc.CreateElement(HtmlTextWriterTag.Script.ToString().ToLower());
WriteAttributeValue(node, HtmlTextWriterAttribute.Type.ToString().ToLower(), "text/javascript");
WriteAttributeValue(node, HtmlTextWriterAttribute.Src.ToString().ToLower(), scriptInclude);
return node;
}
private static void WriteAttributeValue(HtmlNode node, string attribute, string attributeValue)
{
if (node != null && node.Attributes != null)
{
if (node.Attributes[attribute] != null)
{
node.Attributes[attribute].Value = attributeValue;
}
else
{
node.Attributes.Append(attribute, attributeValue);
}
}
}
}
}
The only thing left to do, before we can use our solution, is registering our HtmlHeadModule and the namespace of the ScriptExtensions class to the web.config of our web application.
The following line should be added to the <configuration><system.web><pages><namespaces> element of the web.config
<add namespace="MyAssemblyName.Mvc.HtmlHead.Helpers"/>
The following line should be added to the <configuration><system.web><httpModules> element of the web.config
<add name="HtmlHeadModule" type="MyAssemblyName.Mvc.HtmlHead.HtmlHeadModule, MyAssemblyName" />
Finally the following line should be added to the <configuration><system.webServer><modules runAllManagedModulesForAllRequests="true"> element of the web.config
<add name="HtmlHeadModule" type="MyAssemblyName.Mvc.HtmlHead.HtmlHeadModule, MyAssemblyName" />
You will now be able to use the snippet below to register JavaScript includes to the head of your HTML from within any (partial) view on your page.
Html.RegisterClientScriptInclude("SampleKey", "~/Scripts/SampleJavaScript.js");
In this post I have described, how I mimmic the Web Forms RegisterClientScriptInclude method. You can also use this principle to mimmic the RegisterClientScriptBlock, and perhaps some other useful stuff (like including css files etc.).