A React + Webpack Optimization Case

I have a local SPA project which was being reported slow during initialization. It takes about 2s to finish loading which has a bad user experience. This article is to record my steps to optimize and maybe to share some experience.


Profile, profile, profile

First time profile by Chrome Dev Tools Timeline

As the optimization rule #1 is to do profiling before any optimizations. To dig out what happens during the 2s, Chrome Dev Tools Timeline is a fantastic tool for developers. From the screenshot above, we could see the TTFP (Time To First Print) is about 1.4s. Our goal is to reduce the TTFP time and we will use TTFP as optimize measurement.

The second thing that we could find in the profile is that most of time waste in evaluating JavaScript. Originally I am not really care the JavaScript bundle size since the project has local http server. Fetching JavaScript should not be a bottleneck for us. What I learn is that although network time doesn’t matter, evaluation script time does matter for local web application. After figuring out the bottleneck, I will try to reduce the JavaScript bundle size.

There are some tools could analysis Webpack generated bundle, e.g. webpack-bundle-size-analyzer or webpack-visualizer. I personally prefer source-map-explorer for visualizing and able to work in local environment.

JavaScript bundle analysis by source-map-explorer

Import only the modules which are necessary

Modern modularized libraries support developers to import only partial modules.

Instead of importing whole module:

import { debounce } from 'lodash';
import { pure } from 'recompose';

You could refactor as below to only import the module you need.

import debounce from 'lodash/debounce';
import pure from 'recompose/pure';

Deal with moment.js locale modules

Unfortunately, moment.js does not support to import partial modules. What’s worse is that moment.js has built-in locale files. When you import moment.js, your bundle will increase about 150 kB for all locale files. My workaround is to leverage webpack power to do code splitting.

You also reference this which I copied from.

At first, to remove all moment.js locale modules from bundle by webpack IgnorePlugin.

new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/)

Secondly, to support code splitting for moment.js locale modules. we should rewrite context by webpack ContextReplacementPlugin

new webpack.ContextReplacementPlugin(/^\.\/locale$/, (context) => {
if (!/\/moment\//.test(context.context)) { return; }
Object.assign(context, {
regExp: /^\.\/\w+/,
request: '../../locale',
});
}),

Now we could setup moment.js locale like this

const locale = 'zh-tw';
import(`moment/locale/${locale}.js`)
.then(() => {
moment.locale(locale);
});

Yes! I also upgrade to webpack 2.
Webpack 2 supports native ES6 import/export support. Its Tree Shaking feature could also help to eliminate unused code and reduce bundle size.
The new webpack website is really clear and fantastic, you should definitely to check https://webpack.js.org/.

Back to moment.js, there is a problem for the locale name. If you pass ‘zh_TW’ as locale name, you will have trouble that zh_TW module does not exist for import. We should have some locale name normalization before import the module. My solution is to pass all moment.js locale name into normalization.

In webpack.config.babel.js

function getMomentLocales() {
const momentLocaleRoot = path.resolve(
__dirname, 'node_modules/moment/locale');
return fs.readdirSync(momentLocaleRoot)
.map(file => file.substr(0, file.indexOf('.js')));
}
module.exports = {
...
plugins: [
new webpack.DefinePlugin({
__MOMENT_LOCALES__: JSON.stringify(getMomentLocales()),
})
]
};

In setup.js

function normalizeLocale(locale) {
// Just simple implementation here,
// you could further do well like match by {lang}-{script} like RFC4646.
const normalizedLocale = locale.toLowerCase().replace('_', '-');
  return supportedLocales.indexOf(normalizedLocale) >= 0
? normalizedLocale
: 'en-gb';
}
function setupMomentLocale(locale) {
const normalizedLocale = normalizeLocale(__MOMENT_LOCALES__, locale);
import(`moment/locale/${normalizedLocale}.js`).then(() => {
moment.locale(normalizedLocale);
});
}

Strip development code

I have some fake data code for local development without depends on real production environment. These code are not necessary added to bundle. UglifyJS by default could remove dead code for you. Here to show how to achieve this.

In webpack.config.babel.js

module.exports = {
...
plugins: [
new webpack.DefinePlugin({
__DEV__: JSON.stringify((process.env.NODE_ENV !== 'production'))
}),
new webpack.optimize.UglifyJsPlugin()
]
};

In your_code.js

...
if (__DEV__) {
// ...
// doSomething
// This code block will be stripped if run webpack with NODE_ENV='production'
}

Extract CSS to dedicate CSS file

If you use default webpack style-loader, CSS will add to JavaScript bundle too. To reduce JavaScript bundle evaluation time, I try to move CSS out from JavaScript bundle.

ExtractTextPlugin do good for this.


Code splitting by React-Router routes

Now, it is time to React.

You should identify your application’s Critical Rendering Path. It is not necessary to load modules not on the Critical Rendering Path. There are some articles to show you how to do code splitting with Webpack and React-Router. Here show you how I did it:

// Define your react-router <Route /> by async getComponent()
<Route
path='/path/to/deep_view'
getComponent={(location, cb) => {
import('../containers/DeepView.js')
.then(module => cb(null, module.default));
}}
/>

React components profiling

Did you know that React has an awesome feature after 15.4.0 that you could profile React component by Chrome Dev Tools Timeline?

To find out React component bottleneck, simply to add ‘react_perf’ to URL query string and reload in Dev Tools Timeline.

React component profiling

The final result after optimization

TTFP from 1.4s to 0.8s which seems not bad and the bundle size also reduce about 200 kB.

After optimization

Last but not least…

You should definitely to check Addy Osmani’s great talk.