Over the last couple of weeks, we've prioritized some sustaining product goals to polish the codebase and update some big ticket dependencies. Among those updates were: React, Redux, and Webpack - the biggies. The first two were pretty painless and inspired the confidence to approach updating Webpack from v2 to v4 like maybe no big deal! Though confidence level was on high, I felt a slight chill and a twinge of doubt by the prospect of making changes to our build configs.
The latest version of Webpack has the lowest barrier to entry of any other version. Its new mode
parameter comes with default environment configs and enables built-in optimizations. This "no config" option is ideal for a new project and/or a newcomer to Webpack that wants to get started quickly. Migrating an existing config is a little trickier but following the migration guide got our development environment in pretty good shape. I was pleasantly shocked by the Webpack documentation. It's thorough, well organized, and has improved significantly from the early days of v1.
To begin migrating our development config, I added the new mode
property, removed some deprecated plugins, and replaced autoprefixer
with postcss-preset-env
in the post-css-loader
plugin config. Starting the dev server (npm start
) at this point led to the first snag: this.htmlWebpackPlugin.getHooks is not a function
. Hunting that error landed in an issue thread, suggesting a fix - which did the trick. Development mode: good to go. Confidence mode: strong.
Continuing migration with the production config was a similar process. We have a fairly standard setup to compile the static build directory: transpile (ES6 and JSX) and minify JS; transform, externalize, and minify CSS; then generate an index.html file to tie it all together. However, running the production build (npm run build
) was a different story.
The first issue was harsh: FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory
. Ooof!
Lots of searching and skimming repeatedly offered the same suggestion: to pass an argument to the node process --max_old_space_size=<value>
which increases the heap memory allocation. It felt like slapping some tape on a shiny new toy but it enabled the build process to complete successfully.
Feeling unsatisfied with band-aiding an ominous failure, I investigated why the build was consistently choking on source map generation and here is where I discovered a 2 alarm fire:
First, the bundle needs to be split by configuring optimization.splitChunks
. Then, the vendor source maps need to be excluded by configuring SourceMapDevToolPlugin exclude
option. An important step when using SourceMapDevToolPlugin, is setting dev-tool: false
. Otherwise, the its configuration (with exclude rules) will get trampled by Webpack's dev-tool
operation and output another monster source map (mapping the entire build again).
devtool: false,
optimization: {
splitChunks: {
chunks: 'all',
name: true,
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/].*\.js$/,
filename: 'static/js/vendors.[chunkhash:8].js',
priority: -10
},
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true
}
}
}
}
...
plugins: [
new webpack.SourceMapDevToolPlugin({
filename: 'static/js/[name].[chunkhash:8].js.map',
exclude: /static\/js\/vendors*(.+?).js/
})
]
With the build output in much better shape (though the vendors bundle should be further split into smaller chunks), I try removing the node argument band-aid and re-running the build command (sans gargantuan source map). Success! The fatal error was almost exclusively due to source mapping one enormous build.
Now the build succeeds and I'm cookin with gas. However, the CSS file is much bigger than it used to be...it's no longer minified. One of the plugins that changed with this upgrade was replacing ExtractTextPlugin with MiniCssExtractPlugin (extracts all css modules into a separate file). However, MiniCssExtractPlugin does not handle minification(https://github.com/webpack-contrib/mini-css-extract-plugin#minimizing-for-production) like ExtractTextPlugin did. To minify CSS, the OptimizeCSSAssetsWebpackPlugin (aka OCAWP) is necessary.
To include OCAWP, add optimization.minimizer
configuration to the module:
optimization: {
minimizer: [
new OptimizeCSSAssetsWebpackPlugin({
cssProcessorOptions: {
parser: require('postcss-safe-parser'),
map: {
inline: false,
annotation: true
}
},
cssProcessorPluginOptions: {
preset: ['default', {
discardComments: {
removeAll: true
}
}]
}
})
]
}
Now, CSS is minified but...JavaScript is not. 😑 Hoo boy.
By default, Webpack uses UglifyJs to minify JavaScript. When optimization.minimizer
is customized (in this case for CSS minification), JS minification needs to be explicitly handled as well. Now the optimization.minimizer
config contains OCAWP and UglifyJs but the build script fails again - citing: Unexpected token: keyword (const)
error from UglifyJs. Siiigh.
It turns out, uglify-js (the parser used by UglifyJsWebpackPlugin) does not support ES6 uglification. The maintainer of UglifyJsWebpackPlugin, as well as the Webpack docs urge the adoption of TerserWebpackPlugin instead. This works out great, since the next version of Webpack will use Terser as its default minifier. Thank you, next!
optimization: {
minimizer: [
new OptimizeCSSAssetsWebpackPlugin({...}),
new TerserWebpackPlugin({
sourceMap: true,
parallel: true,
terserOptions: {
parse: {
ecma: 8
},
compress: {
ecma: 5,
warnings: false,
comparisons: false,
inline: 2
},
output: {
ecma: 5,
comments: false,
ascii_only: true
}
}
})
]
}
The production build is finally compiling as expected. There are still improvements to be made but I will rest easier knowing that this configuration isn't exploding CPUs and that I have a better grip on optimizations going forward.
It's been a tough and humbling week. Configuring Webpack's loaders and plugins correctly can feel overwhelming - there are countless options and optimizations to understand. If you or someone you love is going through frontend dependency hardships, just know: it gets better and you are not alone. Hang in there!