Setting Up Dev Environment with Webpack 3
I observe as some people still write ancient ES5 syntax of JavaScript and it’s disheartening. I wonder what keeps them from moving forward. Some must be driven by psychological inertia, but some likely just find it to complex to make their new syntax running in a wide range of browsers. In fact, nowadays we may not fear about it anymore. One can set up a dev environment where tools decide what transformations requires the code and what polyfills to load depending on selected target (list of user agents to support). The only thing one needs to start “a new life” is a proper setup. That’s what the article is about.
My story
In 2012 I was looking for way to combine my numerous JavaScript modules in a single bundle. I had quite an experience with YUI loader, but I didn’t want to go with asynchronous loading as in the times of HTTP 1.x it caused intolerable lags. I noticed that some projects were simply concatenating files between a header and a footer. With my PHP background I saw it more like include or rather import statement so one could have a control on bundling. I created a little utility https://github.com/dsheiko/jsic. Shortly I found out that includes aren’t that handy as they do not care for the scope. Then I thought if I have already pre-compilation stage why not to resolve CommonJS notation (like Node.js) into a bundle file? So by the end of the year I came up with another tool https://github.com/dsheiko/cjsc. When publishing it to NPM repo and seeking a proper wording for description I ran into Browserfy… Yeah, it’s me and it’s not fixable. Nonetheless I relied on CJSC for years and it served me well. It was fast, dead simple to set up and focused on the only task - bundling CommonJS modules. Yet after a while started to pop up new solutions. I recall me pretty impressed by the idea of tree shaking in RollUp. I’m generally very fussy about YAGNI, so it felt like a dream. As for Webpack, honestly I didn’t get it at first. But I engaged and I think it is now one of the very best technologies available to a frontend developer. Of course, if you know how deal with it…
Meet the Webpack
So what is Webpack? It’s a bundler and extremely smart one. It reads the specified source, transpiles it if need (ES.Next, TypeScript, CoffeeScript, etc), resolves JavaScript modules (ES modules, CommonJs, AMD) and other dependencies (HTML, CSS, SASS, images and more) and produces one or more output files. Yes, you can gracefully split the code base in bundles to achieve better user experience. Let say, you can have base bundle with code implementing core UX and other bundles lazy-loading in accordance with their priorities. Webpack does tree-shaking and scope hoisting. What is more, you can pipe in plugins like Uglify to post process the output code. Impressive, isn’t it? Let’s take an example and see in practice.
Practice makes perfect
First we clone my boilerplate with the example:
git clone https://github.com/dsheiko/boilerplate/
cd boilerplate/webpack-babel
This application doesn’t do much. It simply shows how a few modules resolve statically and a few dynamically by using ES.Next syntax. Check out the entry script:
/webpack-babel/src/index.js
import { utilFoo } from "./util/foo";
So here we import foo module statically. This code is going to be included into the main bundle.
import { utilBar } from "util/bar";
We do the same with bar module, but this time we specify its location not relative to the entry script, but to the base directory, which we will set up in Webpack configuration.
Promise.all([
import(/* webpackChunkName: "foo" */ "./widget/foo" ),
import(/* webpackChunkName: "bar" */ "./widget/bar" )
]).then( ([{ widgetFoo }, { widgetBar }]) => {
console.log( "Lazy-loaded modules exports ", widgetFoo(), widgetBar() );
}).catch(( e )=> {
console.error( e );
});
We also resolve two dynamic modules. According to Promise.all they start loading concurrently as soon as the main bundle executed. When both loaded we receive their exports and forward them to the console. As you can see we use Webpack comments like /* webpackChunkName: “foo” */ to point out what names we want for the bundle files.
The example contains ready-made manifest (package.json). So you can obtain all the required dependencies by running:
npm i
What we want of webpack here is:
- transpile ES.Next syntax we use in JavaScript suitable for wider range of browsers
- resolve all the encountered (static and dynamic) modules starting from the entry script
- minify bundles for production
- produce two packages: one thinner for evergreen browsers and one thicker for legacy ones
Configuring Webpack
To avoid code duplication and achieve better readability we split Webpack configuration into four files webpack.common.js, webpack.dev.js, webpack.prod.js, webpack.common-legacy.js. The first one will be abstract config extended by other ones. Webpack.dev.js will serve for development and webpack.dev.js will extend it for code optimization. Eventually it will be extended by webpack.common-legacy.js to inject polyfills required by legacy browsers.
So let’s start with basics:
webpack.common.js
module.exports = {
// Application entry scripts
entry: {
// script alias : path
index : join( SRC_FULL_PATH, "index.js" )
},
// Output configuration for Webpack
output: {
path: PUBLIC_FULL_PATH,
filename: `[name].js`,
chunkFilename: `[name].v${pkg.version}.widget.js`,
publicPath: PUBLIC_PATH
},
…
Here we specify entry script location and give it an alias (index). Next we set output configuration. For the main bundle name we use a placeholder [name], which receives the alias we already set (index). For lazy-loaded modules we define a name template and base public path. Note that I add to the chunk names the manifest version. Thus we bust the CDN cache with every new published version of the project.
...
resolve: {
modules: [
"node_modules",
SRC_FULL_PATH
],
extensions: [ ".js" ]
},
...
With field resolve we state that during resolving modules Webpack has to try searching file relative to respective NPM package and our base directory (given in constant SRC_FULL_PATH).
plugins: [
new CleanWebpackPlugin([ PUBLIC_PATH ])
]
};
At last we call clean-webpack-plugin to clean up the output directory every time Webpack is about to write there the build assets.
webpack.dev.js
const merge = require( "webpack-merge" ),
baseConfig = require( "./webpack.common" );
module.exports = merge( baseConfig, {
...
We start dev configuration by extending webpack.common.js. For that we use webpack-merge tool.
module: {
rules: [
{
test: /.js$/,
exclude: /node_modules/,
use: [{
loader: "babel-loader",
options: {
presets: [ [ "env", {
"targets": {
"browsers": [
"Chrome >= 60",
"Safari >= 10.1",
"iOS >= 10.3",
"Firefox >= 54",
"Edge >= 15"
]
},
"modules": false,
"useBuiltIns": true,
"debug": false
}] ],
plugins: [
"transform-class-properties",
"transform-object-rest-spread",
"babel-plugin-syntax-dynamic-import",
"transform-runtime"
]
}
}]
}
]
}
This one is all about Babel configuration. In plain English, we state that every encountered .js module Webpack shall give to Babel loader. Babel will load env plugin preset, which makes our ES.Next syntax suitable for browsers matching patterns enlisted in targets.browsers field. With useBuiltIns we allow Babel to include polyfills if necessary. On top of it, we apply following plugins:
- transform-class-properties - unlock class properties of ES8
- transform-object-rest-spread - to unlock desctructuring in objects
- transform-runtime - to inject polyfills required according to the env preset configuration
- babel-plugin-syntax-dynamic-import - to prevent Babel stumbing over dynamic modules
Actually that’s already enough to make a dev build:
npm run build:dev
For production build we pipe in uglifyjs-webpack-plugin to minify the bundles:
webpack.prod.js
plugins: [
new UglifyJSPlugin()
]
Note that to optimize ES.Next syntax we need at least 1.0 version of the plugin:
npm i uglifyjs-webpack-plugin@^1.0.0
As for webpack.prod-legacy.js, we apply customizeArray and customizeObject of webpack-merge to override output and babel config. So with this configuration we make Webpack to output in ./build/legacy, which differs from default one ./build. We also widener the range of target browsers.
Now we can build for production:
npm run build:prod
This runs Webpack first on webpack.prod.js and second webpack.prod-legacy.js and results in the following output:
You can see from the log both util/foo.js and foo.v0.0.1.widget.js have slightly bigger size in legacy bundle. This example we have very little of code, but in a real application this difference can be significant.
Now as w have two sets of bundles you may ask how are we going to switch between them depending on running browser? Here the trick:
<!-- Browsers with ES module support load this script. -->
<script type="module" src="./build/index.js"></script>
<!-- Other browsers load this script, new ones skip it -->
<script nomodule src="./build/legacy/index.js"></script>
The target browsers we specified in webpack.dev.js all support ES modules and therefore load the source from script type=module. They also ignore source of script nomodule. On the contrary legacy browser skip the first script and load from the second.
If we start the application we are going to see console.log output redirected in HTML by [console-log-html | https://github.com/Alorel/console-log-html} module
npm start
You can also find in the Network panel of the DevTools loading flow diagram. It shows that index.js starts loading after the page is rendered and next loads both chunks foo and bar.
Final words
So, what have we achieved? We set up dev environment for a generic JavaScript application. We were diligent about progressive enhancement and split the application code into three bundles, where one represented the core functionality and loaded first. As complete it initialized concurrent loading of other two bundles. We used ES.Next (latest EcmaScript syntax) wrapped in ES modules. We made Webpack to compile the code with Babel . We came up with three runnable configurations for Webpack to build for dev, for production and for legacy browsers.