Huozhi

Battle Mangle Property Compression with Babel

image

2 ~ 3 years ago, it’s commonly that we use google closure compiler to handle javascript code compression process. once you got enabled advance compression, that will bring a lot breaks on production code. people are careful on maintaining the whitelist for all the keys you wouldn’t want to be mangled. like native APIs, some properties from response result, etc.

today UglifyJS is welcomed, (even the maintainer gave up the work on it right now) more frameworks and libraries are mixed up, network traffic get better, developers don’t want to handle that stuff again. now more developers choose to use ordinary compression on their code, things work fine. However, sometimes you got to hide the implementation details, that you have to compress it more. then we back to the old school: enable mangle property options.

Why Mangling Properties May Break Your Code?

Emmmm… JavaScript is never a static language from the birthday till now, maybe ever. and it’s too dynamic that compiler is hard to guess the full structure during the parsing work. Define a method on a class could be dynamic, add a property to a class is the same. That’s too hard for compiler - “I can’t dope out everything!”.

Let’s take an example. a very simple class Apple with 1 getter and 1 prototype method, then invoke the method and getter in a closure. simple, right? name it demo.js

(function () {
  class Apple {
    get val() { return 1 }
    mycall() { return 'method' }
  }

var instance = new Apple()
  console.log(instance.val)
  console.log(instance.mycall())
})()

See what we got after babel’s compilation with fully transform to es5 (I ignored the _createClass helper part). name it demo.es5.js

(function() {
  var Apple = (function() {
    function Apple() {
      _classCallCheck(this, Apple);
    }

    _createClass(Apple, [
      {
        key: "mycall",
        value: function mycall() { return "method"; }
      },
      {
        key: "val",
        get: function get() { return 1; }
      }
    ]);

  return Apple;
})();

var instance = new Apple();
  console.log(instance.val);
  console.log(instance.mycall());
})();

Let’s see how uglifyJS (here I choose uglify-es package, which could handle part of es6+ syntax. I’ll tell you why I choose it later)

enable mangle, and mangle-props option! See we what we got now. We name it demo.min.js

(function () {
  class e {
    get t() {
      return 1;
    }
    l() {
      return "method";
    }
  }
  var n = new e();
  n.t;
  n.l();
})();

Successful and nothing wrong right? Now we know how babel and advanced compress process do, let’s figure out how are they working with webpack together?

Compilation and Compression Process on Webpack

Usually we configure babel compilation with babel-loader and compression with uglifyjs-webpack-plugin.

Loaders apply on single module, when a JS file need to compile, it passed loaders first then bundled into modules.

Plugins are more complex, they’ve got life cycle. (checkout https://github.com/webpack/docs/wiki/plugins)

Webpack gave an example for how to make a customized plugin as following.

In the compilation phase, modules are loaded, sealed, optimized, chunked, hashed and restored, etc. This would be the main lifecycle of any operations of the compilation.

// MyPlugin.js

function MyPlugin(options) {
  // Configure your plugin with options...
}

MyPlugin.prototype.apply = function(compiler) {
  compiler.plugin("**compile**", function(params) {
    console.log("The compiler is starting to compile...");
  });

  compiler.plugin("**compilation**", function(compilation) {
    compilation.plugin("**optimize**", function() {});
    compilation.plugin("**optimize-chunk-assets**", function(chunks, callback) {});
    compiler.plugin("**emit**", function(compilation, callback) {
    console.log("The compilation is going to emit files...");
    callback();
  });
};

module.exports = MyPlugin;

This may be outdated, since webpack 4 brought tab API on plugins. But we learn that are phases and stages plugins could take effects.

UglifyJS’s compression require a context of your code. Making it as a plugin, is not only for saving time when patching bundle, it’s mean to save more bytes of output. Let me gave an example,

// a.js
exports.HelloMyWorldHeyHowAreYouWorldPlzSayHelloBack = () => console.log('hello')

// main.js
import {HelloMyWorldHeyHowAreYouWorldPlzSayHelloBack} from './a'

HelloMyWorldHeyHowAreYouWorldPlzSayHelloBack()

If the context is just a module, like a.js, how can compression save your bytes?It had to make sure exports won’t be broken, so it will keep the name. besides it, no more other work to do in this example. Can’t save too much thing if we only know a snippet, a module, a file.

If you need to mangle the name, you get to change all but align them. So UglifyJS plugin consume the input from the bundled module, not the original source file. Then the task order in webpack is babel first, then uglify. Now problems come.

Why Code Not Work When Mangle-Props Is On?

Now we uglify demo.es5.js by uglify with mangle-props on. Finally we have demo.es5.min.js.

(function () {
  var e = function () {
    function e() {
      _classCallCheck(this, e);
    }
    _createClass(e, [{
      key: "mycall",
      value: function e() { return "method"; }
    }, {
      key: "val",
      get: function e() { return 1; }
    }]);
    return e;
  }();
  var n = new e();
  console.log(n.s);
  console.log(n.l());
})();

Run it? you’ll get error. See babel class definition, method mycall and getter val still not change, but the invocation are mangled. Here is the issue of dynamic, when define prototypes by some functions, parser cannot predict all the full shape of class when settle down. it doesn’t know the class will get a method called mycall on it after \createClass_ executed.

If you just use Object.defineProperties or Object.defineProperty on prototype chain class, it will work. UglifyJS can avoid mangling these native APIs only on explicit invocations. Or maybe we can change the createClass template in babel-runtime?

UglifyJS generate AST by parsing code with its own parser, which called parse-js (modified from es3 parser). even it supports parsing by acorn-js, you can only deal it in executable binary, not API.

Now, look back to the all tools we’re using to parse JavaScript file.

webpack — acorn-js;

babel — it’s own parser;

uglify — parse-js, its own parser, modified from es3 support;

(bwt, we also got esprima.js as syntax parser used in some other repos)

Everyone got their own parser with different support level, so we run into troubles if use some special feature maintainer has already warned us. 🙈

It might probably break your code! but still a lot of merits!

How We Solve This

The solution might not be difficult, just uglify first then babel it, problem disappeared.

But before the terser-js came out, we’re figuring out a way to work with uglify-es and babel together. ’cause uglify-js cannot consume es6+ syntax at all, and uglify-es have bugs.

Example of computed-props. we got computed-props.js as below.

var a = {
  b: {
    w: 'w',
    x: 'x',
    y: 'y'
  }
}

var props = a.b
var c = {
  [props.w]: 1,
  [props.x]: 2,
  [props.y]: 3,
}

uglify it with mangle props, then we got _computed-props.es5.js_, it looks like this.

var a = {
  b: {
    w: "w",
    x: "x",
    y: "y"
  }
};

var props = a.b;

var c = {
  p: 1,
  p: 2,
  p: 3
};

look at variable c, every key is compressed as a same one. nested properties had been mangled in wrong way.

Then? What we do is compiling it first with only couples of es6+ plugins like transform-computed-props. we don’t transform classes at the beginning.

Once we got partial es6+ code, uglify-es could mangle it correctly without brokens.

After uglify’s own part is down, webpack will feed assets to another plugin, babel plugin. This guy will apply all presets and plugins we specified in babelrc on the output. We’re done!

Simpler Solution

uglify-js@3 only support es5.

At around Aug 2018, uglify’s job pass to another successor. Since maintainer decide not to maintain uglifyjs anymore, terser-js came out. you may regard it as the new uglify-es.

Leverage terser-js will make that process easier, terser first then babel.

Future of Minify

Babel has published its own minifier called babel-minify, without support mangling props yet. Will it finally adopt this feature? Don’t know.

Mangling props has its shortcut. you have to keep some properties in string format. and non-key properties cannot be invoked in computed format, like obj[‘prefix’ + someValue].

Community has brought a cool thing, prettier, for auto formatting your code. Unfortunately, prettier will wipe out the quotes on string format key. I cannot tell everyone, please comment /* prettier ignore */ on every object with string format properties, that’s not cool.

Frameworks like react, publishing lots of CLI tools to setup a application fast. the compression strategy, is not to mangle the properties, it will be easy for developers to focus on creating phase, not optimizing one.

We’re working on an application with some core libraries inside it now. What I’m thinking is to keep the excessive strategy for core libs but not the top level of application. You need to separate the two parts, ’cause:

  1. application usually grow faster than core libs.
  2. hiding implementation and extreme optimization is no need for top level but core libs.

maintaining an excessive strategy for whole codebase is really suffered. Some new hires may even not heard about such closure-compiler, mangling-properties stuff. It’s like people resource management. someone focus on surface and someone goes deeper.

That’s it. if you figure out any incorrect description and mistakes in this article please contact to fix them and I’ll appreciate.

Code example: https://github.com/huozhi/minifyjs-test/

twins link on my medium

© 2020, Huozhi