在使用 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 会根据模块依赖关系进行代码分割。然而,当多个组件或页面引用了同一个文件时,这个文件会被视为公共模块,默认被打包到主包中,导致:
- 主包体积不必要地增大
- 即使未访问分包,相关代码也被加载
- 不符合按功能模块化的设计初衷
二、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.js
、styles.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.js
或 app.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')
})
}