Organizing Angular Packages, ES2015 Barrels, and Ahead-of-Time Compilation for Speed / Compactness

by
Tags: , , , , ,
Category:

About Barrels and Modules in Angular

While you're beginning to work out how to organize your applications in Angular 2 (or 4, 5, whatever), you'll quickly realize you can make a mess of your code, and you'll want to start organizing it in a less-than-haphazard manner.

Take an application with a source folder that is rather co-mingled:

src/
  management-app/
    models.ts
    customer-list.component.ts
    customer-edit.component.ts
    customer-data.service.ts
    department-list.component.ts
    department-edit.component.ts
    department-data.service.ts
    order-number-formatter.pipe.ts
    security.service.ts
    login-form.component.ts
  management-app.component.ts

Whoa, that's a load of files. All together!

Let's do the easiest refactor, splitting up monolithic TypeScript source files.

One key object/class per file

Assuming we have the Customer, Department and Credentials class definitions in models.ts, We can do this:

src/
   management-app/
    models/
        customer.ts
        department.ts
        credentials.ts

So there, now we can reason about our models easier, the new developers in the group know what file each is stored in, and sanity reigns. So far, at least.

Splitting horizontally by tier

Now we have another choice: where to hold all of our components and services. There are several ways. For example, you could split the files up by their general type (component, model, pipe, etc):

src/
  management-app/
    components/
        customer-form.component.ts
        customer-list.component.ts
        department-form.component.ts
        department-list.component.ts
    models/
        customer.ts
        department.ts
        credentials.ts
    pipes/
        order-number-formatter.pipe.ts
    services/
        customer-data.service.ts
        security-data.service.ts
  management-app.component.ts

Partitioning code by Feature

For a small application and especially for a beginner, this may be tempting. However, as the application grows, this strategy makes it tough to lift slices of functionality and move them around.

Instead, we can organize our functions by feature. Here's the slice for customers:

src/
   management-app/
    customer/
      customer.ts
      customer-form.component.ts
      customer-list.component.ts
      customer-data.service.ts
    department/
      ...
   management-app.component.ts

Compelling to have it all together, isn't it? Any pipes that focus on customers can go there too. Sure, it might get crowded, but then you could further subdivide by functional area:

src/
   management-app/
    customer/
        customer.ts
        edit/
            customer-form.component.ts
        display/
            customer-list.component.ts
            customer-list.ts
        customer-data.service.ts
  ...

That's assuming that the data service's features cut across all of the components that need it in the customer folder. You could even slice the services further — those complex filtering REST calls do get a bit long – especially for things like querying by example.

src/
   management-app/
     customer/
        customer.ts
        edit/
            customer-form.component.ts
            customer-data.service.ts
        view/
            customer-list.component.ts
            customer-query-data.service.ts
        customer-data.service.ts
    department/
   ...
   management-app.component.ts

Shared services, pipes and types

We can perform at least one more optimization here. We have a pipe that likely is used across the application, OrderNumberFormatterPipe, and a security service that likely will be used to assert application entitlements, SecurityService. Also, our models really belong at a higher level if they are cross-cutting in the application too. So we can create a common package…

src/
    management-app/
      common/
        models/
          customer.ts
          department.ts
          credentials.ts
        services/
          security.service.ts
        pipes/
          order-number-formatter.pipe.ts
      customer/
         edit/
           customer-edit.component.ts
           customer-data.service.ts
         display/
           customer-list.component.ts
           customer-query-data.service.ts

Programmer, your tangles are showing…

All right. Now we've spent some time moving our code around, and we have a somewhat reasonable setup.

However, now we want to set up an NgModule for the application, at the management-app level. Naively, we do something like this:

@NgModule({
  declarations: [
    CustomerFormComponent,
    CustomerListComponent
    DepartmentFormComponent,
    DepartmentListComponent,
    OrderNumberFormatPipe
  ],
  providers: [
    DepartmentDataService,
    CustomerQueryDataService,
    CustomerDataService
  ]
})

What's wrong with this picture?

Well, first, the module is huge. If we have one giant @NgModule for our application, we have a big config file, and we certainly aren't writing it with modularity in mind.

But even worse, look at the imports we'd need to do to mount this module in the file:

import { CustomerFormComponent } from './customer/edit/customer-form.component';
import { CustomerListComponent } from './customer/display/customer-list.component';
import { DepartmentFormComponent } from './department/edit/department-form-component';
import { DepartmentListComponent } from './department/display/department-list-component';

…and so on. We aren't even 1/2 way through the imports. Wouldn't it be nice do to something like this:

import * from './customer';
import * from './department';
import * from './common';

and then just use the automatically imported classes in your @NgModule?

Over the Angular lake in a Barrel

Let's slay some import complexity by building TypeScript barrel files. A barrel is a file that encapsulates the complexity of importing files from various source trees.

You're already using barrels with RxJS, @angular/core, etc… Each of these npm modules starts with an outer barrel file, one that exports all imported files from its subdirectories.

To show you how to do this, I'll work the tree from the inside out. Let's take the common folder:

src/
   management-app/
     common/
        models/
           index.ts
           customer.ts
           department.ts
           credential.ts

Our src/management-app/common/models/index.ts file should look something like this:

export * from './customer';
export * from './department';
export * from './credential';

And now we can import these models in our classes in two ways (assuming our relative path is a sibling of the common directory:

import { Customer, Department } from '../common/models';

ES2015 Modules, Angular NgModules and Barrels together

The Holy Grail of this effort is composing folders of components / use cases, barrels for each of the folders, NgModules for the components with services and/or use cases, and keeping your ES2015 modules (files) atomic.

There are several reasons to do this

You only import the level of content you need

Consider a large component library (like Angular Material 2) that contains a ton of components.

The Angular Material team recommends that you only import the components you use. This keeps the final target file smaller than if you imported the entire MaterialModule (which is marked as deprecated).

For example, here's the size of the Webpack-generated output of the base distribution living in dist from the ng build command, which uses just-in-time code compilation:

...
-rw-r--r--  1 krimple  staff   6.4K Apr  9 19:45 main.bundle.js
-rw-r--r--  1 krimple  staff   4.1K Apr  9 19:45 main.bundle.js.map
...
-rw-r--r--  1 krimple  staff   2.1M Apr  9 19:45 vendor.bundle.js
-rw-r--r--  1 krimple  staff   2.6M Apr  9 19:45 vendor.bundle.js.map

This is with Angular 4, and the 1.0.0 released @angular/cli. We get a (unoptimized development) payload in vendor.bundle.js of 2.1 Megabytes.

Now we'll add a module from the MaterialDesign project – a MdProgressBarComponent, to our module:

import { MdProgressBarModule } from '@angular/material';
import { AppComponent } from './app.component';
@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserAnimationsModule,
    MdProgressBarModule, <-- added this
    BrowserModule,
    FormsModule,
    HttpModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

and we'll now add it to our app.component.html view file:

<md-progress-bar ng-value="20"></md-progress-bar>

Let's do this again…

...
-rw-r--r--  1 krimple  staff   6.7K Apr  9 19:44 main.bundle.js
-rw-r--r--  1 krimple  staff   4.1K Apr  9 19:44 main.bundle.js.map
..
-rw-r--r--  1 krimple  staff   3.1M Apr  9 19:44 vendor.bundle.js
-rw-r--r--  1 krimple  staff   3.7M Apr  9 19:44 vendor.bundle.js.map

That's another 1/2 megabyte in vendor.ts to load and parse (from 2.6M to 3.1M) when adding the baseline Material Design library and using one component.

The Material library makes us mount each component's module, rather than using a single large module. This may seem tedious, but it is the right thing to do, and can make for gains in reducing the size of the final vendor.bundle.js script.

Tip – define fine-grained NgModules

Follow the Material Design team's philosophy: if you define the entire library as a single module, and then import that module into your application, you will get the ability to pull small components from the larger twhole.

Optimizing with the Ahead-of-Time compiler

With the latest Angular CLI (greater than v1.0.0), the production environment is built using an ahead-of-time (AOT) compiler. This compiler follows the imports and eliminates chunks of code that aren't being used. It also inlines all templates. Applications built with AOT launch much faster than those that are not, and on Webpack platforms (like those built in Angular's CLI) you can split modules into chunks and even lazy-load them with the Angular Router.

The good news about CLI projects is the fact that they can be built with AOT.

Let's compare the default (non-Material-Design) build size with ng build -prod (which initiates the AOT compiler):

-rw-r--r--  1 krimple  staff   9.2K Apr  9 20:28 main.c13ca390136c04b0e96c.bundle.js
...
-rw-r--r--  1 krimple  staff   333K Apr  9 20:28 vendor.26631cbb422134268f3d.bundle.js

As you can see, we do away with the map files, the output files include a unique random value to cache-bust them, and the size of the default build is 333K.

Now we add in the full Material Design library (by importing the MaterialModule and MdProgressBarModule) modules (our test included the progress bar as our Material component). Same build as before, let's see what our vendor file looks like:

-rw-r--r--  1 krimple  staff   207K Apr  9 21:57 main.57b204d0b0ce98f9c147.bundle.js
...
-rw-r--r--  1 krimple  staff   713K Apr  9 21:57 vendor.a6ebe15951d73a9206ec.bundle.js

MUCH better than the JIT version, about 1/2 of the increase. Yes, we've increased the size of vendor.ts and of our application significantly but much less so than the JIT versions.

Once you start going down the production readiness route, it's probably time to eject your Angular CLI configuration into a full-blown Webpack and npm-based build. Starting with the first production release of @angular/cli you can use the eject command, but remember: once ejected, your project ceases to be controlled by the CLI and it's up to you to configure the build tooling going forward:

# eject the standard JIT-based build
ng eject
# eject the build with aot tooling
ng eject --aot

At this point you can start chunking up the application into separate loadable modules in addition to the vendor, main, polyfills and other modules that the CLI config has pre-built. In addition you have access to the webpack development server, and a set of npm commands that let you build and test your application.

Caution with AOT mode

If you go down the road of working in AOT-based builds, there are some restrictions you should be aware of, such as:

  • Types that are exported as part of the public class definition must be inferable from the AOT compiler. That means we can't hide the types themselves behind functions, or determine them at runtime. It can be a challenge to infer what's going on during builds as the early error messages are cryptic to say the least. Start with atomic changes and back out of your experimental branch changes as they fail. And keep your code simple, which is always a good thing…
  • Instead of defining a class and then exporting it, you will have to export the class directly, as in export class Foo { ... }
  • Instead of using a lambda or local function to define a factory for custom dependency injection, you should export the function too
  • You will likely want to run JIT mode for development and AOT mode for final testing, QA and Prod, so expect that you'll possibly maintain several Webpack configurations.

There are more subtleties, so I recommend piloting this in a testing branch of your application first.

Short tutorial on AOT compilation

I’ve put together a short tutorial on using AOT compilation on our YouTube channel:




Wrap-up

Building a large-scale application with Angular is quite possible, and the tooling exists to handle well-organized code. If you make sure to separate your features vertically, partition it into folders and provide modules for each major component, you can properly configure it at runtime and enable efficient AOT-based builds using Webpack.

Additional Reading

  • Angular.io AOT Cookbook – good for overall background. The comments on rollup apply to SystemJS projects and Angular component libraries (like Material Design).

  • Angular Quickstart – The SystemJS-based platform for the Angular docs. Useful if you go deeper in the above doc and want to play with Rollup.

  • NgTools Webpack – The Angular team's AOT compiler for integrating into Webpack. It's the one you get automatically when you use the CLI and build to -prod for your environment or eject the configuration.