Granular chunks and JavaScript modules for faster page loads
Yoriiis
Posted on May 10, 2020
The race to performance increases from year to year and the front-end ecosystem is evolving more than never.
This article covers how to build a Webpack configuration to improve page load performance. Learn how to set up a granular chunking strategy to split common code. Then, serve modern code with JavaScript modules to modern browsers.
Webpack configuration
To start, the configuration has the following features:
- Multiple Page Application
- Development and production environment
- JavaScript transpilation with Babel and
preset-env
- CSS extraction
- Default optimization behavior
First, let's write our Webpack starter configuration.
webpack.config.js
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
// Export a function for environment flexibility
module.exports = (env, argv) => {
// Webpack mode from the npm script
const isProduction = argv.mode === 'production';
return {
watch: !isProduction,
// Object entry for Multiple Page Application
entry: {
home: 'home.js',
news: 'news.js'
},
output: {
path: path.resolve(__dirname, './dist/assets'),
filename: '[name].js'
},
module: {
rules: [
// Babel transpilation for JavaScript files
{
test: /\.js$/,
loader: 'babel-loader',
options: {
presets: [
[
'@babel/preset-env',
{
targets: {
browsers: ['last 2 versions']
},
// Include polyfills from core-js package
useBuiltIns: 'usage',
corejs: 3
}
]
]
}
},
// Extract content for CSS files
{
test: /\.css$/i,
use: [MiniCssExtractPlugin.loader, 'css-loader']
}
]
},
resolve: {
extensions: ['.js', '.css']
},
plugins: [
// Configure CSS extraction
new MiniCssExtractPlugin({
filename: '[name].css',
chunkFilename: '[name].css'
})
],
// Default optimization behavior depending on environment
optimization: {
minimize: isProduction
}
}
};
For more flexibility, the configuration exports a function, but other configuration types are available.
The entry
key is an object to accept multiple entries (Multiple Page Application). Each entry contains the code for a specific page of the site (ex: home, news, etc.).
The module.rules
key is an array with two rules, one for the JavaScript files and one for the CSS files.
The babel-loader
is used to transpile JavaScript with the presets from @babel/preset-env
.
💡 Browsers list
Thelast 2 versions
transpiles the code for the last two versions of every browsers. It's not the best option, choose instead a browsers list to match your real audience.
The css-loader
is used to interpret CSS files and MiniCssExtractPlugin
to extract CSS content in a dedicated file.
The plugins
array has a unique plugin MiniCssExtractPlugin
to extract CSS content.
The optimization
object has the default behavior; the minimize
option depends of the Webpack mode
(development or production).
💡 Minimizer
Theminimize
key can be replaced byminimizer
to perform optimization with TerserPlugin.
Let's add the npm scripts that will start and build Webpack:
package.json
{
"start": "webpack --mode=development",
"build": "webpack --mode=production"
}
Granular chunks
Split common code
Webpack splitChunks
allows to split common code used inside all entrypoints.
This generates one entrypoint file for JavaScript and CSS plus multiple chunk files which contain common code.
Imagine the pages share some common code for the header. Without the optimization, common code is duplicated across every entrypoints.
With the optimization, a chunk is automatically created with the shared code.
To use this option with multiple entrypoints, the easiest is to install the chunks-webpack-plugin
.
npm install chunks-webpack-plugin --save-dev
Then, update the Webpack configuration to add the plugin.
const ChunksWebpackPlugin = require('chunks-webpack-plugin');
module.exports = (env, argv) => {
return {
// ...
plugins: [
new ChunksWebpackPlugin({
outputPath: path.resolve(__dirname, './dist/templates'),
fileExtension: '.html.twig',
templateStyle: '<link rel="stylesheet" href="{{chunk}}" />',
templateScript: '<script defer src="{{chunk}}"></script>'
})
]
};
};
Enable the optimization.splitChunks
to target all
type of chunks.
module.exports = (env, argv) => {
return {
// ...
optimization: {
splitChunks: {
chunks: 'all',
name: false
}
}
};
};
💡 Build performance
EnablesplitChunks
only for production. I strongly advise you to enable thename
option for debugging.
That's all, granular chunking is done, no more configuration 🎉
Include chunk templates
Now that everything is set up, include the generated templates in the page templates.
With a multiple page application, a base layout is commonly used and pages override blocks. The layout defines the blocks. The pages includes specific files inside these blocks.
base.html.twig
<!DOCTYPE html>
<html>
<head>
{% block styles %}{% endblock %}
{% block scripts %}{% endblock %}
</head>
<body>
{% block body %}
{# Application code here #}
{% endblock %}
</body>
</html>
home.html.twig
{% extends 'base.html.twig' %}
{% block styles %}
{{ include "dist/templates/home-styles.html.twig" }}
{% endblock %}
{% block body %}{% endblock %}
{% block scripts %}
{{ include "dist/templates/home-script.html.twig" }}
{% endblock %}
news.html.twig
{% extends 'base.html.twig' %}
{% block styles %}
{{ include "dist/templates/news-styles.html.twig" }}
{% endblock %}
{% block body %}{% endblock %}
{% block scripts %}
{{ include "dist/templates/news-script.html.twig" }}
{% endblock %}
Content of these generated templates will looks like this:
home-styles.html.twig
<link rel="stylesheet" href="dist/assets/vendors~home~news.css" />
<link rel="stylesheet" href="dist/assets/home.css" />
home-scripts.html.twig
<script src="dist/assets/vendors~home~news.js"></script>
<script src="dist/assets/home.js"></script>
Script type module & nomodule
Many polyfills are not needed for modern browsers. By using modules, Babel transpilation can be avoided and bundle sizes are reduced.
HTML provides useful attributes for the <script>
tag to detect modern browsers and JavaScript modules' support.
<script type="module">
Serve JavaScript modules with ES2015+ syntax for modern browsers (without Babel transpilation).
<script src="dist/assets/modern/home.js" type="module"></script>
<script nomodule>
Serve JavaScript with ES5 syntax for older browsers (with Babel transpilation).
<script src="dist/assets/legacy/home.js" nomodule></script>
Browsers support
Browsers that support modules ignore scripts with the nomodule
attribute. And vice versa, browsers that do not support modules ignore scripts with the type="module"
attribute.
This feature is supported by all latest versions of modern browsers, see on Can I use.
⚠️ Bad guys
IE 11 and Safari 10 are a problematic and download both bundles. Discussion is in progress to fix it on Safari, see the Github Gist.
Multiple Webpack configurations
Instead of exporting a single Webpack configuration, you may export multiple configurations. Simply wrap the different object configurations inside an array.
Let's create a function to avoid code duplication between our configurations.
config-generator.js
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const ChunksWebpackPlugin = require('chunks-webpack-plugin');
const configGenerator = ({ browsers, isProduction, presets }) => {
// Custom attribute depending the browsers
const scriptAttribute = browsers === 'modern' ? 'type="module"' : 'nomodule';
return {
// The name of the configuration
name: browsers,
watch: !isProduction,
entry: {
home: 'home.js',
news: 'news.js'
},
output: {
path: path.resolve(__dirname, `./dist/assets/${browsers}`),
filename: '[name].js'
},
module: {
rules: [
{
test: /\.js$/,
loader: 'babel-loader',
options: {
// Presets depending the browsers
presets
}
},
{
test: /\.css$/i,
use: [MiniCssExtractPlugin.loader, 'css-loader']
}
]
},
resolve: {
extensions: ['.js', '.css']
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].css',
chunkFilename: '[name].css'
}),
new ChunksWebpackPlugin({
outputPath: path.resolve(__dirname, `./dist/templates/${browsers}`),
fileExtension: '.html.twig',
templateStyle: '<link rel="stylesheet" href="{{chunk}}" />',
// Custom tags depending the browsers
templateScript: `<script defer ${scriptAttribute} src="{{chunk}}"></script>`
})
],
optimization: {
splitChunks: {
chunks: 'all',
name: false
}
}
};
};
Next, the webpack.config.js
needs to export two configuration with the configGenerator
function. The first for modern browsers and the second for legacy browsers, with the different Babel presets. The presets target esmodules
browsers instead of a browsers list.
webpack.config.js
import configGenerator from './config-generator';
module.exports = (env, argv) => {
const isProduction = argv.mode === 'production';
// Modern browsers that support Javascript modules
const configModern = configGenerator({
browsers: 'modern',
isProduction,
presets: [
[
'@babel/preset-env',
{
targets: {
esmodules: true
}
}
]
]
});
// Legacy browsers that do not support Javascript modules
const configLegacy = configGenerator({
browsers: 'legacy',
isProduction,
presets: [
[
'@babel/preset-env',
{
targets: {
esmodules: false
},
useBuiltIns: 'usage',
corejs: 3
}
]
]
});
return [configModern, configLegacy];
};
When running Webpack, all configurations are built.
💡 Run a specific configuration
Add--config-name=<BROWSERS>
at the end of the npm script. Replace<BROWSERS>
by the name of the Webpack configuration (modern
orlegacy
in the example above).
Update chunk templates
Include both bundles for JavaScript to target modern and legacy browsers. For CSS, the configuration is identical for both browsers, you can import one or the other.
home.html.twig
{% extends 'base.html.twig' %}
{% block styles %}
{{ include "dist/templates/modern/home-styles.html.twig" }}
{% endblock %}
{% block body %}{% endblock %}
{% block scripts %}
{{ include "dist/templates/modern/home-script.html.twig" }}
{{ include "dist/templates/legacy/home-script.html.twig" }}
{% endblock %}
news.html.twig
{% extends 'base.html.twig' %}
{% block styles %}
{{ include "dist/templates/modern/news-styles.html.twig" }}
{% endblock %}
{% block body %}{% endblock %}
{% block scripts %}
{{ include "dist/templates/modern/news-script.html.twig" }}
{{ include "dist/templates/legacy/news-script.html.twig" }}
{% endblock %}
Conclusion
You now understand how to customize the Webpack configuration to improve page load performance.
Granular chunks with Webpack and chunks-webpack-plugin
offer a better strategy to shares common code.
Next, JavaScript modules provide minimal polyfills and smaller bundles for modern browsers.
The complete example is available on Github, so you can have fun with it! 🧑💻
Additional reading
- Improved Next.js and Gatsby page load performance with granular chunking
- Serve modern code to modern browsers for faster page loads
- last 2 versions" considered harmful
- The real power of Webpack 4 SplitChunks Plugin
Photo by @dylan_nolte on Unsplash
With thanks to Emilie Gervais for her review
Posted on May 10, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.