Webpack from Nothing

Going to Production

We can write JavaScript code in modules and bring in third party libraries, and we can write unit tests. That's pretty darn good for a language that doesn't support literally any of that in any way. Thank Zod for Webpack!

But, we need to go to production. Code that's not live and solving user problems might as well not exist.

Given that our amazing Markdown previewer totally works statically in a browser on localhost, it's just a matter of dumping files up onto some server, right?

Not exactly. It's true that would work, but for any reasonable application we're going to want a bit more. At the very least we want to:

Why?

Minification Saves Time and Money

Our Markdown app is small now, but it could become the world's foremost way of rendering markdown in a browser. We've got VC's knocking at our door, and we need to be web scale.

Uncompressed assets like JavaScript waste bandwidth. The downstream user doesn't care about whitespace or code comments, and we don't need to pay for bandwidth that's ferrying bytes that won't be used. There's literally no reason to send uncompressed JavaScript to a user other than laziness (or, in our case, the lack of wherewithal to configure our awful toolchain to do it as part of our build pipeline).

If you aren't exactly sure what I mean, consider that this JavaScript works the same way as our existing markdownPreviewer.js:

import{m}from "markdown";
var a=function(d,s,p){
return function(e){
var t=d.getElementById(i).value,
q=d.getElementById(p);
q.innerHTML=m.toHTML(t);
e.preventDefault();};}

This takes up less space than our version, but is terrible code. We can use tools to turn our nice code into crappy but small code that works the same way.

This, plus the use of a content-delivery network will make our application performant and fast without a ton of effort. To use a CDN, however, we need to be mindful about how we manage changes.

Unique Names for Each Version of our bundle.js

If we deployed our code to a content delivery network (which we are likely going to want to do at some point), and we used the name bundle.js it will be pretty hard to update it. Some CDNs don't let you update assets (meaning people would use the original version of bundle.js forever, never getting our updates or bugfixes), and others require you to wait for caches to expire, which can be hours or even days. The standard practice to deal with this is that each new version of your code has a unique name.

So, we want to generate a file like 924giuwergoihq3rofdfg-bundle.js, where 924giuwergoihq3rofdfg changes each time our code changes.

This, along with minification, are basic needs for web development, and since Webpack is a monolithic system, we'd expect it to be able to do this for us. Turns out, it can, and we can save some configuration by using the production mode. Although we aren't going to use that since it is entirely opaque.

Webpack Modes are Not Needed for Now

In our existing Webpack configuration, we saw the mode key, and it's set to "none". Per Webpack's mode documentation, this disables all optimizations.

Looking at the documentation for what Webpack does when you set the mode to either "development" or "production", it says that it configures different plugins. Webpack's internals are built on plugins, and presumably there is some internal pipeline where these plugins are applied to the code from the entry points and produce the output bundle.

Most of the plugins are either entirely undocumented or documented for Webpack developers, as opposed to what we are: Webpack users.

While the thought of a production mode that configures all the stuff we need automatically is nice, Webpack hasn't built much trust here, and I'm pretty uneasy using a bunch of undocumented plugins, even if they are recommended by the Webpack developers. Stuff like NoEmitOnErrorsPlugin seem dangerous—why would you want your build tool to swallow errors and exit zero if something went wrong?

Let's not opt into any of this and add configuration explicitly so we know what's going on. We'll leave the mode key as "none" for now.

We're back where we started, still needing minification and bundle hashing. Let's start with minification.

Minifying with the TerserWebpackPlugin

One of the plugins included by default in production mode is the TerserWebpackPlugin, which is documented as so:

This plugin uses terser to minify your JavaScript.

Terser is a JavaScript minifier, so this sounds like what we want.

Before we set it up, let's check the size of our bundle before we minify it:

> yarn webpack
yarn run v1.13.0
$ webpack --config webpack.config.js --display-error-details
Hash: 8f740e9980f30a8938d5
Version: webpack 4.29.6
Time: 203ms
Built at: 2019-03-30 10:24:23
    Asset      Size  Chunks             Chunk Names
bundle.js  80.3 KiB       0  [emitted]  main
Entrypoint main = bundle.js
[0] ./js/index.js 334 bytes {0} [built]
[1] ./js/markdownPreviewer.js 381 bytes {0} [built]
    + 6 hidden modules
Done in 0.83s.
> wc -c dist/bundle.js
82214 dist/bundle.js

We'll compare this value to the bundle size after we set up minification.

While we could install the plugin and configure it directly, Webpack allows us to use it without being so explicit. In this case, we can set the minimize key to true in the optimization key.

We haven't seen the optimization key before, and if you look at the documentation for production mode, it shows a lot of options being set in there.

For our needs, setting minimize to true will configure the TerserWebpackPlugin for us, so let's try it.

Your webpack.config.js should look like so:

const path = require('path');

module.exports = {
  entry: './js/index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js'
  },
/* start new code */
  optimization: {
    minimize: true
  },
/* end new code */
  mode: "none"
};

Now, when we run Webpack, we should see our bundle side get much smaller.

> yarn webpack
yarn run v1.13.0
$ webpack --config webpack.config.js --display-error-details
Hash: fffcfab9085ee7d5cefe
Version: webpack 4.29.6
Time: 1030ms
Built at: 2019-03-30 10:24:25
    Asset      Size  Chunks             Chunk Names
bundle.js  26.8 KiB       0  [emitted]  main
Entrypoint main = bundle.js
[0] ./js/index.js 334 bytes {0} [built]
[1] ./js/markdownPreviewer.js 381 bytes {0} [built]
    + 6 hidden modules
Done in 1.90s.
> wc -c dist/bundle.js
27431 dist/bundle.js

Voila! On my machine, this reduced the file size by about two-thirds. Not too bad.

Creating a Bundle for Long-Term CDN Caching

Creating a hashed filename is called caching, since the filename allows us to put the bundle in a long-term cache. Instead of having some sort of plugin to do this, it turns out the value of the string we give to the filename key of output isn't just an ordinary string! It's a second configuration format (yay) that allows us to put a hashed value into the filename.

We can set the value to "bundle-[contenthash].js", like so:

const path = require('path');

module.exports = {
  entry: './js/index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
/* start new code */
    filename: 'bundle-[contenthash].js'
/* end new code */
  },
  optimization: {
    minimize: true
  },
  mode: "none"
};

Let's remove the existing bundle:

> rm dist/bundle.js

Now, let's re-run webpack and see what happens:

> yarn webpack
yarn run v1.13.0
$ webpack --config webpack.config.js --display-error-details
Hash: aab3f7576e11dddd69da
Version: webpack 4.29.6
Time: 314ms
Built at: 2019-03-30 10:24:27
                         Asset      Size  Chunks             Chunk Names
bundle-e7e10692ba3eadfbb837.js  26.8 KiB       0  [emitted]  main
Entrypoint main = bundle-e7e10692ba3eadfbb837.js
[0] ./js/index.js 334 bytes {0} [built]
[1] ./js/markdownPreviewer.js 381 bytes {0} [built]
    + 6 hidden modules
Done in 1.03s.
> ls dist
bundle-e7e10692ba3eadfbb837.js
index.html

Nice! We can minify and hash the filename for long term caching, but this creates a few new problems. First, since the filename of our bundle changes every time its contents change, we can't reference it in a static HTML file. The second problem is that we likely don't want to doing either minification or hashing when doing local development.

Let's tackle the second problem first and split up our configuration.

Separating Production and Development Configuration

The way this works is that our main webpack.config.js will simply require an environment-specific webpack configuration. Those environment-specific ones will pull in a common Webpack configuration, then using the webpack-merge module, overwrite or augment anything needed specific to those environments.

Managing Webpack configs for different environments
Click to embiggen

Our general requirements at this point are:

Since we don't have much configuration now, this shouldn't be a problem.

First, install webpack-merge:

> yarn add -D webpack-merge
yarn add v1.13.0
[1/4] Resolving packages...
[2/4] Fetching packages...
[3/4] Linking dependencies...
[4/4] Building fresh packages...
success Saved lockfile.
success Saved 1 new dependency.
info Direct dependencies
└─ webpack-merge@4.2.1
info All dependencies
└─ webpack-merge@4.2.1
Done in 2.42s.
warning "jest > jest-cli > @jest/core > jest-resolve-dependencies@24.5.0" has unmet peer dependency "jest-resolve@^24.1.0".
warning "jest > jest-cli > jest-config > jest-resolve@24.5.0" has unmet peer dependency "jest-haste-map@^24.0.0".

Now, to create our four configuration files. I'm willing to tolerate one configuration file at the top-level, but not four. So, we'll be putting the dev, production, and common in a new directory, called config:

> mkdir -p config

Our top-level webpack.config.js will reference the environment-specific file in webpack:

module.exports = function(env) {
  if (env === undefined) {
    env = "dev"
  }
  return require(`./config/webpack.${env}.config.js`)
}

Where does that env come from? It comes from us. We'll need to pass --env=dev or --env=production to webpack to tell it which env we're building for. This is why we've defaulted it to dev so we don't have to type that nonsense every time.

Next, we'll create config/webpack.dev.config.js.

This file will bring in a common Webpack config, and modify it for development. This will look very similar to our original webpack.config.js, but our output path is going to be dev instead of dist, so we don't get confused about what files are what.

Also remember that since this file is in config/ and we want to put files in dev/ (not config/dev), we have to use ../dev.

const path         = require('path');
const Merge        = require('webpack-merge');
const CommonConfig = require('./webpack.common.config.js');

module.exports = Merge(CommonConfig, {
  output: {
    path: path.join(__dirname, '../dev'),
    filename: 'bundle.js'
  }
});

Now, we'll create our production configuration in config/webpack.production.config.js:

const path         = require('path');
const Merge        = require('webpack-merge');
const CommonConfig = require('./webpack.common.config.js');

module.exports = Merge(CommonConfig, {
  output: {
    path: path.join(__dirname, '../production'),
    filename: 'bundle-[contenthash].js'
  },
  optimization: {
    minimize: true
  }
});

Both files reference webpack.common.config.js, we create that next:

module.exports = {
  entry: './js/index.js',
  mode: 'none'
};

One confusing thing that is confusing must be pointed out. All of our calls to require use a path relative to the file require is called from. Further, when we call path.join(__dirname, "../production"), the ../ is because this call, too, is executed relative to the file it's executed in. But, our entry point is relative to where Webpack is executed from, which is to say, the root directory.

Let that sink in. As an exercise for later, decide for yourself if any of this is an example of a good design decision. Perhaps it's my fault for insisting I tuck away these silly files in config/, but I find all of this unnecessarily arbitrary and confusing.

Anyway, we should be able to run webpack as before:

> yarn webpack
yarn run v1.13.0
$ webpack --config webpack.config.js --display-error-details
Hash: 8f740e9980f30a8938d5
Version: webpack 4.29.6
Time: 181ms
Built at: 2019-03-30 10:24:31
    Asset      Size  Chunks             Chunk Names
bundle.js  80.3 KiB       0  [emitted]  main
Entrypoint main = bundle.js
[0] ./js/index.js 334 bytes {0} [built]
[1] ./js/markdownPreviewer.js 381 bytes {0} [built]
    + 6 hidden modules
Done in 0.80s.

This places our bundle in dev, so we'll need to move our HTML file there:

> cp dist/index.html dev/index.html

And now, our app should work as before.

Our app rendering markdown still works

To build for production, we need to pass the --env=production flag to Webpack. We'll want a Node script to do that, and we'll also want to consolidate the arguments to Webpack to avoid duplication. First, we'll add a config section, like so:

{
  "config": {
    "webpack_args": " --config webpack.config.js --display-error-details"
  }
}

We can now use these to create a new task, webpack:production, that mirrors our existing webpack task, but also passes in --env=production:

{
  "scripts": {
    "webpack": "webpack $npm_package_config_webpack_args",
    "webpack:production": "webpack $npm_package_config_webpack_args --env=production",
    "webpack:test": "webpack --config test/webpack.test.config.js --display-error-details",
    "jest": "jest test/bundle.test.js",
    "test": "yarn webpack:test && yarn jest"
  }
}

With this in place, we can run yarn webpack:production to produce the production bundle:

> yarn webpack:production
yarn run v1.13.0
$ webpack $npm_package_config_webpack_args --env=production
Hash: aab3f7576e11dddd69da
Version: webpack 4.29.6
Time: 910ms
Built at: 2019-03-30 10:24:38
                         Asset      Size  Chunks             Chunk Names
bundle-e7e10692ba3eadfbb837.js  26.8 KiB       0  [emitted]  main
Entrypoint main = bundle-e7e10692ba3eadfbb837.js
[0] ./js/index.js 334 bytes {0} [built]
[1] ./js/markdownPreviewer.js 381 bytes {0} [built]
    + 6 hidden modules
Done in 1.77s.

We can see the production bundle in the production/ directory:

> ls production
bundle-e7e10692ba3eadfbb837.js

That solves one of our problems, now for the second problem: how do we reference this file in our HTML file, given that it's name will change over time?

Accessing the Hashed Filename in our HTML

In a more sophisticated application, we would have a web server render our HTML and that server could produce dynamic HTML that includes our bundle name. Setting that up is a huge digression, so let's try to treat our HTML file as a template that we fill in with the bundle name for our production build.

The HtmlWebpackPlugin was designed to do this!

First, install it:

> yarn add -D html-webpack-plugin
yarn add v1.13.0
[1/4] Resolving packages...
[2/4] Fetching packages...
[3/4] Linking dependencies...
[4/4] Building fresh packages...
success Saved lockfile.
success Saved 21 new dependencies.
info Direct dependencies
└─ html-webpack-plugin@3.2.0
info All dependencies
├─ camel-case@3.0.0
├─ clean-css@4.2.1
├─ css-select@1.2.0
├─ css-what@2.1.3
├─ dom-converter@0.2.0
├─ domelementtype@1.3.1
├─ domhandler@2.4.2
├─ domutils@1.5.1
├─ he@1.2.0
├─ html-minifier@3.5.21
├─ html-webpack-plugin@3.2.0
├─ htmlparser2@3.10.1
├─ lower-case@1.1.4
├─ nth-check@1.0.2
├─ param-case@2.1.1
├─ pretty-error@2.1.1
├─ relateurl@0.2.7
├─ renderkid@2.0.3
├─ toposort@1.0.7
├─ uglify-js@3.4.10
└─ upper-case@1.1.3
Done in 3.27s.
warning "jest > jest-cli > @jest/core > jest-resolve-dependencies@24.5.0" has unmet peer dependency "jest-resolve@^24.1.0".
warning "jest > jest-cli > jest-config > jest-resolve@24.5.0" has unmet peer dependency "jest-haste-map@^24.0.0".

By default, this plugin will produce an index.html file from scratch that brings in our bundle. Since we have particular markup that we need for our app, we need a way to specify a template. HtmlWebpackPlugin allows us to specify one to use and, if it's just straight-up, normal HTML, the plugin will insert the <script> tag in the right place.

Let's place that file in a new directory called html. Note that we've omitted the <script> tag we had before.

<!DOCTYPE html>
<html>
  <head>
    <!-- script tag is omitted - Webpack will provide -->
  </head>
  <body>
    <h1>Markdown Preview-o-tron 7000!</h1>
    <form id="editor">
      <textarea id="source" rows="10" cols="80"></textarea>
      <br>
      <input type="submit" value="Preview!">
    </form>
    <hr>
    <section id="preview">
    </section>
  </body>
</html>

Now, we'll bring in the new plugin and configure it. This is common to both production and development, so we'll put this in config/webpack.common.config.js:

/* start new code */
const HtmlPlugin = require("html-webpack-plugin");

module.exports = {
  plugins: [
    new HtmlPlugin({
      template: "./html/index.html"
    })
  ],
/* end new code */
  entry: './js/index.js',
  mode: 'none'
};

Note again the arbitrary nature of relative paths. The template will be accessed from the directory where we run Webpack, so it's just ./html, and not ../html.

Let's clean out the existing dev directory so we can be sure we did what we think we did:

> rm dev/*.*

Now, run Webpack

> yarn webpack
yarn run v1.13.0
$ webpack $npm_package_config_webpack_args
Hash: d64f08c803abb99d8818
Version: webpack 4.29.6
Time: 398ms
Built at: 2019-03-30 10:24:44
     Asset       Size  Chunks             Chunk Names
 bundle.js   80.3 KiB       0  [emitted]  main
index.html  428 bytes          [emitted]  
Entrypoint main = bundle.js
[0] ./js/index.js 334 bytes {0} [built]
[1] ./js/markdownPreviewer.js 381 bytes {0} [built]
    + 6 hidden modules
Child html-webpack-plugin for "index.html":
     1 asset
    Entrypoint undefined = index.html
    [0] ./node_modules/html-webpack-plugin/lib/loader.js!./html/index.html 583 bytes {0} [built]
    [2] (webpack)/buildin/global.js 472 bytes {0} [built]
    [3] (webpack)/buildin/module.js 497 bytes {0} [built]
        + 1 hidden module
Done in 1.22s.

If you look at dev/index.html, you can see Webpack inserted a <script> tag (at the bottom, and messed up our indentation):

> cat dev/index.html
<!DOCTYPE html>
<html>
  <head>
    <!-- script tag is omitted - Webpack will provide -->
  </head>
  <body>
    <h1>Markdown Preview-o-tron 7000!</h1>
    <form id="editor">
      <textarea id="source" rows="10" cols="80"></textarea>
      <br>
      <input type="submit" value="Preview!">
    </form>
    <hr>
    <section id="preview">
    </section>
  <script type="text/javascript" src="bundle.js"></script></body>
</html>

And, if you run this for production, it works as we'd like!

> yarn webpack:production
yarn run v1.13.0
$ webpack $npm_package_config_webpack_args --env=production
Hash: 4ac7998fb8cac191a4fa
Version: webpack 4.29.6
Time: 1061ms
Built at: 2019-03-30 10:24:46
                         Asset       Size  Chunks             Chunk Names
bundle-e7e10692ba3eadfbb837.js   26.8 KiB       0  [emitted]  main
                    index.html  449 bytes          [emitted]  
Entrypoint main = bundle-e7e10692ba3eadfbb837.js
[0] ./js/index.js 334 bytes {0} [built]
[1] ./js/markdownPreviewer.js 381 bytes {0} [built]
    + 6 hidden modules
Child html-webpack-plugin for "index.html":
     1 asset
    Entrypoint undefined = index.html
    [0] ./node_modules/html-webpack-plugin/lib/loader.js!./html/index.html 583 bytes {0} [built]
    [2] (webpack)/buildin/global.js 472 bytes {0} [built]
    [3] (webpack)/buildin/module.js 497 bytes {0} [built]
        + 1 hidden module
Done in 1.79s.
> cat production/index.html
<!DOCTYPE html>
<html>
  <head>
    <!-- script tag is omitted - Webpack will provide -->
  </head>
  <body>
    <h1>Markdown Preview-o-tron 7000!</h1>
    <form id="editor">
      <textarea id="source" rows="10" cols="80"></textarea>
      <br>
      <input type="submit" value="Preview!">
    </form>
    <hr>
    <section id="preview">
    </section>
  <script type="text/javascript" src="bundle-e7e10692ba3eadfbb837.js"></script></body>
</html>

Nice! And, we can see that the production app works:

Production version still works

This was a bit of a slog, but we now have a decent project structure, and can do useful and basic things for development.

There's still room for improvement, but before we look at stuff like debugging and general ergonomics, we should look at how to handle CSS, because our Markdown previewer really could use some pizazz, and the only way to do that is CSS.