Wednesday, December 28, 2011

ASP.NET MVC 4 Bundling and Minification - Composing Child Views

Of the upcoming ASP.NET MVC 4 features, one that I am really excited to see is the Bundling feature contained within the Microsoft.Web.Optimization assembly in the current Developer Preview release version.  You can get a standalone version of this assembly direct from Nuget:
In this article I want to discuss some of the basics of Bundling and also to outline some thoughts that I have for organizing scripts and code when composing views in your application.

MVC4 Bundling Primer

The Bundling features allows you to easily combine and minify resources within your application.  Take for example an application which contains the following Javascript files:
image
In the ‘bad old days’ we may have emitted each of those script files separately, this means that the client would have to make lots of requests to pull down the content.
image
Emitting the files as above would result in the following network traffic on the client:
image
As you can see at the bottom of the image, 30 separate requests have been issued.

Default Bundle Behaviour

Using Bundling is as simple as registering the files that we want to optimize with the BundlingTables feature.  To achieve that, we can pretty much add the following line of code to Application_Start in Global.asax to register all of the files shown above:
BundleTable.Bundles.EnableDefaultBundles();
This default behaviour will register all of the .js and .css files located in their default locations and create routes so that they can be served up by the application.  Rendering them out in the page is then as simple as pointing a script tag as the route that has been registered to display them – in the default case, that would result in the following script tag being added to the bottom of your main layout file:

<script src="@Url.Content("~/Scripts/js")" type="text/javascript"></script>

Dynamic Bundles

For finer-grained control over the ordering of resources within a Bundle you can create a dynamic bundle and manually add files in the order that you want them to be rendered.
Bundle bundle = new Bundle("~/CoreBundle", typeof(JsMinify));
bundle.AddFile("~/Scripts/jquery-1.7.1.min.js");
bundle.AddFile("~/Scripts/jquery-ui-1.8.16.js");
bundle.AddFile("~/Scripts/jquery.validate-vsdoc.js");
...

BundleTable.Bundles.Add(bundle);
And then, in the page, emit the path that was just registered:
<script src="@Url.Content("~/CoreBundle")" type="text/javascript"></script>
Note: I needed to register the Twitter Bootstrap scripts dynamically like this because I was getting errors when using the Default registration.  The errors resulted because of the order that the default registration behaviour was adding the scripts to the bundle.  In the case of Twitter Bootstrap, I needed to order the scripts specifically because of dependencies beteen Twipsy.js and Popover.js
image

File Organization for child views

So far we have seen how to register all of the core scripts for our application but, as our application grows, we will typically introduce lots of individual scripts to manage the behaviour of specific pages.  In my case, I adopt a convention where I separate all of the Javascript and CSS code out of the View files and into a CSS/JS file with a name which mirrors the view that they represent.  The following Table illustrates how this mapping works:

Resource LocationJS Location
Home/Index.cshtmlScripts/Views/Home_Index.js
Shared/ChildView.cshtmlScripts/Views/ChildView.js

To give a concrete example of how this works, let’s consider that the shared partial view listed in the above table contained the following code which allows a user to click on a link:
<h2>Child Content</h2>
<a href="#" id="clickerLink">Click Me</a>
The corresponding JS file would have behavioural code to handle the click event of the anchor tag like so:
$(function () {
        $("#clickerLink").click(function (e) {
            e.preventDefault();
            alert($(this).text());
        });
}); 
When it comes to rendering the output to the browser it will be desirable to have the JS emitted as part of the Core Bundle thus allowing us to have all scripts combined, minified, and in one optimal location at the bottom of the page.

To achieve this desired outcome we simply register the child script from the partial view that depends on it like so:
@{
    var bundle = Microsoft.Web.Optimization.BundleTable.Bundles.GetRegisteredBundles()
        .Where(b => b.TransformType == typeof(Microsoft.Web.Optimization.JsMinify))
        .First();
    
    bundle.AddFile("~/Scripts/Views/ChildContent.js");
}
For the sake of completeness, you would probably abstract that messy BundleTable logic out into a helper which would reduce your child registration code down to the following line of code:
Html.AddJavaScriptBundleFile("~/Scripts/Views/ChildContent.js", typeof(Microsoft.Web.Optimization.JsMinify));
And here's some example code for what the extension class might look like:



public static class HtmlHelperExtensions
{
    public static void AddJavaScriptBundleFile(this HtmlHelper helper, string path, Type type)
    {
        AddBundleFile(helper, path, type, 100);
    }

    /// <param name="index"> 
    /// Added this overload to cater for switching between 
    /// different Script optimizers more easily
    /// e.g. Switching between Microsoft.Web.Optimization 
    /// bundles and ClientLibrary resources could
    /// be done seamlessly to the application
    /// </param>
    public static void AddBundleFile(this HtmlHelper helper, string path, Type type, int index)
    {
        var bundle = BundleTable.Bundles.GetRegisteredBundles()
            .Where(b => b.TransformType == type)
            .First();

        bundle.AddFile(path);
    }
}


6 comments:

  1. Nice!

    I would probably have the extension method be AddJavaScriptBundleFile(string path), which corresponds to using Microsoft.Web.Optimization.JsMinify under the covers, so one could do just:

    Html.AddJavaScriptBundleFile("~/Scripts/Views/ChildContent.js");

    Also, I think there is a typo.

    In HtmlHelperExtensions.AddBundleFile(), you have

    bundle.AddFile("~/Scripts/Views/ChildContent.js");

    which I think should be

    bundle.AddFile(path);

    ReplyDelete
  2. Cheers thanks for that Adam, I'll update the typo in the code. I like your naming convention for the extension method so I'll update the post to include that naming. Kudos!

    ReplyDelete
  3. You might also check out http://RequestReduce.com. RequestReduce will minify and bundle all css and javascript on your page without requiring you to go to the trouble of composing these bundles in code. It simply looks at your page and bundles just the scripts on the page in the exact same order in which they are written. This allows you to be as flexible as you want and to include only the scripts you need on each page. If the scripts are different per page, then RequestReduce generates a different bundled script on each page. Additionally, RequestReduce can auto generate and optimize sprites out of your CSS background imnages which can potentially dramatically improve your performance. And if you are using the twitter bootstrapper, there is a RequestReduce.SassLessCoffee package available which works nicely to compile .less. All of this is available on nuget.

    ReplyDelete
  4. Cheers, thanks Matt. I hadn't heard of ReduceRequest but it sounds interesting, I'll look into it.

    ReplyDelete
  5. I create a default bundle and call this on view1.cshtml. I see it rendered with a token ("A" for shorthand). Everytime I re-load that view, it has the same "A" token.

    In view2.chtml, I add a new JS file to this bundle. I again call view1.cshtml and I get the default bundle with token "A". I then call view2.cshtml and I get the bundle, but this time with a new token "B". Great.

    BUT, I then re-visit view1.cshtml and this time I find the token is no longer "A" but "B" and it contains the JS that is only relevant for view2.cshtml.

    Not sure this is quite working as expected (at least in my hands). Darren - can you confirm the behaviour in your project?

    Thanks

    ReplyDelete
  6. Please refer to the answer in the following post for further details:

    http://stackoverflow.com/questions/14444853/asp-net-mvc-3-bundling-and-minimization

    ReplyDelete