Create a gist now

Instantly share code, notes, and snippets.

Embed
Serving ES6 to modern browsers

Background

Recently I noticed that Safari 10 for Mac/iOS had achieved 100% support for ES6. With that in mind, I began to look at the browser landscape and see how thorough the support in the other browsers. Also, how does that compare to Babel and its core-js runtime. According to an ES6 compatability table, Chrome, Firefox, and IE Edge have all surpassed what the Babel transpiler can generate in conjunction with runtime polyfills. The Babel/core-js combination achieves 71% support for ES6, which is quite a bit lower than the latest browsers provide.

It made me ask the question, "Do we need to run the babel es2015 preset anymore?", at least if our target audience is using Chrome, Firefox, or Safari.

It's clear that, for now, we can't create a site or application that only serves ES6. That will exclude users of Internet Explorer and various older browsers running on older iOS and Android devices. For example, Safari on iOS 9 has pretty mediocre ES6 support. So that means we'll need to have some server-side logic that can serve ES6 or ES5 JavaScript files depending on the user agent. Moreover, we'll need to modify our bundling tools, such as Webpack, to create a bundle for each language version.

For this exercise, I decided to try out modifying a React starter-kit that supports server-side rendering (i.e. isomorphic apps). Such as environment can run code on the server to determine, based on the User-Agent, whether the browser can run ES6 natively or needs transpiled ES5. One of the first tasks is to change the build process to build two bundles, one for each language version. Many React-based application kits use Webpack as the primary build tool to compile and build runtime modules.

Configurations

Webpack/Babel

A typical application will have a webpack.config.js file that configures tools like Babel using "loader" configurations. A loader configuration will instruct Babel to compile JavaScript files with JSX and ES6 language features into "pure" ES5 JavaScript. This can then be served to an arbitrary browser. Webpack will also run additional processing tools to configure Debug/Release builds and perform various optimizations. Since we are going to have two builds of our application, one in ES6 and one in ES5, we will need two Webpack configurations.

Here's an example of a loader config to drive Babel. It specifies the ES2015 and React (JSX) preset:

{
    module: {
        loaders: [
            {test: /\.js$/, loader: "babel?presets[]=es2015&presets[]=react", exclude: /node_modules/}
        ]
    }
}

Our primary configuration file will not use the es2015 preset, since we want native ES6. However, browsers do not implement support for module loading. This means that "import" and "export" statements must be converted into something that can be processed by the Webpack loader runtime. The Babel "es2015" preset includes "transform-es2015-modules-commonjs", which converts those import/export statements into require calls. So our default Babel configuration is thus:

var BABEL_QUERY = {
    presets: ["react"],
    plugins: ["transform-es2015-modules-commonjs"]
};

module.exports = {
    /* other configurations omitted */
    output: {
        filename: "client.js"
    },
    module: {
        loaders: [
            {test: /\.js$/, loader: "babel", query: BABEL_QUERY, exclude: /node_modules/}
        ],

    }
}

We must introduce a second configuration file that uses the Babel presets for ES2015 and React. Notice the change to use the "query" object instead of URL-style query parameters. This makes it easier to modify the Babel configuration in the second file.

var webpack = require("webpack");
var config = require("./webpack.config.js");	// Start with our default configuration

var BABEL_QUERY = {
	presets: ["es2015", "react"]
};

config.output.filename = "client-es5.js";		// The bundle name specifies ES5 support
config.module.loaders[0].query = BABEL_QUERY;	// Override the Babel loader query parameters

module.exports = config;

package.json

Now that we have two Webpack configurations, we need to modify our build script to run webpack twice as well.

If the initial build script looks like this:

{
  "scripts": {
    "build-client": "webpack  --config configs/webpack.config.js",
    "build": "npm run build-client"
  }
}

Now it might look like this:

{
  "scripts": {
    "build-client": "webpack  --config configs/webpack.config.js",
    "build-client-es5": "webpack  --config configs/webpack.config-es5.js",
    "build": "npm run build-client && npm run build-client-es5"
  }
}

The above will produce the two compiled module bundles, "client.js" and "client-es5.js".

Troubleshooting

When I first did this, there were some issues with the production build. Webpack has certain plugins that are often used in production builds. Due to a bug in one of them, I needed to upgrade my webpack dependency from 1.12.x to 1.13.x.

{
	"devDependencies": {
	    "webpack": "1.13.2"
	}
}

However, there is one build tool that does not support ES6 syntax yet - UglifyJS. There is an open issue on it. That may be a showstopper for some folks, since it means the ES6 bundle will not be minified and/or obfuscated. You can still gzip the JavaScript at the HTTP layer to reduce the size over the wire.

Server-Side

Now that we have two module bundles, the server needs to serve the appropriate bundle for the browser. Modern, ES6-capable browsers get "client.js" and everyone else gets "client-es5.js". For applications served by a Node server component such as express or koa, we can use some code that chooses what <script> include to generate based on a User-Agent test. This is the module I've started to encapsulate the test.

I created browser-support.js for this purpose.

This table lists the minimum browsers for ES6 module consumption:

Browser Version
Chrome 49
Edge 14
Firefox 45
Safari 10

Here's an example using browser-suppprt.js

import browser from "modules/browser-support"

let webserver = "http://myserver.example.org/my-app"
let clientJS = browser.supportsES6(this.request.headers) ? `${webserver}/dist/client.js` : `${webserver}/dist/client-es5.js`

writeToResponse(`<script src="${clientJS}"></script>`)

What's Next

So there you have it. There is still more work to check edge cases. I haven't tested all the mobile browsers yet. That being said, I think we're close to the time when this is not a painful thing to do.

Why bother you ask? It may not make sense for most applications. It adds another entire piece of code to deploy and test. However, think back to the days when we all used modern CSS but still had to support IE 6-8. Sometimes it's helpful to be able to take advantage of all the browser or language features you can. If you don't compile ES6 to ES5, you'll be able to. Depending on the browser landscape for your user community, that may make sense.

Have fun...

@picanteverde

This comment has been minimized.

Show comment
Hide comment
@picanteverde

picanteverde Aug 3, 2017

I love what you did here!
two questions:
why are you using webpack 1 instead of 2?
if we want to target more than two classes of browsers, like grouping by ES features how would you do it?

finally a suggestion, you can use https://github.com/babel/babili to avoid the uglify problem

cool post

I love what you did here!
two questions:
why are you using webpack 1 instead of 2?
if we want to target more than two classes of browsers, like grouping by ES features how would you do it?

finally a suggestion, you can use https://github.com/babel/babili to avoid the uglify problem

cool post

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment