Today’s javascript ecosystem is full of new concepts, tools and libraries. Some of them are still with us, some of them died along the way. One of those tools that managed to stay on top is Webpack. If you are not too familiarized with the concepts behind webpack, this post is for you ;). My approach will be with more focus on the practice rather than giving you long, hard to grasp, definitions. So, let’s start by the basics.
MODULE BUNDLES
If you look into the oficial documentation of Webpack, they define this piece of software as: “webpack is static module bundler for modern Javascript applications”. To be honest with you, that definition sounds weird to me: kind of not web-oriented, more propaganda than a real thing. It turns out, webpack it is really a static module bundler for javascript apps. I was being too negative about it (in my defense, every day we know about a new super “solve-all-my-problems” new tool for javascript, so I’m a little bit skeptical regarding this topic).
So, what webpack does? consider this code (ES6):
1 2 3 4 5 |
// file: exciting_times.js import { sum } from ‘./math’; let a = sum(1,2); console.log(a); |
and:
1 2 3 4 |
// file: math.js export function sum(a, b) { return a + b; } |
We have two files: exciting_times.js and math.js, and the first depends on the second. Webpack allow us to bundle those two dependent files into just one no-dependency bundle. Probably, you must be thinking, why?
The first most obvious answer is: to make above code work on most web browsers. ES6 is still not fully supported yet, webpack (plus another tool called babel) let you use ES6 in development mode, and later in the final bundle (built by webpack) your code is going to be fully compatible with older browsers, in a process is called transpiling. That is pretty exciting, bla bla bla. But, there is another good reason to use webpack.
ENTERS TREE SHAKING AND DEAD CODE ELIMINATION
In the near future, ES6 will become the standard in web browsers, so this transpiling feature will not be so useful anymore. So, what is webpack giving to us that is so useful? Consider the next code:
1 2 3 4 5 |
// file: exciting_times2.js import { sum } from 'math.js'; let a = sum(1,2); console.log(a); |
and:
1 2 3 4 5 6 7 8 |
// file: math.js export function sum(a, b) { return a + b; } export function times(a, b) { return a * b; } |
So, we have our math module with two functions but in our actual app (exciting_times2.js) we only use the sum function. If you run the above code in your browser within a webpage:
1 2 3 4 5 6 7 8 9 |
<html> <head> <title>ES6 rocks!</title> </head> <body> <p>Check your console.log!</p> <script src="exciting_times2.js"></script> </body> </html> |
In your networks panel on your browser, you are going to see something like (I’m using chrome):
3 requests! one for the index file, another for the exciting_times script and another for the math library. And if you check the code for the math request:
The entire math.js library is loaded. Nothing wrong or weird here, is just how javascript works, if you want to use the sum function in your code, you need to request the complete math library file. Thats kind of inefficient, even more in the web world where performance is a must. In an ideal world, if we want to use only the sum function, well only that code should be loaded. Webpack enters to save us!
Webpack implements (by means of plugins) a feature called tree shaking. This technique, erase the code that is actually not being used in our application (in the above example, the times function) and in the final bundle produced, this code will not be there! Think about all code you don’t use in third party libraries… This is why webpack is so amazing.
To enable tree shaking, you need to tell webpack to minify your code. For this, we use UglifyJSPlugin:
1 |
yarn add --dev uglifyjs-webpack-plugin |
and in our webpack.config.js file:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
const path = require('path'); const UglifyJSPlugin = require('uglifyjs-webpack-plugin'); module.exports = { entry: './exciting_times.js', output: { path: path.resolve(__dirname, 'dist'), filename: 'webpack.bundle.js' }, plugins: [ new UglifyJSPlugin() ] }; |
with only that amount of configuration we are benefiting our application from tree shaking and bundling.
CODE SPLITTING
Code splitting is a common technique in modern bundling systems which allows the developer to split the code of an application in smaller parts, to avoid loading big chunks of code at once.
So, webpack in its simpler form, will create just one big bundle with all our code and resources, code splitting is about avoiding this. Why? because we are in the web development business, where the number of requests and the size of requests matters and matters a lot!
Webpack offer us different ways to code split our app. Let check the basics with the next example:
1 2 3 4 |
// file: index.js import _ from 'lodash'; console.log("index loaded, time: ", _.now()); |
and:
1 2 3 4 |
// file: module2.js import _ from 'lodash'; console.log("New module loaded, time: ", _.now()); |
and suppose we have this two html pages (the scripts in the dist folder doesn’t exists yet, later I’m going to show how to tell webpack to build those files):
1 2 3 4 5 6 7 8 9 |
<html> <head> <title>Welcome</title> </head> <body> <p>Here we only need to use index.js module!</p> <script src="dist/index.bundle.js"></script> </body> </html> |
and:
1 2 3 4 5 6 7 8 9 |
<html> <head> <title>Welcome</title> </head> <body> <p>Here we only need to use module2.js module!</p> <script src="dist/module2.bundle.js"></script> </body> </html> |
So, we have two different pages, and the second page doesn’t need to know about the code in index.js. Also, the first page doesn’t need to know about the code in module2.js. But, with the default configuration of webpack, we only get one big bundle making the loading of every page inefficient. We can fix that very easily:
1 2 3 4 5 6 7 8 9 10 11 12 |
const path = require('path'); module.exports = { entry: { index: './index.js', module2: './module2.js' }, output: { filename: '[name].bundle.js', path: path.resolve(__dirname, 'dist') } }; |
Execute webpack:
1 |
npx webpack |
and you will see two different bundles in your dist folder, one for index and one for module2. Nice!
Using the technique above, we can split our codebase into different chunks and avoid to load unnecessary code in some pages of our app. The problem with this version of code splitting is, if you check the code of every bundle, you are going to see in both index and module2 the code for lodash, so we are effectively loading lodash twice! (one for the index.js and one for the module2.js). It would be better if we load lodash only once for the two scripts.
Actually, webpack allow us to split our codebase but preventing duplications of code in every bundle. For this, we use the CommonsChunkPlugin:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
const path = require('path'); const webpack = require('webpack'); module.exports = { entry: { index: './index.js', module2: './module2.js' }, plugins: [ new webpack.optimize.CommonsChunkPlugin({ name: 'common' }) ], output: { filename: '[name].bundle.js', path: path.resolve(__dirname, 'dist') } }; |
Note what is the output of webpack:
1 2 3 4 5 6 7 8 9 10 11 12 |
Hash: 0207eea2ad72e70373da Version: webpack 3.9.1 Time: 455ms Asset Size Chunks Chunk Names module2.bundle.js 544 bytes 0 [emitted] module2 index.bundle.js 543 bytes 1 [emitted] index common.bundle.js 547 kB 2 [emitted] [big] common [1] ./index.js 88 bytes {1} [built] [2] (webpack)/buildin/global.js 509 bytes {2} [built] [3] (webpack)/buildin/module.js 517 bytes {2} [built] [4] ./module2.js 95 bytes {0} [built] + 1 hidden module |
Webpack created for us a new bundle called common bundle, if you look the content of the file you will see lodash library on it. So, we effectively split our code in a way more efficient fashion. Now, we load lodash only once and our two bundles consume the same common bundle with lodash on it.
Webpack offers us another useful technique to split out our code. This technique is called /dynamic imports/ and probably it’s the technique where you can get the most of code splitting in webpack. So, to understand what is dynamic import, consider the next scenario. In your application, you need to use a lodash function only when the user clicks on a button. With the technique from above, we are actually creating a bundle just for lodash which improves our performance a little bit. But, it seems unproductive to always load lodash because we need it only when the user clicks a very specific element ¿what if the user never clicks that element? with the technique from above we still are loading lodash. With dynamic imports, we can solve that limitation. Let’s see how this works:
1 2 3 4 5 6 7 8 9 10 11 |
<html> <head> <title>Webpack</title> </head> <body> <p>Check the console log!</p> <br> <button id="myButton">CLICK ME AND CHECK LOGS!</button> <script src="dist/index.bundle.js"></script> </body> </html> |
In the html code from above, we added a button with an id of myButton. Also, we loaded the main bundle of our app. The code for our index.js is:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// file: index.js console.log("Welcome to our app!"); function addEvent() { var button = document.getElementById('myButton'); button.onclick = e => import(/* webpackChunkName: "lodash" */ 'lodash').then(module => { var _ = module; console.log("Actual date is: " + _.now()); }); } document.addEventListener('DOMContentLoaded', function() { addEvent(); }, false); |
So, here we added a new function called /addEvent/, which add an event listener to our button. The interesting thing is import statement in the onClick event:
1 2 3 4 5 6 |
... button.onclick = e => import(/* webpackChunkName: "lodash" */ 'lodash').then(module => { var _ = module; console.log("Actual date is: " + _.now()); }); ... |
That import statement is actually telling webpack: “when the user clicks on the button, load the lodash chunk an after is loaded execute the code passed to the “.then” function. This is also called *lazy loading* of dependencies. Your wepack.config.js should be something like:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
const path = require('path'); const webpack = require('webpack'); module.exports = { entry: { index: './index.js' }, output: { filename: '[name].bundle.js', path: path.resolve(__dirname, 'dist'), publicPath: 'dist/' } }; |
Now run npx webpack:
1 2 3 4 5 6 7 8 9 10 11 |
> npx webpack Hash: 50e368d3326518979d02 Version: webpack 3.9.1 Time: 679ms Asset Size Chunks Chunk Names 0.bundle.js 541 kB 0 [emitted] [big] lodash index.bundle.js 6.19 kB 1 [emitted] index [0] ./index.js 381 bytes {1} [built] [2] (webpack)/buildin/global.js 509 bytes {0} [built] [3] (webpack)/buildin/module.js 517 bytes {0} [built] + 1 hidden module |
Webpack generates two different bundles, one for the index.js app and the other for lodash (automatically knows it needs to have two different bundles, because lodash is being lazy loaded in our code). Let’s see how this works in our browser:
We can see from the gif above, on the page load we only make one request for the index.js file, later when the user clicks on the button another request is made to load lodash. If the user doesn’t click the button, the lodash is never loaded and the overall performance of our app is improved.
SOURCE MAPS
When you bundle your javascript files you often loose track on valuable information when dealing with errors in your code. So, for example, if your file file1.js has an error, in your webpage you are going to see that error pointing to your index.bundle.js file. Tracking the error to the original file can be hard when the codebase grows or when the apps are too complex. This can be solved by the means of source maps (let’s take the same configuration file from the lazy loading section):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
const path = require('path'); const webpack = require('webpack'); module.exports = { entry: { index: './index.js' }, devtool: 'inline-source-map', output: { filename: '[name].bundle.js', path: path.resolve(__dirname, 'dist'), publicPath: 'dist/' } }; |
We only need to add the ‘devtool’ property in our configuration file to enable source maps in our code. Bundle your files with nix webpack:
1 2 3 4 5 6 7 8 9 10 11 |
> npx webpack Hash: 50e368d3326518979d02 Version: webpack 3.9.1 Time: 946ms Asset Size Chunks Chunk Names 0.bundle.js 1.43 MB 0 [emitted] [big] lodash index.bundle.js 14.8 kB 1 [emitted] index [0] ./index.js 381 bytes {1} [built] [2] (webpack)/buildin/global.js 509 bytes {0} [built] [3] (webpack)/buildin/module.js 517 bytes {0} [built] + 1 hidden module |
Everything looks pretty much the same right? actually, take a look at the filesize of each bundle. They have a size twice as their original value! *you should use this feature only in development mode!*. So, why is this useful? the browser will help us to answer that question:
From the animation above, we can see the console.log’s tracking down to the original javascript code (index.js) and not to the index.bundle.js. The source maps included in our bundles tells to google chrome where to find the original source code. This is really useful when solving coding problems and to easily track the code that triggered an error in our application.
HOW YOU CAN ACTUALLY RUN THIS SAMPLES? WEBPACK-DEV-SERVER
Until now, I haven’t told you how you can actually run those samples. Of course, you can just write the index.html file, bundle your javascript and open the final html file from your browser. That technique has a lot of limitations (browsers impose a lot of limitations to directly-from-the-filesystem html files) and to overcome those, one usually lift up a development server to serve our assets and html files. Luckily for us, webpack has a nice server to help us, let’s start by adding it as a dependency to our package.json file:
1 |
yarn add --dev webpack-dev-server |
And you should see an output like this one:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
> npx webpack-dev-server --open Project is running at http://localhost:8080/ webpack output is served from /dist/ Content not from webpack is served from . Hash: d6e1ee17ec04dd3ce7cd Version: webpack 3.9.1 Time: 4801ms Asset Size Chunks Chunk Names 0.bundle.js 1.43 MB 0 [emitted] [big] lodash index.bundle.js 846 kB 1 [emitted] [big] index [0] (webpack)/buildin/global.js 509 bytes {1} [built] [3] multi (webpack)-dev-server/client?http://localhost:8080 ./index.js 40 bytes {1} [built] [4] (webpack)-dev-server/client?http://localhost:8080 7.95 kB {1} [built] [5] ./node_modules/url/url.js 23.3 kB {1} [built] [8] ./node_modules/querystring-es3/index.js 127 bytes {1} [built] [11] ./node_modules/strip-ansi/index.js 161 bytes {1} [built] [12] ./node_modules/ansi-regex/index.js 135 bytes {1} [built] [13] ./node_modules/loglevel/lib/loglevel.js 7.86 kB {1} [built] [14] (webpack)-dev-server/client/socket.js 1.05 kB {1} [built] [16] (webpack)-dev-server/client/overlay.js 3.73 kB {1} [built] [17] ./node_modules/ansi-html/index.js 4.26 kB {1} [built] [21] (webpack)/hot nonrecursive ^\.\/log$ 170 bytes {1} [built] [23] (webpack)/hot/emitter.js 77 bytes {1} [built] [25] ./index.js 381 bytes {1} [built] [26] ./node_modules/lodash/lodash.js 540 kB {0} [built] + 12 hidden modules webpack: Compiled successfully. |
If you go to http://localhost:8080 you will see your webpage served together with your assets, and the best of all: fully functional and without file:// restrictions!
Another great feature of this web server is the ability to monitor the changes on our source code. To test this, make a change in your index.js file, you will see webpack-dev-server recompile everything and even automatically reload your browser. Nice!
HOT MODULE REPLACEMENT
Besides webpack-dev-server, another nice feature of webpack is its ability to refresh our modules without the need of a full refresh of our page. Again, obviously this feature is only useful in development mode. Lets see how this works. First, we need to enable HMR (Hot Module Replacement) in our configuration file:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
const path = require('path'); const webpack = require('webpack'); module.exports = { entry: { index: './index.js' }, devtool: 'inline-source-map', devServer: { contentBase: '.', hot: true }, plugins: [ new webpack.HotModuleReplacementPlugin() ], output: { filename: '[name].bundle.js', path: path.resolve(__dirname, 'dist'), publicPath: 'dist/' } }; |
Note the “hot: true” in the devServer property and the addition of the HotModuleReplacementPlugin in the plugins property. Also, we our going to change a little bit our index.js file, remember our old math.js module? let’s use it again:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// file: index.js function addEvent() { var button = document.getElementById('myButton'); button.onclick = e => import(/* webpackChunkName: "math" */ './math').then(module => { var math = module; console.log("1+2 = ", math.sum(1,2)); }); } document.addEventListener('DOMContentLoaded', function() { addEvent(); }, false); if (module.hot) { module.hot.accept('./math.js', function() { console.log('Accepting the updated math module!'); }) } |
So, we updated our onclick handler to use our math module and calculate a simple sum. Note also the last piece of code in the file:
1 2 3 4 5 |
if (module.hot) { module.hot.accept('./math.js', function() { console.log('Accepting the updated math module!'); }) } |
To actually make HMR works, we need to implement the module.hot interface in our client javascript file. Is pretty simple, for every module we want to reload, we need to implement the accept function with the module as a parameter. In this case, we are implementing the accept for the math module. Note that if you don’t implement the accept call, HMR will not work! Now, as usual, let’s see how this works on the browser:
Note that after the HMR, the console of the browser is not reloaded, an indication of no new request to inject the changes on the math module.
PREPARING FOR PRODUCTION
To finalize this introduction to webpack, let’s check how we can prepare our application to go into production. Luckily for us, webpack has some useful features to handle development and production environments with ease.
The basics are to separate the configuration of webpack into development and production mode configurations, so separate configurations per environment. As a lot of the basic configuration will be the same for the development and the production environment, we are going to keep a common configuration for the two envs and then separate only specific parts of the configuration files per environment. For this to work, we can use the tool webpack-merge:
1 |
yarn add webpack-merge --dev |
This tool will allow us to reutilize the common configuration in our different environments. Let’s see the definition of the common configuration:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
// file: webpack.common.js const path = require('path'); const CleanWebpackPlugin = require('clean-webpack-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin'); module.exports = { entry: { app: './src/index.js' }, plugins: [ new CleanWebpackPlugin(['dist']), new HtmlWebpackPlugin({ title: 'My App' }) ], output: { filename: '[name].bundle.js', path: path.resolve(__dirname, 'dist') } }; |
It’s very similar to our previous examples. Now, the development configuration:
1 2 3 4 5 6 7 8 9 10 |
// file: webpack.dev.js const merge = require('webpack-merge'); const common = require('./webpack.common.js'); module.exports = merge(common, { devtool: 'inline-source-map', devServer: { contentBase: './dist' } }); |
with the merge tool, we “imported” the configuration of the common file. Also, we enabled the source maps in our bundle. For the production configuration:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// file: webpack.prod.js const webpack = require('webpack'); const merge = require('webpack-merge'); const UglifyJSPlugin = require('uglifyjs-webpack-plugin'); const common = require('./webpack.common.js'); module.exports = merge(common, { plugins: [ new UglifyJSPlugin(), new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify('production') }) ] }); |
So, no source maps for production, and also we used UglifyJS. The DefinePlugin instruction it’s used to notify Node that we are actually in production environment (when using the webpack.prod.js). To finalize, let’s add some tasks to our package.json file:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
{ "name": "webpack-demo", "version": "1.0.0", "description": "Webpack Demo", "main": "index.js", "license": "MIT", "scripts": { "start": "webpack-dev-server --open --config webpack.dev.js", "build": "webpack --config webpack.prod.js" }, "devDependencies": { "clean-webpack-plugin": "^0.1.19", "express": "^4.16.3", "html-webpack-plugin": "^3.1.0", "webpack": "^4.2.0", "webpack-cli": "^2.0.13", "webpack-dev-middleware": "^3.0.1", "webpack-dev-server": "^3.1.1", "webpack-merge": "^4.1.2" } } |
So, we have a start task that runs the webpack-dev-server (development mode) and a build task that use our webpack.prod.js file to produce a production bundle. To test if this worked correctly, run:
1 |
yarn start |
and point your browser to localhost:8080. Everything should be working as usual, in development mode (like the previous examples). Stop the webpack dev server and execute:
1 |
yarn build |
You should end up with two files in your dist folder. One for the index.html and the other with the content of your bundle, if you look your bundle for the production build, you will see no source maps at the bottom!
FINAL THOUGHTS
I hope that with this short introduction you can have a notion of what is webpack and what are the benefits of using it. As stated at the beginning of the article, sometimes with the documentation and all the hype terms you read on the internet it can be daunting when starting with a new technology, I tried to do a gentle introduction to webpack based strongly on their own docs. The best way to get a grasp of webpack is to use it, so start a new project, choose your preferred library, maybe with ES6 and use webpack as your bundle generator. You are not going to regret it.