Web 應用日益復雜,相關開發技術也百花齊放,這對前端構建工具提出了更高的要求。 Webpack 從眾多構建工具中脫穎而出成為目前最流行的構建工具,幾乎成為目前前端開發里的必備工具之一。 大多數人在使用 Webpack 的過程中都會遇到構建速度慢的問題,在項目大時顯得尤為突出,這極大的影響了我們的開發體驗,降低了我們的開發效率。
本文將傳授你一些加速 Webpack 構建的技巧,下面來一一介紹。
通過多進程并行處理
由于有大量文件需要解析和處理,構建是文件讀寫和計算密集型的操作,特別是當文件數量變多后,Webpack 構建慢的問題會顯得嚴重。 運行在 Node.js 之上的 Webpack 是單線程模型的,也就是說 Webpack 需要處理的任務需要一件件挨著做,不能多個事情一起做。
文件讀寫和計算操作是無法避免的,那能不能讓 Webpack 同一時刻處理多個任務,發揮多核 CPU 電腦的威力,以提升構建速度呢?
使用 HappyPack
HappyPack 就能讓 Webpack 做到上面拋出的問題,它把任務分解給多個子進程去并發的執行,子進程處理完后再把結果發送給主進程。
接入 HappyPack 的相關代碼如下:
const path = require('path');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const HappyPack = require('happypack');
module.exports = {
module: {
rules: [
{ test: /\.js$/,
// 把對 .js 文件的處理轉交給 id 為 babel 的 HappyPack 實例
use:['happypack/loader?id=babel'],
// 排除 node_modules 目錄下的文件,node_modules目錄下的文件都是采用的 ES5 語法,沒必要再通過 Babel 去轉換
exclude: path.resolve(__dirname, 'node_modules'),
},
{
// 把對 .css 文件的處理轉交給 id 為 css 的 HappyPack 實例
test: /\.css$/,
use:ExtractTextPlugin.extract({
use: ['happypack/loader?id=css'],
}),
},
] },
plugins: [
new HappyPack({
// 用唯一的標識符 id 來代表當前的HappyPack 是用來處理一類特定的文件
id: 'babel',
// 如何處理 .js 文件,用法和 Loader配置中一樣
loaders: ['babel-loader?cacheDirectory'],
}),
new HappyPack({
id: 'css',
// 如何處理 .css 文件,用法和Loader 配置中一樣
loaders: ['css-loader'], }),
new ExtractTextPlugin({
filename: `[name].css`,
}),
],
};
以上代碼有兩點重要的修改:
在 Loader 配置中,所有文件的處理都交給了 happypack/loader 去處理,使用緊跟其后的 querystring ?id=babel 去告訴 happypack/loader 去選擇哪個 HappyPack 實例去處理文件。
在 Plugin 配置中,新增了兩個 HappyPack 實例分別用于告訴 happypack/loader 去如何處理 .js 和 .css 文件。選項中的 id 屬性的值和上面 querystring 中的 ?id=babel 相對應,選項中的 loaders 屬性和 Loader 配置中一樣。
接入 HappyPack 后,你需要給項目安裝新的依賴:
安裝成功后重新執行構建,你就會看到以下由 HappyPack 輸出的日志:
Happy[babel]: Version: 4.0.0-beta.5. Threads: 3
Happy[babel]: All set; signaling webpack to proceed.Happy[css]: Version: 4.0.0-beta.5. Threads: 3Happy[css]: All set; signaling webpack to proceed.
說明你的 HappyPack 配置生效了,并且可以得知 HappyPack 分別啟動了3個子進程去并行的處理任務。
在整個 Webpack 構建流程中,最耗時的流程可能就是 Loader 對文件的轉換操作了,因為要轉換的文件數據巨多,而且這些轉換操作都只能一個個挨著處理。 HappyPack 的核心原理就是把這部分任務分解到多個進程去并行處理,從而減少了總的構建時間。
從前面的使用中可以看出所有需要通過 Loader 處理的文件都先交給了 happypack/loader 去處理,收集到了這些文件的處理權后 HappyPack 就好統一分配了。
每通過 new HappyPack() 實例化一個 HappyPack 其實就是告訴 HappyPack 核心調度器如何通過一系列 Loader 去轉換一類文件,并且可以指定如何給這類轉換操作分配子進程。
核心調度器的邏輯代碼在主進程中,也就是運行著 Webpack 的進程中,核心調度器會把一個個任務分配給當前空閑的子進程,子進程處理完畢后把結果發送給核心調度器,它們之間的數據交換是通過進程間通信 API 實現的。
核心調度器收到來自子進程處理完畢的結果后會通知 Webpack 該文件處理完畢。
使用 ParallelUglifyPlugin
在使用 Webpack 構建出用于發布到線上的代碼時,都會有壓縮代碼這一流程。 最常見的 JavaScript 代碼壓縮工具是 UglifyJS,并且 Webpack 也內置了它。
用過 UglifyJS 的你一定會發現在構建用于開發環境的代碼時很快就能完成,但在構建用于線上的代碼時構建一直卡在一個時間點遲遲沒有反應,其實卡住的這個時候就是在進行代碼壓縮。
由于壓縮 JavaScript 代碼需要先把代碼解析成用 Object 抽象表示的 AST 語法樹,再去應用各種規則分析和處理 AST,導致這個過程計算量巨大,耗時非常多。
為什么不把多進程并行處理的思想也引入到代碼壓縮中呢?
ParallelUglifyPlugin 就做了這個事情。 當 Webpack 有多個 JavaScript 文件需要輸出和壓縮時,原本會使用 UglifyJS 去一個個挨著壓縮再輸出, 但是 ParallelUglifyPlugin 則會開啟多個子進程,把對多個文件的壓縮工作分配給多個子進程去完成,每個子進程其實還是通過 UglifyJS 去壓縮代碼,但是變成了并行執行。 所以 ParallelUglifyPlugin 能更快的完成對多個文件的壓縮工作。
使用 ParallelUglifyPlugin 也非常簡單,把原來 Webpack 配置文件中內置的 UglifyJsPlugin 去掉后,再替換成 ParallelUglifyPlugin,相關代碼如下:
const path = require('path');
const ParallelUglifyPlugin = require('webpack-parallel-uglify-plugin');
module.exports = {
plugins: [
// 使用 ParallelUglifyPlugin 并行壓縮輸出的 JS 代碼
new ParallelUglifyPlugin({
// 傳遞給 UglifyJS 的參數
uglifyJS: {
},
}),
],
};
接入 ParallelUglifyPlugin 后,項目需要安裝新的依賴:
npm i -D webpack-parallel-uglify-plugin
安裝成功后,重新執行構建你會發現速度變快了許多。如果設置 cacheDir 開啟了緩存,在之后的構建中會變的更快。
縮小文件搜索范圍
Webpack 啟動后會從配置的 Entry 出發,解析出文件中的導入語句,再遞歸的解析。 在遇到導入語句時 Webpack 會做兩件事情:
根據導入語句去尋找對應的要導入的文件。例如 require(‘react') 導入語句對應的文件是 ./node_modules/react/react.js,而require(‘./util')導入語句 對應的文件是 ./util.js。
根據找到的要導入文件的后綴,使用配置中的 Loader 去處理文件。例如使用 ES6 開發的 JavaScript 文件需要使用 babel-loader 去處理。
以上兩件事情雖然對于處理一個文件非常快,但是當項目大了以后文件量會變的非常多,這時候構建速度慢的問題就會暴露出來。 雖然以上兩件事情無法避免,但需要盡量減少以上兩件事情的發生,以提高速度。
接下來一一介紹可以優化它們的途徑。
縮小 resolve.modules 的范圍
Webpack的resolve.modules 用于配置 Webpack 去哪些目錄下尋找第三方模塊。
resolve.modules 的默認值是 [‘node_modules'],含義是先去當前目錄下的 ./node_modules 目錄下去找想找的模塊,如果沒找到就去上一級目錄 ../node_modules 中找,再沒有就去 ../../node_modules 中找,以此類推,這和 Node.js 的模塊尋找機制很相似。
當安裝的第三方模塊都放在項目根目錄下的 ./node_modules 目錄下時,沒有必要按照默認的方式去一層層的尋找,可以指明存放第三方模塊的絕對路徑,以減少尋找,配置如下:
module.exports = {
resolve: {
// 使用絕對路徑指明第三方模塊存放的位置,以減少搜索步驟
// 其中 __dirname 表示當前工作目錄,也就是項目根目錄
modules: [path.resolve(__dirname, 'node_modules')]
},
};
縮小 Loader 的命中范圍
除此之外在使用 Loader 時可以通過 test 、 include 、 exclude 三個配置項來命中 Loader 要應用規則的文件。 為了盡可能少的讓文件被 Loader 處理,可以通過 include 去命中只有哪些文件需要被處理。
以采用 ES6 的項目為例,在配置 babel-loader 時,可以這樣:
module.exports = {
module: {
rules: [
{
// 如果項目源碼中只有 js 文件就不要寫成 /\.jsx?$/,提升正則表達式性能
test: /\.js$/,
// babel-loader 支持緩存轉換出的結果,通過 cacheDirectory 選項開啟
use: ['babel-loader?cacheDirectory'],
// 只對項目根目錄下的 src 目錄中的文件采用 babel-loader
include: path.resolve(__dirname, 'src'),
},
]
},
};
你可以適當的調整項目的目錄結構,以方便在配置 Loader 時通過 include 去縮小命中范圍。
縮小 resolve.extensions 的數量
在導入語句沒帶文件后綴時,Webpack 會自動帶上后綴后去嘗試詢問文件是否存在。 Webpack 配置中的 resolve.extensions 用于配置在嘗試過程中用到的后綴列表,默認是:
extensions: ['.js', '.json']
也就是說當遇到 require(‘./data') 這樣的導入語句時,Webpack 會先去尋找 ./data.js 文件,如果該文件不存在就去尋找 ./data.json 文件,如果還是找不到就報錯。
如果這個列表越長,或者正確的后綴在越后面,就會造成嘗試的次數越多,所以 resolve.extensions 的配置也會影響到構建的性能。 在配置 resolve.extensions 時你需要遵守以下幾點,以做到盡可能的優化構建性能:
后綴嘗試列表要盡可能的小,不要把項目中不可能存在的情況寫到后綴嘗試列表中。
頻率出現最高的文件后綴要優先放在最前面,以做到盡快的退出尋找過程。
在源碼中寫導入語句時,要盡可能的帶上后綴,從而可以避免尋找過程。例如在你確定的情況下把 require(‘./data') 寫成 require(‘./data.json')。
相關 Webpack 配置如下:
module.exports = {
resolve: {
// 盡可能的減少后綴嘗試的可能性
extensions: ['js'],
},
};
縮小 resolve.mainFields 的數量
Webpack 配置中的 resolve.mainFields 用于配置第三方模塊使用哪個入口文件。
安裝的第三方模塊中都會有一個 package.json 文件用于描述這個模塊的屬性,其中有些字段用于描述入口文件在哪里,resolve.mainFields 用于配置采用哪個字段作為入口文件的描述。
可以存在多個字段描述入口文件的原因是因為有些模塊可以同時用在多個環境中,針對不同的運行環境需要使用不同的代碼。 以 isomorphic-fetchfetch API 為例,它是 的一個實現,但可同時用于瀏覽器和 Node.js 環境。
為了減少搜索步驟,在你明確第三方模塊的入口文件描述字段時,你可以把它設置的盡量少。 由于大多數第三方模塊都采用 main 字段去描述入口文件的位置,可以這樣配置 Webpack:
module.exports = {
resolve: {
// 只采用 main 字段作為入口文件描述字段,以減少搜索步驟
mainFields: ['main'],
},
};
使用本方法優化時,你需要考慮到所有運行時依賴的第三方模塊的入口文件描述字段,就算有一個模塊搞錯了都可能會造成構建出的代碼無法正常運行。
善用現存的文件
通過 module.noParse 忽略文件
Webpack 配置中的 module.noParse 配置項可以讓 Webpack 忽略對部分沒采用模塊化的文件的遞歸解析處理,這樣做的好處是能提高構建性能。 原因是一些庫,例如 jQuery 、ChartJS, 它們龐大又沒有采用模塊化標準,讓 Webpack 去解析這些文件耗時又沒有意義。
在上面的 優化 resolve.alias 配置 中講到單獨完整的 react.min.js 文件就沒有采用模塊化,讓我們來通過配置 module.noParse 忽略對 react.min.js 文件的遞歸解析處理, 相關 Webpack 配置如下:
module.exports = {
module: {
// 獨完整的 `react.min.js` 文件就沒有采用模塊化,忽略對 `react.min.js` 文件的遞歸解析處理
noParse: [/react\.min\.js$/],
},
};
注意被忽略掉的文件里不應該包含 import 、 require 、 define 等模塊化語句,不然會導致構建出的代碼中包含無法在瀏覽器環境下執行的模塊化語句。
通過 resolve.alias 映射文件
Webpack 配置中的 resolve.alias 配置項通過別名來把原導入路徑映射成一個新的導入路徑。
在實戰項目中經常會依賴一些龐大的第三方模塊,以 React 庫為例,庫中包含兩套代碼:
一套是采用 CommonJS 規范的模塊化代碼,這些文件都放在 lib 目錄下,以 package.json 中指定的入口文件 react.js 為模塊的入口。
一套是把 React 所有相關的代碼打包好的完整代碼放到一個單獨的文件中,這些代碼沒有采用模塊化可以直接執行。其中 dist/react.js 是用于開發環境,里面包含檢查和警告的代碼。dist/react.min.js 是用于線上環境,被最小化了。
默認情況下 Webpack 會從入口文件 ./node_modules/react/react.js 開始遞歸的解析和處理依賴的幾十個文件,這會時一個耗時的操作。 通過配置 resolve.alias 可以讓 Webpack 在處理 React 庫時,直接使用單獨完整的 react.min.js 文件,從而跳過耗時的遞歸解析操作。
相關 Webpack 配置如下:
module.exports = {
resolve: {
// 使用 alias 把導入 react 的語句換成直接使用單獨完整的 react.min.js 文件,
// 減少耗時的遞歸解析操作
alias: {
'react': path.resolve(__dirname, './node_modules/react/dist/react.min.js'),
}
},
};
除了 React 庫外,大多數庫發布到 Npm 倉庫中時都會包含打包好的完整文件,對于這些庫你也可以對它們配置 alias。
但是對于有些庫使用本優化方法后會影響到后面要講的使用 Tree-Shaking 去除無效代碼的優化,因為打包好的完整文件中有部分代碼你的項目可能永遠用不上。 一般對整體性比較強的庫采用本方法優化,因為完整文件中的代碼是一個整體,每一行都是不可或缺的。 但是對于一些工具類的庫,例如 lodash,你的項目可能只用到了其中幾個工具函數,你就不能使用本方法去優化,因為這會導致你的輸出代碼中包含很多永遠不會執行的代碼。
使用 DllPlugin
在介紹 DllPlugin 前先給大家介紹下 DLL。 用過 Windows 系統的人應該會經常看到以 .dll 為后綴的文件,這些文件稱為動態鏈接庫,在一個動態鏈接庫中可以包含給其他模塊調用的函數和數據。
要給 Web 項目構建接入動態鏈接庫的思想,需要完成以下事情:
把網頁依賴的基礎模塊抽離出來,打包到一個個單獨的動態鏈接庫中去。一個動態鏈接庫中可以包含多個模塊。
當需要導入的模塊存在于某個動態鏈接庫中時,這個模塊不能再次被打包,而是去動態鏈接庫中獲取。
頁面依賴的所有動態鏈接庫需要被加載。
為什么給 Web 項目構建接入動態鏈接庫的思想后,會大大提升構建速度呢? 原因在于包含大量復用模塊的動態鏈接庫只需要編譯一次,在之后的構建過程中被動態鏈接庫包含的模塊將不會在重新編譯,而是直接使用動態鏈接庫中的代碼。 由于動態鏈接庫中大多數包含的是常用的第三方模塊,例如 react、react-dom,只要不升級這些模塊的版本,動態鏈接庫就不用重新編譯。
接入 Webpack
Webpack 已經內置了對動態鏈接庫的支持,需要通過2個內置的插件接入,它們分別是:
DllPlugin 插件:用于打包出一個個單獨的動態鏈接庫文件。
DllReferencePlugin 插件:用于在主要配置文件中去引入 DllPlugin 插件打包好的動態鏈接庫文件。
下面以基本的 React 項目為例,為其接入 DllPlugin,在開始前先來看下最終構建出的目錄結構:
├── main.js
├── polyfill.dll.js
├── polyfill.manifest.json
├── react.dll.js
└── react.manifest.json
其中包含兩個動態鏈接庫文件,分別是:
polyfill.dll.js 里面包含項目所有依賴的 polyfill,例如 Promise、fetch 等 API。
react.dll.js 里面包含 React 的基礎運行環境,也就是 react 和 react-dom 模塊。
以 react.dll.js 文件為例,其文件內容大致如下:
var _dll_react = (function(modules) {
// ... 此處省略 webpackBootstrap 函數代碼
}([
function(module, exports, __webpack_require__) {
// 模塊 ID 為 0 的模塊對應的代碼
}
// ... 此處省略剩下的模塊對應的代碼
]));
可見一個動態鏈接庫文件中包含了大量模塊的代碼,這些模塊存放在一個數組里,用數組的索引號作為 ID。 并且還通過 _dll_react 變量把自己暴露在了全局中,也就是可以通過 window._dll_react 可以訪問到它里面包含的模塊。
其中 polyfill.manifest.json 和 react.manifest.json 文件也是由 DllPlugin 生成,用于描述動態鏈接庫文件中包含哪些模塊, 以 react.manifest.json 文件為例,其文件內容大致如下:
{
// 描述該動態鏈接庫文件暴露在全局的變量名稱
"name": "_dll_react",
"content": {
"./node_modules/process/browser.js": {
"id": 0,
"meta": {}
},
// ... 此處省略部分模塊
}
}
可見 manifest.json 文件清楚地描述了與其對應的 dll.js 文件中包含了哪些模塊,以及每個模塊的路徑和 ID。
main.js 文件是編譯出來的執行入口文件,當遇到其依賴的模塊在 dll.js 文件中時,會直接通過 dll.js 文件暴露出的全局變量去獲取打包在 dll.js 文件的模塊。 所以在 index.html 文件中需要把依賴的兩個 dll.js 文件給加載進去,index.html 內容如下:
<!--導入依賴的動態鏈接庫文件-->
<script src="./dist/polyfill.dll.js"></script>
<script src="./dist/react.dll.js"></script>
<!--導入執行入口文件-->
<script src="./dist/main.js"></script>
以上就是所有接入 DllPlugin 后最終編譯出來的代碼,接下來教你如何實現。
構建出動態鏈接庫文件
構建輸出的以下這四個文件
├── polyfill.dll.js
├── polyfill.manifest.json
├── react.dll.js
└── react.manifest.json
和以下這一個文件
是由兩份不同的構建分別輸出的。
與動態鏈接庫相關的文件需要由一個獨立的構建輸出,用于給主構建使用。新建一個 Webpack 配置文件 webpack_dll.config.js 專門用于構建它們,文件內容如下:
const path = require('path');
const DllPlugin = require('webpack/lib/DllPlugin');
module.exports = {
// JS 執行入口文件
entry: {
// 把 React 相關模塊的放到一個單獨的動態鏈接庫
react: ['react', 'react-dom'],
// 把項目需要所有的 polyfill 放到一個單獨的動態鏈接庫
polyfill: ['core-js/fn/object/assign', 'core-js/fn/promise', 'whatwg-fetch'],
},
output: {
// 輸出的動態鏈接庫的文件名稱,[name] 代表當前動態鏈接庫的名稱,
// 也就是 entry 中配置的 react 和 polyfill
filename: '[name].dll.js',
// 輸出的文件都放到 dist 目錄下
path: path.resolve(__dirname, 'dist'),
// 存放動態鏈接庫的全局變量名稱,例如對應 react 來說就是 _dll_react
// 之所以在前面加上 _dll_ 是為了防止全局變量沖突
library: '_dll_[name]',
},
plugins: [
// 接入 DllPlugin
new DllPlugin({
// 動態鏈接庫的全局變量名稱,需要和 output.library 中保持一致
// 該字段的值也就是輸出的 manifest.json 文件 中 name 字段的值
// 例如 react.manifest.json 中就有 "name": "_dll_react"
name: '_dll_[name]',
// 描述動態鏈接庫的 manifest.json 文件輸出時的文件名稱
path: path.join(__dirname, 'dist', '[name].manifest.json'),
}),
],
};
使用動態鏈接庫文件
構建出的動態鏈接庫文件用于在其它地方使用,在這里也就是給執行入口使用。
用于輸出 main.js 的主 Webpack 配置文件內容如下:
const DllReferencePlugin = require('webpack/lib/DllReferencePlugin');
module.exports = {
plugins: [
// 告訴 Webpack 使用了哪些動態鏈接庫
new DllReferencePlugin({
// 描述 react 動態鏈接庫的文件內容
manifest: require('./dist/react.manifest.json'),
}),
new DllReferencePlugin({
// 描述 polyfill 動態鏈接庫的文件內容
manifest: require('./dist/polyfill.manifest.json'),
}),
],
devtool: 'source-map'
};
注意:在 webpack_dll.config.js 文件中,DllPlugin 中的 name 參數必須和 output.library 中保持一致。 原因在于 DllPlugin 中的 name 參數會影響輸出的 manifest.json 文件中 name 字段的值, 而在 webpack.config.js 文件中 DllReferencePlugin 會去 manifest.json 文件讀取 name 字段的值, 把值的內容作為在從全局變量中獲取動態鏈接庫中內容時的全局變量名。
執行構建
在修改好以上兩個 Webpack 配置文件后,需要重新執行構建。 重新執行構建時要注意的是需要先把動態鏈接庫相關的文件編譯出來,因為主 Webpack 配置文件中定義的 DllReferencePlugin 依賴這些文件。
執行構建時流程如下:
如果動態鏈接庫相關的文件還沒有編譯出來,就需要先把它們編譯出來。方法是執行 webpack –config webpack_dll.config.js 命令。
在確保動態鏈接庫存在的前提下,才能正常的編譯出入口執行文件。方法是執行 webpack 命令。這時你會發現構建速度有了非常大的提升。
相信給你的項目加上以上優化方法后,構建速度會大大提高,趕快去試試把!