Thursday, September 12, 2013

Bundles of Joy

Bundling (as it pertains to the MVC project type in Visual Studio) is a feature that was introduced... I don't know, you're not here for a history lesson.  If you're here at all you're probably here because something hinky went wrong with your bundling and you're searching the Internet desperately for something resembling an answer.  That, or you're bored.  Hopefully the first thing, though.

We had quite the charlie foxtrot the other day when it came to bundling and the issue wasn't discovered until we went to Production.  Oh, the good times.  First things first, bundling is the process by which you can tell the runtime to combine multiple resource files (such as css or javascript) together to be requested as a single document from the server.  There's a pretty good explanation here and it even has pictures!  Essentially, if you use many css files across all pages of your site (or javascript files, or you just want to reference jquery and jquery ui together) bundling can help speed up the performance of your site by turning those multiple calls into fewer calls.  In addition to bundling them, .NET will automatically minify your files (I know for sure that it will minify your javascript files and I'm pretty sure it will do the same for your css files), which means it will shrink them down to remove pretty much all white space.  It makes them nearly impossible for you to debug so you shouldn't just minify everything all the time, but it can help performance so you should definitely minify before production.

Now on to the problem.  We have an MVC3 application, which didn't include bundling "out of the box".  Instead I had to go through a bit of a long process of Googling and slapping together some suggestions until I found something that worked, which was ultimately to add System.Web.Optimization to my project (which also includes references to WebGrease and Antlr3.Runtime in case you see those in there).  After the reference was added, I just had to implement the bundling and everything worked like a charm.  Easy, right?  What, you want pictures or something?

Fine.

Here are the references in my project before doing anything...





















This is the menu you need to use.  The nuget package manager won't actually work for this.
















Using the above menu will open the Package Manager Console, in which you should type this.









Once you do that, your references should look similar to this.





















That's the setup part.  Now to actually create and consume the bundles.  Here are the using statements from my global.asax.cs file before I changed anything.


using System;
using System.Collections.Generic;
using System.Data.Entity;
using System.Data.Entity.Infrastructure;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.Web.Routing;

I need to add a using statement for System.Web.Optimization so it looks like this.

using System;
using System.Collections.Generic;
using System.Data.Entity;
using System.Data.Entity.Infrastructure;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.Web.Optimization;
using System.Web.Routing;

Next, we add the code to build the bundles.

protected void RegisterBundles(BundleCollection bundles)
{
    bundles.Add(new ScriptBundle("~/bundles/jquery").Include(
        "~/Scripts/jquery-1.7.1.js",
        "~/Scripts/jquery-ui-1.8.20.js",
        "~/Scripts/jquery.unobtrusive-ajax.js",
        "~/Scripts/jquery.validate-vsdoc.js",
        "~/Scripts/jquery.validate.js",
        "~/Scripts/jquery.validate.unobtrusive.js"
        ));
 
    bundles.Add(new StyleBundle("~/bundles/css").Include(
        "~/Content/Site.css"
        ));
}

That's great, but we need to actually call that method we just created.

protected void Application_Start()
{
    AreaRegistration.RegisterAllAreas();
 
    // Use LocalDB for Entity Framework by default
    Database.DefaultConnectionFactory = new SqlConnectionFactory("@Data Source=(local)");
 
    RegisterGlobalFilters(GlobalFilters.Filters);
    RegisterRoutes(RouteTable.Routes);
    RegisterBundles(BundleTable.Bundles);
}

Now that we have the bundles created we have to actually consume them.  This is in the head section of my _layout.cshtml file (note that you'll need to follow these same steps for all layouts used in your project).

<title>@ViewBag.Title</title>
<link href="@Url.Content("~/Content/Site.css")" rel="stylesheet" type="text/css" />
<script src="@Url.Content("~/Scripts/jquery-1.7.1.min.js")" type="text/javascript"></script>
<script src="@Url.Content("~/Scripts/jquery.validate.js")" type="text/javascript"></script>
<script src="@Url.Content("~/Scripts/jquery.validate.unobtrusive.js")" type="text/javascript"></script>

We make a couple of minor changes and we should be all set.

<title>@ViewBag.Title</title>
@Scripts.Render("~/bundles/jquery")
@Styles.Render("~/bundles/css")

Now, I may have told a very little lie earlier when I said "everything worked like a charm".  See, the reality is that everything appeared to have worked like a charm, but in reality was waiting like a lion at the watering hole, just waiting for me to get thirsty... or push to Production.  Apparently, Visual Studio disables the optimizations portion of bundling when the program is in debug mode.  This kinda makes sense, but it can really bite you if you don't know about it.  That's what happened to me.  I guess we deployed debug mode to our Dev, QA, and Stage environments (we used TFS build configurations to do it and I didn't have a hand in that).  When we went to Production for some reason (there's actually a long story behind "some reason", but I'm going to skip it for now) we deployed the release version of the code and we didn't use a TFS build configuration.  So when it hit Production, the optimizations kicked in and everything went to Hell in a handbasket.  Here's why:

.awesomeBackground {
    background: url(images/awesome.jpg)
}

Yup, the good ol' background image in the CSS file.  In my project I have the css files stored in /Content, which means that reference you see up there actually points to /Content/images/awesome.jpg.  But scroll back up and take a look at the code in my global.asax.cs file and you'll notice that the bundle I created is "~/bundles/css", which means when everything is minified and combined into a single file, the reference to the image will be /bundles/css/images/awesome.jpg.  You can probably guess how well that works when you don't actually have an image at /bundles/css/images/awesome.jpg; it looks awful.  But fear not!  There is a solution, and I'll even throw in a way for you to verify it's all working as expected in your Dev environment so you don't have to wait until Production to find out whether everything worked.

In order to get your image url references in your CSS files to keep their maps, you just need to name your bundle the same name as the original path.  So the updated RegisterBundles method looks like this.


protected void RegisterBundles(BundleCollection bundles)
{
    bundles.Add(new ScriptBundle("~/bundles/jquery").Include(
        "~/Scripts/jquery-1.7.1.js",
        "~/Scripts/jquery-ui-1.8.20.js",
        "~/Scripts/jquery.unobtrusive-ajax.js",
        "~/Scripts/jquery.validate-vsdoc.js",
        "~/Scripts/jquery.validate.js",
        "~/Scripts/jquery.validate.unobtrusive.js"
        ));
 
    bundles.Add(new StyleBundle("~/Content/css").Include(
        "~/Content/Site.css"
        ));
}

The difference is subtle, but really important.  I changed the name of the StyleBundle.  That allows the references to work as I expected them to.  As for how you can test this in your Dev environment (while still deploying the debug version of your code), do this.

protected void Application_Start()
{
    AreaRegistration.RegisterAllAreas();
 
    // Use LocalDB for Entity Framework by default
    Database.DefaultConnectionFactory = new SqlConnection(@"Data Source=(local)");
 
    RegisterGlobalFilters(GlobalFilters.Filters);
    RegisterRoutes(RouteTable.Routes);
    RegisterBundles(BundleTable.Bundles);
 
    BundleTable.EnableOptimzations = true;
}

Yup, that one line tells .NET to go ahead and use the optimizations.  That should be all you need to do bundling in your MVC 3 application.  Fortunately, Microsoft decided to include System.Web.Optimization in a MVC 4 project type.  They also went ahead and bundled up all the jquery stuff for us so this is all a bit moot.  Hopefully it wasn't moot for you, though.

No comments:

Post a Comment