Webpack打包實用優化方案 Webpack3.0 的 Scope Hoisting (作用域提升) CommonsChunkPlugin 的使用 DllPlugin 和 DllReferencePlugin 總結 擴展閱讀:

作者:汪楠

簡介: Webpack是當下最熱門的前端資源模塊化管理和打包工具。如何提升打包效率,成爲了大家關注的點之一。下文將分享我們開發到上線的實用優化方案。

目前最火的打包工具莫過於 Webpack 了,關於 Webpack 的優化方案,網上有很多文章可以供大家參考。查閱前人的資料,總結自己在項目中遇到的問題,最終得出一些比較實用的優化方案,本文將與大家一一分享。既然是打包優化,那麼我們需要時刻關注以下幾點:

  • 減少編譯時間
  • 減少編譯輸出文件大小
  • 提高頁面性能

Webpack3.0 最大的一個新功能就是 ScopeHoisting (作用域提升)。曾經的 Webpack 是將每一個模塊(每一個被 import 或者 require 的代碼) bundle 到每一個獨立的閉包函數裏。這會導致 bundle 的文件裏,每一個模塊外層都會有一些特殊的閉包包裝,導致文件增大,同時它也使得編譯後的 js 文件在瀏覽器中的執行效率降低。

雖然,理論知識已經瞭解,但我們還是用實際操作來驗證一下:

開啓 ScopeHoisting 非常簡單,因爲 webpack 已經內置了這個功能,在插件入口處新增 new webpack.optimize.ModuleConcatenationPlugin()。

plugins:[
new webpack.optimize.ModuleConcatenationPlugin()
]
我們簡單的編寫兩個文件 app.js 和 timer.js。

import timer from './timer'
var time = (new Date()).getTime();
timer();
console.log('hello webpack'+time);
export default function bar(){
console.log('pig');
}
var path = require('path');
var webpack = require('webpack');
var config = {
entry:{
app:'./src/app.js'
},
output:{
filename:'bundle.js',
path: path.resolve(__dirname,'./build')
},
plugins:[
//new webpack.optimize.ModuleConcatenationPlugin()
]
}
module.exports = config;

執行編譯後,結果如下: bundle.js 大小是3.03KB

我們把 new webpack.optimize.ModuleConcatenationPlugin() 打開,編譯結果如下:

對比兩個的區別大家可以發現,在 bundle.js Webpack2.0 Webpack3.0 多了以下的代碼:

(function(module, __webpack_exports__, __webpack_require__) {
...
});

timer.js app.js 都在一個函數中了, timer.js 並沒有編譯在閉包函數中。一個模塊就少了一個閉包函數,那麼多引用幾個,就可以少很多了。從體積上的確可以看出明顯減少。除了這一效果之外,這個內置優化能使得編譯後的代碼在瀏覽器中執行效率顯著提高。在 Aaron Hardy 的 Optimizing Javascript Through Scope Hoisting[1]的文章中表明,在一個真實的 Tubine[2] 的項目中,作者對比了使用 ScopeHoisting 和不使用的打包大小和js在瀏覽器執行效率。 Turbine 壓縮的gzip文件大小減少了約41%,提高了初始化執行時間約12%。 看到這裏,是不是很心動,如果能用在我們項目中,那很完美了。可惜現實比較殘酷,下面我們看看它的侷限性。 我將 demo 例子的引用模塊改成 CommonJs 的語法,執行效果如下:

//import bar from './timer'
var bar = require('./timer');
var time = (new Date()).getTime();
bar();
console.log('hello webpack'+time);
// export default function bar(){
// console.log('pig');
// }
exports.bar = function(){
console.log('big');
}

你會發現用 CommonJS 的模塊語法在開啓 ScopeHoisting 時候,編譯打包的 bundle 並沒有發生改變。因爲目前 webpack3.0 只支持 ESModule 的模塊語法。聯想一下,你在自己的腳手架中,大多的NPM依賴包都還是 CommonJS 的語法, Webpack 會回退到原打包模式。你在做升級處理的時候,可以使用 --display-optimization-bailout 查看被降級原因。

除了目前支持 ESModlue 的語法外,也許你的老代碼中還有以下幾點:

  • 使用了ProvidePlugin[3]
  • 使用了eva()函數
  • 項目有多個entry

就目前前端生態環境來看, Webpack3.0 ScopeHoisting 的新特性暫時無法使用,不過 ESModule 是趨勢,未來模塊的引用的方式肯定被ES Module所取代。

Webpack3.0 ScopeHoisting 在實際項目中用不了,那我們來看看 CommonsChunkPlugin 這個插件的使用。 CommonChunkPlugin 插件是一個可選的用於建立一個獨立文件(又稱作 chunk )的功能,這個文件包含多個入口 Chunk 的公共模塊( CommonsChunkPlugin 已經從 webpack v4 legato 中移除了,想要了解最新版本中如何處理 chunk ,可以查看 SplitChunksPlugin [4]。 CommonsChunkPlugin 優化的思路就是通過將公共模塊拆出來,最終合成的文件能在最開始的是加載一次,便於後續訪問其餘頁面,直接使用瀏覽器緩存中的公共代碼,這樣無疑體驗會更好。 理論知識知道了,那麼我們就動手來試試,是不是效果不錯。我們搭一個基於 Webpack2.7.0 的簡單的 Vue 腳手架:

const path = require('path');
const webpack = require('webpack');
const configw = require('./package.json');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const { VueLoaderPlugin } = require('vue-loader')
var config = {
entry:{
app:'./src/app.js'
},
output:{
path: path.resolve(__dirname, 'build'),
publicPath: configw.publicPath + '/',
filename: 'js/[name].[chunkhash].js'
},
plugins:[
new CleanWebpackPlugin('build'),
new HtmlWebpackPlugin({
template: './src/index.html'
}),
new ExtractTextPlugin({
filename: 'css/app.css'
}),
+ new webpack.optimize.CommonsChunkPlugin({
+ name:'vender',
+ minChunks: function(module) {
+ return (
+ module.resource &&
+ /\.js$/.test(module.resource) &&
+ module.resource.indexOf(
+ path.join(__dirname, './node_modules')
+ ) === 0
+ )
+ }
+
+ }),
new VueLoaderPlugin(),
],
module:{
rules: [
{
test: /\.css$/,
use: ExtractTextPlugin.extract({
fallback: "style-loader",
use: ['css-loader']
}),
},
{
test: /\.scss$/,
use: ExtractTextPlugin.extract({
fallback: 'style-loader',
use: ['css-loader', 'sass-loader']
})
},
{
test: /\.vue$/,
loader: 'vue-loader',
options: {
}
},
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/,
query: {
presets: ['env']
}
}
]
}
}
module.exports = config;

爲了方便查看,並沒有引入壓縮插件什麼的做優化。目前來看,在業務js中的依賴於 node_modules 中的 vue vue-router axios 等第三方公共庫都被抽離出 app.js 打包在 vender.js 中。爲了能做到業務js和第三方庫js 相分離,做到瀏覽器端緩存不會頻繁更新的js邁出了第一步。可惜的是,如果我們改動業務代碼比如, app.js app.vue index.vue 等業務代碼,你會發現除了 app.js 的哈希值發生了變化,連沒有做更改的 vender.js 哈希值都變了。

哈希值變了,就說明文件的內容變了。這一定會讓你很奔潰,你想做到的 Webpack 構建持久化緩存 js 的功能基本是不可能的。找一下原因吧,沒有更改依賴文件,爲什麼只改動業務 js, vender.js 也會變呢?因爲每次構建時候, Webpack 會生成 webpack runtime 代碼,用來幫助 Webpack 完成其工作,比如在模塊交互時,鏈接模塊所需的加載和解析邏輯。如下圖所示,在我們的腳手架中,編譯後的結果對比,就只有一個運行時產生的哈希值的值不一樣。

既然差別這麼小,那麼我們把 runtime 這部分代碼抽離出來。這樣就能持久化緩存 vender.js 了。我們試一下。

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

修改了以下 app.vue 中的代碼,前後的編譯結果如下:

vender.js 的哈希值如預期一樣,沒有發生變化。因爲文件內變化的代碼已經被抽離出到 manifest 這個文件中了。 manifest 裏面存儲了 chunks 映射關係,有了 chunks 的映射,我們才知道要加載的 chunk 的真實地址。那麼每次修改業務 js,都不需要部署 vender.js 了。這樣就達到了第三依賴庫實現了用戶端和服務端持久緩存。每次上線更新也就部署較小的 app.js manifest.js 。不過需要注意的是 manifest 必須先加載。 那麼直接在生產環境使用這種部署策略,不,還不行。我們的目標只有一個實現第三方庫的在持久化緩存,但不能給我們的上線帶來風險。要保證你不上線第三方庫,用戶直接訪問本地瀏覽器緩存中的第三方庫,即使上線更新業務 js 依然可以正常運行。 CommonsChunkPlugin 這種打包方式是在運行時編譯出的代碼,如果我們在業務代碼裏新增或者刪除依賴,試想一下,你項目採用此方式上線後,你對項目也許做優化或者功能模塊增加,業務 js 中的對模塊依賴部分的代碼難免有變動。我們來對比一下保留 index.vue 中的依賴和刪除依賴的結果:

很明顯我修改了業務代碼中的模塊依賴,導致了 vender.js 庫也發生了變化,這是沒法避免的。因爲 vender.js app.js 是緊密耦合在一塊的,你雖然把 runtime 的代碼抽離到 manifest 中。對引入模塊的刪除和新增會導致在運行時編譯的模塊的id依賴發生變化。我對比了兩個 vender.js 的區別,如下圖所示,主要是引用的模塊 id 發生了變化。

看到這裏, CommonsChunkPlugin 雖然能解決問題,但是上線風險無法避免。這樣做是不值得,爲了利用瀏覽器緩存,從而只上線 app.js 文件。很難保證上線無問題。最主要問題是,我們在 build 完提交測試還是把 vender.js 一併提交的。當然有人會說你看哈希值不就知道需不需要上線 verder.js 了。可是我上面提到的場景在業務代碼中的修改還是很常見的,不值得再上線一次 vender.js 。 最後的解決辦法是採用DllPlugin,請看下文。

這兩個 Webpack 打包插件, Dllplugin 會打包出一個dll文件和一個 manifest.json 模塊引用的映射文件。dll文件放什麼呢,是我們的第三方庫依賴。這樣就好比是 Windows 的動態鏈接庫。 Dllplugin 的使用思路是將我們項目中的公共的第三方庫打包到一個dll文件中。它是靜態的,除非你手動修改在項目中需要引入的庫。同時也會編譯出一個 manifest.json 的映射文件。它也是靜態的,裏面存儲了通過id值映射值找到在dll文件中對應的庫 js。 DllReferencePlugin 則是將映射值打包進我們的業務 js 了。這樣就可以完完全全的提前抽離了第三方依賴庫。之後,只會打包編譯業務部分的代碼,再也不用去重複構建第三方庫 js。構建編譯的時間會大大減少。

我們還是通過實踐的方式來證明吧:

首先我們配置一份生成dll的 config

const path = require("path");
const webpack = require("webpack");
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
const config = require('./package.json');
const curDate = new Date();
const curTime = curDate.getFullYear() + '/' + (curDate.getMonth() + 1) + '/' + curDate.getDate() + ' ' + curDate.getHours() + ':' + curDate.getMinutes() + ':' + curDate.getSeconds()
const bannerTxt = config.name + ' ' + config.version + ' ' + curTime;
module.exports = {
//你想要打包的模塊數組
entry:{
vendor:['vue','axios','vue-router','qs']
},
output:{
path:path.join(__dirname,'/static/'),
filename:'[name].dll.js',
library:'[name]_library'
//vendor.dll.js 中暴露出的全局變量
//主要是給DllPlugin中的name 使用
//故這裏需要和webpack.DllPlugin 中的 'name :[name]_libray 保持一致
},
plugins:[
+ new webpack.DllPlugin({
+ path:path.join(__dirname,'.','[name]-manifest.json'),
+ name:'[name]_library',
+ context:__dirname
+ }),
new UglifyJsPlugin({
cache:true,
sourceMap:false,
parallel:4,
uglifyOptions: {
ecma:8,
warnings:false,
compress:{
drop_console:true,
},
output:{
comments:false,
beautify:false,
}
}
}),
new webpack.BannerPlugin(bannerTxt)
]
}

entry 配置了常見的 Vue 全家桶系列。因爲幾乎每個頁面都需要用到它們,把它們提到公共的 vender.js 中是再好不過的事了。我們看一下運行結果。我配置了 npm script 執行代碼 npm run dll:

"scripts": {
"dev": "webpack-dev-server -d --open --progress",
"build": "cross-env NODE_ENV=production webpack --hide-modules --progress",
"upload": "cross-env NODE_ENV=upload webpack --hide-modules --progress",
"dll": "webpack --config ./webpack.dll.config.js"
}

壓縮過的 dll.js 的大小還是可以接受的。我們看看生成的 manifest.json 裏面都存儲了什麼。

跟預期的一樣,裏面存儲了引用映射路徑和對應的id值。 dll.js manifest.json 只需要編譯一次。之後我們開發業務代碼和上線打包都不需要再次編譯打包 vender.dll.js 了。我們看一下 webpack.config.js 中如何配置的。

const webpack = require('webpack');
const config = require('./package.json');
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
const autoprefixer = require('autoprefixer');
const htmlwebpackincludeassetsplugin = require('html-webpack-include-assets-plugin');
const AddAssetHtmlPlugin = require('add-asset-html-webpack-plugin');
const webpackConfig = module.exports = {};
const isProduction = process.env.NODE_ENV === 'production';
const isUpload = process.env.NODE_ENV === 'upload';
const curDate = new Date();
const curTime = curDate.getFullYear() + '/' + (curDate.getMonth() + 1) + '/' + curDate.getDate() + ' ' + curDate.getHours() + ':' + curDate.getMinutes() + ':' + curDate.getSeconds();
const bannerTxt = config.name + ' ' + config.version + ' ' + curTime; //構建出的文件頂部banner(註釋)內容
webpackConfig.entry = {
app: './src/app.js',
};
webpackConfig.output = {
path: path.resolve(__dirname, 'build' + '/' + config.version),
publicPath: config.publicPath + '/'+config.version+'/',
filename: 'js/[name].js'
};
webpackConfig.module = {
rules: [{
test: /\.css$/,
use: ExtractTextPlugin.extract({
fallback: "style-loader",
use: ['css-loader', 'postcss-loader']
}),
}, {
test: /\.scss$/,
use: ExtractTextPlugin.extract({
fallback: 'style-loader',
use: ['css-loader', 'sass-loader', 'postcss-loader']
})
}, {
test: /\.vue$/,
loader: 'vue-loader',
options: {
extractCSS: true,
postcss: [require('autoprefixer')()]
}
}, {
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/,
}, {
test: /\.(png|jpg|gif|webp)$/,
loader: 'url-loader',
options: {
limit: 3000,
name: 'img/[name].[ext]',
}
}, ]
};
webpackConfig.plugins = [
new webpack.optimize.ModuleConcatenationPlugin(),
new CleanWebpackPlugin('build'),
new HtmlWebpackPlugin({
template: './src/index.html'
}),
new ExtractTextPlugin({
filename: 'css/app.css'
}),
new CopyWebpackPlugin([
{ from: path.join(__dirname, "./static/"), to: path.join(__dirname, "./build/lib") }
]),
+ new webpack.DllReferencePlugin({
+ context:__dirname,
+ manifest:require('./vendor-manifest.json')
+ })
];
if (isProduction || isUpload) {
webpackConfig.plugins = (webpackConfig.plugins || []).concat([
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: '"production"'
}
}),
new webpack.LoaderOptionsPlugin({
minimize: true
}),
new UglifyJsPlugin({
cache:true,
sourceMap:false,
parallel:4,
uglifyOptions: {
ecma:8,
warnings:false,
compress:{
drop_console:true,
},
output:{
comments:false,
beautify:false,
}
}
}),
new htmlwebpackincludeassetsplugin({
assets:['/lib/vendor.dll.js'],
publicPath:config.publicPath,
append:false
}),
new webpack.BannerPlugin(bannerTxt)
]);
} else {
webpackConfig.output.publicPath = '/';
webpackConfig.devtool = '#cheap-module-eval-source-map';
webpackConfig.plugins = (webpackConfig.plugins || []).concat([
new AddAssetHtmlPlugin({
filepath:require.resolve('./static/vendor.dll.js'),
includeSourcemap:false,
})
]);
webpackConfig.devServer = {
contentBase: path.resolve(__dirname, 'build'),
compress: true, //gzip壓縮
historyApiFallback: true,
};
}

我們用 DllReferencePlugin 把生成好的 manifest.json 映射文件引入到正式的業務代碼打包中。

app.js 只有7.45KB, vender.dll.js 被拷貝到 build 目錄下 lib 文件夾下。

所有業務的代碼則都在版本控制文件夾下, vender.dll.js 放置在 lib 文件夾下。每次上線如果有版本變化只要上線業務js就行。不需要上線 lib 文件夾。只要你不手動修改 webpack.dll.config.js 的entry

entry:{
vendor:['vue','axios','vue-router','qs']
}

就永遠不會發生變化。 還是對比一樣優化之前和優化之後的:

優化之前 app.js 由於有打包了第三方庫所以有116KB,優化之後,抽離第三庫 app.js 只有7.45KB。只是項目開始時候,需要提前打包一份dll文件。以後每次編譯時間都比之前少了將近一半。這個還只是一個腳手架demo。等到用在實際項目中的構建,效果更加明顯。

有很多人不建議使用 DllPlugin ,覺得沒必要把所有公共的打包在一起,放在首屏就加載,這樣使得首屏加載時間過長之類的,還有覺得多了一份 config 增加了工作量。不過我個人覺得,對於像 React Vue 這種全家桶系列的,整體性偏強的技術棧。抽離出全家桶放置在 vender.js 中還是很有必要的。因爲幾乎每個頁面都會用到。而且,他們是完全跟業務邏輯無關的第三方庫。對它們實現持久化緩存,對於開發者和用戶的體驗都會大大提升。一點腳手架搭建的心得,感謝各位的瀏覽,有任何問題歡迎討論哈~

[1]optimizing Javascript Through Scope Hoisting:https://medium.com/launch-by-adobe/optimizing-javascript-through-scope-hoisting-47c132ef27e4

[2]Tubine:https://github.com/Adobe-Marketing-Cloud/reactor-turbine

[3]ProvidePlugin:https://webpack.js.org/plugins/provide-plugin/

[4]SplitChunksPlugin:https://webpack.js.org/plugins/split-chunks-plugin

文章轉自公衆號“全棧探索”,歡迎關注:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章