Making Friends with Webpack


Why Webpack?

Recently I began re-writing a web-app I had done before, but this time with ReactJs. If you want to deliver a page that uses React and associated libraries, you will soon wind up with a long list of script src="..."' tags in the header or body of your page unless you use a bundler.

This is how I manage script inclusion when I work with AngularJs (1.x). Depending on the complexity of the project and the number of dependencies, it can produce dozens of script ...="" src="" tags into the index.html file. You have to be careful to make sure that scripts that contain definitions get loaded before the scripts that reference those definitions or your app won't run. It also takes bandwidth and resources to download and process all those files although browser-side caching, perhaps with etags, will help. The client still has to make a HEAD request for each included script.

There are other solutions, but examining them is beyond the scope of this post. This discussion on reactjs.org attempted to explore it, but didn't go into much detail, and is nevertheless a bit dated already.

For now, I'm going to assume that if you're working on a ReactJs project, Webpack is the bundler of choice. After all, can over 1.5 million developers be wrong?

Webpack Goodness

Webpack will bundle all the scripts in a project into one monolithic file, typically named "bundle.js", that a client page can load with one request. But it does a whole lot more than that.

Instead of scooping up every file matching '*.js' under a directory, you give it a starting file and it actually parses them looking for invocations of "require(...)". Only those assets that actually get required somewhere in code will be included in the bundle. I haven't yet examined how or whether it can handle something like:

function myfunc(path) {
  var lib = require(path)
}


There is however, a well-populated and traveled ecosystem of loaders and plugins that can add all sorts of functionality. According to this excellent post by @rajaraodv,

... Loaders work at the individual file level during or before the bundle is generated... Where as Plugins work at bundle or chunk level and usually work at the end of the bundle generation process. 
There are also resolvers that deal with actually resolving the file-paths or URIs to resources.

With the right plugins and configuration, Webpack takes everything it can get its hands on, including images and CSS files, and converts them to Javascript code that can be consumed by a running app.

Loading CSS

css-loader is a specialized loader that has the side effect of injecting style tags into the DOM whenever CSS has been loaded. Instead of stringified CSS, require('path/to/css') returns an object that can be assigned as an inline style property to a React component to assign classnames that match the CSS selectors.

Inline styles have a few disadvantages. They don't support CSS media queries. Therefore, in my code I chose to ignore the value returned by required(path/to/css) and use it only for its side-effect - injecting a style tag directly into the DOM at runtime with selectors that already match my class names. I may experiment with this in the future.

Finding a good way to load CSS into apps using ReactJs has become a bit of a minefield due to an overwhelming number of choices one has, each with different benefits and consequences. For now I opt to keep it simple.

Loading Images and Fonts

Bundling some assets, such as images and fonts into code can result in a bloated bundle that takes up several megabytes. file-loader will copy each asset it finds through require('path/to/file'). require() will return the new URL. This avoids bloat and allows the browser to load these assets in parallel. Even better, the assets get loaded only when code containing require('path/to/file') is actually executed, instead of immediately upon page load. The user doesn't have to wait for the browser to load stuff that she'll never see.

Have it Your Way

Would you like to load some asset, like an SVG file, as a data-url? No problem, the url-loader will do that. You can even give it a file size threshhold, above which it will revert to file-loader behavior.

babel-loader preprocesses its input with Babel, as you would guess, but it has its own eco-system of plugins to customize what Babel will transpile. You'll want to be sure to configure babel-loader to ignore everything from third-party libraries, since presumably, that should already be in ES5 for consumption in your code. Failing to do so will cause the build process to take much longer while babel-loader processes thousands of files under node_modules only to spit it back out again without doing any real work.

Finally, if you are going to distribute your project as an importable module, the webpack config key externals: lets you tell webpack what not to bundle. That would be almost anything that npm or yarn would install when it installs your module by reading the dependencies in your project's package.json. Failing to do so will result in a vastly bloated bundle. Before I used the externals key, my resulting bundle.js weighed about 1.1 MB. After configuring externals, it trimmed down to a svelte 133 Kb. That makes room for a few carry-ons plus some leg-room.

There may be some paranoid types who insist on nailing down each and every release of each library their project depends on, and therefore, are willing to pay the premium for bundling the everything and the kitchen sink into their bundle.js. Webpack doesn't care. Of course, you may have to explain why you do this to those who's projects consume your component.

In my case, there were a few third-party libraries that I absolutely had to include in my bundle.js or the app that consumes it downstream couldn't import it. webpack-node-externals lets you have your cake and eat it too. For example, you can configure it to treat everything under node_modules/ as external, but white-list the chosen few that must be in your bundle for things to work downstream. I'll get back to this soon.

babel-loader preprocesses its input with Babel, as you would guess, but it has its own eco-system of plugins to customize the what Babel will transpile. You'll want to be sure to configure babel-loader to ignore everything from third-party libraries, since presumably, that should already be in ES5 for consumption in your code. Failing to do so will cause the build process to take much longer while babel-loader processes thousands of files under node_modules only to spit it back out again without doing any real work.

Finally, if you are going to distribute your project as an importable module, the webpack config key externals: lets you tell webpack what not to bundle. That would be almost anything that npm or yarn would install when it installs your module by reading the dependencies in your project's package.json. Failing to do so will result in a vastly bloated bundle. Before I used the externals key, my resulting bundle.js weighed about 1.1 MB. After configuring externals, it trimmed down to a svelte 133 Kb. That makes room for a few carry-ons plus some leg-room. 

In case you want to exclude all third-party libraries but a select few, webpack-node-externals lets you do that. For example, you can configure it to treat everything under node_modules/ as external, but whitelist the chosen few that must be in your bundle for things to work downstream.  Have your cake and eat it too.

The Hard Part

My project displays an online calculator that helps people determine how long they will be able to afford to stay in various assisted-living facilities.  That's the part I want to with ReactJs.  The rest of it consists of static pages, like "About", "Privacy", and an introduction.

In order to make it easier to manage, I decided to separate concerns and split it into two projects.  The consumer project consists of mostly static pages.  The component project provides the calculator with all its logic.  The consumer then includes the component into one of its pages to present to visitors.

Stylesheets

The component project contains its own stylesheets.  I wanted these to be included in the bundle that the consumer project includes.  I also wanted to keep everything but my own code and stylesheets out of the bundle.  The consumer project will resolve those based on the dependencies it imports from the component project's package.json.

I ran into a snag the first time I tried to build the consumer.  It complained that it didn't know what to do with the require(path/to/stylesheet) tags.  The problem is that webpack running for the consumer needs to have access to the webpack loaders such as css-loader and scss-loader to resolve stylesheet assets in the component bundle in order to load them.

In a standalone project, those loaders should be declared as development-only dependencies, since they are needed only for the standalone build, but not the browser.  But if a bundle is going to be included by another app as a component and it is loading other resource types, it will need to keep those loaders in the bundle for deployment.  They are still used for only for development, but they are needed by nodejs when building the consumer bundle.  

In my case, css-loader, scss-loader, and file-loader all had to be re-categorized as unconditional dependencies, not dev-only dependencies.  This made sure that yarn or npm imported them into the consumer project along with the component project so it could use them to resolve the stylesheet and image assets that the component project had already bundled.

Path Aliases

I had a hard time getting path aliases to work.  These allow you to write "require('@lib/my-lib')" rather than "require('../../../../lib/my-lib')" from deep within your project source directory tree.  babel-plugin-module-resolver can resolve these.  I tried to configure it under the "babel" key in my projects package.json, but that didn't work.  I finally got the aliases to work when I moved all the Babel configuration into .babelrc.  Not package.json and not webpack.config.js.  No idea why those last two methods don't work, but come to think of it, putting all the Babel's configuration into .babelrc allows you to run Babel from the command line, yarn/npm, or through the webpack babel-loader with the identical configuration.  If the shoe fits, wear it.

Conclusion

Webpack is powerful and useful and packs lots of goodness for those building front-end Javascript projects.  It is not limited to React projects.  But it comes with a learning curve.  Depending on what you're trying to achieve, there can be a lot of moving parts that have to be configured properly in order to get result you want.

For reference, here is how I set up my webpack.config.js.  I have separate config files for production and dev.  webpack-merge takes care of merging each with common settings in webpack.config.js.

Some have complained that the documentation, while comprehensive, does not always do a good job of explaining what some things are for and how to use them.  This is especially true when it comes to some of the plugins.  More examples would be very helpful.  Webpack is still pretty young, so I'm betting that documentation will improve over time, as more people get involved and start contributing.

Nevertheless, there are already some excellent resources I found useful or that I might want to go back to:

Resources


Comments

Popular Posts