Webpack 4 配置最佳實踐

螞蟻金服資料體驗技術發表於2018-06-25

作者 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 就可以穩定下來。

Screen Shot 2018-06-03 at 12.59.28 AM.png | left

這裡的 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('####', '@'),歡迎有志之士加入~

原文地址:github.com/ProtoTeam/b…

相關文章