Precompiled JavaScript

How to use ES6 and target older NodeJS platforms without mangling the code to the lowest common denominator

You can use a lot of ES6 features today. Most of the features are already implemented in Node 4, 5 and even 0.12. You can precisely transpile the missing features when the client installs your module, see my blog post JavaScript needs the compile step (on install). The ES6 features already supported by the client are unchanged, and only the missing features are transpiled.

There was a lot of feedback to the blog post and the example implementation, ranging from "No way, don't do this" to "Wow, this is the way JavaScript should be written in 2016". A lot of people pointed the major shortcoming of the proposed method: it adds a LOT of weight to the NPM install - the client has to download Babel and its plugins in order to transpile!

I wanted a better solution.

Pre-compiled JavaScript

Here is an alternate solution. The range of NodeJS versions in the wild is pretty small. The most popular are 0.10, 0.12, and the newer versions 4 and 5. We can pre-build a single bundle for each of these versions during the build step (on the dev or CI machine). During the installation by the client, we can determine which bundle to use depending on the Node version.

This obviously increases the NPM download size, but the increase is only in the source code size, and not in any additional module downloads.

I wrote pre-compiled tool for the devs. The tool uses compiled to bundle (using Rollup) and transpile the produced code for different Node platforms. I have collected the ES features available by default (without --harmony flag) on each platform, see features files. I still test the source code to see which ES features it actually uses to minimize transpile times and avoid transpiling a feature the platform already supports.

Example

Here is an example how one could use this approach in practice. I have created precompiled-example repo with a few source files. It uses my favorite ES6 features, for example template literals and object parameter shorthand notation

src/main.js
1
2
3
4
5
6
7
8
9
import { add } from './calc'
const a = 10
const b = 2
Promise.resolve(add(a, b))
.then(function (sum) {
console.log(`${a} + ${b} = ${sum}`)
})
const objectAdd = ({a, b}) => a + b
console.log('Adding object properties', objectAdd({ a: 10, b: 2 }))

It uses ES6 module import of course to allow efficient tree-shaking when producing the output bundle.

First, install the pre-compiled build tool

npm install --save-dev pre-compiled

Create the build step

package.json
1
2
3
"scripts": {
"build": "precompile"
}

We need to tell pre-compiled tool which files to start with and where to output them. Add a config object to the package.json file

package.json
1
2
3
4
5
6
7
8
9
"scripts": {
"build": "precompile"
},
"config": {
"pre-compiled": {
"dir": "dist",
"files": ["src/main.js"]
}
}

The precompile step will start with src/main.js file and will roll all ES6 'imported' modules into one bundle, producing dist/main.js bundle. Then it will produce several bundles (you can control this list via config option, of course)

dist/main.compiled.for.0.10.js
dist/main.compiled.for.0.11.js
dist/main.compiled.for.0.12.js
dist/main.compiled.for.4.js
dist/main.compiled.for.5.js

We should add these bundles to the list of files included in our NPM package

package.json
1
2
3
{
"files": ["dist"]
}

Second, we need to add a production dependency that will run on the client during install.

npm install --save pick-precompiled

And we need to call it during postinstall step

package.json
1
2
3
4
"scripts": {
"build": "precompile",
"postinstall": "pick-precompiled"
}

We have multiple bundles, and at install one of them will be picked and copied to dist/main.js (according to the output directory and the original bundle name). Thus we should also set the main script to point at the bundle, even if it does not exist yet.

package.json
1
2
3
{
"main": "dist/main.js"
}

Picking bundle in action

I have made a precompiled-example module to show the bundling and picking the right bundle in action.

If we do npm install precompiled-example we get the following output

node version 0.12.9
for node version 0.12.9 picked bundle dist/main.compiled.for.0.12.js
copied bundle dist/main.js

Our bundle has a lot of transpiled features and it works

$ node node_modules/precompiled-example/
Adding object properties 12
binary literal 5
object foo and bar
10 + 2 = 12

We can inspect the bundle, notice the transpiled features for Node 0.12

$ cat node_modules/precompiled-example/dist/main.js
'use strict';
require('pick-precompiled').babelPolyfill()
var add = function (a, b) {
  return a + b;
};
var a = 10;
var b = 2;
Promise.resolve(add(a, b)).then(function (sum) {
  console.log(a + ' + ' + b + ' = ' + sum);
});
var objectAdd = function (_ref) {
  var a = _ref.a;
  var b = _ref.b;
  return a + b;
};
console.log('Adding object properties', objectAdd({ a: 10, b: 2 }));
...

Let us try installing from Node 4.

$ nvm use 4
Now using node v4.2.2 (npm v3.5.0)
for node version 4.2.2 picked bundle dist/main.compiled.for.4.js
copied bundle dist/main.js
$ node node_modules/precompiled-example/
Adding object properties 12
binary literal 5
object foo and bar
10 + 2 = 12

Same working module, but the code now runs without transpiled parts, Node 4 supports almost all features we needed (arrows, template literals)

$ cat node_modules/precompiled-example/dist/main.js
'use strict';
require('pick-precompiled').babelPolyfill()
const add = (a, b) => a + b;
const a = 10;
const b = 2;
Promise.resolve(add(a, b)).then(function (sum) {
  console.log(`${ a } + ${ b } = ${ sum }`);
});
const objectAdd = _ref => {
  let a = _ref.a;
  let b = _ref.b;
  return a + b;
};
console.log('Adding object properties', objectAdd({ a: 10, b: 2 }));

Finally, let us see if Node 0.10 is working.

$ nvm use 0.10
Now using node v0.10.40 (npm v1.4.28)
$ npm install precompiled-example
for node version 0.10.40 picked bundle dist/main.compiled.for.0.10.js
$ node node_modules/precompiled-example/
Adding object properties 12
binary literal 5
object foo and bar
10 + 2 = 12

Nice! We do have a nice bundle for each case.

Conclusion

With this approach you can admit that your NPM module is pre-rolled and pre-compiled :)

There are shortcomings - building and including several bundles instead of one, changing Node version using nvm requires npm install call. But overall I like this approach because the bundles installed have nice, clean source.