Angular library lazy loading
In this article, I wanted to focus on some of the options available with Angular to lazy load elements from a library, and how it affects the build and the bundles.
Our application will consist of a typical Angular workspace, nothing fancy, with at least one application and one library. So let’s build this. We will start with angular 16 and I will show why later.
npx -p @angular/cli@16 ng new ngLazy --create-application false
Then we create a standard application, it’s not standalone for now.
ng g application ng-app --style scss --routing true
The different ways to lazy load a library.
A feature in the application
First, let’s create a feature in our application. For now the feature will be module based, but that’s not so important regarding our issue here.
ng g m users --routing -- route users -m app-routing
We should have something like that :
const routes: Routes = [
{
path: '', component: HomeComponent,
},
{
path: 'users',
loadChildren: () => import('./users/users.module')
.then((m) => m.UsersModule),
},
j];
But the most important here is :
- When accessing our feature through the route
/users
we should load the js bundle that contains the code of this module.
- When we change something inside one of the elements of this module, it should only rebuild the specific code (along with the runtime but only if you run
ng serve
command, try and runng build
instead)
A feature in a library
Let’s create a library that will hold a whole feature ‘orders’ :
ng g library orders
Now we have to declare routing in this orders
library, link the default route to the OrdersComponent, and then remove this component from the public-api
surface exposition (you would want to expose only the component that will be explicitely used in the application).
Then our workspace package.json will be configured to ‘build watch’ the library and in the same time, ‘ng start’ the application :
"scripts": {
"watch:orders": "ng build orders --watch --configuration development",
"start": "ng serve ng-app --port 4400"
}
Now, every time we change something inside one of the elements of this library, the build will be specific and produce the bundle that will be lazy loaded.
Note : if you remove elements from your
public-api.ts
file, you should see the weight of the bundle evolve accordingly. So get rid of anything purely internal.
A more complex library
Now, imagine we don’t want only one feature in the library but another one, say bills
feature. Let’s refactor the folders and create this new feature :
Alright, this looks good, lets wire this up and display the appropriate link in our application.
The first thing to notice is the lack of specificity of the import
statement, both ‘bills’ and ‘orders’ come from the same namespace, that’s not very cute. The other caveat shows when building the application :
We have 2 features but only one bundle, when we update the code of either of the features, it’s all reprocessed. And indeed when we navigate to one of the routes, the first one does load the chunk, but it also loads the other one, even if we never access the route. How can we break this ?
Note: of course you can create as many libraries as you want to organise your features or “domains”, but let’s suppose that we want to keep our library unique, right ;)
Secondary entrypoints to the rescue
Secondary entrypoints in Angular libraries are a common feature that serve multiple purposes :
- Have specific names for the imports in the applications
- Separate the build and therefore make it faster
- Create separated bundles that allow a better tree shaking in the applications
- And as a result of all this, allow a single library to expose multiple features ready for lazy loading
To create a secondary entrypoint, it’s very easy :
- Create a folder
bills
in the root of the library, with a subfoldersrc
for conveniency - Create the 3 following files in this folder :
public-api.ts
index.ts
ng-package.json
- Move all the code from
bills
feature in thebills/src
newly created.
The content of the 3 root files are as follow :
Note that you still have to be careful with the components you decide to expose in your surface ‘bills’ public-api.
Now, once you refactored the library, for local build, you should ensure that whatever entrypoint in the library is handled by tsconfig
file :
"paths": {
"orders": [
"dist/orders"
],
"orders/*": [
"dist/orders/*"
],
},
This allows us to alter our imports in the routing of the application :
And of course, the build reflects these settings :
Which means that now, only the dedicated bundle will rebuild when working on the components of this specific feature.
Access secondary entrypoint code
Now, let’s add some code to our feature, and monitor the effect on the build. For a start, we add a simple dto interface in ‘bills’ feature, and create a Bill in our application.
import { Bill } from 'orders/bills';
@Component({
...
})
export class AppComponent {
title = 'Orders Application';
bill: Bill = { id: '1', name: 'Archery supplies', amount: 123.45,
date: new Date(),
};
}
No effect on the build and the lazy loading, that’s fortunate because interfaces are not supposed to be produced in the js bundle.
Let’s add some code to a bills service, for example, on the front page we want the number of pending bills and on the bills page, all the bills. So we need to export the service in our feature public-api
file, and then use it in our app component :
But, as soon as you do that, you create adherence between the application and the library entrypoint. The original orders
feature is untouched, but the code of the bills
bundle is now back in the main bundle :
That means that we don’t want to expose any code out of the feature if we want to keep it lazy loaded, so maybe we will refactor our library and have a core entrypoint in the library and separate secondary entrypoints for our features. This way we won’t confuse what is to be used by the application.
Now, the library is called core
, not that it’s very clever, but that allows us to create 2 separate entrypoints dedicated to our lazy loaded features. The build reflects the refactoring with separate bundles.
In the next article, we will update to Angular 17 and move to the latest builder and see how optimized it will be.
Next part
Reference
Find all the steps for this article in the commits of the repo :