玩轉 webpack,使你的打包速度提升 90%


玩轉 webpack,使你的打包速度提升 90%


前言

webpack 打包優化並沒有什麼固定的模式,一般我們常見的優化就是拆包、分塊、壓縮等,並不是對每一個項目都適用,針對於特定項目,需要不斷調試不斷優化。

對於 webpack4,建議從零開始配置,在項目初期,使用 webpack4 默認的配置。

接下來,本篇文章會列出所有適用於 webpack 優化打包速度的技術方案,並給出相應的限制,請在實際項目中分情況使用。如有任何疑問,請聯繫瓶子君。

一、分析打包速度

優化 webpack 構建速度的第一步是知道將精力集中在哪裡。我們可以通過 speed-measure-webpack-plugin 測量你的 webpack 構建期間各個階段花費的時間:

<code>// 分析打包時間
const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");
const smp = new SpeedMeasurePlugin();
// ...
module.exports = smp.wrap(prodWebpackConfig)
複製代碼/<code>

特定的項目,都有自己特定的性能構建瓶頸,下面我們對打包的每一個環節進行優化。

二、分析影響打包速度環節

在「窺探原理:手寫一個 JavaScript 打包器」中,我們已經介紹過,打包就是從入口文件開始將所有的依賴模塊打包到一個文件中的過程,當然,在打包過程中涉及各種編譯、優化過程。

打包過程中,常見影響構建速度的地方有哪些喃?

1. 開始打包,我們需要獲取所有的依賴模塊

搜索所有的依賴項,這需要佔用一定的時間,即搜索時間,那麼我們就確定了:

我們需要優化的第一個時間就是搜索時間。

2. 解析所有的依賴模塊(解析成瀏覽器可運行的代碼)

webpack 根據我們配置的 loader 解析相應的文件。日常開發中我們需要使用 loader 對 js ,css ,圖片,字體等文件做轉換操作,並且轉換的文件數據量也是非常大。由於 js 單線程的特性使得這些轉換操作不能併發處理文件,而是需要一個個文件進行處理。

我們需要優化的第二個時間就是解析時間。

3. 將所有的依賴模塊打包到一個文件

將所有解析完成的代碼,打包到一個文件中,為了使瀏覽器加載的包更新(減小白屏時間),所以 webpack 會對代碼進行優化。

JS 壓縮是發佈編譯的最後階段,通常 webpack 需要卡好一會,這是因為壓縮 JS 需要先將代碼解析成 AST 語法樹,然後需要根據複雜的規則去分析和處理 AST,最後將 AST 還原成 JS,這個過程涉及到大量計算,因此比較耗時,打包就容易卡住。

我們需要優化的第三個時間就是壓縮時間。

4. 二次打包

當更改項目中一個小小的文件時,我們需要重新打包,所有的文件都必須要重新打包,需要花費同初次打包相同的時間,但項目中大部分文件都沒有變更,尤其是第三方庫。

我們需要優化的第四個時間就是二次打包時間。

三、 優化解析時間 - 開啟多進程打包

運行在 Node.js 之上的 webpack 是單線程模式的,也就是說,webpack 打包只能逐個文件處理,當 webpack 需要打包大量文件時,打包時間就會比較漫長。

1. thread-loader(webpack4 官方推薦)

把這個 loader 放置在其他 loader 之前, 放置在這個 loader 之後的 loader 就會在一個單獨的 worker【worker pool】 池裡運行,一個worker 就是一個nodeJS 進程【node.js proces】,每個單獨進程處理時間上限為600ms,各個進程的數據交換也會限制在這個時間內。

thread-loader 使用起來也非常簡單,只要把 thread-loader 放置在其他 loader 之前, 那 thread-loader 之後的 loader 就會在一個單獨的 worker 池(worker pool)中運行。

例如:

<code>module.exports = {
// ...
module: {
rules: [
{
test: /\\.js$/,
exclude: /node_modules/,
// 創建一個 js worker 池
use: [
'thread-loader',
'babel-loader'
]
},
{
test: /\\.s?css$/,
exclude: /node_modules/,
// 創建一個 css worker 池
use: [
'style-loader',

'thread-loader',
{
loader: 'css-loader',
options: {
modules: true,
localIdentName: '[name]__[local]--[hash:base64:5]',
importLoaders: 1
}
},
'postcss-loader'
]
}
// ...
]
// ...
}
// ...
}
複製代碼/<code>

注意:thread-loader 放在了 style-loader 之後,這是因為 thread-loader 後的 loader 沒法存取文件也沒法獲取 webpack 的選項設置。

官方上說每個 worker 大概都要花費 600ms ,所以官方為了防止啟動 worker 時的高延遲,提供了對 worker 池的優化:預熱

<code>// ...
const threadLoader = require('thread-loader');

const jsWorkerPool = {
// options

// 產生的 worker 的數量,默認是 (cpu 核心數 - 1)
// 當 require('os').cpus() 是 undefined 時,則為 1
workers: 2,

// 閒置時定時刪除 worker 進程
// 默認為 500ms
// 可以設置為無窮大, 這樣在監視模式(--watch)下可以保持 worker 持續存在

poolTimeout: 2000
};

const cssWorkerPool = {
// 一個 worker 進程中並行執行工作的數量
// 默認為 20
workerParallelJobs: 2,
poolTimeout: 2000
};

threadLoader.warmup(jsWorkerPool, ['babel-loader']);
threadLoader.warmup(cssWorkerPool, ['css-loader', 'postcss-loader']);


module.exports = {
// ...
module: {
rules: [
{
test: /\\.js$/,
exclude: /node_modules/,
use: [
{
loader: 'thread-loader',
options: jsWorkerPool
},
'babel-loader'
]
},
{
test: /\\.s?css$/,
exclude: /node_modules/,
use: [
'style-loader',
{
loader: 'thread-loader',
options: cssWorkerPool
},
{
loader: 'css-loader',
options: {
modules: true,
localIdentName: '[name]__[local]--[hash:base64:5]',
importLoaders: 1
}
},
'postcss-loader'
]
}

// ...
]
// ...
}
// ...
}
複製代碼/<code>

注意:請僅在耗時的 loader 上使用。

2. HappyPack

在webpack構建過程中,實際上耗費時間大多數用在 loader 解析轉換以及代碼的壓縮中,HappyPack 可利用多進程對文件進行打包(默認cpu核數-1),對多核cpu利用率更高。HappyPack 可以讓 Webpack 同一時間處理多個任務,發揮多核 CPU 的能力,將任務分解給多個子進程去併發的執行,子進程處理完後,再把結果發送給主進程。

happypack 的處理思路是將原有的 webpack 對 loader 的執行過程從單一進程的形式擴展多進程模式,原本的流程保持不變。使用 HappyPack 也有一些限制,它只兼容部分主流的 loader,具體可以查看官方給出的 兼容性列表。

注意:Ahmad Amireh 推薦使用 thread-loader,並宣佈將不再繼續維護 happypack,所以不推薦使用它

<code>const path = require('path')
const webpack = require("webpack");
const HappyPack = require('happypack'); // 多進程loader
// node 提供的系統操作模塊

const os = require('os');
// 構造出共享進程池,根據系統的內核數量,指定進程池個數,也可以其他數量
const happyThreadPool = HappyPack.ThreadPool({ size: os.cpus().length });
const createHappyPlugin = (id, loaders) => new HappyPack({
// 用唯一的標識符 id 來代表當前的 HappyPack 是用來處理一類特定的文件
id: id,
// 如何處理 .js 文件,用法和 Loader 配置中一樣
loaders: loaders,
// 其它配置項(可選)
// 代表共享進程池,即多個 HappyPack 實例都使用同一個共享進程池中的子進程去處理任務,以防止資源佔用過多
threadPool: happyThreadPool,
// 是否允許 HappyPack 輸出日誌,默認是 true
verbose: true
// threads:代表開啟幾個子進程去處理這一類型的文件,默認是3個,類型必須是整數
});

const clientWebpackConfig = {
// ...
module: {
rules: [
{
test: /\\.(js|jsx)$/,
// 把對 .js .jsx 文件的處理轉交給 id 為 happy-babel 的 HappyPack 實例
use: ["happypack/loader?id=happy-babel"],
// 排除 node_modules 目錄下的文件
// node_modules 目錄下的文件都是採用的 ES5 語法,沒必要再通過 Babel 去轉換
exclude: /node_modules/,
}
]

},
// ...
plugins: [
createHappyPlugin('happy-babel', [{
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', "@babel/preset-react"],
plugins: [
["import", { "libraryName": "antd", "style": true }],
['@babel/plugin-proposal-class-properties',{loose:true}]
],
cacheDirectory: true,
// Save disk space when time isn't as important
cacheCompression: true,
compact: true,
}
}]),
// ...
]
}
複製代碼/<code>

注意,當項目較小時,多進程打包反而會使打包速度變慢。

四、合理利用緩存(縮短連續構建時間,增加初始構建時間)

使用 webpack 緩存的方法有幾種,例如使用 cache-loader,HardSourceWebpackPlugin 或 babel-loader 的 cacheDirectory 標誌。 所有這些緩存方法都有啟動的開銷。 重新運行期間在本地節省的時間很大,但是初始(冷)運行實際上會更慢。

如果你的項目生產版本每次都必須進行初始構建的話,緩存會增加構建時間,減慢你的速度。如果不是,那它們就會大大縮減你二次構建的時間。

1. cache-loader

cache-loader 和 thread-loader 一樣,使用起來也很簡單,僅僅需要在一些性能開銷較大的 loader 之前添加此 loader,以將結果緩存到磁盤裡,顯著提升二次構建速度。

<code>module.exports = {
module: {
rules: [
{
test: /\\.ext$/,
use: ['cache-loader', ...loaders],
include: path.resolve('src'),
},
],
},
};
複製代碼/<code>

⚠️ 請注意,保存和讀取這些緩存文件會有一些時間開銷,所以請只對性能開銷較大的 loader 使用此 loader。

2. HardSourceWebpackPlugin

  • 第一次構建將花費正常的時間
  • 第二次構建將顯著加快(大概提升90%的構建速度)。
<code>const HardSourceWebpackPlugin = require('hard-source-webpack-plugin')
const clientWebpackConfig = {
// ...
plugins: [
new HardSourceWebpackPlugin({
// cacheDirectory是在高速緩存寫入。默認情況下,將緩存存儲在node_modules下的目錄中

// 'node_modules/.cache/hard-source/[confighash]'
cacheDirectory: path.join(__dirname, './lib/.cache/hard-source/[confighash]'),
// configHash在啟動webpack實例時轉換webpack配置,
// 並用於cacheDirectory為不同的webpack配置構建不同的緩存
configHash: function(webpackConfig) {
// node-object-hash on npm can be used to build this.
return require('node-object-hash')({sort: false}).hash(webpackConfig);
},
// 當加載器、插件、其他構建時腳本或其他動態依賴項發生更改時,
// hard-source需要替換緩存以確保輸出正確。
// environmentHash被用來確定這一點。如果散列與先前的構建不同,則將使用新的緩存
environmentHash: {
root: process.cwd(),
directories: [],
files: ['package-lock.json', 'yarn.lock'],
},
// An object. 控制來源
info: {
// 'none' or 'test'.
mode: 'none',
// 'debug', 'log', 'info', 'warn', or 'error'.
level: 'debug',
},
// Clean up large, old caches automatically.
cachePrune: {
// Caches younger than `maxAge` are not considered for deletion. They must
// be at least this (default: 2 days) old in milliseconds.
maxAge: 2 * 24 * 60 * 60 * 1000,
// All caches together must be larger than `sizeThreshold` before any
// caches will be deleted. Together they must be at least this
// (default: 50 MB) big in bytes.
sizeThreshold: 50 * 1024 * 1024
},
}),
new HardSourceWebpackPlugin.ExcludeModulePlugin([
{
test: /.*\\.DS_Store/
}
]),
]

}
複製代碼/<code>

五、優化壓縮時間

1. webpack3

webpack3 啟動打包時加上 --optimize-minimize ,這樣 Webpack 會自動為你注入一個帶有默認配置的 UglifyJSPlugin 。

或:

<code>module.exports = {
optimization: {
minimize: true,
},
}
複製代碼/<code>

壓縮 JavaScript 代碼需要先把代碼解析成用 Object 抽象表示的 AST 語法樹,再去應用各種規則分析和處理 AST,導致這個過程計算量巨大,耗時非常多。但 UglifyJsPlugin 是單線程,所以我們可以使用 ParallelUglifyPlugin 。

ParallelUglifyPlugin 插件實現了多進程壓縮,ParallelUglifyPlugin 會開啟多個子進程,把對多個文件的壓縮工作分配給多個子進程去完成,每個子進程其實還是通過 UglifyJS 去壓縮代碼,但是變成了並行執行。 所以 ParallelUglifyPlugin 能更快的完成對多個文件的壓縮工作。

2. webpack4

webpack4 中 webpack.optimize.UglifyJsPlugin 已被廢棄。

也不推薦使用 ParallelUglifyPlugin,項目基本處於沒人維護的階段,issue 沒人處理,pr沒人合併。

webpack4 默認內置使用 terser-webpack-plugin 插件壓縮優化代碼,而該插件使用 terser 來縮小 JavaScript 。

terser 是什麼?

所謂 terser,官方給出的定義是:

用於 ES6+ 的 JavaScript 解析器、mangler/compressor(壓縮器)工具包。

為什麼 webpack 選擇 terser?

不再維護 uglify-es ,並且 uglify-js 不支持 ES6 +。

terser 是 uglify-es 的一個分支,主要保留了與 uglify-es 和 uglify-js@3 的 API 和 CLI 兼容性。

terser 啟動多進程

使用多進程並行運行來提高構建速度。併發運行的默認數量為 os.cpus().length - 1 。

<code>module.exports = {
optimization: {
minimizer: [
new TerserPlugin({
parallel: true,
}),
],
},
};
複製代碼/<code>

可以顯著加快構建速度,因此強烈推薦開啟多進程

六、優化搜索時間- 縮小文件搜索範圍 減小不必要的編譯工作

webpack 打包時,會從配置的 entry 觸發,解析入口文件的導入語句,再遞歸的解析,在遇到導入語句時 webpack 會做兩件事情:

  • 根據導入語句去尋找對應的要導入的文件。例如 require('react') 導入語句對應的文件是 ./node_modules/react/react.js,require('./util') 對應的文件是 ./util.js。
  • 根據找到的要導入文件的後綴,使用配置中的 Loader 去處理文件。例如使用 ES6 開發的 JavaScript 文件需要使用 babel-loader 去處理。

以上兩件事情雖然對於處理一個文件非常快,但是當項目大了以後文件量會變的非常多,這時候構建速度慢的問題就會暴露出來。 雖然以上兩件事情無法避免,但需要儘量減少以上兩件事情的發生,以提高速度。

接下來一一介紹可以優化它們的途徑。

1. 優化 loader 配置

使用 Loader 時可以通過 test 、 include 、 exclude 三個配置項來命中 Loader 要應用規則的文件

2. 優化 resolve.module 配置

resolve.modules 用於配置 webpack 去哪些目錄下尋找第三方模塊,resolve.modules 的默認值是 ['node_modules'] ,含義是先去當前目錄下的 ./node_modules 目錄下去找想找的模塊,如果沒找到就去上一級目錄 ../node_modules 中找,再沒有就去 ../../node_modules 中找,以此類推。

3. 優化 resolve.alias 配置

resolve.alias 配置項通過別名來把原導入路徑映射成一個新的導入路徑,減少耗時的遞歸解析操作。

4. 優化 resolve.extensions 配置

在導入語句沒帶文件後綴時,webpack 會根據 resolve.extension 自動帶上後綴後去嘗試詢問文件是否存在,所以在配置 resolve.extensions 應儘可能注意以下幾點:

  • resolve.extensions 列表要儘可能的小,不要把項目中不可能存在的情況寫到後綴嘗試列表中。
  • 頻率出現最高的文件後綴要優先放在最前面,以做到儘快的退出尋找過程。
  • 在源碼中寫導入語句時,要儘可能的帶上後綴,從而可以避免尋找過程。

5. 優化 resolve.mainFields 配置

有一些第三方模塊會針對不同環境提供幾分代碼。 例如分別提供採用 ES5 和 ES6 的2份代碼,這2份代碼的位置寫在 package.json 文件裡,如下:

<code>{
"jsnext:main": "es/index.js",// 採用 ES6 語法的代碼入口文件
"main": "lib/index.js" // 採用 ES5 語法的代碼入口文件

}
複製代碼/<code>

webpack 會根據 mainFields 的配置去決定優先採用那份代碼,mainFields 默認如下:

<code>mainFields: ['browser', 'main']
複製代碼/<code>

webpack 會按照數組裡的順序去 package.json 文件裡尋找,只會使用找到的第一個。

假如你想優先採用 ES6 的那份代碼,可以這樣配置:

<code>mainFields: ['jsnext:main', 'browser', 'main']
複製代碼/<code>

6. 優化 module.noParse 配置

module.noParse 配置項可以讓 Webpack 忽略對部分沒采用模塊化的文件的遞歸解析處理,這樣做的好處是能提高構建性能。 原因是一些庫,例如 jQuery 、ChartJS, 它們龐大又沒有采用模塊化標準,讓 Webpack 去解析這些文件耗時又沒有意義。

7. 詳細配置

<code>// 編譯代碼的基礎配置
module.exports = {
// ...
module: {
// 項目中使用的 jquery 並沒有採用模塊化標準,webpack 忽略它
noParse: /jquery/,
rules: [
{
// 這裡編譯 js、jsx

// 注意:如果項目源碼中沒有 jsx 文件就不要寫 /\\.jsx?$/,提升正則表達式性能
test: /\\.(js|jsx)$/,
// babel-loader 支持緩存轉換出的結果,通過 cacheDirectory 選項開啟
use: ['babel-loader?cacheDirectory'],
// 排除 node_modules 目錄下的文件
// node_modules 目錄下的文件都是採用的 ES5 語法,沒必要再通過 Babel 去轉換
exclude: /node_modules/,
},
]
},
resolve: {
// 設置模塊導入規則,import/require時會直接在這些目錄找文件
// 可以指明存放第三方模塊的絕對路徑,以減少尋找
modules: [
path.resolve(`${project}/client/components`),
path.resolve('h5_commonr/components'),
'node_modules'
],
// import導入時省略後綴
// 注意:儘可能的減少後綴嘗試的可能性
extensions: ['.js', '.jsx', '.react.js', '.css', '.json'],
// import導入時別名,減少耗時的遞歸解析操作
alias: {
'@compontents': path.resolve(`${project}/compontents`),
}
},
};
複製代碼/<code>

以上就是所有和縮小文件搜索範圍相關的構建性能優化了,在根據自己項目的需要去按照以上方法改造後,你的構建速度一定會有所提升。

六、往期 webpack 系列

五種可視化方案分析 webpack 打包性能瓶頸

webpack 最佳配置指北

窺探原理:手寫一個 JavaScript 打包器

是時候拋棄Postman了,試試 VS Code 自帶神器插件

七、走在最後

  1. ❤️玩得開心,不斷學習,並始終保持編碼。
  2. 如有任何問題或更獨特的見解,歡迎評論


分享到:


相關文章: