Webpack from Nothing

Writing Code in Another Language

JavaScript is not a great programming language. Even with Webpack giving us the ability to write modular code, JavaScript is still a bit weak.

For example, we have to remember to use === everywhere or things get weird. We also have to type the keyword function a lot. Scoping is a mess, we can't make constants, and it would be nice to define a proper class that handles this.

ECMAScript 2015 addresses all of these failures in JavaScript, but we can't run it in a browser. What we can do is translate it to JavaScript, allowing us to write modern code that still runs in a browser.

Write Modern JavaScript, Ship Compatible JavaScript

To do this with Webpack, we'll need to set up Babel, which will do the work. Babel advertises itself as “a JavaScript compiler” which is also kindof what Webpack is. The confusion lies around what is meant by the word “JavaScript”. In Babel's case, it means “a newer version of JavaScript than your browser can produce”, which is sort of what Webpack can do as well. Suffice it to say, Babel handles the newest version of JavaScript most properly, so we do need it to accomplish our goal of writing entirely in ES2015, but being able to rely on our code working in lots of browsers that don't support it.

Let's write some! Replace js/markdownPreviewer.js with this:

import { markdown } from "markdown";

const attachPreviewer = ($document,sourceId,previewId) => {
  return (event) => {
    const text    = $document.getElementById(sourceId).value,
          preview = $document.getElementById(previewId);

    preview.innerHTML = markdown.toHTML(text);
    event.preventDefault();
  };
}

export default {
  attachPreviewer: attachPreviewer
}

It's fairly similar, because we don't have that much, but note that we've changed from function to using arrows and we've changed our use of var to const, since the variables never get assigned more than once.

Also note that if you run Webpack now, and you are using a modern browser, this code has a good chance of working. But, it won't work for all browsers, including ones we want to support. Let's continue.

First, we'll install babel. Which, sadly, cannot be accomplished via yarn add babel. Instead we must:

> yarn add @babel/core -D
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
└─ @babel/core@7.4.0
info All dependencies
└─ @babel/core@7.4.0
Done in 2.94s.
warning Pattern ["@babel/core@^7.4.0"] is trying to unpack in the same destination "/Users/davec/Library/Caches/Yarn/v4/npm-@babel-core-7.4.0-248fd6874b7d755010bfe61f557461d4f446d9e9/node_modules/@babel/core" as pattern ["@babel/core@^7.1.0","@babel/core@^7.1.0"]. This could result in non-deterministic behavior, skipping.
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".

Yes, that is an @ sign which is a scoped package and more and more big frameworks are using it to group their submodules.

We'll also need the Babel loader for Webpack:

> yarn add babel-loader -D
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
└─ babel-loader@8.0.5
info All dependencies
└─ babel-loader@8.0.5
Done in 3.07s.
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".

Babel should process all JavaScript, so we'll configure the loader in config/webpack.common.config.js to handle all .js files, save for those in node_modules, which are already assumed to be ready for distribution to a browser:

const HtmlPlugin = require("html-webpack-plugin");
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  module: {
    rules: [
/* start new code */
      {
        test: /.js$/,
        exclude: /node_modules/,
        loader: "babel-loader"
      },
/* end new code */
      {
        test: /.css$/,
        use: [
          MiniCssExtractPlugin.loader,
          "css-loader"
        ]
      }
    ]
  },
  plugins: [
    new HtmlPlugin({
      template: "./html/index.html"
    })
  ],
  entry: './js/index.js',
  mode: 'none'
};

Of course, Babel doesn't automatically do anything, so we need more configuration and more modules. Babel has the concept of presets, but of course, none of them are actually pre-set. We'll use the recommend “env” preset, which means “generally do the right thing without having to configure stuff”, which is a godsend, so we'll take it.

> yarn add @babel/preset-env -D
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 59 new dependencies.
info Direct dependencies
└─ @babel/preset-env@7.4.2
info All dependencies
├─ @babel/helper-builder-binary-assignment-operator-visitor@7.1.0
├─ @babel/helper-call-delegate@7.4.0
├─ @babel/helper-define-map@7.4.0
├─ @babel/helper-explode-assignable-expression@7.1.0
├─ @babel/helper-member-expression-to-functions@7.0.0
├─ @babel/helper-replace-supers@7.4.0
├─ @babel/helper-wrap-function@7.2.0
├─ @babel/plugin-proposal-async-generator-functions@7.2.0
├─ @babel/plugin-proposal-json-strings@7.2.0
├─ @babel/plugin-proposal-object-rest-spread@7.4.0
├─ @babel/plugin-proposal-optional-catch-binding@7.2.0
├─ @babel/plugin-proposal-unicode-property-regex@7.4.0
├─ @babel/plugin-transform-arrow-functions@7.2.0
├─ @babel/plugin-transform-async-to-generator@7.4.0
├─ @babel/plugin-transform-block-scoped-functions@7.2.0
├─ @babel/plugin-transform-block-scoping@7.4.0
├─ @babel/plugin-transform-classes@7.4.0
├─ @babel/plugin-transform-computed-properties@7.2.0
├─ @babel/plugin-transform-destructuring@7.4.0
├─ @babel/plugin-transform-dotall-regex@7.2.0
├─ @babel/plugin-transform-duplicate-keys@7.2.0
├─ @babel/plugin-transform-exponentiation-operator@7.2.0
├─ @babel/plugin-transform-for-of@7.4.0
├─ @babel/plugin-transform-function-name@7.2.0
├─ @babel/plugin-transform-literals@7.2.0
├─ @babel/plugin-transform-modules-amd@7.2.0
├─ @babel/plugin-transform-modules-commonjs@7.4.0
├─ @babel/plugin-transform-modules-systemjs@7.4.0
├─ @babel/plugin-transform-modules-umd@7.2.0
├─ @babel/plugin-transform-named-capturing-groups-regex@7.4.2
├─ @babel/plugin-transform-new-target@7.4.0
├─ @babel/plugin-transform-object-super@7.2.0
├─ @babel/plugin-transform-parameters@7.4.0
├─ @babel/plugin-transform-regenerator@7.4.0
├─ @babel/plugin-transform-shorthand-properties@7.2.0
├─ @babel/plugin-transform-spread@7.2.2
├─ @babel/plugin-transform-sticky-regex@7.2.0
├─ @babel/plugin-transform-template-literals@7.2.0
├─ @babel/plugin-transform-typeof-symbol@7.2.0
├─ @babel/plugin-transform-unicode-regex@7.2.0
├─ @babel/preset-env@7.4.2
├─ browserslist@4.5.3
├─ caniuse-lite@1.0.30000955
├─ core-js-compat@3.0.0
├─ core-js-pure@3.0.0
├─ core-js@3.0.0
├─ electron-to-chromium@1.3.122
├─ js-levenshtein@1.1.6
├─ node-releases@1.1.12
├─ private@0.1.8
├─ regenerate-unicode-properties@8.0.2
├─ regenerator-transform@0.13.4
├─ regexp-tree@0.1.5
├─ regjsgen@0.5.0
├─ regjsparser@0.6.0
├─ unicode-canonical-property-names-ecmascript@1.0.4
├─ unicode-match-property-ecmascript@1.0.4
├─ unicode-match-property-value-ecmascript@1.1.0
└─ unicode-property-aliases-ecmascript@1.0.5
Done in 5.60s.
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, create a config file for Babel in the root directory (yup) called .babelrc (note that this file is JSON and not JavaScript, so it's far easier to mess up, and you can be sure you won't get a good error message if you do):

{
  "presets": ["@babel/preset-env"]
}

And now, re-run Webpack:

> yarn webpack
yarn run v1.13.0
$ webpack $npm_package_config_webpack_args
Hash: 3d25c346c0c2776d05af
Version: webpack 4.29.6
Time: 1036ms
Built at: 2019-03-30 10:26:25
     Asset       Size  Chunks             Chunk Names
 bundle.js    210 KiB       0  [emitted]  main
index.html  833 bytes          [emitted]  
styles.css    327 KiB       0  [emitted]  main
Entrypoint main = styles.css bundle.js
[0] ./js/index.js 382 bytes {0} [built]
[2] ./css/styles.css 39 bytes {0} [built]
[3] ./js/markdownPreviewer.js 397 bytes {0} [built]
    + 9 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 961 bytes {0} [built]
    [2] (webpack)/buildin/global.js 472 bytes {0} [built]
    [3] (webpack)/buildin/module.js 497 bytes {0} [built]
        + 1 hidden module
Child mini-css-extract-plugin node_modules/css-loader/dist/cjs.js!css/styles.css:
    Entrypoint mini-css-extract-plugin = *
    [0] ./node_modules/css-loader/dist/cjs.js!./css/styles.css 210 bytes {0} [built]
        + 1 hidden module
Child mini-css-extract-plugin node_modules/css-loader/dist/cjs.js!node_modules/tachyons/css/tachyons.css:
    Entrypoint mini-css-extract-plugin = *
       2 modules
Done in 1.69s.

If you inspect dev/bundle.js, you can see that the fancy arrows and use of const is gone, replaced with old school JavaScript.

Now, we can write modern JavaScript and not worry about what browsers actually support it. What about tests?

Using Modern JavaScript to Write Tests

Our test files are pretty basic, so there's nothing exciting about using arrows as those will work with the version of Node we are using. Instead, let's use an experimental feature called optional chaining. As of this writing, it's not supported by many browser or by Node. It will also require adding a plugin to Babel to support, so this should be a good learning.

The way optional chaining works is to allow you to safely dereference a deep object without worrying about undefined:

const o = {
  foo: {
    bar: "blah"
   }
};

console.log(o?.foo?.bar); # => "blah"

We'll add this code as a test:

import markdownPreviewer from "../js/markdownPreviewer"

describe("markdownPreviewer", () => {
  it("should exist", () => {
    expect(markdownPreviewer).toBeDefined();
  });
  it("should allow deep references", () => {
    const o = {
      foo: {
        bar: "blah"
       }
    };

    expect((o?.foo?.bar)).toBe("blah");
  });
});

This should fail with a huge stack about the syntax error we created:

> yarn test
yarn run v1.13.0
$ yarn webpack:test && yarn jest
$ webpack $npm_package_config_webpack_args --env=test
Hash: 69aafdd891ec560ceee3
Version: webpack 4.29.6
Time: 548ms
Built at: 2019-03-30 10:26:27
         Asset       Size  Chunks             Chunk Names
bundle.test.js   13.7 KiB       0  [emitted]  main
    index.html  797 bytes          [emitted]  
Entrypoint main = bundle.test.js
[0] multi ./test/canary.test.js ./test/markdownPreviewer.test.js 40 bytes {0} [built]
[1] ./test/canary.test.js 108 bytes {0} [built]
[2] ./test/markdownPreviewer.test.js 4.67 KiB {0} [built] [failed] [1 error]

ERROR in ./test/markdownPreviewer.test.js
Module build failed (from ./node_modules/babel-loader/lib/index.js):
SyntaxError: /Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/test/markdownPreviewer.test.js: Support for the experimental syntax 'optionalChaining' isn't currently enabled (14:14):

  12 |     };
  13 | 
> 14 |     expect((o?.foo?.bar)).toBe("blah");
     |              ^
  15 |   });
  16 | });
  17 | 

Add @babel/plugin-proposal-optional-chaining (https://git.io/vb4Sk) to the 'plugins' section of your Babel config to enable transformation.
    at Parser.raise (/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/@babel/parser/lib/index.js:3851:17)
    at Parser.expectPlugin (/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/@babel/parser/lib/index.js:5170:18)
    at Parser.parseSubscript (/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/@babel/parser/lib/index.js:5951:12)
    at Parser.parseSubscripts (/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/@babel/parser/lib/index.js:5937:19)
    at Parser.parseExprSubscripts (/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/@babel/parser/lib/index.js:5926:17)
    at Parser.parseMaybeUnary (/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/@babel/parser/lib/index.js:5896:21)
    at Parser.parseExprOps (/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/@babel/parser/lib/index.js:5783:23)
    at Parser.parseMaybeConditional (/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/@babel/parser/lib/index.js:5756:23)
    at Parser.parseMaybeAssign (/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/@babel/parser/lib/index.js:5703:21)
    at Parser.parseParenAndDistinguishExpression (/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/@babel/parser/lib/index.js:6468:28)
    at Parser.parseExprAtom (/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/@babel/parser/lib/index.js:6262:21)
    at Parser.parseExprSubscripts (/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/@babel/parser/lib/index.js:5916:23)
    at Parser.parseMaybeUnary (/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/@babel/parser/lib/index.js:5896:21)
    at Parser.parseExprOps (/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/@babel/parser/lib/index.js:5783:23)
    at Parser.parseMaybeConditional (/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/@babel/parser/lib/index.js:5756:23)
    at Parser.parseMaybeAssign (/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/@babel/parser/lib/index.js:5703:21)
    at Parser.parseExprListItem (/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/@babel/parser/lib/index.js:6979:18)
    at Parser.parseCallExpressionArguments (/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/@babel/parser/lib/index.js:6123:22)
    at Parser.parseSubscript (/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/@babel/parser/lib/index.js:6018:29)
    at Parser.parseSubscripts (/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/@babel/parser/lib/index.js:5937:19)
    at Parser.parseExprSubscripts (/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/@babel/parser/lib/index.js:5926:17)
    at Parser.parseMaybeUnary (/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/@babel/parser/lib/index.js:5896:21)
    at Parser.parseExprOps (/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/@babel/parser/lib/index.js:5783:23)
    at Parser.parseMaybeConditional (/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/@babel/parser/lib/index.js:5756:23)
    at Parser.parseMaybeAssign (/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/@babel/parser/lib/index.js:5703:21)
    at Parser.parseExpression (/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/@babel/parser/lib/index.js:5651:23)
    at Parser.parseStatementContent (/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/@babel/parser/lib/index.js:7422:23)
    at Parser.parseStatement (/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/@babel/parser/lib/index.js:7293:17)
    at Parser.parseBlockOrModuleBlockBody (/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/@babel/parser/lib/index.js:7879:25)
    at Parser.parseBlockBody (/Users/davec/Projects/Personal/wtf-is-a-webpack/work/work/node_modules/@babel/parser/lib/index.js:7866:10)
 @ multi ./test/canary.test.js ./test/markdownPreviewer.test.js main[1]
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 961 bytes {0} [built]
    [2] (webpack)/buildin/global.js 472 bytes {0} [built]
    [3] (webpack)/buildin/module.js 497 bytes {0} [built]
        + 1 hidden module
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
error Command failed with exit code 2.
error Command failed with exit code 1.

To allow using this new feature, we'll add the babel plugin @babel/plugin-proposal-optional-chaining to our project:

> yarn add @babel/plugin-proposal-optional-chaining -D
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 2 new dependencies.
info Direct dependencies
└─ @babel/plugin-proposal-optional-chaining@7.2.0
info All dependencies
├─ @babel/plugin-proposal-optional-chaining@7.2.0
└─ @babel/plugin-syntax-optional-chaining@7.2.0
Done in 3.50s.
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".

To use this, we'll add the plugins: key in our .babelrc file:

{
  "presets": ["@babel/preset-env"],
  "plugins": ["@babel/plugin-proposal-optional-chaining"]
}

Now, when we re-run our tests, they pass, since the new syntax as transpiled by Babel:

> yarn test
yarn run v1.13.0
$ yarn webpack:test && yarn jest
$ webpack $npm_package_config_webpack_args --env=test
Hash: b0e1eedb795a0ab83457
Version: webpack 4.29.6
Time: 696ms
Built at: 2019-03-30 10:26:34
         Asset       Size  Chunks             Chunk Names
bundle.test.js    210 KiB       0  [emitted]  main
    index.html  797 bytes          [emitted]  
Entrypoint main = bundle.test.js
[0] multi ./test/canary.test.js ./test/markdownPreviewer.test.js 40 bytes {0} [built]
[1] ./test/canary.test.js 108 bytes {0} [built]
[2] ./test/markdownPreviewer.test.js 459 bytes {0} [built]
[3] ./js/markdownPreviewer.js 397 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 961 bytes {0} [built]
    [2] (webpack)/buildin/global.js 472 bytes {0} [built]
    [3] (webpack)/buildin/module.js 497 bytes {0} [built]
        + 1 hidden module
$ jest test/bundle.test.js
Done in 4.59s.
PASS test/bundle.test.js
  canary
    ✓ can run a test (4ms)
  markdownPreviewer
    ✓ should exist
    ✓ should allow deep references

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        1.184s
Ran all test suites matching /test\/bundle.test.js/i.

From here, you can configure Babel in a ton of ways. Notably, you will need to use Babel if you want to use React and write JSX files.

Where We Are

We started with nothing, and we gradually changed our configuration to solve real problems. In the end, what we have isn't bad! We can write modular JavaScript, use third party libraries and CSS, get useful stack traces, run unit tests, and bundle for production. We can also use bleeding edge features of JavaScript while ensuring browser compatibility. That's pretty good!

And, we didn't have to write a ton of configuration:

> wc -l webpack.config.js config/webpack.*
6 webpack.config.js
      30 config/webpack.common.config.js
      17 config/webpack.dev.config.js
      21 config/webpack.production.config.js
      21 config/webpack.test.config.js
      95 total

That's less than 100 lines total, and we have a completely workable development environment.

My hope is you have taken a few things away from this. First, I hope you understand Webpack a bit better and can navigate it's basic features and learn what works and why. Second, and more important, I hope you feel confident working with tools that just aren't very well designed for your needs. I hope you feel validated when you feel frustrated by cryptic error messages that it's not just you. I hope you feel like running to Google or browsing GitHub issues to find out how to use your tools is perfectly normal.

Webpack clearly values moving fast and delivering features over ergonomics and predictability. There's nothing inherently wrong with that tradeoff, but it means you have to shoulder the burden when things dont' work the first time, and they rarely do. Hopefully, you've seen that by taking small steps each time and changing very little in each step will allow you to understand what has broken this time and how you might fix it.

That said, I want to spend the last chapter discussing the design decisions that I believe make this entire thing so difficult to deal with and what might make it all work better. These are ways of thinking that help you build any application, even if it's not a build tool.