A common mistake in many codebases is the assumption that modules are being lazy loaded when, in fact, they are not. The culprit? Barrel files.
Barrel files are widely used, particularly in codebases that utilize NX. But what exactly are barrel files? There is a good chance you have already worked with them.
A barrel file re-exports modules from a folder, and it is conventionally named index.ts
. For example:
// index.ts
export { TransactionsComponent } from './lib/transactions.component';
export { CardsComponent } from './lib/cards.component';
In this case, we export TransactionsComponent
and CardsComponent
from the library into a single file. This creates a public API that hides all the implementation details and exposes the modules that consumers can use.
This setup seems convenient, but what is the issue with it? Let’s explore this further by creating a banking website (called demo-app
) that includes a transaction, card, and account page. The account settings page is shared between different apps, not just only demo-app
.
You would likely organize the transactions and cards pages in one library and the account settings in another library, structured like this:
├── apps
│ ├── demo-app
│ │ ├── app stuff
│ ├── other-app
│ │ ├── other app stuff
├── libs
│ ├── demo-app
│ │ ├── feature
│ │ │ ├── card.component.ts
│ │ │ ├── transactions.component.ts
│ │ │ ├── index.ts (barrel file)
│ ├── other-app
│ ├── shared
│ │ ├── feature
│ │ │ ├── account.component.ts
│ │ │ ├── index.ts (barrel file)
└── root stuff
When creating a new library with NX, a barrel file is automatically added, and the tsconfig.base.json
is updated with the appropriate path. This allows the modules to be imported from the public API.
// tsconfig.base.json
"paths": {
"@bundleperf/demo-app/feature": ["libs/demo-app/feature/src/index.ts"],
"@bundleperf/shared/feature": ["libs/shared/feature/src/index.ts"]
}
I created a basic application that displays links to the three different pages. The loadComponent
function is used to lazy load these pages (standalone components).
// app.routes.ts
export const appRoutes: Route[] = [
{
path: 'transactions',
loadComponent: () =>
import('@bundleperf/demo-app/feature').then(
(c) => c.TransactionsComponent
),
},
{
path: 'cards',
loadComponent: () =>
import('@bundleperf/demo-app/feature').then((c) => c.CardsComponent),
},
{
path: 'account',
loadComponent: () =>
import('@bundleperf/shared/feature').then((c) => c.AccountComponent),
},
];
When clicking on the Transactions
link, Angular lazy loads the Transactions
component.
As you can see in the network tab, the chunk containing the TransactionsComponent
is lazy-loaded. Now, let's navigate to the cards page by clicking on the Cards link.
The page changes, but the network activity remains the same. No new chunk is being loaded. But didn’t we lazy load it? It turns out that the chunk initially loaded includes both TransactionsComponent
and CardsComponent
.
If we closely examine the chunk, it becomes clear that the AccountComponent
is not included. Only TransactionsComponent
and CardsComponent
are present. Which happens to be both exported in the same barrel file.
To further investigate, we can add the current date to the CardsComponent
using the Luxon date library.
Refresh the page and revisit the transaction page. Let’s examine the network activity.
Suddenly, we are also loading the Luxon library, which is only used within the CardsComponent
. Why? When you import an individual API, all the files in that barrel file must be fetched and transformed because they may contain the imported API and potential side effects that run on initialization. Resulting in loading more files than necessary.
Note that with the introduction of the defer feature in Angular 17, lazy loading will not occur either.
Solution
To address this issue, one approach is to avoid using barrel files and instead import modules directly. However, this means sacrificing the ability to hide the implementation details.
A more effective solution is to create libraries with a more granular structure. This involves carefully deciding what components belong together and splitting those that do not. By adopting this approach, you will reap the benefits of improved lazy loading, as well as optimization for NX. With more granular libraries, NX can more precisely determine which code should be linted, tested, built, etc., thereby speeding up local development and pipeline processes.
In a follow-up article, I’ll delve into how you can properly split your code to maximize the benefits of lazy loading, deferred loading, and pipeline tasks. Once it’s complete, I’ll provide a link here or you can follow me for updates.
Top comments (1)
Great post! I'm curious, how would you recommend handling barrel files for large-scale applications?