作者 Daniel 螞蟻金服·資料體驗技術團隊
Webpack 4 釋出已經有一段時間了。Webpack 的版本號已經來到了 4.12.x。但因為 Webpack 官方還沒有完成遷移指南,在文件層面上還有所欠缺,大部分人對升級 Webpack 還是一頭霧水。
不過 Webpack 的開發團隊已經寫了一些零散的文章,官網上也有了新版配置的文件。社群中一些開發者也已經成功試水,升級到了 Webpack 4,並且總結成了部落格。所以我也終於去了解了 Webpack 4 的具體情況。以下就是我對遷移到 Webpack 4 的一些經驗。
本文的重點在:
- Webpack 4 在配置上帶來了哪些便利?要遷移需要修改配置檔案的哪些內容?
- 之前的 Webpack 配置最佳實踐在 Webpack 4 這個版本,還適用嗎?
Webpack 4 之前的 Webpack 最佳實踐
這裡以 Vue 官方的 Webpack 模板 vuejs-templates/webpack 為例,說說 Webpack 4 之前,社群裡比較成熟的 Webpack 配置檔案是怎樣組織的。
區分開發和生產環境
大致的目錄結構是這樣的:
+ build
+ config
+ src
複製程式碼
在 build 目錄下有四個 webpack 的配置。分別是:
- webpack.base.conf.js
- webpack.dev.conf.js
- webpack.prod.conf.js
- webpack.test.conf.js
這分別對應開發、生產和測試環境的配置。其中 webpack.base.conf.js 是一些公共的配置項。我們使用 webpack-merge 把這些公共配置項和環境特定的配置項 merge 起來,成為一個完整的配置項。比如 webpack.dev.conf.js 中:
'use strict'
const merge = require('webpack-merge')
const baseWebpackConfig = require('./webpack.base.conf')
const devWebpackConfig = merge(baseWebpackConfig, {
...
})
複製程式碼
這三個環境不僅有一部分配置不同,更關鍵的是,每個配置中用 webpack.DefinePlugin
向程式碼注入了 NODE\_ENV
這個環境變數。
這個變數在不同環境下有不同的值,比如 dev 環境下就是 development。這些環境變數的值是在 config 資料夾下的配置檔案中定義的。Webpack 首先從配置檔案中讀取這個值,然後注入。比如這樣:
build/webpack.dev.js
plugins: [
new webpack.DefinePlugin({
'process.env': require('../config/dev.env.js')
}),
]
複製程式碼
config/dev.env.js
module.exports ={
NODE_ENV: '"development"'
}
複製程式碼
至於不同環境下環境變數具體的值,比如開發環境是 development,生產環境是 production,其實是大家約定俗成的。
框架、庫的作者,或者是我們的業務程式碼裡,都會有一些根據環境做判斷,執行不同邏輯的程式碼,比如這樣:
if (process.env.NODE_ENV !== 'production') {
console.warn("error!")
}
複製程式碼
這些程式碼會在程式碼壓縮的時候被預執行一次,然後如果條件表示式的值是 true,那這個 true 分支裡的內容就被移除了。這是一種編譯時的死程式碼優化。這種區分不同的環境,並給環境變數設定不同的值的實踐,讓我們開啟了編譯時按環境對程式碼進行鍼對性優化的可能。
Code Splitting && Long-term caching
Code Splitting 一般需要做這些事情:
- 為 Vendor 單獨打包(Vendor 指第三方的庫或者公共的基礎元件,因為 Vendor 的變化比較少,單獨打包利於快取)
- 為 Manifest (Webpack 的 Runtime 程式碼)單獨打包
- 為不同入口的公共業務程式碼打包(同理,也是為了快取和載入速度)
- 為非同步載入的程式碼打一個公共的包
Code Splitting 一般是通過配置 CommonsChunkPlugin 來完成的。一個典型的配置如下,分別為 vendor、manifest 和 vendor-async 配置了 CommonsChunkPlugin。
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
minChunks (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
}),
new webpack.optimize.CommonsChunkPlugin({
name: 'app',
async: 'vendor-async',
children: true,
minChunks: 3
}),
複製程式碼
CommonsChunkPlugin 的特點就是配置比較難懂,大家的配置往往是複製過來的,這些程式碼基本上成了模板程式碼(boilerplate)。如果 Code Splitting 的要求簡單倒好,如果有比較特殊的要求,比如把不同入口的 vendor 打不同的包,那就很難配置了。總的來說配置 Code Splitting 是一個比較痛苦的事情。
而 Long-term caching 策略是這樣的:給靜態檔案一個很長的快取過期時間,比如一年。然後在給檔名里加上一個 hash,每次構建時,當檔案內容改變時,檔名中的 hash 也會改變。瀏覽器在根據檔名作為檔案的標識,所以當 hash 改變時,瀏覽器就會重新載入這個檔案。
Webpack 的 Output 選項中可以配置檔名的 hash,比如這樣:
output: {
path: config.build.assetsRoot,
filename: utils.assetsPath('js/[name].[chunkhash].js'),
chunkFilename: utils.assetsPath('js/[id].[chunkhash].js')
},
複製程式碼
Webpack 4 下的最佳實踐
Webpack 4 的變與不變
Webpack 4 這個版本的 API 有一些 breaking change,但不代表說這個版本就發生了翻天覆地的變化。其實變化的點只有幾個。而且只要你仔細瞭解了這些變化,你一定會拍手叫好。
遷移到 Webpack 4 也只需要檢查一下 checklist,看看這些點是否都覆蓋到了,就可以了。
開發和生產環境的區分
Webpack 4 引入了 mode 這個選項。這個選項的值可以是 development 或者 production。
設定了 mode 之後會把 process.env.NODE\_ENV
也設定為 development 或者 production。然後在 production 模式下,會預設開啟 UglifyJsPlugin 等等一堆外掛。
Webpack 4 支援零配置使用,可以從命令列指定 entry 的位置,如果不指定,就是 src/index.js
。mode 引數也可以從命令列引數傳入。這樣一些常用的生產環境打包優化都可以直接啟用。
我們需要注意,Webpack 4 的零配置是有限度的,如果要加上自己想加的外掛,或者要加多個 entry,還是需要一個配置檔案。
雖然如此,Webpack 4 在各個方面都做了努力,努力讓零配置可以做的事情更多。這種內建優化的方式使得我們在專案起步的時候,可以把主要精力放在業務開發上,等後期業務變複雜之後,才需要關注配置檔案的編寫。
在 Webpack 4 推出 mode 這個選項之前,如果想要為不同的開發環境打造不同的構建選項,我們只能通過建立多個 Webpack 配置且分別設定不同的環境變數值這種方式。這也是社群裡的最佳實踐。
Webpack 4 推出的 mode 選項,其實是一種對社群中最佳實踐的吸收。這種思路我是很贊同的。開源專案來自於社群,在社群中成長,從社群中吸收養分,然後回報社群,這是一個良性迴圈。最近我在很多前端專案中都看到了類似的趨勢。接下來要講的其他幾個 Webpack 4 的特性也是和社群的反饋離不開的。
那麼上文中介紹的使用多個 Webpack 配置,以及手動環境變數注入的方式,是否在 Webpack 4 下就不適用了呢?其實不然。在Webpack 4 下,對於一個正經的專案,我們依然需要多個不同的配置檔案。如果我們對為測試環境的打包做一些特殊處理,我們還需要在那個配置檔案裡用 webpack.DefinePlugin
手動注入 NODE\_ENV
的值(比如 test)。
Webpack 4 下如果需要一個 test 環境,那 test 環境的 mode 也是 development。因為 mode 只有開發和生產兩種,測試環境應該是屬於開發階段。
第三方庫 build 的選擇
在 Webpack 3 時代,我們需要在生產環境的的 Webpack 配置裡給第三方庫設定 alias,把這個庫的路徑設定為 production build 檔案的路徑。以此來引入生產版本的依賴。
比如這樣:
resolve: {
extensions: [".js", ".vue", ".json"],
alias: {
vue$: "vue/dist/vue.runtime.min.js"
}
},
複製程式碼
在 Webpack 4 引入了 mode 之後,對於部分依賴,我們可以不用配置 alias,比如 React。React 的入口檔案是這樣的:
'use strict';
if (process.env.NODE_ENV === 'production') {
module.exports = require('./cjs/react.production.min.js');
} else {
module.exports = require('./cjs/react.development.js');
}
複製程式碼
這樣就實現了 0 配置自動選擇生產 build。
但大部分的第三庫並沒有做這個入口的環境判斷。所以這種情況下我們還是需要手動配置 alias。
Code Splitting
Webpack 4 下還有一個大改動,就是廢棄了 CommonsChunkPlugin,引入了 optimization.splitChunks
這個選項。
optimization.splitChunks
預設是不用設定的。如果 mode 是 production,那 Webpack 4 就會開啟 Code Splitting。
預設 Webpack 4 只會對按需載入的程式碼做分割。如果我們需要配置初始載入的程式碼也加入到程式碼分割中,可以設定
splitChunks.chunks
為'all'
。
Webpack 4 的 Code Splitting 最大的特點就是配置簡單(0配置起步),和__基於內建規則自動拆分__。內建的程式碼切分的規則是這樣的:
- 新 bundle 被兩個及以上模組引用,或者來自 node_modules
- 新 bundle 大於 30kb (壓縮之前)
- 非同步載入併發載入的 bundle 數不能大於 5 個
- 初始載入的 bundle 數不能大於 3 個
簡單的說,Webpack 會把程式碼中的公共模組自動抽出來,變成一個包,前提是這個包大於 30kb,不然 Webpack 是不會抽出公共程式碼的,因為增加一次請求的成本是不能忽視的。
具體的業務場景下,具體的拆分邏輯,可以看 SplitChunksPlugin 的文件以及 webpack 4: Code Splitting, chunk graph and the splitChunks optimization 這篇部落格。這兩篇文章基本羅列了所有可能出現的情況。
如果是普通的應用,Webpack 4 內建的規則就足夠了。
如果是特殊的需求,Webpack 4 的 optimization.splitChunks
API也可以滿足。
splitChunks 有一個引數叫 cacheGroups,這個引數類似之前的 CommonChunks 例項。cacheGroups 裡每個物件就是一個使用者定義的 chunk。
之前我們講到,Webpack 4 內建有一套程式碼分割的規則,那使用者也可以自定義 cacheGroups,也就是自定義 chunk。那一個 module 應該被抽到哪個 chunk 呢?這是由 cacheGroups 的抽取範圍控制的。每個 cacheGroups 都可以定義自己抽取模組的範圍,也就是哪些檔案中的公共程式碼會抽取到自己這個 chunk 中。不同的 cacheGroups 之間的模組範圍如果有交集,我們可以用 priority 屬性控制優先順序。Webpack 4 預設的抽取的優先順序是最低的,所以模組會優先被抽取到使用者的自定義 chunk 中。
splitChunksPlugin 提供了兩種控制 chunk 抽取模組範圍的方式。一種是 test 屬性。這個屬性可以傳入字串、正則或者函式,所有的 module 都會去匹配 test 傳入的條件,如果條件符合,就被納入這個 chunk 的備選模組範圍。如果我們傳入的條件是字串或者正則,那匹配的流程是這樣的:首先匹配 module 的路徑,然後匹配 module 之前所在 chunk 的 name。
比如我們想把所有 node_modules 中引入的模組打包成一個模組:
vendors1: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
chunks: 'all',
}
複製程式碼
因為從 node_modules 中載入的依賴路徑中都帶有 node_modules,所以這個正則會匹配所有從 node_modules 中載入的依賴。
test 屬性可以以 module 為單位控制 chunk 的抽取範圍,是一種細粒度比較小的方式。splitChunksPlugin 的第二種控制抽取模組範圍的方式就是 chunks 屬性。chunks 可以是字串,比如 'all'|'async'|'initial'
,分別代表了全部 chunk,按需載入的 chunk 以及初始載入的 chunk。chunks 也可以是一個函式,在這個函式裡我們可以拿到 chunk.name
。這給了我們通過入口來分割程式碼的能力。這是一種細粒度比較大的方式,以 chunk 為單位。
舉個例子,比如我們有 a, b, c 三個入口。我們希望 a,b 的公共程式碼單獨打包為 common。也就是說 c 的程式碼不參與公共程式碼的分割。
我們可以定義一個 cacheGroups,然後設定 chunks 屬性為一個函式,這個函式負責過濾這個 cacheGroups 包含的 chunk 是哪些。示例程式碼如下:
optimization: {
splitChunks: {
cacheGroups: {
common: {
chunks(chunk) {
return chunk.name !== 'c';
},
name: 'common',
minChunks: 2,
},
},
},
},
複製程式碼
上面配置的意思就是:我們想把 a,b 入口中的公共程式碼單獨打包為一個名為 common 的 chunk。使用 chunk.name
,我們可以輕鬆的完成這個需求。
在上面的情況中,我們知道 chunks 屬性可以用來按入口切分幾組公共程式碼。現在我們來看一個稍微複雜一些的情況:對不同分組入口中引入的 node_modules 中的依賴進行分組。
比如我們有 a, b, c, d 四個入口。我們希望 a,b 的依賴打包為 vendor1,c, d 的依賴打包為 vendor2。
這個需求要求我們對入口和模組都做過濾,所以我們需要使用 test 屬性這個細粒度比較小的方式。我們的思路就是,寫兩個 cacheGroup,一個 cacheGroup 的判斷條件是:如果 module 在 a 或者 b chunk 被引入,並且 module 的路徑包含 node\_modules
,那這個 module 就應該被打包到 vendors1 中。 vendors2 同理。
vendors1: {
test: module => {
for (const chunk of module.chunksIterable) {
if (chunk.name && /(a|b)/.test(chunk.name)) {
if (module.nameForCondition() && /[\\/]node_modules[\\/]/.test(module.nameForCondition())) {
return true;
}
}
}
return false;
},
minChunks: 2,
name: 'vendors1',
chunks: 'all',
},
vendors2: {
test: module => {
for (const chunk of module.chunksIterable) {
if (chunk.name && /(c|d)/.test(chunk.name)) {
if (module.nameForCondition() && /[\\/]node_modules[\\/]/.test(module.nameForCondition())) {
return true;
}
}
}
return false;
},
minChunks: 2,
name: 'vendors2',
chunks: 'all',
},
};
複製程式碼
Long-term caching
Long-term caching 這裡,基本的操作和 Webpack 3 是一樣的。不過 Webpack 3 的 Long-term caching 在操作的時候,有個小問題,這個問題是關於 chunk 內容和 hash 變化不一致的:
在公共程式碼 Vendor 內容不變的情況下,新增 entry,或者 external 依賴,或者非同步模組的時候,Vendor 的 hash 會改變。
之前 Webpack 官方的專欄裡面有一篇文章講這個問題:Predictable long term caching with Webpack。給出了一個解決方案。
這個方案的核心就是,Webpack 內部維護了一個自增的 id,每個 chunk 都有一個 id。所以當增加 entry 或者其他型別 chunk 的時候,id 就會變化,導致內容沒有變化的 chunk 的 id 也發生了變化。
對此我們的應對方案是,使用 webpack.NamedChunksPlugin
把 chunk id 變為一個字串識別符號,這個字元包一般就是模組的相對路徑。這樣模組的 chunk id 就可以穩定下來。
這裡的 vendors1 就是 chunk id
HashedModuleIdsPlugin 的作用和 NamedChunksPlugin 是一樣的,只不過 HashedModuleIdsPlugin 把根據模組相對路徑生成的 hash 作為 chunk id,這樣 chunk id 會更短。因此在生產中更推薦用 HashedModuleIdsPlugin。
這篇文章說還講到,webpack.NamedChunksPlugin
只能對普通的 Webpack 模組起作用,非同步模組,external 模組是不會起作用的。
非同步模組可以在 import 的時候加上 chunkName 的註釋,比如這樣:import(/* webpackChunkName: "lodash" */ 'lodash').then() 這樣就有 Name 了
所以我們需要再使用一個外掛:name-all-modules-plugin
這個外掛中用到一些老的 API,Webpack 4 會發出警告,這個 pr 有新的版本,不過作者不一定會 merge。我們使用的時候可以直接 copy 這個外掛的程式碼到我們的 Webpack 配置裡面。
做了這些工作之後,我們的 Vendor 的 ChunkId 就再也不會發生不該發生的變化了。
總結
Webpack 4 的改變主要是對社群中最佳實踐的吸收。Webpack 4 通過新的 API 大大提升了 Code Splitting 的體驗。但 Long-term caching 中 Vendor hash 的問題還是沒有解決,需要手動配置。本文主要介紹的就是 Webpack 配置最佳實踐在 Webpack 3.x 和 4.x 背景下的異同。希望對讀者的 Webpack 4 專案的配置檔案組織有所幫助。
另外,推薦 SURVIVEJS - WEBPACK 這個線上教程。這個教程總結了 Webpack 在實際開發中的實踐,並且把材料更新到了最新的 Webpack 4。
對我們團隊感興趣的可以關注專欄,關注github或者傳送簡歷至'tao.qit####alibaba-inc.com'.replace('####', '@'),歡迎有志之士加入~