JavaScript Demos Using Webpack 4 With Angular 6.1.7 And Ahead Of Time (AoT) Compiling
As you may know, I collect most of my JavaScript demos in a single GitHub repository (JavaScript Demos). And, for the last year or so, I've been compiling my subset of Angular demos with Webpack 3. These Webpack compilations have been using the ts-loader plugin to transpile and concatenate files. As of today, however, I've finally figured out how to upgrade my Angular build process to use Webpack 4 along with the Angular-provided webpack tooling, complete with Ahead of Time (AoT) compiling. This should significantly reduce my vendor bundle and decrease the initial load time of my Angular applications.
CAUTION: I don't really know what I'm doing. Webpack is a mystery to me. Take all of the code here with a handful of caution and understand that I have not battle-tested it. I have only gotten to a point where it all seems to compile and behave the way I want it to. This post is primarily for my own documentation. Your mileage may vary. Thar be dragons. And so on.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
Since the Angular portion of this post isn't really relevant, I will omit the code (you can view it on GitHub if you want). I'll only point out that the Angular application has this subset of files:
- ./app/app.module.ts
- ./app/main.htm
- ./app/main.polyfill.ts
- ./app/main.ts
- ./tsconfig.json
- ./webpack.config.js
With my previous builds, I was using the ts-loader and angular2-template-loader Webpack loaders. Both of these loaders are now being replaced by the one Angular-provided Webpack loader, "@ngtools/webpack". This one loader will handle the transpilation, HTML and CSS inlining, and Ahead of Time (AoT) compilation.
In order to get the "@ngtools/webpack" loader to work, I had to install the following packages:
- "@angular/cli": "6.2.1"
- "@angular/compiler-cli": "6.1.7"
- "@ngtools/webpack": "6.2.1"
This is, of course, in addition to all of the other Angular, TypeScript, and Webpack packages I normally include (see below). And, actually, I had to back-peddle on the version of TypeScript I was using. When I had Webpack 3 with ts-loader, I was able to use TypeScript 3.x. However, in order to get Ahead of Time (AoT) compilation working, I had to lower the version of TypeScript to use 2.9.2:
- "typescript": "2.9.2"
One of the big changes in Webpack 4 is that the compiler now runs in different modes: "production" and "development". You can detect these different modes in the Webpack configuration file (webpack.config.js); but, more importantly, Webpack will use these different modes to automatically apply different optimizations. For example, in "development" mode, your code won't be minified. But, in "production" mode, Webpack will automatically run minifcation using UglifyJS.
In my npm run scripts, I am defining the "watch" script as using "development" mode; and, I am defining the "build" script as using "production" mode:
- "build": "webpack --mode production"
- "watch": "webpack --mode development --watch"
Here is my full package.json file so you can see what npm modules I am using for my Angular 6 demos:
{
"name": "angular-demo",
"version": "6.1.7",
"license": "ISC",
"scripts": {
"build": "webpack --mode production",
"watch": "webpack --mode development --watch"
},
"dependencies": {
"@angular/animations": "6.1.7",
"@angular/cli": "6.2.1",
"@angular/common": "6.1.7",
"@angular/compiler": "6.1.7",
"@angular/compiler-cli": "6.1.7",
"@angular/core": "6.1.7",
"@angular/forms": "6.1.7",
"@angular/http": "6.1.7",
"@angular/platform-browser": "6.1.7",
"@angular/platform-browser-dynamic": "6.1.7",
"@angular/router": "6.1.7",
"@ngtools/webpack": "6.2.1",
"@types/lodash": "4.14.116",
"@types/node": "10.9.4",
"clean-webpack-plugin": "0.1.19",
"core-js": "2.5.7",
"html-webpack-plugin": "3.2.0",
"less": "3.8.1",
"less-loader": "4.1.0",
"lodash": "4.17.10",
"npm": "6.4.1",
"raw-loader": "0.5.1",
"rxjs": "6.3.2",
"typescript": "2.9.2",
"webpack": "4.18.0",
"webpack-cli": "3.1.0",
"zone.js": "0.8.26"
}
}
And, here is my Webpack configuration file:
// Load the core node modules.
var AngularCompilerPlugin = require( "@ngtools/webpack" ).AngularCompilerPlugin;
var CleanWebpackPlugin = require( "clean-webpack-plugin" );
var HtmlWebpackPlugin = require( "html-webpack-plugin" );
var path = require( "path" );
var webpack = require( "webpack" );
// We are exporting a Function instead of a configuration object so that we can
// dynamically define the configuration object based on the execution mode.
module.exports = ( env, argv ) => {
var isDevelopmentMode = ( argv.mode === "development" );
// Locally, we want robust source-maps. However, in production, we want something
// that can help with debugging without giving away all of the source-code. This
// production setting will give us proper file-names and line-numbers for debugging;
// but, without actually providing any code content.
var devtool = isDevelopmentMode
? "eval-source-map"
: "nosources-source-map"
;
// By default, each module is identified based on Webpack's internal ordering. This
// can cause issues for cache-busting and long-term browser caching as a localized
// change can create a rippling effect on module identifiers. As such, we want to
// identify modules based on a name that is order-independent. Both of the following
// plugins do roughly the same thing; only, the one in development provides a longer
// and more clear ID.
var moduleIdentifierPlugin = isDevelopmentMode
? new webpack.NamedModulesPlugin()
: new webpack.HashedModuleIdsPlugin()
;
return({
// I define the base-bundles that will be generated.
// --
// NOTE: There is no explicit "vendor" bundle. With Webpack 4, that level of
// separation is handled by default. You just include your entry bundle and
// Webpack's splitChunks optimization DEFAULTS will automatically separate out
// modules that are in the "node_modules" folder.
// --
// CAUTION: The ORDER OF THESE KEYS is meaningful "by coincidence." Technically,
// the order of keys in a JavaScript object shouldn't make a difference because,
// TECHNICALLY, the JavaScript language makes to guarantees around key ordering.
// However, from a practical standpoint, JavaScript keys are iterated over in the
// same order in which they were defined (especially in V8). By putting the
// POLYFILL bundle first in the object definition, it will cause the polyfill
// bundle to be injected into the generated HTML file first. If you don't want to
// rely on this ordering - or, if it breaks for you anyway - you can use the
// HtmlWebpackPlugin (see: chunksSortMode) to explicitly order chunks.
entry: {
polyfill: "./app/main.polyfill.ts",
main: "./app/main.ts"
},
// I define the bundle file-name scheme.
output: {
filename: "[name].[contenthash].js",
path: path.join( __dirname, "build" )
},
devtool: devtool,
resolve: {
extensions: [ ".ts", ".js" ]
},
module: {
rules: [
// I provide a TypeScript compiler that performs Ahead of Time (AoT)
// compilation for the Angular application and TypeScript code.
{
test: /(\.ngfactory\.js|\.ngstyle\.js|\.ts)$/,
loader: "@ngtools/webpack"
},
// When the @ngtools webpack loader runs, it will replace the @Component()
// "templateUrl" and "styleUrls" with inline "require()" calls. As such, we
// need the raw-loader so that require() will know how to load .htm and .css
// files as plain-text.
{
test: /\.(htm|css)$/,
loader: "raw-loader"
},
// If our components link to .less files instead of .css files, then the
// less-loader will parse the LESS CSS file on-the-fly during the require()
// call that is generated by the @ngtools webpack loader.
{
test: /\.less$/,
loaders: [
"raw-loader",
"less-loader"
]
}
]
},
plugins: [
// I clean the build directory before each build.
new CleanWebpackPlugin([
path.join( __dirname, "build/*.js" ),
path.join( __dirname, "build/*.js.map" )
]),
// I work with the @ngtools webpack loader to configure the Angular compiler.
new AngularCompilerPlugin({
tsConfigPath: path.join( __dirname, "tsconfig.json" ),
mainPath: path.join( __dirname, "app/main" ),
entryModule: path.join( __dirname, "app/app.module#AppModule" ),
// Webpack will generate source-maps independent of this setting. But,
// this setting uses the original source code in the source-map, rather
// than the generated / compiled code.
sourceMap: true
}),
// I generate the main "index" file and inject Script tags for the files emitted
// by the compilation process.
new HtmlWebpackPlugin({
// Notice that we are saving the index UP ONE DIRECTORY, so that it is output
// in the root of the demo.
filename: "../index.htm",
template: "./app/main.htm"
}),
// I facilitate better caching for generated bundles.
moduleIdentifierPlugin
],
optimization: {
splitChunks: {
// Apply optimizations to all chunks, even initial ones (not just the
// ones that are lazy-loaded).
chunks: "all"
},
// I pull the Webpack runtime out into its own bundle file so that the
// contentHash of each subsequent bundle will remain the same as long as the
// source code of said bundles remain the same.
runtimeChunk: "single"
}
});
};
I tried to keep this configuration code as simple as possible. And, I tried to comment it to the best of my abilities (if for no other reason to help ensure that I knew what the heck I was talking about). There are few key things to see:
I'm exporting a Function, not a configuration object. By doing this, I can examine the "mode" in which the build is being run. This allows me to tweak the configuration for the various "production" and "development" modes. For example, I'm using sanitized source-maps in production and "full" source-maps in development.
There is no "vendors" bundle file. In previous versions of Webpack, it was common to explicitly create a vendors bundle so that stable code could be extracted and cached independently. With Webpack 4, the default configuration does this for you! It automatically creates a "caching group" based on files pulled from "node_modules".
The order of the "entry" keys is important. While JavaScript technically makes no guarantees about the order of keys in an object, V8 will maintain the order in which the keys were defined. This is important because we need our Polyfill bundle to be included before our Main bundle. For reasons that don't seem to be documented anywhere, this "entry" key ordering will allow the HtmlWebpackPlugin() plugin to provide the files in the correct order when injecting them into the index.htm template.
With this Webpack configuration in place, if I run "npm run build" to generate the production build with Angular's Ahead of Time (AoT) compiling, we can see that the automatically-generated "vendor" bundle is less than half the size of what it would be normally:
This reduction in size is thanks to the fact that Angular can completely exclude the compiler from the bundle that gets delivered to the browser. Since the Angular code has already been compiled at build-time, there's no need to include the JIT (Just in Time) compiler code in the browser context.
Now, in my Webpack configuration file, you can see that I am changing the "devtool" setting for production. Specifically, I am using the "nosources-source-map" setting. To be completely transparent, I have little understanding of how source-maps actually work - to me, it's all magic. That said, this setting allows the browser to include proper file-names and line-numbers in errors stack-traces without actually having access to the original source code.
To test this, I added a click-handler in my Angular code that would throw an error. And, if I trigger that error, we get the following output:
As you can see, the Error stack-trace properly reports the file-name, method-name, and line-number. But, when we look at the source-map content, it is empty. None of our original source-code has to go to the browser in order to get high-fidelity error reporting.
Of course, if we ran this in "development" mode, using the devtool setting, "eval-source-map", our source-maps would contain the original non-transpiled code. We could then proceed to add break-points to the source-map code and debug our errors like a boss!
At some point, I may try to switch over to using the Angular CLI directly. But for now, I am using Webpack. Thankfully, the "@ngtools/webpack" module makes this interaction fairly low-friction. And, without too much effort, allows me to use Ahead of Time compilation with Webpack 4 and Angular 6.1.7.
Want to use code from this post? Check out the license.
Reader Comments
@All,
I've tried to take this AoT + Webpack approach even further and get it working with Lazy Loading of modules:
www.bennadel.com/blog/3502-experimenting-with-lazy-loaded-modules-ahead-of-time-aot-compiling-and-webpack-4-in-angular-6-1-7.htm
Hi Nadel,
Thank you for this demo!
I have been using Angular with Webpack 3/4 for my company's projects. All of the project compile with JIT. I attempted few times to switch to AoT but there are some old libraries were not compatible with AoT, so I stayed with JIT. I also want to try Angular CLI but most of my project are using ASP .NET Core so I need to custom a little bit Webpack, this also a reason. Now after I read your blog, I think it is a time to upgrade my Webpack config using AoT. Thanks for inspiring me.
Cheers,
Nguyen
@Nguyen,
Very cool! Super excited to be able to provide some inspiration. I hope that it is going well. The Webpack stuff -- all of it -- has been challenging for me. So, once I get something working, I tend to not touch it for a while. Hopefully you are getting there!