2

从Mpx资源构建优化看splitChunks代码分割 - wonyun

 1 year ago
source link: https://www.cnblogs.com/wonyun/p/16422785.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

MPX是滴滴出品的一款增强型小程序跨端框架,其核心是对原生小程序功能的增强。具体的使用不是本文讨论的范畴,想了解更多可以去官网了解更多。

回到正题,使用MPX开发小程序有一段时间了,该框架对不同包之间的共享资源有一套自己的构建输出策略,其官网有这样一段描述说明:

408483-20220629115225947-1020306874.png

总结关键的两点:

  • 纯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来决定打包到哪个组中。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK