Taro 2.x 分包优化实践:解决主包体积过大的问题

262 阅读5分钟

在 Taro 开发微信小程序的过程中,主包体积过大一直是困扰开发者的常见问题。特别是当项目逐渐复杂,分包与主包共享代码时,很容易出现分包的公共代码被错误打包到主包,或者主包中只有分包使用的模块无法被正确分离的情况。今天,我将为大家介绍一个专为解决这些问题而设计的 Taro 插件——SubPackageFilePlugin。

一、问题背景

微信小程序对主包体积有严格限制(通常不超过 2MB),超出限制将无法正常发布。在 Taro 项目中,常见的体积优化痛点包括:

  1. 分包中引入的公共样式和脚本被错误地打包到主包
  2. 主包中某些只被分包使用的模块无法被有效分离
  3. 跨分包共享代码时的依赖处理困难

针对这些问题,SubPackageFilePlugin 应运而生,它通过巧妙的文件命名约定和 webpack 插件机制,实现了更精细的分包资源管理。

二、插件核心功能

SubPackageFilePlugin 主要提供了两大核心功能:

  1. .stay. 后缀文件处理:确保带有 .stay. 后缀的文件始终保留在引用它的分包中,不会被提升到主包
  2. .split. 后缀文件处理:将带有 .split. 后缀的文件提取为公共模块,并复制到各个引用它的分包中

三、实现原理深度解析

1. 数据结构设计

插件首先定义了一系列数据映射,用于追踪模块与 chunk 之间的关系:

const dataMaps = {
  styleModuleToChunkName: new Map(), // 样式模块到chunk名称的映射
  styleChunkNameToTargetChunks: new Map(), // 样式chunk名称到目标chunks的映射
  scriptModuleToChunkName: new Map(), // 脚本模块到chunk名称的映射
  scriptChunkNameToTargetChunks: new Map(), // 脚本chunk名称到目标chunks的映射
  splitCommonModules: new Set(), // 分割的公共模块集合
  splitCommonChunkNameToTargetChunks: new Map(), // 分割的公共chunk名称到目标chunks的映射
  commonChunkNameToSplitCommonChunkNames: new Map(), // 公共chunk名称到分割后的公共chunk名称的映射
}

这些数据结构是插件实现分包资源精确管理的基础。

2. webpack 缓存组策略

插件通过定义三个关键的 webpack splitChunks 缓存组来实现其核心功能:

chain.merge({
  optimization: {
    splitChunks: {
      cacheGroups: {
        stayStyle: createStayCacheGroup(dataMaps.styleModuleToChunkName, dataMaps.styleChunkNameToTargetChunks),
        stayScript: createStayCacheGroup(dataMaps.scriptModuleToChunkName, dataMaps.scriptChunkNameToTargetChunks),
        splitCommon: splitCommonCacheGroup,
      }
    }
  }
})

3. .stay. 后缀文件处理机制

对于带有 .stay. 后缀的文件,插件会执行以下操作:

  1. optimizeChunks 钩子中识别这些文件,并检查它们是否只被同一分包内的模块引用
  2. 通过 getIsSameSubRoot 方法确保模块只在同一分包内使用
  3. 为这些模块创建专用的缓存组,确保它们不会被提升到主包

关键实现代码如下:

const processOfStayModule = (predicateModule, ext, moduleToChunkName) => {
  let targetModule = predicateModule
  if (predicateModule.type === CSS_MINI_EXTRACT_MODULE_TYPE) {
    targetModule = predicateModule.issuer || predicateModule
  }
  const moduleResource = targetModule.resource || ''
  const moduleBasename = path.basename(moduleResource)
  if (new RegExp(`\\.stay\\..*${ext}$`).test(moduleBasename)) {
    const isSameSubRoot = this.getIsSameSubRoot(targetModule.chunksIterable)
    if (!isSameSubRoot) return

    const chunkName = this.getChunkName(moduleResource)
    moduleToChunkName.set(predicateModule, chunkName)
  }
}

4. .split. 后缀文件处理机制

对于带有 .split. 后缀的文件,插件采用了不同的处理策略:

  1. 首先将这些文件标记为公共模块,并临时放在主包中
  2. emit 阶段,将这些模块复制到每个引用它的分包目录下
  3. 最后删除主包中临时存放的这些模块文件

关键实现代码:

const processOfSplitCommonModule = (predicateModule) => {
  const moduleResource = predicateModule.resource || ''
  const moduleBasename = path.basename(moduleResource)
  if (new RegExp(`\\.split\\..*js$`).test(moduleBasename)) {
    const isNotIncludeMainChunk = this.getIsNotIncludeMainChunk(predicateModule.chunksIterable)
    if (!isNotIncludeMainChunk) return
    dataMaps.splitCommonModules.add(predicateModule)
  }
}

5. 依赖注入与资源复制

插件在 renderWithEntry 钩子中,为引用了这些特殊模块的入口文件添加 require 语句:

// 监听chunk模板渲染事件,在入口渲染前添加依赖
compilation.chunkTemplate.hooks.renderWithEntry.tap({
  name: SubPackageFilePlugin.PluginName,
  before: 'TaroLoadChunksPlugin',
}, (source, chunk) => {
  // ...获取依赖模块并添加require语句
  if (chunkNames.size) {
    result = this.addRequireToSource(chunkId, source, Array.from(chunkNames))
  }
  return result
})

而在 emit 钩子中,插件负责实际的资源复制和清理工作:

// 复制公共模块到各个分包
dataMaps.commonChunkNameToSplitCommonChunkNames.forEach((subPackageChunkNames, chunkName) => {
  for (const key in FileExts) {
    const ext = FileExts[key]
    const assetName = chunkName + ext

    const assetSource = assets[taroHelper.normalizePath(assetName)]
    if (assetSource) {
      subPackageChunkNames.forEach(subPackageChunkName => {
        const subAssetName = subPackageChunkName + ext
        const source = new ConcatSource()
        const _source = assetSource._source || assetSource._value
        source.add(_source)
        patchSource(subAssetName, source)
      })
    }
  }
})

// 删除根目录下的资源,因为已经复制到各个分包中
for (const assetPath in assets) {
  if (new RegExp(`^${subPackageCommonDir}\\/.*`).test(assetPath)) {
    delete assets[assetPath]
  }
}

四、在项目中如何使用

1. 安装与配置

首先,将 SubPackageFilePlugin 引入到你的 Taro 项目中:

// config/index.js
module.exports = {
  // ...其他配置
  plugins: [
    // ...其他插件
    require('path').resolve(__dirname, '../lib/SubPackageFilePlugin'),
  ]
}

2. 使用 .stay. 后缀

对于只希望在特定分包内使用的样式或脚本文件,添加 .stay. 后缀:

// 例如,在分包 packageA 中的样式文件
// src/subPackages/packageA/styles/common.stay.scss

// 在组件中引用
import './styles/common.stay.scss'

这样,common.stay.scss 的内容将始终保留在 packageA 分包中,不会被打包到主包。

3. 使用 .split. 后缀

对于需要在多个分包间共享但又不希望增加主包体积的脚本,添加 .split. 后缀:

// 例如,多个分包共享的工具函数
// src/utils/common.split.js

export const commonUtil = () => {
  // ...工具函数实现
}

当这个文件被多个分包引用时,插件会将它复制到每个引用它的分包目录下,避免增加主包体积。

五、项目实践效果

通过在实际项目中应用这个插件,我们观察到了以下优化效果:

  1. 主包体积显著减小:通过将分包专用代码和跨分包共享代码从主包中分离,主包体积可减少 30%-50%
  2. 分包独立性增强:每个分包包含了自己所需的全部资源,提高了分包的独立性和可维护性
  3. 开发体验提升:开发者只需通过简单的文件命名约定,就能实现复杂的分包资源管理

六、源码地址

插件源码已开源,有兴趣的同学可以前往 Gitee 仓库查看完整实现:

gitee.com/jeffchong92…

七、总结

SubPackageFilePlugin 为 Taro2.x 项目提供了一种简单而有效的分包优化方案。通过巧妙的文件命名约定和 webpack 插件机制,它解决了主包体积过大、分包资源管理复杂等常见问题。

如果你也在为 Taro 项目的主包体积而烦恼,不妨尝试一下这个插件,相信它能给你的项目带来显著的优化效果!

在微信小程序开发中,性能优化是一个永恒的话题。除了使用这类插件外,我们还应该关注代码分割、懒加载、资源压缩等多种优化手段,共同打造高性能的小程序应用。

温馨提示:该插件目前主要针对 Taro2.x 版本和微信小程序平台优化,如果你使用的是 Taro3.x 版本,可能需要进行适当的调整。