Bundling and minification are two well-known techniques used to improve the load time of your website. These are especially important for sites that use extensively JavaScript to offer better user experience. There are plenty of tools to help you do bundling and minification of JavaScript and CSS files. If you are a .NET developer you are probably very used to live inside Visual Studio and expect it to offer you everything you might think of. In ASP.NET 4.5 you can use a bundling API to define how your files will be grouped and sent to the client. The following example demonstrates the usage of this API.
1 2 3 4 5 6 7 |
public static void Register(BundleCollection bundles) { bundles.Add(new ScriptBundle("~/bundles/jquery") .Include("~/Scripts/jquery-{version}.js")); bundles.Add(new StyleBundle("~/bundles/css") .Include("~/css/*.css")); } |
Then in your page (either an ASP.NET page or a MVC view) you would invoke the rendering of these bundles by calling Scripts.Render(“~/bundles/jquery”) and Styles.Render(“~/bundles/css”).
When using AngularJS for building a rich client-side applications you would typically end up with a lot of HTML pages too. These HTML pages are actually the views in the MV* pattern that AngularJS implements. If you use a routing mechanism to simulate multiple pages in AngularJS, then for every page a new request to the server will be issues to download the HTML page for the corresponding page. For your production environment, however, you may wish to predownload all HTML pages so that no further requests are made to the server. One options is to rely on tools like Grunt, which provides a rich set of options. However, in this post I want to show you a very easy way to achieve the same using the bundling API of ASP.NET.
First we have to understand how AngularJS processes the HTML pages. When AngularJS needs to render a view (e.g., when loading a route or when visualizing a directive), it checks a local cache, called template cache. This is an abstraction over a simple key-value store that runs locally. If the template is not in this template cache, then AngularJS issues a request to the server in order to download it and then it puts into the cache. Next time the same view is requested, it can be rendered immediately. So what we can do in this scenario is that we can prepopulate this template cache will all the HTML pages we have. Thus, every time AngularJS needs to render a view, it will have a cache hit.
The next question is how to implement this logic using the bundle API. With the help of a .NET reflector one can look into the code of the existing JavaScript and CSS minification mechanisms and see how they work. The interface IBundleTransform provides a method that can manipulate all the files in a bundle and decide how to render them. Thus, we can collect all HTML pages that are included in the bundle and render them as a JavaScript file. We can attach a method to the run block of our AngularJS application module and make use of the $templateCache service to manipulate the template cache. Here is an excerpt of our AngularJsHtmlCombine transform implementation.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
var sb = new StringBuilder(); sb.Append("(function(){"); sb.AppendFormat("angular.module('{0}'), appName); sb.Append("a.run(['$templateCache',function(t){"); foreach (BundleFile file in response.Files) { string fileId = VirtualPathUtility.ToAbsolute(file.IncludedVirtualPath); string filePath = HttpContext.Current.Server.MapPath(file.IncludedVirtualPath); string fileContent = File.ReadAllText(filePath); sb.AppendFormat("t.put({0},{1});", JsonConvert.SerializeObject(fileId), JsonConvert.SerializeObject(fileContent)); } sb.Append("}]);"); sb.Append("})();"); |
Note that I rely on JSON.NET to convert a .NET string to a JSON string. We can then create a special bundle to apply our transformation.
1 2 3 4 5 6 |
public class AngularJsHtmlBundle : Bundle { public AngularJsHtmlBundle(string virtualPath) : base(virtualPath, null, new[] { (IBundleTransform)new AngularJsHtmlCombine() }) {} } |
How do we use this bundle now? Well, the same way as we use the scripts and the styles bundle.
1 2 3 4 5 6 7 |
public static void Register(BundleCollection bundles) { bundles.Add(new ScriptBundle("~/bundles/js") .Include("~/Content/app.js")); bundles.Add(new AngularJsHtmlBundle("~/bundles/myapp/html") .Include("~/Content/*.html")); } |
When we request the bundle via Scripts.Render(“~/bundles/myapp/html”) a JavaScript file will be returned with all HTML pages rendered as strings. If we do not want to enable the bundling process, e.g., the debug attribute is present in the web.config, then nothing will be rendered and AngularJS will still make the usual requests to the server. Voila!
A have to mention a few notes in my implementation.
- We need a way to pass the name of the application module to our bundle. I have chosen to do this via a predefined URL template, i.e. ~/bundles/{appName}/html.
- Although I use JSON.NET, even the traditional DataContractJsonSerializer should be capable of the string serialization.
- Be careful with the HTML file IDs. This implementation will make the file IDs with a leading slash in the URL. However, AngularJS is sensitive about this slash. So if you write the template URL of your directive to be “Content/hello.html”, AngularJS will not find it in the template cache and hence a new request will be made.
You can find the source code along with a demo on GitHub.