Skip to content

分包循环依赖

结论先行

分包循环依赖的本质是:模块提升规则 + 交叉引用 = 依赖图出现环

uni-app 构建时,被多个分包引用的模块会自动提升到主包。如果两个分包互相引用对方的模块,或者主包引用了分包中已经被提升的模块——依赖图里出现环——构建行为变得不可预测。轻则 vendor.js 异常膨胀,重则直接编译报错或运行时模块未定义。

解决不是"改一行配置",是切断依赖环。而切断的前提是先找到环。


模块提升机制:循环依赖的根源

uni-app 构建时的模块归属规则

uni-app 编译小程序时,内部是一套 webpack 构建链。模块归属遵循:

text
对于一个 JS 模块(组件 / util / mixin / store module):

1. 如果只被主包的页面引用 → 留在主包
2. 如果只被分包 A 的页面引用 → 留在分包 A
3. 如果被主包 + 分包 A 同时引用 → 提升到主包
4. 如果被分包 A + 分包 B 同时引用 → 提升到主包
5. 如果被主包 + 分包 A + 分包 B 同时引用 → 提升到主包

规则 3-5 统称"模块提升"(module hoisting)。提升的模块被打进主包的 common/vendor.js

这是合理的——微信小程序的分包之间不能直接引用对方的代码,浏览器的 require 在分包边界上是隔离的。所以共享代码必须放在主包。

提升链如何产生循环

问题出在:提升不是只看直接引用,而是看完整依赖链

text
场景:
  pages-sub/order/util/format.js  ← 在 order 分包
    → 引用了 @/utils/request.js   ← 在 main
  pages/main/home/index.vue       ← 在 main
    → 引用了 pages-sub/order/util/format.js  ← 在 order 分包

构建分析:
  home/index.vue(主包)引用了 format.js(分包)
    → format.js 满足"被主包引用" → 提升到主包
  format.js 引用了 request.js(主包)
    → request.js 已经在主包 → 无影响
  format.js 提升到主包后,主包的模块集合包含了 format.js
    → 如果 request.js 反过来也 import 了 format.js 中的某个导出
      → 循环依赖:request.js ↔ format.js

单看每个 import 都是合理的。但提升改变了模块的物理位置,使得原本在不同包中的"交叉引用"变成了同一包中的"循环引用"。


五类常见循环依赖场景

场景 1:公共组件回引分包模块

text
components/UserCard.vue     ← 主包公共组件
  → import { formatUser } from '@/pages-sub/user/util/user-helper'
                                 ↑ user 分包的模块

pages-sub/user/pages/profile.vue
  → import UserCard from '@/components/UserCard.vue'

UserCard 在主包,引用了分包的 util。profile.vue 在分包,引用了主包的组件。构建时:

  1. user-helper.js 被主包组件引用 → 提升到主包
  2. UserCard.vue 原本就在主包 → 无变化
  3. user-helper.js 在分包中如果还引用了该分包的其他模块 → 那些模块也被递归提升
  4. 如果 user-helper.js 中还引用了 @/utils/common.js,而这个 common.js 又间接引回了 UserCard → 形成环

修复:主包公共组件不应直接 import 分包模块。将 formatUser 移到主包的 utils/ 下,或将其逻辑内联到组件中。

场景 2:mixin / composable 的交叉依赖

text
mixins/auth-mixin.js         ← 主包
  → import { checkLogin } from '@/pages-sub/user/api/login'

pages-sub/user/pages/settings.vue   ← 分包
  → import authMixin from '@/mixins/auth-mixin'

mixin 在主包,引用了分包的 API。分包的页面引用了 mixin。这个比场景 1 更隐蔽——mixin 通常被多处使用,修改 mixin 的 import 需要确认所有使用处的影响。

修复:将 checkLogin 等被主包 mixin 引用的 API 模块提取到主包的 api/services/ 目录下。原则:被主包模块引用的东西,物理上就必须在主包

场景 3:store module 的跨包引用

text
store/modules/cart.js           ← 主包(vuex/pinia store)
  → import { applyPromo } from '@/pages-sub/promo/util/calculator'

pages-sub/promo/pages/index.vue  ← 分包
  → import { useCartStore } from '@/store/modules/cart'

Store 在主包是一种常见架构选择(全局状态自然应该跟随主包)。但如果 store 引用了分包模块,每个引用该 store 的分包页面都会触发该分包模块的提升。

修复:store module 中只引用主包模块。如果 store 需要分包中的数据计算逻辑,将计算函数提取到主包,或通过 action 的 payload 传入数据而非直接在 store 内 import 分包代码。

场景 4:工具函数的单向引用错觉

text
// 分包 order 的 util
// pages-sub/order/util/price.js
import { request } from '@/utils/request'     // 主包 ← 方向:分包 → 主包,OK
export function calcTotal(items) { /* ... */ }

// 主包页面
// pages/home/index.vue
import { calcTotal } from '@/pages-sub/order/util/price'  // 主包 → 分包,触发提升

初看没有循环:分包 util → 主包 request,主包页面 → 分包 util。这是一个简单的提升场景,不形成环——price.js 被提升到主包,request 本来就在主包,两者都在主包中,无环。

但是,如果 request.js(主包)反过来也依赖了已被提升到主包的模块,而这个模块又引用了 request 中的其他导出——就形成环。这种情况往往不是一次写出来的,是多次迭代后逐步形成的:某次重构把一段逻辑从 request.js 抽到了 price.js,导致 request.js 没有显式 import price.js,但逻辑上 price.js 在被提升后和 request.js 处于同一作用域。

修复:这是最隐蔽的一类。需要完整走一遍依赖图分析才能发现。可以用 madge 工具生成依赖图,查找环。

场景 5:动态 import 引入的隐式循环

js
// 主包页面
const Comp = () => import('@/pages-sub/order/components/OrderDetail.vue')

动态 import 在运行时才加载,webpack 会将其打包为独立的 chunk(微信小程序中为独立的分包文件)。但如果这个动态 import 的组件内部通过静态 import 引用了主包模块,而那个主包模块又通过其他路径引用了分包的某个模块(已经被提升到主包)——构建时的依赖分析仍然可能在编译阶段报循环依赖警告。

关键点:动态 import 不会打破编译时的依赖分析。webpack 仍然需要分析被动态 import 模块的完整依赖链。动态 import 改变的只是加载时机,不是依赖图的拓扑结构。


如何排查和定位

1. 看构建警告

uni-app 编译微信小程序时,webpack 会输出循环依赖警告:

text
WARNING in Circular dependency detected:
  src/utils/request.js -> src/pages-sub/order/util/price.js -> src/utils/request.js

这个警告一定不要忽略。警告中给出的路径链就是依赖环。从路径链的最后一环往回追溯,找到引入环的那条 import 语句。

2. madge 分析依赖图

bash
npx madge --circular --extensions js,vue src/

输出所有循环依赖的列表。对于分包场景,重点关注跨 pages-sub/pages/(或 components/utils/ 等主包目录)之间的环。

3. 分析 vendor.js 体积构成

bash
# uni-app 构建后
ls -lh dist/build/mp-weixin/common/vendor.js

# 如果 vendor.js 超过 500KB,值得拆开看内容
# webpack-bundle-analyzer 对 uni-app 小程序构建产物不直接支持
# 替代方案:在 vue.config.js 中开启 webpack 的 stats 输出
js
// vue.config.js
module.exports = {
  configureWebpack: {
    plugins: [
      new (require('webpack-bundle-analyzer').BundleAnalyzerPlugin)({
        analyzerMode: 'static',
        reportFilename: 'bundle-report.html',
      })
    ]
  }
}

通常 vendor.js 中体积异常的模块就是被过度提升的模块——追踪它被哪些入口引用,找到跨包引用的那条链路。

4. 在依赖图中标注分包边界

维护一个依赖图的思维模型:

text
主包 = pages/ + components/ + utils/ + store/ + api/
分包A = pages-sub/A/ + ...
分包B = pages-sub/B/ + ...

在这个图上,合法的 import 方向:

  • 分包 → 主包 ✓
  • 主包 → 主包 ✓
  • 分包 A → 分包 B ✗(除非通过动态 import,且 runtime 层面各自独立)
  • 主包 → 分包 ✗(触发提升,可能引入循环)

两个实际修复案例

案例 A:公共表单组件回引分包校验逻辑

问题:

text
components/FormValidator.vue  →  import { rules } from '@/pages-sub/order/validate/rules'
pages-sub/order/pages/checkout.vue  →  import FormValidator from '@/components/FormValidator.vue'
pages-sub/user/pages/address.vue  →  import FormValidator from '@/components/FormValidator.vue'

rules 被主包组件引用 → 提升到主包。但 rules 本身依赖分包的多个常量定义文件 → 这些也被递归提升。3 个文件被从分包提升到主包,主包增加了约 8KB。虽然没有形成环(因为被提升的模块都留在主包了,没有回引分包的路径),但主包体积被悄悄增加。

修复方案:

text
components/FormValidator.vue
  → 改为接收 rules 作为 prop,不再内部 import 分包模块

pages-sub/order/pages/checkout.vue
  → import { rules } from './validate/rules'
  → <FormValidator :rules="rules" />

原则:可配置的数据通过 prop 传入,不要在公共组件中硬引用分包模块。这既避免了不必要的模块提升,也让组件更可复用。

案例 B:请求拦截器中引用分包的 token 刷新逻辑

问题:

text
utils/request.js(主包)
  → import { refreshToken } from '@/pages-sub/user/api/auth'
  → 401 响应时自动调用 refreshToken 刷新

pages-sub/user/pages/login.vue
  → import request from '@/utils/request'

这是最常见的一类。request 拦截器在主包,引用了分包的刷新 token 逻辑。构建时的处理:auth.js 被提升到主包。如果 auth.js 中还引用了 store 或其他主包模块,且这些模块又间接引用 request 中的方法(如 request.get)→ 形成环。

修复方案:不改变 request 拦截器的位置(它必须在主包),但将 token 刷新逻辑移到主包:

text
utils/request.js              ← 主包
  → 401 → 触发刷新逻辑

utils/auth-refresh.js         ← 主包(从分包移出来)
  → 包含 refreshToken 函数
  → 内部引用 request.js 的原始方法(避免循环)

原则:request 拦截器是主包的基础设施,它依赖的任何模块都必须在主包。基础设施层不引用业务层代码。


构建产物的差异:有循环 vs 无循环

对于同一个模块,循环依赖存在与否影响构建产物:

text
无循环依赖(正常提升):
  dist/build/mp-weixin/
    common/vendor.js          ← 被提升的 price.js 在这里
    pages-sub/order/
      list/index.js           ← 引用 vendor.js 中的模块(不重复打包)

有循环依赖时,webpack 可能的行为:
  1. 将环中的一个模块复制到两个位置(破坏单例,运行时可能行为异常)
  2. 将整条环上的模块全部提升到主包(vendor.js 膨胀)
  3. 直接报错(如果 webpack 的 splitChunks 配置严格)

行为不确定是核心问题——同样的代码在不同 webpack 版本或配置下产出物可能不同。不要在"这次构建通过了就没事"上依赖。


预防规则

  1. 主包不 import 任何以 pages-sub/ 开头的路径。 如果发现自己在写 import ... from '@/pages-sub/...' 在一个主包文件中,停下来。这条 import 属于基础设施层引用业务层,方向反了。

  2. 分包公共模块明确归属。 如果一段代码被两个分包都需要,它不属于任何一个分包——属于主包。直接把它放在 utils/common/ 下。

  3. 公共组件不硬编码分包路径。 通过 props、slots、provide/inject 传递差异部分。组件保持对分包的无感知。

  4. CI 中加入循环依赖检测。

    bash
    npx madge --circular --extensions js,vue src/ && echo "No circular dependencies" || exit 1
  5. 定期审查 vendor.js 体积。 vendor.js 单次增长超过 20KB 时,review 新增了哪些模块——通常是某次 PR 引入了跨包引用。