Code-splitting in Angular or how to share components between lazy modules

Alexey Zuev
Angular In Depth
Published in
9 min readJan 21, 2020

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

  1. Why should I care?
  2. Angular CLI code-splitting under the hood
  3. Simple Angular Application with lazy modules
  4. How to share components between lazy modules
  5. 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.

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:

SplitChunksPlugin configuration in Angular CLI 8

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 be initial, async or all. initial means only add files to the chunk if they are imported inside sync chunks. async means only add files to the chunk if they are imported inside async 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 ignore minSize, minChunks, maxAsyncRequests and maxInitialRequests options and always create chunks for this cache group. There is one small gotcha here: if any of those ignored options are provided at the cacheGroup 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 all node_modules dependencies to vendor 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 from node_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(hello priority) 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.

Dependencies between Angular modules

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.

chunks by index

2) Then it loops over all modules in compilation to fill chunkSetsInGraph Map . This dictionary shows which chunks share the same code.

chunkSetsInGraph

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 a defaultAutomaticNameDelimiter
  • 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

Structure of default chunk name

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.

Structure of big chunk name that shares code across multiple lazy modules

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.

chunksInfoMap

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
Angular CLI output by type of chunks

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!

--

--