從Mpx資源構建優化看splitChunks程式碼分割

wonyun 發表於 2022-06-29

背景

MPX是滴滴出品的一款增強型小程式跨端框架,其核心是對原生小程式功能的增強。具體的使用不是本文討論的範疇,想了解更多可以去官網瞭解更多。

回到正題,使用MPX開發小程式有一段時間了,該框架對不同包之間的共享資源有一套自己的構建輸出策略,其官網有這樣一段描述說明:

從Mpx資源構建優化看splitChunks程式碼分割

總結關鍵的兩點:

  • 純js資源:主包引用則輸出主包,或者分包之間共享也輸出到主包
  • 非js資源,包括wxml、樣式、圖片等:主包引用則輸出主包,分包之間共享則輸出到各自分包

很好奇MPX內部是怎麼做到上面這種效果的,尤其是js資源,於是就拜讀了@mpxjs/[email protected]揭開其實現的細節。

mpx怎麼實現的

首先簡單介紹下MPX是怎麼整合小程式離散化的檔案結構,它基於webpack打包構建的,使用者在Webpack配置中只需要配置一個入口檔案app.mpx,它會基於依賴分析動態新增entry的方式來整合小程式的離散化檔案,,loader會解析json配置檔案中的pages域usingComponents域中宣告的路徑,通過動態新增entry的方式將這些檔案新增到Webpack的構建系統當中,並遞迴執行這個過程,直到整個專案中所有用到的.mpx檔案都加入進來。

重點來了,MPX在輸出前,其藉助了webpack的SplitChunksPlugin的能力將複用的模組抽取到一個外部的bundle中,確保最終生成的包中不包含重複模組。

js資源模組的輸出

@mpxjs/webpack-plugin外掛是MPX基於webapck構建的核心,其會在webpack所有模組構建完成的finishMoudles鉤子中來實現構建輸出策略,主要是配置SplitChunks的cacheGroup,後續webpack程式碼優化階段會根據SplitChunks的配置來輸出程式碼。

apply(compiler) {

    ...
    // 拿到webpack預設配置物件splitChunks
    let splitChunksOptions = compiler.options.optimization.splitChunks
    // 刪除splitChunks配置後,webpack內部就不會例項化SplitChunkPlugin
    delete compiler.options.optimization.splitChunks
    // SplitChunkPlugin的例項化由mpx來接管,這樣可以拿到其例項可以後續對其options進行修正
    let splitChunksPlugin = new SplitChunksPlugin(splitChunksOptions)
    splitChunksPlugin.apply(compiler)
    ...
    
    compilation.hooks.finishModules.tap('MpxWebpackPlugin', (modules) => {
            // 自動跟進分包配置修改splitChunksPlugin配置
            if (splitChunksPlugin) {
              let needInit = false
              Object.keys(mpx.componentsMap).forEach((packageName) => {
                if (!splitChunksOptions.cacheGroups.hasOwnProperty(packageName)) {
                  needInit = true
                  splitChunksOptions.cacheGroups[packageName] = getPackageCacheGroup(packageName)
                }
              })
              if (needInit) {
                splitChunksPlugin.options = SplitChunksPlugin.normalizeOptions(splitChunksOptions)
              }
           }
    })

可以看出在所有模組構建完成時,針對不同的packageName來生成其對應的cacheGroups,主要體現在getPackageCacheGroup方法的實現。然後拿到SplitChunksPlugin例項的控制程式碼,對其options進行重寫規格化。

function isChunkInPackage (chunkName, packageName) {
  return (new RegExp(`^${packageName}\\/`)).test(chunkName)
}

function getPackageCacheGroup (packageName) {
  if (packageName === 'main') {
    return {
      name: 'bundle',
      minChunks: 2,
      chunks: 'all'
    }
  } else {
    return {
      test: (module, chunks) => {
        return chunks.every((chunk) => {
          return isChunkInPackage(chunk.name, packageName)
        })
      },
      name: `${packageName}/bundle`,
      minChunks: 2,
      minSize: 1000,
      priority: 100,
      chunks: 'all'
    }
  }
}

getPackageCacheGroup會為小程式的每個包生成一個程式碼分割組,也就是生成每個包對應的cacheGroups

例如一個小程式專案有主包和A、B兩個分包,其生成的cacheGroups內容如下:

{
  default: {
    automaticNamePrefix: '',
    reuseExistingChunk: true,
    minChunks: 2,
    priority: -20
  },
  vendors: {
    automaticNamePrefix: 'vendors',
    test: /[\\/]node_modules[\\/]/,
    priority: -10
  },
  main: { name: 'bundle', minChunks: 2, chunks: 'all' },
  A: {
    test: [Function: test],
    name: 'A/bundle',
    minChunks: 2,
    minSize: 1000,
    priority: 100,
    chunks: 'all'
  },
  B: {
    test: [Function: test],
    name: 'B/bundle',
    minChunks: 2,
    minSize: 1000,
    priority: 100,
    chunks: 'all'
  }

分包程式碼分割輸出bundle的優先順序是最高的(priority: 100),所以會優先處理分包中的打包;否則會執行main中的程式碼打包規則,它會處理所有包之間的共享模組的打包以及主包中被複用的模組。

下面來看分包和主包的打包規則。

1、針對分包中的模組:

{
  test: (module, chunks) => {
    // 依賴當前模組的所有chunks是否都是當前分包下的chunk
    return chunks.every((chunk) => {
      return isChunkInPackage(chunk.name, packageName)
    })
  },
  name: `${packageName}/bundle`,
  minChunks: 2,
  minSize: 1000,
  priority: 100,
  chunks: 'all'
}

分包中的模組被抽離到當前分包下的bundle檔案中,需滿足:

  • 該模組沒有被其他包引用,包括主包和其他分包(test函式邏輯
  • 至少被該分包下的2個chunk引用(minChunks:2
  • 抽離後的bundle大小最少滿足 約1kb(minSize: 1000

2、針對主包中的模組:

{
  name: 'bundle',
  minChunks: 2,
  chunks: 'all'
}

會抽取到主包的bundle檔案的條件:

  • 該模組至少被2個chunk引用(minChunks:2),這個chunk不區分主分包中的chunk

3、針對分包間共享的模組:

分包間共享的模組,不滿足SplitChunks為每個分包設定的分包獨享規則,即該模組只在當前分包引用,沒有在其他包中被引用過。

test: (module, chunks) => {
    return chunks.every((chunk) => {
      return isChunkInPackage(chunk.name, packageName)
    })
}

所以,最終會走到main的cacheGroup的打包規則中,也就是主包的打包規則中。

這樣,MPX通過配置SplitChunksOptions.cacheGroups來實現將主包中的js模組和分包共享的js模組都輸出到主包,分包單獨引用的模組輸出到當前分包下。

元件和靜態資源

對於元件和靜態資源,MPX在webpack構建的thisCompilation鉤子函式中會在compilation上掛載一個有關打包的__mpx__物件,包含靜態資源、元件資源、頁面資源等屬性,也包含靜態的非js資源的輸出處理等:

compiler.hooks.thisCompilation.tap('MpxWebpackPlugin', (compilation, { normalModuleFactory }) => {
    ...
    if (!compilation.__mpx__) {
        mpx = compilation.__mpx__ = {
             ...
             componentsMap: {
                main: {}
              },
              // 靜態資源(圖片,字型,獨立樣式)等,依照所屬包進行記錄,冗餘儲存,同上
              staticResourcesMap: {
                main: {}
              },
              ...
              // 元件和靜態資源的輸出規則如下:
              // 1. 主包引用的資源輸出至主包
              // 2. 分包引用且主包引用過的資源輸出至主包,不在當前分包重複輸出
              // 3. 分包引用且無其他包引用的資源輸出至當前分包
              // 4. 分包引用且其他分包也引用過的資源,重複輸出至當前分包
          getPackageInfo: ({ resource, outputPath, resourceType = 'components', warn }) => {
            let packageRoot = ''
            let packageName = 'main'
            const { resourcePath } = parseRequest(resource)
            const currentPackageRoot = mpx.currentPackageRoot
            const currentPackageName = currentPackageRoot || 'main'
            const resourceMap = mpx[`${resourceType}Map`]
            const isIndependent = mpx.independentSubpackagesMap[currentPackageRoot]
            // 主包中有引用一律使用主包中資源,不再額外輸出
            if (!resourceMap.main[resourcePath] || isIndependent) {
              packageRoot = currentPackageRoot
              packageName = currentPackageName
              ...
            }
            resourceMap[packageName] = resourceMap[packageName] || {}
            const currentResourceMap = resourceMap[packageName]

            let alreadyOutputed = false
            if (outputPath) {
              outputPath = toPosix(path.join(packageRoot, outputPath))
              // 如果之前已經進行過輸出,則不需要重複進行
              if (currentResourceMap[resourcePath] === outputPath) {
                alreadyOutputed = true
              } else {
                currentResourceMap[resourcePath] = outputPath
              }
            } else {
              currentResourceMap[resourcePath] = true
            }

            return {
              packageName,
              packageRoot,
              outputPath,
              alreadyOutputed
            }
          },
          ...
        }
    }
}

這樣webpack構建編譯非js資源時會呼叫compilation.__mpx__.getPackageInfo方法返回非js的靜態資源的輸出路徑,在該方法內部制定瞭如下資源的的輸出規則:

  1. 主包引用的資源輸出至主包
  2. 分包引用且主包引用過的資源輸出至主包,不在當前分包重複輸出
  3. 分包引用且無其他包引用的資源輸出至當前分包
  4. 分包引用且其他分包也引用過的資源,重複輸出至當前分包

這樣,mpx在處理專案中的靜態資源時,會呼叫該方法獲得靜態資源的輸出路徑。

下面以一個簡單例子來說明,

例如對專案中的圖片會呼叫@mpxjs/webpack-plugin提供的url-loader進行處理,與webpack的url-loader類似,對於圖片大小小於指定limit的進行base64處理,否則使用file-loader來輸出圖片(此時需要呼叫getPackageInfo方法獲取圖片的輸出路徑),相關程式碼:

 let outputPath

  if (options.publicPath) { // 優先loader配置的publicPath
    outputPath = url
    if (options.outputPathCDN) {
      if (typeof options.outputPathCDN === 'function') {
        outputPath = options.outputPathCDN(outputPath, this.resourcePath, context)
      } else {
        outputPath = toPosix(path.join(options.outputPathCDN, outputPath))
      }
    }
  } else {
      // 否則,呼叫getPackageInfo獲取輸出路徑
    url = outputPath = mpx.getPackageInfo({
      resource: this.resource,
      outputPath: url,
      resourceType: 'staticResources',
      warn: (err) => {
        this.emitWarning(err)
      }
    }).outputPath
  }
  
  ...
  
  this.emitFile(outputPath, content);
  
  ...

最終,圖片資源會呼叫compilation.__mpx__.getPackageInfo方法來獲取圖片資源的輸出路徑進行產出。

同樣對於css資源wxml資源以及json資源,mpx內部是通過建立子編譯器來抽取的,這裡就不做深入介紹。

splitChunks的用法

webpack的splitChunks外掛是用來進行程式碼拆分的,通過上面的分析可以看出MPX內部是通過內建splitChunkscacheGroups配置項來主動實現對小程式js模組實現分割優化的。webpack常見的程式碼分割方式有三種:

  • 多入口分割:webpack的entry配置項配置的手動入口,也包括可以使用compilation.addEntry程式新增的入口
  • 動態匯入:通過模組的行內函數來分離程式碼,如通過import('./a')
  • 防止重複:使用splitChunks來去重和分離chunk

前兩種在我們日常的開發中比較常見,第三種是通過webpack的optimization.splitChunks配置項來配置的。

通常情況下,webpack配置項optimization.splitChunks會有預設配置來實現程式碼分割,上面我們說到MPX在為不同包生成cacheGroups時,細心的同學會發現我們最終生成的包多了兩個配置組:

{
    default: {
    automaticNamePrefix: '',
    reuseExistingChunk: true,
    minChunks: 2,
    priority: -20
  },
  vendors: {
    automaticNamePrefix: 'vendors',
    test: /[\\/]node_modules[\\/]/,
    priority: -10
  },
  ...
}

這是webpack為optimization.splitChunks.cacheGroups配置的預設組,除此之外optimization.splitChunks還有一些其他預設配置項,如下程式碼所示:

splitChunks: {
    chunks: "async",
    minSize: 30000,
    minChunks: 1,
    maxAsyncRequests: 5,
    maxInitialRequests: 3,
    automaticNameDelimiter: '~',
    name: true,
    cacheGroups: {
        vendors: {
            test: /[\\/]node_modules[\\/]/,
            priority: -10
        },
    default: {
            minChunks: 2,
            priority: -20,
            reuseExistingChunk: true
        }
    }
}

上面預設配置的實現的效果是:滿足下面4個條件的模組程式碼會抽離成新的chunk

  • 來自node_modules中的模組,或者至少被2個chunk複用的模組程式碼
  • 分離出的chunk必須大於等於3000byte,約30kb
  • 按需非同步載入chunk時,並行請求的最大數不超過5個
  • 頁面初始載入時,並行請求的最大數不超過3個

下面來介紹下這些配置項的作用:

  • chunks:表示webpack將對哪些chunk進行分割,可選值為asyncallinitial
    • async:對於非同步載入的chunks進行分割
    • initial:對非非同步載入的初始chunks進行分割
    • all:對所有chunks進行分割
  • minSize: 分割後的chunk要滿足的最小大小,否則不會分割
  • minChunks: 表示一個模組至少應被minChunks個chunk所包含才能分割
  • maxAsyncRequests: 表示按需載入非同步chunk時,並行請求的最大數目;這個數目包括當前請求的非同步chunk以及其所依賴chunk的請求
  • maxInitialRequests: 表示載入入口chunk時,並行請求的最大數目
  • automaticNameDelimiter: 表示拆分出的chunk的名稱連線符,預設為。如chunkvendors.js
  • name: 設定chunk的檔名,預設為true,表示splitChunks基於chunk和cacheGroups的key自動命名。
  • cacheGroups: 通過它可以配置多個組,實現精細化分割程式碼;
    • 該物件配置屬性繼承splitChunks中除cacheGroups外所有屬性,可以在該物件重新配置這些屬性值覆蓋splitChunks中的值
    • 該物件還有一些特有屬性如testpriorityreuseExistingChunk

針對cacheGroups配置補充一點:

cacheGroups配置的每個組可以根據test設定條件,符合test條件的模組,就分配到該組。模組可以被多個組引用,但最終會根據priority來決定打包到哪個組中。