Lets quickly summarize the capabilities the bundling and minification API in ASP.NET 4.5 offered us:
- CSS and JavaScript files could be combined into bundles
- CSS and JavaScript files could be minified
- An Expires header was added to each bundle response so that the bundle would be cached by the browser
- A SHA1 hash was added to the URL of the bundle to act as a cache breaker.
So far, the approaches I have seen to this problem only cover points 1 and 2. They bundle and minify the files, but they do not address anything with caching. And this is really important. CSS and JavaScrpt files change somewhat infrequently, so we want the browser to cache them, especially since we are using more and more JavaScript in our apps to deliver highly interactive experiences.
What is also important though is that any solution we come up with addresses point #4 above. By taking a SHA1 hash of the bundle and incorporating it into the URL, if any file in the bundle is changes (or if a file is added to or removed from the bundle), then the URL of the bundle changes and the browser will know to download the new version of the bundle. This is critical, because CSS and JavaScript that we write will inevitable have bugs or need features added, and when we do this, we need to make sure the browser knows to download the new bundle.
What follows is the approach that I developed for my new Pluralsight course "Improving Website Performance with PageSpeed Insights" (to be release mid-July 2015). I do expect that as time goes on, I'll update this approach. But this will at the minimum provide you a starting point for how to solve this problem.
Step 1 - Setting up Gulp
The first thing you need to do is edit your packages.json file in order to pull in the needed npm packages. This file is in your project root, and when done should look like this
{
"name": "ASP.NET",
"version": "0.0.0",
"devDependencies": {
"gulp": "3.8.11",
"rimraf": "2.2.8",
"gulp-concat-css": "2.2.0",
"gulp-concat": "2.5.2",
"gulp-minify-css": "1.1.6",
"gulp-uglify": "1.2.0",
"gulp-hash": "2.0.4",
"gulp-rename": "1.2.2",
"event-stream": "3.3.1",
"gulp-extend": "0.2.0",
"gulp-clean": "0.3.1"
}
}
What is important here are the lines in red, as that is what you are needing to add. Note that by the time you read this, version numbers may have changed. Each of these packages though will perform a very specific task, and what we will do in the next step is set the pipeline to do this. For now though, lets understand why we are including each of these packages.
- gulp-concat-css - Used to concatenate (bundle) CSS files together
- gulp-concat - Used to concatenate (bundle) JavaScript files together
- gulp-minify-css - Used to minify CSS files
- gulp-uglify - Used to minify JavaScript files
- gulp-hash - Used to generate a SHA1 hash for a file and embed that hash in the filename
- gulp-rename - Used to copy files (the name says rename, but it really copies files)
- event-stream - Used to help create the pipeline of events below.
- gulp-extend - Used to merge the contents of JSON files. Used to create our manifest below
- gulp-clean - Used to clean directories so we can start fresh for every build
Once we save this file, Visual Studio 2015 will automatically download these packages into your solution.
I've seen a couple different approaches taken here. Many people are using the environment tag helper. This wasn't going to work for me because I have different filenames I need to account for. And I think it is going to be a bit of a stretch that everyone will set an environment variable on their web servers at this point.
So I need a couple of pieces here.
First, somewhere to tell my app that it should use the minified or unminified content. For this, I chose to put a value in appSettings. Low tech, yes, but this works. So here is my config.json file:
Then, I need something that is going to look at this and output the appropriate HTML based on what I need. This is where a custom tag helper comes in. Basically, a custom tag helper just outputs some HTML for you. So what I am going to have in my cshtml files will look like
So in this case, cssBundle corresponds to the class CssBundleTagHelper, and we are passing in two items, the name of the bundle we want and the virtual path of where that bundle is.
So lets take a look at the tag helper class that processes this.
Starting off, we have two TargetElement attributes on the class, and these help define the attributes that we can pass into this tag. This is what tells ASP.NET what attributes this tag can accept. Further down in the class, you see two properties called Name and Directory that have an attribute of HtmlAttributeName, and this is what links up the HTML attributes with the properties. So this is how we get those parameters supplied on our web page into the specific TagHelper object that is running.
Then what happens in the Process() method will get called, and the job of the Process() method is to output whatever HTML you need to into the TagHelperOutput object. By setting the TagName equal to null, this tells ASP.NET to throw away the cssBundle part of the tag. In this case, we don't want cssBundle at all, we want to completely replace it. Then, it is using a helper class to get the path you our CSS bundle (more on that in a minute) and it just interpolates that into a <link> tag.
So the TagHelper isn't that complicated. It is taking a bundle name, using a helper class to look up the name of the bundle file it wants to use and then producing a link tag. I have a jsBundle tag helper class that works exactly the same, but produces a <script> tag instead.
So what about this BundleConfig class and the GetBundleFilename() method. What do they look like?
In the Startup.cs file, I am creating a BundleConfig object as a Singleton so the same object will always get used when a class needs it. As you can see, in the constructor, it is looking fo rthe appSettings property to see if we should use minification and stores that as a member variable. It also reads the manifest file in the bundles subdirectory, so it knows what bundle name goes with what file.
In the GetBundledFileName() method, it looks to see if minification is enabled. If so, it needs to look the name of the file up in the manifest entries. This has to be done at runtime, because the hash could be different, and of course we don't want to go update all of our pages everything a bundles hash changes. If the file is not minified, it knows the pattern of the files, so it just constructs the appropriate filename based on this pattern.
I am happy that I was able to embed the SHA1 hash as a cachebuster in the filenames. I think this is an oversight in other solutions I have seen, and it may be a surprise to people when the browser is not loading their latest JavaScript. So I think this is really important.
I like the custom tags of cssBundle and jsBundle I created. I think these are succinct and to the point of what I am doing. Put this bundle of files here. Simple and easy to understand.
I'm not happy that in my BundleConfig class, I am constructing some Strings to look up the bundle or just create the filename based on a pattern. I think by revising my gulp tasks, I could produce a better manifest that would take the magic strings out of this, so when I get some time, I want to revisit this.
As I said above, I would like to pull my bundle definitions out of the gulpfile.js and into a config file, maybe bundle.config. Right now I am mixing code and configuration, which is never good.
So there is some work to do, and as I revise this, I'll update this post so you can see the revisions (this post will remain though, as the link in my Pluralsight course points here). But this will hopefully give you a good start as you tackle this problem. Many have argued that the approach ASP.NET 5 is taking in this regard is better. I am not ready to make that assertion. I think ultimately, this is more flexible. But the old approach was simple and you could be up and running in 5-10 minutes. This new approach is not that way, at least not yet. I spent a lot of time figuring this out, part of which was due things still being changed in ASP.NET 5, part of it due to the new approach. So if nothing else, I hope this saves you some of the trouble that that I went through in figuring this out. I think better solutions will emerge in time, but better solutions are often built on earlier solutions, so we have to get that conversation started.
Step 2 - Creating a Gulp Task to Bundle and Minify Our Files
Next, we need to edit the file gulpfile.js (also in the project root) to create a new gulp task. Eventually, we'll set this task up to run every time we build, but for now, lets concentrate on the code.
I added the following code at the end of the file
paths.webroot = "./" + project.webroot;
paths.css = "./" + project.webroot + "/css/";
paths.bundles = "./" + project.webroot + "/bundles/";
var manifestPath = paths.webroot + '/bundle-hashes.json';
var concatCss = require("gulp-concat-css"),
concat = require("gulp-concat"),
minifyCss = require("gulp-minify-css"),
uglify = require("gulp-uglify"),
hash = require("gulp-hash"),
rename = require("gulp-rename"),
es = require('event-stream'),
extend = require('gulp-extend');
var hashOptions = {
algorithm: 'sha1',
hashLength: 40,
template: '<%= name %>.<%= hash %><%= ext %>'
};
var cssBundleConfig =
[
{
name: "site-css-bundle",
files: [
paths.lib + "bootstrap/css/bootstrap.css",
paths.lib + "bootstrap-touch-carousel/css/bootstrap-touch-carousel.css",
paths.css + "site.css"
]
}
];
var jsBundleConfig =
[
{
name: "scripts-bundle",
files: [
paths.lib + "jquery/jquery.js",
paths.lib + "bootstrap/js/bootstrap.js",
paths.lib + "hammer.js/hammer.js",
paths.lib + "bootstrap-touch-carousel/js/bootstrap-touch-carousel.js"
]
}
];
function createCssBundle(bundleName, cssFiles, bundlePath) {
return addToManifest(
gulp.src(cssFiles)
.pipe(concatCss(bundleName + ".css"))
.pipe(gulp.dest(bundlePath))
.pipe(rename(bundleName + ".min.css"))
.pipe(minifyCss())
.pipe(hash(hashOptions))
.pipe(gulp.dest(bundlePath))
);
}
function createJsBundle(bundleName, jsFiles, bundlePath) {
return addToManifest(
gulp.src(jsFiles)
.pipe(concat(bundleName + ".js"))
.pipe(gulp.dest(bundlePath))
.pipe(rename(bundleName + ".min.js"))
.pipe(uglify())
.pipe(hash(hashOptions))
.pipe(gulp.dest(bundlePath))
);
}
function addToManifest(srcStream) {
return es.concat(
gulp.src(manifestPath),
srcStream
.pipe(hash.manifest(manifestPath))
)
.pipe(extend(manifestPath, false, 4))
.pipe(gulp.dest('.'));
}
gulp.task("cleanBundles", function (cb) {
rimraf(paths.bundles, cb);
});
gulp.task("bundleFiles", ['cleanBundles'], function () {
for(var i=0; i < cssBundleConfig.length; i++) {
var item = cssBundleConfig[i];
createCssBundle(item.name, item.files, paths.bundles);
}
for (var i = 0; i < jsBundleConfig.length; i++) {
var item = jsBundleConfig[i];
createJsBundle(item.name, item.files, paths.bundles);
}
});
This is kind of a long segment of code, so lets break it down into some smaller segments and take it step by step to see what it does.
The first section is just setting up some directory paths that we are going to need. The paths object is actually defined earlier in the file, I am just adding some paths to it.
The next section is pulling in all of those packages that we added to our project earlier and storing them in a variable so we can make use of them later.
This third section is setting up the options for our hashing package. We are going to use the SHA1 algorithm and we want all 40 bytes of the hash to be embedded into the filename. This is important to include the entire hash to make sure we avoid any hash collisions.
In the fourth section, I am defining some JSON objects that define my bundles. In this example, I have just one CSS and one JavaScript bundle, but a real project would of course have more. It would also be better in my opinion to load this out of a separate config file, but for this demo, I didn't go that far.
Next, we have two helper functions that create the CSS and JavaScript bundles respectively. If you have done any shell scripting in Unix or Powershell, this is pretty similar. We have a bunch of small, individual commands that pipe their output to one another to accomplish a bigger task.
So what each of these do is
All this is wrapped in the addToManifest() helper function so we can add an entry to our manifest file.
The next function is the helper function that is used to create the manifest file. We'll need this later, because we will need to translate a bundle name into the name of the file with the hash embedded into it. This code I took from documentation page for the gulp-hash project on npm.
Now finally, we have our gulp tasks. The first of these cleans (removes) all of our existing bundles, and the second is the task that will build the bundles. One thing to note here. The cleanBundles task is a dependency of the bundleFiles task, so every time bundleFiles gets called, cleanBundles will be called automatically.
The first section is just setting up some directory paths that we are going to need. The paths object is actually defined earlier in the file, I am just adding some paths to it.
paths.webroot = "./" + project.webroot;
paths.css = "./" + project.webroot + "/css/";
paths.bundles = "./" + project.webroot + "/bundles/";
var manifestPath = paths.webroot + '/bundle-hashes.json';
The next section is pulling in all of those packages that we added to our project earlier and storing them in a variable so we can make use of them later.
var concatCss = require("gulp-concat-css"),
concat = require("gulp-concat"),
minifyCss = require("gulp-minify-css"),
uglify = require("gulp-uglify"),
hash = require("gulp-hash"),
rename = require("gulp-rename"),
es = require('event-stream'),
extend = require('gulp-extend');
This third section is setting up the options for our hashing package. We are going to use the SHA1 algorithm and we want all 40 bytes of the hash to be embedded into the filename. This is important to include the entire hash to make sure we avoid any hash collisions.
var hashOptions = {
algorithm: 'sha1',
hashLength: 40,
template: '<%= name %>.<%= hash %><%= ext %>'
};
In the fourth section, I am defining some JSON objects that define my bundles. In this example, I have just one CSS and one JavaScript bundle, but a real project would of course have more. It would also be better in my opinion to load this out of a separate config file, but for this demo, I didn't go that far.
var cssBundleConfig =
[
{
name: "site-css-bundle",
files: [
paths.lib + "bootstrap/css/bootstrap.css",
paths.lib + "bootstrap-touch-carousel/css/bootstrap-touch-carousel.css",
paths.css + "site.css"
]
}
];
var jsBundleConfig =
[
{
name: "scripts-bundle",
files: [
paths.lib + "jquery/jquery.js",
paths.lib + "bootstrap/js/bootstrap.js",
paths.lib + "hammer.js/hammer.js",
paths.lib + "bootstrap-touch-carousel/js/bootstrap-touch-carousel.js"
]
}
];
Next, we have two helper functions that create the CSS and JavaScript bundles respectively. If you have done any shell scripting in Unix or Powershell, this is pretty similar. We have a bunch of small, individual commands that pipe their output to one another to accomplish a bigger task.
So what each of these do is
- read in the source files
- concatenate them together
- output a bundled file (not minified yet -- we'll use this version while developing)
- make a copy of the file with the ".min" in it that we can operate on
- minify this file
- use the hash package to create a SHA1 hash of the file and embed that hash in the filename
- output that file to our bundles directory
All this is wrapped in the addToManifest() helper function so we can add an entry to our manifest file.
function createCssBundle(bundleName, cssFiles, bundlePath) {
return addToManifest(
gulp.src(cssFiles)
.pipe(concatCss(bundleName + ".css"))
.pipe(gulp.dest(bundlePath))
.pipe(rename(bundleName + ".min.css"))
.pipe(minifyCss())
.pipe(hash(hashOptions))
.pipe(gulp.dest(bundlePath))
);
}
function createJsBundle(bundleName, jsFiles, bundlePath) {
return addToManifest(
gulp.src(jsFiles)
.pipe(concat(bundleName + ".js"))
.pipe(gulp.dest(bundlePath))
.pipe(rename(bundleName + ".min.js"))
.pipe(uglify())
.pipe(hash(hashOptions))
.pipe(gulp.dest(bundlePath))
);
}
The next function is the helper function that is used to create the manifest file. We'll need this later, because we will need to translate a bundle name into the name of the file with the hash embedded into it. This code I took from documentation page for the gulp-hash project on npm.
function addToManifest(srcStream) {
return es.concat(
gulp.src(manifestPath),
srcStream
.pipe(hash.manifest(manifestPath))
)
.pipe(extend(manifestPath, false, 4))
.pipe(gulp.dest('.'));
}
Now finally, we have our gulp tasks. The first of these cleans (removes) all of our existing bundles, and the second is the task that will build the bundles. One thing to note here. The cleanBundles task is a dependency of the bundleFiles task, so every time bundleFiles gets called, cleanBundles will be called automatically.
gulp.task("cleanBundles", function (cb) {
rimraf(paths.bundles, cb);
});
gulp.task("bundleFiles", ['cleanBundles'], function () {
for(var i=0; i < cssBundleConfig.length; i++) {
var item = cssBundleConfig[i];
createCssBundle(item.name, item.files, paths.bundles);
}
for (var i = 0; i < jsBundleConfig.length; i++) {
var item = jsBundleConfig[i];
createJsBundle(item.name, item.files, paths.bundles);
}
});
Step 3 - Running the bundleFiles task and Adding to the Build Process
Visual Studio 2015 contains a new window called Task Runner Explorer where you run can run these tasks from. To open this window, in the Visual Studio menu go to View --> Other Windows --> Task Runner Explorer (its about in the middle).
From there, you will see all of your Gulp tasks. If you want to run your task directly, just click on it and say run.
What you really want to do though is set your tasks up to run whenever you build, and to do that, you again right click on the task, go to bindings and make sure that "After Build" is clicked for the bundleFiles task.
Step 4 - Creating a TagHelper To Get the Bundles Into Our Pages
Where we are at now is that our bundles are created and minified and they are sitting in a bundles directory below our application. What we have to do now is get our pages to use these bundles.
I've seen a couple different approaches taken here. Many people are using the environment tag helper. This wasn't going to work for me because I have different filenames I need to account for. And I think it is going to be a bit of a stretch that everyone will set an environment variable on their web servers at this point.
So I need a couple of pieces here.
First, somewhere to tell my app that it should use the minified or unminified content. For this, I chose to put a value in appSettings. Low tech, yes, but this works. So here is my config.json file:
{
"AppSettings": {
"SiteTitle": "AspNet5Bundling",
"UseMinifiedContent": true
},
"Data": {
"DefaultConnection": {
"ConnectionString": "Server=(localdb)\\mssqllocaldb;Database=aspnet5-AspNet5Bundling-c7120031-310d-47a6-b645-970e20afc890;Trusted_Connection=True;MultipleActiveResultSets=true"
}
}
}
Then, I need something that is going to look at this and output the appropriate HTML based on what I need. This is where a custom tag helper comes in. Basically, a custom tag helper just outputs some HTML for you. So what I am going to have in my cshtml files will look like
<cssBundle bundle-name="site-css-bundle" bundle-dir="~/bundles"></cssBundle>
So in this case, cssBundle corresponds to the class CssBundleTagHelper, and we are passing in two items, the name of the bundle we want and the virtual path of where that bundle is.
So lets take a look at the tag helper class that processes this.
using System;
using Microsoft.AspNet.Razor.Runtime.TagHelpers;
using Microsoft.AspNet.Mvc;
using AspNet5Bundling.Util;
namespace AspNet5Bundling.Tags
{
[TargetElement("cssBundle", Attributes = BUNDLE_ATTRIBUTE_NAME)]
[TargetElement("cssBundle", Attributes = BUNDLE_DIRECTORY)]
public class CssBundleTagHelper : TagHelper
{
public const String BUNDLE_ATTRIBUTE_NAME = "bundle-name";
public const String BUNDLE_DIRECTORY = "bundle-dir";
private const String LINK_TAG_TEMPLATE = "<link href=\"{0}\" rel=\"stylesheet\"/>";
[Activate]
public BundleConfig BundleConfig { get; set; }
[HtmlAttributeName(BUNDLE_ATTRIBUTE_NAME)]
public string Name { get; set; }
[HtmlAttributeName(BUNDLE_DIRECTORY)]
public String Directory { get; set; }
public override void Process(TagHelperContext context, TagHelperOutput output)
{
// Always strip the outer tag name as we never want <cssBundle> to render
output.TagName = null;
String bundleFileName = this.BundleConfig.GetBundleFileName(this.Name, BundleType.css);
String cssBundlePath = String.Format("{0}/{1}", this.Directory, bundleFileName);
output.Content.SetContent(String.Format(LINK_TAG_TEMPLATE, cssBundlePath));
}
}
}
Starting off, we have two TargetElement attributes on the class, and these help define the attributes that we can pass into this tag. This is what tells ASP.NET what attributes this tag can accept. Further down in the class, you see two properties called Name and Directory that have an attribute of HtmlAttributeName, and this is what links up the HTML attributes with the properties. So this is how we get those parameters supplied on our web page into the specific TagHelper object that is running.
Then what happens in the Process() method will get called, and the job of the Process() method is to output whatever HTML you need to into the TagHelperOutput object. By setting the TagName equal to null, this tells ASP.NET to throw away the cssBundle part of the tag. In this case, we don't want cssBundle at all, we want to completely replace it. Then, it is using a helper class to get the path you our CSS bundle (more on that in a minute) and it just interpolates that into a <link> tag.
So the TagHelper isn't that complicated. It is taking a bundle name, using a helper class to look up the name of the bundle file it wants to use and then producing a link tag. I have a jsBundle tag helper class that works exactly the same, but produces a <script> tag instead.
So what about this BundleConfig class and the GetBundleFilename() method. What do they look like?
using Microsoft.Framework.ConfigurationModel;
using System;
using System.Collections.Generic;
namespace AspNet5Bundling.Util
{
public class BundleConfig
{
public BundleConfig(IConfiguration siteConfiguration, IConfiguration bundleConfig)
{
String useMinified = siteConfiguration.GetSubKey("AppSettings")["UseMinifiedContent"];
if (!String.IsNullOrWhiteSpace(useMinified) && String.Equals(useMinified, "true", StringComparison.CurrentCultureIgnoreCase))
this.enableMinification = true;
this.bundleMapping = new Dictionary<string, string>();
foreach (var item in bundleConfig.GetSubKeys())
{
String key = item.Key;
String value = bundleConfig[key];
this.bundleMapping.Add(key, value);
}
}
private bool enableMinification = false;
private Dictionary<String, String> bundleMapping;
public String GetBundleFileName(String bundleName, BundleType bundleType)
{
if ( enableMinification )
{
String baseBundleName = bundleName + ".min." + bundleType.ToString();
if ( this.bundleMapping.ContainsKey(baseBundleName) )
{
String bundleNameWIthHash = this.bundleMapping[baseBundleName];
return bundleNameWIthHash;
}
else
{
throw new Exception(String.Format("Unable to find {0} bundle with name {1}",
bundleType.ToString(), bundleName));
}
}
else
{
String bundleFile = bundleName + "." + bundleType.ToString();
return bundleFile;
}
}
}
}
In the Startup.cs file, I am creating a BundleConfig object as a Singleton so the same object will always get used when a class needs it. As you can see, in the constructor, it is looking fo rthe appSettings property to see if we should use minification and stores that as a member variable. It also reads the manifest file in the bundles subdirectory, so it knows what bundle name goes with what file.
In the GetBundledFileName() method, it looks to see if minification is enabled. If so, it needs to look the name of the file up in the manifest entries. This has to be done at runtime, because the hash could be different, and of course we don't want to go update all of our pages everything a bundles hash changes. If the file is not minified, it knows the pattern of the files, so it just constructs the appropriate filename based on this pattern.
What This Gives You
We can now bundle and minify our CSS and JavaScript files. Every time we build, these bundles will be created, and the minifed bundles will have a SHA1 hash embedded in them to act as a cachebuster, so if we update a CSS or JavaScript file, we know the browser will get the new version.Analysis
I'll be the first to say, this is one approach, and I think I'll refine this approach over time. There are things I am happy with and things I think could be better.I am happy that I was able to embed the SHA1 hash as a cachebuster in the filenames. I think this is an oversight in other solutions I have seen, and it may be a surprise to people when the browser is not loading their latest JavaScript. So I think this is really important.
I like the custom tags of cssBundle and jsBundle I created. I think these are succinct and to the point of what I am doing. Put this bundle of files here. Simple and easy to understand.
I'm not happy that in my BundleConfig class, I am constructing some Strings to look up the bundle or just create the filename based on a pattern. I think by revising my gulp tasks, I could produce a better manifest that would take the magic strings out of this, so when I get some time, I want to revisit this.
As I said above, I would like to pull my bundle definitions out of the gulpfile.js and into a config file, maybe bundle.config. Right now I am mixing code and configuration, which is never good.
So there is some work to do, and as I revise this, I'll update this post so you can see the revisions (this post will remain though, as the link in my Pluralsight course points here). But this will hopefully give you a good start as you tackle this problem. Many have argued that the approach ASP.NET 5 is taking in this regard is better. I am not ready to make that assertion. I think ultimately, this is more flexible. But the old approach was simple and you could be up and running in 5-10 minutes. This new approach is not that way, at least not yet. I spent a lot of time figuring this out, part of which was due things still being changed in ASP.NET 5, part of it due to the new approach. So if nothing else, I hope this saves you some of the trouble that that I went through in figuring this out. I think better solutions will emerge in time, but better solutions are often built on earlier solutions, so we have to get that conversation started.
No comments:
Post a Comment