Tuesday, February 2, 2010

Combine, compress, and update your CSS file in ASP.NET MVC

After doing some analysis using Google's web master tools I discovered that I needed to make some improvements to how the CSS file(s) on a busy site were being delivered. This post follows on from and improves on Automatically keeping CSS file current on any web page.

  • The CSS files need to be combined into a single file.
  • It needs to be compressed.
  • It needs to be cached, but only until it's changed.
  • Image references need to be dynamic.

I'm using ASP.NET MVC so the first thing that I did was to add an Action to a Controller that was to be the new CSS file. Instead of referencing a physical file on disk the CSS file has now become a resource that follows this pattern:

~/Site/Css/20100201105959.css

Site is the controller, Css is the action, and 20100201105959.css is the single parameter that this action accepts.

This is what the SiteController class looks like:

[CompressFilter]
public class SiteController : Controller
{
    static ContentResult cr = null;

    [CacheFilter(Duration=9999999)]
    public ActionResult CSS(string fileName)
    {
        try
        {
            if (cr == null)
            {
                StringBuilder sb = new StringBuilder();
                foreach (string cssFile in Constants.cssFiles)
                {
                    string file = Request.PhysicalApplicationPath + cssFile;
                    sb.Append(System.IO.File.ReadAllText(file));
                    sb.Append("!!!!!");
                }

                sb.Replace("[IMAGE_URL]", StaticData.Instance.ImageUrl);

                cr = new ContentResult();
                cr.Content = sb.ToString();
                cr.ContentType = "text/css";
            }

            return cr;
        }
        catch (Exception ex)
        {
            System.Diagnostics.Trace.TraceError(ex.Message);
            return View();
        }
    }
}

Notice that we have a couple of filters on the class and method. The [CompressFilter] on the class will check the browser's capability for gzip or deflate and activate compression for any action/method in this class. The [CacheFilter] will add a 3 month "cache this file" direction to the HTTP header of the HTML that's returned.

The Constants.cssFiles returns a list of root-relative CSS files that need to be combined into the single response.

The Replace() function on the StringBuilder allows us to put a tag in the CSS files so that any references to images can be dynamically determined at runtime. This allows us to run the images off a url such as http://localhost:1234/images/ during development and http://images.mysite.com/ in production. Storing images in a subdomain instead of the main domain will improve performance because more images will be able to be downloaded in parallel by the browser.

The single fileName parameter is a dummy parameter that is needed to make the URL different when one of the underlying CSS files is modified but still allow optimum caching in the browsers. i.e. by changing this value in the HTML we return we force the browser to re-fetch the CSS but only when one of the underlying CSS files change.

The ContentResult is a static variable so that the building of this CSS file is a single hit after the App Pool has been recycled.

The following snippet of code is placed in the code behind page for the Site.Master. Normally you would not have a code behind page in the MVC model but I have not been able to work out how to dynamically inject the CSS file name into the master file any other way.

protected void Page_Load(object sender, EventArgs e)
{
    HtmlLink css = new HtmlLink();
    css.Href = String.Format("/Site/CSS/{0}", StaticData.Instance.CssFileName);
    css.Attributes["rel"] = "stylesheet";
    css.Attributes["type"] = "text/css";
    css.Attributes["media"] = "all";
    Page.Header.Controls.Add(css);
}

The CSS file name is generated with the following snippet of code:

string _CssFileName = null;public string CssFileName
{
    get
    {
        if (String.IsNullOrEmpty(_CssFileName))
        {
            DateTime dt = new DateTime(2000,1,1);
            foreach (string cssFile in Constants.cssFiles)
            {
                string file = System.Web.HttpContext.Current.Server.MapPath(cssFile);
                FileInfo fi = new FileInfo(file);
                DateTime lastWriteTime = fi.LastWriteTime;
                if (lastWriteTime > dt)
                {
                    dt = lastWriteTime;
                }
            }
            _CssFileName = dt.ToString("yyyyMMddHHmmss") + ".css";
        }
        return _CssFileName;
    }
}

We iterate through each of the CSS files and extract the most recent modified date. Using this date, we generate the the CSS file name. That way a different file name will be injected into the HTML whenever one of the CSS files is modified and this will force the browser to reload the new CSS file keeping it always up-to-date.

It may seem like a lot of work for a CSS file at first glance but the benefits are enormous and the added flexibility will allow you to change it on a whim.

I haven't shown you the Constants.cssFiles but that's just an array of file names. I was also thinking of implementing this as a loop that found all the *.css files in a folder. That way if you added a new CSS file it would automatically be included without a code change. However, the disadvantage of this is that you cannot predetermine the order in which the CSS files are combined into the single file and the order is usually important.

If, however, you did want to use that all-css-in-one-folder approach you could adopt a naming convention such as 01_myfile.css, 02_myfile.css and then sort the file names before combining them.

1 comment:

  1. Thanks for this cool trick. I've figured how to get the file name into the view without a code behind.
    I used a viewbag property called CssFile that i set in a custom filter, and then rendered with a call to<pre><link href="@Url.Action("CSS", "Resource", new { [email protected] })" rel="stylesheet" type="text/css" /></pre>

    ReplyDelete