组件化方案的设计
# 02.组件化方案的设计
本篇定位:架构(01 篇)解决的是"代码该写在哪一层",组件化解决的是"代码该写在哪一个仓库 / 模块"。本文从一个真实的协作灾难讲起,回答三个问题——为什么要做组件化?业界三家做法怎么选?拆到什么粒度才算合理?
# 目录介绍
# 01.一个协作的灾难
# 1.1 团队的真实困境
某 App 团队从 2018 年发展到 2022 年,代码量从 30 万行涨到 180 万行,团队从 8 人扩到 45 人。业务还是那些业务,但每次发版越来越痛苦:
- 冷编译耗时:从最初的 90 秒涨到 18 分钟,开发改一行代码也要等 4 分钟增量编译
- 代码冲突:每周平均出现 12 次合并冲突,最严重的一次有 7 个团队改同一个 BaseActivity
- 回归成本:一个支付组的小改动,常常引发首页崩溃,全量回归测试要 2 天
- 新人上手:新员工平均需要 3 周才能独立提交代码
# 1.2 单体仓库的代价
把单体仓库的痛点解构一下:
flowchart TD
A[180 万行代码<br/>单一仓库] --> B[编译: 全量 18 分钟]
A --> C[协作: 7 人改同一个文件]
A --> D[发布: 任何改动都要全量发版]
A --> E[复用: 想抽出去给新 App 用?<br/>抽不出来]
B --> F[开发效率每年下降]
C --> F
D --> F
E --> G[战略上无法快速孵化新业务]
style A fill:#ffebee
style F fill:#ffebee
style G fill:#ffebee
2
3
4
5
6
7
8
9
10
11
12
13
14
这就是为什么"组件化"会成为大型 App 团队的必经之路——它解决的不是代码层面的问题,而是组织层面的问题。
# 1.3 组件化的契机
那个团队最终下决心做组件化的真正契机是:公司要孵化一个新 App,希望复用主 App 80% 的核心能力。结果发现核心能力全部和主 App 的业务代码混在一起,根本抽不出来。
这是一个非常典型的"被业务推着改"的故事——组件化往往不是技术选择,而是业务发展的必然。
# 02.要解决的核心矛盾
# 2.1 编译效率瓶颈
为什么单体工程的编译时间会指数级增长?因为编译器需要为每次改动都做"全图依赖分析"。代码量翻倍,依赖图边数可能翻 4 倍。
graph LR
A[代码量增长] --> B[文件数增长]
B --> C[依赖图边数 ∝ N²]
C --> D[编译时间指数级增长]
style D fill:#ffebee
2
3
4
5
6
组件化的破解:把代码拆成多个独立编译单元(aar / framework / npm 包),改动一个组件只编译这一个组件,其他直接用缓存。
# 2.2 协作冲突频发
3 个人改同一个 BaseActivity 必然会冲突。组件化通过"物理隔离"消除冲突——每个组件一个仓库(或一个目录),每个团队只在自己的组件里写代码。
graph TB
subgraph "单体仓库 - 协作冲突高"
T1[团队A] --> Code[共享代码库]
T2[团队B] --> Code
T3[团队C] --> Code
Code --> Conflict[冲突频发]
end
subgraph "组件化 - 物理隔离"
TA[团队A] --> CA[组件A]
TB[团队B] --> CB[组件B]
TC[团队C] --> CC[组件C]
CA --> Shell[App 壳]
CB --> Shell
CC --> Shell
end
style Conflict fill:#ffebee
style Shell fill:#e8f5e8
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 2.3 复用与解耦失衡
复用和解耦看似一对,其实是矛盾的。
- 强复用 意味着大量代码被多处依赖,一改全身动 → 高耦合
- 强解耦 意味着各模块互不依赖 → 难以复用
组件化通过接口下沉化解:把可复用的"能力"抽到下层基础组件(强复用),上层业务组件通过接口依赖(强解耦)。
# 2.4 组件化的本质
组件化 = 用物理边界(仓库 / 模块)固化逻辑边界(职责 / 依赖)
它解决的是大团队、大代码库下的组织复杂度和演进成本问题。如果团队 < 10 人或代码 < 30 万行,组件化的收益不如其成本。
# 03.业界主流方案
# 3.1 三种思路溯源
思路一:模块化(Module-based) 最早出现于 90 年代的桌面软件。把代码按"功能领域"分包(如 user/order/payment),每个包独立编译。代表:Java 的 module、Android 的 library module。重点是分包,不强调独立发布。
思路二:组件化(Component-based) 2014 年前后由阿里巴巴、美团等大型 App 团队推动。每个业务组件独立成一个仓库,可以独立编译、独立测试、独立发布 aar/framework。代表:阿里 ARouter、美团 WMRouter。重点是独立发布 + 路由通信。
思路三:微前端(Micro-frontend) 2016 年由 ThoughtWorks 提出,把后端微服务的思想搬到前端。每个子应用是独立技术栈、独立运行时、独立部署。代表:qiankun、single-spa、Module Federation。重点是运行时隔离 + 独立部署。
flowchart LR
A[1990s 模块化<br/>分包就行] --> B[2014 组件化<br/>独立仓库 + 路由通信]
B --> C[2016 微前端<br/>运行时隔离 + 独立部署]
style A fill:#e3f2fd
style B fill:#e8f5e8
style C fill:#fff3e0
2
3
4
5
6
7
每一代方案都比前一代隔离得更彻底,但成本也更高。
# 3.2 横向对比矩阵
| 维度 | 模块化 | 组件化 | 微前端 |
|---|---|---|---|
| 隔离粒度 | 分包 | 独立 aar/framework | 独立运行时 |
| 是否独立发布 | ❌ | ✅ | ✅ |
| 是否独立部署 | ❌ | ❌(仍是同一个 App) | ✅ |
| 技术栈是否统一 | 必须统一 | 必须统一 | 可不同 |
| 通信成本 | 极低(直接调用) | 中(路由 / SPI) | 高(postMessage / Custom Event) |
| 接入成本 | 低 | 中 | 高 |
| 典型场景 | 中小型 App / 后端 | 大型 App | 大型 Web / 多团队 SaaS |
| 代表方案 | Gradle module | ARouter / WMRouter | qiankun / Module Federation |
# 3.3 模块化与组件化
很多团队搞不清楚模块化和组件化的区别。一句话:
模块化偏复用,组件化偏解耦 + 独立发布
| 维度 | 模块化 | 组件化 |
|---|---|---|
| 关注点 | 代码逻辑划分 | 代码 + 编译 + 发布的全方位隔离 |
| 粒度 | 较粗(按业务领域) | 较细(按可独立交付的能力) |
| 典型形态 | 一个仓库下多个 module | 多个仓库 + 中央壳工程 |
| 能否独立运行 | 不能 | 可以单独跑成"组件壳" |
关键判断:如果你的团队还在为"代码该放哪个目录"纠结,先做模块化;如果已经为"编译慢 + 协作冲突"头疼,再上组件化。
# 04.组件化设计原则
# 4.1 单向依赖法则
组件化最容易踩的坑是循环依赖。一旦组件 A 依赖 B、B 依赖 A,独立编译就成了笑话。
graph TB
subgraph "❌ 错误: 循环依赖"
A1[组件A] -->|依赖| B1[组件B]
B1 -->|依赖| A1
end
subgraph "✅ 正确: 单向依赖"
A2[组件A] -->|依赖| Common[公共接口层]
B2[组件B] -->|依赖| Common
end
style A1 fill:#ffebee
style B1 fill:#ffebee
style Common fill:#e8f5e8
2
3
4
5
6
7
8
9
10
11
12
13
14
铁律:业务组件之间永远不能直接依赖,必须通过公共接口层或路由间接调用。
# 4.2 职责单一边界
每个组件应该只为"一类业务变化"负责。判断标准:如果这个组件需要改动,触发它改动的团队是不是同一个?
| 反例 | 问题 | 正确做法 |
|---|---|---|
| "通用业务组件"包含登录 + 支付 + 个人中心 | 三个团队都要改它,高频冲突 | 拆成 3 个独立组件 |
| "工具组件"既有日期工具又有网络工具 | 改网络要发布工具组件,影响所有使用方 | 工具按领域拆分 |
# 4.3 接口下沉策略
业务组件 A 想调用业务组件 B 的能力,怎么办?不要让 A 依赖 B,而是把 B 提供的能力抽成接口下沉到公共层。
graph TB
subgraph "业务层"
A[组件A]
B[组件B - 实现]
end
subgraph "接口层(公共)"
IB[B 的接口]
end
A -->|依赖接口| IB
B -->|实现接口| IB
Note[运行时通过 SPI / 路由<br/>把接口绑定到 B 的实现]
style IB fill:#fff3e0
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
好处:A 不再知道 B 的存在,可以独立编译;B 即使被替换为 B2,A 也无感知。
# 4.4 拆分粒度判断
拆得太粗 → 组件化等于没做;拆得太细 → 维护成本爆炸。判断标准:
| 信号 | 说明 | 应该拆 |
|---|---|---|
| 不同团队负责 | 两块代码归属不同团队 | ✅ 必须拆 |
| 不同发布节奏 | A 要日发,B 是季发 | ✅ 必须拆 |
| 不同业务变化 | 改 A 的需求和改 B 的需求来自不同业务 | ✅ 推荐拆 |
| 可独立复用 | B 可以拿到另一个 App 复用 | ✅ 推荐拆 |
| 依赖关系清晰 | A 和 B 之间几乎不互调 | ✅ 推荐拆 |
| 代码量都很小 | A 和 B 加起来才 200 行 | ❌ 不要拆 |
# 05.组件化架构落地
# 5.1 整体分层结构
graph TB
subgraph "应用层 Application Layer"
Shell[App 主壳<br/>路由注册 + 启动逻辑]
end
subgraph "业务组件层 Business Layer"
UserComp[用户组件]
PayComp[支付组件]
OrderComp[订单组件]
HomeComp[首页组件]
end
subgraph "接口层 Interface Layer"
IUser[IUserService]
IPay[IPayService]
IOrder[IOrderService]
end
subgraph "通用组件层 Common Layer"
UI[UI Kit]
Network[网络库]
Storage[存储库]
Router[路由库]
end
subgraph "基础设施层 Infrastructure Layer"
Platform[平台适配 / 工具]
end
Shell --> UserComp & PayComp & OrderComp & HomeComp
UserComp -.通过接口.-> IPay
UserComp --> UI & Network
PayComp --> UI & Network & Storage
OrderComp --> UI & Network
HomeComp --> UI & Router
UserComp -.实现.-> IUser
PayComp -.实现.-> IPay
OrderComp -.实现.-> IOrder
UI & Network & Storage & Router --> Platform
style Shell fill:#ffebee
style IUser fill:#fff3e0
style IPay fill:#fff3e0
style IOrder fill:#fff3e0
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
为什么这样分层:
- App 壳:薄薄一层,只做"路由注册 + 启动初始化",不写业务
- 业务组件层:彼此不直接依赖,通过接口层间接调用
- 接口层:所有跨组件调用的契约,必须最稳定
- 通用组件层:业务无关、可在任意 App 复用
- 基础设施层:平台/语言级能力
# 5.2 组件依赖规范
| 依赖方向 | 是否允许 | 说明 |
|---|---|---|
| 应用层 → 业务组件层 | ✅ | 主壳组装业务 |
| 业务组件层 → 业务组件层 | ❌ | 必须通过接口层 |
| 业务组件层 → 接口层 | ✅ | 跨组件调用走这里 |
| 业务组件层 → 通用组件层 | ✅ | 复用通用能力 |
| 通用组件层 → 业务组件层 | ❌ | 严格反向,否则复用没意义 |
| 通用组件层 → 通用组件层 | ⚠️ | 谨慎,避免循环 |
把这张表写进 CI 检查脚本(如 dependency analyzer),自动拦截违规依赖,是保证组件化不腐坏的关键。
# 5.3 组件拆分决策
flowchart TD
Start([候选拆分点]) --> Q1{有明确业务边界?}
Q1 -->|否| Merge[合并到相邻组件]
Q1 -->|是| Q2{属于不同团队?<br/>或不同发布节奏?}
Q2 -->|是| IndepComp[独立成业务组件]
Q2 -->|否| Q3{代码量 > 5000 行?}
Q3 -->|是| SubModule[作为子模块拆分]
Q3 -->|否| Q4{未来 6 个月<br/>会有新 App 复用?}
Q4 -->|是| CommonComp[沉淀为通用组件]
Q4 -->|否| Stay[保持现状]
style IndepComp fill:#e8f5e8
style CommonComp fill:#fff3e0
style Merge fill:#ffebee
style Stay fill:#e3f2fd
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 06.组件间通信方案
# 6.1 业务调用案例
来看一个真实场景,便于后续讨论选型:
用户在首页组件 A 看到一个商品,点击购买。系统需要:
- A 调用支付组件 C 发起支付流程
- 支付组件 C 调用用户组件 B 检查实名认证状态
- 支付组件 C 调用订单组件 D 创建订单
- 订单组件 D 完成后通知首页组件 A 刷新角标
这个场景同时涉及"页面跳转""能力调用""跨组件通知",非常典型。
# 6.2 三种通信方式
业界主流的组件间通信只有三种:
| 方式 | 用途 | 典型代表 |
|---|---|---|
| 路由(Router) | 页面跳转 + 携带参数 | ARouter / WMRouter / Vue Router |
| 服务发现(SPI) | 调用其他组件的"能力" | Java SPI / ARouter Service |
| 事件总线(EventBus) | 一对多的通知 | EventBus / RxBus / LiveEventBus |
核心原则:三者各司其职,不要混用。最常见的反例是用 EventBus 处理本该用 SPI 的能力调用,导致依赖关系完全失控。
# 6.3 路由方案落地
路由解决"我要跳到另一个组件的页面"。本质是用字符串 URL 替代类引用,从而切断编译期依赖。
// 注册(在组件 B 中)
@Route(path = "/user/profile")
class ProfileActivity { ... }
// 调用(在组件 A 中,A 不需要依赖 B)
Router.navigate("/user/profile", { userId: "123" })
2
3
4
5
6
关键优势:A 完全不知道 ProfileActivity 在哪里,B 可以随意改实现。
# 6.4 SPI 服务发现
SPI 解决"我要调用另一个组件的能力(不跳页面)"。本质是接口定义在公共层,实现注册到全局服务表。
// 接口(在公共接口层)
interface IUserService {
isVerified(userId: string): boolean;
}
// 实现(在用户组件 B)
@Service
class UserServiceImpl implements IUserService { ... }
// 调用(在支付组件 C,C 只依赖接口层)
const userService = ServiceRegistry.resolve<IUserService>("IUserService");
const verified = userService.isVerified("123");
2
3
4
5
6
7
8
9
10
11
12
关键优势:调用方只依赖接口,运行时才绑定实现,可以做 Mock、可以热替换。
# 6.5 事件总线场景
EventBus 解决"我做完一件事,要通知所有关心它的组件"。本质是发布订阅模式。
// 订阅(在首页组件 A)
EventBus.on("ORDER_CREATED", (orderId) => refreshBadge());
// 发布(在订单组件 D)
EventBus.emit("ORDER_CREATED", orderId);
2
3
4
5
适用场景:一对多通知、解耦弱关系。 绝对禁忌:不要用 EventBus 做"请求-响应"——例如 A emit("getUser") 然后等 B emit("getUserResp")。这种用法 3 个月后就会变成"事件流满天飞"的灾难。
# 6.6 选型对比矩阵
| 场景 | 路由 | SPI | EventBus |
|---|---|---|---|
| 页面跳转 | ✅ 首选 | ❌ | ❌ |
| 同步调用能力 | ❌ | ✅ 首选 | ❌ |
| 异步广播通知 | ❌ | ❌ | ✅ 首选 |
| 一对一精确调用 | ⚠️ | ✅ | ❌ |
| 一对多通知 | ❌ | ❌ | ✅ |
| 是否阻塞调用方 | 是 | 是 | 否 |
| 是否需要返回值 | 否 | 是 | 否 |
回到 6.1 的真实场景:
| 步骤 | 选用方式 | 原因 |
|---|---|---|
| 1. A 调用 C 发起支付 | 路由 | 是页面跳转 |
| 2. C 调用 B 查实名 | SPI | 是同步能力调用,需要返回值 |
| 3. C 调用 D 创建订单 | SPI | 同上 |
| 4. D 通知 A 刷新角标 | EventBus | 一对多广播,A 不阻塞 D |
# 07.组件化常见陷阱
# 7.1 过度拆分反例
反例:某 30 万行代码的 App 被拆成 87 个组件,平均每个组件 3500 行。
问题:
- 一个简单业务需求要改 5 个组件,组件间通信代码比业务代码还多
- CI 跑 87 个组件的发布流水线,发版从 30 分钟变成 2 小时
- 新人光看组件依赖图就要 1 周
教训:组件数量应该和团队数量匹配。1 个组件 ≈ 1 个团队 ≈ 1 万 ~ 3 万行代码是相对舒服的尺度。
# 7.2 循环依赖反例
反例:用户组件依赖订单组件查"用户订单数",订单组件依赖用户组件查"用户信息"。
问题:编译时就报错,无法独立发布任何一个。
正确做法:
graph TB
User[用户组件] -.依赖.-> IOrder[IOrderService 接口]
Order[订单组件] -.实现.-> IOrder
Order -.依赖.-> IUser[IUserService 接口]
User -.实现.-> IUser
style IOrder fill:#fff3e0
style IUser fill:#fff3e0
2
3
4
5
6
7
8
9
两个接口都下沉到接口层,业务组件之间永远只通过接口层互调。
# 7.3 接口爆炸反例
反例:团队规定"所有跨组件调用必须走 SPI",结果接口层堆了 240 个接口,新人完全不知道有什么能力可用。
教训:接口层也需要治理:
- 按"领域"分包(user / order / payment / common)
- 接口必须有 README,写清楚谁实现、谁调用
- 已废弃的接口要明确
@Deprecated并给出迁移路径
# 7.4 版本治理反例
反例:30 个组件各自发版本,主壳工程的依赖文件长达 300 行版本号,经常出现"组件 A 的 1.2 版本和组件 B 的 3.5 版本不兼容"。
正确做法:引入 **BOM(Bill of Materials)**机制——主壳只声明一个 BOM 版本,BOM 里固定一组互相兼容的组件版本。这是 Spring 和阿里开源系列普遍采用的方案。
mindmap
root((组件化常见陷阱))
过度拆分
87 个组件
通信代码比业务多
发版流水线爆炸
循环依赖
A 依赖 B B 依赖 A
无法独立发布
接口爆炸
接口层失控
没人知道有什么接口
版本治理
手动维护 300 个版本号
经常版本冲突
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 08.组件化演进路线
组件化不是一次性工程,而是 4 个阶段的渐进改造。下面是开篇那个 45 人团队的真实演进路径。
# 8.1 V1 单体起步
规模:8 人团队、30 万行代码、1 个仓库
结构:
graph LR
A[单一仓库] --> B[按 MVP 分层]
B --> C[按业务分包]
style A fill:#e3f2fd
2
3
4
5
痛点:暂时没有,编译 90 秒可接受。 何时该升级:编译时间 > 5 分钟、团队 > 12 人。
# 8.2 V2 模块化分包
规模:18 人团队、80 万行代码、1 个仓库 + 多 module
结构:
graph TB
App[App 壳]
UI[ui-kit]
Net[network]
Util[utils]
Biz[business 业务包]
App --> Biz
Biz --> UI & Net & Util
style App fill:#ffebee
style UI fill:#e8f5e8
2
3
4
5
6
7
8
9
10
11
12
做了什么:抽出基础 module(UI / 网络 / 工具),但业务还在一个大 module 里。 带来的收益:基础设施跨项目复用、编译时间降到 8 分钟。 何时该升级:业务还在打架、编译时间又涨到 15 分钟、有跨 App 复用需求。
# 8.3 V3 组件化拆分
规模:35 人团队、140 万行代码、N 个仓库
结构:
graph TB
Shell[App 壳]
User[用户组件]
Pay[支付组件]
Order[订单组件]
Home[首页组件]
IF[接口层]
Common[通用层<br/>UI/网络/路由/存储]
Shell --> User & Pay & Order & Home
User & Pay & Order & Home --> IF
User & Pay & Order & Home --> Common
style Shell fill:#ffebee
style IF fill:#fff3e0
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
做了什么:业务拆成 4 个独立仓库,引入路由 + SPI + EventBus 三件套。 带来的收益:4 个团队完全并行、编译降到 3 分钟(独立组件 50 秒)、新 App 用 2 周就能搭起来。 带来的痛:通信成本上升、版本治理成为问题。
# 8.4 V4 组件治理
规模:45 人团队、180 万行代码、12 个组件仓库
做了什么:
- 引入 BOM 统一版本
- 接口层按领域分包并强制 README
- CI 加上"违规依赖检测",自动拦截
- 每个组件独立 demo App,可单独运行调试
gantt
title 真实团队的组件化演进 (4 年)
dateFormat YYYY-MM
section V1 单体
单仓库 + MVP 分层 :2018-01, 2019-06
section V2 模块化
抽离基础 module :2019-06, 2020-12
section V3 组件化
业务组件独立仓库 :2020-12, 2022-03
路由 + SPI + EventBus :2021-06, 2022-03
section V4 治理
BOM 版本管理 :2022-03, 2022-09
CI 依赖检测 :2022-06, 2022-12
独立调试 demo :2022-09, 2023-03
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
⚠️ 关键提醒:这条演进路线走完用了 4 年。任何想"一年搞定组件化"的计划都是危险的——业务还在跑,改造必须渐进。
# 09.总结与决策
# 9.1 拆分检查清单
每次决定要不要拆一个组件,对照下面这张清单打分(≥ 3 项为是再拆):
- [ ] 这块代码归属一个独立的团队
- [ ] 它有自己独立的发布节奏(如日发 vs 月发)
- [ ] 它的代码量 > 5000 行且仍在增长
- [ ] 未来 6 个月有跨 App 复用预期
- [ ] 它和其他业务的耦合点 ≤ 5 个
- [ ] 它的接口契约稳定,半年改不超过 2 次
# 9.2 选型决策树
flowchart TD
Start([我的项目要做组件化吗?]) --> Q1{团队 < 8 人<br/>且代码 < 30 万行?}
Q1 -->|是| NoNeed[不需要<br/>分包就够]
Q1 -->|否| Q2{编译时间 > 10 分钟<br/>或冲突严重?}
Q2 -->|否| Mod[做模块化即可]
Q2 -->|是| Q3{有跨 App 复用<br/>或独立发布需求?}
Q3 -->|是| Full[完整组件化<br/>路由 + SPI + EventBus]
Q3 -->|否| Light[轻量组件化<br/>同仓库多 module]
Full --> Q4{规模超 30 人<br/>组件超 10 个?}
Q4 -->|是| Govern[加治理层<br/>BOM + CI 检测]
Q4 -->|否| Stay1[保持现状]
style NoNeed fill:#e3f2fd
style Mod fill:#e8f5e8
style Light fill:#fff3e0
style Full fill:#ffebee
style Govern fill:#f3e5f5
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
最后一句话:组件化不是技术潮流,是当业务规模逼到你不得不做时才做的事。回到本文开头那个 45 人团队,他们花 4 年走完 V1→V4,真正的胜利不是组件多漂亮,而是孵化新 App 从 6 个月缩短到 6 周。这才是组件化的商业价值。
好的组件化 = 让大团队像小团队一样灵活,让大代码库像小代码库一样好改。