webpack2 的 tree-shaking 好用嗎?

  • 2019 年 12 月 5 日
  • 筆記

本文作者:IMWeb jerytang 原文出處:IMWeb社區 未經同意,禁止轉載

程式碼壓縮的現狀

下面是一個使用 react 的業務的程式碼依賴,但是實際上業務程式碼中並沒有對依賴圖中標識的模組,也就是說構建工具將不需要的程式碼打包到了最終的程式碼當中。顯然,這是很不合理的。

$ webpack --profile --json --config webpack/config.common.js > stats.json  $ # 將 stats.json 上傳到 http://alexkuz.github.io/webpack-chart/ 可視化 entry 的依賴

隨著 es6 的普及使用,由於 es6 的 模組是語言層面支援的,方便做靜態分析,讓進一步的程式碼優化成為可能,也就是我們今天要討論的 tree-shaking。

tree-shaking 較早由 Rich_Harris 的 rollupjs 實現,webpack2 也引入了tree-shaking 的能力。其實在更早,有 google closure compiler 來做類似的事情,不過由於 closure compiler 對程式碼書寫要求比較多,感覺一直沒有流行開。

什麼是 tree-shaking ?

tree-shaking 可以形象的理解為搖樹。在 webpack 項目中,有一個入口文件,相當於一棵樹的主幹,入口文件有很多依賴的模組,相當於樹枝。實際情況中,雖然依賴了某個模組,但其實只使用其中的某些功能。通過 tree-shaking,將沒有使用的模組搖掉,這樣來達到刪除無用程式碼的目的。

實際效果如何

所有示例在 tree-shaking-demo

示例 1

main.js

import { A } from './components/index';  let a = new A();  a.render();

index.js

export A from './A';  export B from './B';

components/A.js

function A () {      this.render = function() {          return "AAAA";      }  }  export default A;

components/B.js

function B () {      this.render = function() {          return "BBBB";      }  }  export default B;
$ npm run 001

結果

查看 dist/001.min.js class B 被成功消除了,不能找到 BBBB

!function(n){function t(e){if(r[e])return r[e].exports;var u=r[e]={i:e,l:!1,exports:{}};return n[e].call(u.exports,u,u.exports,t),u.l=!0,u.exports}var r={};return t.m=n,t.c=r,t.i=function(n){return n},t.d=function(n,r,e){t.o(n,r)||Object.defineProperty(n,r,{configurable:!1,enumerable:!0,get:e})},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=3)}([function(n,t,r){"use strict";var e=r(1);r(2);r.d(t,"a",function(){return e.a})},function(n,t,r){"use strict";function e(){this.render=function(){return"AAAA"}}t.a=e},function(n,t,r){"use strict"},function(n,t,r){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var e=r(0),u=new e.a;u.render()}]);

示例 2

稍微修改下 A、B 的定義方式:

function A() {  }  A.prototype.render = function() {      return "AAAA";  }  export default A;
function B() {  }    B.prototype.render = function() {      return "BBBB";  }  export default B;

跟上面的區別在於採用原型鏈的方式添加了一個 render 方法

$ npm run 002

結果

查看 dist/002.min.js 發現 class B 並沒有被成功消除

!function(n){function t(e){if(r[e])return r[e].exports;var u=r[e]={i:e,l:!1,exports:{}};return n[e].call(u.exports,u,u.exports,t),u.l=!0,u.exports}var r={};return t.m=n,t.c=r,t.i=function(n){return n},t.d=function(n,r,e){t.o(n,r)||Object.defineProperty(n,r,{configurable:!1,enumerable:!0,get:e})},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=3)}([function(n,t,r){"use strict";var e=r(1);r(2);r.d(t,"a",function(){return e.a})},function(n,t,r){"use strict";function e(){}e.prototype.render=function(){return"AAAA"},t.a=e},function(n,t,r){"use strict";function e(){}e.prototype.render=function(){return"BBBB"}},function(n,t,r){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var e=r(0),u=new e.a;u.render()}]);

示例 3

再修改下 A、B 的定義方式,改為 es6 的 class 語法,看起來更加簡潔了:

class A {      render() {          return "AAAA";      }  }  export default A;
class B {      render() {          return "BBBB";      }  }  export default B;
$ npm run 003

結果

查看 dist/003.min.js 發現 class B 並沒有被成功消除,並且文件還變大了

!function(n){function t(e){if(r[e])return r[e].exports;var o=r[e]={i:e,l:!1,exports:{}};return n[e].call(o.exports,o,o.exports,t),o.l=!0,o.exports}var r={};return t.m=n,t.c=r,t.i=function(n){return n},t.d=function(n,r,e){t.o(n,r)||Object.defineProperty(n,r,{configurable:!1,enumerable:!0,get:e})},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=3)}([function(n,t,r){"use strict";var e=r(1);r(2);r.d(t,"a",function(){return e.a})},function(n,t,r){"use strict";function e(n,t){if(!(n instanceof t))throw new TypeError("Cannot call a class as a function")}var o=function(){function n(){e(this,n)}return n.prototype.render=function(){return"AAAA"},n}();t.a=o},function(n,t,r){"use strict";function e(n,t){if(!(n instanceof t))throw new TypeError("Cannot call a class as a function")}(function(){function n(){e(this,n)}return n.prototype.render=function(){return"BBBB"},n})()},function(n,t,r){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var e=r(0),o=new e.a;o.render()}]);

示例 4

簡單的變數場景

const A = "AAAA";  export default A;
const B = "BBBB";  export default B;
import { A, B } from './components/index';  let a = new A();  a.render();
$npm run 004

結果

查看 dist/004.min.jsB 被成功消除

!function(t){function n(e){if(r[e])return r[e].exports;var u=r[e]={i:e,l:!1,exports:{}};return t[e].call(u.exports,u,u.exports,n),u.l=!0,u.exports}var r={};return n.m=t,n.c=r,n.i=function(t){return t},n.d=function(t,r,e){n.o(t,r)||Object.defineProperty(t,r,{configurable:!1,enumerable:!0,get:e})},n.n=function(t){var r=t&&t.__esModule?function(){return t.default}:function(){return t};return n.d(r,"a",r),r},n.o=function(t,n){return Object.prototype.hasOwnProperty.call(t,n)},n.p="",n(n.s=3)}([function(t,n,r){"use strict";var e=r(1);r(2);r.d(n,"a",function(){return e.a})},function(t,n,r){"use strict";var e="AAAA";n.a=e},function(t,n,r){"use strict"},function(t,n,r){"use strict";Object.defineProperty(n,"__esModule",{value:!0});var e=r(0);console.log(e.a)}]);

為什麼

tree-shaking 不能消除帶有副作用的程式碼。

比如示例2,在函數的原型鏈上添加了方法,在這個場景下,B

其實應該被刪除掉,但是換一個場景,比如王 Array 的原型鏈上加一個 unique 方法:

function B() {  }    B.prototype.render = function() {      return "BBBB";  }    Array.prototype.unique = function() {      // 將 array 中的重複元素去除  }    export default B;

如果移除 function B 並且移除其原型成員 B.prototype.render ,是否應該移除 Array.prototype.unique 呢?在其它程式碼里,可能使用 arr = new Array() ,並且調用 arr.unique() ,所以移除 Array.prototype.unique 是不安全的。而現在實現的 tree-shaking 並不能區分 B.prototype.renderArray.prototype.unique ,既然後者不能移除,那麼前者也不能移除。並且 function B 也不能被移除。

示例 3 使用 es6 的 class 語法定義,按理說,應該沒有副作用了吧,可是查看 dist/003.min.jsB 還是沒有被消除。為什麼呢?因為babel 將 class 定義轉變成了 function 定義,而這個定義是有副作用的。

$ npm run 005 # 即執行下面的命令  $ # ./node_modules/webpack/bin/webpack.js --config webpack/005.js  $ # 跟 npm run 004 的命令的區別在於缺少 -p 壓縮參數  $ # ./node_modules/webpack/bin/webpack.js -p --config webpack/004.js

查看生成的程式碼 dist/005.min.jsclass B 被轉換成了如下的,跟示例 2 類似的程式碼了,B 是一個自執行的函數,帶有副作用,所以並不能被安全的移除。程式碼片段:

/* 2 */  /***/ function(module, exports, __webpack_require__) {    "use strict";  function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }    var B = function () {      function B() {          _classCallCheck(this, B);      }        B.prototype.render = function render() {          return "BBBB";      };        return B;  }();    /* unused harmony default export */ var _unused_webpack_default_export = B;

既然babel會轉換程式碼,那麼能不能不使用 babel 呢?

示例 6

修改 webpack config webpack/006.js ,禁用 babel loader

    module: {          // rules: [          //     {          //         test: /.js$/,          //         loader: 'babel-loader',          //         query: {          //             babelrc: false,          //             presets: [["es2015", { "modules": false, "loose": true }]]          //         }          //     }          // ]      },
$ npm run 006

結果

$ npm run 006    > @ 006 E:work5_codewebpack-demo-project  > node ./node_modules/webpack/bin/webpack.js -p --config webpack/006.js    Hash: e46574279f3737838494  Version: webpack 2.2.0-rc.3  Time: 76ms       Asset     Size  Chunks             Chunk Names  006.min.js  3.65 kB       0  [emitted]  006     [3] ./src/006/main.js 72 bytes {0} [built]      + 3 hidden modules    ERROR in 006.min.js from UglifyJs  SyntaxError: Unexpected token: name (A) [006.min.js:89,6]    npm ERR! Windows_NT 6.1.7601  npm ERR! argv "D:\Program Files\nodejs\node.exe" "D:\Program Files\nodejs\node_modules\npm\bin\npm-cli.js" "run" "006"  npm ERR! node v6.9.2  npm ERR! npm  v3.10.9  npm ERR! code ELIFECYCLE  npm ERR! @ 006: `node ./node_modules/webpack/bin/webpack.js -p --config webpack/006.js`  npm ERR! Exit status 2  npm ERR!  npm ERR! Failed at the @ 006 script'node ./node_modules/webpack/bin/webpack.js -p --config webpack/006.js'.  npm ERR! Make sure you have the latest version of node.js and npm installed.  npm ERR! If you do, this is most likely a problem with the  package,  npm ERR! not with npm itself.  npm ERR! Tell the author that this fails on your system:  npm ERR!     node ./node_modules/webpack/bin/webpack.js -p --config webpack/006.js  npm ERR! You can get information on how to open an issue for this project with:  npm ERR!     npm bugs  npm ERR! Or if that isn't available, you can get their info via:  npm ERR!     npm owner ls  npm ERR! There is likely additional logging output above.    npm ERR! Please include the following file with any support request:  npm ERR!     E:work5_codewebpack-demo-projectnpm-debug.log

因為帶了 -p 參數進行壓縮,內部使用的是 uglifyjs ,並不識別 es6 語法。

/* 2 */  /***/ function(module, exports, __webpack_require__) {    "use strict";  class B {      render() {          return "BBBB";      }  }    /* unused harmony default export */ var _unused_webpack_default_export = B;

示例 7

既然 babel 轉換的方案不行,存不存在將 class 定義轉換後無副作用的的方案呢?

答案是,有的。babili 是一個完全基於 es6 的刪減方案。

上面說到,class 定義被轉換成了帶副作用的函數,而 babili 不一樣的地方在於,不對 es6 的程式碼做 babel 轉換,而是輸入、輸出都是 es6。

// Example ES2015 Code  class Mangler {    constructor(program) {      this.program = program;    }  }  new Mangler(); // without this it would just output nothing since Mangler isn't used

Before

// ES2015+ code -> Babel -> Babili/Uglify -> Minified ES5 Code  var a=function a(b){_classCallCheck(this,a),this.program=b};new a;

After

// ES2015+ code -> Babili -> Minified ES2015+ Code  class a{constructor(b){this.program=b}}new a;

最後生成的程式碼如果要運行在 es5 環境中,需要再用 babel 轉換成 es5。

webpack/007.js

let BabiliPlugin = require("babili-webpack-plugin");  ...      plugins: [          new BabiliPlugin()      ],
$ npm run 007

結果

查看 dist/007.js 可以看到生成的程式碼,不包含 class B 的定義了:

(function(b){function c(e){if(d[e])return d[e].exports;var f=d[e]={i:e,l:!1,exports:{}};return b[e].call(f.exports,f,f.exports,c),f.l=!0,f.exports}var d={};return c.m=b,c.c=d,c.i=function(e){return e},c.d=function(e,f,g){c.o(e,f)||Object.defineProperty(e,f,{configurable:!1,enumerable:!0,get:g})},c.n=function(e){var f=e&&e.__esModule?function(){return e['default']}:function(){return e};return c.d(f,'a',f),f},c.o=function(e,f){return Object.prototype.hasOwnProperty.call(e,f)},c.p='',c(c.s=3)})([function(b,c,d){'use strict';var e=d(1);d(2),d.d(c,'a',function(){return e.a})},function(b,c){'use strict';c.a=class{render(){return'AAAA'}}},function(){'use strict'},function(b,c,d){'use strict';Object.defineProperty(c,'__esModule',{value:!0});var e=d(0);let f=new e.a;f.render()}]);

將上面的程式碼縮進後查看,class B 成功被刪除掉了, class A 的定義經過壓縮處理,變為:

function(b, c) {      'use strict';      c.a = class {          render() {              return 'AAAA'          }      }  }

A 的語法還是 es6 class 語法,如果要在瀏覽器中運行,還需要進一步處理。

看似很完美,但是 babili 的缺點在於,不能使用 babel 的很多 plugin ,而社區中很多方案都是基於 babel ,比如 babel-preset-react ,所以此方案還是有實用性上的問題。

不過,這也可能是一個潛在的方案。將來有機會再研究下去。

總結

查看其它使用 tree-shaking 的例子,能達到效果的都是使用函數來組織模組的,比如 webpack example: harmony-unused 。所以,其實使用 tree-shaking 的局限性還是比較大。

適用場景

目前看到使用 tree-shaking 比較成功的例子是 d3-jsnext ,不過使用的是 rollupjs 的方案。瀏覽 d3-jsnext 的程式碼,其程式碼基本都是用 function 而不是 class 來組織的。

參考

  1. Tree shaking completely broken?
  2. uglifyjs 配合webpack 壓縮程式碼的一個思路
  3. Tree shaking and "unused harmony default export
  4. webpack2 官方 tree-shaking 示例