Taro 2.x 分包优化实践:如何防止分包文件被打包到主包

在使用 Taro 开发小程序的过程中,分包加载是一种常见的优化手段,可以有效减小主包体积,提升小程序启动速度。然而,在 Taro 2.x 版本中,开发者经常会遇到一个棘手的问题:某些分包内的文件,尤其是公共组件或工具类,会被错误地打包到主包中,导致主包体积增大,影响小程序性能。

今天,我将分享一个自定义 Taro 插件 StaySubPackageFilePlugin,它可以解决 Taro 2.x 分包加载中的一个常见问题:指定的分包文件被错误地打包到主包中

一、Taro 2.x 分包机制与问题

分包加载的基本原理

在小程序开发中,分包加载允许将代码拆分为多个包,按需加载。Taro 框架会根据 app.json 中的 subPackages 配置来识别分包结构:

json 复制代码
{
  "pages": ["pages/index/index"],
  "subPackages": [
    {
      "root": "packageA",
      "pages": ["pages/list/list"]
    }
  ]
}

分包文件被打包到主包的问题

在 Taro 2.x 的构建过程中,webpack 会根据模块依赖关系进行代码分割。然而,当多个组件或页面引用了同一个文件时,这个文件会被视为公共模块,默认被打包到主包中,导致:

  1. 主包体积不必要地增大
  2. 即使未访问分包,相关代码也被加载
  3. 不符合按功能模块化的设计初衷

二、StaySubPackageFilePlugin 插件原理解析

StaySubPackageFilePlugin 插件通过巧妙的 webpack 钩子机制,确保特定文件始终保留在其所属分包中。让我们来看看它的核心实现:

1. 插件初始化与分包识别

javascript 复制代码
class StaySubPackageFilePlugin {
  static PluginName = 'StaySubPackageFilePlugin'

  constructor(options) {
    this.options = Object.assign({/* 默认配置 */}, options)
    this.subPackageRoots = new Set()
  }

  initSubPackageRoots(compiler) {
    // 解析 app.js 获取分包配置
    const appEntry = this.getAppEntry(compiler)
    const code = taroHelper.fs.readFileSync(appEntry).toString()
    try {
      const transformResult = wxTransformer({/* 转换配置 */})
      const { configObj } = parseAst(/* 解析参数 */)
      const subPackages = configObj.subPackages || configObj['subpackages'] || []
      subPackages.forEach(subPackage => {
        const root = subPackage.root
        this.subPackageRoots.add(root)
      })
    } catch (error) {
      console.error(error)
    }
  }
}

插件首先会解析项目的 app.js 文件,提取出 subPackages 配置,识别所有分包根目录并存储起来。

2. 标记特殊文件并创建缓存组

javascript 复制代码
// 数据映射,用于跟踪模块和chunk关系
const dataMaps = {
  styleModuleToChunkName: new Map(),
  styleChunkNameToTargetChunks: new Map(),
  scriptModuleToChunkName: new Map(),
  scriptChunkNameToTargetChunks: new Map(),
}

// 处理需要保留在分包中的模块
processOfStayModule(module, 'scss', dataMaps.styleModuleToChunkName)
processOfStayModule(module, 'js', dataMaps.scriptModuleToChunkName)

// 创建缓存组,确保文件被正确打包
const createStayCacheGroup = (moduleToChunkName, chunkNameToTargetChunks) => {
  return (module) => {
    const chunkName = moduleToChunkName.get(module)
    if (!chunkName) return
    // 记录模块与chunk的关系
    module.chunksIterable.forEach(chunk => {
      const chunkNames = chunkNameToTargetChunks.get(chunkName) || (new Set())
      chunkNames.add(getChunkIdOrName(chunk))
      chunkNameToTargetChunks.set(chunkName, chunkNames)
    })
    return {
      name: chunkName,
      priority: 9527,
      minChunks: 1,
    }
  }
}

插件通过识别文件名中包含 .stay. 的特殊文件(如 utils.stay.jsstyles.stay.scss),并为它们创建高优先级的 webpack 缓存组,确保这些文件被独立打包。

3. 在编译流程中注入依赖

javascript 复制代码
// 在渲染入口时注入依赖
compilation.chunkTemplate.hooks.renderWithEntry.tap({
  name: StaySubPackageFilePlugin.PluginName,
  before: 'TaroLoadChunksPlugin',
}, (source, chunk) => {
  // 检查是否需要注入依赖
  if (/* 满足条件 */) {
    // 获取需要注入的chunk名称
    // 添加require语句到源码
    result = this.addRequireToSource(chunkId, source, Array.from(chunkNames))
  }
  return result
})

// 为样式文件添加@import语句
addRequireToSource(id, source, chunkNames) {
  const cs = new ConcatSource()
  chunkNames.forEach(chunkName => {
    const val = `require(${JSON.stringify(taroHelper.promoteRelativePath(path.relative(id, chunkName)))});\n`
    cs.add(val)
  })
  cs.add('\n')
  cs.add(source)
  return cs
}

插件在 webpack 的编译流程中,通过钩子函数在适当的时机将对特殊文件的引用注入到对应的分包代码中,确保它们能够被正确加载。

三、插件的使用方法

1. 引入插件

在 Taro 项目的 config/index.js 中引入插件:

javascript 复制代码
const TestPlugin = require('./lib/TestPlugin')

module.exports = {
  // 其他配置
  plugins: [
    TestPlugin,
  ],
}

2. 标记需要保留在分包中的文件

只需在文件名中添加 .stay. 标记即可:

  • 样式文件:components.stay.scss
  • 脚本文件:utils.stay.js

3. 确认分包配置正确

确保在 app.jsapp.config.js 中正确配置了分包:

javascript 复制代码
export default {
  pages: [/* 主包页面 */],
  subPackages: [
    {
      root: 'packageA',
      pages: [/* 分包页面 */]
    }
  ]
}

四、插件的重要意义

1. 精确控制代码打包位置

StaySubPackageFilePlugin 插件解决了 Taro 2.x 中分包文件可能被错误打包到主包的问题,让开发者能够精确控制每个文件的打包位置。

2. 有效减小主包体积

通过将公共组件和工具类保留在各自的分包中,显著减小了主包体积,提升了小程序的启动速度和运行性能。

3. 优化按需加载机制

确保只有在访问对应分包时才加载相关代码,完美实现了分包按需加载的设计初衷,提升了用户体验。

4. 简化开发流程

开发者只需通过简单的文件命名约定即可实现复杂的代码分割策略,无需手动配置复杂的 webpack 规则。

五、总结

StaySubPackageFilePlugin 插件通过巧妙利用 webpack 钩子机制,解决了 Taro 2.x 中分包文件被错误打包到主包的问题,为开发者提供了一种简单有效的分包优化方案。在小程序开发中,合理使用该插件可以显著提升应用性能,优化用户体验。

对于 Taro 开发者来说,了解并掌握这类自定义插件的开发和使用,不仅可以解决实际项目中遇到的问题,还能更深入地理解 Taro 框架的工作原理,为后续的性能优化和功能扩展打下坚实基础。

本文示例代码基于 Taro 2.x 版本,不同版本可能存在差异,请根据实际情况调整。

javascript 复制代码
const path = require('path')
const { ConcatSource } = require('webpack-sources')
const chainHelper = require('@tarojs/mini-runner/dist/webpack/chain')
const wxTransformer = require('@tarojs/transformer-wx').default
const parseAst = require('@tarojs/mini-runner/dist/utils/parseAst').default

const CSS_MINI_EXTRACT_MODULE_TYPE = 'css/mini-extract'
const miniFileTypes = {
  style: '.wxss',
  config: '.json',
  script: '.js',
  templ: '.wxml'
}

/**
 * @param {import('@tarojs/service').IPluginContext} ctx
 */
module.exports = function (ctx) {
  const taroHelper = ctx.helper
  const getPartialBuildConfig = () => {
    const emptyObj = {}
    const { appPath, nodeModulesPath, } = ctx.paths
    const config = ctx.runOpts.config || ctx.initialConfig
    const {
      alias = emptyObj,
      entry = emptyObj,
      env = emptyObj,
      defineConstants = emptyObj,
      sourceRoot = 'src',
      fileType = miniFileTypes,
    } = config
    const constantsReplaceList = chainHelper.mergeOption([chainHelper.processEnvOption(env), defineConstants])
    const sourceDir = path.join(appPath, sourceRoot)

    const entryRes = chainHelper.getEntry({
      sourceDir,
      entry,
      isBuildPlugin: false,
    })

    return {
      alias,
      entry: entryRes.entry,
      nodeModulesPath,
      sourceDir,
      constantsReplaceList,
      fileType,
    }
  }

  const dataMaps = {
    styleModuleToChunkName: new Map(),
    styleChunkNameToTargetChunks: new Map(),
    scriptModuleToChunkName: new Map(),
    scriptChunkNameToTargetChunks: new Map(),
  }
  const clearDataMaps = () => {
    Object.keys(dataMaps).forEach((key) => {
      dataMaps[key].clear()
    })
  }
  const getChunkIdOrName = (chunk) => {
    if (typeof chunk.id === 'string') {
      return chunk.id
    }
    return chunk.name
  }

  const createStayCacheGroup = (moduleToChunkName, chunkNameToTargetChunks) => {
    return (module) => {
      const chunkName = moduleToChunkName.get(module)
      if (!chunkName) return
      module.chunksIterable.forEach(chunk => {
        const chunkNames = chunkNameToTargetChunks.get(chunkName) || (new Set())
        chunkNames.add(getChunkIdOrName(chunk))
        chunkNameToTargetChunks.set(chunkName, chunkNames)
      })
      return {
        name: chunkName,
        priority: 9527,
        minChunks: 1,
      }
    }
  }


  class StaySubPackageFilePlugin {
    static PluginName = 'StaySubPackageFilePlugin'

    /**
     * @param {ReturnType<getPartialBuildConfig>} options
     */
    constructor(options) {
      this.options = Object.assign({
        buildAdapter: 'weapp',
        nodeModulesPath: '',
        sourceDir: '',
        constantsReplaceList: {},
        fileType: miniFileTypes
      }, options)
      this.sourceDir = this.options.sourceDir
      this.subPackageRoots = new Set()
      this.parseConstantsList()
    }

    parseConstantsList() {
      const parsedConstantsReplaceList = {}
      Object.keys(this.options.constantsReplaceList).forEach(key => {
        try {
          parsedConstantsReplaceList[key] = JSON.parse(this.options.constantsReplaceList[key])
        } catch (error) {
          parsedConstantsReplaceList[key] = this.options.constantsReplaceList[key]
        }
      })
      this.options.constantsReplaceList = parsedConstantsReplaceList
    }

    getAppEntry(compiler) {
      const { entry } = compiler.options
      if (this.options.appEntry) return this.options.appEntry
      return Array.isArray(entry.app) ? entry.app[0] : entry.app
    }

    initSubPackageRoots(compiler) {
      const {
        buildAdapter,
        constantsReplaceList,
        nodeModulesPath,
        alias,
      } = this.options
      const appEntry = this.getAppEntry(compiler)
      const code = taroHelper.fs.readFileSync(appEntry).toString()
      try {
        const transformResult = wxTransformer({
          code,
          sourcePath: appEntry,
          sourceDir: this.sourceDir,
          isTyped: ctx.helper.REG_TYPESCRIPT.test(appEntry),
          isApp: true,
          adapter: buildAdapter,
          env: constantsReplaceList,
        })
        const { configObj } = parseAst(transformResult.ast, appEntry, nodeModulesPath, alias, false)
        const subPackages = configObj.subPackages || configObj['subpackages'] || []
        subPackages.forEach(subPackage => {
          const root = subPackage.root
          this.subPackageRoots.add(root)
        })
      } catch (error) {
        console.error(error)
      }
    }

    /**
     * @param {import('webpack').Compiler} compiler
     */
    apply(compiler) {
      this.initSubPackageRoots(compiler)

      compiler.hooks.thisCompilation.tap(StaySubPackageFilePlugin.PluginName, (compilation) => {
        compilation.hooks.optimizeChunks.tap(StaySubPackageFilePlugin.PluginName, () => {
          // 初始化
          clearDataMaps()
          for (const module of compilation.modules) {
            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

                let chunkName = taroHelper.normalizePath(moduleResource.replace(this.sourceDir, '').replace(path.extname(moduleResource), ''))
                chunkName = chunkName.replace(/^\//, '')
                moduleToChunkName.set(predicateModule, chunkName)
              }
            }

            processOfStayModule(module, 'scss', dataMaps.styleModuleToChunkName)
            processOfStayModule(module, 'js', dataMaps.scriptModuleToChunkName)
          }
        })

        compilation.chunkTemplate.hooks.renderWithEntry.tap({
          name: StaySubPackageFilePlugin.PluginName,
          before: 'TaroLoadChunksPlugin',
        }, (source, chunk) => {
          if (chunk.entryModule) {
            let entryModule = chunk.entryModule.rootModule
              ? chunk.entryModule.rootModule
              : chunk.entryModule

            if (
              (
                dataMaps.styleChunkNameToTargetChunks.size
                || dataMaps.scriptChunkNameToTargetChunks.size
              )
              && (entryModule.miniType === taroHelper.PARSE_AST_TYPE.PAGE
                || entryModule.miniType === taroHelper.PARSE_AST_TYPE.COMPONENT)
            ) {
              const chunkId = getChunkIdOrName(chunk)
              const chunkNames = new Set()
              const helper = (chunkNameToTargetChunks) => {
                chunkNameToTargetChunks.forEach((d, n) => {
                  if (d.has(chunkId)) {
                    chunkNames.add(n)
                  }
                })
              }
              helper(dataMaps.scriptChunkNameToTargetChunks)
              helper(dataMaps.styleChunkNameToTargetChunks)
              let result
              if (chunkNames.size) {
                result = this.addRequireToSource(chunkId, source, Array.from(chunkNames))
              }
              return result
            }
          }
        })
      })

      compiler.hooks.emit.tap({
        name: StaySubPackageFilePlugin.PluginName,
        before: 'MiniPlugin',
      }, (compilation) => {
        // 样式
        ; (() => {
          const assets = compilation.assets
          const targetChunkDependStyles = new Map()
          dataMaps.styleChunkNameToTargetChunks.forEach((chunks, styleChunkName) => {
            chunks.forEach(chunkId => {
              const dependStyles = targetChunkDependStyles.get(chunkId) || (new Set())
              dependStyles.add(styleChunkName)
              targetChunkDependStyles.set(chunkId, dependStyles)
            })
          })
          targetChunkDependStyles.forEach((dependStyles, chunkId) => {
            // 没有相关页面或组件
            if (!assets[chunkId + this.options.fileType.script]) return
            if (!dependStyles.size) return
            const targetChunkStyle = chunkId + this.options.fileType.style
            const asset = assets[targetChunkStyle]
            const source = new ConcatSource()
            dependStyles.forEach(styleChunkName => {
              source.add(`@import ${JSON.stringify(taroHelper.promoteRelativePath(path.relative(chunkId, styleChunkName)) + this.options.fileType.style)};`)
              source.add('\n')
            })
            if (asset) {
              const _source = asset._source || asset._value
              source.add(_source)
            }
            assets[targetChunkStyle] = {
              size: () => source.source().length,
              source: () => source.source(),
              // MiniPlugin 会使用
              _source: source,
            }
          })
        })()
      })
    }

    addRequireToSource(id, source, chunkNames) {
      const cs = new ConcatSource()
      chunkNames.forEach(chunkName => {
        const val = `require(${JSON.stringify(taroHelper.promoteRelativePath(path.relative(id, chunkName)))});\n`
        cs.add(val)
      })
      cs.add('\n')
      cs.add(source)
      cs.add(';')
      return cs
    }

    getIsSameSubRoot(chunks) {
      let isSameSubRoot = true
      let sameRoot
      chunks.forEach(chunk => {
        let chunkRoot = ''
        for (const root of this.subPackageRoots) {
          const reg = new RegExp(`^${root}\\/`)
          if (!reg.test(chunk.name)) continue
          chunkRoot = root
          break
        }
        if (!sameRoot) sameRoot = chunkRoot
        if (sameRoot !== chunkRoot) isSameSubRoot = false
      })
      return isSameSubRoot
    }
  }

  ctx.modifyWebpackChain(({ chain }) => {
    // 只处理微信小程序
    if (ctx.runOpts.platform !== 'weapp') return
    chain.merge({
      optimization: {
        splitChunks: {
          cacheGroups: {
            stayStyle: createStayCacheGroup(dataMaps.styleModuleToChunkName, dataMaps.styleChunkNameToTargetChunks),
            stayScript: createStayCacheGroup(dataMaps.scriptModuleToChunkName, dataMaps.scriptChunkNameToTargetChunks),
          }
        }
      }
    })
    const options = getPartialBuildConfig()
    // 在 miniPlugin 之前,确保能获取 entry
    chain.plugin(StaySubPackageFilePlugin.PluginName)
      .use(StaySubPackageFilePlugin, [options]).before('miniPlugin')
  })
}
相关推荐
谢尔登2 小时前
【React】React 哲学
前端·react.js·前端框架
wow_DG2 小时前
【Vue2 ✨】Vue2 入门之旅 · 进阶篇(八):Vuex 内部机制
前端·javascript·vue.js
若年封尘3 小时前
吃透 Vue 样式穿透:从 scoped 原理到组件库样式修改实战
前端·javascript·vue.js·样式穿透·scoped
掘金安东尼3 小时前
CSS 颜色混乱实验
前端·javascript·github
Zhen (Evan) Wang3 小时前
.NET 6 文件下载
java·前端·.net
前端码农.3 小时前
Element Plus 数字输入框箭头隐藏方案
前端·vue.js
李游Leo3 小时前
npm / yarn / pnpm 包管理器对比与最佳实践(含国内镜像源配置与缓存优化)
前端·缓存·npm
Mintopia3 小时前
轻量化AIGC模型在移动端Web应用的适配技术
前端·javascript·aigc
Mintopia3 小时前
Next.js CI/CD 基础(GitHub Actions)
前端·javascript·next.js