Let’s talk about code splitting in Angular, lazy-loading and a sprinkle of Webpack. Code splitting allows us to essentially break our codebase down into smaller chunks and serve those chunks on demand, which we call “lazy loading”. So, let’s learn how to do it and some of the concepts/terminology behind it.
Want the code? Go straight to GitHub or view the live demo
The above .gif
demonstrates lazy loading, you can see 0-chunk.js
and 1-chunk.js
are both fetched over the network when navigating to these routes. The above recording is also AoT compiled.
Table of contents
Terminology
For some further clarity let’s cover some of the terminology.
Code splitting
Code splitting is the process of, putting it very obviously, splitting our code. But what, how and where do we split? We’ll figure this piece out as we progress through the article, but code splitting allows us to essentially take our full application bundle, and chop it up into different pieces. This is all code splitting is, and Webpack allows us to do it super easily with a loader for Angular. In a nut shell, your application becomes lots of small applications, which we typically call “chunks”. These chunks can be loaded on demand.
Lazy loading
This is where “on demand” comes into play. Lazy loading is the process in taking already “code split” chunks of our application, and simply loading them on demand. With Angular, the router is what allows us to lazy load. We call it “lazy” because it’s not “eagerly” loading - which would mean loading assets upfront. Lazy loading helps boost performance - as we’re only downloading a fraction of our app’s bundle instead of the entire bundle. Instead, we can code split per @NgModule
with Angular, and we can serve them lazily via the router. Only when a specific route is matched, Angular’s router will load the code split module.
Webpack setup
Setting up the Webpack side of things is fairly trivial, you can check the full config to see how everything hangs together, but essentially we need just a few key pieces.
Choosing a router loader
You may wish to use the angular-router-loader or ng-router-loader to accomplish your lazy loading mission - I’m going to roll with the former, angular-router-loader
as it’s pretty simple to get working and both cover the base set of features we’d need for lazy loading.
Here’s how I’ve added it to my Webpack config:
{
test: /\.ts$/,
loaders: [
'awesome-typescript-loader',
'angular-router-loader',
'angular2-template-loader'
]
}
Free eBook
Directives, simple right? Wrong! On the outside they look simple, but even skilled Angular devs haven’t grasped every concept in this eBook.
- Observables and Async Pipe
- Identity Checking and Performance
- Web Components <ng-template> syntax
- <ng-container> and Observable Composition
- Advanced Rendering Patterns
- Setters and Getters for Styles and Class Bindings
Here I’m including the angular-router-loader
in the loaders array for TypeScript files, this will kick things off and let us use the awesome loader to lazy load! The next step is the output
property on our Webpack config:
output: {
filename: '[name].js',
chunkFilename: '[name]-chunk.js',
publicPath: '/build/',
path: path.resolve(__dirname, 'build')
}
This is where we can specify our “chunk” names, which are drive dynamically and typically end up looking like:
0-chunk.js
1-chunk.js
2-chunk.js
3-chunk.js
Check the full config again if necessary for tying it together in perhaps your own Webpack configuration.
Lazy @NgModules
To illustrate the setup as shown in the live demo and gif, we have three feature modules that are identical, apart from renaming of the module and components to suit.
Feature modules
Feature modules, aka child modules, are the modules that we can lazy load using the router. Here are the three child module names:
DashboardModule
SettingsModule
ReportsModule
And the parent, app module:
AppModule
The AppModule
has the responsibility at this point to somehow “import” those other modules. There are a few ways we can do this, asynchronously and synchronously.
Async module lazy loading
We look to the router to power our lazy loading, and all we need for it is the magical loadChildren
property on our routing definitions.
Here’s the ReportsModule
:
// reports.module.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
// containers
import { ReportsComponent } from './reports.component';
// routes
export const ROUTES: Routes = [{ path: '', component: ReportsComponent }];
@NgModule({
imports: [RouterModule.forChild(ROUTES)],
declarations: [ReportsComponent],
})
export class ReportsModule {}
Note how we’re using an empty path
:
// reports.module.ts
export const ROUTES: Routes = [{ path: '', component: ReportsComponent }];
This module can then be used together with loadChildren
and path
in a parent module, letting AppModule
dictate the URL. This creates a flexible module structure where your feature modules are “unaware” of their absolute path, they become relative paths based on the AppModule
paths.
This means that inside app.module
, we can do this:
// app.module.ts
export const ROUTES: Routes = [
{ path: 'reports', loadChildren: '../reports/reports.module#ReportsModule' },
];
This says to Angular “when we hit /reports
, please load this module”. Note how the routing definition inside the ReportsModule
is an empty path, this is how it’s achievable. Similarly, our other routing definitions are also empty:
// reports.module.ts
export const ROUTES: Routes = [
{ path: '', component: ReportsComponent }
];
// settings.module.ts
export const ROUTES: Routes = [
{ path: '', component: SettingsComponent }
];
// dashboard.module.ts
export const ROUTES: Routes = [
{ path: '', component: DashboardComponent }
];
The full picture of the AppModule
routing definitions:
export const ROUTES: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'dashboard' },
{
path: 'dashboard',
loadChildren: '../dashboard/dashboard.module#DashboardModule',
},
{
path: 'settings',
loadChildren: '../settings/settings.module#SettingsModule',
},
{ path: 'reports', loadChildren: '../reports/reports.module#ReportsModule' },
];
This means at any moment in time, we can “move” an entire module under a new route path and everything will work as intended to, which is great!
Notice in the recording below how
*-chunk.js
files are being loaded in as we navigate to these particular routes
We call this “lazy loading” when we make the call to a chunk asynchronously. When using loadChildren
and the string value to point to a module, these will typically load async, unless using the loader you specify sync loading.
Sync module loading
If, like in my application, your base path redirects to another route - like this:
{ path: '', pathMatch: 'full', redirectTo: 'dashboard' },
You have a potential area to specify one module to be loaded synchronously. This means it’ll be bundled into your app.js
(in my case, this may change depending on the depth in feature modules you are lazy loading). As I’m redirecting straight away to DashboardModule
, is there any benefit to me chunking it? Yes and no.
Yes: if the user goes to /settings
first (page refresh), we don’t want to load even more code, so there’s again an initial payload savings here.
No: this module may be used most frequently, so it’s probably best to load it upfront eagerly.
Both yes/no depend on your scenario, however.
Here’s how we can sync load our DashboardModule
using an import
and arrow function:
import { DashboardModule } from '../dashboard/dashboard.module';
export const ROUTES: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'dashboard' },
{ path: 'dashboard', loadChildren: () => DashboardModule },
{
path: 'settings',
loadChildren: '../settings/settings.module#SettingsModule',
},
{ path: 'reports', loadChildren: '../reports/reports.module#ReportsModule' },
];
I prefer this way as it’s more implicit for the intentions. At this point, DashboardModule
would be bundled in with AppModule
and served up in app.js
. You can try it yourself by running the project locally and changing things.
The angular-router-loader
project has a nice feature also which is worth mentioning for a custom syntax which dictates which modules are loaded sync by appending ?sync=true
to our string:
loadChildren: '../dashboard/dashboard.module#DashboardModule?sync=true';
This has the same effects as using the arrow function approach.
Performance
With a simple application demo like mine, you’re not really going to notice a performance increase, however with a bigger application with a nice sized codebase, you’ll benefit greatly from code splitting and lazy loading!
Lazy loading modules
Let’s imagine we have the following:
vendor.js [200kb] // angular, rxjs, etc.
app.js [400kb] // our main app bundle
Now let’s assume we code split:
vendor.js [200kb] // angular, rxjs, etc.
app.js [250kb] // our main app bundle
0-chunk.js [50kb]
1-chunk.js [50kb]
2-chunk.js [50kb]
Again, on a much bigger scale, the performance savings would be huge for things such as PWAs (Progressive Web Apps), initial network requests and severely decrease initial payloads.
Preloading lazy modules
There’s another option we have, the PreloadAllModules feature that allows Angular, once bootstrapped, to go and fetch all the remaining module chunks from your server. This could again be part of your performance story and you choose to eagerly download your chunked modules. This would lead to faster navigation between different modules, and they download asynchronously once you add it to your root module’s routing. An example of doing this:
import { RouterModule, Routes, PreloadAllModules } from @angular/router;
export const ROUTES: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'dashboard' },
{ path: 'dashboard', loadChildren: '../dashboard/dashboard.module#DashboardModule' },
{ path: 'settings', loadChildren: '../settings/settings.module#SettingsModule' },
{ path: 'reports', loadChildren: '../reports/reports.module#ReportsModule' }
];
@NgModule({
// ...
imports: [
RouteModule.forRoot(ROUTES, { preloadingStrategy: PreloadAllModules })
],
// ...
})
export class AppModule {}
In my application demo, Angular would bootstrap then go ahead and load the rest of the chunks by using this approach.
View the full source code on GitHub or check out the live demo!
I highly recommend trying these out and seeing the different scenarios available to you so you can paint your own performance picture.