uniapp-解放主包,组件下沉分包插件

在 uni-app 小程序优化中,主包体积超限 是开发者最头疼的问题。除了共享 JS 模块,自定义组件更是主包体积的重灾区:

很多组件明明只被分包使用、主包从未引用,却依然被打包在主包内,白白占用主包体积。官方分包策略无法自动处理这类组件,只能手动拆分,极易出错、维护成本极高。

编写次 插件:全自动扫描、智能分析、一键将「仅分包使用的组件」从主包移动到对应分包,彻底解放主包体积,全程无需修改业务代码、无侵入、无风险。

一、插件解决了什么核心问题?

痛点场景

  1. 组件被分包使用,但主包未使用 → 依然打包进主包
  2. 大量自定义组件、uni-ui 组件、自定义弹窗 / 表单组件堆积主包
  3. 主包体积超标,无法发布小程序
  4. 手动移动组件工作量大、路径修改繁琐、容易引发报错
  5. 组件深层依赖(组件引用组件)无法自动处理

二、核心工作流程(极简理解)

  1. 读取 app.json → 获取主包页面 + 所有分包配置
  2. 全量扫描 JSON → 构建页面 / 组件「引用关系地图」
  3. 递归解析依赖 → 找到组件的所有深层子组件
  4. 智能筛选
    • 必须:仅分包使用
    • 必须:主包未使用
    • 可配置:最多被 N 个分包使用
  5. 自动复制组件 → 到目标分包目录
  6. 批量替换路径 → 所有引用自动更新
  7. 删除主包源文件 → 完成主包瘦身

三、完整插件代码(开箱即用)

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
      })
    ]
  }
}
相关推荐
窝子面1 天前
uni-app的初体验
uni-app
笨笨狗吞噬者1 天前
【uniapp】微信小程序实现自定义 tabBar
前端·微信小程序·uni-app
雪芽蓝域zzs1 天前
uniapp MD5加密 加密传输 密码加密
uni-app
2501_915909061 天前
iOS 抓包不越狱,代理抓包 和 数据线直连抓包两种实现方式
android·ios·小程序·https·uni-app·iphone·webview
给钱,谢谢!1 天前
记录uni-app Vue3 慎用 Teleport,会导致页面栈混乱
前端·vue.js·uni-app
TON_G-T1 天前
深入学习webpack-tapable
前端·学习·webpack
郑州光合科技余经理1 天前
海外O2O系统源码剖析:多语言、多货币架构设计与二次开发实践
java·开发语言·前端·小程序·系统架构·uni-app·php
烈焰飞鸟2 天前
iconfont 在 uni-app 项目中的完整使用指南
vue.js·uni-app·iconfont
En^_^Joy2 天前
Node.js开发指南:模块、npm与Webpack
webpack·npm·node.js