Skip to main content

Code Splitting

The process of splitting application bundle into multiple chunks is called bindle splitting. There a multiple reasons why the bundle could/should be split into chunks, but most of them are related to the application performance. Splitting into code chunks could be accomplished in two different ways:

  • Static split
  • Dynamic split

In both of these scenarios multiple chunks are produced, but the purpose of using them is different.

Static split

During static split, chunks are split as a separate webpack application entry points. This way, webpack grabs all the code reachable from the particular entry point and moves it into a separate chunk. With no additional configuration, all the duplicated code across chunks will be copied to every chunk resulting total application bundle size increase. Let's see how static split is configured.

Utils

webpack/parts.ts

Change webpack/parts.ts to include an additional entry point:

entry: {
main: './index',
utils: './utils/index'
} as Entry,

In this split, we are defining an additional chunk called utils. The chunk will contain all the parts of the application code reachable from the ./utils/index entry point. If you build the app, you should see an additional chunk in the output. As mentioned above, all the code that belongs to (is reachable from) both chunks gets bundled into every chunk. Let's fix this problem.

webpack/parts.ts

Change webpack/parts.ts to include optimization section:

optimization: {
splitChunks: {
chunks: 'all'
}
}

This optimization configures SplitChunkPlugin to remove all the duplicates (to keep duplicates in one place). Strictly speaking, it is not needed for the local dev workflows, but debugging the app with no duplications is a bit less confusing.

webpack/webpack.config.dev.ts

Change webpack/webpack.config.dev.ts to include optimization part:

optimization: parts.optimization,

webpack/webpack.config.ts

Change webpack/webpack.config.ts to include optimization part:

optimization: {
...parts.optimization,

minimize: true,
removeAvailableModules: true
}

Vendors

So why would you split the app into multiple entry points? Well, statistically, some parts of the app will be changing more frequently than others. We can take advantage of this fact by splitting less frequently changing parts into separate chunks. In the previous example, we made an assumption that utils chunk will be changing less that main app chunk. The browser will cache this chunk and keep it until the new version is released. Similar idea could be applied to the third party application code (vendors). Again, statistically, third party dependencies are added more frequently in the begging of the project. With the stability of the code base, more independent parts get more stable. This makes them good candidates for browser caching, hence splitting into vendors chunk.

webpack/parts.ts

Change optimization section of webpack/parts.ts:

optimization: {
splitChunks: {
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/i,
name: 'vendors',
chunks: 'all'
}
}
},
runtimeChunk: {
name: 'vendors'
}
} as Options.Optimization

We are defining a chunk name vendors and assigning all the source files with the node_modules in the path to it.

  • splitChunks - finds modules which are shared between chunk and splits them into separate chunks to reduce duplication
  • cacheGroups - groups JavaScript modules is cache groups
  • vendors - user defined cache group (any string value that could represent a key entry within the cacheGroups object)
  • test - regex for testing file paths. Controls which modules are selected by this cache group
  • name - chunk name,
  • chunks - indicates which chunks will be selected for optimization
  • runtimeChunk - create a separate chunk for the webpack runtime code and chunk hash maps

Dynamic split

As opposite to static splitting, the purpose of dynamic splitting is to create chunks that are used less frequently by the user or to load off less important chunk to the background while the main application is loading. Chunks are created during the build time from the marks in the code, but it is up to the application logic to load them when needed.

Dependencies

To instrument webpack for creating code chunks that could be loaded conditionally we have to use JavaScript import function. Webpack understands this syntax out of the box. Unfortunately, babel does not. It is one of those case where a feature works without babel but bringing babel breaks it and it needs additional configuration.

  • @babel/plugin-syntax-dynamic-import - extends babel functionality to understand JavaScript import functionality.
npm -i @babel/plugin-syntax-dynamic-import -D --save-exact

Babel configuration

babel.config.js

Add dynamic import plugin to babel.config.js config:

plugins: [
// ...
"@babel/plugin-syntax-dynamic-import",
// ...
]

Webpack configuration

webpack/parts.ts

Change webpack/parts.ts contents:

output: {
path: path.resolve(distFolder(), 'js'),
filename: '[name].bundle.js',
publicPath: '/js/'
} as Output,

Sources

src/app/index.ts

Change src/app/index.ts to load feature code dynamically:

export type DynamicImport = Promise<{ default: () => string }>

function* func (): Generator<void | DynamicImport, void> {
const result = yield console.log('test')
console.log(result)

yield import(/* webpackChunkName: "feature" */ './feature/index')
}

export default func

We gave a feature chunk name feature by using webpack related comment webpackChunkName. By default, webpack assigns sequential numbers to anonymous chunks. This makes it a bit more difficult to understand the purpose of loaded chunks during debugging.

src/index.ts

Change src/index.ts to execute loaded chunk:

import 'core-js/stable'
import 'regenerator-runtime/runtime'

import func, { DynamicImport } from '@app'
import { toCapital } from '@utils'

const f = func()
f.next()

setTimeout((): void => {
const next = f.next(toCapital('hello world'));

(next.value as DynamicImport)
.then((data): void => {
console.log(data.default())
})
}, 3000)

Final version

Reference implementation