Proper tree-shaking with Webpack 2

Tree-shaking trong bundle JavaScript xuất hiện lần đầu tiên trong Rollup, một module bundler giống như Webpack. Nó có nghĩa là chỉ những đoạn code cần thiết để chạy trong app của bạn thì mới được thêm vào trong bundle. Nhờ vậy, kích thước của bundle có thể được giảm đi đáng kể. Tính năng này mới được thêm vào Webpack kể từ version 2. Trong bài viết này, hãy cùng tìm hiểu xem nó hoạt động thế nào và vài vấn đề mà bạn có thể sẽ gặp phải.

Example application

Hãy dùng một ví dụ thật đơn giản. Một class Car cần dùng một class Engine nào đó trong một package với nhiều loại Engine. Chúng ta có 2 file như sau.

Đây là file package với các loại Engine khác nhau. Chúng ta có V6EngineV8Engine, khuyến mại thêm một function getVersion nữa.

export class V6Engine {
  toString() {
    return 'V6';
  }
}

export class V8Engine {
  toString() {
    return 'V8';
  }
}

export function getVersion() {
  return '1.0';
}

Còn đây là class SportCar, sử dụng V8Engine mới nhất.

import { V8Engine } from './engine';

class SportCar {
  constructor(engine) {
    this.engine = engine;
  }

  toString() {
    return this.engine.toString() + ' Sports Car';
  }
}

console.log(new SportsCar(new V8Engine()).toString());

Bạn có thể thấy class code của chúng ta chỉ dùng đến V8Engine, những phần khác đều không cần dùng đến. Với tree-shaking, bundle được tạo ra sẽ chỉ bao gồm các class và function được dùng đến trong code của bạn. Trong ví dụ này, chúng ta chỉ có class SportCarV8Engine. Hãy thử xem Webpack làm điều này thế nào.

Bundling

Khi mới tạo bundle mà không dùng đến công cụ trasnspile (Babel) hay minify (UglifyJS) nào hết, chúng ta sẽ nhận được kết quả như sau


(function(module, __webpack_exports__, __webpack_require__) {

"use strict";
/* unused harmony export getVersion */
class V6Engine {
  toString() {
    return 'V6';
  }
}
/* unused harmony export V6Engine */

class V8Engine {
  toString() {
    return 'V8';
  }
}
/* harmony export (immutable) */ __webpack_exports__["a"] = V8Engine;

function getVersion() {
  return '1.0';
}

/***/ }),
/* 1 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__engine__ = __webpack_require__(0);

class SportsCar {
  constructor(engine) {
    this.engine = engine;
  }

  toString() {
    return this.engine.toString() + ' Sports Car';
  }
}

console.log(new SportsCar(new __WEBPACK_IMPORTED_MODULE_0__engine__["a" /* V8Engine */]()).toString());

/***/ })

Webpack đánh dấu các đoạn code không được dùng đến với unused harmony export và các đoạn code được dùng đến với harmony export (immutable). Bạn có thể thấy những đoạn code không dùng đến như V6Engine hay getVersion vẫn còn ở đấy. Có nghĩa là Webpack vẫn chưa có tree-shaking (*_ *?).

Dead code elimination vs live code inclusion

Đó là vì Webpack chỉ đánh dấu các đoạn code không được dùng đến và không export chúng trong module được tạo ra.

/* harmony export (immutable) */ __webpack_exports__["a"] = V8Engine;

Những đoạn code thừa (không được export) sau đó sẽ được loại bỏ bởi các thư viện minify code như UglifyJS (Dead code elimination). UglifyJS sẽ loại bỏ các đoạn code này trước khi minify nó. Vậy chúng ta sẽ hi vọng nó sẽ xóa bỏ phần V6Engine hay getVersion.

Với Rollup thì chỉ những đoạn code cần thiết để chạy mới được thêm vào bundle thôi. Khi bundle được tạo ra thì nó đã loại bỏ những đoạn code thừa rồi (live code inclusion) và các thư viện minify sẽ chỉ cần minify nó thôi.

Let's try minifying it

UglifyJS vẫn chưa support các tính năng mới của JavaScript, nghĩa là các tính năng mới kể từ ES2015 trở đi sẽ không hoạt động. Vì vậy chúng ta cần dùng Babel hoặc TypeScript để transpile nó đã. Trong bài này chúng ta sẽ dùng Babel.

Quá trình tạo file bundle.js sẽ như thế này. Đầu tiên Babel sẽ transpile các đoạn code ES2015 thành ES5. Sau đó Webpack sẽ gom các module lại thành 1 file bundle duy nhất. Cuối cùng UglifyJS sẽ minify bundle, bỏ đi các đoạn không cần thiết.

Vì Webpack cần đến tính năng module của ES2015 để biết được đoạn code nào được import nên cần phải để Babel giữ nguyên các module và để việc transpile các module sang CommonJS cho Webpack. Preset cho Babel sẽ trông thế này.

{
  "presets": [
    ["env", {
        "loose": true,
        "modules": false
    }]
  ]
}

Module và plugins cho file webpack.config.js để transpile với Babel và minify với UglifyJS sẽ như thế này

module: {
    rules: [
        { test: /\.js$/, loader: 'babel-loader' }
    ]
},

plugins: [
    new webpack.LoaderOptionsPlugin({
        minimize: true,
        debug: false
    }),
    new webpack.optimize.UglifyJsPlugin({
        compress: {
            warnings: true
        },
        output: {
            comments: false
        },
        sourceMap: false
    })
]

Rồi bây giờ chạy Webpack thì sẽ được file bundle cuối cùng như thế này

!function(n){function t(o){if(r[o])return r[o].exports;var e=r[o]={i:o,l:!1,exports:{}};return n[o].call(e.exports,e,e.exports,t),e.l=!0,e.exports}var r={};t.m=n,t.c=r,t.i=function(n){return n},t.d=function(n,r,o){t.o(n,r)||Object.defineProperty(n,r,{configurable:!1,enumerable:!0,get:o})},t.n=function(n){var r=n&&n.__esModule?function(){return n.default}:function(){return n};return t.d(r,"a",r),r},t.o=function(n,t){return Object.prototype.hasOwnProperty.call(n,t)},t.p="",t(t.s=1)}([function(n,t,r){"use strict";function o(n,t){if(!(n instanceof t))throw new TypeError("Cannot call a class as a function")}r.d(t,"a",function(){return e});var e=(function(){function n(){o(this,n)}n.prototype.toString=function(){return"V6"}}(),function(){function n(){o(this,n)}return n.prototype.toString=function(){return"V8"},n}())},function(n,t,r){"use strict";function o(n,t){if(!(n instanceof t))throw new TypeError("Cannot call a class as a function")}Object.defineProperty(t,"__esModule",{value:!0});var e=r(0),i=function(){function n(t){o(this,n),this.engine=t}return n.prototype.toString=function(){return this.engine.toString()+" Sports Car"},n}();console.log(new i(new e.a).toString())}]);

Mặc dù khó mà có thể hiểu được cái đống code đấy viết những cái gì, nhưng mà có một cái có thể thấy đó là cái đoạn return"V6" vẫn còn đấy. Nghĩa là class V6Engine vẫn chưa bị bỏ đi. Tuy nhiên function getVersion thì có vẻ đã bị bỏ đi rồi. Vậy còn vấn đề gì nữa nhỉ?

What went wrong

Nếu nhìn vào output của UglifyJS bạn có thể thấy ngay warning này

WARNING in car.prod.bundle.js from UglifyJs
Dropping unused function getVersion [car.prod.bundle.js:103,9]
Side effects in initialization of unused variable V6Engine [car.prod.bundle.js:79,4]

UglifyJs đã biết được đoạn code V6Engine không cần dùng đến, tuy nhiên vì trong đó có một đoạn code gây ra "Side effect" nên đoạn code đó không thể được bỏ đi.

Nếu chúng ta dừng ở bước tạo file bundle sau khi đã transpile với Babel thì class V6Engine sẽ trông như thế này.

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

    V6Engine.prototype.toString = function toString() {
        return 'V6';
    };

    return V6Engine;
}();

Bạn có thể thấy V6Engine là một pure function và hoàn toàn không có side-effect gì ở đây. Tuy nhiên vì Uglify JS không đọc code flow, nên đoạn code này bị nó xem là side-effect.

V6Engine.prototype.toString = function toString() {
    return 'V6';
};

Bạn đã biết khi tạo class với ES5 thì các method của class phải nằm trong thuộc tính prototype của function dùng để tạo object. Nghĩa là bạn nhất định phải assign thuộc tính prototype khi khởi tạo class.

Và vì UglifyJS không đọc code flow nên nó không thể biết được đoạn code đó chỉ là để tạo class hay V6Engine thật ra chỉ nằm trong scope của function V6Engine thôi nên không thể tính là side effect.

Tóm lại là các class sau khi được transpile thành ES5 class thì sẽ không thể bị loại bỏ với tree-shaking của Webpack.

A solution?

Phiên bản gần đây của UglifyJS mới có thêm tính năng cho phép mark pure function với annotation @__PURE__ hoặc #__PURE__. Nghĩa là khi chúng ta có một pure function như V6Engine ở trên chẳng hạn thị có thể dùng annotation này để tránh việc các assignment bên trong bị xem là side-effect trong khi nó không phải là như thế. Tuy nhiên ta vẫn còn phải chờ các transpiler như Babel hay TypeScript implement nó nữa.

Hoặc là bạn có thể đợi UglifyJS support minify với các tính năng mới từ ES2015. Tuy nhiên cái này thì có vẻ là sẽ không đến trong tương lai gần.

Một tool minify khác tới từ chính Babel đó là Babili cũng mới được release mấy tháng trước. Tool này sẽ bao gồm cả Babel và minify luôn. Vì tính năng classimport, export module của ES2015 giúp việc nhận biết class và các đoạn code không dùng đến dễ dàng hơn nhiều nên Babili sẽ loại bỏ các class, function không dùng đến trước khi transpile. Đến bước minify cuối cùng thì không cần quan tâm đến vấn đề như ở trên nữa.

Tuy nhiên cũng vì thế mà nó chỉ có thể loại bỏ các class không dùng đến khi chúng được viết với ES2015 syntax thôi. Nếu bạn đang dùng Babel thì có lẽ là là bạn đang viết class theo ES2015 syntax rồi. Tuy nhiên nếu bạn muốn dùng Babili cho TypeScript trong khi chờ TypeScrip implement annotation pure của UglifyJS thì bạn cần phải để TypeScript output với ES2015 syntax và để việc transpile thành ES5 cho Babili làm.

Nhiều thư viện cũng có kiểu export khiến cho tree-shaking không hoạt động được, như rxjs chẳng hạn. Nên nếu bạn thực sự quan tâm đến size của bundle thì có thể bạn nên import trực tiếp từ file module bên trong package đó thay vì import module từ cả package.

Summary

Tree-shaking có thể giúp làm giảm đáng kể kích thước của bundle. Dù cho Webpack 2 đã support tree-shaking nhưng nó lại đi theo một hướng khác với Rollup. Nó dựa vào các tool minify để thực sự loại bỏ các phần code không dùng đến. Tuy nhiên tool minify thông dụng như UglifyJS lại chưa được tối ưu cho việc này, thế nên tree-shaking với webpack vẫn chưa thực sự hiệu quả.

Original English post