loading...

Stages of Learning Webpack, Pt. 3 - SourceMaps, Loaders, & Plugins

nitishdayal profile image Nitish Dayal ・12 min read

Intro

This article is a continuation of the Stages of Learning Webpack series. The repository containing the source code has been updated since the last article to include the latest versions of all dependencies as well as some configuration improvements (read: I might've made a lot of goofs the first time around).

Github Repo

Step 2, Cont.

At some point, you'll need to debug your application. Most modern browsers provide intuitive developer tools which can assist in debugging and optimizing your application.

In your text editor, change the first line in src/app/sayHello.js to the following:

export default name => alet(`Hello ${name}`);

We've placed an intentional error in our application by misspelling alert as alet. From the command line, run npm run build/yarn build. The build should still succeed; Webpack is not responsible for maintaining the accuracy of our JavaScript code. Open the index.html file in your browser and open your browser's developer tools. There will be an error message along the lines of ReferenceError: alet is not defined.

Clicking on the filename to the right of the error message will navigate us to the line & column of the file in which the error occurred. Since our index.html file is using the generated Webpack bundle to load our JavaScript code, we'll be pointed to the line in the generated Webpack bundle at which the error occurred. From there, it's up to us to work our way backwards from the point of error in the bundle to the point of error in our actual source code.

If that's what's involved in debugging, then I'm not debugging any more. There must be an easier way. We can do better. We have the technology.

Step 3

Branch: sourceMaps

Let's start by looking at the differences between this branch (sourceMaps) and the previous (init):


Changes to webpack.config.js:

We've added a new key to the Webpack configuration object; the devtool key. The value associated with this key depends on the value of the argument env.

module.exports = env => ({
  devtool: (env && env.production) ? "source-map" : "cheap-eval-source-map",
  entry: "./src/index.js",
  output: { filename: "./build/bundle.js" },
  resolve: { extensions: [".js"] }
});

We can rewrite the file in ES5 as:

module.exports = function(env) {
  var devtool;

  if (env !== undefined && env.production === true) {
      devtool = "source-map";
  } else {
      devtool = "cheap-eval-source-map";
  };

  return {
    devtool: devtool,
    entry: "./src/index.js",
    output: { filename: "./build/bundle.js" },
    resolve: { extensions: [".js"] }
  };
};

First, we declare a variable devtool. Then, if the env argument isn't undefined and env is an object with a key/value pair { production: true }, then define the value of devtool as a string "source-map". Otherwise, define devtool as "cheap-eval-source-map". The meaning associated with these values will be explained later; for now, I want to be clear that all we've done is create a variable, and define that variable as a string. The value of that string is dependent on a conditional statement (the if/else block).

Finally, we return an object with a set of key/value pairs that Webpack can use to create our bundle. The entry, output, and resolve key/value pairs have been carried over from the init branch.

Changes to package.json:

We've updated the scripts section of the package.json file.

Before:

/*...*/
  "scripts": {
    "build": "webpack"
  },
/*...*/

After:

/*...*/
  "scripts": {
    "dev": "webpack",
    "prod": "webpack --env.production"
  },
/*...*/

The name of the command which calls Webpack has been changed from build to dev. The naming convention implies that this will create a development version of the bundle, and this is true. We're not having Webpack run any sort of optimization when it creates the bundle. Our configuration just says 'take this entry file (src/index.js) and every file it imports, bundle them all together, and output that bundle as a file (./build/bundle.js).

There is also a new key, prod. Again, the naming convention implies that this will create a production version of the bundle. It doesn't. Yet. But it will! Right now, the only difference between the prod script and the dev script is that we're now passing an argument to the exported function in webpack.config.js as the env argument, which the function then uses to create and return the Webpack configuration object. To see this in action, you can place a console.log(env) statement inside the function exported from webpack.config.js.

// webpack.config.js
module.exports = env => {
  console.log(env);

  return {
    devtool: env && env.production ? "source-map" : "cheap-eval-source-map",
    entry: "./src/index.js",
    output: { filename: "./build/bundle.js" },
    resolve: { extensions: [".js"] }
  }
};

From the command line, run the command npm run dev/yarn dev.

> webpack

undefined
Hash: 9d81a1b766e4629aec0c
Version: webpack 2.6.1
Time: 82ms
            Asset     Size  Chunks             Chunk Names
./build/bundle.js  5.75 kB       0  [emitted]  main
   [0] ./src/app/sayHello.js 233 bytes {0} [built]
   [1] ./src/index.js 453 bytes {0} [built]

That undefined right after > webpack is our console.log(env) statement. It's undefined because we didn't pass any additional arguments to Webpack in our dev command. Now, let's run the npm run prod/yarn prod command from the command line.

> webpack --env.production

{ production: true }
Hash: cbc8e27e9f167ab0bc36
Version: webpack 2.6.1
Time: 90ms
                Asset     Size  Chunks             Chunk Names
    ./build/bundle.js  3.79 kB       0  [emitted]  main
./build/bundle.js.map  3.81 kB       0  [emitted]  main
   [0] ./src/app/sayHello.js 233 bytes {0} [built]
   [1] ./src/index.js 453 bytes {0} [built]

Instead of seeing undefined, we're seeing an object with one key/value pair { production: true }. These values match up with the conditional statement in our Webpack configuration; our conditional statement ensures that the argument env isn't undefined, and that it is an object with a key/value pair { production: true }. You might have noticed that the generated bundles from the commands are different as well. The bundle generated with the dev command is larger than bundle generated by prod, however the prod command generated an additional file bundle.js.map.


Open the file src/app/sayHello.js. Since this is a different branch of the Git repository, the error we previously placed in this file might not carry over if the changes were made in the init branch. If that's the case, change the first line so that the alert call is misspelled as alet. Save your changes, then run npm run dev/yarn dev from the command line again. Open index.html in your browser, then open the browser's devtools. You should have an error in the console stating alet is not defined.

If the console claims that this error is being generated in the index.html file, refresh the page. You should see something along the lines of:

ReferenceError: alet is not defined          sayHello.js?7eb0:1

Clicking on this error should take you to the line & file in which the error occurred, but you'll notice that the entire line is highlighted as an error. In this case, that's not entirely inaccurate. But let's say we change the src/app/sayHello.js file around again. This time, we'll change the reference to name inside of the alert call to be namen:

export default name => alert(`Hello ${namen}`);

export const donut = "I WANT YOUR DONUTS";

/**
 * Same code, ES5 style:
 * 
 * function sayName(name){
 *    return alert('Hello ' + name);
 * }
 * 
 * export default sayName;
 * 
 */

Run npm run dev/yarn dev from the command line again, and refresh the index.html file that's open in your browser. The console in your devtools should display a similar error message; namen is not defined. Clicking on the error message will, again, take us to the line in which the error occurred.

Now, run npm run prod/yarn prod from the command line, and refresh the index.html file in your browser. Open your devtools and look at the error in your console, the filename is now just sayHello.js. Clicking on the error navigates us not only to the file & line in which the error occurred, but also the column in which it occurred. The error underline is more specific as well; it begins at namen as opposed to underlining the whole first line.

And that's the difference between the two commands; the accuracy of the source maps they generate. The reason we use a less accurate version of source maps for development purposes is because they are faster to generate than to have Webpack generate full source map files each time we create a build. You can learn about the different options for source mapping with Webpack here: Webpack Devtool Configuration.

Step 4

Branch: loader

Notice that the generated bundles maintain all ES2015 syntax used in the source files; let & const, arrow functions, newer object literal syntax, etc. If we tried to run our application in an older browser which didn't have support for these features, the application would fail. This is where we'd usually take advantage of a transpiler such as Babel, TypeScript, CoffeeScript, etc. to run through our code and translate it to a version with better cross-browser support. The loader branch covers how to integrate TypeScript into our Webpack build process in order to transpile our application code down to ES3 syntax. Note that we don't introduce any TypeScript-specific features; I even leave the files as .js files. We'll be using TypeScript as an ESNext --> ES3 transpiler.

Strap in folks; this one's gonna be bumpy.

Dependencies

Looking at the package.json file, we've added two new developer dependencies.

  • TypeScript: As stated earlier, we'll use TypeScript as our transpiler.
  • TS-Loader: Loaders allow Webpack to understand more than JavaScript. In this case, TS-Loader allows Webpack to use TypeScript to load TypeScript (and JavaScript) files and transpile them based on your TypeScript configuration before generating a browser-friendly bundle.

To install these dependencies, run npm install from the command line. NPM should read the package.json file and install the dependencies as listed. In general, to install additional developer dependencies, you can run npm i -D <package-name>, where <package-name> is the package you want to install, ie: npm i -D typescript. The -D flag tells NPM to save the installed package as a developer dependency.

The prod command has been updated as well; it now includes the flag -p. The -p flag is an option that can be provided to the Webpack CLI (command line interface, the tool that NPM calls on when a script in the package.json file uses webpack) which provides optimizations for a production environment. We'll take a deeper look at this shortly.

TypeScript Configuration

The tsconfig.json file provides information for TypeScript to utilize when transpiling our code.

{
  "compilerOptions": {
    "allowJs": true,
    "module": "es2015",
    "target": "es3",
    "sourceMap": true,
    "strict": true
  },
  "include": [
    "./src/"
  ],
  "exclude": [
    "node_modules/",
    "./build/"
  ]
}

This configuration object tells TypeScript a few things:

  • TypeScript is generally used to transpile TypeScript files (.ts) into JavaScript. By setting allowJs to true, we're allowing TypeScript to transpile .js files.
  • TypeScript is capable of transpiling JavaScript to work with a variety of module systems. We're telling TypeScript to use the ES2015 module system because Webpack is able to apply some pretty nifty optimizations when applications are created using this variation.
  • We can target most JavaScript versions from ES3 to ESNext. Given that we're aiming for BETTER browser support, not horrendously worse, we go with ES3.
  • Generate source maps for each transpiled file.
  • Use all the strict type-checking features that TypeScript offers.

Webpack Configuration Updates

module: {
  devtool: env && env.production ? "source-map" : "inline-source-map",
  /* ... */
  rules: [
    {
      test: /\.js(x)?/,
      loader: "ts-loader",
      options: {
        transpileOnly: true,
        entryFileIsJs: true
      }
    }
  ]
}

We've introduced a new key to the Webpack configuration object; module. The module section provides information to Webpack regarding how to work with certain files that are utilized throughout the application. We've provided one rule, which can be read as such:

When Webpack comes across a file ending in .js or .jsx, it will first pass the files to TS-Loader before generating a the requested bundle. TS-Loader is responsible only for transpiling the application; we are not using any of the type-checking features provided by TypeScript.

The type of source map used for development environments has been changed from "cheap-eval-source-map" to "inline-source-map". The differences between these two options are covered in the Webpack documentation: here: Webpack Devtool Configuration.

Run npm run dev/yarn dev from the command line and open the index.html file in your browser. Everything should work as expected. Look at lines 73-105 in the generated bundle:

"use strict";
/* unused harmony export donut */
/* harmony default export */ __webpack_exports__["a"] = (function (name) { return alert("Hello " + name); });;
var donut = "I WANT YOUR DONUTS";
/**
 * Same code, ES5 style:
 *
 * function sayName(name){
 *    return alert('Hello ' + name);
 * }
 *
 * export default sayName;
 *
 */


/***/ }),
/* 1 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__app_sayHello__ = __webpack_require__(0);

// Import whatever the default export is from /app/sayHello
// and refer to it in this file as 'Hello'
var name = "Nitish";
// Reference to the <div id="root"> element in
var root = document.getElementById("root");
// Call the function that was imported from /app/sayHello, passing in
// `const name` that was created on line 5.
__webpack_require__.i(__WEBPACK_IMPORTED_MODULE_0__app_sayHello__["a" /* default */])(name);
root.textContent = "Helllloooo " + name + "!";

All const and let declarations have been converted to var. The template strings used in the alert message and for root.textContent have been replaced with string concatenation. Our bundle was created using the transpiled code generated by TypeScript.

If we remember from earlier, src/app/sayHello.js exports two items; a function as a default export, and a constant donut as a named export.

export default name => alert(`Hello ${name}`);

export const donut = "I WANT YOUR DONUTS";

The second export isn't used anywhere in the application, but it's still included in the bundle. However, if we run npm run prod/yarn prod and take a look at our bundle then...

It's a hot mess! Here's a (nicer, formatted) look at the bundle:

!(function(t) {
  function e(r) {
    if (n[r]) return n[r].exports;
    var o = (n[r] = { i: r, l: !1, exports: {} });
    return t[r].call(o.exports, o, o.exports, e), (o.l = !0), o.exports;
  }
  var n = {};
  (e.m = t), (e.c = n), (e.i = function(t) {
    return t;
  }), (e.d = function(t, n, r) {
    e.o(t, n) ||
      Object.defineProperty(t, n, { configurable: !1, enumerable: !0, get: r });
  }), (e.n = function(t) {
    var n = t && t.__esModule
      ? function() {
          return t.default;
        }
      : function() {
          return t;
        };
    return e.d(n, "a", n), n;
  }), (e.o = function(t, e) {
    return Object.prototype.hasOwnProperty.call(t, e);
  }), (e.p = ""), e((e.s = 1));
})([
  function(t, e, n) {
    "use strict";
    e.a = function(t) {
      return alert("Hello " + t);
    };
  },
  function(t, e, n) {
    "use strict";
    Object.defineProperty(e, "__esModule", { value: !0 });
    var r = n(0), o = document.getElementById("root");
    n.i(r.a)("Nitish"), (o.textContent = "Helllloooo Nitish!");
  }
]);
//# sourceMappingURL=bundle.js.map

It's still a hot mess! There isn't much of a need to manually parse through this; it's 38 lines of IIFE goodness, so it's doable, but there's no obligation and it won't assist with the rest of this guide. What I'm trying to show here is that the generated production bundle has no reference to the line const donut = "I WANT YOUR DONUTS!";. It's completely dropped from the bundle. Along with the minification, uglification, and handful of other out-of-the-box production optimizations Webpack is capable of implementing when provided the -p flag, tree-shaking is part of that list. I didn't have to do anything to enable tree-shaking; it Just Works™.

Excellent! We're transpiling our ES2015+ code down to ES3, removing any unused code along the way, and generating a production(ish)-quality bundle that can be loaded by most modern browsers with errors and warnings pointing back to our source code for simplified debugging.

Step 5

Branch: plugin

Plugins do exactly what they say on the tin; they plug into the build process to introduce extra functionality. In this example, we'll get introduced to HTMLWebpackPlugin, a plugin for generating HTML documents which can serve our Webpack bundles.

As it stands, we created an HTML file which points to the expected bundle. In simple situations, a setup like this would work fine. As the application grows, the bundle could get split into more than one file, The filenames might be randomly generated, etc. If we were to try and manually maintain the list of files that need to be loaded into our HTML file...well, we're kind of back to square A, right? We'll use HTMLWebpackPlugin to automate the process of loading our bundles into our HTML document.

File Changes

  1. Introduced a new developer dependency to the package.json file; HTMLWebpackPlugin. Make sure to run npm install/yarn when you've switched to this branch to get the necessary dependencies.
    "devDependencies": {
      "html-webpack-plugin": "^2.28.0",
      "ts-loader": "^2.1.0",
      "typescript": "^2.3.4",
      "webpack": "^2.6.1"
    }
  1. The index.html file no longer loads the build/bundle.js file.

  2. webpack.config.js has been updated to include a CommonJS style import statement (const HTMLWebpackPlugin = require("html-webpack-plugin");) at the top of the file, and has a new section, plugins:

    //webpack.config.js
    const HTMLWebpackPlugin = require("html-webpack-plugin");

    module.exports = env => {
      /*...*/
      plugins: [
        new HTMLWebpackPlugin({
          filename: "./build/index.html",
          inject: "body",
          template: "./index.html"
        })
      ]
    }

We're telling Webpack that we'll be using HTMLWebpackPlugin to generate an HTML file named index.html inside of the build folder. HTMLWebpackPlugin is to take any generated bundles and inject them into the body of the HTML file in script tags. It will use the existing index.html found in our application root as a template.

If we call on npm run dev/yarn dev or npm run prod/yard prod, we should see something similar to:

$ npm run dev

> webpack -p --env.production

ts-loader: Using typescript@2.3.4 and /Projects/dev_to/webpack_configs/example/tsconfig.json
Hash: 693b4a366ee89bdb9cde
Version: webpack 2.6.1
Time: 2233ms
             Asset       Size  Chunks             Chunk Names
 ./build/bundle.js    8.96 kB       0  [emitted]  main
./build/index.html  352 bytes          [emitted]

Based on the configuration provided, Webpack generated the requested bundle along with an index.html file. The generated index.html file looks very similar to our existing template, but carries a reference to the generated Webpack bundle inside of the document body.

Open the new index.html file (./build/index.html) in your browser to make sure everything works as expected.

Now stand back, rejoice in your work, and soak it all in. You're on your way to Webpacking the world, amigos.

Discussion

pic
Editor guide