jpeterson-esristaff

How to Tame your Web App - Module Bundling 201 - Optimization

Blog Post created by jpeterson-esristaff Employee on Mar 7, 2017
First, the links:

Crowdsource Reporter Repo: https://github.com/esri/crowdsource-reporter

My Migration Repo: https://github.com/jpeterson/crowdsource-reporter

 

Where we are

This is the fourth part in a seven-part blog series called How to Tame your Web App. Here’s where we are:

  1. The Plan
  2. ES6 and Modern Modules
  3. Module Bundling 101
  4. Module Bundling 201 - Optimization (you are here)
  5. Linting
  6. UX and DX: The Best of Both Worlds
  7. Legacy Support - coming soon

 

Today's place: Austria

Header: Overlooking the Danube from the hills above Dürnstein.

 

Stephensdom

Stephensdom, in Stephensplatz, Vienna

 

Style/Asset Bundles 

 

The Plan

Our app still uses CSS and images the same way it always did – most likely via <link> tags in our index.html. Let’s bundle that up so it can be shipped in a more optimized package.

 

The Reason

CSS and image files are inefficient in the browser for the same reason JS files are. We’ll bundle and optimize our CSS so that it can be delivered more quickly to our users.

 

The Commits

https://github.com/jpeterson/crowdsource-reporter/pull/4/commits

 

For CSS, all we need to do is remove the <link> tag(s) in our index.html and instead import our CSS files via our JS entry file. In the future, you could refactor your CSS in such a way that you import only the styles needed for a module in that module – therefore making your bundling process even more streamlined.

 

Next, let’s address image files. This will require 2 more loaders:

> npm install file-loader url-loader –-save-dev

 

file-loader is dead simple – it takes a file and puts it in your output folder.

 

url-loader does a similar thing, but it turns the files into Data URIs instead. This is useful for smaller images that can be turned into Base64 encoded strings in our code, eliminating a network request. Base64, however, is only more efficient up to a certain file size – this is where the url-loader’s limit option helps by falling back to file-loader for files larger than a given size.

 

We’ll use url-loader as our “catch all” loader. Instead of configuring it to only include certain file types, we’ll tell it to exclude the file types we’re already handling with other loaders, and it will attempt to load everything else. Here is the config I used.

 

Quick note: you may not want to configure this “catch all” loader until you’ve taken account of all the file types your app uses – seeing Webpack build errors for unhandled file types will force you to think about how you want to load them.

 

Go ahead and fire off another build:

> npm run build

 

It works, right? If you look at your network request, you might feel like we've introduced some black magic. There are no CSS files being loaded, but your styles are still there… The reason for this is that Webpack is loading your CSS into JS files and injecting the styles for you. This may or may not be ideal for you. The biggest drawback is that it inhibits the browser’s ability to load CSS asynchronously and in parallel – so your page won’t be styled until all your JS is loaded. Let’s add one more step to get a real CSS file.

 

This time, instead of a loader, we’ll be using a Webpack Plugin:

> npm install extract-text-webpack-plugin –-save-dev

 

 Add the plugin to your config file (remember that you need to import it). Then tell your css-loader to use ExtractTextPlugin.extract.

 

Now simply add a <link> tag back into your index.html, referencing the name of the css file in your config.

 

That’s it! You now have an optimized CSS bundle being served as a single file. If all your CSS was in a single file to begin with, this might seem like a waste of time – but it’s not. You’ve laid the foundation to add more optimizations (minification, PostCSS, etc…) and to write your CSS in a more modular fashion.

 

That deserves a photo break.

 

Burg Aggstein

Burg Aggstein and the Danube in Wachau, Austria

 

Vendor Bundle

 

The Plan

To this point, we are still using vendor libraries by including them as <script> tags in our HTML – we will use Webpack to bundle them in a more intelligent manner.

 

The Reason

A single vendor bundle means fewer network requests; we can also take advantage of browser caching to serve this bundle faster when it doesn’t change.

 

The Commits

https://github.com/jpeterson/crowdsource-reporter/pull/5/commits

 

A quick note about this section: due to the issues with the Dojo Loader mentioned above, our vendor bundle will not include any Dojo-loaded resources (including the JSAPI). This is okay! The JSAPI is already optimized and served out via a speedy CDN, so let it do its thing. If you have a custom build of the JSAPI - that should work exactly the same way.

 

Again, this process will be specific to your app, but for Crowdsource Reporter, the 3rd party libraries have been downloaded and placed in a folder called /vendor, then included in the app via <script> tags in index.html.

 

First thing to do is start managing those dependencies properly using NPM. I’ll wait while you find all your dependencies on npmjs.com and install them (remember the --save argument). Make sure you download the same versions you were using previously! We don’t want to confuse some library’s breaking changes with Webpack issues.

 

Here is the dependency list for Crowdsource Reporter (excluding the JSAPI):

jQuery, Moment.js, Bootstrap, Bootstrap Datepicker, Bootstrap TouchSpin

 

Hopefully your dependency tree is simpler than this one – this particular app has a bit of a mess of dependencies. Dependencies 3, 4, and 5 all depend on jQuery (and check for its existence in different ways), and dependencies 4 and 5 depend on Bootstrap. Fun stuff.

 

My first inclination was to use webpack’s ProvidePlugin, which checks a module to see if it needs some external dependency (that you define), and makes that dependency available to it. So, I thought I could do something like this: 

new webpack.ProvidePlugin({  
  '$': 'jquery', 
  'jQuery': 'jquery'
})

 

That didn’t work. The reason is that Webpack re-imports the jQuery module any time it's needed as a dependency, but the way these jQuery plugins work is by attaching their functionality onto the jQuery reference they have. This works well in a setting where you have a single jQuery global reference, but the concept breaks down when you start passing a bunch of different jQuery references around. We need to provide the same instance of jQuery to our dependent modules.

 

This turned out to be the most painstaking part of this entire migration process – but eventually I figured it out. I’ll try to turn my curse-word-laden notes into a succinct explanation of the issues I had and how to resolve them .

 

Let me start by showing you the 4 lines of code I eventually needed: 

import jquery from 'script-loader!jquery';
import bootstrap from 'bootstrap';
import bootstrapDatetimepicker from 'imports-loader?moment,this=>window,define=>undefined,exports=>undefined!eonasdan-bootstrap-datetimepicker';
import bootstrapTouchspin from 'bootstrap-touchspin';

 

script-loader is a Webpack loader that sort of acts as a last line of defense against legacy “modules” (which aren't really modular at all). It essentially injects your module as if it were a <script> tag in an html file. I’m not going to blame jQuery for this – rather the jQuery plugin architecture. Anyway, the first line puts jQuery and $ in the global namespace. Bootstrap and Bootstrap TouchSpin are happy now – they don’t throw errors on load, and they function just fine in the app. Almost there.

 

Bootstrap Datepicker, on the other hand, is a bit cantankerous. This library tries to be intelligent about the module environment it is being used within, but in this case it just screws up what we’re trying to do. I ended up using imports-loader (which lets you pass specific "global" variables to a module) to trick the library into thinking we don’t know about modules: this=>window,define=>undefined,exports=>undefined. This made the library play nicely with the global jQuery. Lastly, we also pass it a reference to the moment module (not a global one, one that will get imported by Webpack).

 

Phew. That was a royal pain, but all our 3rd party libraries are now being loaded successfully by Webpack!

 

Melk Abbey

Spiral staircase inside Melk Abbey outside Melk, Austria 

 

Uuuuuugh. One more curveball from Bootstrap Datepicker. The npm package doesn’t ship with any CSS (just Sass). It would be overkill right now to involve a Sass compiler in our app just to support what I would consider an edge-case… so I’ll just import the CSS file that was jammed in that /vendor folder from before.

 

In case you think I forgot Moment.js, I didn’t. I just didn’t mention it yet because it’s a proper module that can be imported and used as necessary – none of this global namespace nonsense.

 

Running the build should work now, and looking closely we should see all our vendor code bundled up into bundle.js.

 

Let's stop and see where we are. We’ve now got our vendor dependencies being loaded by Webpack, enabling all its optimization goodies. That’s a win all by itself – but let’s go one step further and split the vendor code into its own bundle. This will let us take advantage of browser caching for that hefty vendor code that doesn’t change very often (i.e. your users won’t even need to download this code on repeat visits to your app).

 

To add this optimization, let’s head back to our webpack.config.js. We’re going to modify 3 sections: entry, output, and plugins.

 

entry

We need to add a second entry point that will correspond to our vendor bundle. It took me a while to figure this out, but we essentially need to just copy all the paths we were importing here into our config file – inline loaders and all. Don’t forget to throw moment in here as well.

 

Note that Webpack treats separate entry points as totally separate dependency trees – this is why we need to include that custom inline loader logic. It’s also why we will need to involve another webpack plugin soon... 

entry: {      
  bundle: './js/bootstrapper.js',
  vendor: [
    'script-loader!jquery',
    'moment',
    'bootstrap',
    'bootstrap-touchspin',
    'imports-loader?moment,this=>window,define=>undefined,exports=>undefined!eonasdan-bootstrap-datetimepicker'
  ]
}

  

output

Webpack supports a few placeholders in our config file to insert dynamic strings. We’ll use [name] here to make our output bundle match the name of the entry point. 

output: {
  path: path.resolve(__dirname, 'dist'),
  filename: '[name].js',
  publicPath: './',
  libraryTarget: 'amd'
}

  

plugins

If we run the build now, we get 2 separate bundles, which is what we want. But since Webpack treats the 2 entry points as separate dependency trees – it still thinks all our vendor dependencies are needed within the primary bundle. This is where the CommonsChunkPlugin is useful. This plugin will make sure the code in our vendor bundle isn’t duplicated in our primary bundle. Read over this page if you want to know more about how this plugin works. CommonsChunkPlugin is part of webpack core, so don’t forget to import Webpack at the top of your config. 

new webpack.optimize.CommonsChunkPlugin({ name: ['vendor'] })

 

Lastly, make sure you include the new vendor.js file in your index.html, and remove the import statements from your app’s entry file (bootstrapper.js in my case).

 

Now you’ve got a dedicated bundle for your 3rd party code!

 

On to part 5: Linting.

Outcomes