Optimizing TaskMuncher with Webpack 4
by John Vincent
Posted on November 3, 2018
Let's discuss updating Webpack 4 TaskMuncher Optimizations.
Optimizing TaskMuncher
For extensive discussions regarding TaskMuncher, please see TaskMuncher Overview
For a prelude to this discussion, please see Update TaskMuncher to Webpack v4, Babel v7, Material-UI v3
The optimization of TaskMuncher led to TaskMuncher Performance
Code Splitting
Code splitting is one of the most compelling features of webpack. This feature allows you to split your code into various bundles which can then be loaded on demand or in parallel. It can be used to achieve smaller bundles and control resource load prioritization which, if used correctly, can have a major impact on load time.
Lazy Loading
Lazy, or "on demand", loading is a great way to optimize your site or application. This practice essentially involves splitting your code at logical breakpoints, and then loading it once the user has done something that requires, or will require, a new block of code. This speeds up the initial load of the application and lightens its overall weight as some blocks may never even be loaded.
TaskMuncher
The techniques are beyond the scope of this document. There are many very good sources.
The following briefly describes the TaskMuncher implementation.
React Loadable
react-loadable
is a higher-order component for loading components with dynamic imports. I chose react-loadable
as it is so simple to implement.
Add the following:
npm i react-loadable --save
npm i @babel/plugin-syntax-dynamic-import --save-dev
Add to .babelrc
{
"plugins": [
"@babel/plugin-syntax-dynamic-import"
]
}
thus now have
{
"presets": ["@babel/env", "@babel/react"],
"plugins": [
"@babel/proposal-object-rest-spread",
"@babel/proposal-class-properties",
"@babel/plugin-syntax-dynamic-import"
]
}
webpack.config.js
const path = require('path');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const copyWebpackPluginOptions = 'warning'; // info, debug, warning
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const transforms = require('./transforms');
const SCSS_FOLDER = path.resolve(__dirname, './scss');
const ICONS_FOLDER = path.resolve(__dirname, './icons');
const DIST_FOLDER = path.resolve(__dirname, './dist');
const INCLUDE_SCSS_FOLDER = path.resolve(__dirname, './src');
const HTMLPlugin = new HtmlWebpackPlugin({
template: './templates/index.hbs',
file: './index.html',
hash: false,
chunksSortMode: 'none',
// inlineSource: 'manifest~.+\\.js',
HOME_URL: transforms.HOME_URL,
TITLE: transforms.TITLE,
DESCRIPTION: transforms.DESCRIPTION,
KEYWORDS: transforms.KEYWORDS,
AUTHOR: transforms.AUTHOR,
AUTHOR_IMAGE: transforms.AUTHOR_IMAGE,
TWITTER_USERNAME: transforms.TWITTER_USERNAME,
GOOGLE_PROFILE: transforms.GOOGLE_PROFILE,
GOOGLE_SITE_VERIFICATION: transforms.GOOGLE_SITE_VERIFICATION,
GOOGLE_ANALYTICS_UA: transforms.GOOGLE_ANALYTICS_UA,
GOOGLE_ANALYTICS_URL: transforms.GOOGLE_ANALYTICS_URL,
FACEBOOK_APP_ID: transforms.FACEBOOK_APP_ID
});
const extractSCSSBundle = new MiniCssExtractPlugin({
filename: '[name].[contenthash].css',
chunkFilename: '[id].[contenthash].css'
});
const PRODUCTION_MODE = process.env.NODE_ENV === 'production';
const config = {};
config.entry = ['./src/index.jsx', './scss/styles.scss'];
config.optimization = {
splitChunks: {
cacheGroups: {
commons: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
chunks: 'initial'
}
}
},
runtimeChunk: {
name: 'manifest'
},
minimizer: [
new UglifyJsPlugin({
sourceMap: true,
uglifyOptions: {
ecma: 8,
mangle: false,
keep_classnames: true,
keep_fnames: true
}
})
]
};
config.plugins = [
// list all React app required env variables
new webpack.EnvironmentPlugin(['NODE_ENV', 'GOOGLE_APP_ID']),
HTMLPlugin,
// create css bundle
extractSCSSBundle,
// copy images
new CopyWebpackPlugin([{ from: 'src/images', to: 'images' }], {
debug: copyWebpackPluginOptions
}),
// copy static assets
new CopyWebpackPlugin([{ from: 'static/sitemap.xml', to: '.' }], {
debug: copyWebpackPluginOptions
}),
new CopyWebpackPlugin([{ from: 'static/google9104b904281bf3a3.html', to: '.' }], {
debug: copyWebpackPluginOptions
}),
new CopyWebpackPlugin([{ from: 'static/robots.txt', to: '.' }], {
debug: copyWebpackPluginOptions
}),
new CopyWebpackPlugin([{ from: 'static/favicon_package', to: '.' }], {
debug: copyWebpackPluginOptions
})
];
config.module = {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
loader: 'babel-loader'
},
{
test: /\.(sass|scss)$/,
include: INCLUDE_SCSS_FOLDER,
exclude: [SCSS_FOLDER, /node_modules/],
use: ['style-loader', 'css-loader', 'sass-loader']
},
{
test: /\.(sass|scss)$/,
include: SCSS_FOLDER,
exclude: [INCLUDE_SCSS_FOLDER, /node_modules/],
use: [
{
loader: MiniCssExtractPlugin.loader
},
{
loader: 'css-loader',
options: {
sourceMap: true,
modules: true,
localIdentName: '[local]___[hash:base64:5]'
}
},
{
loader: 'sass-loader'
}
]
},
{
test: /\.(png|jpg|jpeg|gif|ttf|eot|svg|woff(2)?)(\?[a-z0-9=&.]+)?$/,
// include: [FONTS_FOLDER, ICONS_FOLDER],
include: [ICONS_FOLDER],
loader: 'file-loader?name=assets/[name].[ext]'
}
]
};
config.resolve = {
extensions: ['.js', '.jsx']
};
if (PRODUCTION_MODE) {
config.output = {
path: DIST_FOLDER,
publicPath: '/',
chunkFilename: '[name].[chunkhash].bundle.js',
filename: '[name].[chunkhash].bundle.js'
};
config.mode = 'production';
config.devtool = 'cheap-module-source-map';
}
if (!PRODUCTION_MODE) {
config.output = {
path: DIST_FOLDER,
chunkFilename: '[name].bundle.js',
filename: '[name].bundle.js'
};
config.mode = 'development';
config.devtool = 'inline-source-map';
config.devServer = {
contentBase: DIST_FOLDER,
compress: false,
// inline: true,
port: 8065,
clientLogLevel: 'info',
proxy: {
'/api/**': {
target: 'http://localhost:3001',
changeOrigin: true,
secure: false
}
}
};
}
module.exports = config;
config.optimization
This code splits the code into bundles
- vendor - node_modules
- manifest - describes what files webpack should load
- main - application code
Will also split into bundles code that is lazy loaded.
config.output
Note that production uses
chunkFilename: '[name].[chunkhash].bundle.js',
filename: '[name].[chunkhash].bundle.js'
which will generate a bundle with a different filename if the contents of the file have changed. This will ensure that cached files will not be referenced by the browser if a newer version is available. This is called Cache Busting.
Cache busting is not useful in development, thus
chunkFilename: '[name].bundle.js',
filename: '[name].bundle.js'
html-webpack-plugin
This plugin interacts with the above optimizations. The bundles that have been created are added by the plugin to the index.html
<link href="/1.42284c7d3da4035df30b.css" rel="stylesheet"></head>
<body>
<div id="root"></div>
<script type="text/javascript" src="/main.211380e843cdd5ab81b9.bundle.js"></script>
<script type="text/javascript" src="/vendor.348028e469699b4c4e1b.bundle.js"></script>
<script type="text/javascript" src="/manifest.87a618c1f83c717e6bdd.bundle.js"></script>
</body>
</html>
Lazy Loading
Now the table is set to split up the application code and lazy load some parts of the application.
Code Splitting by Routes
An obvious place to split up the code. The non-logged in user and member code are different so let's split them up.
Non Member including Home Page
All this code is in HomeMain.jsx
and is only referenced from routes/index.jsx
. The changes are straightforward.
Remove the direct reference
// import HomeMain from '../components/containers/HomeMain';
Add reference to react-loadable
import Loadable from 'react-loadable';
Add reference to a Loading
component
import Loading from '../components/containers/Loading';
Create the Loading
component
import React from 'react';
import PropTypes from 'prop-types';
const Loading = props => {
const { pastDelay } = props;
return <span>{pastDelay ? <h3>Loading...</h3> : null}</span>;
};
Loading.propTypes = {
pastDelay: PropTypes.bool.isRequired
};
export default Loading;
Create an Async component AsyncHomeMain
which will be invoked to lazy load HomeMain
const AsyncHomeMain = Loadable({
loader: () => import('../components/containers/HomeMain'),
loading: Loading,
delay: 200,
render(loaded, props) {
const Component = loaded.default;
return <Component {...props} />;
}
});
Change all the routes from using HomeMain
to AsyncHomeMain
For example
<Route exact path="/" render={props => <HomeMain datatype="home" {...props} />} />
<Route exact path="/join" render={props => <HomeMain datatype="register" {...props} />} />
becomes
<Route exact path="/" render={props => <AsyncHomeMain datatype="home" {...props} />} />
<Route exact path="/join" render={props => <AsyncHomeMain datatype="register" {...props} />} />
Member
All this code is in MemberMain.jsx
and is only referenced from routes/index.jsx
. The changes are straightforward.
The changes are essentially the same as for a non-member
Remove the direct reference
// import MemberMain from '../components/containers/MemberMain';
Create an Async component AsyncMemberMain
which will be invoked to lazy load MemberMain
const AsyncMemberMain = Loadable({
loader: () => import('../components/containers/MemberMain'),
loading: Loading,
delay: 200,
render(loaded, props) {
console.log('--- AsyncMemberMain::render(); loaded ', loaded, ' props ', props);
console.log(' loaded.default ', loaded.default);
const Component = loaded.default;
return <Component {...props} />;
}
});
Change all the routes from using MemberMain
to AsyncMemberMain
For example
<PrivateRoute permission="free" path="/starred" render={props => <MemberMain datatype="starred" {...props} />} />
<PrivateRoute
permission="free"
path="/calendar/:param"
render={props => <MemberMain datatype="calendar" {...props} />}
/>
<PrivateRoute permission="free" path="/goals" render={props => <MemberMain datatype="goals" {...props} />} />
<PrivateRoute
permission="free"
path="/projects"
render={props => <MemberMain datatype="projects" {...props} />}
/>
becomes
<PrivateRoute
permission="free"
path="/starred"
render={props => <AsyncMemberMain datatype="starred" {...props} />}
/>
<PrivateRoute
permission="free"
path="/calendar/:param"
render={props => <AsyncMemberMain datatype="calendar" {...props} />}
/>
<PrivateRoute permission="free" path="/goals" render={props => <AsyncMemberMain datatype="goals" {...props} />} />
<PrivateRoute
permission="free"
path="/projects"
render={props => <AsyncMemberMain datatype="projects" {...props} />}
/>
Code Splitting by Component
The calendar uses the 3rd party calendar component react-big-calendar
which itself is quite large. As the calendar is separate and only invoked in one place (in the member section code), it is an obvious candidate for code splitting and lazy loading.
The code is very similar to the above.
MemberMain.jsx
import Loadable from 'react-loadable';
// import ViewCalendar from '../calendar/ViewCalendar';
const AsyncCalendar = Loadable({
loader: () => import('../calendar/ViewCalendar'),
loading: Loading,
delay: 200
});
and
renderCalendar() {
const { goals } = this.props;
const { param } = this.props.match.params;
return <ViewCalendar events={events} onEventDrop={this.onCalendarEventDrop} />;
}
becomes
renderCalendar() {
const { goals } = this.props;
const { param } = this.props.match.params;
return <AsyncCalendar events={events} onEventDrop={this.onCalendarEventDrop} />;
}