Big thanks to Alex Eagle from the Angular Team and to Carmen Popoviciu for reviewing this post.

Closure is widely regarded as the most advanced JavaScript compiler currently on the market. Its advanced optimization mode surpasses the tree shaking capabilities of other tools, enabling bundles to be reduced to a minimal size. Google employs it to enhance the performance of its own products such as Google Docs, and Microsoft has also adopted it for Office 365. That said, it is seen as a tool for experts and thus challenging to set up. Additionally, it presumes that the underlying JavaScript was authored in a particular manner.

At present, the Angular team is actively working on integrating Angular with Closure and its build tool Bazel. There are some preliminary examples available, like the Example created by Alex Eagle from the Angular Team.

This post leverages the mentioned example to demonstrate how to employ the Closure compiler and the benefits it offers in terms of bundle size. Furthermore, it details how to incorporate your own and existing packages into a Closure-based project.

Establishing a Baseline with the Angular CLI

To create a reference point for comparing Closure with a standard Angular build process, let's generate a fresh Hello World application using the Angular CLI:

ng new baseline
cd baseline

Next, we generate a production build:

ng build --prod

The resulting bundles come in at roughly 394K:

   1.460 inline.093de888567e5146835d.bundle.js
   9.360 main.0d097609144c942cc763.bundle.js
  60.845 polyfills.d90888e283bda7f009a0.bundle.js
 322.320 vendor.765bef7fc0b73d2d51d7.bundle.js

         393.985 Bytes

Given that the Closure sample in the subsequent sections imports only the zone.js polyfill directly and no others, we should exclude the polyfills bundle from this analysis:

   1.460 inline.093de888567e5146835d.bundle.js
   9.360 main.0d097609144c942cc763.bundle.js
 322.320 vendor.765bef7fc0b73d2d51d7.bundle.js

         333.140 Bytes

This brings the figure down to about 333K.

Following that, we install Angular Material along with the Animation package, which Angular Material depends on:

npm i @angular/material --save
npm i @angular/animations --save

To bring it into the application, the AppModule references some of Angular Material's modules:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpModule } from '@angular/http';
import { AppComponent } from './app.component';

import { 
  MdButtonModule, 
  MdAutocompleteModule,
  MdCheckboxModule,
  MdDatepickerModule,
  MdCardModule,
  MdRadioModule,
  MdChipsModule,
  MdListModule,
  MdSnackBarModule,
  MdSliderModule,
  MdDialogModule,
  MdMenuModule,
  MdSidenavModule
} from '@angular/material';

@NgModule({
  imports: [
    BrowserModule,
    FormsModule,
    HttpModule,
    MdButtonModule, 
    MdAutocompleteModule,
    MdCheckboxModule,
    MdDatepickerModule,
    MdCardModule,
    MdRadioModule,
    MdChipsModule,
    MdListModule,
    MdSnackBarModule,
    MdSliderModule,
    MdDialogModule,
    MdMenuModule,
    MdSidenavModule
  ],
  declarations: [
    AppComponent
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

After regenerating a production build (ng build --prod), we observe that it has expanded to around 951K:

   1.460 inline.36030f130bb8b4d4d1e6.bundle.js
 246.959 main.bd55167e3a85bc1edaab.bundle.js
 702.496 vendor.9618c29d7fbb7af5a536.bundle.js
      950.915 Bytes

Given that the bundle enlarged despite the imported Angular Material modules not being utilized, this demonstrates that the CLI/ webpack cannot detect this and eliminate those modules through tree shaking.

Now, let's examine how Closure can optimize the sample application.

Leveraging the Closure Compiler

To begin working with Angular and the Closure Compiler, I'm utilizing Alex Eagle's example. To do this, I've forked the version that was available at the time of writing.

I adjusted it to use npm instead of yarn, and after executing npm run build, I obtained a bundle of just about 106K:

 105.934 bundle.js

This marks a substantial improvement over using the CLI with webpack, which produced bundles of approximately 390K for a comparable "Hello-World" application.

For a fair comparison, we must also incorporate the packages @angular/forms and @angular/http, since these packages are also imported into the CLI-based application:

npm i @angular/http --save
npm i @angular/forms --save

To bring them into the Angular app, the AppModule must reference them:

import {NgModule} from '@angular/core';
import {HttpModule} from '@angular/http';
import {FormsModule} from '@angular/forms';
import {BrowserModule} from '@angular/platform-browser';
import {Basic} from './basic';

@NgModule({
  declarations: [Basic],
  bootstrap: [Basic],
  imports: [BrowserModule, FormsModule, HttpModule],
})
export class AppModule {
}

Unfortunately, the Closure Compiler does not adhere to NodeJS conventions and thus does not automatically search the node_modules directory for these packages. Consequently, they need to be explicitly referenced within the closure.conf file:

node_modules/@angular/forms/@angular/forms.js
--js_module_root=node_modules/@angular/forms

node_modules/@angular/http/@angular/http.js
--js_module_root=node_modules/@angular/http

These lines reference both the location of the package and its entry point, which also constitutes the package's entire contents due to the use of the FESM15-Format.

After rebuilding everything (npm run build), we arrive at approximately 125K:

 125.134 bundle.js

This continues to be a significant improvement over using the CLI and/or Webpack.

Please note that this example employs Closure's Advanced Mode. This mode yields superior results compared to other known tools, but it is also quite aggressive. That is why it can potentially damage the generated bundle, so it is advisable to always pair this mode with comprehensive E2E testing.

Integrating Closure with an Angular Package via Angular Material

Now, let's proceed to import Angular Material. Once more, we need to load the following packages:

npm i @angular/material --save
npm i @angular/animations --save

And, of course, we must import the same Angular Material modules as before:

import {NgModule} from '@angular/core';
import {BrowserModule} from '@angular/platform-browser';
import {Basic} from './basic';
import { LibStarterModule } from 'stuff-lib';
import { HttpModule } from "@angular/http";
import { FormsModule } from "@angular/forms";

import { 
  MdButtonModule, 
  MdAutocompleteModule,
  MdCheckboxModule,
  MdDatepickerModule,
  MdCardModule,
  MdRadioModule,
  MdChipsModule,
  MdListModule,
  MdSnackBarModule,
  MdSliderModule,
  MdDialogModule,
  MdMenuModule,
  MdSidenavModule
} from '@angular/material';

@NgModule({
  declarations: [Basic],
  bootstrap: [Basic],
  imports: [
    BrowserModule, 
    HttpModule, 
    FormsModule, 
    MdButtonModule, 
    MdAutocompleteModule,
    MdCheckboxModule,
    MdDatepickerModule,
    MdCardModule,
    MdRadioModule,
    MdChipsModule,
    MdListModule,
    MdSnackBarModule,
    MdSliderModule,
    MdDialogModule,
    MdMenuModule,
    MdSidenavModule
  ],
})
export class AppModule {
}

Additionally, it's imperative to inform Closure about the imported modules. Therefore, the closure.conf receives the following supplementary lines:

node_modules/@angular/animations/@angular/animations.js
--js_module_root=node_modules/@angular/animations

node_modules/@angular/material/@angular/material.js
--js_module_root=node_modules/@angular/material

Before we can initiate a build, we need to update the TypeScript Compiler, as the example's version comes with 2.1, while the current Angular Material version requires 2.2 or higher:

npm uninstall typescript --save-dev
npm install typescript@^2.2 --save-dev

After producing another build, our bundle stands at around 200K:

199.970 bundle.js

This illustrates two points: first, using Closure offers a massive improvement over the CLI and/or webpack, which produced a bundle of approximately 951K. However, this experiment also reveals that even Closure cannot completely eliminate all imported but unused modules through tree shaking.

Developing a Custom Angular Package Compatible with Closure

Creating your own Angular package that works with the Closure compiler is relatively straightforward. You simply need to follow the conventions specified in the Angular Package Format. The key consideration for Closure is providing a build in the FESM15 format. This entails using EcmaScript 2015+ with EcmaScript Modules. Additionally, everything must be "flattened" into a single file that serves as the package's entry point. The Angular Package Format also advises providing your code in other formats, but here I'll concentrate solely on FESM15.

For testing purposes, I've created such a package with some demo code. It's named angular-stuff-lib and contains only a simple Angular Module with a DemoComponent.

@NgModule({
    imports: [
        CommonModule,
        FormsModule,
        HttpModule
    ],
    declarations: [
        DemoComponent
    ],
    exports: [
        DemoComponent
    ]
})
export class LibStarterModule {

    static forRoot(): ModuleWithProviders {
        return {
            ngModule: LibStarterModule,
            providers: [
                DemoService
            ]
        };
    }

}

Its forRoot method returns this module along with a DemoService. Using this method ensures that the service is only registered with your application's RootModule, not with other modules that might also import it.

The package employs the following tsconfig.json:

{
  "compilerOptions": {
    "module": "es2015",
    "target": "es2015",
    "outDir": "build",
    "noImplicitAny": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "declaration": true,
    "moduleResolution": "node",
    "noUnusedLocals": true,
    "types": [
      "hammerjs",
      "jasmine",
      "node"
    ],
    "lib": ["es2015", "dom"]
  },
  "files": [
    "index.ts"
  ],
  "angularCompilerOptions": {
    "strictMetadataEmit": true,
    "skipTemplateCodegen": true,
    "annotationsAs": "static fields",
    "annotateForClosureCompiler": true,
    "flatModuleOutFile": "stuff-lib.js",
    "flatModuleId": "stuff-lib"
  }
}

Several aspects are worth noting here:

  • The compilation target is EcmaScript 2015 with EcmaScript (2015) Modules.
  • The destination folder is designated as built.
  • The configuration points directly to the main file index.ts.
  • Thanks to the declaration property, the TypeScript compiler emits Type Declarations. This enables the compiled EcmaScript-based package to be used within TypeScript projects.
  • There are several options for the Angular Compiler in the angularCompilerOptions:
    • strictMetadataEmit produces metadata that the Angular Compiler needs to generate an AOT build for projects using this package.
    • skipTemplateCodegen is set to true since compiled templates aren't needed for a library; the final project will handle that.
    • annotateForClosureCompiler prompts the Angular Compiler to generate annotation comments that Closure uses to optimize the emitted code.
    • flatModuleOutFile specifies a file that the Angular Compiler creates as an entry point, which tools like rollup can use to create a flat package (a package consisting of just one file).
    • flatModuleId contains the module name of the generated package, which is the name used with import statements.

To generate the build, I'm utilizing some npm scripts:

"scripts": {
    "build": "npm run clear && npm run ngc && npm run rollup && npm run copy",
    "clear": "rimraf build && rimraf dist",
    "ngc": "ngc",
    "rollup" : "rollup build/stuff-lib.js -o dist/stuff-lib.js",
    "copy": "npm run copy-package && npm run copy-metadata && npm run copy-typedef",
    "copy-typedef": "cd build && cpy **/*.d.ts ../dist --parents",
    "copy-metadata": "cd build && cpy **/*.metadata.json ../dist",
    "copy-package": "cpy dist-package.json dist/package.json"
}

The build script initiates the entire process (npm run build). First, it cleans the compilation target folders using rimraf. Then, it invokes the Angular Compiler ngc, which also operates the TypeScript Compiler underneath. The results of this compilation step end up in the build folder. Afterward, the tool rollup generates the flat package file and places it in the dist folder. To ensure all necessary files are present in dist, they are copied there. Additionally, the package.json containing essential metadata for this package is copied to the dist folder.

Among others, this metadata includes the following entries:

  "name": "stuff-lib",
  [...]
  "module": "stuff-lib.js",
  "es2015": "stuff-lib.js",
  "typings": "stuff-lib.d.ts",

The name property holds the package's name, and es2015 points to the generated flat ES2015 bundle. Typically, module would point to its ES5 counterpart. However, since I'm concentrating only on ES2015 here, it points to the ES2015 bundle as well. Additionally, typings points to the entry file of the emitted type definitions.

One final point that isn't specific to Closure or the Angular Module Format but is still important: to prevent Angular from being installed as a sub-dependency when downloading this package, Angular is only mentioned within peerDependencies. This instructs npm that anyone installing this package must also install those packages.

"peerDependencies": {
  "@angular/core": "^4.0.0",
  "@angular/http": "^4.0.0"
},

To build this project, simply run npm run build. Afterward, you'll find the compiled package in the dist folder.

Testing the Custom Package with Closure

To test your own Angular package with the Closure Compiler, switch to the dist folder after building it and run npm link. Then, navigate to the root folder of the Closure project and execute npm link stuff-lib, which creates a symbolic link to your package's dist folder.

After that, you need to inform Closure about the added package by inserting some lines into the closure.conf file:

node_modules/stuff-lib/stuff-lib.js
--js_module_root=node_modules/stuff-lib

As mentioned previously, the line with --js_module_root points to the package's root directory within node_modules. This is necessary because Closure doesn't adhere to Node's conventions. The other line points to the flat bundle, which serves as the package's entry point and contains its entirety.

Following this, simply import the package's module into the AppModule:

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { Basic } from './basic';
import { LibStarterModule } from 'stuff-lib';
import { MaterialModule } from '@angular/material';
import { HttpModule } from "@angular/http";
import { FormsModule } from "@angular/forms";

@NgModule({
  declarations: [Basic],
  bootstrap: [Basic],
  imports: [
    BrowserModule, 
    MaterialModule, 
    HttpModule, 
    FormsModule, 
    LibStarterModule.forRoot()
  ],
})
export class AppModule {
}

After building with Closure, you'll notice that this doesn't significantly impact the bundle size, since the package in use is just a minimal demo. You can also verify that the entire application functions correctly. To ensure Closure properly utilizes the package, inject its DemoService into the Root Component and use it:

import {Component, Injectable} from '@angular/core';
import { DemoService} from 'stuff-lib';

@Component({
  selector: 'basic',
  templateUrl: './basic.ng.html',
})
export class Basic {
  ctxProp: string;
  constructor(private demoService: DemoService) {
    this.ctxProp = Hello World;

    this.demoService.info = 'Hello World';
    console.log('demoService', this.demoService.doStuff());
  }
}

After this, rebuild the application and run it using npm run serve. This launches a demo web server on port 8080. Navigate to it and observe that the specified message is logged to the JavaScript console.

Integrating Closure with a CommonJS/NodeJS Package

I've also experimented with using a CommonJS/NodeJS package alongside the Closure Compiler. For this, I installed the base64-js package (npm install base64-js --save), which I also use in my angular-oauth2-oidc project. The simplest way to make Closure aware of such a library appears to be pointing directly to it, much like you would with your own files. Alex's sample does this for RxJs as well. To do this, add the following line to your closure.config:

--js node_modules/base64-js/**.js

After reading this, Closure assumes the referenced folder contains a CommonJS Module named base64-js. The file index.js is treated as the entry point, which you can import by referencing the module itself via require('base64-js') or import ... from 'base64-js'. If there were other files, they could be referenced using the path base64-js/other-file. To specify a different entry point, you can also point to the package.json using the --js flag—in this case, the entry point defined in the main property of that file is used.

Following this, simply import the required parts of the library and put them to use:

import { fromByteArray } from 'base64-js';

[...]

this.ctxProp = fromByteArray(this.ctxProp);

Applying a Manual Fix to a CommonJS/NodeJS Package for Closure Compatibility

I also experimented with the sha256 package, which I need for my angular-oauth2-oidc project. However, it doesn't function properly with Closure. The issue lies in its package.json file (node_modules/sha256/package.json):

"browser": "./lib/sha256.js",
"main": "index.js",

That file includes two fields that define an entry point. According to standard practice, the browser field should point to a bundle meant for browser execution, while main is used for NodeJS builds. Unfortunately, as of now, Closure only consults the main field. There are ongoing discussions about adding support for these other fields. My workaround was to manually edit the package so that main points to ./lib/sha256.js:

"browser": "./lib/sha256.js",
"main": "./lib/sha256.js",

Obviously, this isn't an elegant fix, and you must ensure that npm doesn't overwrite these changes. One approach is to relocate the modified package to a directory outside node_modules.

After making the adjustment, simply add the following lines to your closure.conf file:

--js node_modules/sha256/**.js
--js node_modules/convert-string/**.js
--js node_modules/convert-hex/**.js

--js node_modules/sha256/package.json
--js node_modules/convert-string/package.json
--js node_modules/convert-hex/package.json

These lines not only include the files for sha256 but also the files for two of its dependencies. Moreover, they load the package.json files for these libraries, giving Closure the necessary context to determine the correct entry points.

From there, you can import the package—which is exported as a single function—and start using it:

const sha256 = require('sha256');
[...]
this.ctxProp = fromByteArray(sha256(this.ctxProp));

Curated Repository for Closure-Compatible Packages

As demonstrated in the previous section, achieving seamless compatibility with Closure isn't straightforward for every library. To address this, Alex Eagle from the Angular Team put together the repository angular-closure-compatibility. Its purpose is to document which packages are known to work and to offer examples illustrating how to integrate them into a Closure setup.

Final Thoughts

The Closure Compiler is a sophisticated tool that can lead to significant reductions in bundle size. The Angular Package Format ensures that your own Angular libraries can interoperate with it. Because Closure relies on code adhering to certain conventions, integrating third-party packages can sometimes be tricky and require extra effort.