Webpack 4 配置最佳實踐
來自專欄數據體驗技術100 人贊了文章
作者 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 strictconst 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(####, @),歡迎有志之士加入~
原文地址:https://github.com/ProtoTeam/blog/blob/master/201806/3.md
推薦閱讀:
※?? 再見,CommonsChunkPlugin
※兩萬塊的Vue
※入門Webpack,看看這個肯定會幫到你
※WebPack基本概念
※React中react-router-dom包實現路由