多頁應用 Webpack4 配置優化與踩坑記錄

相學長發表於2019-03-04

前言

最近新起了一個多頁專案,之前都未使用 webpack4,於是準備上手實踐一下。這篇文章主要就是一些配置介紹,對於正準備使用 webpack4 的同學,可以做一些參考。

webpack4 相比之前的 2 與 3,改變很大。最主要的一點是很多配置已經內建,使得 webpack 能“開箱即用”。當然這個開箱即用不可能滿足所有情況,但是很多以往的配置,其實可以不用了。比如在之前,壓縮混淆程式碼,需要增加uglify外掛,作用域提升(scope hosting)需要增加ModuleConcatenationPlugin。而在 webpack4 中,只需要設定 modeproduction即可。當然,如果再強行增加這些外掛也不會報錯。

所以我建議,如果大家想遷移到 webpack4,還是從 0 開始做加法,參考歷史,重新做一個配置。而不是從歷史的配置裡刪刪減減,再升級為 webpack4。這樣 webpack4 的配置會顯得更精簡。

打包優化

打包優化主要就是多頁應用構建時,對所有頁面載入的依賴進行合理打包。這個目前業界都已經有了很多實踐,包括 webpack4,也有很多文章介紹。我再補充幾個不容易注意的小細節。有些點我不詳細介紹,不熟悉 webpack 配置的同學可能會不明白,可以搜尋對應關鍵詞,網上肯定有非常詳細的文章介紹。

首先,構建多頁應用,往往會抽離如下幾個 chunk 包:

  1. common:將被多個頁面同時引用的依賴包打到一個 common chunk 中。網上大部分教程是被引入兩次即打入 common。我建議可以根據自己頁面數量來調整,在我的工程中,我設定引入次數超過頁面數量的 1/3 時,才會打入 common 包。
  2. dll: 將每個頁面都會引用的且基本不會改變的依賴包,如 react/react-dom 等再抽離出來,不讓其他模組的變化汙染 dll 庫的 hash 快取。
  3. manifest: webpack 執行時(runtime)程式碼。每當依賴包變化,webpack 的執行時程式碼也會發生變化,如若不將這部分抽離開來,增加了 common 包 hash 值變化的可能性。
  4. 頁面入口檔案對應的page.js

然後我們會給打出的 chunk 包名,注入 contentHash,以實現最大快取效果。在我們分 chunk 的過程中,最關鍵的一個思想就是,每次迭代釋出,儘量減少 chunk hash 值的改變。這個在業界也有很多非常多的實踐,比如這篇文章:github.com/pigcan/blog…

不過在 webpack4 中,我們不用再增加這麼多外掛啦,一個 optimization 配置完全就能搞定。

我先貼上我的 webpack 的 optimization 配置,然後我再對其做一些介紹,加深大家印象

const commonOptions = {
  chunks: `all`,
  reuseExistingChunk: true
}

export default {
  namedChunks: true,
  moduleIds: `hashed`,
  runtimeChunk: {
    name: `manifest`
  },
  splitChunks: {
    maxInitialRequests: 5,
    cacheGroups: {
      polyfill: {
        test: /[\/]node_modules[\/](core-js|raf|@babel|babel)[\/]/,
        name: `polyfill`,
        priority: 2,
        ...commonOptions
      },
      dll: {
        test: /[\/]node_modules[\/](react|react-dom)[\/]/,
        name: `dll`,
        priority: 1,
        ...commonOptions
      },
      commons: {
        name: `commons`,
        minChunks: Math.ceil(pages.length / 3), // 至少被1/3頁面的引入才打入common包
        ...commonOptions
      }
    }
  }
}
複製程式碼

runtimeChunk

在 webpack4 之前,抽離 manifest,需要使用 CommonsChunkPlugin,配置一個指定 name 屬性為`manifest`的 chunk。在 webpack4 中,無需手動引入外掛,配置 runtimeChunk 即可。

splitChunks

這個配置能讓我們以一定規則抽離想要的包,我們可能會抽好幾個包,如 verdor + common,所以 splitChunks 中提供 cacheGroups 欄位,cacheGroups 每增加一個 key,就相當於多一個抽包規則。

在網上很多教程中,dll 往往是專門再加一個 webpack 配置,使用 DllPlugin 來構建 dll 庫,再在自己專案工程的 webpack 中利用 DllReferencePlugin 來對映 dll 庫。雖然這樣構建速度會快不少,但是,哎,是真 TM 煩…..

我是一個很怕煩的人,我情願在 webpack4 中利用 splitChunks,配好規則,再抽離對應的 dll 包。當然這個大家可以自己根據實際情況選擇方案。

除了 dll 與 common 兩個 chunk,我還加了一個 polyfill。這是因為我們用的某些新的庫或者使用某些 ES6+語法(如 async/await)需要 runtime 墊片。比如我工程中使用了 react16,需要增加Map/Set/requestAnimationFrame (reactjs.org/docs/javasc…)。那我必須在 dll 庫載入之前增加 polyfill,因此我將所有 core-js 與 babel 引入的包專門打進 polyfill,保證後續載入的 chunk 能執行。priority欄位用來配置 chunk 的引入優先順序,一般的專案應該都是 polyfill > dll > common > page。

splitChunks 中配置項maxInitialRequests表示在一個入口(entry)中,最大初始請求 chunk 數(不包含按需載入的,即 dom 中 script 引入的 chunk),預設值是 3。我現在 cacheGroups 中已經有三個,又因為配置了 runtimeChunk,會打出 manifest,故而總共有 4 個 chunk 包,超出了預設 3 個,因此需要重新配置值。

moduleIds

稍微瞭解過 webpack 執行機制的同學會知道,專案工程中載入的 module,webpack 會為其分配一個 moduleId,對映對應的模組。這樣產生的問題是一旦工程中模組有增刪或者順序變化,moduleId 就會發生變化,進而可能影響所有 chunk 的 content hash 值。只是因為 moduleId 變化就導致快取失效,這肯定不是我們想要的結果。

在 webpack4 以前,通過 HashedModuleIdsPlugin 外掛,我們可以將模組的路徑對映成 hash 值,來替代 moduleId,因為模組路徑是基本不變的,故而 hash 值也基本不變。

但在 webpack4 中,只需要optimization的配置項中設定 moduleIdshashed 即可。

namedChunks

除了 moduleId,我們知道分離出的 chunk 也有其 chunkId。同樣的,chunkId 也有因其 chunkId 發生變化而導致快取失效的問題。由於manifest與打出的 chunk 包中有chunkId相關資料,所以一旦如“增刪頁面”這樣的操作導致 chunkId 發生變化,可能會影響很多的 chunk 快取失效。

在 webpack4 以前,通過增加NamedChunksPlugin,使用 chunkName 來替換 chunkId,實現固化 chunkId,保持快取的能力。在 webpack4 中,只需在optimization的配置項中設定 namedChunkstrue 即可。

css 相關

在 webpack4 以前,使用 extract-text-webpack-plugin 外掛將 css 從 js 包中分離出來單獨打包。在 webpack 中則需要換成 MiniCssExtractPlugin。並且在生產環境或者需要 HMR(模組熱替換)時,要用 MiniCssExtractPlugin.loader 替換 style-loader

注意,這裡有個坑。由於開發環境我們會配置熱更新,css 的熱更新目前MiniCssExtractPlugin.loader自身還待支援,故而還需要增加 css-hot-loader切記,css-hot-loader一定不能在生產環境下使用。否則每次構建過程所有 js chunk 包的 contentHash 值都會不一致,進而導致所有 js 快取失效。 因為生產環境增加這個配置不會有任何報錯,頁面也能正常構建,故而容易忽視。

簡化多頁應用的入口檔案

使用react/vue等框架的同學知道,我們一般需要一個入口index.js,如這樣:

import React from `react`
import ReactDOM from `react-dom`
import App from `./app`

ReactDOM.render(<App />, document.getElementById(`root`))
複製程式碼

如果你還需要使用dva,或者給所有 react 頁面增加一個 layout 功能的話,可能就會變成這樣:

import React from `react`
import dva from `dva`
import Model from `./model`
import Layout from `~@/layout`
import App from `./app`

const app = dva()
app.router(() => (
  <Layout>
    <App />
  </Layout>
))
app.model(Model)
app.start(document.getElementById(`root`))
複製程式碼

如果每個頁面都這樣,略略有點兒難受,因為程式設計師最怕寫重複的東西了。但是它又必須要有,沒辦法抽離成一個單獨檔案。因為這個是入口檔案,而多頁工程,每個頁面必須要有自己的入口檔案,即使他們長得一模一樣。於是,我們的資源目錄就會是這樣:

- src
  - layout.js
  - pages
    - pageA
      - index.js
      - app.js
      - model.js
    - pageB
      - index.js
      - app.js
      - model.js
複製程式碼

因為所有的 index 都一樣,我理想中的頁面的入口檔案僅僅需要app.js就好,像這樣:

- src
  - layout.js
  - pages
    - pageA
      - app.js
      - model.js
    - pageB
      - app.js
      - model.js
複製程式碼

作為一名前端開發工程師,Node 對於我們來說,應該是熟練運用的工具,而不是僅僅拿別人已經封裝好的各類工具。

在這個問題中,我們大可以在 webpack 構建前,通過Node的檔案系統(File System),對應我們的每個頁面,通過同一個入口檔案模板,建立一些臨時入口檔案:

- src
  - .entires
    - pageA.js
    - pageB.js
  - layout.js
  - pages
複製程式碼

然後將這些臨時檔案,作為 webpack 的 entry 配置。程式碼如下:

const path = require(`path`)
const fs = require(`fs`)
const glob = require(`glob`)
const rimraf = require(`rimraf`)
const entriesDir = path.resolve(process.cwd(), `./src/.entries`)
const srcDir = path.resolve(process.cwd(), `./src`)

// 返回webpack entry配置
module.exports = function() {
  if (fs.existsSync(entriesDir)) {
    rimraf.sync(entriesDir)
  }
  fs.mkdirSync(entriesDir)
  return buildEntries(srcDir)
}

function buildEntries(srcDir) {
  return getPages(srcDir).reduce((acc, current) => {
    acc[current.pageName] = buildEntry(current)
    return acc
  }, {})
}
// 獲取頁面資料,只考慮一級目錄
function getPages(srcDir) {
  const pagesDir = `${srcDir}/pages`
  const pages = glob.sync(`${pagesDir}/**/app.js`)
  return pages.map(pagePath => {
    return {
      pageName: path.relative(pagesDir, p).replace(`/app.js`, ``), // 取出page資料夾名
      pagePath: pagePath
    }
  })
}
// 構建臨時入口檔案
function buildEntry({ pageName, pagePath }) {
  const fileContent = buildFileContent(pagePath)
  const entryPath = `${entriesDir}/${pageName}.js`
  fs.writeFileSync(entryPath, fileContent)
  return entryPath
}
// 替換模板中的 App 模組地址,返回臨時入口檔案內容
function buildFileContent(pagePath) {
  return `
    import React from `react`
    import dva from `dva`
    import Model from `./model`
    import Layout from `~@/layout`
    import App from `PAGE_APP_PATH`

    const app = dva()
    app.router(() => (
      <Layout>
        <App />
      </Layout>
    ))
    app.model(Model)
    app.start(document.getElementById(`root`))
  `.replace(PAGE_APP_PATH, pagePath)
}
複製程式碼

這樣一來,我們就簡單的去掉了重複的入口檔案,還增加了一個 layout 的功能。這只是簡單的程式碼,實際專案可能還有多級目錄,多個 model 等等,需要自己再定製啦。

webpack4出來已經挺久了,文章寫的有點兒滯後了,所以很多我覺得應該大家都明白的地方就沒詳細寫了。如果還有什麼疑問的話,歡迎評論~~

相關文章