Redux-Saga typescript to es5 with Webpack

2017-12-17

If you don't already know what Redux-saga or the saga pattern is I advise you to check out the github page and this awesome video which helps explain the pattern in depth

I wont go to much into detail about what redux-saga does in this article, so I am making the assumption that you already know what it is and how it works. Im going to talk explicitly about a problem I ran into when using Typescript, Redux-saga and Webpack together. Basically I was trying to transpile redux-saga from Typescript to es5.

I initially assumed I could go straight to to from Typescript to es5 and normally this is doable however i soon ran into a few errors. Below was my initial tsconfig.json file

{
  "compilerOptions": {
    "module": "commonjs",
    "target": "es5", // <- Sadly not possible with redux-saga
    "allowSyntheticDefaultImports": true,
    "noImplicitAny": false,
    "sourceMap": true,
    "baseUrl": "app",
    "jsx": "react",
    "types": [
      "mocha",
      "node",
      "should"
    ],
    "typeRoots": [
      "node_modules/@types"
    ]
  },
  "exclude": [
    "node_modules"
  ]
}

The above will give this error

[at-loader] app\article-viewer\saga\fetch-article.saga.ts:19:16
    TS1220: Generators are only available when targeting ECMAScript 2015 or higher.

To get around this error I had to put my thinking hat on. After some research I decided that the es5 transpilation wasn't a necessity when running web-pack in development mode as most browsers can handle es6 generators, however it is absolutely crucial when going to production to be able to support as many browsers as possible. The simplest solution I could come up with was to first transpile typescript to es6 by updating the tsconfig.json to below

{
  "compilerOptions": {
    "module": "commonjs",
    "target": "es6", // <- Go to es6
    "allowSyntheticDefaultImports": true,
    "noImplicitAny": false,
    "sourceMap": true,
    "baseUrl": "app",
    "jsx": "react",
    "types": [
      "mocha",
      "node",
      "should"
    ],
    "typeRoots": [
      "node_modules/@types"
    ]
  },
  "exclude": [
    "node_modules"
  ]
}

I again tried my production build however I also wanted to uglify the output to save bandwidth. This is not possible and I ran into this rather unhelpful error

ERROR in bundle-9c7c56c7f815a7bb9714.js from UglifyJs
SyntaxError: Unexpected token: name (ArticlesService) [bundle-9c7c56c7f815a7bb9714.js:2784,6]

Its not clear but what it means is uglify cannot handle es6 features like classes or generators, the only way to get around this is to take the es6 bundle file and transpile it again to es5. Again I cant stress this enough as it is a very expensive task in terms of time I am only doing this when building the production bundle.

So how do we go from es6 to es5? Turns out its quite easy, we just need to use another transpiler called babel, more info here. Babel needs a few extra tools to achieve transpiling of generators, You will need a minimum of the following node modules.

npm install --save-dev babel-core babel-preset-es2015 babel-preset-stage-0 babel-polyfill babel-webpack-plugin

or if your like me and are using yarn the command is below

yarn add -D babel-core babel-preset-es2015 babel-preset-stage-0 babel-polyfill babel-webpack-plugin

Next we need to update our webpack configs. I have the following 3 webpack config files

  • webpack.config.base.js
  • webpack.config.dev.js
  • webpack.config.prod.js

It should be clear from the names that the base config holds configuration common to both dev and prod. Dev config is for dev and Prod config is for production. Im going to focus on the webpack.config.prod.js file as this is what does the es6 -> es5 conversion. My config is below

let webpack = require('webpack');
let ExtractTextPlugin = require('extract-text-webpack-plugin');
let merge = require('webpack-merge');
let HtmlWebpackPlugin = require('html-webpack-plugin');
let CompressionPlugin = require("compression-webpack-plugin");
let BabelPlugin = require("babel-webpack-plugin");
let CopyWebpackPlugin = require('copy-webpack-plugin');
let CleanWebpackPlugin = require('clean-webpack-plugin');
let baseConfig = require('./webpack.config.base');

module.exports = merge(baseConfig, {
  devtool: 'cheap-module-source-map',

  entry: [
    ////////////////////////////////////////////////////////////////////////////////////////////////////
    ////////    This is necessary to polyfill the generators
    ////////////////////////////////////////////////////////////////////////////////////////////////////
    'babel-polyfill',
    './app/index'
  ],

  output: {
    publicPath: './',
    filename: 'bundle-[chunkhash].js',
  },

  module: {
    rules: [
      {
        test: /\.global\.css$/,
        loader: ExtractTextPlugin.extract({fallback: 'style-loader', use: 'css-loader'})
      },
      {
        test: /^((?!\.global).)*\.css$/,
        loader: ExtractTextPlugin.extract({
          fallback: 'style-loader', use: 'css-loader?modules&importLoaders=1&minimize=true&localIdentName=[local]'
        })
      }
    ]
  },

  plugins: [
    new webpack.optimize.OccurrenceOrderPlugin(),
    new CleanWebpackPlugin(['dist'], {}),
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify('production')
    }),
    ////////////////////////////////////////////////////////////////////////////////////////////////////
    ////////    This will do the es6 to es5 conversion for you
    ////////////////////////////////////////////////////////////////////////////////////////////////////
    new BabelPlugin({
      test: /\.js$/,
      presets: ['es2015', 'stage-0'],
      sourceMaps: false,
      compact: false
    }),
    new ExtractTextPlugin({filename: 'style-[contenthash].css', allChunks: true}),
    new HtmlWebpackPlugin({template: 'index.ejs'}),
    new webpack.optimize.UglifyJsPlugin({
      compressor: {
        screw_ie8: true,
        warnings: false
      }
    }),
    new CompressionPlugin({
      algorithm: "gzip",
      test: /\.js$|\.html$\.css$|/,
      minRatio: 0.8
    }),
    new CopyWebpackPlugin([
      {from: 'app/img/**'},
      {from: 'manifest.yml'},
      {from: 'nginx.conf'}
    ], {})
  ]

});

If the above configuration is correct than you should now be able to do the following

  1. Transpile from Typescript to es6
  2. Transpile from es6 to es5
  3. Uglify

If you using webpack 2 tree shaking, the file size saving are temendous. To put this into perspective lets go through the bundle filesize after each step

  1. Transpile from Typescript to es6 - 1.7MB
  2. Transpile from es6 to es5 - 800kb
  3. Uglify - 500kb
  4. Gzip - 120kb

I have prepared a boilerplate app which does all of the above, I hope you stop by and give it a try - https://github.com/el-davo/webpack-react-typescript-boilerplate