In my previous blog post, we discussed setting up RequireJS for a multi-page ASP.NET MVC app.
The code for the reference app is on GitHub, and when run, it shows us loading seven JavaScript files (409 KB) and four CSS files (31.1 KB):
We know we can reduce the number of HTTP requests, and reduce network traffic, if we can load fewer files and smaller files at run time.
There are several ways to do this bundling and minification: use MVC's built in tools, use Web Essentials, or use r.js, the tool that comes with RequireJS. The advantage of the later is you can let RequireJS, which is already tracking your dependency graph, decide which files should be bundled together for a specific page.
You can also use r.js to bundle ALL the scripts in your project together, and for a single-page app, that may make sense. But for a multi-page app, you want to have a common set of scripts you need on most pages, and an a la cart bundle for each page. We'll focus on the later here.
Prerequisites
You'll need to download r.js, which comes in the RequireJS nuget package, and you'll need a local version of Node running.
You'll also likely want to do this optimization only in Release mode, so be sure your project has a Debug and a Release build config.
What to bundle for JavaScript?
Here are the JavaScript files we have to work with:
We want page-level bundles, so we can load require.js, then main.js (including common libraries and kickoff.js), then main-currentDateTime.js and anything it needs that we haven't already loaded.
I usually name the main-*.js file as the name of the main MVC view or controller/action being pulled up on the screen. Theses main-*.js files are the entry point for firing up the JavaScript for just that page.
What to bundle for CSS?
We have three CSS files we can combine and a main.css file that references them:
The main.css file uses import statements for the three style sheets:
/* 3rd party stylesheets */
@import url("lib/bootstrap.css");
@import url("lib/bootstrap-theme.css");
/* Application stylesheets */
@import url("app/site.css");
This is usually a no-no for web development, because now there is a fourth style sheet, and a fourth HTTP request as the browser loads main.css, then turns around and loads the three style sheets it wants us to load. In this case, I think it's fine since this will be the way it works in Debug mode when you are working locally. When you go to Release mode, everything in main.css will be bundled and combined into one file.
Now change the link in the _Layout.cshtml file to point to main.css:
<link href="~/Styles/main.css" rel="stylesheet" />
TIP: Be sure your CSS files are listed in main.css in the order in which you want them to override, with your app styles at the bottom. The last ones listed will take precedence.
Setting up r.js, build-scripts.js, build-styles.js
I created an App_Build folder at the solution level. Call yours anything you like. The point is, this is stuff for building the project, not part of the code that is deployed when running the app.
I added the r.js file and two other JavaScript files with build config settings. One is for the JavaScript code and one is for the CSS.
The JavaScript config file, "build-scripts.js", looks like this:
({
appDir: "../Scripts",
dir: "../Scripts-Build",
baseUrl: "app",
mainConfigFile: "../Scripts/main.js",
paths: {
main: "../main"
},
keepBuildDir: false,
modules: [{
name: "main",
include: [
// These JS files will be on EVERY page in the main.js file
// So they should be the files we will almost always need everywhere
"domReady",
"jquery",
"jqueryValidate",
"jqueryValidateUnobtrusive",
"bootstrap",
"moment"
]
},
// These are page-specific bundles, usually named main-*
{ name: "main-currentDateTime", exclude: ["main"] }
],
onBuildRead: function (moduleName, path, contents) {
if (moduleName === "main") {
return contents.replace("Scripts", "Scripts-Build");
}
return contents;
}
})
Most of the paths are relative to the r.js file. The appDir is the main folder, so the appDir plus the baseUrl is the /Scripts/app folder. The dir setting is where you want the built scripts to go. The mainConfigFile is the path to main.js.
The first argument in the modules section is all the code you want rolled up into main.js. These are files used on almost every if not every page. The next argument in modules is each page file. Here, you need to manually add each page of your app where you want bundling. I've only got one page/bundle listed here. RequireJS will inspect that main-*.js file, and its dependencies, and their dependencies, and so on, but it will exclude files already bundled into main.
For example, the main-currentDateTime module uses "jquery". That file is already included in main.js in these build instructions, so it won't be bundled into main-currentDateTime.
The onBuildRead stuff is for swapping out paths in the built scripts. More on that down below.
The CSS config file, "build-styles.js", looks like this:
({
keepBuildDir: false,
optimizeCss: "standard",
cssIn: "../Styles/main.css",
out: "../Styles-Build/main.css"
})
The main configuration settings as the cssIn folder, where the code to bundle comes from, and the out folder, where it's going.
In both cases, we're outputting to new folders that are cleaned out on every Release build, /Scripts-Build and /Styles-Build.
Bundling and minifying on Release build
Open the web project Properties and select Build Events. In the Pre-build event command line, add:
if $(ConfigurationName) == Release node "$(ProjectDir)App_Build\r.js" -o "$(ProjectDir)App_Build\build-scripts.js"
if $(ConfigurationName) == Release node "$(ProjectDir)App_Build\r.js" -o "$(ProjectDir)App_Build\build-styles.js"
Note the call to execute r.js with Node. You must have Node set up in your Paths on your machine for this to work. Now the build scripts will run every time the build is executed in Release mode.
Change your build configuration to Release mode and build the app. If it worked, the output window should show:
1>------ Build started: Project: RequireJSWithMVC, Configuration: Release Any CPU ------
1>
1> Tracing dependencies for: main
1>
1> Tracing dependencies for: main-currentDateTime
1> Uglifying file: C:/RequireJSWithMVC/Scripts-Build/app/currentDateTime.js
1> Uglifying file: C:/RequireJSWithMVC/Scripts-Build/app/kickoff.js
1> Uglifying file: C:/RequireJSWithMVC/Scripts-Build/app/main-currentDateTime.js
1> Uglifying file: C:/RequireJSWithMVC/Scripts-Build/lib/bootstrap.js
1> Uglifying file: C:/RequireJSWithMVC/Scripts-Build/lib/domReady.js
1> Uglifying file: C:/RequireJSWithMVC/Scripts-Build/lib/jquery-2.1.1.js
1> Uglifying file: C:/RequireJSWithMVC/Scripts-Build/lib/jquery.validate.js
1> Uglifying file: C:/RequireJSWithMVC/Scripts-Build/lib/jquery.validate.unobtrusive.js
1> Uglifying file: C:/RequireJSWithMVC/Scripts-Build/lib/moment.js
1> Uglifying file: C:/RequireJSWithMVC/Scripts-Build/lib/require.js
1> Uglifying file: C:/RequireJSWithMVC/Scripts-Build/main.js
1>
1> main.js
1> ----------------
1> app/kickoff.js
1> main.js
1> lib/domReady
1> lib/jquery-2.1.1.js
1> lib/jquery.validate.js
1> lib/jquery.validate.unobtrusive.js
1> lib/bootstrap.js
1> lib/moment.js
1>
1> app/main-currentDateTime.js
1> ----------------
1> app/main-currentDateTime.js
1>
1>
1> C:/RequireJSWithMVC/Styles-Build/main.css
1> ----------------
1> C:/RequireJSWithMVC/Styles/lib/bootstrap.css
1> C:/RequireJSWithMVC/Styles/lib/bootstrap-theme.css
1> C:/RequireJSWithMVC/Styles/app/site.css
1> C:/RequireJSWithMVC/Styles/main.css
1>
1> RequireJSWithMVC -> C:\RequireJSWithMVC\bin\RequireJSWithMVC.dll
========== Build: 1 succeeded, 0 failed, 0 up-to-date, 0 skipped ==========
r.js does a nice job of reporting back what it's done. Here, you can see it traces dependencies, minifies (uglifies) every JavaScript file, then it reports the files it bundled for main.js, main-currentDateTime.js, and main.css.
You should be able to show hidden files in Visual Studio and see the new folders and their files. Open them up and look at the minification changes
Dealing with paths with MVC Helpers
So great! We've got the files we want in /Scripts-Build and /Styles-Build. Unfortunately, our code is still pointing to /Scripts and /Styles. We can fix this with some MVC Helpers.
Looking at our _Layout.cshtml, we'll need to fix the path to the stylesheets and the path to require.js,:
<!DOCTYPE html>
<html>
<head>
…
<link href="~/Styles/main.css" rel="stylesheet" />
</head>
<body>
…
<div class="container">
@RenderBody()
</div>
<script src="/Scripts/lib/require.js"></script>
@RenderSection("scripts", required: false)
</body>
</html>
Here's the C# for the stylesheet and scripts PathHelpers.cs:
using System.Web;
using System.Web.Mvc;
namespace RequireJSWithMVC.Extensions
{
public static class PathHelpers
{
public static string ScriptsPath(this HtmlHelper helper, string pathWithoutScripts)
{
#if (DEBUG)
var scriptsPath = "~/Scripts/";
#else
var scriptsPath = "~/Scripts-Build/";
#endif
return VirtualPathUtility.ToAbsolute(scriptsPath + pathWithoutScripts);
}
public static string StylesPath(this HtmlHelper helper, string pathWithoutStyles)
{
#if (DEBUG)
var stylesPath = "~/Styles/";
#else
var stylesPath = "~/Styles-Build/";
#endif
return VirtualPathUtility.ToAbsolute(stylesPath + pathWithoutStyles);
}
}
}
Now our _Layout.cshtml can be update to this:
@using RequireJSWithMVC.Extensions
<!DOCTYPE html>
<html>
<head>
...
<link rel="stylesheet" href="@Html.StylesPath("main.css")" />
</head>
<body>
...
<div class="container">
@RenderBody()
</div>
<script src="@Html.ScriptsPath("lib/require.js")"></script>
@RenderSection("scripts", required: false)
</body>
</html>
We still need to fix the paths within each page. They still point to the /Scripts folder and need to point to /Scripts-Build in Release mode. Let's do that by updating our RequireJsHelper.cs from the last post:
using System;
using System.Text;
using System.Web;
using System.Web.Mvc;
namespace RequireJSWithMVC.Extensions
{
public static class RequireJsHelpers
{
public static MvcHtmlString InitPageMainModule(this HtmlHelper helper, string pageModule)
{
var require = new StringBuilder();
#if (DEBUG)
var scriptsPath = "~/Scripts/";
#else
var scriptsPath = "~/Scripts-Build/";
#endif
var absolutePath = VirtualPathUtility.ToAbsolute(scriptsPath);
require.AppendLine("<script>");
require.AppendFormat(" require([\"{0}main.js\"]," + Environment.NewLine, absolutePath);
require.AppendLine(" function() {");
require.AppendFormat(" require([\"{0}\", \"domReady!\"]);" + Environment.NewLine, pageModule);
require.AppendLine(" }");
require.AppendLine(" );");
require.AppendLine("</script>");
return new MvcHtmlString(require.ToString());
}
}
}
Both of these helpers rely on the compiler flag for debug. If you don't like that, you could always branch off an Environment key in appSettings in your web.config or something.
Now when the page is loaded for this app, we have three scripts and one stylesheet, so we've gone from eleven HTTP requests to four. Furthermore, the files requested are much smaller after the minification, and we've gone from 440.1 KB to 223.3 KB total. You could get that down even more if you removed all the license info from the minified JavaScript in the build-scripts.js config with "preserveLicenseComments: false".
Finally, your users' browsers should cache most of these files after landing on any page on your site. The files require.js, main.js, and main.css will be the same for each page. Then the user's browser only needs to download the JavaScript for each newly browsed page.