Combine multiple stylesheets at runtime

by Mads Kristensen 4. October 2007 02:10

I often use multiple stylesheets in a single website to keep things nicely separated. The only problem is that the client has to make multiple HTTP requests to get them all. Today, I thought it was about time I did something about it. The idea is to take all the referenced stylesheet in the <head> tag and combine them into a single reference at runtime. The only rule I had was that I couldn’t touch the way stylesheets are referenced. In other words, it had to be done only by writing C# and leave the HTML alone.

That means I should be able to import multiple stylesheets the way I normally would like so:

<head runat="server">
   <link rel="stylesheet" type="text/css" href="~/css/master.css" />
   <link rel="stylesheet" type="text/css" href="~/css/menu.css" />
</head>

Luckily, if the head tag has a runat=”server” attribute, all stylesheets are treated as HtmlControls we can reference in the code-behind. That's a prerequisite for this to work. What I needed was to things:

  1. Code to remove the stylesheets from the head tag
  2. An HttpHandler to combine all the reference stylesheets

The code

The following code removes all stylesheets from the head tag and adds a new one that points to the HttpHandler and passes the original stylesheet file names as URL parameters. It looks like this:

<link rel="stylesheet" type="text/css" href="~/stylesheet.ashx?stylesheets=~css/master.css,~/css/menu.css" />

Note that the file names are being URL encoded but that looked too messy for this example, so I just leaved them in clear text so it's easier to see what's going on. I use a custom base page so I put the following code in there. You could also place it in your master page or in every .aspx page you want this feature.

protected void Page_PreRender(object sender, EventArgs e)
{
 CombineCss();
}

protected virtual void CombineCss()
{
 Collection<HtmlControl> stylesheets = new Collection<HtmlControl>();
 foreach (Control control in Page.Header.Controls)
 {
  HtmlControl c = control as HtmlControl;

  if (c != null && c.Attributes["rel"] != null && c.Attributes["rel"].Equals("stylesheet", StringComparison.OrdinalIgnoreCase))
  {
   if (!c.Attributes["href"].StartsWith("http://"))
    stylesheets.Add(c);
  }
 }

 string[] paths = new string[stylesheets.Count];
 for (int i = 0; i < stylesheets.Count; i++)
 {
  Page.Header.Controls.Remove(stylesheets[i]);
  paths[i] = stylesheets[i].Attributes["href"];
 }

 AddStylesheetsToHeader(paths);
}

private void AddStylesheetsToHeader(string[] paths)
{
 HtmlLink link = new HtmlLink();
 link.Attributes["rel"] = "stylesheet";
 link.Attributes["type"] = "text/css";
 link.Href = "~/stylesheet.ashx?stylesheets=" + Server.UrlEncode(string.Join(",", paths));
 Page.Header.Controls.Add(link);
}

The HttpHandler

I’ve created an .ashx file that takes all the stylesheet references as a URL parameter separated by commas. It then iterates through them all, reads the .css file from disk, removes all whitespace and writes it to the response stream.

There is a serious IO overhead in this, so the HttpHandler caches the final response server-side so IO operations only take place the first time it is requested. It also adds a cache file dependency which means that whenever you change one of the stylesheet files, it reloads the cache. It also makes sure that the browsers will correctly cache the combined stylesheet by sending the correct cache headers.

Performance gains

I’ve done a test on two of my stylesheets I used for an old project. One is 7.11kb and the other is 20.2kb. That totals to 27.31kb and two HTTP requests. After I’ve implemented this feature I only have a single HTTP request and the total file size is only 13.08 because the whitespace is stripped. That’s more than half the size in kilobytes and just a single HTTP request was needed.

Implementation

Download the stylesheet.ashx file below and place it at the root of your application. If your website isn't located at the root then remember to update the references in the code above. You might also need to use the "~" when you reference the stylesheets in the head tag like you can see I did.

stylesheet.ashx (1.14 kb)

* Only $4.95/month ASP.NET & Windows 2008 + IIS 7 Hosting! FREE SQL Included

Tags: , ,

ASP.NET

Comments

10/4/2007 3:02:33 AM #

Josh Stodola

Hey man, this was a great idea!  Nicely done!

Josh Stodola United States |

10/4/2007 4:03:36 AM #

Göran

Terrific!!!

Mads, have you seen this: http://ajaxian.com/archives/sass-y-dynamic-css. Wouldn't that be something?

Göran |

10/4/2007 4:49:57 AM #

Mads Kristensen

@Göran. That looks really cool. Maybe I should try doing an ASP.NET implementation of that some day.

Mads Kristensen Denmark |

10/4/2007 2:20:39 PM #

Jonah

It would be even sweeter if it automatically ran through the css files in App_Themes and made them into a single request, or had the option to do that. Nevertheless, supersweet idea. Keep up the good work!

Jonah Sweden |

10/4/2007 2:28:28 PM #

Mads Kristensen

@Johan, but id does work with App_Themes stylesheets. All stylesheets located in the <head> will be combined. It doesn't matter if the stylesheets are added dynamically, like the App_Themes stylesheets, or manually as I have shown.

Mads Kristensen Denmark |

10/4/2007 5:01:27 PM #

pepa

If you create a handler for *.css files that sets correctly caching information ("dear browser, cache this css file") then browsers would access the css files only once. Or am I wrong?

pepa Czech Republic |

10/4/2007 5:25:36 PM #

TweeZz

Great post! I (or better Mads) reduced my css request from 12 to 1 and reduced the size with 34%!

I wonder if something similar could be done for javascript files.

TweeZz |

10/4/2007 6:38:21 PM #

Hartvig

There's also some good pointers on compressing css and js in this discussion (not blogengine.NET related, but more general .NET even though it's on the umbraco forum):
http://forum.umbraco.org/15726

/n

Hartvig Denmark |

10/4/2007 8:46:10 PM #

oVan

Mads, I think you should look here... this is what we could use in asp.net/blogengine: verens.com/archives/2007/10/01/variables-in-css/

oVan |

10/4/2007 9:24:37 PM #

TweeZz

Based on Mads stylesheet.ashx I was able to make a similar handle for javascript files.
The hardest part was to filter out the script tags in the head.
If someone is interested in it, please send me an email. manu.temmermanuyttenbroeck@gmail.com

TweeZz |

10/5/2007 4:23:26 PM #

Gokhan Demir

Very clever and clean solution and it works like a charm Smile
thanks mad..

ps : As you mention I  put the tilda character('~') when I referenced the stylesheets , handler gave me a directory not found exception.So i remove the tilda character, and it worked properly..

Gokhan Demir Turkey |

10/5/2007 4:26:13 PM #

Gokhan Demir

Plus  : i think javascript version of this handler which combine multiple javascript files will be very nice..i will work on it..

Gokhan Demir Turkey |

10/5/2007 5:25:43 PM #

TweeZz

@Gokhan: You don't need to work on it. I made it already. See my previous comment.

TweeZz |

10/5/2007 9:30:41 PM #

kevin

I just love the stuff you cook up mads. great stuff.

kevin United States |

10/8/2007 9:25:38 PM #

Hristo Deshev

Nice post! You can move the code that intercepts the header stylesheet controls a bit later in the control lifecycle. Some controls, Telerik's included, register stylesheets at the PreRenderComplete stage. Here is a Page override that works in that case:

protected override void OnPreRenderComplete(EventArgs e)
{
    base.OnPreRenderComplete(e);
    CombineCss();
}

Use it instead of the Page_PreRender event handler.

Cheers,
Hristo Deshev

Hristo Deshev Bulgaria |

10/10/2007 8:15:12 PM #

Frank Rau

I recently incorporated the code for optimising the css into my own combination, compression and caching solution (developer.franklinrau.com/post.aspx  It worked well for the css file.  Note that the regular expression for the removal of comments breaks when there is an * in the middle of a comment and if there is more than one space in between selectors.
Nice Job on BlogEngine 1.2!

Frank Rau United States |

10/23/2007 2:36:53 AM #

Dave

Excellent post, however some improvement is needed as image paths are not re-mapped to the App_Themes directory.
Dave.

Dave United Kingdom |

10/23/2007 3:49:04 PM #

Thor Larholm

Nice post, I'm a bit annoyed with myself that I can't seem to remember seeing SetLastModifiedFromFileDependencies before.

A nice improvement would be to add some file path restrictions, such as limiting the file includes to a specific directory. As it stands now you can use directory traversal to read any file on the server that IIS has access to and which has a file ending of .css, or simply any file that IIS has access to provided that you can get past URL Scan or ISA Server and hand off null byte characters.

Thor Larholm Denmark |

1/25/2008 2:58:35 AM #

Nebbercracker

Mads,

I did not get around to trying this until now.  Brilliant!  Really brilliant.  I used firebug to see what was taking place; this speeds things up a lot!

Thanks for sharing this code.

Mr. Nebbercracker

Nebbercracker United States |

1/25/2008 3:09:45 AM #

Nebbercracker

Following up on my previous post.

For my projects, I updated the reference to the following:

<style type="text/css" media="screen">
    @import '<%=ResolveUrl("~/stylesheet.ashx?stylesheets=~/_css/screen.css, ~/_css/print.css")%>';
</style>

This allows the .ashx to be found regardless of whether the requesting page is in the root directory or in a sub directory.

Again, thanks!

Mr. Nebbercracker

Nebbercracker United States |

1/25/2008 6:07:37 AM #

pingback

Pingback from domainforum.co.cc

Domain Forum  » Blog Archive   » Combine multiple stylesheets at runtime

domainforum.co.cc |

4/26/2008 12:51:34 AM #

trackback

Trackback from Michael Baird

test

Michael Baird |

5/3/2008 2:08:36 AM #

sezer

CSS "Cascading Style Sheets" LessoNs - WeB DesigN LessoN - - Web site : http://WWW.css-lessons.ucoz.com/index.html

sezer United States |

9/25/2008 5:12:17 AM #

trackback

Using Cache And Compression For Performance

Using Cache And Compression For Performance

Tim-Stanley.com |

12/17/2008 4:25:35 AM #

Betty

Possible exploit, although I haven't figured out a use for it yet. Your security check simply checks if resource.axd is in the url not the path, meaning it can be in the query string. Meaning the following can be used to get past the check.

http://website/js.axd?path=/robots.txt%3FRESOURCE.AXD

That said you can only get files that the webserver is willing to serve up, so it can't be used to get the web.config or anything. However the request does count as the webserver itself rather then the user doing the request meaning you may be able to get items you can't normally.


I'm also having another problem with this seeing as my web host seems to limit the length of my query strings. I tried compressing the querystring with no real results, so I was considering caching the current querystring and using a Guid instead but that wouldn't exactly be ideal (or reliable if the cache dies). Anyone have some brilliant idea around this?

Betty |

12/17/2008 4:40:38 AM #

Betty

=( the above comment was meant to go on blog.madskristensen.dk/.../...riptResourceaxd.aspx not this page

Betty |

1/15/2010 10:51:32 AM #

pingback

Pingback from adylevy.com

flash of unstyled content | Ady Levy

adylevy.com |

Comments are closed

About the slave

Mads Kristensen Mads Kristensen
Web developer at ZYB and founder of BlogEngine.NET. More...

LinkedIn ZYB Facebook Last.fm Twitter View Mads Kristensen's profile on Technorati

The Lounge

Disclaimer

The opinions expressed herein are my own personal opinions and do not represent my employer's view in anyway.

© Copyright 2008