Combine multiple stylesheets at runtime

Oct 3, 2007

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)

* $4.95/month ASP.NET Hosting with FREE SQL 2012 DB! – Click Here!

Comments (23) -

Josh Stodola
Josh Stodola United States
10/3/2007 6:02:33 PM #

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

G&#246;ran
Göran
10/3/2007 7:03:36 PM #

Terrific!!!

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

Mads Kristensen
Mads Kristensen Denmark
10/3/2007 7:49:57 PM #

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

Jonah
Jonah Sweden
10/4/2007 5:20:39 AM #

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!

Mads Kristensen
Mads Kristensen Denmark
10/4/2007 5:28:28 AM #

@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.

pepa
pepa Czech Republic
10/4/2007 8:01:27 AM #

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?

TweeZz
TweeZz
10/4/2007 8:25:36 AM #

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.

Hartvig
Hartvig Denmark
10/4/2007 9:38:21 AM #

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

oVan
oVan
10/4/2007 11:46:10 AM #

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/

TweeZz
TweeZz
10/4/2007 12:24:37 PM #

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

Gokhan Demir
Gokhan Demir Turkey
10/5/2007 7:23:26 AM #

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
Gokhan Demir Turkey
10/5/2007 7:26:13 AM #

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

TweeZz
TweeZz
10/5/2007 8:25:43 AM #

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

kevin
kevin United States
10/5/2007 12:30:41 PM #

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

Hristo Deshev
Hristo Deshev Bulgaria
10/8/2007 12:25:38 PM #

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

Frank Rau
Frank Rau United States
10/10/2007 11:15:12 AM #

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!

Dave
Dave United Kingdom
10/22/2007 5:36:53 PM #

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

Thor Larholm
Thor Larholm Denmark
10/23/2007 6:49:04 AM #

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.

Nebbercracker
Nebbercracker United States
1/24/2008 5:58:35 PM #

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
Nebbercracker United States
1/24/2008 6:09:45 PM #

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

sezer
sezer United States
5/2/2008 5:08:36 PM #

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

Betty
Betty
12/16/2008 7:25:35 PM #

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
Betty
12/16/2008 7:40:38 PM #

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

Pingbacks and trackbacks (4)+

Comments are closed

About the author

Mads Kristensen

Mads Kristensen
Program Manager at the Microsoft Web Platform team and founder of BlogEngine.NET.

More...

Month List

Disclaimer

The opinions expressed herein are my own personal opinions and do not represent my employer’s view in any way.