Journey to webpack, part 4 – the components

June 22, 2017 | By Brian | No Comments | Filed in: work.

New to the series? Start at the beginning

In part 3 we took care of our 3rd party libraries. Now, it’s time to deal with our 2nd party libraries.

What’s a 2nd party library?

Glad you asked – it’s the name I’ve given to libraries created by teams you collaborate with within your organization. This is usually where shared components and services go.

Aside: Sharing is always complicated. At what point does something get promoted to shared status? Everyone likes to think that what they are building is going to be used by millions of people, or at least everyone in their department. But starting off building something in the shared space is tricky. Does it support everyone’s use cases? How long before it is used by more than one team? We’ve had shared implementations of stuff that was never even used! I try to push for a simple rule where nothing gets promoted into shared until the 2nd team needs it. This works pretty well most of the time, but has the downside of all teams needing to know what each other has built. Oh you have a Google Analytics service too? Yep, currently we have 5 of them. The bright side of this is that the requirements of each are well defined. Now we can take a look at all the functionality of each and try to boil it down into a single service. Easier said than done, of course.

But I digress…

We’ve got libraries for handling language, logging, buttons, modals, and pokerchips. Currently these files get uglified into app-components.min.js.

new entry point

So, let’s create a new entry point file that will process these and call it app-components.ts. It will will look like this:

 import '../node_modules/app-angular-logging/dist/js/app-angular-logging';
 import '../node_modules/app-angular-language/dist/js/app-angular-language';
 import '../node_modules/app-angular-utilities/dist/js/app-angular-utilities';
 import '../node_modules/app-angular-meta/dist/app-angular-meta';
 import '../node_modules/app-angular-ui-alert/dist/js/app-angular-ui-alert';
 import '../node_modules/app-angular-ui-button/dist/js/app-angular-ui-button';
 import '../node_modules/app-angular-ui-card/dist/js/app-angular-ui-card';
 import '../node_modules/app-angular-ui-containers/dist/js/app-angular-ui-containers';
 import '../node_modules/app-angular-ui-dashboard/dist/js/app-angular-ui-dashboard';
 import '../node_modules/app-angular-ui-form/dist/js/app-angular-ui-form';
 import '../node_modules/app-angular-ui-loading/dist/js/app-angular-ui-loading';
 import '../node_modules/app-angular-ui-pokerchip/dist/js/app-angular-ui-pokerchip';
 import '../node_modules/app-angular-ui-modal/dist/js/app-angular-ui-modal';
 import '../node_modules/app-angular-ui-popover/dist/js/app-angular-ui-popover';
 import '../node_modules/app-angular-ui-select/dist/js/app-angular-ui-select';
 import '../node_modules/app-angular-ui-tabs/dist/js/app-angular-ui-tabs';
 import '../node_modules/app-angular-ui-slidein/dist/js/app-angular-ui-slidein';
 import '../node_modules/app-shared/dist/app-shared';
 import '../node_modules/app-angular-time/dist/js/app-angular-time.shared';

Here’s what our config looks like now

output: {
  filename: 'build/static/app/vendor/js/vendor.min.js'
},
entry: {
  vendor: './webpack-config/vendor.ts'
},

We need to add a new entry point, but we only have one output filename. Luckily webpack comes with a variable substitution mechanism that uses [name]. To take advantage of this, we’ll change entry.vendor to contain more of the destination, like this:

output: {
  filename: 'build/static/app/vendor/js/[name]'
},
entry: {
  'vendor.min.js': './webpack-config/vendor.ts',
},

Cool, that still work. Now we just need to add an entry for app-components.min.js

output: {
  filename: 'build/static/app/vendor/js/[name]'
},
entry: {
  'vendor.min.js': './webpack-config/vendor.ts',
  'app-components.min.js': './webpack-config/app-components.ts',
},

Errrr.. not so fast. Now app-components.min.js is being output into vendor/js/. Clearly not what we want.

Luckily, webpack has your back. It takes whatever you put as the key in the entry object as the [name] to substitute. Check this out:

output: {
  filename: 'build/static/app/[name]'
},

entry: {
  'vendor/js/vendor.min.js': './webpack-config/vendor.ts',
  'app-components.min.js': './webpack-config/app-components.ts',
},

YES! This results in exactly what we need:

 build/static/app/vendor/js/vendor.min.js
 build/static/app/app-components.min.js

Next, we can go and remove the ‘uglify:components’ from our list of Grunt tasks!

Now we can make a commit and relish in the glory of making our build process a slightly nicer place.

recap

Our homegrown components and libraries “just work” with webpack. Looking back, it would have been far less frustrating to start with these, but who knew?

Here is our updated webpack.config.js

Looking back at our TODO list, there is still much to do:

  1. Create vendor.min.js
  2. Create app-components.min.js
  3. Create app.min.js
  4. Create templates.app.min.js
  5. Try out webpack 3.0!
  6. Figure out what’s up with moment.js
  7. Use the webpack’d files in karma for unit tests
  8. Try the CommonChunksPlugin
  9. Handle CSS/LESS/SASS
  10. Use the dev-server instead of grunt-contrib-connect

Journey to webpack, part 3 – finish the vendors

June 19, 2017 | By Brian | No Comments | Filed in: work.

Welcome to part 3 of our journey, catch up with part 1 and part 2.

quick recap

We’ve injected webpack into our build process, it’s run before ‘uglify:vendor’ and is bundling 2 of our 27 3rd party libraries (detectizr & modernizr). We did this by paring down our vendor.ts file, which we use as an entry point, then massaging the libraries to work with webpack, using various loaders. Currently we have 2 “vendor” files that are mutually exclusive.

{
  test: require.resolve('modernizr'),
  use: [
   'expose-loader?Modernizr',
   'imports-loader?this=>window',
   'exports-loader?window.Modernizr'
  ]
},
{
  test: require.resolve('detectizr'),
  use: [
   'expose-loader?Detectizr',
   'imports-loader?this=>window',
   'exports-loader?window.Detectizr'
  ]
},

The next step is to get the rest of the vendor files converted over to use webpack, and remove the ‘uglify:vendor’ task completely.

let’s get to it

Let’s take it from the top. Here is what our vendor.ts file looks like:

 import '../node_modules/modernizr/modernizr';
 import '../node_modules/detectizr/dist/detectizr';
 // import '../node_modules/jquery/dist/jquery';
 // import '../node_modules/jquery.easing/jquery.easing';
 // import '../node_modules/lodash/index';
 // import '../node_modules/moment/min/moment-with-locales.min';
 // import '../node_modules/moment-timezone/builds/moment-timezone-with-data';
 // import '../node_modules/d3/d3';
 // import '../node_modules/bootstrap/dist/js/bootstrap';
 // import '../node_modules/fastclick/lib/fastclick';
 // import '../node_modules/angular/angular';
 // import '../node_modules/angular-cookie/angular-cookie';
 // import '../node_modules/angular-cookies/angular-cookies';
 // import '../node_modules/angular-ui-bootstrap/ui-bootstrap-tpls';
 // import '../node_modules/angular-moment/angular-moment';
 // import '../node_modules/angular-resource/angular-resource';
 // import '../node_modules/angular-sanitize/angular-sanitize';
 // import '../node_modules/angular-animate/angular-animate';
 // import '../node_modules/angular-touch/angular-touch';
 // import '../node_modules/angular-ui-router/release/angular-ui-router';
 // import '../node_modules/angulartics/src/angulartics';
 // import '../node_modules/angulartics/src/angulartics-ga';
 // import '../node_modules/log4javascript/log4javascript';
 // import '../node_modules/isotope/dist/isotope.pkgd';
 // import '../node_modules/phoneformat/phoneformat.min';
 // import '../node_modules/jquery-form/jquery.form';
 // import '../node_modules/intl-tel-input/build/js/intlTelInput';
 // import '../node_modules/intl-tel-input/lib/libphonenumber/build/utils';
 // import '../node_modules/bootstrap-colorpickersliders/dist/tinycolor';
 // import '../node_modules/bootstrap-colorpickersliders/dist/bootstrap.colorpickersliders';
 // import '../node_modules/ng-sortable/dist/ng-sortable';
 // import '../node_modules/highcharts/lib/highcharts';
 // import '../node_modules/highcharts/lib/modules/drilldown';
 // import '../node_modules/highcharts/lib/modules/exporting';
 // import '../node_modules/highcharts/lib/modules/heatmap';
 // import '../node_modules/oclazyload/dist/ocLazyLoad';
 // import '../node_modules/tslib/tslib';

We’ll go down the list one-by-one, uncommenting from here, and commenting out from our ‘uglify:vendor’ task then running the server to make sure there are no console errors in the browser.

First up: jQuery

Uncaught ReferenceError: jQuery is not defined

Looks like something in vendor.min.js (the non-webpack’d file) expects jQuery to already be loaded and to be defined on window. Luckily, we know how to fix this via the expose-loader. Since jQuery is also often referred to as $, we need to expose it as $ too.

{
  test: require.resolve('jquery'),
  use: [
   'expose-loader?jQuery',
   'expose-loader?$'
  ]
},

Success!

fast-forward a bit

We can clearly see where this is headed… let’s use expose-loader for all the libraries that create global variables: lodash, moment, fastclick, angular, d3, log4javascript, and highcharts

{
  test: require.resolve('lodash'),
  use: [ 'expose-loader?_' ]
},
{
  test: require.resolve('moment'),
  use: [ 'expose-loader?moment' ]
},
{
  test: require.resolve('fastclick'),
  use: [ 'expose-loader?FastClick' ]
},
{
  test: require.resolve('angular'),
  use: [ 'expose-loader?angular' ]
},
{
  test: require.resolve('d3'),
  use: [ 'expose-loader?d3' ]
},
{
  test: require.resolve('log4javascript'),
  use: [ 'expose-loader?log4javascript' ]
},
{
  test: require.resolve('highcharts'),
  use: [ 'expose-loader?Highcharts' ]
},

Seems simple enough.

But whooops, something is wrong with moment.

WARNING in ./~/moment/min/moment-with-locales.min.js
Module not found: Error: Can't resolve './locale' in '/Users/oloreb/dev/app/node_modules/moment/min'
@ ./~/moment/min/moment-with-locales.min.js 1:2798-2820
@ ./webpack-config/vendor.ts

A quick Google search and people suggesting the IgnorePlugin. Apparently the pathing for moment’s locale files is funky, so we will just ignore them for now. We’ll queue it up to deal with later.

new webpack.IgnorePlugin(/\.\/locale$/)

Success with no warnings this time! OK, what’s left now?

 // import '../node_modules/bootstrap/dist/js/bootstrap';
 // import '../node_modules/angular-cookie/angular-cookie';
 // import '../node_modules/angular-cookies/angular-cookies';
 // import '../node_modules/angular-ui-bootstrap/ui-bootstrap-tpls';
 // import '../node_modules/angular-moment/angular-moment';
 // import '../node_modules/angular-resource/angular-resource';
 // import '../node_modules/angular-sanitize/angular-sanitize';
 // import '../node_modules/angular-animate/angular-animate';
 // import '../node_modules/angular-touch/angular-touch';
 // import '../node_modules/angular-ui-router/release/angular-ui-router';
 // import '../node_modules/angulartics/src/angulartics';
 // import '../node_modules/angulartics/src/angulartics-ga';
 // import '../node_modules/isotope/dist/isotope.pkgd';
 // import '../node_modules/phoneformat/phoneformat.min';
 // import '../node_modules/jquery-form/jquery.form';
 // import '../node_modules/intl-tel-input/build/js/intlTelInput';
 // import '../node_modules/intl-tel-input/lib/libphonenumber/build/utils';
 // import '../node_modules/bootstrap-colorpickersliders/dist/tinycolor';
 // import '../node_modules/bootstrap-colorpickersliders/dist/bootstrap.colorpickersliders';
 // import '../node_modules/ng-sortable/dist/ng-sortable';
 // import '../node_modules/oclazyload/dist/ocLazyLoad';
 // import '../node_modules/tslib/tslib';

Not much! Next we knock out all the angular stuff, bootstrap, ng-sortable, ocLazyLoad and tslib.

This is going smooth!

Leaving us with a few odd balls:

 // import '../node_modules/isotope/dist/isotope.pkgd';
 // import '../node_modules/phoneformat/phoneformat.min';
 // import '../node_modules/intl-tel-input/build/js/intlTelInput';
 // import '../node_modules/intl-tel-input/lib/libphonenumber/build/utils';

Uncommenting isotope, leaves us with an error which we can resolve with our old friend imports-loader

{
  test: require.resolve('isotope/dist/isotope.pkgd'),
  use: [
   'imports-loader?this=>window'
  ]
}

Note: this is only a problem with isotope v2. If you are using isotope v3, use this method.

the final three

The last 3 items all deal with our telephone number input boxes.
Uncommenting phoneformat leaves us with:

i18n is not defined

Uncommenting intl-tel-input leaves us with with the same thing.

Neither just work on their own, so let’s dig into phoneformat

phoneformat uses a Google module loading system, so first we need to use imports-loader to convert “this=>window”

It’s also creating a global variable called goog, so we need to expose it via the expose-loader.
Then, similar to modernizr, we also need the exports-loader to export the goog variable.

{
  test: require.resolve('phoneformat'),
  use: [
   'expose-loader?goog',
   'imports-loader?this=>window',
   'exports-loader?goog'
  ]
},

This isn’t so bad, but we’re still not working. Same error, but at least window.goog is available.

We have libraries that expecting window.i18n to be available, but it’s different than window.goog, and we can only use expose-loader to expose a single variable (or, as with jQuery, expose a single variable with different names: jQuery & $).

Looking into the source of phoneformat, we find a a line declaring the i18n variable.  Not to be confused with window.goog.i18n, which is completely different (but threw me off the track for a solid hour!)

So, I thought to myself, hey if that said “window.i18n =” instead of “var i18n =”, we’d be in business. Luckily there is a plugin that lets you do string replacement:

{
  test: require.resolve('phoneformat'),
  use: [
   'expose-loader?goog',
   'imports-loader?this=>window',
   'exports-loader?goog',
   {
     loader: StringReplacePlugin.replace({
     replacements: [
       {
         pattern: /var i18n/ig,
         replacement: function (match, p1, offset, string) {
           return 'window.i18n';
         }
       }
     ]})
   }
  ]
},

Bingo! Nailed it! While this feels incredibly hacky, it’s 100% functional and the logic checks out. The string replacement is confined to the phoneformat JS file, and won’t affect others. We’ll have to revisit if we ever upgrade the library, but we can worry about that later. * For the record, even though it sounds like this took minutes, it took a few hours over the course of a few days to come up with this solution. In hindsight, it seems obvious, but reality is that this one had us stumped for a while. It’s good to know that webpack has your back for situations like these.

Now we switch over intl-tel-input, and it works without further changes!

Now we can go back through and remove references to files and tasks that build the old vendor.min.js (remember we’re using vendor-webpack.min.js). This includes the the grunt tasks and index.html. In fact, we’ll leave the references to vendor.min.js in the index.html file, and switch our webpack config to create vendor.min.js.

That’s it! We have now replaced ‘uglify:vendor’ with ‘shell:webpack’ to generate vendor.min.js!

Looking ahead, we’ve still got lots more for webpack to do:

  1. Create app-components.min.js
  2. Create templates.app.min.js
  3. Create app.min.js
  4. Handle CSS/LESS/SASS
  5. Try out webpack 3.0!
  6. Use the webpack’d files in karma for unit tests
  7. Use the dev server instead of grunt-contrib-connect
  8. Try the CommonChunksPlugin
  9. Figure out what’s up with moment.js

for reference

Here’s our webpack.config.js

Here are the versions of these libraries that we are currently using. Things change, and maybe newer releases of these libraries will play more nicely with webpack. So, here are the relevant entries from package.json:

 "angular": "1.5.5",
 "angular-cookie": "4.0.10",
 "angular-cookies": "1.5.5",
 "angular-moment": "0.10.3",
 "angular-resource": "1.5.5",
 "angular-sanitize": "1.5.5",
 "angular-touch": "1.5.5",
 "angular-resource": "1.5.5",
 "angular-sanitize": "1.5.5",
 "angular-touch": "1.5.5",
 "bootstrap-colorpickersliders": "3.0.2",
 "bootstrap": "3.3.5",
 "d3": "3.5.6",
 "detectizr": "2.0.0",
 "fastclick": "1.0.6",
 "highcharts": "4.1.10",
 "intl-tel-input": "3.6.1",
 "isotope": "2.0.0",
 "jquery": "2.1.4",
 "jquery-form": "3.46.0",
 "jquery.easing": "1.3.2",
 "lodash": "3.10.1",
 "log4javascript": "1.4.15",
 "modernizr": "2.8.3",
 "moment": "2.10.6",
 "moment-timezone": "0.5.1",
 "ng-sortable": "1.3.0",
 "oclazyload": "1.0.6",
 "phoneformat": "0.0.7",
 "tslib": "1.5.0",