作者:汪楠
簡介: 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
文章轉自公衆號“全棧探索”,歡迎關注: