Code-splitting in Angular or how to share components between lazy modules
This article will give you a better understanding of how Angular splits your code into chunks.
AngularInDepth is moving away from Medium. More recent articles are hosted on the new platform inDepth.dev. Thanks for being part of indepth movement!
If you are scared from Angular CLI output showed above or if you’re curious how that code-splitting actually happens then this post is for you.
Code splitting allows you to split your code into various bundles which can then be loaded on demand. If used correctly, can have a major impact on load time.
Contents
- Why should I care?
- Angular CLI code-splitting under the hood
- Simple Angular Application with lazy modules
- How to share components between lazy modules
- Conclusion
Why should I care?
Let’s imagine you started a brand new Angular project. You read many resources on how to architect an Angular application, what is the appropriate folder structure and, what is most important, how to keep great startup performance.
You chose Angular CLI and created a modular application with lots of lazy-loaded feature modules. And of course, you created a shared module where you put commonly used directives, pipes, and components.
After a while, you caught yourself thinking that once your new feature module requires some functionality from other feature modules you tend to move this functionality to that single shared module.
The application evolves and soon you noticed that its startup time doesn’t meet your(and, most importantly, your client) expectation.
Now, you’re in doubts…
- If I put all my pipes, directives and common components in one big shared module and then import it in lazy-loaded modules (where I use only one or two of the imported features) it probably may cause unused-code duplicates in the output files.
- On the other hand, if I split shared features among several shared modules and import only those of them needed in every particular module will it reduce the size of my app? Or Angular does all such optimizations by default?
Let’s demystify!
Angular CLI code-splitting under the hood
As we all know, the current Angular CLI version uses webpack to perform bundling. But despite that, webpack is also responsible for code-splitting.
So, let’s take a look at how webpack does it.
Webpack 4 introduced SplitChunksPlugin that allows us to define some heuristics to split modules into chunks. Many people complain that this configuration seems mysterious. And at the same time, this is the most interesting part of code splitting.
But before SplitChunksPlugin optimization is applied webpack creates a new chunk:
- for every entry point
Angular CLI configures the following entry points
main
polyfills
styles
which will result in the chunks with the same names.
- for every dynamically loaded module(by using
import()
syntax that conforms to the ECMAScript proposal for dynamic imports)
Do you remember loadChildren syntax? This is the signal for webpack to create a chunk.
Now let’s move on to the SplitChunksPlugin. It can be enabled inside optimization
block of webpack.config.js
Let’s look at Angular CLI source code and find that configuration section:
We will be focusing on cacheGroups
options here since this is the “recipe” for webpack on how to create separated chunks based on some conditions.
cacheGroups
is a plain object where the key is a group name. Basically, we can think of a cache group as a potential opportunity for a new chunk to be created.
Each group has many configurations and can inherit configuration from splitChunks
level.
Let’s go real quick over those of options we saw in Angular CLI configuration above:
chunks
value can be used to filter modules between sync and async chunks. Its value can beinitial
,async
orall
.initial
means only add files to the chunk if they are imported insidesync
chunks.async
means only add files to the chunk if they are imported insideasync
chunks(async
by default)minChunks
tells webpack to only inject modules in chunk if they are shared between at least 2 chunks(1 by default)name
tells webpack to use that name for a newly created chunk. Specifying either a string or a function that always returns the same string will merge all common modules into a single chunk.priority
value is used to identify the best-matched chunks when a module falls under many chunk groups.enforce
tells webpack to ignoreminSize
,minChunks
,maxAsyncRequests
andmaxInitialRequests
options and always create chunks for this cache group. There is one small gotcha here: if any of those ignored options are provided at thecacheGroup
level then that option will still be used.test
controls which modules are selected by this cache group. As we could notice, Angular CLI uses this option to move allnode_modules
dependencies tovendor
chunk.minSize
is used to identify minimum size, in bytes, for a chunk to be generated. It didn’t appear in Angular CLI config but it is a very important option that we should be aware of. (As source code states, it’s 30kb by default in production and 10kb in dev environment)
Tip: despite the fact the webpack documentation defines defaults I would refer to webpack source code to find the exact values
Let’s recap here: Angular CLI will move a module to:
vendor
chunk if that module is coming fromnode_modules
directory.default
chunk if that module is imported inside an async module and shared between at least two modules. Note that many default chunks are possible here. I will explain how webpack generates names for those chunks later.common
chunk if that module is imported inside an async module and shared between at least two modules and did not fall under default chunk(hellopriority
) and also no matter which size it is(thanks to theenforce
option)
Enough theory, let’s practice.
Simple Angular application with lazy modules
To explain the process of SplitChunksPlugin, we are going to start with a simplified version of Angular application:
app
├── a(lazy)
│ └── a.component.ts
│ └── a.module.ts
│
├── ab
│ └── ab.component.ts
│ └── ab.module.ts
│
├── b(lazy)
│ └── b.component.ts
│ └── b.module.ts
│
└── c(lazy)
│ └── c.component.ts
│ └── c.module.ts
│
└── cd
│ └── cd.component.ts
│ └── cd.module.ts
│
└── d(lazy)
│ └── d.component.ts
│ └── d.module.ts
│
└── shared
│ └── shared.module.ts
│
└── app.component.ts
└── app.module.ts
Here a
, b
, c
and d
are lazy modules, meaning they are imported by using import()
syntax.
a
and b
components use ab
component in their templates. c
and d
components use cd
component.
The difference between ab.module
and cd.module
is that ab.module
is imported in a.module
and b.module
while cd.module
is imported in shared.module
.
This structure describes exactly the doubts we wanted to demystify. Let’s figure out where ab
and cd
modules will be in the final output.
Algorithm
1) SplitChunksPlugin’s algorithm starts with giving each previously created chunks an index.
2) Then it loops over all modules in compilation to fill chunkSetsInGraph Map
. This dictionary shows which chunks share the same code.
E.g. 1,2 main,polyfill
row means that there is at least one module that appears in two chunks: main
and polyfill
.
a
and b
modules share the same code fromab-module
so we can also notice the combination (4,5)
above.
3) Walk through all modules and figure out if it’s possible to create a new chunk for a specific cacheGroup
.
3a) First of all, webpack determines if a module can be added to specific cacheGroup
by checking thecacheGroup.test
property.
ab.module tests
default test undefined => okcommon test undefined => okvendor test function => false
default
and common
cache group didn’t define thetest
property so it should pass it. vendor
cache group defines a function where there is a filter to only include modules from thenode_modules
path.
cd.module tests are the same.
3b) Now it’s time to walk through all chunk combinations.
Each module understands in which chunks it appears(thanks to module.chunksIterable
property).
ab.module is imported into two lazy chunks. So its combinations are (4)
, (5)
and (4,5)
.
On the other hand, cd.module is imported only in theshared
module, meaning it is imported only in themain
chunk. Its combinations are only (1)
.
Then plugin filters combinations by minChunk
size:
if (chunkCombination.size < cacheGroup.minChunks) continue;
Since ab.module has the combination (4,5)
it should pass this check. This we can not say about cd.module. At this point, this module remains to live inside main chunk.
3c) There is one more check by cacheGroup.chunkds
(initial
, async
or all
)
ab.module is imported inside async(lazy loaded) chunks. This is exactly what default
and common
cache groups require. This way ab.module is added to two new possible chunks(default
and common
).
I promised it earlier so here we go.
How does webpack generate the name for a chunk created by SplitChunksPlugin?
The simplified version of that can be represented as
where:
groupName
is the name of the group(default
in our case)~
is adefaultAutomaticNameDelimiter
chunkNames
refers to the list of all chunk names which are included in that group. That name is like a fullPath path but instead of slash it uses—
.
E.g. d-d-module
means that we have d.module
file in d
folder.
So having that we usedimport('./a/a.module')
and import('./b/b.module')
we get
One more thing worth mentioning is that when the length of a chunk name reaches 109 characters, webpack cuts it and adds some hash at the end.
We’re ready to fill chunksInfoMap which knows all about all possible new chunks and also knows which modules it should consist of and related chunks where those modules currently reside.
It’s time to filter possible chunks
SplitChunksPlugin loops over chunksInfoMap’s items in order to find the best matching entry. What does it mean?
default
cache group has a priority 10
which overweightscommon
(which has only 5
). This means that default
is the best matching entry and it should be processed first.
Once all other requirements are fulfilled webpack removes all chunk’s modules from other possible chunks in chunksInfoMap dictionary. If there is no module left then the module is deleted
This way default~a-a-module~b-b-module
takes precedence over thecommon
chunk. The latter is removed since it contains the same list of modules.
Last but not least step is to make some optimizations(like remove duplications) and make sure that all requirements like maxSize
are fulfilled.
The entire source code of SplitChunksPlugin can be found here
We’ve discovered that webpack creates chunks in three different ways:
- for each entry
- for dynamically loaded modules
- for shared code with the help of SplitChunksPlugin
Now let’s go back to our doubts about what is the best way to keep shared code.
How to share components between lazy modules
As we’ve seen in our simple Angular application, webpack created separated chunk for ab.module
but included cd.module
in the main
chunk.
Let’s summarize key takeaways from this post:
- If we put all shared pipes, directives and common components in one big shared module and then import it everywhere(inside sync and async chunks) then that code will be in our initial
main
chunk. So if you want to get a bad initial load performance then it’s the way to go. - On the other hand, if we split commonly used code across lazy loaded modules then a new shared chunk will be created and will be loaded only if any of those lazy modules are loaded. This should improve the application initial load. But do it wisely because sometimes it’s better to put small code in one chunk that having the extra request needed for a separate chunk load.
Conclusion
I hope now you should clearly understand the output of Angular CLI and distinguish between entry, dynamic and splitted by using SplitChunksPlugin chunks.
Happy coding!