Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at Scotch On The Rocks (SOTR) 2011 (Edinburgh) with: Darren Walker
Ben Nadel at Scotch On The Rocks (SOTR) 2011 (Edinburgh) with: Darren Walker@bayornet )

Building JavaScript Demos With TypeScript 2.2.1, Webpack 2, And Angular 2.4.9

By Ben Nadel on

CAUTION: This is the first time that I've used Webpack and I am in no way a Webpack expert. Actually, I'm barely a novice. This is just me trying to get my GitHub-based demo workflow to run using Webpack - the scope of this is no larger than that. In fact, this covers less than the Webpack intro in the Angular 2 documentation. This entire post is basically just a NOTE TO SELF.

Over the last few weeks, I've been evolving the way in which I develop my GitHub-based JavaScript demos. Once I learned that the System.js TypeScript plugin was dropping support for in-browser type-checking, I learned out how to use the TypeScript compiler to perform type-checking offline. But, once I decided to take the compilation process offline, I figured I might as well take my workflow one step further and start using Webpack 2 to bundle my files. After all, if I'm already using an offline build process, I might as well reduce the number (and size) of the files that I need to load. This post represents my novice approach to getting Webpack 2 to power by GitHub-based Angular 2 and JavaScript demos.

Run this demo in my JavaScript Demos project on GitHub.

As with the vast majority of my JavaScript demos, I keep the vendor files in a shared folder location so that they can be used by multiple demos. Of course, once I start compiling and bundling files offline for the GitHub demos, the use of shared files becomes somewhat less meaningful; however, I still want to reduce the number of raw source files that I have to check into GitHub. So, for my Webpack 2 exploration, I am continuing to keep my package.json file in a separate vendor folder:

/vendor/angular2/2.4.9-webpack/package.json

  • {
  • "name": "angular2-v2-4-9-webpack",
  • "version": "2.4.9-webpack",
  • "license": "ISC",
  • "dependencies": {
  • "@angular/common": "2.4.9",
  • "@angular/compiler": "2.4.9",
  • "@angular/core": "2.4.9",
  • "@angular/forms": "2.4.9",
  • "@angular/http": "2.4.9",
  • "@angular/platform-browser": "2.4.9",
  • "@angular/platform-browser-dynamic": "2.4.9",
  • "@angular/router": "3.4.9",
  • "@types/lodash": "4.14.57",
  • "@types/node": "7.0.11",
  • "angular2-template-loader": "0.6.2",
  • "core-js": "2.4.1",
  • "html-webpack-plugin": "2.28.0",
  • "lodash": "4.17.4",
  • "raw-loader": "0.5.1",
  • "reflect-metadata": "0.1.10",
  • "rxjs": "5.2.0",
  • "systemjs": "0.20.10",
  • "ts-loader": "2.0.2",
  • "typescript": "2.2.1",
  • "web-animations-js": "2.2.2",
  • "webpack": "2.2.1",
  • "zone.js": "0.8.4"
  • }
  • }

This is the minimum number of dependencies that I believe I need in order to get Angular 2 and Webpack 2 playing nicely together. I won't necessarily use all of these libraries in every demo (for example, not all Angular 2 demos need the Router); but, this should give me a small but flexible base from which to start exploring.

Once I start using Webpack 2, I am no longer going to be calling the TypeScript compiler directly. Instead, Webpack 2 will invoke the TypeScript compiler for me using the ts-loader loader. Of course, ts-loader still needs to know how to transpile the TypeScript source files; so, I am still providing a tsconfig.json file in my demo folder:

/demos/webpack-angular2/tsconfig.json

  • {
  • "compilerOptions": {
  • "baseUrl": "../../vendor/angular2/2.4.9-webpack/node_modules/",
  • "emitDecoratorMetadata": true,
  • "experimentalDecorators": true,
  • "lib": [
  • "DOM",
  • "ES6"
  • ],
  • "module": "commonjs",
  • "moduleResolution": "node",
  • "noImplicitAny": true,
  • "paths": {
  • "lodash": [
  • "@types/lodash"
  • ]
  • },
  • "pretty": true,
  • "removeComments": false,
  • "sourceMap": true,
  • "suppressImplicitAnyIndexErrors": true,
  • "target": "es5",
  • "typeRoots": [
  • "../../vendor/angular2/2.4.9-webpack/node_modules/@types/"
  • ],
  • "types": [
  • "node"
  • ]
  • }
  • }

Because my vendor files are not contained directly within my individual demo folders, I have to tell TypeScript how to resolve imports. As such, I have to provide typeRoots and a baseUrl so that the TypeScript compiler knows where to locate modules. This way, when I import lodash, for example, it knows where to find the type definition file for lodash (in this case, using the "paths" property) so that it can validate my consumption of lodash.

NOTE: If your node_modules folder is in the same folder as your Webpack 2 config, some of this configuration becomes unnecessary as the node_modules folder would be in an "expected" location and modules will be resolved naturally.

Tranpsiling and type-checking TypeScript is only half the battle. If you've ever looked at my earlier Angular 2 demos, you'll see that they make - literally - hundres of network requests to load source files and type defintion files (*.d.ts) for in-browser type-checking and transpilation. All of those files are still necessary for the demo; only, with an offline build process, we can drastically reduce the number of requests made over the wire. Instead of making hundreds of requests, we can bundle common files together:

  • Webpack 2 runtime - This is the code that wires all of the dependencies together during application execution.
  • Polyfill - This is the code that fills in any holes with the Browser's adherence to modern JavaScript Standards.
  • Vendor - This is the 3rd-party code that we're consuming in the application.
  • App - This is the actual custom application code and I am writing.

NOTE: Type definition files are not included in these bundles since they are only consumed during the transpiling process - once the TypeScript compiler runs offline, the type definition files are no longer needed at runtime.

These groups don't actually have to be four separate files - we could create just one giant bundle. However, what we're trying to do here is isolate the parts of the code that change for different reasons. For example, the Webpack 2 runtime will probably never change (unless we upgrade Webpack); and, the polyfills won't change until we change our browser support or new browsers are released; vendor code will change only as we include or remove 3rd-party libraries; and, our app code will change with every single change to our application logic.

By compiling these four groups down into different bundles, it allows us to provide more granular caching mechanisms. For my GitHub demos this doesn't really matter much since the demos don't evolve over time. But, for a production application, it would be great for the vendor filename to remain the same across builds so that the user only has to download the latest application code and not re-download the same vendor code over and over again.

Of course, Webpack doesn't know the difference between app code and vendor code and polyfill code - this separation has to be defined in the Webpack configuration file in terms of "entry" files. Here's the configuration file that I'm using for my GitHub demos:

/demos/webpack-angular2/webpack.config.js

  • // Load the core node modules.
  • var HtmlWebpackPlugin = require( "../../vendor/angular2/2.4.9-webpack/node_modules/html-webpack-plugin" );
  • var webpack = require( "../../vendor/angular2/2.4.9-webpack/node_modules/webpack" );
  •  
  • module.exports = {
  • // I am going to generate 3 separate JavaScript files (that the HtmlWebpackPlugin
  • // will automatically inject into my HTML template). Creating three files helps me
  • // isolate the parts of the code that change often (my code) from the parts of the
  • // code that change infrequently (the vendor code).
  • entry: {
  • polyfill: "./app/main.polyfill.ts",
  • vendor: "./app/main.vendor.ts",
  • main: "./app/main.ts"
  • },
  • // In normal development, I might use "[name].[chunkhash].js"; however, since this
  • // is just getting committed to GitHub, I don't want to create a new hash-based file
  • // for every file-save event. Instead, I can use the "hash" option in the
  • // HtmlWebpackPlugin to help with cache-busting per build.
  • output: {
  • filename: "[name].js",
  • path: "./build"
  • },
  • resolve: {
  • extensions: [ ".ts", ".js" ],
  • // Tell Webpack to use my shared vendor folder when resolving modules that it
  • // finds in "import" statements.
  • modules: [
  • "../../vendor/angular2/2.4.9-webpack/node_modules/"
  • ]
  • },
  • resolveLoader: {
  • // Tell Webpack to use my shared vendor folder when resolving loaders that it
  • // finds in this config (ex, "ts-loader") (or in inline references, I suppose).
  • modules: [
  • "../../vendor/angular2/2.4.9-webpack/node_modules/"
  • ]
  • },
  • module: {
  • rules: [
  • {
  • test: /\.ts$/,
  • loaders: [
  • // I compile the TypeScript content into ES5 JavaScript. In addition
  • // to transpiling the code, it is also running type-checks based on
  • // the tsconfig.json file.
  • "ts-loader",
  • // Given the transpiled code, I convert Template and Style URL
  • // references into require() statements that will subsequently get
  • // consumed by the raw-loader.
  • // --
  • // NOTE: Do not include the "keepUrl=true" that is in some examples;
  • // that inlines the content, but does not replace the property name
  • // used in the component meta-data.
  • "angular2-template-loader"
  • ]
  • },
  • // When the "angualr2-template-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
  • // file as plain-text.
  • {
  • test: /\.(htm|css)$/,
  • loader: "raw-loader"
  • }
  • ]
  • },
  • plugins: [
  • // I move common references in the Entry files down into the lowest-common entry
  • // file in this list.
  • // --
  • // CAUTION: The order of these chunk names has to be in the REVERSE order of the
  • // order in which you intent to include them in the Browser. I believe, but am not
  • // sure, that this is because common dependencies are moved to the next file down
  • // in this list. So, if "main" and "vendor" have things in common, they will be
  • // moved down to "vendor". Were the order reversed, with "vendor" above "main",
  • // then common dependencies would be moved down to "main" (which is what we want
  • // to avoid).
  • new webpack.optimize.CommonsChunkPlugin({
  • names: [
  • "main",
  • "vendor",
  • "polyfill",
  • // Extract the Webpack bootstrap logic into its own file by providing a
  • // name that wasn't listed in the "entry" file list.
  • // --
  • // NOTE: I don't really need this for my kind of GitHub based development;
  • // but, this seems to be a common pattern as it moves frequently changing
  • // code out of the "vendor" file.
  • "manifest"
  • ]
  • }),
  • // 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",
  •  
  • // This will append a unique query-string hash (for cache busting) to the
  • // injected files after each build. All files get the same hash, which makes
  • // this DIFFERENT from using the "chunkhash" in the "output" config.
  • hash: true
  • }),
  • // I compact the JavaScript content.
  • new webpack.optimize.UglifyJsPlugin({
  • keep_fnames: true
  • })
  • ]
  • };

The first thing you may notice here is the long file paths. Again, since my vendor files are stored in a shared folder location, I have to tell Webpack how to resolve both module names and Webpack Loader names. For example, when I tell Webpack to use "ts-loader" to compile the imported *.ts files, Webpack has to know to go up two directories and down into the shared vendor folder in order to figure out what "ts-loader" is. If your node_modules folder is in the same directory as your Webpack config, most of this pathing becomes unnecessary since Webpack uses node's module resolution strategy (with some additional logic wrapped around it).

The "entry" files tell Webpack which bundles to create. It does this by opening those files, creating a dependency graph of imports, and then bundling all the dependencies into a single file. On its own, this doesn't really get us what we want because all of the "vendor" files get doubly-bundled into the main app file as well. This is because Webpack doesn't know that it should separate-out common dependencies - that's what the CommonsChunkPlugin does: it takes common dependencies from 2 or more dependency graphs and moves them to just one of the emitted files.

The order of the files in the CommonsChunkPlugin has to be in the reverse order of the way in which those files need to be included in the HTML page. This is because (I believe) the CommonsChunkPlugin will move common dependencies down that list. As such, you want your more "vendory" files at the bottom so that they contain all the common dependencies as they [the dependencies] get dragged down that list.

Once these various entry files have been emitted by the compilation process, the HtmlWebpackPlugin will then auto-inject them as Script tags into the given HTML template. To keep things simple, all of my entry files - including the main HTML page - are just variations of the "main" script:

  • main.ts
  • main.vendor.ts
  • main.polyfill.ts
  • main.htm

These all get compiled down into the "output" directory, "./build", with the exception of the "main.htm" which we want to store in the root of the demo, so the browser knows where to find it. The main.htm gets moved up a directory - from the build directory - because the filename provided to the HtmlWebpackPlugin contains a "../" relative path traversal.

While our main.ts file contains imports of our app modules, the main.vendor.ts and main.polyfill.ts are a little different; they are essentially a list of unconsumed imports to the files that we want to include in the various bundles. For example, here's my polyfill file:

  • // Import these libraries for their side-effects.
  • import "core-js/client/shim.min.js";
  • import "zone.js/dist/zone.js";
  • import "reflect-metadata/Reflect.js";
  •  
  • // Load the Web Animations API polyfill for most browsers (basically any browser other than Chrome and Firefox).
  • // import "web-animations-js/web-animations.min.js";

And, here's my vendor file:

  • // Import these libraries for their side-effects.
  • // --
  • // CAUTION: As you add more "import" statements to your application code, you will have
  • // to come back to this file and add those imports here as well (otherwise that imported
  • // content may get bundled with your main application bundle, not your vendor bundle.
  • import "@angular/core";
  • import "@angular/platform-browser-dynamic";
  • import "lodash";
  • import "rxjs/add/observable/of";
  • import "rxjs/Observable";

As you can see, I'm not actually consuming these imports - I'm just defining them. On its own, this will tell Webpack to bundle the various imports into each one of the resultant entry files. And, when used in conjunction with the CommonsChunkPlugin, it will ensure that common imports only get included in one of the entry files (based on the order of the files defined in the CommonsChunkPlugin configuration).

In the vendor file, I'm only including the imports that I actually use in the app. For example, I'm not including "@angular/router" here because my demo app doesn't use routing. This is the tricky thing about these common dependencies - as you add new dependencies to your main application, you have to remember to come back and update the vendor file so that those new dependencies don't accidentally get bundled into the main application bundle. I am not sure if there is some "Webpack way" to help ensure that this doesn't happen? Or, if this is just a matter of process and it's up to you, as the developer, to keep it locked down?

NOTE: If you forget to update your vendor file, nothing will "break" - it just means the new "vendor dependency" will get bundled into the "main" entry file rather than extracted by the CommonsChunkPlugin.

To test all of this, I created a simple hello-world type demo that included RxJS and Lodash (to test my dependencies):

  • // Import the core angular services.
  • import { Component } from "@angular/core";
  • import { Observable } from "rxjs/Observable";
  • import * as _ from "lodash";
  •  
  • // Import these modules to create side-effects.
  • import "rxjs/add/observable/of";
  •  
  • @Component({
  • selector: "my-app",
  • styleUrls: [ "./app.component.css" ],
  • templateUrl: "./app.component.htm"
  • })
  • export class AppComponent {
  •  
  • public movies: Observable<string[]>;
  •  
  •  
  • // I initialize the app component.
  • constructor() {
  •  
  • // NOTE: Neither the use of an RxJS stream nor the use of Lodash makes any real
  • // sense in this scenario; I'm only using these libraries in order to ensure that
  • // the imports work and result in the proper Vendor bundles.
  • this.movies = Observable.of(
  • _.map(
  • [
  • "Conversations with Other Women",
  • "Planet of the Apes",
  • "Fight Club",
  • "The Theory of Flight"
  • ],
  • ( movie: string ) : string => {
  •  
  • return( movie + "." );
  •  
  • }
  • )
  • );
  •  
  • }
  •  
  • }

Technically, I don't actually need this line in the module:

import "rxjs/add/observable/of";

... because this line is only needed for a side-effect (to modify the Observable class) and is already being executed in my main.vendor.ts file. That said, it is important that I include it here as both a self-documenting feature and as one that can be used outside of my Webpack build process (where there may not be a main.vendor.ts file that already executes said import).

Here is the root components template, defined in the @Component() meta-data and inlined by the "angular2-template-loader" loader:

  • <p>
  • Best <strong>Helena Bonham Carter</strong> movies:
  • </p>
  •  
  • <ul>
  • <li *ngFor="let movie of movies | async">
  • {{ movie }}
  • </li>
  • </ul>
  •  
  • <p>
  • <em>This demo was built using webpack 2 and TypeScript 2.2.1.</em>
  • </p>

To pull it all together, I created a demo-local package.json file that provided a few run scripts for invoking the webpack binary from my shared vendor folder:

  • {
  • "scripts": {
  • "build": "../../vendor/angular2/2.4.9-webpack/node_modules/.bin/webpack",
  • "watch": "../../vendor/angular2/2.4.9-webpack/node_modules/.bin/webpack --watch"
  • }
  • }

Then, when we compile the project and run the code, we get the following page output:


 
 
 

 
 Building Angular 2 demos with Webpack 2 and TypeScript. 
 
 
 

As I said at the onset of this post, there's nothing here that isn't covered in Angular's introduction to Webpack. In fact, my post contains considerably less information. But, this is how I got Webpack 2 working with my JavaScript demos, which have slightly different constraints like a shared set of vendor files. As I learn more about Webpack, the one thing I really want to do is try to keep my application code "webpack agnostic". That means I want to avoid things like importing CSS files into my JavaScript modules, since that only makes sense in a Webpack build context. I like magic, but I don't like shenanigans.



Looking For A New Job?

Ooops, there are no jobs. Post one now for only $29 and own this real estate!

100% of job board revenue is donated to Kiva. Loans that change livesFind out more »

Reader Comments

Ben, I know, mostly tutorials show splitting polyfills and vendor files in two separate entry points. But if you have two bundles, Tree-Shaking will not work properly. AFAIK, you need one entry point for both polyfills and vendor files, so that Webpack can statically analyze used and not used exports and Uglify can remove dead code. Can you compare total bundle sizes with and without splitting please?

Reply to this Comment

@Colin - Agree. And Angular CLI only generates main.ts and polyfills.ts :-) No vendor.ts.

Reply to this Comment

@Oleg. What do you mean about no vendor.ts?

When using the latest Angualr CLI with --aot, it creates the following scripts in the HTML:

inline.bundle.js
polyfills.bundle.js
main.bundle.js
styles.bundle.js
vendor.bundle.js

Reply to this Comment

@Oleg,

To be honest, I don't know much about Tree Shaking or the AoT compiler yet. I've heard of the concept, but I haven't actually seen it in practice. That said, I think there are two competing concepts here - dead code elimination and efficient cache management. The Tree Shaking helps eliminate dead code; but, splitting up the bundles is done [in part] to help the Browser only re-download files as necessary. The idea is to split apart the things that change at different rates. Since vendor scripts are fairly stable (comparatively), they can be cached for a longer period by the Browser. Compared to the "app" bundle, which probably is outdated after every single deploy.

Reply to this Comment

@Colin,

I haven't read up on the CLI yet. This has been a journey from in-browser System.js to in-browser System.js w/ type-checking (via TypeScript plugin), to System.js with offline type-checking, to Webpack 2. I've basically been evolving the way that I put my demos together.

Also, since the demo have a slightly unique structure, in that many demos share the same vendor code, I am not sure how well that will work with the expectation of the Angular CLI. I just haven't read up on it enough to know if I can configure it to work this way. Of course, since its all based on tsconfig and webpack.config files, I assume it can ... just not there yet :)

Reply to this Comment

@Oleg,

That's interesting. I don't any experience, yet, with http2 other than what I've heard on various podcasts and presentations. I've heard some of the WebPack people talk about http2 as being a mixed bag -- that's not a perfect solution, and there is still room for bundling files. Sounds like even the Aggressive Splitting plugin also gets more course than individual files. Anyway, cool stuff -- so much to learn, I feel like I've only scratched the surface.

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
NEW: Some basic markdown formatting is now supported: bold, italic, blockquotes, lists, fenced code-blocks. Read more about markdown syntax »
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.