Monday 19 December 2016

Using ts-loader with webpack 2

Hands up, despite being one of the maintainers of ts-loader (a TypeScript loader for webpack) I have not been tracking webpack v2. My reasons? Well, I'm keen on cutting edge but bleeding edge is often not a ton of fun as dealing with regularly breaking changes is frustrating. I'm generally happy to wait for things to settle down a bit before leaping aboard. However, webpack 2 RC'd last week and so it's time to take a look!

Porting our example

Let's take ts-loader's webpack 1 example and try and port it to webpack 2. Will it work? Probably; I'm aware of other people using ts-loader with webpack 2. It'll be a voyage of discovery. Like Darwin on the Beagle, I shall document our voyage for a couple of reasons:

  • I'm probably going to get some stuff wrong. That's fine; one of the best ways to learn is to make mistakes. So do let me know where I go wrong.
  • I'm doing this based on what I've read in the new docs; they're very much a work in progress and the mistakes I make here may lead to those docs improving even more. That matters; documentation matters. I'll be leaning heavily on the Migrating from v1 to v2 guide.

So here we go. Our example is one which uses TypeScript for static typing and uses Babel to transpile from ES-super-modern (yes - it's a thing) to ES-older-than-that. Our example also uses React; but that's somewhat incidental. It only uses webpack for typescript / javascript and karma. It uses gulp to perform various other tasks; so if you're reliant on webpack for less / sass compilation etc then I have no idea whether that works.

First of all, let's install the latest RC of webpack:


npm install webpack@2.2.0-rc.1 --save-dev

webpack.config.js

Let's look at our existing webpack.config.js:


'use strict';

var path = require('path');

module.exports = {
  cache: true,
  entry: {
    main: './src/main.tsx',
    vendor: [
      'babel-polyfill',
      'fbemitter',
      'flux',
      'react',
      'react-dom'
    ]
  },
  output: {
    path: path.resolve(__dirname, './dist/scripts'),
    filename: '[name].js',
    chunkFilename: '[chunkhash].js'
  },
  module: {
    loaders: [{
      test: /\.ts(x?)$/,
      exclude: /node_modules/,
      loader: 'babel-loader?presets[]=es2016&presets[]=es2015&presets[]=react!ts-loader'
    }, {
      test: /\.js$/,
      exclude: /node_modules/,
      loader: 'babel',
      query: {
        presets: ['es2016', 'es2015', 'react']
      }
    }]
  },
  plugins: [
  ],
  resolve: {
    extensions: ['', '.webpack.js', '.web.js', '.ts', '.tsx', '.js']
  },
};

There's a number of things we need to do here. First of all, we can get rid of the empty extension under resolve; I understand that's unnecessary now. Also, I'm going to get rid of '.webpack.js' and '.web.js'; I never used them anyway. Also, just having 'babel' as a loader won't fly anymore. We need that suffix as well.

Now I could start renaming loaders to rules as the terminology is changing. But I'd like to deal with that later since I know the old school names are still supported at present. More interestingly, I seem to remember hearing that one of the super exciting things about webpack is that it supports modules directly now. (I think that's supposed to be good for tree-shaking but I'm not totally certain.)

Initially I thought I was supposed to switch to a custom babel preset called babel-preset-es2015-webpack. However it has a big "DEPRECATED" mark at the top and it says I should just use babel-preset-es2015 (which I already am) with the following option specified:


{
    "presets": [
        [
            "es2015",
            {
                "modules": false
            }
        ]
    ]
}

Looking at our existing config you'll note that for js files we're using query (options in the new world I understand) to configure babel usage. We're using query parameters for ts files. I have zero idea how to configure preset options using query parameters. Fiddling with query / options didn't seem to work. So, I've decided to abandon using query entirely and drop in a .babelrc file using our presets combined with the modules setting:


{
   "presets": [
      "react", 
      [
         "es2015",
         {
            "modules": false
         }
      ],
      "es2016"
   ]
}

As an aside; apparently these are applied in reverse order. So es2016 is applied first, es2015 second and react third. I'm not totally certain this is correct; the .babelrc docs are a little unclear.

With our query options extracted we're down to a simpler webpack.config.js:


'use strict';

var path = require('path');

module.exports = {
  cache: true,
  entry: {
    main: './src/main.tsx',
    vendor: [
      'babel-polyfill',
      'fbemitter',
      'flux',
      'react',
      'react-dom'
    ]
  },
  output: {
    path: path.resolve(__dirname, './dist/scripts'),
    filename: '[name].js',
    chunkFilename: '[chunkhash].js'
  },
  module: {
    loaders: [{
      test: /\.ts(x?)$/,
      exclude: /node_modules/,
      loader: 'babel-loader!ts-loader'
    }, {
      test: /\.js$/,
      exclude: /node_modules/,
      loader: 'babel-loader'
    }]
  },
  plugins: [
  ],
  resolve: {
    extensions: ['.ts', '.tsx', '.js']
  },
};

plugins

In our example the plugins section of our webpack.config.js is extended in a separate process. Whilst we're developing we also set the debug flag to be true. It seems we need to introduce a LoaderOptionsPlugin to do this for us.

As we introduce our LoaderOptionsPlugin we also need to make sure that we provide it with options. How do I know this? Well someone raised an issue against ts-loader. I don't think this is actually an issue with ts-loader; I think it's just a webpack 2 thing. I could be wrong; answers on a postcard please.

Either way, to get up and running we just need the LoaderOptionsPlugin in play. Consequently, most of what follows in our webpack.js file is unchanged:


// .....

var webpackConfig = require('../webpack.config.js');
var packageJson = require('../package.json');

// .....

function buildProduction(done) {

   // .....

   myProdConfig.plugins = myProdConfig.plugins.concat(
      // .....

      // new webpack.optimize.DedupePlugin(), Not a thing anymore apparently
      new webpack.optimize.UglifyJsPlugin(),

      // I understand this here matters...
      // but it doesn't seem to make any difference; perhaps I'm missing something?
      new webpack.LoaderOptionsPlugin({
        minimize: true,
        debug: false
      }),

      failPlugin
   );

   // .....
}

function createDevCompiler() {
   var myDevConfig = webpackConfig;
   myDevConfig.devtool = 'inline-source-map';
   // myDevConfig.debug = true; - not allowed in webpack 2

   myDevConfig.plugins = myDevConfig.plugins.concat(
      new webpack.optimize.CommonsChunkPlugin({ name: 'vendor', filename: 'vendor.js' }),
      new WebpackNotifierPlugin({ title: 'Webpack build', excludeWarnings: true }),

      // this is the Webpack 2 hotness!
      new webpack.LoaderOptionsPlugin({
         debug: true,
         options: myDevConfig
      })
      // it ends here - there wasn't much really....

   );

   // create a single instance of the compiler to allow caching
   return webpack(myDevConfig);
}

// .....

LoaderOptionsPlugin we hardly new ya

After a little more experimentation it seems that the LoaderOptionsPlugin is not necessary at all for our own use case. In fact it's probably not best practice to get used to using it as it's only intended to live a short while whilst people move from webpack 1 to webpack 2. In that vein let's tweak our webpack.js file once more:


function buildProduction(done) {

   // .....

   myProdConfig.plugins = myProdConfig.plugins.concat(
      // .....

      new webpack.optimize.UglifyJsPlugin({
         compress: {
            warnings: true
         }
      }),

      failPlugin
   );

   // .....
}

function createDevCompiler() {
   var myDevConfig = webpackConfig;
   myDevConfig.devtool = 'inline-source-map';

   myDevConfig.plugins = myDevConfig.plugins.concat(
      new webpack.optimize.CommonsChunkPlugin({ name: 'vendor', filename: 'vendor.js' }),
      new WebpackNotifierPlugin({ title: 'Webpack build', excludeWarnings: true }),
   );

   // create a single instance of the compiler to allow caching
   return webpack(myDevConfig);
}

// .....

karma.conf.js

Finally Karma. Our karma.conf.js with webpack 1 looked like this:


/* eslint-disable no-var, strict */
'use strict';

var webpackConfig = require('./webpack.config.js');

module.exports = function(config) {
  // Documentation: https://karma-runner.github.io/0.13/config/configuration-file.html
  config.set({
    browsers: [ 'PhantomJS' ],

    files: [
      // This ensures we have the es6 shims in place and then loads all the tests
      'test/main.js'
    ],

    port: 9876,

    frameworks: [ 'jasmine' ],

    logLevel: config.LOG_INFO, //config.LOG_DEBUG

    preprocessors: {
      'test/main.js': [ 'webpack', 'sourcemap' ]
    },

    webpack: {
      devtool: 'inline-source-map',
      debug: true,
      module: webpackConfig.module,
      resolve: webpackConfig.resolve
    },

    webpackMiddleware: {
      quiet: true,
      stats: {
        colors: true
      }
    },

    // reporter options
    mochaReporter: {
      colors: {
        success: 'bgGreen',
        info: 'cyan',
        warning: 'bgBlue',
        error: 'bgRed'
      }
    }
  });
};

We just need to chop out the debug statement from the webpack section like so:


module.exports = function(config) {

  // .....

    webpack: {
      devtool: 'inline-source-map',
      module: webpackConfig.module,
      resolve: webpackConfig.resolve
    },

  // .....

  });
};

Compare and contrast

We now have a repo that works with webpack 2 rc 1. Yay! If you'd like to see it then take a look here.

I thought I'd compare performance / output size of compiling with webpack 1 to webpack 2. First of all in debug / development mode:


// webpack 1

Version: webpack 1.14.0
Time: 5063ms
    Asset     Size  Chunks             Chunk Names
  main.js  37.2 kB       0  [emitted]  main
vendor.js  2.65 MB       1  [emitted]  vendor

// webpack 2

Version: webpack 2.2.0-rc.1
Time: 5820ms
    Asset     Size  Chunks                    Chunk Names
  main.js  38.7 kB       0  [emitted]         main
vendor.js  2.63 MB       1  [emitted]  [big]  vendor

Size and compilation time is not massively different from webpack 1 to webpack 2. It's all about the same. I'm not sure if that's to be expected or not.... Though I've a feeling in production mode I'm supposed to feel the benefits of tree shaking so let's have a go:


// webpack 1

Version: webpack 1.14.0
Time: 5788ms
                         Asset     Size  Chunks             Chunk Names
  main.269c66e1bc13b7426cee.js  10.5 kB       0  [emitted]  main
vendor.269c66e1bc13b7426cee.js   231 kB       1  [emitted]  vendor

// webpack 2

Version: webpack 2.2.0-rc.1
Time: 5659ms
                         Asset     Size  Chunks             Chunk Names
  main.33e0d70eeec29206e9b6.js  9.22 kB       0  [emitted]  main
vendor.33e0d70eeec29206e9b6.js   233 kB       1  [emitted]  vendor

To my surprise this looks pretty much unchanged before and after as well. This may be a sign I have missed something crucial out. Or maybe that's to be expected. Do give me a heads up if I've missed something...

Sunday 11 December 2016

webpack: syncing the enhanced-resolve

Like Captain Ahab I resolve to sync the white whale that is webpack's enhanced-resolve... English you say? Let me start again:

So, you're working on a webpack loader. (In my case the typescript loader; ts-loader) You have need of webpack's resolve capabilities. You dig around and you discover that that superpower is lodged in the very heart of the enhanced-resolve package. Fantastic. But wait, there's more: your needs are custom. You need a sync, not an async resolver. (Try saying that quickly.) You regard the description of enhanced-resolve with some concern:

"Offers an async require.resolve function. It's highly configurable."

Well that doesn't sound too promising. Let's have a look at the docs. Ah. Hmmm. You know how it goes with webpack. Why document anything clearly when people could just guess wildly until they near insanity and gibber? Right? It's well established that webpack's attitude to docs has been traditionally akin to Gordon Gecko's view on lunch.

In all fairness, things are beginning to change on that front. In fact the new docs look very promising. But regrettably, the docs on the enhanced-resolve repo are old school. Which is to say: opaque. However, I'm here to tell you that if a sync resolver is your baby then, contrary to appearances, enhanced-resolve has your back.

Sync, for lack of a better word, is good

Nestled inside enhanced-resolve is the ResolverFactory.js which can be used to make a resolver. However, you can supply it with a million options and that's just like giving someone a gun with a predilection for feet.

What you want is an example of how you could make a sync resolver. Well, surprise surprise it's right in front of your nose. Tucked away in node.js (I do *not* get the name) is exactly what you're after. It contains a number of factory functions which will construct a ready-made resolver for you; sync or async. Perfect! So here's how I'm rolling:


const node = require("enhanced-resolve/lib/node");

function makeSyncResolver(options) {
    return node.create.sync(options.resolve);
}

const resolveSync = makeSyncResolver(loader.options);

The loader options used above you'll be familiar with as the resolve section of your webpack.config.js. You can read more about them here and here.

What you're left with at this point is a function; a resolveSync function if you will that takes 3 arguments:

context
I don't know what this is. So when using the function I just supply undefined; and that seems to be OK. Weird, right?
path
This is the path to your code (I think). So, a valid value to supply - handily lifted from the ts-loader test pack - would be: C:\source\ts-loader\.test\babel-issue92
request
The actual module you're interested in; so using the same test the relevant value would be ./submodule/submodule

Put it all together and what have you got?


const resolvedFileName = resolveSync(
    undefined,
    'C:\source\ts-loader\.test\babel-issue92',
    './submodule/submodule'
);

// resolvedFileName: C:\source\ts-loader\.test\babel-issue92\submodule\submodule.tsx

Boom.