引言
webpack
的打包優化一直是個老生常談的話題,常規的無非就分塊、拆包、壓縮等。
本文以我自己的經驗向大家分享如何通過一些分析工具、外掛以及webpack
新版本中的一些新特性來顯著提升webpack
的打包速度和改善包體積,學會分析打包的瓶頸以及問題所在。
本文演示程式碼,倉庫地址
速度分析 ?
webpack 有時候打包很慢,而我們在專案中可能用了很多的 plugin
和 loader
,想知道到底是哪個環節慢,下面這個外掛可以計算 plugin
和 loader
的耗時。
yarn add -D speed-measure-webpack-plugin
配置也很簡單,把 webpack
配置物件包裹起來即可:
const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");
const smp = new SpeedMeasurePlugin();
const webpackConfig = smp.wrap({
plugins: [
new MyPlugin(),
new MyOtherPlugin()
]
});
來看下在專案中引入speed-measure-webpack-plugin
後的打包情況:
從上圖可以看出這個外掛主要做了兩件事情:
- 計算整個打包總耗時
- 分析每個外掛和 loader 的耗時情況
知道了具體loader
和plugin
的耗時情況,我們就可以“對症下藥”了
體積分析 ?
打包後的體積優化是一個可以著重優化的點,比如引入的一些第三方元件庫過大,這時就要考慮是否需要尋找替代品了。
這裡採用的是webpack-bundle-analyzer
,也是我平時工作中用的最多的一款外掛了。
它可以用互動式可縮放樹形圖顯示webpack
輸出檔案的大小。用起來非常的方便。
首先安裝外掛:
yarn add -D webpack-bundle-analyzer
安裝完在webpack.config.js
中簡單的配置一下:
const BundleAnalyzerPlugin = require("webpack-bundle-analyzer").BundleAnalyzerPlugin;
module.exports = {
plugins: [
new BundleAnalyzerPlugin({
// 可以是`server`,`static`或`disabled`。
// 在`server`模式下,分析器將啟動HTTP伺服器來顯示軟體包報告。
// 在“靜態”模式下,會生成帶有報告的單個HTML檔案。
// 在`disabled`模式下,你可以使用這個外掛來將`generateStatsFile`設定為`true`來生成Webpack Stats JSON檔案。
analyzerMode: "server",
// 將在“伺服器”模式下使用的主機啟動HTTP伺服器。
analyzerHost: "127.0.0.1",
// 將在“伺服器”模式下使用的埠啟動HTTP伺服器。
analyzerPort: 8866,
// 路徑捆綁,將在`static`模式下生成的報告檔案。
// 相對於捆綁輸出目錄。
reportFilename: "report.html",
// 模組大小預設顯示在報告中。
// 應該是`stat`,`parsed`或者`gzip`中的一個。
// 有關更多資訊,請參見“定義”一節。
defaultSizes: "parsed",
// 在預設瀏覽器中自動開啟報告
openAnalyzer: true,
// 如果為true,則Webpack Stats JSON檔案將在bundle輸出目錄中生成
generateStatsFile: false,
// 如果`generateStatsFile`為`true`,將會生成Webpack Stats JSON檔案的名字。
// 相對於捆綁輸出目錄。
statsFilename: "stats.json",
// stats.toJson()方法的選項。
// 例如,您可以使用`source:false`選項排除統計檔案中模組的來源。
// 在這裡檢視更多選項:https: //github.com/webpack/webpack/blob/webpack-1/lib/Stats.js#L21
statsOptions: null,
logLevel: "info"
)
]
}
然後在命令列工具中輸入npm run dev
,它預設會起一個埠號為 8888 的本地伺服器:
圖中的每一塊清晰的展示了元件、第三方庫的程式碼體積。
有了它,我們就可以針對體積偏大的模組進行相關優化了。
多程式/多例項構建 ?
大家都知道 webpack
是執行在 node
環境中,而 node
是單執行緒的。webpack
的打包過程是 io
密集和計算密集型的操作,如果能同時 fork
多個程式並行處理各個任務,將會有效的縮短構建時間。
平時用的比較多的兩個是thread-loader
和HappyPack
。
先來看下thread-loader
吧,這個也是webpack4
官方所推薦的。
thread-loader
安裝
yarn add -D thread-loader
thread-loader
會將你的 loader
放置在一個 worker
池裡面執行,以達到多執行緒構建。
把這個loader
放置在其他loader
之前(如下面示例的位置), 放置在這個loader
之後的loader
就會在一個單獨的worker
池(worker pool
)中執行。
示例
module.exports = {
module: {
rules: [
{
test: /\.js$/,
include: path.resolve("src"),
use: [
"thread-loader",
// your expensive loader (e.g babel-loader)
]
}
]
}
}
HappyPack
安裝
yarn add -D happypack
HappyPack
可以讓 Webpack
同一時間處理多個任務,發揮多核 CPU
的能力,將任務分解給多個子程式去併發的執行,子程式處理完後,再把結果傳送給主程式。通過多程式模型,來加速程式碼構建。
示例
// webpack.config.js
const HappyPack = require('happypack');
exports.module = {
rules: [
{
test: /.js$/,
// 1) replace your original list of loaders with "happypack/loader":
// loaders: [ 'babel-loader?presets[]=es2015' ],
use: 'happypack/loader',
include: [ /* ... */ ],
exclude: [ /* ... */ ]
}
]
};
exports.plugins = [
// 2) create the plugin:
new HappyPack({
// 3) re-add the loaders you replaced above in #1:
loaders: [ 'babel-loader?presets[]=es2015' ]
})
];
這裡有一點需要說明的是,HappyPack
的作者表示已不再維護此專案,這個可以在github
倉庫看到:
作者也是推薦使用webpack
官方提供的thread-loader
。
thread-loader
和happypack
對於小型專案來說打包速度幾乎沒有影響,甚至可能會增加開銷,所以建議儘量在大專案中採用。
多程式並行壓縮程式碼 ?
通常我們在開發環境,程式碼構建時間比較快,而構建用於釋出到線上的程式碼時會新增壓縮程式碼這一流程,則會導致計算量大耗時多。
webpack
預設提供了UglifyJS
外掛來壓縮JS
程式碼,但是它使用的是單執行緒壓縮程式碼,也就是說多個js
檔案需要被壓縮,它需要一個個檔案進行壓縮。所以說在正式環境打包壓縮程式碼速度非常慢(因為壓縮JS
程式碼需要先把程式碼解析成用Object
抽象表示的AST
語法樹,再應用各種規則分析和處理AST
,導致這個過程耗時非常大)。
所以我們要對壓縮程式碼這一步驟進行優化,常用的做法就是多程式並行壓縮。
目前有三種主流的壓縮方案:
- parallel-uglify-plugin
- uglifyjs-webpack-plugin
- terser-webpack-plugin
parallel-uglify-plugin
上面介紹的HappyPack
的思想是使用多個子程式去解析和編譯JS
,CSS
等,這樣就可以並行處理多個子任務,多個子任務完成後,再將結果發到主程式中,有了這個思想後,ParallelUglifyPlugin
外掛就產生了。
當webpack
有多個JS
檔案需要輸出和壓縮時,原來會使用UglifyJS
去一個個壓縮並且輸出,而ParallelUglifyPlugin
外掛則會開啟多個子程式,把對多個檔案壓縮的工作分給多個子程式去完成,但是每個子程式還是通過UglifyJS
去壓縮程式碼。並行壓縮可以顯著的提升效率。
安裝
yarn add -D webpack-parallel-uglify-plugin
示例
import ParallelUglifyPlugin from 'webpack-parallel-uglify-plugin';
module.exports = {
plugins: [
new ParallelUglifyPlugin({
// Optional regex, or array of regex to match file against. Only matching files get minified.
// Defaults to /.js$/, any file ending in .js.
test,
include, // Optional regex, or array of regex to include in minification. Only matching files get minified.
exclude, // Optional regex, or array of regex to exclude from minification. Matching files are not minified.
cacheDir, // Optional absolute path to use as a cache. If not provided, caching will not be used.
workerCount, // Optional int. Number of workers to run uglify. Defaults to num of cpus - 1 or asset count (whichever is smaller)
sourceMap, // Optional Boolean. This slows down the compilation. Defaults to false.
uglifyJS: {
// These pass straight through to uglify-js@3.
// Cannot be used with uglifyES.
// Defaults to {} if not neither uglifyJS or uglifyES are provided.
// You should use this option if you need to ensure es5 support. uglify-js will produce an error message
// if it comes across any es6 code that it can't parse.
},
uglifyES: {
// These pass straight through to uglify-es.
// Cannot be used with uglifyJS.
// uglify-es is a version of uglify that understands newer es6 syntax. You should use this option if the
// files that you're minifying do not need to run in older browsers/versions of node.
}
}),
],
};
webpack-parallel-uglify-plugin
已不再維護,這裡不推薦使用
uglifyjs-webpack-plugin
安裝
yarn add -D uglifyjs-webpack-plugin
示例
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
module.exports = {
plugins: [
new UglifyJsPlugin({
uglifyOptions: {
warnings: false,
parse: {},
compress: {},
ie8: false
},
parallel: true
})
]
};
其實它和上面的parallel-uglify-plugin
類似,也可通過設定parallel: true
開啟多程式壓縮。
terser-webpack-plugin
不知道你有沒有發現:webpack4
已經預設支援 ES6
語法的壓縮。
而這離不開terser-webpack-plugin
。
安裝
yarn add -D terser-webpack-plugin
示例
const TerserPlugin = require('terser-webpack-plugin');
module.exports = {
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
parallel: 4,
}),
],
},
};
預編譯資源模組 ?
什麼是預編譯資源模組?
在使用webpack
進行打包時候,對於依賴的第三方庫,比如vue
,vuex
等這些不會修改的依賴,我們可以讓它和我們自己編寫的程式碼分開打包,這樣做的好處是每次更改我原生程式碼的檔案的時候,webpack
只需要打包我專案本身的檔案程式碼,而不會再去編譯第三方庫。
那麼第三方庫在第一次打包的時候只打包一次,以後只要我們不升級第三方包的時候,那麼webpack
就不會對這些庫去打包,這樣的可以快速的提高打包的速度。其實也就是預編譯資源模組
。
webpack
中,我們可以結合DllPlugin
和 DllReferencePlugin
外掛來實現。
DllPlugin
是什麼?
它能把第三方庫程式碼分離開,並且每次檔案更改的時候,它只會打包該專案自身的程式碼。所以打包速度會更快。
DLLPlugin
外掛是在一個額外獨立的webpack
設定中建立一個只有dll
的bundle
,也就是說我們在專案根目錄下除了有webpack.config.js
,還會新建一個webpack.dll.js
檔案。
webpack.dll.js
的作用是把所有的第三方庫依賴打包到一個bundle
的dll
檔案裡面,還會生成一個名為 manifest.json
檔案。該manifest.json
的作用是用來讓 DllReferencePlugin
對映到相關的依賴上去的。
DllReferencePlugin
又是什麼?
這個外掛是在webpack.config.js
中使用的,該外掛的作用是把剛剛在webpack.dll.js
中打包生成的dll
檔案引用到需要的預編譯的依賴上來。
什麼意思呢?就是說在webpack.dll.js
中打包後比如會生成 vendor.dll.js
檔案和vendor-manifest.json
檔案,vendor.dll.js
檔案包含了所有的第三方庫檔案,vendor-manifest.json
檔案會包含所有庫程式碼的一個索引,當在使用webpack.config.js
檔案打包DllReferencePlugin
外掛的時候,會使用該DllReferencePlugin
外掛讀取vendor-manifest.json
檔案,看看是否有該第三方庫。
vendor-manifest.json
檔案就是一個第三方庫的對映而已。
怎麼在專案中使用?
上面說了這麼多,主要是為了方便大家對於預編譯資源模組
和DllPlugin
和、DllReferencePlugin
外掛作用的理解(我第一次使用看了好久才明白~~)
先來看下完成的專案目錄結構:
主要在兩塊配置,分別是webpack.dll.js
和webpack.config.js
(對應這裡我是webpack.base.js
)
webpack.dll.js
const path = require('path');
const webpack = require('webpack');
module.exports = {
mode: 'production',
entry: {
vendors: ['lodash', 'jquery'],
react: ['react', 'react-dom']
},
output: {
filename: '[name].dll.js',
path: path.resolve(__dirname, './dll'),
library: '[name]'
},
plugins: [
new webpack.DllPlugin({
name: '[name]',
path: path.resolve(__dirname, './dll/[name].manifest.json')
})
]
}
這裡我拆了兩部分:vendors
(存放了lodash
、jquery
等)和react
(存放了 react 相關的庫,react
、react-dom
等)
webpack.config.js
(對應我這裡就是webpack.base.js
)
const path = require("path");
const fs = require('fs');
// ...
const AddAssetHtmlWebpackPlugin = require('add-asset-html-webpack-plugin');
const webpack = require('webpack');
const plugins = [
// ...
];
const files = fs.readdirSync(path.resolve(__dirname, './dll'));
files.forEach(file => {
if(/.*\.dll.js/.test(file)) {
plugins.push(new AddAssetHtmlWebpackPlugin({
filepath: path.resolve(__dirname, './dll', file)
}))
}
if(/.*\.manifest.json/.test(file)) {
plugins.push(new webpack.DllReferencePlugin({
manifest: path.resolve(__dirname, './dll', file)
}))
}
})
module.exports = {
entry: {
main: "./src/index.js"
},
module: {
rules: []
},
plugins,
output: {
// publicPath: "./",
path: path.resolve(__dirname, "dist")
}
}
這裡為了演示省略了很多程式碼,專案完整程式碼在這裡
由於上面我把第三方庫做了一個拆分,所以對應生成也就會是多個檔案,這裡讀取了一下檔案,做了一層遍歷。
最後在package.json
裡面再新增一條指令碼就可以了:
"scripts": {
"build:dll": "webpack --config ./webpack.dll.js",
},
執行yarn build:dll
就會生成本小節開頭貼的那張專案結構圖了~
利用快取提升二次構建速度 ?
一般來說,對於靜態資源,我們都希望瀏覽器能夠進行快取,那樣以後進入頁面就可以直接使用快取資源,頁面開啟速度會顯著加快,既提高了使用者的體驗也節省了寬頻資源。
當然瀏覽器快取方法有很多種,這裡只簡單討論下在webpack
中如何利用快取來提升二次構建速度。
在webpack
中利用快取一般有以下幾種思路:
babel-loader
開啟快取- 使用
cache-loader
- 使用
hard-source-webpack-plugin
babel-loader
babel-loader
在執行的時候,可能會產生一些執行期間重複的公共檔案,造成程式碼體積冗餘,同時也會減慢編譯效率。
可以加上cacheDirectory
引數開啟快取:
{
test: /\.js$/,
exclude: /node_modules/,
use: [{
loader: "babel-loader",
options: {
cacheDirectory: true
}
}],
},
cache-loader
在一些效能開銷較大的 loader
之前新增此 loader
,以將結果快取到磁碟裡。
安裝
yarn add -D cache-loader
使用
cache-loader
的配置很簡單,放在其他 loader
之前即可。修改Webpack
的配置如下:
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.ext$/,
use: [
'cache-loader',
...loaders
],
include: path.resolve('src')
}
]
}
}
請注意,儲存和讀取這些快取檔案會有一些時間開銷,所以請只對效能開銷較大的loader
使用此loader
。
hard-source-webpack-plugin
HardSourceWebpackPlugin
為模組提供了中間快取,快取預設的存放路徑是: node_modules/.cache/hard-source
。
配置 hard-source-webpack-plugin
後,首次構建時間並不會有太大的變化,但是從第二次開始,構建時間大約可以減少 80%
左右。
安裝
yarn add -D hard-source-webpack-plugin
使用
// webpack.config.js
var HardSourceWebpackPlugin = require('hard-source-webpack-plugin');
module.exports = {
entry: // ...
output: // ...
plugins: [
new HardSourceWebpackPlugin()
]
}
webpack5
中會內建hard-source-webpack-plugin
。
縮小構建目標/減少檔案搜尋範圍 ?
有時候我們的專案中會用到很多模組,但有些模組其實是不需要被解析的。這時我們就可以通過縮小構建目標或者減少檔案搜尋範圍的方式來對構建做適當的優化。
縮小構建目標
主要是exclude
與 include
的使用:
- exclude: 不需要被解析的模組
- include: 需要被解析的模組
// webpack.config.js
const path = require('path');
module.exports = {
...
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
// include: path.resolve('src'),
use: ['babel-loader']
}
]
}
這裡babel-loader
就會排除對node_modules
下對應 js
的解析,提升構建速度。
減少檔案搜尋範圍
這個主要是resolve
相關的配置,用來設定模組如何被解析。通過resolve
的配置,可以幫助Webpack
快速查詢依賴,也可以替換對應的依賴。
resolve.modules
:告訴webpack
解析模組時應該搜尋的目錄resolve.mainFields
:當從npm
包中匯入模組時(例如,import * as React from 'react'
),此選項將決定在package.json
中使用哪個欄位匯入模組。根據webpack
配置中指定的target
不同,預設值也會有所不同resolve.mainFiles
:解析目錄時要使用的檔名,預設是index
resolve.extensions
:副檔名
// webpack.config.js
const path = require('path');
module.exports = {
...
resolve: {
alias: {
react: path.resolve(__dirname, './node_modules/react/umd/react.production.min.js')
}, //直接指定react搜尋模組,不設定預設會一層層的搜尋
modules: [path.resolve(__dirname, 'node_modules')], //限定模組路徑
extensions: ['.js'], //限定副檔名
mainFields: ['main'] //限定模組入口檔名
動態 Polyfill 服務 ?
介紹動態Polyfill
前,我們先來看下什麼是babel-polyfill
。
什麼是 babel-polyfill?
babel
只負責語法轉換,比如將ES6
的語法轉換成ES5
。但如果有些物件、方法,瀏覽器本身不支援,比如:
- 全域性物件:
Promise
、WeakMap
等。 - 全域性靜態函式:
Array.from
、Object.assign
等。 - 例項方法:比如
Array.prototype.includes
等。
此時,需要引入babel-polyfill
來模擬實現這些物件、方法。
這種一般也稱為墊片
。
怎麼使用babel-polyfill
?
使用也非常簡單,在webpack.config.js
檔案作如下配置就可以了:
module.exports = {
entry: ["@babel/polyfill", "./app/js"],
};
為什麼還要用動態Polyfill
?
babel-polyfill
由於是一次性全部匯入整個polyfill
,所以用起來很方便,但與此同時也帶來了一個大問題:檔案很大,所以後續的方案都是針對這個問題做的優化。
來看下打包後babel-polyfill
的佔比:
佔比 29.6%,有點太大了!
介於上述原因,動態Polyfill
服務誕生了。
通過一張圖來了解下Polyfill Service
的原理:
每次開啟頁面,瀏覽器都會向Polyfill Service
傳送請求,Polyfill Service
識別 User Agent
,下發不同的 Polyfill
,做到按需載入Polyfill
的效果。
怎麼使用動態Polyfill
服務?
採用官方提供的服務地址即可:
//訪問url,根據User Agent 直接返回瀏覽器所需的 polyfills
https://polyfill.io/v3/polyfill.min.js
Scope Hoisting
?
什麼是Scope Hoisting
?
Scope hoisting
直譯過來就是「作用域提升」。熟悉 JavaScript
都應該知道「函式提升」和「變數提升」,JavaScript
會把函式和變數宣告提升到當前作用域的頂部。「作用域提升」也類似於此,webpack
會把引入的 js
檔案“提升到”它的引入者頂部。
Scope Hoisting
可以讓 Webpack
打包出來的程式碼檔案更小、執行的更快。
啟用Scope Hoisting
要在 Webpack
中使用 Scope Hoisting
非常簡單,因為這是 Webpack
內建的功能,只需要配置一個外掛,相關程式碼如下:
// webpack.config.js
const webpack = require('webpack')
module.exports = mode => {
if (mode === 'production') {
return {}
}
return {
devtool: 'source-map',
plugins: [new webpack.optimize.ModuleConcatenationPlugin()],
}
}
啟用Scope Hoisting
後的對比
讓我們先來看看在沒有 Scope Hoisting
之前 Webpack
的打包方式。
假如現在有兩個檔案分別是
constant.js
:
export default 'Hello,Jack-cool';
- 入口檔案
main.js
:
import str from './constant.js';
console.log(str);
以上原始碼用 Webpack
打包後的部分程式碼如下:
[
(function (module, __webpack_exports__, __webpack_require__) {
var __WEBPACK_IMPORTED_MODULE_0__constant_js__ = __webpack_require__(1);
console.log(__WEBPACK_IMPORTED_MODULE_0__constant_js__["a"]);
}),
(function (module, __webpack_exports__, __webpack_require__) {
__webpack_exports__["a"] = ('Hello,Jack-cool');
})
]
在開啟 Scope Hoisting
後,同樣的原始碼輸出的部分程式碼如下:
[
(function (module, __webpack_exports__, __webpack_require__) {
var constant = ('Hello,Jack-cool');
console.log(constant);
})
]
從中可以看出開啟 Scope Hoisting
後,函式申明由兩個變成了一個,constant.js
中定義的內容被直接注入到了 main.js
對應的模組中。 這樣做的好處是:
- 程式碼體積更小,因為函式申明語句會產生大量程式碼;
- 程式碼在執行時因為建立的函式作用域更少了,記憶體開銷也隨之變小。
Scope Hoisting
的實現原理其實很簡單:分析出模組之間的依賴關係,儘可能的把打散的模組合併到一個函式中去,但前提是不能造成程式碼冗餘。 因此只有那些被引用了一次的模組才能被合併。
由於Scope Hoisting
需要分析出模組之間的依賴關係,因此原始碼必須採用ES6
模組化語句,不然它將無法生效。
參考
極客時間 【玩轉 webpack】
❤️ 愛心三連擊
1.如果覺得這篇文章還不錯,就幫忙點贊、分享一下吧,讓更多的人也看到~
2.關注公眾號前端森林,定期為你推送新鮮乾貨好文。
3.特殊階段,帶好口罩,做好個人防護。
4.新增微信fs1263215592,拉你進技術交流群一起學習 ?