背景
回顧2016的前端技術的發展真的可以用百(gui)花(quan)爭(zhen)鳴(luan)來形容,無論是技術棧的演進,技術框架的推新,還是各種模式,反模式的最佳實踐都在不斷地湧現,網上的一篇文章《在2016年學JavaScript是一種什麼樣的體驗?》更是把這一現狀做了很好總結。當然,吐槽歸吐槽,技術的車輪還是要始終向前邁進,從中我們也不難發現,從前那種直接在js中寫指令碼。通過src嵌入到頁面,然後按F5重新整理頁面檢視結果的開發方式已經漸行漸遠,基本上選擇一款合適的編譯和資源管理工具已經成為了所有前端工程中的標配,而在諸多的構建工具中,webpack以其豐富的功能和靈活的配置在整個2016年中大放光彩,React,Vue,angularjs2等諸多知名專案也都選用其作為官方構建工具,極受業內追捧,但是隨者工程開發的複雜程度和程式碼規模不斷地增加,webpack暴露出來的各種效能問題也愈發明顯,極大的影響著開發過程中的體驗。
問題歸納
歷經了多個web專案的實戰檢驗,我們對webapck在構建中逐步暴露出來的效能問題歸納主要有如下幾個方面:
- 程式碼全量構建速度過慢,即使是很小的改動,也要等待長時間才能檢視到更新與編譯後的結果(引入HMR熱更新後有明顯改進);
- 隨著專案業務的複雜度增加,工程模組的體積也會急劇增大,構建後的模組通常要以M為單位計算;
- 多個專案之間共用基礎資源存在重複打包,基礎庫程式碼複用率不高;
- node的單程式實現在耗cpu計算型loader中表現不佳;
針對以上的問題,我們來看看怎樣利用webpack現有的一些機制和第三方擴充套件外掛來逐個擊破。
慢在何處
作為工程師,我們一直鼓勵要理性思考,用資料和事實說話,“我覺得很慢”,“太卡了”,“太大了”之類的表述難免顯得太籠統和太抽象,那麼我們不妨從如下幾個方面來著手進行分析:
- 從專案結構著手,程式碼組織是否合理,依賴使用是否合理;
- 從webpack自身提供的優化手段著手,看看哪些api未做優化配置;
- 從webpack自身的不足著手,做有針對性的擴充套件優化,進一步提升效率;
在這裡我們推薦使用一個wepback的視覺化資源分析工具:webpack-visualizer,在webpack構建的時候會自動幫你計算出各個模組在你的專案工程中的依賴與分佈情況,方便做更精確的資源依賴和引用的分析。
從上圖中我們不難發現大多數的工程專案中,依賴庫的體積永遠是大頭,通常體積可以佔據整個工程專案的7-9成,而且在每次開發過程中也會重新讀取和編譯對應的依賴資源,這其實是很大的的資源開銷浪費,而且對編譯結果影響微乎其微,畢竟在實際業務開發中,我們很少會去主動修改第三方庫中的原始碼,改進方案如下:
方案一、合理配置CommonsChunkPlugin
webpack的資源入口通常是以entry為單元進行編譯提取,那麼當多entry共存的時候,CommonsChunkPlugin的作用就會發揮出來,對所有依賴的chunk進行公共部分的提取,但是在這裡可能很多人會誤認為抽取公共部分指的是能抽取某個程式碼片段,其實並非如此,它是以module為單位進行提取。
假設我們的頁面中存在entry1,entry2,entry3三個入口,這些入口中可能都會引用如utils,loadash,fetch等這些通用模組,那麼就可以考慮對這部分的共用部分機提取。通常提取方式有如下四種實現:
1、傳入字串引數,由chunkplugin自動計算提取
1 |
new webpack.optimize.CommonsChunkPlugin('common.js') |
這種做法預設會把所有入口節點的公共程式碼提取出來, 生成一個common.js
2、有選擇的提取公共程式碼
1 |
new webpack.optimize.CommonsChunkPlugin('common.js',['entry1','entry2']); |
只提取entry1節點和entry2中的共用部分模組, 生成一個common.js
3、將entry下所有的模組的公共部分(可指定引用次數)提取到一個通用的chunk中
1 2 3 4 5 6 7 8 9 10 11 12 |
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中的最小引用數;
4、抽取enry中的一些lib抽取到vendors中
1 2 3 4 5 6 7 8 |
entry = { vendors: ['fetch', 'loadash'] }; new webpack.optimize.CommonsChunkPlugin({ name: "vendors", minChunks: Infinity }); |
新增一個entry名叫為vendors,並把vendors設定為所需要的資源庫,CommonsChunk會自動提取指定庫至vendors中。
方案二、通過externals配置來提取常用庫
在實際專案開發過程中,我們並不需要實時除錯各種庫的原始碼,這時候就可以考慮使用external選項了。
簡單來說external就是把我們的依賴資源宣告為一個外部依賴,然後通過script外鏈指令碼引入。這也是我們早期頁面開發中資源引入的一種翻版,只是通過配置後可以告知webapck遇到此類變數名時就可以不用解析和編譯至模組的內部檔案中,而改用從外部變數中讀取,這樣能極大的提升編譯速度,同時也能更好的利用CDN來實現快取。
external的配置相對比較簡單,只需要完成如下三步:
1、在頁面中加入需要引入的lib地址,如下:
1 2 3 4 5 6 7 8 |
<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> |
2、在webapck.config.js中加入external配置項:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
module.export = { externals: { 'react-router': { amd: 'react-router', root: 'ReactRouter', commonjs: 'react-router', commonjs2: 'react-router' }, jquery: { amd: 'jquery', root: 'jQuery', commonjs: 'jquery', commonjs2: 'jquery' }, 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的頭部一般也能看到如下字樣:
1 2 3 4 5 6 7 8 9 |
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"]); } |
3、非常重要的是一定要在output選項中加入如下一句話:
1 2 3 |
output: { libraryTarget: 'umd' } |
由於通過external提取過的js模組是不會被記錄到webapck的chunk資訊中,通過libraryTarget可告知我們構建出來的業務模組,當讀到了externals中的key時,需要以umd的方式去獲取資源名,否則會有出現找不到module的情況。
通過配置後,我們可以看到對應的資源資訊已經可以在瀏覽器的source map中讀到了。
對應的資源也可以直接由頁面外鏈載入,有效地減小了資源包的體積。
方案三、利用DllPlugin和DllReferencePlugin預編譯資源模組
我們的專案依賴中通常會引用大量的npm包,而這些包在正常的開發過程中並不會進行修改,但是在每一次構建過程中卻需要反覆的將其解析,如何來規避此類損耗呢?這兩個外掛就是幹這個用的。
簡單來說DllPlugin的作用是預先編譯一些模組,而DllReferencePlugin則是把這些預先編譯好的模組引用起來。這邊需要注意的是DllPlugin必須要在DllReferencePlugin執行前先執行一次,dll這個概念應該也是借鑑了windows程式開發中的dll檔案的設計理念。
相對於externals,dllPlugin有如下幾點優勢:
- dll預編譯出來的模組可以作為靜態資源連結庫可被重複使用,尤其適合多個專案之間的資源共享,如同一個站點pc和手機版等;
- dll資源能有效地解決資源迴圈依賴的問題,部分依賴庫如:react-addons-css-transition-group這種原先從react核心庫中抽取的資源包,整個程式碼只有一句話:
1 |
module.exports = require('react/lib/ReactCSSTransitionGroup'); |
卻因為重新指向了react/lib中,這也會導致在通過externals引入的資源只能識別react,定址解析react/lib則會出現無法被正確索引的情況。
- 由於externals的配置項需要對每個依賴庫進行逐個定製,所以每次增加一個元件都需要手動修改,略微繁瑣,而通過dllPlugin則能完全通過配置讀取,減少維護的成本;
1、配置dllPlugin對應資源表並編譯檔案
那麼externals該如何使用呢,其實只需要增加一個配置檔案:webpack.dll.config.js:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 |
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 }; |
然後執行命令:
1 2 |
$ NODE_ENV=development webpack --config webpack.dll.lib.js --progress $ NODE_ENV=production webpack --config webpack.dll.lib.js --progress |
即可分別編譯出支援除錯版和生產環境中lib靜態資源庫,在構建出來的檔案中我們也可以看到會自動生成如下資源:
1 2 3 4 5 6 7 8 9 |
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資訊,在實際開發過程中就無需要進行重複編譯;
2、dllPlugin的靜態資源引入
- lib.js和manifest.json存在一一對應的關係,所以我們在呼叫的過程也許遵循這個原則,如當前處於開發階段,對應我們可以引入common/debug資料夾下的lib.js和manifest.json,切換到生產環境的時候則需要引入common/dist下的資源進行對應操作,這裡考慮到手動切換和維護的成本,我們推薦使用add-asset-html-webpack-plugin進行依賴資源的注入,可得到如下結果:
1 2 3 |
<head> <script src="/static/common/lib.js"></script> </head> |
- 在webpack.config.js檔案中增加如下程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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同時引入使用,如下:
1 2 3 4 5 6 7 8 9 10 11 12 |
module.export = { plugins: [ new webpack.DllReferencePlugin({ context: __dirname, manifest: require(libPath), }), new webpack.DllReferencePlugin({ context: __dirname, manifest: require(ChartsPath), }) ] } |
方案四 使用Happypack加速你的程式碼構建
以上介紹均為針對webpack中的chunk計算和編譯內容的優化與改進,對資源的實際體積改進上也較為明顯,那麼除此之外,我們能否針對資源的編譯過程和速度優化上做些嘗試呢?
眾所周知,webpack中為了方便各種資源和型別的載入,設計了以loader載入器的形式讀取資源,但是受限於node的程式設計模型影響,所有的loader雖然以async的形式來併發呼叫,但是還是執行在單個 node的程式以及在同一個事件迴圈中,這就直接導致了當我們需要同時讀取多個loader檔案資源時,比如babel-loader需要transform各種jsx,es6的資原始檔。在這種同步計算同時需要大量耗費cpu運算的過程中,node的單程式模型就無優勢了,那麼happypack就針對解決此類問題而生。
開啟happypack的執行緒池
happypack的處理思路是將原有的webpack對loader的執行過程從單一程式的形式擴充套件多程式模式,原本的流程保持不變,這樣可以在不修改原有配置的基礎上來完成對編譯過程的優化,具體配置如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
var HappyPack = require('happypack'); var happyThreadPool = HappyPack.ThreadPool({ size: os.cpus().length }); module: { loaders: [ { test: /.js[x]?$/, // loader: 'babel-loader?presets[]=es2015&presets[]=react' loader: 'happypack/loader?id=happybabel' } ] }, plugins: [ new HappyPack({ id: 'happybabel', loaders: ['babel-loader'], threadPool: happyThreadPool, cache: true, verbose: true }) ] |
我們可以看到通過在loader中配置直接指向happypack提供的loader,對於檔案實際匹配的處理 loader,則是通過配置在plugin屬性來傳遞說明,這裡happypack提供的loader與plugin的銜接匹配,則是通過id=happybabel來完成。配置完成後,laoder的工作模式就轉變成了如下所示:
happypack在編譯過程中除了利用多程式的模式加速編譯,還同時開啟了cache計算,能充分利用快取讀取構建檔案,對構建的速度提升也是非常明顯的,經過測試,最終的構建速度提升如下:
優化前:
優化後:
關於happyoack的更多介紹可以檢視:
方案五 增強uglifyPlugin
uglifyJS憑藉基於node開發,壓縮比例高,使用方便等諸多優點已經成為了js壓縮工具中的首選,但是我們在webpack的構建中觀察發現,當webpack build進度走到80%前後時,會發生很長一段時間的停滯,經測試對比發現這一過程正是uglfiyJS在對我們的output中的bunlde部分進行壓縮耗時過長導致,針對這塊我們推薦使用webpack-uglify-parallel來提升壓縮速度。
從外掛原始碼中可以看到,webpack-uglify-parallel的是實現原理是採用了多核並行壓縮的方式來提升我們的壓縮速度。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
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配置:
1 2 3 4 5 6 |
new webpack.optimize.UglifyJsPlugin({ exclude:/.min.js$/ mangle:true, compress: { warnings: false }, output: { comments: false } }) |
修改成如下程式碼即可:
1 2 3 4 5 6 7 8 9 10 11 12 |
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 } }) |
適用場景
在實際的開發過程中,可靈活地選擇適合自身業務場景的優化手段。
優化手段 | 開發環境 | 生產環境 |
---|---|---|
CommonsChunkPlugin | √ | √ |
externals | √ | |
DllPlugin | √ | √ |
Happypack | √ | √ |
uglify-parallel | √ |
優化實踐
小結
效能優化無小事,追求快沒有止境,在前端工程日益龐大複雜的今天,針對實際專案,持續改進構建工具的效能,對專案開發效率的提升和工具深度理解都是極其有益的。