webpack 構建性能優化策略小結

背景

現在前端工程化的概念早已經深刻人心,選擇一款合適的編譯和資源管理工具已經成爲了全部前端工程中的標配,而在諸多的構建工具中,webpack以其豐富的功能和靈活的配置而深受業內吹捧,逐步取代了grunt和gulp成爲大多數前端工程實踐中的首選,React,Vue,Angular等諸多知名項目也都相繼選用其做爲官方構建工具,極受業內追捧。可是,隨者工程開發的複雜程度和代碼規模不斷地增長,webpack暴露出來的各類性能問題也愈發明顯,極大的影響着開發過程當中的體驗。css

圖片描述

問題概括

歷經了多個web項目的實戰檢驗,咱們對webapck在構建中逐步暴露出來的性能問題概括主要有以下幾個方面:html

  • 代碼全量構建速度過慢,即便是很小的改動,也要等待長時間才能查看到更新與編譯後的結果(引入HMR熱更新後有明顯改進);
  • 隨着項目業務的複雜度增長,工程模塊的體積也會急劇增大,構建後的模塊一般要以M爲單位計算;
  • 多個項目之間共用基礎資源存在重複打包,基礎庫代碼複用率不高;
  • node的單進程實如今耗cpu計算型loader中表現不佳;

針對以上的問題,咱們來看看怎樣利用webpack現有的一些機制和第三方擴展插件來逐個擊破。前端

慢在何處

做爲工程師,咱們一直鼓勵要理性思考,用數據和事實說話,「我以爲很慢」,「太卡了」,「太大了」之類的表述不免顯得太籠統和太抽象,那麼咱們不妨從以下幾個方面來着手進行分析:node

圖片描述

  • 從項目結構着手,代碼組織是否合理,依賴使用是否合理;
  • 從webpack自身提供的優化手段着手,看看哪些api未作優化配置;
  • 從webpack自身的不足着手,作有針對性的擴展優化,進一步提高效率;

在這裏咱們推薦使用一個wepback的可視化資源分析工具:webpack-bundle-analyzer,在webpack構建的時候會自動幫你計算出各個模塊在你的項目工程中的依賴與分佈狀況,方便作更精確的資源依賴和引用的分析。react

從上圖中咱們不難發現大多數的工程項目中,依賴庫的體積永遠是大頭,一般體積能夠佔據整個工程項目的7-9成,並且在每次開發過程當中也會從新讀取和編譯對應的依賴資源,這實際上是很大的的資源開銷浪費,並且對編譯結果影響微乎其微,畢竟在實際業務開發中,咱們不多會去主動修改第三方庫中的源碼,改進方案以下:jquery

方案1、合理配置 CommonsChunkPlugin

webpack的資源入口一般是以entry爲單元進行編譯提取,那麼當多entry共存的時候,CommonsChunkPlugin的做用就會發揮出來,對全部依賴的chunk進行公共部分的提取,可是在這裏可能不少人會誤認爲抽取公共部分指的是能抽取某個代碼片斷,其實並不是如此,它是以module爲單位進行提取。webpack

假設咱們的頁面中存在entry1,entry2,entry3三個入口,這些入口中可能都會引用如utils,loadash,fetch等這些通用模塊,那麼就能夠考慮對這部分的共用部分機提取。一般提取方式有以下四種實現:git

一、傳入字符串參數,由chunkplugin自動計算提取es6

new webpack.optimize.CommonsChunkPlugin('common.js')

這種作法默認會把全部入口節點的公共代碼提取出來, 生成一個common.jsgithub

二、有選擇的提取公共代碼

new webpack.optimize.CommonsChunkPlugin('common.js',['entry1','entry2']);

只提取entry1節點和entry2中的共用部分模塊, 生成一個common.js

三、將entry下全部的模塊的公共部分(可指定引用次數)提取到一個通用的chunk中

new webpack.optimize.CommonsChunkPlugin({
    name: 'vendors',
    minChunks: function (module, count) {
       return (
          module.resource &&
          /\.js$/.test(module.resource) &&
          module.resource.indexOf(
            path.join(__dirname, '../node_modules')
          ) === 0
       )
    }
});

提取全部node_modules中的模塊至vendors中,也能夠指定minChunks中的最小引用數;

四、抽取enry中的一些lib抽取到vendors中

entry = {
    vendors: ['fetch', 'loadash']
};
new webpack.optimize.CommonsChunkPlugin({
    name: "vendors",
    minChunks: Infinity
});

添加一個entry名叫爲vendors,並把vendors設置爲所須要的資源庫,CommonsChunk會自動提取指定庫至vendors中。

方案2、經過 externals 配置來提取經常使用庫

在實際項目開發過程當中,咱們並不須要實時調試各類庫的源碼,這時候就能夠考慮使用external選項了。

圖片描述

簡單來講external就是把咱們的依賴資源聲明爲一個外部依賴,而後經過script外鏈腳本引入。這也是咱們早期頁面開發中資源引入的一種翻版,只是經過配置後能夠告知webapck遇到此類變量名時就能夠不用解析和編譯至模塊的內部文件中,而改用從外部變量中讀取,這樣能極大的提高編譯速度,同時也能更好的利用CDN來實現緩存。

external的配置相對比較簡單,只須要完成以下三步:

一、在頁面中加入須要引入的lib地址,以下:

<head>
<script src="//cdn.bootcss.com/jquery.min.js"></script>
<script src="//cdn.bootcss.com/underscore.min.js"></script>
<script src="/static/common/react.min.js"></script>
<script src="/static/common/react-dom.js"></script>
<script src="/static/common/react-router.js"></script>
<script src="/static/common/immutable.js"></script>
</head>

二、在webapck.config.js中加入external配置項:

module.export = {
    externals: {
        'react-router': {
            amd: 'react-router',
            root: 'ReactRouter',
            commonjs: 'react-router',
            commonjs2: 'react-router'
        },
        react: {
            amd: 'react',
            root: 'React',
            commonjs: 'react',
            commonjs2: 'react'
        },
        'react-dom': {
            amd: 'react-dom',
            root: 'ReactDOM',
            commonjs: 'react-dom',
            commonjs2: 'react-dom'
        }
    }
}

這裏要提到的一個細節是:此類文件在配置前,構建這些資源包時須要採用amd/commonjs/cmd相關的模塊化進行兼容封裝,即打包好的庫已是umd模式包裝過的,如在node_modules/react-router中咱們能夠看到umd/ReactRouter.js之類的文件,只有這樣webpack中的require和import * from 'xxxx'才能正確讀到該類包的引用,在這類js的頭部通常也能看到以下字樣:

if (typeof exports === 'object' && typeof module === 'object') {
    module.exports = factory(require("react"));
} else if (typeof define === 'function' && define.amd) {
    define(["react"], factory);
} else if (typeof exports === 'object') {
    exports["ReactRouter"] = factory(require("react"));
} else {
    root["ReactRouter"] = factory(root["React"]);
}

三、很是重要的是必定要在output選項中加入以下一句話:

output: {
  libraryTarget: 'umd'
}

因爲經過external提取過的js模塊是不會被記錄到webapck的chunk信息中,經過libraryTarget可告知咱們構建出來的業務模塊,當讀到了externals中的key時,須要以umd的方式去獲取資源名,不然會有出現找不到module的狀況。

經過配置後,咱們能夠看到對應的資源信息已經能夠在瀏覽器的source map中讀到了。

externals.png

對應的資源也能夠直接由頁面外鏈載入,有效地減少了資源包的體積。

圖片描述

方案3、利用 DllPlugin 和 DllReferencePlugin 預編譯資源模塊

咱們的項目依賴中一般會引用大量的npm包,而這些包在正常的開發過程當中並不會進行修改,可是在每一次構建過程當中卻須要反覆的將其解析,如何來規避此類損耗呢?這兩個插件就是幹這個用的。

簡單來講DllPlugin的做用是預先編譯一些模塊,而DllReferencePlugin則是把這些預先編譯好的模塊引用起來。這邊須要注意的是DllPlugin必需要在DllReferencePlugin執行前先執行一次,dll這個概念應該也是借鑑了windows程序開發中的dll文件的設計理念。

相對於externals,dllPlugin有以下幾點優點:

  • dll預編譯出來的模塊能夠做爲靜態資源連接庫可被重複使用,尤爲適合多個項目之間的資源共享,如同一個站點pc和手機版等;
  • dll資源能有效地解決資源循環依賴的問題,部分依賴庫如:react-addons-css-transition-group這種原先從react核心庫中抽取的資源包,整個代碼只有一句話:

    module.exports = require('react/lib/ReactCSSTransitionGroup');

    卻由於從新指向了react/lib中,這也會致使在經過externals引入的資源只能識別react,尋址解析react/lib則會出現沒法被正確索引的狀況。

  • 因爲externals的配置項須要對每一個依賴庫進行逐個定製,因此每次增長一個組件都須要手動修改,略微繁瑣,而經過dllPlugin則能徹底經過配置讀取,減小維護的成本;

一、配置dllPlugin對應資源表並編譯文件

那麼externals該如何使用呢,其實只須要增長一個配置文件:webpack.dll.config.js:

const webpack = require('webpack');
const path = require('path');
const isDebug = process.env.NODE_ENV === 'development';
const outputPath = isDebug ? path.join(__dirname, '../common/debug') : path.join(__dirname, '../common/dist');
const fileName = '[name].js';

// 資源依賴包,提早編譯
const lib = [
  'react',
  'react-dom',
  'react-router',
  'history',
  'react-addons-pure-render-mixin',
  'react-addons-css-transition-group',
  'redux',
  'react-redux',
  'react-router-redux',
  'redux-actions',
  'redux-thunk',
  'immutable',
  'whatwg-fetch',
  'byted-people-react-select',
  'byted-people-reqwest'
];

const plugin = [
  new webpack.DllPlugin({
    /**
     * path
     * 定義 manifest 文件生成的位置
     * [name]的部分由entry的名字替換
     */
    path: path.join(outputPath, 'manifest.json'),
    /**
     * name
     * dll bundle 輸出到那個全局變量上
     * 和 output.library 同樣便可。
     */
    name: '[name]',
    context: __dirname
  }),
  new webpack.optimize.OccurenceOrderPlugin()
];

if (!isDebug) {
  plugin.push(
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify('production')
    }),
    new webpack.optimize.UglifyJsPlugin({
      mangle: {
        except: ['$', 'exports', 'require']
      },
      compress: { warnings: false },
      output: { comments: false }
    })
  )
}

module.exports = {
  devtool: '#source-map',
  entry: {
    lib: lib
  },
  output: {
    path: outputPath,
    filename: fileName,
    /**
     * output.library
     * 將會定義爲 window.${output.library}
     * 在此次的例子中,將會定義爲`window.vendor_library`
     */
    library: '[name]',
    libraryTarget: 'umd',
    umdNamedDefine: true
  },
  plugins: plugin
};

而後執行命令:

$ NODE_ENV=development webpack --config  webpack.dll.lib.js --progress
 $ NODE_ENV=production webpack --config  webpack.dll.lib.js --progress

便可分別編譯出支持調試版和生產環境中lib靜態資源庫,在構建出來的文件中咱們也能夠看到會自動生成以下資源:

common
├── debug
│   ├── lib.js
│   ├── lib.js.map
│   └── manifest.json
└── dist
    ├── lib.js
    ├── lib.js.map
    └── manifest.json

文件說明:

  • lib.js能夠做爲編譯好的靜態資源文件直接在頁面中經過src連接引入,與externals的資源引入方式同樣,生產與開發環境能夠經過相似charles之類的代理轉發工具來作路由替換;
  • manifest.json中保存了webpack中的預編譯信息,這樣等於提早拿到了依賴庫中的chunk信息,在實際開發過程當中就無須要進行重複編譯;

二、dllPlugin的靜態資源引入

lib.js和manifest.json存在一一對應的關係,因此咱們在調用的過程也許遵循這個原則,如當前處於開發階段,對應咱們能夠引入common/debug文件夾下的lib.js和manifest.json,切換到生產環境的時候則須要引入common/dist下的資源進行對應操做,這裏考慮到手動切換和維護的成本,咱們推薦使用add-asset-html-webpack-plugin進行依賴資源的注入,可獲得以下結果:

<head>
<script src="/static/common/lib.js"></script>
</head>

在webpack.config.js文件中增長以下代碼:

const isDebug = (process.env.NODE_ENV === 'development');
const libPath = isDebug ? '../dll/lib/manifest.json' : 
'../dll/dist/lib/manifest.json';

// 將mainfest.json添加到webpack的構建中

module.export = {
  plugins: [
       new webpack.DllReferencePlugin({
       context: __dirname,
       manifest: require(libPath),
      })
  ]
}

配置完成後咱們能發現對應的資源包已經完成了純業務模塊的提取

圖片描述

多個工程之間若是須要使用共同的lib資源,也只須要引入對應的lib.js和manifest.js便可,plugin配置中也支持多個webpack.DllReferencePlugin同時引入使用,以下:

module.export = {
  plugins: [
     new webpack.DllReferencePlugin({
        context: __dirname,
        manifest: require(libPath),
      }),
      new webpack.DllReferencePlugin({
        context: __dirname,
        manifest: require(ChartsPath),
      })
  ]
}

方案4、使用 Happypack 加速你的代碼構建

以上介紹均爲針對webpack中的chunk計算和編譯內容的優化與改進,對資源的實際體積改進上也較爲明顯,那麼除此以外,咱們可否針對資源的編譯過程和速度優化上作些嘗試呢?

衆所周知,webpack中爲了方便各類資源和類型的加載,設計了以loader加載器的形式讀取資源,可是受限於node的編程模型影響,全部的loader雖然以async的形式來併發調用,可是仍是運行在單個 node的進程以及在同一個事件循環中,這就直接致使了當咱們須要同時讀取多個loader文件資源時,好比babel-loader須要transform各類jsx,es6的資源文件。在這種同步計算同時須要大量耗費cpu運算的過程當中,node的單進程模型就無優點了,那麼happypack就針對解決此類問題而生。

開啓happypack的線程池

happypack的處理思路是將原有的webpack對loader的執行過程從單一進程的形式擴展多進程模式,本來的流程保持不變,這樣能夠在不修改原有配置的基礎上來完成對編譯過程的優化,具體配置以下:

const HappyPack = require('happypack');
 const os = require('os')
 const HappyThreadPool = HappyPack.ThreadPool({ size: os.cpus().length}); // 啓動線程池});

module:{
    rules: [
      {
        test: /\.(js|jsx)$/,
        // use: ['babel-loader?cacheDirectory'],
        use: 'happypack/loader?id=jsx',
        exclude: /^node_modules$/
      }
    ]
  },
  plugins:[
    new HappyPack({
     id: 'jsx',
     cache: true,
     threadPool: HappyThreadPool,
     loaders: ['babel-loader']
   })
  ]

咱們能夠看到經過在loader中配置直接指向happypack提供的loader,對於文件實際匹配的處理 loader,則是經過配置在plugin屬性來傳遞說明,這裏happypack提供的loader與plugin的銜接匹配,則是經過id=happybabel來完成。配置完成後,laoder的工做模式就轉變成了以下所示:

圖片描述

happypack在編譯過程當中除了利用多進程的模式加速編譯,還同時開啓了cache計算,能充分利用緩存讀取構建文件,對構建的速度提高也是很是明顯的,通過測試,最終的構建速度提高以下:

優化前:
圖片描述

優化後:
圖片描述

關於happyoack的更多介紹能夠查看:

方案5、加強 uglifyPlugin

uglifyJS憑藉基於node開發,壓縮比例高,使用方便等諸多優勢已經成爲了js壓縮工具中的首選,可是咱們在webpack的構建中觀察發現,當webpack build進度走到80%先後時,會發生很長一段時間的停滯,經測試對比發現這一過程正是uglfiyJS在對咱們的output中的bunlde部分進行壓縮耗時過長致使,針對這塊咱們可使用webpack-uglify-parallel來提高壓縮速度。

從插件源碼中能夠看到,webpack-uglify-parallel的是實現原理是採用了多核並行壓縮的方式來提高咱們的壓縮速度。

plugin.nextWorker().send({
    input: input,
    inputSourceMap: inputSourceMap,
    file: file,
    options: options
});

plugin._queue_len++;
                
if (!plugin._queue_len) {
    callback();
}               

if (this.workers.length < this.maxWorkers) {
    var worker = fork(__dirname + '/lib/worker');
    worker.on('message', this.onWorkerMessage.bind(this));
    worker.on('error', this.onWorkerError.bind(this));
    this.workers.push(worker);
}

this._next_worker++;
return this.workers[this._next_worker % this.maxWorkers];

使用配置也很是簡單,只須要將咱們原來webpack中自帶的uglifyPlugin配置:

new webpack.optimize.UglifyJsPlugin({
   exclude:/\.min\.js$/
   mangle:true,
   compress: { warnings: false },
   output: { comments: false }
})

修改爲以下代碼便可:

const os = require('os');
    const UglifyJsParallelPlugin = require('webpack-uglify-parallel');
    
    new UglifyJsParallelPlugin({
      workers: os.cpus().length,
      mangle: true,
      compressor: {
        warnings: false,
        drop_console: true,
        drop_debugger: true
       }
    })

目前webpack官方也維護了一個支持多核壓縮的UglifyJs插件:uglifyjs-webpack-plugin,使用方式相似,優點在於徹底兼容webpack.optimize.UglifyJsPlugin中的配置,能夠經過uglifyOptions寫入,所以也作爲推薦使用,參考配置以下:

const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
  new UglifyJsPlugin({
    uglifyOptions: {
      ie8: false,
      ecma: 8,
      mangle: true,
      output: { comments: false },
      compress: { warnings: false }
    },
    sourceMap: false,
    cache: true,
    parallel: os.cpus().length * 2
  })

方案6、Tree-shaking & Scope Hoisting

wepback在2.X和3.X中從rolluo中借鑑了tree-shakingScope Hoisting,利用es6的module特性,利用AST對全部引用的模塊和方法作了靜態分析,從而能有效地剔除項目中的沒有引用到的方法,並將相關方法調用概括到了獨立的webpack_module中,對打包構建的體積優化也較爲明顯,可是前提是全部的模塊寫法必須使用ES6 Module進行實現,具體配置參考以下:

// .babelrc: 經過配置減小沒有引用到的方法
  {
    "presets": [
      ["env", {
        "targets": {
          "browsers": ["last 2 versions", "safari >= 7"]
        }
      }],
      // https://www.zhihu.com/question/41922432
      ["es2015", {"modules": false}]  // tree-shaking
    ]
  }

  // webpack.config: Scope Hoisting
  {
    plugins:[
      // https://zhuanlan.zhihu.com/p/27980441
      new webpack.optimize.ModuleConcatenationPlugin()
    ]
  }

適用場景

在實際的開發過程當中,可靈活地選擇適合自身業務場景的優化手段。

優化手段 開發環境 生產環境
CommonsChunk
externals  
DllPlugin
Happypack  
uglify-parallel  

工程演示demo

舒適提醒

本文中的全部例子已經從新優化,支持最新的webpack3特性,並附帶有分享ppt地址,能夠在線點擊查看

小結

性能優化無小事,追求快沒有止境,在前端工程日益龐大複雜的今天,針對實際項目,持續改進構建工具的性能,對項目開發效率的提高和工具深度理解都是極其有益的。

相關文章
相關標籤/搜索