Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Combine/Compress/Minify JS and CSS files in ASP.NET MVC

0.00/5 (No votes)
10 Mar 2009 1  
This article will present a technique for delivering CSS and JavaScript files to a client within an ASP.NET MVC application. More specifically it will show how to deliver compressed, minified, combined and cached CSS / JavaScript files in the context of an ASP.NET MVC application.

Introduction

This article will present a technique for delivering CSS and JavaScript files to a client within an ASP.NET MVC application. More specifically it will show how to deliver compressed, minified, combined and cached CSS / JavaScript files in the context of an ASP.NET MVC application. The goal was to improve overall performance on the client side. Similarly, it will show how we can force a client side refresh of the same CSS and JavaScript files (meaning we don't have to change our file names). This becomes increasingly important when using AJAX techniques within an application that introduce new features and functionality. In short, we can ensure our clients have the necessary upgraded JavaScript files in order execute new functionality and not the files stored in the browser's cache.

The Code

The impetus for this article was based on the article by Khaled Atashbahar. I use a lot of the same techniques but the main difference is that I use a custom Controller and ActionResult rather than a HttpHandler to handle the minify/combine/render. I wanted to stay consistent with MVC pattern by leveraging my routes. In addition, I was using Structure Map dependency injection. When using a path to an HTTPHandler in my markup that was not registered with my IContollerFactory factory, DI, MVC and the HTTP handler were not playing nicely. To this end, let’s get right into the code and show how this works. First in my master page, I place the following markup. For the CSS files, I have:

<link type="text/css" rel="Stylesheet" 
	href="http://www.codeproject.com/cache/cachecontent/CSSInclude/
	<%=GetApplicationVersionID() %>/css" />

And for the JavaScript files, I have:

<script type="text/javascript" 
	src="http://www.codeproject.com/cache/cachecontent/JavaScriptInclude/
	<%=GetApplicationVersionID() %>/javascript" />  

These are URL paths to the controller that will handle writing the files out to the client. Now in order to understand how the path references work, let’s take a look at the applicable route I define in the Global.asax file.

    routes.Add(new
        Route ("cache/{action}/{key}/{version}/{type}", new MvcRouteHandler()) {
            Defaults = new RouteValueDictionary(new { controller = "Cache", 
		action = "CacheContent", key = "", version = "", type = ""
            }),
            }
           ); 

So what does this route say?  It says when we encounter a URL in a format like /cache/cachecontent/CSSInclude/12345/css, the request should be handed off the Cache controller and specifically the CacheContent action method in order to handle the event.  A few comments about the format of the URL: 

{action} = The method we call when the controller receives the request.

{key} = The name of the key in the web.config that holds a comma separated value list of all the files we want to combine/compress and minify. Let’s look at this example snippet from a web.config file.

 <add key="CssInclude" value="~/Content/styles/globalStylesheet.css,
					~/Content/styles/ie6.css"/>
<add key="JavaScriptInclude" value=
"~/Content/scripts/jquery=1.2.6.min.js,
      ~/Content/scripts/jquery-DatePickerUI.js,
      ~/Content/scripts/jQuery.BlockUI.js,
      ~/Content/scripts/jquery.selectboxes.min.js,
      ~/Content/scripts/jquery.swfobject.js,
      ~/Content/scripts/MicrosoftAjax.js,
~/Content/scripts/MvcAjax.js,~/Content/scripts/DataServices.js,
~/Content/scripts/s_code.js,
      ~/Content/scripts/template.js,
      ~/Content/scripts/ValidationServices.js" />

Here we see we have a key name for CSS and a key name for JavaScript. If we use JavaScriptInclude in our URL, we will combine all the JavaScript files in the value list.

{version} = The version number that we should use on the files we will be rendering. I use a number within the URL path so we can change the URL to make the browser think it has not seen the file in the past. The version could come from the database or even the web.config. The key point is we can leverage the version value to control client side caching. In development, I use a random number but in production I use something that has been configured in the web.config. The reason for the random number is I can ensure my browser always uses the latest and greatest version of my JavaScript files. This alleviates the need to clear the browsers cache during development. I am sure we all have been burned by forgetting to clear the cache while tracking down a piece of functionality that is not working as expected.

{type} = A name value to inform whether we are handling a CSS or JavaScript request.

Next, let’s take a look at our cache controller.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.Web.Mvc.Ajax;
using System.Drawing;
using System.Web.Configuration;
using System.Reflection;
using System.Web.Routing;
using System.IO;
using System.Web.Caching;

namespace Cache
{
    public class CacheResult : ActionResult
    {
      private string _keyname;
      private string _version;
      private string _type;

      public CacheResult(string keyname, string version, string type)
      {
        this._keyname = keyname;
        this._version = version;
        if ( type.ToLower().Contains("css")   )
        {
          this._type = @"text/css";
        }

        if ( type.ToLower().Contains("javascript") )
        {
          this._type = @"text/javascript";
        }
      }

      public override void ExecuteResult(ControllerContext context)
      {
        if (context == null)
          throw new ArgumentNullException("context");
        ScriptCombiner myCombiner = new ScriptCombiner
			( this._keyname, this._version, this._type);

        myCombiner.ProcessRequest(context.HttpContext);
      }
    }

  public static class CacheControllerExtensions
  {
    public static CacheResult RenderCacheResult
		(string keyname, string version, string type)
    {
      return new CacheResult(keyname, version, type);
    }
  }

    public class CacheController :Controller
    {
      #region Constructor Definitions
      public CacheController()
        : base()
      {
      }
      #endregion

    #region Method Definitions
    #region public
      public CacheResult CacheContent(string key, string version, string type)
      {
        return CacheControllerExtensions.RenderCacheResult(key, version, type);
      }

      public CacheResult ClearCache()
      {
        //LOGIC TO CLEAR OUT CACHE
      }
    #endregion
    #endregion

    } // End class CacheController
}

Here we see that our CacheContent method invokes a ControllerExtension method called RenderCacheResult. It passes in the key name of the files to render, the version it should use as well as the type of content (CSS or JavaScript) it will be rendering. The heavy lifting is completed by the ExecuteResult method within custom CacheResult class. This method uses the techniques mentioned in the beginning of the article by Khaled Atashbahar. For completeness, I include the code below. I do however want to point out a few changes I made to his code. First, I changed the class so it can handle both CSS and JavaScript files by adding the type parameter to the CacheResult constructor. Next, I removed the use of querystring parameters in favor of values passed into the constructor. I also changed the GetScriptFileNames to leverage the file names stored in the web.config file rather than something passed via the querystring. Last, I forgo the minify step during ProcessRequest to make it easier to debug JavaScript files when in debug mode. Here is the code for your reference.

using System;
using System.IO;
using System.IO.Compression;
using System.Text;
using System.Web;
using System.Web.Mvc;

namespace SomeNameSpace
{
  public class ScriptCombiner
  {
    private readonly static TimeSpan CACHE_DURATION = TimeSpan.FromDays(30);
    private System.Web.HttpContextBase context;
    private string _ContentType;
    private string _key;
    private string _version;

    public ScriptCombiner(string keyname, string version, string type)
    {
      this._ContentType = type;
      this._key = keyname;
      this._version = version;
    }

    public void ProcessRequest(System.Web.HttpContextBase context)
    {
      this.context = context;
      HttpRequestBase request = context.Request;

      // Read setName, version from query string
      string setName = _key;
      string version = _version;
      string contentType = _ContentType;
      // Decide if browser supports compressed response
      bool isCompressed = this.CanGZip(context.Request);

      // If the set has already been cached, write the response directly from
      // cache. Otherwise generate the response and cache it
      if (!this.WriteFromCache(setName, version, isCompressed, contentType))
      {
        using (MemoryStream memoryStream = new MemoryStream(8092))
        {
          // Decide regular stream or gzip stream based on 
	 // whether the response can be compressed or not
          //using (Stream writer = isCompressed ?  (Stream)(new GZipStream
	 // (memoryStream, CompressionMode.Compress)) : memoryStream)
          using (Stream writer = isCompressed ?
               (Stream)(new GZipStream(memoryStream, CompressionMode.Compress)) :
               memoryStream)
          {
            // Read the files into one big string
            StringBuilder allScripts = new StringBuilder();
            foreach (string fileName in GetScriptFileNames(setName))
              allScripts.Append(File.ReadAllText(context.Server.MapPath(fileName)));

            // Minify the combined script files and remove comments and white spaces
            var minifier = new JavaScriptMinifier();
            string minified = minifier.Minify(allScripts.ToString());
#if DEBUG
            minified = allScripts.ToString();
#endif
            byte[] bts = Encoding.UTF8.GetBytes(minified);
            writer.Write(bts, 0, bts.Length);
          }

          // Cache the combined response so that it can be directly written
          // in subsequent calls
          byte[] responseBytes = memoryStream.ToArray();
          context.Cache.Insert(GetCacheKey(setName, version, isCompressed),
              responseBytes, null, System.Web.Caching.Cache.NoAbsoluteExpiration,
              CACHE_DURATION);

          // Generate the response
          this.WriteBytes(responseBytes, isCompressed, contentType);
        }
      }
    }
    private bool WriteFromCache(string setName, string version, 
			bool isCompressed, string ContentType)
    {
      byte[] responseBytes = context.Cache[GetCacheKey
			(setName, version, isCompressed)] as byte[];

      if (responseBytes == null || responseBytes.Length == 0)
        return false;

      this.WriteBytes(responseBytes, isCompressed, ContentType);
      return true;
    }

    private void WriteBytes(byte[] bytes, bool isCompressed, string ContentType)
    {
      HttpResponseBase response = context.Response;

      response.AppendHeader("Content-Length", bytes.Length.ToString());
      response.ContentType = ContentType;
      if (isCompressed)
        response.AppendHeader("Content-Encoding", "gzip");
      else
        response.AppendHeader("Content-Encoding", "utf-8");

      context.Response.Cache.SetCacheability(HttpCacheability.Public);
      context.Response.Cache.SetExpires(DateTime.Now.Add(CACHE_DURATION));
      context.Response.Cache.SetMaxAge(CACHE_DURATION);
      response.ContentEncoding = Encoding.Unicode;
      response.OutputStream.Write(bytes, 0, bytes.Length);
      response.Flush();
    }

    private bool CanGZip(HttpRequestBase request)
    {
      string acceptEncoding = request.Headers["Accept-Encoding"];
      if (!string.IsNullOrEmpty(acceptEncoding) &&
           (acceptEncoding.Contains("gzip") || acceptEncoding.Contains("deflate")))
        return true;
      return false;
    }

    private string GetCacheKey(string setName, string version, bool isCompressed)
    {
      return "HttpCombiner." + setName + "." + version + "." + isCompressed;
    }

    public bool IsReusable
    {
      get { return true; }
    }

    // private helper method that return an array of file names 
    // inside the text file stored in App_Data folder
    private static string[] GetScriptFileNames(string setName)
    {
      var scripts = new System.Collections.Generic.List<string>();

      string setDefinition =
                  System.Configuration.ConfigurationManager.AppSettings[setName] ?? "";
      string[] fileNames = setDefinition.Split(new char[] { ',' },
          StringSplitOptions.RemoveEmptyEntries);

      foreach (string fileName in fileNames)
      {
        if (!String.IsNullOrEmpty(fileName))
          scripts.Add(fileName);
      }
      return scripts.ToArray();
    }
  }
}

To summarize this article, we introduced a Custom Controller called CacheController and a route within the controller that allows for minifying/compressing /combining and caching our JavaScript and CSS files within an ASP.NET MVC application. We also introduced a mechanism within the controller that we can leverage to force a client side refresh of JavaScript and CSS files.

I hope you find this helpful. Let me know your thoughts or ideas you may have to improve this technique.

History

  • 8th March, 2009: Initial version

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here