Is there a stock or pluggable way (like a NuGet package) to let me declare .js
, .css
, and ideally .less
files in the MVC views and partials where I use them, and have them automatically runtime bundle and minify in production? (a.k.a. "Autobunding")
I've tried the built-in MVC 4 bundling. I dislike that bundles are defined far away from where a page author would expect to find them, in BundleConfig.cs
. This is unworkable for non-C# team members.
As an example of what I'm looking for, here's what I cobbled together myself using SquishIt.
ExtendedViewPage.cs
/// <summary>
/// Caches a bundle of .js and/or .css specific to this ViewPage, at a path similar to:
/// shared_signinpartial_F3BD3CCE1DFCEA70F5524C57164EB48E.js
/// </summary>
public abstract class ExtendedViewPage<TModel> : WebViewPage<TModel> {
// This is where I keep my assets, and since I don't actually store any in my root,
// I emit all my bundles here. I also use the the web deployment engine,
// and remove extra files on publish, so I never personally have to clean them up,
// and I also don't have to hand-identify generated bundles from original code.
// However, to keep from needing to give the app write permissions
// on a static content folder, or collocate bundles with original assets,
// or conform to a specific asset path, this should surely be configurable
private const string ASSET_PATH = "~/assets/";
/// <summary>
/// Emits here the bundled resources declared with "AddResources" on all child controls
/// </summary>
public MvcHtmlString ResourceLinks {
get {
return MvcHtmlString.Create(
string.Join("", CssResourceLinks) + string.Join("", JsResourceLinks));
}
}
// This allows all resources to be specified in a single command,
// which permits .css and .js resources to be declared in an
// interwoven manner, in any order the site author prefers
// For me, this makes it clearer, to group my related .css and .js links,
// and to place my often control-specific CSS near last in the list
/// <summary>
/// Queues compressible resources to be emitted with the ResourceLinks directive
/// </summary>
/// <param name="resourceFiles">Project paths to JavaScript and/or CSS files</param>
public void AddResources(params string[] resourceFiles) {
var css = FilterFileExtension(resourceFiles, ".css");
AddCssResources(css);
var js = FilterFileExtension(resourceFiles, ".js");
AddJsResources(js);
}
/// <summary>
/// Bundles JavaScript files to be emitted with the ResourceLinks directive
/// </summary>
/// <param name="resourceFiles">Zero or more project paths to JavaScript files</param>
public void AddJsResources(params string[] resourceFiles) {
if (resourceFiles.Any()) {
JavaScriptBundle jsBundle = Bundle.JavaScript();
foreach (string jsFile in resourceFiles) {
jsBundle.Add(jsFile);
}
// Pages render from the inside-out, which is required for us to expose
// our resources declared in children to the parent where they are emitted
// however, it also means our resources naturally collect here in an order
// that is probably not what the site author intends.
// We reverse the order with insert
JsResourceLinks.Insert(0, jsBundle.MvcRender(ASSET_PATH + ViewIdentifier + "_#.js"));
}
}
/// <summary>
/// Bundles CSS files to be emitted with the ResourceLinks directive
/// </summary>
/// <param name="resourceFiles">Zero or more project paths to CSS files</param>
public void AddCssResources(params string[] resourceFiles) {
// Create a separate reference for each CSS path, since CSS files typically include path-relative images.
foreach (
var cssFolder in resourceFiles.
GroupBy(r => r.Substring(0, r.LastIndexOf('/')).ToLowerInvariant()).
// Note the CssResourceLinks.Insert command below reverses not only desirably
// the order of view emission, but also undesirably reverses the order of resources within this one view.
// for this page we'll 'pre-reverse' them. There's probably a clearer way to address this.
Reverse()) {
CSSBundle cssBundle = Bundle.Css();
foreach (string cssFile in cssFolder) {
cssBundle.Add(cssFile);
}
// See JsResourceLinks.Insert comment above
CssResourceLinks.Insert(0, cssBundle.MvcRender(cssFolder.Key + "/" + ViewIdentifier + "_#.css"));
}
}
#region private implementation
private string _viewIdentifier = null;
// ViewIdentifier returns a site-unique name for the current control, such as "shared_signinpartial"
// Some security wonks may take issue with exposing folder structure here
// It may be appropriate to obfuscate it with a checksum
private string ViewIdentifier {
get {
if (_viewIdentifier == null) {
_viewIdentifier =
// VirtualPath uniquely identifies the currently rendering View or Partial,
// such as "~/Views/Shared/SignInPartial.cshtml"
Path.GetFileNameWithoutExtension(VirtualPath).
// This "Substring" truncates the ~/Views/ or ~/Areas/ in my build, in others
// but it is probably inappropriate to make this assumption.
// It is certainly possible to have views in the root.
// Substring(8).
// It's assumed all of these bundles will be output to a single folder,
// to keep filesystem write-access minimal, so we flatten them here.
Replace("/", "_").
// The following assumes a typical MS filesystem, preserve-but-ignore case.
// The .NET string recommendations suggest instead using ToUpperInvariant
// for such an operation, but this was just a personal preference.
// My IIS rules typically drop the case on all content served.
// It may be altogether inappropriate to alter,
// although appending the MD5 hash ensure it does no harm on other platforms,
// while still collapsing the cases where multiply-cased aliases are used
ToLowerInvariant();
}
return _viewIdentifier;
}
}
private List<MvcHtmlString> CssResourceLinks {
get { return getContextHtmlStringList("SquishItCssResourceLinks"); }
}
private List<MvcHtmlString> JsResourceLinks {
get { return getContextHtmlStringList("SquishItJsResourceLinks"); }
}
// Note that at the resource render, if no bundles of a specific type (.css or .js)
// have been provided, this performs the unnecessary operation of instanciating a new List<MvcHtmlString>
// and adding it to the HttpContext.Items. This get/set could benefit from some clarification.
private List<MvcHtmlString> getContextHtmlStringList(string itemName) {
IDictionary contextItems = Context.ApplicationInstance.Context.Items;
List<MvcHtmlString> resourceLinks;
if (contextItems.Contains(itemName)) {
resourceLinks = contextItems[itemName] as List<MvcHtmlString>;
}
else {
resourceLinks = new List<MvcHtmlString>();
contextItems.Add(itemName, resourceLinks);
}
return resourceLinks;
}
private string[] FilterFileExtension(string[] filenames, string mustEndWith) {
IEnumerable<string> filtered =
filenames.Where(r => r.EndsWith(mustEndWith, StringComparison.OrdinalIgnoreCase));
return filtered.ToArray();
}
#endregion private implementation
}
PageWithHeaderLayout.cshtml (example usage)
@{
AddResources(
Links.Assets.Common.Script.GoogleAnalytics_js,
Links.Assets.Common.Style.ProprietaryTheme.jquery_ui_1_8_23_custom_css,
Links.Assets.Common.Style.SiteStandards_css,
Links.Assets.Common.CdnMirror.jquery._1_7_2.jquery_js,
Links.Assets.Common.CdnMirror.jQuery_Validate._2_0_0pre.jquery_validate_120826_js,
Links.Assets.Common.CdnMirror.jqueryui._1_8_23.jquery_ui_min_js,
Links.Assets.Common.JqueryPlugins.templates.jquery_tmpl_min_js,
Links.Assets.Common.JqueryPlugins.jquery_ajaxmanager_js,
Links.Assets.Common.JqueryPlugins.hashchange.jquery_ba_hashchange_min_js
);
}
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>@ViewBag.Title</title>
<meta name="description" content="@ViewBag.Description" />
<meta name="keywords" content="@ViewBag.Keywords" />
<link rel="shortcut icon" href="@Url.Content("~/favicon.ico")" type="image/x-icon" />
<!-- all bundles from all page components are emitted here -->
@ResourceLinks
</head>
<body>
@Html.Partial(MVC.Common.Views.ContextNavigationTree)
<div id="pageContent">
@RenderBody()
</div>
</body>
</html>
Unfortunately I wrote it, so it has many limitations. Scripts don't de-duplicate, it takes a simple approach to bundle delineation, I added an ugly hack recently to permit .less
support, etc.
Are there any existing solutions to do this?
This is kind of a comment but I ran out of space.
This is neat but it seems like you end up with one bundle per (complete, rendered) page, which is pretty much the worst case scenario for a first-time visitor to the site. If you have several pages that use the same master page and don't add any additional content, you will end up with the same large file downloaded on each page with a different name. Instead of basing the name off the page name, try concatenating all of the filenames (in order) and calculating an MD5 hash to use as your bundle name - this serves as a fairly good uniqueness check, and should really cut down on your bandwidth usage. You can see an example here of how we do this in SquishIt - just remember that the value you calculated would be whats coming in as "key" at this point in the code. Another thing I would consider is defining bundles per physical view file instead of for the entire page, to maximize reusability.
I realize this sounds critical, but I do like the general direction you're going with this. I'm just not sure what the exact destination is. If you need any help, I try to watch this tag pretty closely and I'm pretty easy to find elsewhere.
As far as "auto-bundling" is concerned, I don't think there is anything out there that does what you're looking for - in large part because it requires such a nuanced approach. You can look at RequestReduce - it does a lot of optimization for you without intervention, but I don't believe it combines assets.