webpack構建和效能優化探索

麥樂丶發表於2019-01-19

前言

隨著業務複雜度的不斷的增加,工程模組的體積也會不斷增加,構建後的模組通常要以M為單位計算。在構建過程中,基於nodejs的webpack在單程式的情況下loader表現變得越來越慢,在不做任何特殊處理的情況下,構建完後的多專案之間公用基礎資源存在重複打包,基礎庫程式碼複用率也不高,這都慢慢暴露出webpack的問題。

原文地址

正文

針對存在的問題,社群湧出了各種解決方案,包括webpack自身也在不斷優化。

構建優化

下面利用相關的方案對實際專案一步一步進行構建優化,提升我們的編譯速度,本次優化相關屬性如下:

  • 機器: Macbook Air 四核 8G記憶體

  • Webpack: v4.10.2

  • 專案:922個模組

構建優化方案如下:

  • 減少編譯體積大小

  • 將大型庫外鏈

  • 將庫預先編譯

  • 使用快取

  • 並行編譯

初始構建時間如下:

增量構建 Development 構建 Production 構建 備註
3088ms 43702ms 89371ms

減少編譯體積大小

初始構建時候,我們利用webpack-bundle-analyzer對編譯結果進行分析,結果如下:

webpack構建和效能優化探索

可以看到,td-ui(類似於antd的ui元件庫)、moment庫的locale、BizCharts佔了專案的大部分體積,而在沒有全部使用這些庫的全部內容的情況下,我們可以對齊進行按需載入。

針對td-ui和BizCharts,我們對齊新增按需載入babel-plugin-import,這個包可以在使用ES6模組匯入的時候,對其進行分析,解析成引入相應資料夾下面的模組,如下:

webpack構建和效能優化探索

首先,我們先新增babel的配置,在plugins中加入babel-plugin-import:

{
    ...
    "plugins": [
        ...
        ["import", [
            { libraryName: 'td-ui', style: true },
            { libraryName: 'bizcharts', libraryDirectory: 'lib/components' },
        ]]
    ]
}
複製程式碼

可以看到,我們給bizcharts也新增了按需載入,配置中新增了按需載入的指定資料夾,針對bizcharts,編譯前後程式碼對比如下:

編譯前:

webpack構建和效能優化探索

編譯後:

webpack構建和效能優化探索

注意:bizcharts按需載入需要引入其核心程式碼bizcharts/lib/core;

到此為止,td-ui和bizcharts的按需載入已經處理完畢,接下來是針對moment的處理。moment的主要體積來源於locale國際化資料夾,由於專案中有中英文國際化的需求,我們這裡使用webpack.ContextReplacementPugin對該資料夾的上下文進行匹配,只匹配中文和英文的語言包,plugin配置如下:

new webpack.ContextReplacementPugin(
    /moment[\/\\]locale$/, //匹配資料夾
    /zh-cn|en-us/  // 中英文語言包
)
複製程式碼

如果沒有國際化的需求,可以使用webpack.IgnorePlugin對整個locale資料夾進行忽略,配置如下:

new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/)
複製程式碼

減少編譯體積大小完成之後得到如下構建對比結果:

增量構建 Development 構建 Production 構建 備註
3088ms 43702ms 89371ms
2561ms 27864ms 67441ms 減少編譯體積大小

將大型庫外鏈 && 將庫預先編譯

為了避免一些已經編譯好的大型庫重新編譯,我們需要將這些庫放在編譯意外的地方,或者預先編譯這些庫。

webpack也為我們提供了將模組外鏈的配置externals,比如我們把lodash外鏈,配置如下

module.exports = {
  //...
  externals : {
    lodash: 'window._'
  },

  // 或者

  externals : {
    lodash : {
      commonjs: 'lodash',
      amd: 'lodash',
      root: '_' // 指向全域性變數
    }
  }
};
複製程式碼

針對庫預先編譯,webpack也提供了相應的外掛,那就是webpack.Dllplugin,這個外掛可以預先編譯製定好的庫,最後在實際專案中使用webpack.DllReferencePlugin將預先編譯好的庫關聯到當前的編譯結果中,無需重新編譯。

Dllplugin配置檔案webpack.dll.config.js如下:

webpack構建和效能優化探索

dllReference配置檔案webpack.dll.reference.config.js如下:

webpack構建和效能優化探索

最後使用webpack-mergewebpack.dll.reference.config.js合併到到webpack配置中。

注意:預先編譯好的庫檔案需要在html中手動引入並且必須放在webpack的entry引入之前,否則會報錯。

其實,將大型庫外鏈和將庫預先編譯也屬於減少編譯體積的一種,最後得到編譯時間結果如下:

增量構建 Development 構建 Production 構建 備註
3088ms 43702ms 89371ms
2561ms 27864ms 67441ms 減少編譯體積大小
2246ms 22870ms 50601ms Dll優化後

使用快取

首先,我們開啟babel-loader自帶的快取功能(預設其實就是開啟的)。

webpack構建和效能優化探索

另外,開啟uglifyjs-webpack-plugin的快取功能。

webpack構建和效能優化探索

新增快取外掛hard-source-webpack-plugin(當然也可以新增cache-loader)

const hardSourcePlugin = require('hard-source-webpack-plugin');

moudle.exports = {
    // ...
    plugins: [
        new hardSourcePlugin()
    ],
    // ...
}
複製程式碼

新增快取後編譯結果如下:

增量構建 Development 構建 Production 構建 備註
3088ms 43702ms 89371ms
2561ms 27864ms 67441ms 減少編譯體積大小
2246ms 22870ms 50601ms Dll優化後
1918ms 10056ms 17298ms 使用快取後

可以看到,編譯效果極好。

並行編譯

由於nodejs為單執行緒,為了更好利用好電腦多核的特性,我們可以將編譯並行開始,這裡我們使用happypack,當然也可以使用thread-loader,我們將babel-loader和樣式的loader交給happypack接管。

babel-loader配置如下:

webpack構建和效能優化探索

less-loader配置如下:

webpack構建和效能優化探索

構建結果如下:

增量構建 Development 構建 Production 構建 備註
3088ms 43702ms 89371ms
2561ms 27864ms 67441ms 減少編譯體積大小
2246ms 22870ms 50601ms Dll優化後
1918ms 10056ms 17298ms 使用快取後
2252ms 11846ms 18727ms 開啟happypack後

可以看到,新增happypack之後,編譯時間有所增加,針對這個結果,我對webpack版本和專案大小進行了對比測試,如下:

  • Webpack:v2.7.0

  • 專案:1013個模組

  • 全量production構建:105395ms

新增happypack之後,全量production構建時間降低到58414ms

針對webpack版本:

  • Webpack:v4.23.0

  • 專案:1013個模組

  • 全量development構建 : 12352ms

新增happypack之後,全量development構建降低到11351ms。

得到結論:Webpack v4 之後,happypack已經力不從心,效果並不明顯,而且在小型中並不適用。

所以針對並行載入方案要不要加,要具體專案具體分析。

效能優化

對於webpack編譯出來的結果,也有相應的效能優化的措施。方案如下:

  • 減少模組數量及大小

  • 合理快取

  • 合理拆包

減少模組數量及大小

針對減少模組數量及大小,我們在構建優化的章節中有提到很多,具體點如下:

  • 按需載入 babel-plugin-import(antd、iview、bizcharts)、babel-plugin-component(element-ui)
  • 減少無用模組webpack.ContextReplacementPlugin、webpack.IgnorePlugin
  • Tree-shaking:樹搖功能,消除無用程式碼,無用模組。
  • Scope-Hoisting:作用域提升。
  • babel-plugin-transform-runtime,針對babel-polyfill清除不必要的polyfill。

前面兩點我們就不具體描述,在構建優化章節中有說。

Tree-shaking

樹搖功能,將樹上沒用的葉子搖下來,寓意將沒有必要的程式碼刪除。該功能在webapck V2中已被webpack預設開啟,但是使用前提是,模組必須是ES6模組,因為ES6模組為靜態分析,動態引入的特性,可以讓webpack在構建模組的時候知道,哪些模組內容在引入中被使用,哪些模組沒有被使用,然後將沒有被引用的的模組在轉為為AST後刪除。

由於必須使用ES6模組,我們需要將babel的自動模組轉化功能關閉,否則你的es6模組將自動轉化為commonjs模組,配置如下:

{
    "presets": [
        "react",
        "stage-2",
        [
            "env",
            {
                "modlues": false // 關閉babel的自動轉化模組功能,保留ES6模組語法
            }
        ]
    ]
}
複製程式碼

Tree-shaking編譯時候可以在命令後使用--display-used-exports可以在shell列印出關於程式碼剔除的提示。

Scope-Hoisting

作用域提升,儘可能的把打散的模組合併到一個函式中,前提是不能造成程式碼冗餘。因此只有那些被引用了一次的模組才能被合併。

可能不好理解,下面demo對比一下有無Scope-Hoisting的編譯結果。

首先定義一個util.js檔案

export default 'Hello,Webpack';
複製程式碼

然後定義入口檔案main.js

import str from './util.js'
console.log(str);
複製程式碼

下面是無Scope-Hoisting結果:

webpack構建和效能優化探索

然後是Scope-Hoisting後的結果:

webpack構建和效能優化探索

與Tree-Shaking類似,使用Scope-Hoisting的前提也是必須是ES6模組,除此之外,還需要加入webpack內建外掛,位於webpack資料夾,webpack/lib/optimize/ModuleConcatenationPlugin,配置如下:

const ModuleConcatenationPlugin = require('webpack/lib/optimize/ModuleConcatenationPlugin');
module.exports = {
    //...
    plugins: [
        new ModuleConcatenationPlugin()
    ]
    //...
}
複製程式碼

另外,為了更好的利用Scope-Hoisting,針對Npm的第三方模組,它們也可能提供了ES6模組,我們可以指定優先使用它們的ES6模組,而不是使用它們編譯後的程式碼,webpack的配置如下:

module.exports = {
    //...
    resolve: {
        // 優先採用jsnext:main中指定的ES6模組檔案
        mainFields: ['jsnext:main', 'module', 'browser', 'main']
    }
    //...
}
複製程式碼

jsnext:main為業內大家約定好的存放ES6模組的資料夾,後續為了規範,更改為module資料夾。

babel-plugin-transform-runtime

在我們實際的專案中,為了相容一些老式的瀏覽器,我們需要在專案加入babel-polyfill這個包。由於babel-polyfill太大,導致我們編譯後的包體積增大,降低我們的載入效能,但是實際上,我們只需要加入我們使用到的不相容的內容的polyfill就可以,這個時候babel-plugin-transform-runtime就可以幫我們去除那些我們沒有使用到的polyfill,當然,你需要在babal-preset-env中配置你需要相容的瀏覽器,否則會使用預設相容瀏覽器。

新增babel-plugin-transform-runtime的.babelrc配置如下:

{
    "presets": [["env", {
        "targets": {
            "browsers": ["last 2 versions", "safari >= 7", "ie >= 9", "chrome >= 52"] // 配置相容瀏覽器版本
        },
        "modules": false
    }], "stage-2"],
    "plugins": [
        "transform-class-properties",
        "transform-runtime", // 新增babel-plugin-transform-runtime
        "transform-decorators-legacy"
    ]
}
複製程式碼

合理使用快取

webpack對應的快取方案為新增hash,那我們為什麼要給靜態資源新增hash呢?

  • 避免覆蓋舊檔案
  • 回滾方便,只需要回滾html
  • 由於檔名唯一,可開啟伺服器永遠緩

然後,webpack對應的hash有兩種,hashchunkhash

  • hash是跟整個專案的構建相關,只要專案裡有檔案更改,整個專案構建的hash值都會更改,並且全部檔案都共用相同的hash值
  • chunkhash根據不同的入口檔案(Entry)進行依賴檔案解析、構建對應的chunk,生成對應的雜湊值。

細想我們期望的最理想的hash就是當我們的編譯後的檔案,不管是初始化檔案,還是chunk檔案或者樣式檔案,只要檔案內容一修改,我們的hash就應該更改,然後重新整理快取。可惜,hash和chunkhash的最終效果都沒有達到我們的預期。

另外,還有來自於的 extract-text-webpack-plugincontenthash,contenthash針對編譯後的每個檔案內容生成hash。只是extract-text-webpack-plugin在wbepack4中已經被棄用,而且這個外掛只對css檔案生效。

webpack-md5-hash

為了達到我們的預期效果,我們可以為webpack新增webpack-md5-hash外掛,這個外掛可以讓webpack的chunkhash根據檔案內容生成hash,相對穩定,這樣就可以達到我們預期的效果了,配置如下:


var WebpackMd5Hash = require('webpack-md5-hash');
 
module.exports = {
    // ...
    output: {
        //...
        chunkFilename: "[chunkhash].[id].chunk.js"
    },
    plugins: [
        new WebpackMd5Hash()
    ]
};

複製程式碼

合理拆包

為了減少首屏載入的時候,我們需要將包拆分成多個包,然後需要的時候在載入,拆包方案有:

  • 第三方包,DllPlugin、externals。
  • 動態拆包,利用import()、require.ensure()語法拆包
  • splitChunksPlugin

針對第一點第三方包,我們也在第一章節構建優化中有介紹,這裡就不詳細說了。

動態拆包

首先是import(),這是webpack提供的語法,webpack在解析到這樣的語法時,會將指定的目錄檔案打包成一個chunk,當成非同步載入檔案輸出到編譯結果中,語法如下:

import(/* webpackChunkName: chunkName */ './chunkFile.js').then(_module => {
    // do something
});
複製程式碼

import()遵循promise規範,可以在then的回撥函式中處理模組。

注意:import()的引數不能完全是動態的,如果是動態的字串,需要預先指定字首資料夾,然後webpack會把整個資料夾編譯到結果中,按需載入。

然後是require.ensure(),與import()類似,為webpack提供函式,也是用來生成非同步載入模組,只是是使用callback的形式處理模組,語法如下:

// require.ensure(dependencies: String[], callback: function(require), chunkName: String)

require.ensure([], function(require){
	const _module = require('chunkFile.js');
}, 'chunkName');
複製程式碼
splitChunksPlugin

webpack4中,將commonChunksPlugin廢棄,引入splitChunksPlugin,兩個plugin的作用都是用來切割chunk。

webpack 把 chunk 分為兩種型別,initial和async。在webpack4的預設情況下,production構建會分析你的 entry、動態載入(import()、require.ensure)模組,找出這些模組之間共用的node_modules下的模組,並將這些模組提取到單獨的chunk中,在需要的時候非同步載入到頁面當中。

預設配置如下:

module.exports = {
  //...
  optimization: {
    splitChunks: {
      chunks: 'async', // 標記為非同步載入的chunk
      minSize: 30000,
      minChunks: 1,
      maxAsyncRequests: 5,
      maxInitialRequests: 3,
      automaticNameDelimiter: '~', // 檔名中chunk的分隔符
      name: true,
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10
        },
        default: {
          minChunks: 2, // 最小共享的chunk數
          priority: -20,
          reuseExistingChunk: true
        }
      }
    }
  }
};
複製程式碼

splitChunksPlugin提供了靈活的配置,開發者可以根據自己的需求分割chunk,比如下面官方的例子1程式碼:

module.exports = {
  //...
  optimization: {
    splitChunks: {
      cacheGroups: {
        commons: {
          name: 'commons',
          chunks: 'initial',
          minChunks: 2
        }
      }
    }
  }
};
複製程式碼

意思是在所有的初始化模組中抽取公共部分,生成一個chunk,chunk名字為comons。

在如官方例子2程式碼:

module.exports = {
  //...
  optimization: {
    splitChunks: {
      cacheGroups: {
        commons: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all'
        }
      }
    }
  }
};
複製程式碼

意思是從所有模組中抽離來自於node_modules下的所有模組,生成一個chunk。當然這只是一個例子,實際生產環境中並不推薦,因為會使我們首屏載入的包增大。

針對官方例子2,我們可以在開發環境中使用,因為在開發環境中,我們的node_modules下的所有檔案是基本不會變動的,我們將其生產一個chunk之後,每次增量編譯,webpack都不會去編譯這個來自於node_modules的已經生產好的chunk,這樣如果專案很大,來源於node_modules的模組非常多,這個時候可以大大降低我們的構建時間。

最後

現在大部分前端專案都是基於webpack進行構建的,面對這些專案,或多或少都有一些需要優化的地方,或許做優化不為完成KPI,僅為自己有更好的開發體驗,也應該行動起來。

相關文章