• 中文
  • ENGLISH
今天,你升級Webpack2了嗎?
2017/02/20

本文作者:彼洋

1. 前言

Web前端打包技術日新月異,從grunt到gulp再到webpack,如今又出現了新的挑戰者:rollup,它祭出的殺手锏是tree-shaking,可以減小打包后的體積。為了趕超rollup,webpack也加入了tree-shaking功能,推出了webpack2。

筆者最近在開發移動端web應用,出于減小資源體積的想法,從webpack升級到了webpack2,利用tree-shaking成功將體積減小了14%。鑒于它是公司內部的業務項目,不便把代碼公之于眾,在這里,筆者把自己的經驗分享給大家。

文章整體目錄如下:
  1. 前言
  2. tree-shaking
    • 什么是tree-shaking
    • webpack2的tree-shaking實現
  3. 升級
    • 升級版本
    • 修改配置文件
    • 調整模塊目錄結構
    • 修改模塊語法
    • 改變打包流程
  4. 總結
  5. 附錄
  6. 參考資料

2. tree-shaking

2.1. 什么是tree-shaking

tree-shaking

tree-shaking這個概念最初是由rollup提出來的,從字面意思來理解,搖動一棵樹,枯枝敗葉就會掉下來,看起來是指去掉不需要的代碼,和DCE(dead code elimination,死代碼消除)差不多。不過,它和DCE還是有區別的,用作者自己的話來說,DCE是去除死代碼,而tree-shaking是保留活代碼,是實現DCE的一種方式。
眾所周知,commonjs模塊是動態加載的,且可以重命名,要想在靜態分析階段判斷哪些代碼不會被執行到,有一定難度,需要借助數據流分析。所以tree-shaking是借助了ES6的模塊機制,通過import/export等關鍵字來定義輸入輸出的方法,且其重命名只能通過as這個關鍵字,模塊一旦被import進來,就是只讀的,這樣,我們只根據名字,就可以從入口文件一路溯源到模塊定義處,只把用到的方法打包進來。

2.2. webpack2的tree-shaking實現

要想使用tree-shaking,需要解析ES6模塊語法,webpack2是借助于acorn實現這一點的。在拿到AST之后,webpack2會統計每個模塊export的方法被使用的次數,并把沒有用到的export語句刪掉。至于沒有被export的定義,則要在后續的DCE(dead code elimination)過程中消除。import/export之外的ES6代碼,要使用Babel進行轉碼,因為acorn只有解析功能,但沒有轉換功能。示例代碼如下:
helpers.js

export function foo() {
    return 'foo';
}
export function bar() {
    return 'bar';
}

main.js

import {foo} from './helpers';

let elem = document.getElementById('output');
elem.innerHTML = `Output: ${foo()}`;

helpers.bundle.js (// after webpack)

function(module, exports, __webpack_require__) {
    /* harmony export */ exports["foo"] = foo;
    /* unused harmony export bar */;

    function foo() {
        return 'foo';
    }
    function bar() {
        return 'bar';
    }
}

helpers.bundle.min.js (// after uglify)

function (t, n, r) {
    function e() {
        return "foo"
    }
    n.foo = e
}

可見,經過webpack2打包之后,未使用的export bar會被標記為/* unused harmony export bar */,然后,再經過uglify,未被export的bar定義會被刪除。

3. 升級

看起來webpack2很好很強大,我們應該升級,就連它的官方網站都在催促升級。

webpack v1 is deprecated. We encourage all developers to upgrade to webpack 2.

還給出了一份升級指南(這里還有一份第三方的),具體我就不再贅述了。當然,這些指南只涉及了配置文件的修改,而對大多數的工程來說,只修改配置是無法充分利用tree-shaking的。下面,我就以自己的項目為例,從升級版本,到修改配置文件,再到改寫模塊語法,改變打包流程等方面,來說明一下webpack2 tree-shaking優化升級的過程。

3.1. 升級版本

首先把webpack更新為webpack2,默認安裝v2版本

tnpm ii webpack --save-dev

查看版本:

./node_modules/.bin/webpack --version   //2.2.1

然后升級[email protected][email protected][email protected]的版本。

3.2. 修改配置文件

3.2.1. .babelrc

首先更新.babelrc,主要是更新babel-preset-es2015這個預設,因為它包含了babel-plugin-transform-es2015-modules-commonjs這個變換插件,會把ES6模塊變換為commonjs模塊。前面我已經說過了,tree-shaking是利用ES6模塊機制實現的,所以不可以使用這個babel插件進行轉換。

Axel的例子中,他把這個預設中的其他插件一一手動列出,后來有人把這些插件放在一起作為新的預設babel-preset-es2015-native-modules,再后來babel-preset-es2015本身就支持禁用這個插件,只要寫成["es2015", { "modules": false }]即可。

然后,如果你還在使用babel-plugin-add-module-exports,那么現在可以去掉了。因為它是為了支持錯誤的import/export方法而存在的,而這現在是由webpack2接管的。

另外,如果你的開發環境是chrome等現代瀏覽器,原生就已經支持ES6的大部分特性,就可以在開發時去掉大部分的preset和plugin,只保留babel-preset-react等瀏覽器無法支持的預設,以提升編譯速度,上線時再加上剩余的babel轉換。

3.2.2. webpack.config.js

然后更新webpack的配置文件,大部分改動都在這個文件中。結合官方遷移教程,對照自己的配置,一一修改即可。如果遺漏,webpack會提示的。

loader方面改動較大。首先是把loaders改名為rules。然后,為了支持loader的傳參,增設了options參數;為了方便多loader,增設了use字段,其參數為loader的數組。寫loader名字的時候,不再能省略后面的-loader

在webpack2中使用happypack,如果需要向loader傳遞參數,需要通過query參數,可以去github倉庫自尋官方示例

值得一提的是,webpack2去掉了OccurenceOrderPlugin和DedupePlugin,前者的功能是調整模塊順序,把經常用到的放在前面,可以減小require時的數字長度,去掉是因為已經默認支持,不再像之前那樣會影響速度;后者的功能是去除重復模塊,去掉是因為重復模塊應該靠npm去除,而不是靠webpack來比較hash。

3.3. 調整模塊目錄結構

要想用npm實現模塊去重,應該使用[email protected],或者[email protected]/4,前者會把依賴的模塊安裝在同級目錄,后者會維持node_modules目錄結構,但是通過軟鏈接指向同級的實際模塊目錄。不管用哪種方式,都實現了只有一個模塊實體。

值得注意的是,如果你使用npm link,則仍然保持原有的目錄結構,可能會帶來重復模塊,這時可以使用alias把依賴的模塊指向最外層。

3.4. 修改模塊語法

考察下面這種寫法:

export function setTitle() {
  ...
}

export function addButton() {
  ...
}

export default {
  setTitle,
  addButton
};

它的目的是把setTitle和addButton作為單獨的方法輸出,同時也把模塊整體作為對象輸出。這種寫法其實是反模式的。根據ES6模塊規范,可以認為export default是把后面的變量先賦給default再輸出,所以用export default {}的寫法,輸出的其實就是個Object,沒法利用ES6模塊系統的多輸入多輸出特性,也沒法利用tree-shaking。要想輸出多個方法,應該分別輸出,或者先定義,再統一以下面的形式輸出:

export {
  setTitle,
  addButton
};

你可能想問,不輸出default的話,如果想要整體import該怎么辦。

ES6提供了下面這種語法(namespace import):

import * as a from 'a'

然后即使調用a.setTitlea.addButton也能正常tree-shaking。

3.5. 改變打包流程

之前我們的打包流程,一般是先經過babel-loader轉碼,然后webpack打包,最后使用uglify壓縮,這一切都可以在webpack.congif.js中配置。但現在,出于一點考慮,我要改變這個流程了,這一點就是副作用。

3.5.1. 副作用

3.5.1.1. IIFE的副作用

我們看一下經過DCE的代碼,以其中的一個class定義為例:

(function() {
    function LocalStore() {
        _classCallCheck(this, LocalStore), this.LocalStoreObj = null;
    }
    return _createClass(LocalStore, [
        ...
    ]), LocalStore;
})()

用到這個class的export已經被去除了,但class仍然存在。到uglify的warning信息中搜索LocalStore,可以發現這么一句:

Side effects in initialization of unused variable LocalStore [ubanner.js:10902,4]

它后面的行號是uglify之前的位置,所以我們去掉uglify插件,再打包一次,然后定位到這個位置:

var LocalStore = function () {
    function LocalStore() {
        _classCallCheck(this, LocalStore);
        this.LocalStoreObj = null;
    }
    _createClass(LocalStore, [
      ...
    ])
    return LocalStore;
}();

結合報錯信息,問題應該出在后面的IIFE上。關于這個問題,有人已經踩過坑。簡單來說,就是,對某個變量的屬性賦值操作無法被消除,因為不確定這個變量是否在其他地方被使用。在沒有數據流分析的情況下,只能做這種保守處理。他給的例子中,是對prototype屬性進行了賦值;而我的這個例子中,既存在對對象屬性的賦值:this.LocalStoreObj = null;,又存在函數調用:_classCallCheck_createClass。(同樣是class的轉換結果,之所以會和例子中的不一樣,是因為loose mode

要想解決這個問題,需要在class未被轉換為ES5的情況下就進行DCE,具體來說,就是去掉babel-preset-es2015中的babel-plugin-transform-es2015-classes。前面說過,開發階段可以在現代瀏覽器中運行ES6的代碼,只保留react預設和其他少量plugin,那么,DCE就可以在其基礎上進行。

對于ES6代碼的uglify,不可以使用webpack的uglify插件,因為它其實是UglifyJS2,其harmony branch尚未發布,不能壓縮ES6的代碼,所以要尋找其他的uglify工具,我選擇了babel-plugin-minify-dead-code-elimination。它隸屬于babel的babel-preset-babili,雖然支持的配置項比較少,但勝在代碼規范且簡單,方便后續修改。

使用上述工具進行DCE之后的代碼還是ES6的,所以在發布時,需要使用babel對打包之后的代碼進行轉換,開啟preset-es2015及preset-stage-0,然后再用Uglify進行壓縮。

3.5.1.2. getter的副作用

查看下面的代碼:

(class extends __WEBPACK_IMPORTED_MODULE_0_weex_rx__["Component"] {
  ...
});

它是MessageCon類的定義,很明顯,前面的let MessageCon =已經被去掉了,但是后面的initialization還保留著,原因是副作用。具體來說,是__WEBPACK_IMPORTED_MODULE_0_weex_rx__["Component"]可能會調用__WEBPACK_IMPORTED_MODULE_0_weex_rx__的getter方法,所以被判斷有副作用。要想解決它,只要把__WEBPACK_IMPORTED_MODULE_0_weex_rx__["Component"]先賦值給某個變量,比如_ref1,然后再把_ref1作為MessageCon的父類。要實現這個變換,可以使用正則表達式,當然更好的方法是自己實現babel插件來做轉換。

然后,__WEBPACK_IMPORTED_MODULE_0_weex_rx__["Component"]需要通過UglifyJS2去除,把pure_getters選項置為true,即可提示UglifyJS2形如foo.barfoo["bar"]的屬性訪問是沒有副作用的。

3.5.2. 3輪DCE

這里要說明一點,要想有效消除死代碼,我們需要至少3輪DCE。為何要3輪?以Message組件為例,其代碼如下:

let _ref10 = __WEBPACK_IMPORTED_MODULE_0_weex_rx__["Component"];
let MessageCon = class MessageCon extends _ref10 {
    constructor() {
        super();
    }
    ...
};

function success(children, onClosed) {
    expression containing MessageCon
}

function warn(children) {
    expression containing MessageCon
}

function error(children) {
    expression containing MessageCon
}

這是經過webpack的tree-shaking和上面提到的babel-super-class-plugin處理過的代碼,顯然,這個模塊的export已被webpack去掉,所有代碼都應該去除,但是它們之間還有依賴關系,如下圖所示:

DCE dependency.png

因為有3層,所以要經過3輪DCE。實際上,應該經過N輪,直到代碼不再變化為止,但大多數文件3輪足矣。

3.5.3. 新的打包流程

綜上所述,新的打包流程就是:
1. 使用preset-react(或其他類似preset/plugin)和super-class-plugin將jsx轉碼為ES6(開發環境可直接使用這個代碼)
2. 使用babel-plugin-minify-dead-code-elimination對ES6代碼進行3輪DCE
3. 使用preset-es2015和preset-state-0轉碼為ES5
4. 使用Uglify對ES5代碼進行DCE

4. 總結

總結一下,本文所采取的升級步驟為:
1. 升級webpack和babel版本
2. 改寫webpack和babel配置
3. 升級npm/tnpm版本以確保node_modules不包含重復模塊
4. 修改import/export語法
5. 改變打包流程
1. 使用preset-react(或其他類似preset/plugin)和super-class-plugin將jsx轉碼為ES6(開發環境可直接使用這個代碼)
2. 使用babel-plugin-minify-dead-code-elimination對ES6代碼進行3輪DCE
3. 使用preset-es2015和preset-state-0轉碼為ES5
4. 使用Uglify對ES5代碼進行DCE

經過以上步驟,我的項目體積減小了14%,當然,還尚未對一些第三方庫進行tree-shaking,而且一些二方庫放到了CDN,也限制了tree-shaking的效果。接下來,我會繼續研究對于第三方庫的tree-shaking,歡迎同道者交流。

5. 附錄

5.1. happypack

如果你使用了happypack,在升級時可能需要清空happypack的緩存目錄,默認是.happypack。
happypack是一個webpack插件,用來實現多線程打包。它默認使用緩存,判斷緩存是否生效是看上次編譯時的最后修改時間和當前的最后修改時間是否一致,如果一致就使用緩存。這就帶來了一個問題:如果只改變打包配置,但不改變文件內容,則會使用緩存,但實際上我們是需要它重新打包的,這時就要刪除緩存目錄: .happypack。

5.2. stats.json

有時,你不知道打出來的包體積為何會這么大,這時,你可以在webpack analyze網站分析模塊重復情況。

首先,我們要生成打包信息的記錄文件stats.json,注意,如果你使用命令行的webpack,可以通過配置來開啟stats,但如果使用Node.js的webpack,則要參照這個文檔

把記錄文件上傳到網站之后,可以看到下圖這個畫面:

screenshot.png

點擊modules,即可查看各個模塊,點擊size,按從大到小排序,去尋找那些不該出現,或者出現了多次的模塊。想要知道模塊在哪里被引用,點擊issuer即可。

5.3. babel-plugin-transform-imports

這個插件會把形如

import { Text } from 'nuke';

的引用轉換為下面的形式:

import Text from 'nuke/lib/Text';

如果你在使用一個組件庫,那么,把大包引用改為小包就可以節省絕大部分的體積,從而不需要tree-shaking。

5.4. uglify參數

要想看哪些部分沒有tree-shaking,需要對uglify的參數進行定制。要想DCE,需要開啟compress,但是要想查看代碼,需要關閉uglify以及保留comment。具體參數如下:

{
  beautify: true, // 添加適當的空格和換行
  compress: {     // 開啟代碼壓縮,包括DCE等
    warnings: true,   // 當因為副作用等原因DCE失敗時,會在命令行中給出警告
    drop_console: true,   // 不用解釋了吧
  },
  output: { comments: true },  // 保留注釋,方便尋找`unused harmony`標簽
  mangle: false   // 禁用變量混淆,以方便分析
}  

5.5. 第三方ES6模塊

現在,大部分的模塊輸出的代碼都是commonjs標準的,沒辦法進行tree-shaking,但是,可以通過在package.json中添加module字段來指定es6的代碼輸出,如redux的做法。但要注意的是,由于現階段大部分環境都不支持ES6特性,而webpack2又只能轉換import/export部分的代碼,所以其余的ES6特性需要模塊提供者來進行轉換,參見redux的babel配置。另外,鑒于上面提到的副作用,被轉換為ES5代碼后,有些代碼無法被DCE消除,如轉碼后的class。所以,如果可能的話,直接解析模塊的源碼可以獲得更好的體積壓縮。或者,采用激進的DCE策略,這一點有待研究。

6. 參考資料

訂閱我們
体彩20选5开奖结果查询