Using Grunt/Gulp for Bundling and Minification in ASP.NET
Monday, November 23, 2015
ASP.NET has the Microsoft.AspNet.Web.Optimization NuGet package to bundle and minify our JavaScript and CSS at runtime. It works great and offers an excellent developer experience.
Today we'll look at how to use Grunt (or Gulp) to replace the ASP.NET Web Optimization framework for handling bundling and minification of an ASP.NET MVC 5 app. Why would we want to do this? There are a few advantage to using Grunt/Gulp for bundling:
- Static bundles are created once at build-time rather than during runtime
- Bundles can be uploaded to Amazon S3 or Azure Blob Storage for serving off a CDN
- Different versions of the bundles can be served simultaneously from a CDN, supporting rollback and canary release scenarios
- ASP.NET 5 will be moving to using Grunt/Gulp for bundling
- Grunt and Gulp are well-known to frontend devs who'll be interacting the most with JavaScript and CSS
With first-class Grunt and Gulp support in Visual Studio 2015 via the Task Runner Explorer, using Grunt with ASP.NET is easier than ever.
Review of ASP.NET Bundling
Before we get to bundling with Grunt, let's review how ASP.NET bundling works. We begin by specifying our bundles in a bundle repository. Here's an example from the ASP.NET MVC project template (in BundleConfig.cs
):
public static void RegisterBundles(BundleCollection bundles)
{
bundles.Add(new ScriptBundle("~/bundles/jquery").Include(
"~/Scripts/jquery-{version}.js"));
bundles.Add(new ScriptBundle("~/bundles/jqueryval").Include(
"~/Scripts/jquery.validate*"));
bundles.Add(new ScriptBundle("~/bundles/modernizr").Include(
"~/Scripts/modernizr-*"));
bundles.Add(new ScriptBundle("~/bundles/bootstrap").Include(
"~/Scripts/bootstrap.js",
"~/Scripts/respond.js"));
bundles.Add(new StyleBundle("~/Content/css").Include(
"~/Content/bootstrap.css",
"~/Content/site.css"));
BundleTable.EnableOptimizations = true;
}
RegisterBundles()
is called at application startup to create and cache the bundles in memory. Internally, watches are set up so that the cache is invalidated if any of the files are changed on disk.
And in our Razor views, we simply refer to those bundles whenever we need to render a <script>
or <link>
tag. Here's an example from _layout.cshtml
:
@Styles.Render("~/Content/css")
@Scripts.Render("~/bundles/modernizr")
At runtime, this renders the following HTML. Notice a hash of all the contents of the bundle is appended to each URL for cache-busting:
<link href="/Content/css?v=MDbdFKJHBa_ctS5x4He1bMV0_RjRq8jpcIAvPpKiN6U1" rel="stylesheet"/>
<script src="/bundles/modernizr?v=wBEWDufH_8Md-Pbioxomt90vm6tJN2Pyy9u9zHtWsPo1"></script>
Our goal is to use Grunt for bundling and minification, and create Razor helpers with similar semantics to @Scripts.Render()
and @Styles.Render()
to render <script>
and <link>
the tags.
Grunt and Visual Studio 2015
Visual Studio 2015 has great support for managing packages via NPM and for running Grunt and Gulp tasks.
While we can install Node packages via command line using NPM, we can also directly edit our package.json
file in our projects. Visual Studio helps us along with Intellisense for package names and versions; and it automatically watches for changes to the file and runs npm install
to update our packages!
And if we have a gruntfile.js
or gulpfile.js
in our project, Visual Studio will automatically find it and display its tasks in the new Task Runner Explorer. The Task Runner Explorer is built into Visual Studio 2015, but can be added to VS 2013 as an extension (it doesn't work as well though).
Here's the Task Runner Explorer loading the gruntfile we'll be building in the next section:
Grunt Bundling and Minification
So let's get started on getting Grunt to bundle and minify our assets. There are lots of resources on the web about how Grunt works, so I'll dive right into the configuration.
We'll be using a new ASP.NET MVC project as an example and replacing its dynamic bundles with Grunt generated static files.
Clean
The first thing we'll need to do is create a task to remove all previously generated files. Here we're using grunt-contrib-clean to remove everything in the bundles
folder and a file called assets.json
that we'll be discussing later:
clean: {
build: ['bundles', 'assets.json']
}
JavaScript Bundles
We'll be using grunt-contrib-uglify to bundle and minify our JavaScript files. It's pretty straight forward. We're also generating source maps to help with debugging in the browser.
uglify: {
options: {
sourceMap: true
},
build: {
files: {
'bundles/jquery.js': ['Scripts/jquery-*.min.js'],
'bundles/jqueryval.js': ['Scripts/jquery.validate*.min.js'],
'bundles/modernizr.js': ['Scripts/modernizr-*.js'],
'bundles/bootstrap.js': ['Scripts/bootstrap.js', 'Scripts/respond.js'],
'bundles/site.js': ['Scripts/site.js']
}
}
}
CSS Bundles
We'll do the same thing for CSS using grunt-contrib-cssmin:
cssmin: {
options: {
sourceMap: true
},
build: {
files: {
'bundles/main.css': ['Content/bootstrap.css', 'Content/site.css']
}
}
}
Cache-Busting
One behavior from ASP.NET bundling that we want to reproduce is how it uses a content hash to perform cache-busting. In Grunt, we'll use grunt-filerev to append a hash to a file's URL. It's aware of source maps and will revision them as well.
filerev: {
js: {
src: [
'bundles/jquery.js',
'bundles/jqueryval.js',
'bundles/modernizr.js',
'bundles/bootstrap.js',
'bundles/site.js'
]
},
css: {
src: [
'bundles/main.css'
]
}
}
This task will change the file names to things like jquery.58b06350.js
and jquery.58b06350.js.map
. But how will we know what they were renamed to? This is where grunt-filerev-assets comes in. It will output a file called assets.json
that maps the original filenames to the hashed filenames.
filerev_assets: {
build: {
options: {
prefix: "/",
prettyPrint: true
}
}
}
Build
Now that we have all the tasks needed to create our bundles, let's create a "Build" task that brings it all together:
grunt.registerTask('build', ['clean', 'uglify', 'cssmin', 'filerev', 'filerev_assets']);
To run the task, simply double-click it in the Task Runner Explorer.
Using Generated Bundles in ASP.NET
To integrate what we've done with ASP.NET, we'll need to create replacements for ASP.NET Web Optimization's @Scripts.Render()
and Styles.Render()
methods. To do this, we'll create a static class called StaticAssetsResolver that will map a bundle's original path to the path with the hash appended. This will simply use JSON.NET to read the values from assets.json
into a dictionary. It also caches assets.json
and will invalidate the cache if the file changes.
public class StaticAssetResolver
{
private string assetsJsonPath;
private Cache cache;
private const string CACHE_KEY = "assetsJsonDictionary";
public StaticAssetResolver(string assetsJsonPath, Cache cache)
{
this.assetsJsonPath = assetsJsonPath;
this.cache = cache;
}
public string GetActualPath(string assetPath)
{
var assets = cache.Get(CACHE_KEY) as AssetCollection;
if (assets == null)
{
assets = GetAssetsFromFile();
cache.Insert(CACHE_KEY, assets, new CacheDependency(assetsJsonPath));
Trace.TraceInformation("Assets cache miss");
}
else
{
Trace.TraceInformation("Assets cache hit");
}
return assets[assetPath];
}
private AssetCollection GetAssetsFromFile()
{
return JsonConvert.DeserializeObject<AssetCollection>(File.ReadAllText(assetsJsonPath));
}
}
And then we'll create a class called StaticAssets
that has static methods that will render the <script>
and <link>
tags using the StaticAssetsResolver
.
public static class StaticAssets
{
private static StaticAssetResolver assetResolver;
public static void Initialize (StaticAssetResolver staticAssetResolver)
{
if (assetResolver == null)
{
assetResolver = staticAssetResolver;
}
}
public static HtmlString RenderScript(string path)
{
var actualPath = assetResolver.GetActualPath(path);
return new HtmlString($"<script src=\"{ actualPath }\"></script>");
}
public static HtmlString RenderStyle(string path)
{
var actualPath = assetResolver.GetActualPath(path);
return new HtmlString($"<link href=\"{ actualPath }\" rel=\"stylesheet\" />");
}
}
In our Global.asax
, we'll need to initialize it with the path to assets.json
and an instance of System.Web.Caching.Cache
:
protected void Application_Start()
{
// ...
StaticAssets.Initialize(new StaticAssetResolver(Server.MapPath("~/assets.json"), HttpContext.Current.Cache));
}
We can now change _layout.cshtml
to use StaticAssets
:
@StaticAssets.RenderStyle("bundles/main.css")
@StaticAssets.RenderScript("bundles/modernizr.js")
<!-- ... -->
@StaticAssets.RenderScript("bundles/jquery.js")
@StaticAssets.RenderScript("bundles/bootstrap.js")
And they will generate these tags when run:
<link href="/bundles/main.cf404633.css" rel="stylesheet" />
<script src="/bundles/modernizr.287fd894.js"></script>
<!-- ... -->
<script src="/bundles/jquery.58b06350.js"></script>
<script src="/bundles/bootstrap.9aa9dc49.js"></script>
More Visual Studio Integration
While we can run the build task whenever we need to, we can make things simpler for ourselves by triggering the Grunt build whenever a JavaScript or CSS file changes or when Visual Studio builds the project.
Grunt Watch
We first want to create a watch task using grunt-watch:
watch: {
bundles: {
files: ['Scripts/**/*.js', 'Content/**/*.css'],
tasks: ['build']
}
}
When the watch task is running, the build task will automatically kick off whenever a JavaScript or CSS file is updated.
Visual Studio Event Bindings
We can further make things easier by using event bindings in Task Runner Explorer. For instance, we can get our watch task to start when we open the project by right-clicking it and selecting the Project Open binding:
We can also do the same thing to bind the build task to Before Build and the clean task to Clean:
The build task will now run whenever the project is built in Visual Studio.
Source Code
The complete source code to this project can be found here:
https://github.com/anthonychu/GruntBundling/
ASP.NET has the Microsoft.AspNet.Web.Optimization NuGet package to bundle and minify our JavaScript and CSS at runtime. It works great and offers an excellent developer experience.
Today we'll look at how to use Grunt (or Gulp) to replace the ASP.NET Web Optimization framework for handling bundling and minification of an ASP.NET MVC 5 app. Why would we want to do this? There are a few advantage to using Grunt/Gulp for bundling:
- Static bundles are created once at build-time rather than during runtime
- Bundles can be uploaded to Amazon S3 or Azure Blob Storage for serving off a CDN
- Different versions of the bundles can be served simultaneously from a CDN, supporting rollback and canary release scenarios
- ASP.NET 5 will be moving to using Grunt/Gulp for bundling
- Grunt and Gulp are well-known to frontend devs who'll be interacting the most with JavaScript and CSS
With first-class Grunt and Gulp support in Visual Studio 2015 via the Task Runner Explorer, using Grunt with ASP.NET is easier than ever.
Review of ASP.NET Bundling
Before we get to bundling with Grunt, let's review how ASP.NET bundling works. We begin by specifying our bundles in a bundle repository. Here's an example from the ASP.NET MVC project template (in BundleConfig.cs
):
public static void RegisterBundles(BundleCollection bundles)
{
bundles.Add(new ScriptBundle("~/bundles/jquery").Include(
"~/Scripts/jquery-{version}.js"));
bundles.Add(new ScriptBundle("~/bundles/jqueryval").Include(
"~/Scripts/jquery.validate*"));
bundles.Add(new ScriptBundle("~/bundles/modernizr").Include(
"~/Scripts/modernizr-*"));
bundles.Add(new ScriptBundle("~/bundles/bootstrap").Include(
"~/Scripts/bootstrap.js",
"~/Scripts/respond.js"));
bundles.Add(new StyleBundle("~/Content/css").Include(
"~/Content/bootstrap.css",
"~/Content/site.css"));
BundleTable.EnableOptimizations = true;
}
RegisterBundles()
is called at application startup to create and cache the bundles in memory. Internally, watches are set up so that the cache is invalidated if any of the files are changed on disk.
And in our Razor views, we simply refer to those bundles whenever we need to render a <script>
or <link>
tag. Here's an example from _layout.cshtml
:
@Styles.Render("~/Content/css")
@Scripts.Render("~/bundles/modernizr")
At runtime, this renders the following HTML. Notice a hash of all the contents of the bundle is appended to each URL for cache-busting:
<link href="/Content/css?v=MDbdFKJHBa_ctS5x4He1bMV0_RjRq8jpcIAvPpKiN6U1" rel="stylesheet"/>
<script src="/bundles/modernizr?v=wBEWDufH_8Md-Pbioxomt90vm6tJN2Pyy9u9zHtWsPo1"></script>
Our goal is to use Grunt for bundling and minification, and create Razor helpers with similar semantics to @Scripts.Render()
and @Styles.Render()
to render <script>
and <link>
the tags.
Grunt and Visual Studio 2015
Visual Studio 2015 has great support for managing packages via NPM and for running Grunt and Gulp tasks.
While we can install Node packages via command line using NPM, we can also directly edit our package.json
file in our projects. Visual Studio helps us along with Intellisense for package names and versions; and it automatically watches for changes to the file and runs npm install
to update our packages!
And if we have a gruntfile.js
or gulpfile.js
in our project, Visual Studio will automatically find it and display its tasks in the new Task Runner Explorer. The Task Runner Explorer is built into Visual Studio 2015, but can be added to VS 2013 as an extension (it doesn't work as well though).
Here's the Task Runner Explorer loading the gruntfile we'll be building in the next section:
Grunt Bundling and Minification
So let's get started on getting Grunt to bundle and minify our assets. There are lots of resources on the web about how Grunt works, so I'll dive right into the configuration.
We'll be using a new ASP.NET MVC project as an example and replacing its dynamic bundles with Grunt generated static files.
Clean
The first thing we'll need to do is create a task to remove all previously generated files. Here we're using grunt-contrib-clean to remove everything in the bundles
folder and a file called assets.json
that we'll be discussing later:
clean: {
build: ['bundles', 'assets.json']
}
JavaScript Bundles
We'll be using grunt-contrib-uglify to bundle and minify our JavaScript files. It's pretty straight forward. We're also generating source maps to help with debugging in the browser.
uglify: {
options: {
sourceMap: true
},
build: {
files: {
'bundles/jquery.js': ['Scripts/jquery-*.min.js'],
'bundles/jqueryval.js': ['Scripts/jquery.validate*.min.js'],
'bundles/modernizr.js': ['Scripts/modernizr-*.js'],
'bundles/bootstrap.js': ['Scripts/bootstrap.js', 'Scripts/respond.js'],
'bundles/site.js': ['Scripts/site.js']
}
}
}
CSS Bundles
We'll do the same thing for CSS using grunt-contrib-cssmin:
cssmin: {
options: {
sourceMap: true
},
build: {
files: {
'bundles/main.css': ['Content/bootstrap.css', 'Content/site.css']
}
}
}
Cache-Busting
One behavior from ASP.NET bundling that we want to reproduce is how it uses a content hash to perform cache-busting. In Grunt, we'll use grunt-filerev to append a hash to a file's URL. It's aware of source maps and will revision them as well.
filerev: {
js: {
src: [
'bundles/jquery.js',
'bundles/jqueryval.js',
'bundles/modernizr.js',
'bundles/bootstrap.js',
'bundles/site.js'
]
},
css: {
src: [
'bundles/main.css'
]
}
}
This task will change the file names to things like jquery.58b06350.js
and jquery.58b06350.js.map
. But how will we know what they were renamed to? This is where grunt-filerev-assets comes in. It will output a file called assets.json
that maps the original filenames to the hashed filenames.
filerev_assets: {
build: {
options: {
prefix: "/",
prettyPrint: true
}
}
}
Build
Now that we have all the tasks needed to create our bundles, let's create a "Build" task that brings it all together:
grunt.registerTask('build', ['clean', 'uglify', 'cssmin', 'filerev', 'filerev_assets']);
To run the task, simply double-click it in the Task Runner Explorer.
Using Generated Bundles in ASP.NET
To integrate what we've done with ASP.NET, we'll need to create replacements for ASP.NET Web Optimization's @Scripts.Render()
and Styles.Render()
methods. To do this, we'll create a static class called StaticAssetsResolver that will map a bundle's original path to the path with the hash appended. This will simply use JSON.NET to read the values from assets.json
into a dictionary. It also caches assets.json
and will invalidate the cache if the file changes.
public class StaticAssetResolver
{
private string assetsJsonPath;
private Cache cache;
private const string CACHE_KEY = "assetsJsonDictionary";
public StaticAssetResolver(string assetsJsonPath, Cache cache)
{
this.assetsJsonPath = assetsJsonPath;
this.cache = cache;
}
public string GetActualPath(string assetPath)
{
var assets = cache.Get(CACHE_KEY) as AssetCollection;
if (assets == null)
{
assets = GetAssetsFromFile();
cache.Insert(CACHE_KEY, assets, new CacheDependency(assetsJsonPath));
Trace.TraceInformation("Assets cache miss");
}
else
{
Trace.TraceInformation("Assets cache hit");
}
return assets[assetPath];
}
private AssetCollection GetAssetsFromFile()
{
return JsonConvert.DeserializeObject<AssetCollection>(File.ReadAllText(assetsJsonPath));
}
}
And then we'll create a class called StaticAssets
that has static methods that will render the <script>
and <link>
tags using the StaticAssetsResolver
.
public static class StaticAssets
{
private static StaticAssetResolver assetResolver;
public static void Initialize (StaticAssetResolver staticAssetResolver)
{
if (assetResolver == null)
{
assetResolver = staticAssetResolver;
}
}
public static HtmlString RenderScript(string path)
{
var actualPath = assetResolver.GetActualPath(path);
return new HtmlString($"<script src=\"{ actualPath }\"></script>");
}
public static HtmlString RenderStyle(string path)
{
var actualPath = assetResolver.GetActualPath(path);
return new HtmlString($"<link href=\"{ actualPath }\" rel=\"stylesheet\" />");
}
}
In our Global.asax
, we'll need to initialize it with the path to assets.json
and an instance of System.Web.Caching.Cache
:
protected void Application_Start()
{
// ...
StaticAssets.Initialize(new StaticAssetResolver(Server.MapPath("~/assets.json"), HttpContext.Current.Cache));
}
We can now change _layout.cshtml
to use StaticAssets
:
@StaticAssets.RenderStyle("bundles/main.css")
@StaticAssets.RenderScript("bundles/modernizr.js")
<!-- ... -->
@StaticAssets.RenderScript("bundles/jquery.js")
@StaticAssets.RenderScript("bundles/bootstrap.js")
And they will generate these tags when run:
<link href="/bundles/main.cf404633.css" rel="stylesheet" />
<script src="/bundles/modernizr.287fd894.js"></script>
<!-- ... -->
<script src="/bundles/jquery.58b06350.js"></script>
<script src="/bundles/bootstrap.9aa9dc49.js"></script>
More Visual Studio Integration
While we can run the build task whenever we need to, we can make things simpler for ourselves by triggering the Grunt build whenever a JavaScript or CSS file changes or when Visual Studio builds the project.
Grunt Watch
We first want to create a watch task using grunt-watch:
watch: {
bundles: {
files: ['Scripts/**/*.js', 'Content/**/*.css'],
tasks: ['build']
}
}
When the watch task is running, the build task will automatically kick off whenever a JavaScript or CSS file is updated.
Visual Studio Event Bindings
We can further make things easier by using event bindings in Task Runner Explorer. For instance, we can get our watch task to start when we open the project by right-clicking it and selecting the Project Open binding:
We can also do the same thing to bind the build task to Before Build and the clean task to Clean:
The build task will now run whenever the project is built in Visual Studio.
Source Code
The complete source code to this project can be found here: https://github.com/anthonychu/GruntBundling/