组件化思路:从页面复制到可复用组件
系列:UI 与交互篇·第 1/6 篇
做业务 UI 时,最常见的捷径是:需求来了 → 打开旧页面 → 复制一整段 build → 改文案改颜色改接口。短期能交货,两周后同款卡片出现在三个页面,状态各写一份,动一个间距要改三遍,这才是「技术债」的真实利息。
这篇把「复制页面」升级成「可复用组件」的套路讲清楚:什么时候该抽、抽到哪一层、props 怎么定,让后来的需求改在组件上自动传导到所有引用点。
1. 问题背景:业务场景 + 现象
- 场景:列表卡片、空态占位、带角标的头像区、底部操作条、筛选项一行等,在产品迭代里反复出现变体。
- 现象 :
- 多处
Column/Row结构几乎相同,只有边距、图标、回调不同。 - 同一视觉在 A 页用
GestureDetector,B 页用InkWell,点击态不一致。 - 「 small 改一下」要 grep 好几个文件,还容易漏改。
- 新人不敢动大块页面文件,只能继续复制。
- 多处
目标不是「每个 Widget 都要抽象成库」,而是在重复第三次之前,把稳定结构收拢成组件。
2. 原因分析:核心原理 + 排查过程
2.1 Flutter 里「组件」到底是什么
在工程语境下,可复用单元通常是:无业务路由知识、少副作用、通过构造函数明确输入输出的 Widget(+ 可选的 Controller/Style)。页面负责拼装与导航;组件负责一块 UI 的展示与局部交互。
2.2「只复制页面」会坏在哪里
| 根因 | 表现 |
|---|---|
| 没有稳定边界 | 样式、文案、监听散落在页面 State |
| 隐式约定 | 「和首页一样」靠口头,不靠 API |
| 不可组合 | 无法把同一卡片嵌进列表/弹窗/横滑 |
2.3 自检:该不该抽成组件
满足任意两条就值得抽:
- 同一结构在 2+ 页面出现或即将出现。
- 有一处 设计规范(圆角、字号、间距)要统一收口。
- 需要单独 单测 / Goldens 覆盖一小块 UI。
- 同一文件
build超过 ~150 行且可读性明显下降。
3. 解决方案:方案对比 + 最终选择
3.1 三种常见抽象层级
A. 原子级(Atom)
PrimaryButton、AppAvatar:只解决视觉与交互基线。适合设计系统。
B. 模块级(Molecule / 业务区块)
RoomSeatTile、UserStatsHeader:带业务语义,但仍不直连全局单例,通过回调/参数注入。
业务项目里 80% 的「从复制到复用」落在这层。
C. 模板级(Template / Page Section)
Scaffold + AppBar + 统一 padding:少而精,避免过早抽象整页。
3.2 最终选择(推荐口径)
- 先抽「结构 + 可变点」 :把不变的骨架留在组件内;把文案、资源、回调、
bool isEnabled等放到构造参数。 - 样式用
Theme/ 设计令牌收口(本篇不深展开,同系列第 4 篇会继续)。抽组件时至少避免魔法数字散落在三个文件。 - 禁止组件里
Navigator.push写死路由名 (除非你就是壳工程里的路由封装);改成onTap、VoidCallback? onMore让页面接线。 - 状态策略 :展示型用
StatelessWidget+ 外部数据;需要动画/展开收起再升StatefulWidget;和列表滚动强相关再考虑把 Controller 外置。
4. 关键代码:最小必要片段
4.1 反面:页面内联一大段「将来会复制」的 UI
dart
// page_profile.dart(示意:能跑但难复用)
Widget build(BuildContext context) {
return Column(
children: [
Row(
children: [
CircleAvatar(radius: 28, backgroundImage: NetworkImage(avatarUrl)),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisConversion: CrossAxisAlignment.start,
children: [
Text(nickname, style: Theme.of(context).textTheme.titleMedium),
Text(bio, style: Theme.of(context).textTheme.bodySmall),
],
),
),
IconButton(onPressed: onEdit, icon: const Icon(Icons.edit_outlined)),
],
),
// ... 下面重复结构又出现在「房间成员列表头」
],
);
}
问题:Row 骨架重复;edit 行为绑死在页面。
4.2 正面:抽出「模块级」组件,显式 API
dart
/// 模块级:ProfileHeaderBar
/// - 不负责拉取头像 URL,只展示
/// - 编辑/更多通过回调交给页面(接线层)
class ProfileHeaderBar extends StatelessWidget {
const ProfileHeaderBar({
super.key,
required this.avatarUrl,
required this.title,
this.subtitle,
this.onEdit,
this.trailing,
});
final String avatarUrl;
final String title;
final String? subtitle;
final VoidCallback? onEdit;
final Widget? trailing;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
CircleAvatar(
radius: 28,
backgroundImage: NetworkImage(avatarUrl),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(title, style: theme.textTheme.titleMedium),
if (subtitle != null)
Text(subtitle!, style: theme.textTheme.bodySmall),
],
),
),
if (trailing != null) trailing!,
if (onEdit != null)
IconButton(
onPressed: onEdit,
icon: const Icon(Icons.edit_outlined),
),
],
);
}
}
页面里只剩「拼数据 + 导航」:
dart
ProfileHeaderBar(
avatarUrl: user.avatarUrl,
title: user.nickname,
subtitle: user.bio,
onEdit: () => context.push(EditProfileRoute(user.id)),
)
4.3 需要可选变体时:用命名构造或 small API,不要复制类
dart
class ProfileHeaderBar extends StatelessWidget {
const ProfileHeaderBar.compact({
super.key,
required this.avatarUrl,
required this.title,
this.subtitle,
this.onEdit,
}) : dense = true,
trailing = null;
// ...
final bool dense;
// dense 为 true 时缩小 avatar、字号 ------ 仍是一个组件
}
原则:变体是同一组件的参数组合 ,而不是再复制一个 ProfileHeaderBar2。
5. 效果验证:数据 / 截图 / 日志
工程向的验证可以这样落:
| 维度 | 做法 | 预期 |
|---|---|---|
| 修改成本 | 改一处圆角/间距,全局引用点同步 | grep 组件名,引用 ≥2 |
| CR 可读性 | PR 中页面 diff 主要是数据与回调 | build 行数下降 |
| 一致性 | 设计走查:同组件无不一致点击态 | 统一 InkWell/Material |
| 回归 | 可选:对组件做 Golden test | 改视觉时有基线对比 |
6. 可复用结论:通用经验 + 避坑清单
经验:
- 第三次重复再抽往往太晚;第二次出现就要评估接口。
- 组件 API 要像小函数签名 :参数少而准,复杂对象用
ValueKey+ 深比较要小心。 - 页面是编排(orchestration),组件是表达(presentation)------导航、埋点、权限可在页面层组合。
避坑:
- 把
Provider/Riverpod的ref.watch写进「本应通用的」小组件里,导致任何页面一引用就绑全局。 -
context滥用:MediaQuery、Theme在子树覆写时行为诡异;需要可考虑把EdgeInsets等由上层传入。 - 一个 500 行的
God Widget:先纵向拆文件,再横向抽模块。 - 仅为了「看起来优雅」抽原子,却导致 20 个参数;宁可保留一层「业务区块」适度综合。
下期预告:UI 与交互篇(2/6)------复杂列表性能优化(卡顿定位与修复)。