在 uni-app 小程序优化中,主包体积超限 是开发者最头疼的问题。除了共享 JS 模块,自定义组件更是主包体积的重灾区:
很多组件明明只被分包使用、主包从未引用,却依然被打包在主包内,白白占用主包体积。官方分包策略无法自动处理这类组件,只能手动拆分,极易出错、维护成本极高。
编写次 插件:全自动扫描、智能分析、一键将「仅分包使用的组件」从主包移动到对应分包,彻底解放主包体积,全程无需修改业务代码、无侵入、无风险。
一、插件解决了什么核心问题?
痛点场景
- 组件被分包使用,但主包未使用 → 依然打包进主包
- 大量自定义组件、uni-ui 组件、自定义弹窗 / 表单组件堆积主包
- 主包体积超标,无法发布小程序
- 手动移动组件工作量大、路径修改繁琐、容易引发报错
- 组件深层依赖(组件引用组件)无法自动处理
二、核心工作流程(极简理解)
- 读取 app.json → 获取主包页面 + 所有分包配置
- 全量扫描 JSON → 构建页面 / 组件「引用关系地图」
- 递归解析依赖 → 找到组件的所有深层子组件
- 智能筛选 :
- 必须:仅分包使用
- 必须:主包未使用
- 可配置:最多被 N 个分包使用
- 自动复制组件 → 到目标分包目录
- 批量替换路径 → 所有引用自动更新
- 删除主包源文件 → 完成主包瘦身
三、完整插件代码(开箱即用)
javascript
'use strict'
const fs = require('fs')
const path = require('path')
// ─── Utils ────────────────────────────────────────────────────────────────
function ensureDirectory(dir) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
}
function formatPath(p) {
return p.replace(/\\/g, '/')
}
function loadJson(filePath) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
} catch (e) {
return null
}
}
function normalizeComponentPath(rawPath) {
if (typeof rawPath !== 'string') return ''
return rawPath.startsWith('/') ? rawPath.slice(1) : rawPath
}
function parseUsingComponents(usingComponents) {
if (!usingComponents || typeof usingComponents !== 'object') return {}
return Object.keys(usingComponents).reduce((acc, name) => {
const compPath = normalizeComponentPath(usingComponents[name])
if (compPath) acc[compPath] = {}
return acc
}, {})
}
function copyStaticFile(src, dest) {
ensureDirectory(path.dirname(dest))
fs.copyFileSync(src, dest)
}
function removeStaticFile(filePath) {
try {
if (fs.existsSync(filePath)) fs.unlinkSync(filePath)
} catch (e) {
/* ignore */
}
}
// ─── Dependency Analysis ───────────────────────────────────────────────────
function scanComponentDependencies(outputDir) {
const dependencyMap = {}
function walk(dir) {
if (!fs.existsSync(dir)) return
let entries
try {
entries = fs.readdirSync(dir)
} catch (e) {
return
}
entries.forEach((name) => {
const fullPath = path.join(dir, name)
let stat
try {
stat = fs.statSync(fullPath)
} catch (e) {
return
}
if (stat.isDirectory()) {
walk(fullPath)
return
}
if (!name.endsWith('.json')) return
const relativePath = formatPath(
path.relative(outputDir, fullPath).replace(/\.json$/, '')
)
if (
relativePath === 'app' ||
relativePath === 'sitemap' ||
relativePath.endsWith('project.config') ||
relativePath.endsWith('project.private.config')
) {
return
}
const json = loadJson(fullPath)
if (!json) return
let genericComponents = {}
if (Array.isArray(json.genericComponents)) {
json.genericComponents.forEach(item => {
if (item && typeof item === 'object') {
Object.assign(genericComponents, item)
}
})
} else if (json.genericComponents) {
genericComponents = json.genericComponents
}
const merged = { ...genericComponents, ...(json.usingComponents || {}) }
dependencyMap[relativePath] = parseUsingComponents(merged)
})
}
walk(outputDir)
return dependencyMap
}
function loadAppConfig(outputDir) {
const appJsonPath = path.join(outputDir, 'app.json')
const appConfig = loadJson(appJsonPath)
if (!appConfig) {
throw new Error('[PackageComponentMovePlugin] 无法读取 app.json')
}
const mainPages = new Set()
const subPackages = []
;(appConfig.pages || []).forEach(page => {
mainPages.add(formatPath(page))
})
const rawSubPackages = appConfig.subPackages || appConfig.subpackages || []
rawSubPackages.forEach(pkg => {
const root = formatPath(pkg.root || '')
if (!root) return
const pages = new Set()
;(pkg.pages || []).forEach(page => {
pages.add(formatPath(path.join(root, page)))
})
subPackages.push({ root, pages })
})
return { mainPages, subPackages }
}
function buildDeepDependencyTree(dependencyMap) {
Object.keys(dependencyMap).forEach(node => {
const deps = dependencyMap[node]
Object.keys(deps).forEach(child => {
if (dependencyMap[child]) {
deps[child] = dependencyMap[child]
}
})
})
}
function flattenDependencyTree(tree) {
const result = {}
function collect(node, visited, list) {
Object.keys(node).forEach(key => {
if (visited.has(key)) return
visited.add(key)
if (!list.includes(key)) list.push(key)
if (tree[key]) collect(tree[key], visited, list)
})
}
Object.keys(tree).forEach(key => {
const list = []
const visited = new Set([key])
if (tree[key]) collect(tree[key], visited, list)
result[key] = list
})
return result
}
function analyzeComponentOwnership(flatMap, subPackages, mainPages) {
const componentUsage = {}
const mainUsedComponents = new Set()
mainPages.forEach(page => {
const deps = flatMap[page] || []
deps.forEach(comp => mainUsedComponents.add(comp))
})
subPackages.forEach(pkg => {
pkg.pages.forEach(page => {
const deps = flatMap[page] || []
deps.forEach(comp => {
if (mainUsedComponents.has(comp)) return
if (!componentUsage[comp]) componentUsage[comp] = new Set()
componentUsage[comp].add(pkg.root)
})
})
})
return componentUsage
}
function isComponentInPackage(compPath, packageRoot) {
return compPath === packageRoot || compPath.startsWith(packageRoot + '/')
}
// ─── File Operations ───────────────────────────────────────────────────────
function getComponentAllFiles(outputDir, component) {
const base = path.join(outputDir, component)
const dir = path.dirname(base)
const name = path.basename(base)
const files = []
if (!fs.existsSync(dir)) return files
const entries = fs.readdirSync(dir)
entries.forEach(file => {
if (
file.startsWith(name + '.') ||
file.startsWith(name + '-create-component')
) {
files.push(path.join(dir, file))
}
})
return files
}
function copyComponentToPackage(outputDir, source, target) {
const sourceBase = path.join(outputDir, source)
const targetBase = path.join(outputDir, target)
const sourceDir = path.dirname(sourceBase)
const sourceName = path.basename(sourceBase)
const targetDir = path.dirname(targetBase)
const targetName = path.basename(targetBase)
if (!fs.existsSync(sourceDir)) return []
const entries = fs.readdirSync(sourceDir)
const copied = []
entries.forEach(file => {
if (
file.startsWith(sourceName + '.') ||
file.startsWith(sourceName + '-create-component')
) {
const srcFile = path.join(sourceDir, file)
const ext = file.slice(sourceName.length)
const destFile = path.join(targetDir, targetName + ext)
ensureDirectory(targetDir)
copyStaticFile(srcFile, destFile)
copied.push({ src: srcFile, dest: destFile })
}
})
return copied
}
function deleteSourceComponent(outputDir, component) {
getComponentAllFiles(outputDir, component).forEach(removeStaticFile)
}
function replaceAllComponentReferences(outputDir, scopedReplacements) {
const scopes = []
scopedReplacements.forEach((rules, scopeDir) => {
scopes.push({ prefix: formatPath(scopeDir) + '/', rules })
})
if (!scopes.length) return
function scanAndReplace(dir) {
if (!fs.existsSync(dir)) return
const entries = fs.readdirSync(dir)
entries.forEach(name => {
const full = path.join(dir, name)
const stat = fs.statSync(full)
if (stat.isDirectory()) {
scanAndReplace(full)
return
}
if (!/\.(js|json|wxml|wxss)$/.test(name)) return
const normalFull = formatPath(full)
const rules = []
scopes.forEach(scope => {
if (normalFull.startsWith(scope.prefix)) {
rules.push(...scope.rules)
}
})
if (!rules.length) return
let content = fs.readFileSync(full, 'utf8')
let changed = false
rules.forEach(rule => {
if (content.includes(rule.old)) {
content = content.split(rule.old).join(rule.new)
changed = true
}
})
if (changed) {
fs.writeFileSync(full, content, 'utf8')
}
})
}
scanAndReplace(outputDir)
}
// ─── Main Plugin ───────────────────────────────────────────────────────────
class PackageComponentMovePlugin {
constructor(options = {}) {
this.maxPackages = options.maxPackages ?? 1
this.excludeList = Array.isArray(options.excludeList) ? options.excludeList : []
this.debug = !!options.debug
}
apply(compiler) {
compiler.hooks.done.tap('PackageComponentMovePlugin', (stats) => {
const platform = process.env.UNI_PLATFORM || ''
if (!platform.startsWith('mp-') && platform !== 'quickapp-webview') {
return
}
const outputDir = stats.compilation.outputOptions.path || compiler.outputPath
try {
this.start(outputDir)
} catch (e) {
console.error('[PackageComponentMovePlugin] 执行失败:', e)
}
})
}
isExcluded(component) {
return this.excludeList.some(key => component.includes(key))
}
log(...args) {
if (this.debug) {
console.log('[PackageComponentMovePlugin]', ...args)
}
}
start(outputDir) {
const startTime = Date.now()
console.log('[PackageComponentMovePlugin] 开始自动分包组件迁移...')
const { mainPages, subPackages } = loadAppConfig(outputDir)
if (!subPackages.length) {
console.log('[PackageComponentMovePlugin] 未配置分包,已跳过')
return
}
const dependencyMap = scanComponentDependencies(outputDir)
buildDeepDependencyTree(dependencyMap)
const flatDependencies = flattenDependencyTree(dependencyMap)
const componentOwnership = analyzeComponentOwnership(
flatDependencies,
subPackages,
mainPages
)
let moved = 0
let skipped = 0
const replacements = new Map()
const deleteList = []
Object.keys(componentOwnership).forEach(component => {
const owners = [...componentOwnership[component]]
if (this.isExcluded(component)) {
this.log('已排除:', component)
skipped++
return
}
if (owners.length > this.maxPackages) {
this.log(`跳过(被 ${owners.length} 个分包引用):`, component)
skipped++
return
}
const targetPackages = owners.filter(root => !isComponentInPackage(component, root))
if (!targetPackages.length) return
let allSuccess = true
targetPackages.forEach(pkg => {
const newComponentPath = `${pkg}/${component}`
const files = copyComponentToPackage(outputDir, component, newComponentPath)
if (!files.length) {
this.log('复制失败:', component)
allSuccess = false
return
}
this.log(`迁移:${component} => ${newComponentPath}`)
const scope = path.join(outputDir, pkg)
if (!replacements.has(scope)) replacements.set(scope, [])
replacements.get(scope).push({ old: component, new: newComponentPath })
})
if (allSuccess) {
const sourcePackage = subPackages.find(pkg => isComponentInPackage(component, pkg.root))
const keepSource = sourcePackage && owners.includes(sourcePackage.root)
if (!keepSource) deleteList.push(component)
moved++
}
})
console.log('[PackageComponentMovePlugin] 批量更新引用路径...')
replaceAllComponentReferences(outputDir, replacements)
deleteList.forEach(component => {
this.log('清理源文件:', component)
deleteSourceComponent(outputDir, component)
})
const duration = ((Date.now() - startTime) / 1000).toFixed(2)
console.log(`[PackageComponentMovePlugin] 完成!迁移=${moved} 跳过=${skipped} 耗时 ${duration}s`)
}
}
module.exports = PackageComponentMovePlugin
配套 vue.config.js 新版配置
javascript
const PackageComponentMovePlugin = require('./package-component-move-plugin')
module.exports = {
configureWebpack: {
plugins: [
new PackageComponentMovePlugin({
maxPackages: 1,
excludeList: [
'components/nav-bar',
'components/tab-bar'
],
debug: false
})
]
}
}