HarmonyOS应用《民族图鉴》开发第48篇:自定义组件深度封装——从0到1打造高质量可复用组件

📖 引言
组件化是现代前端开发的核心思想。一个项目的代码质量,很大程度上取决于组件抽象得好不好。
组件抽得好——复用率高、一致性强、改起来方便,一个人顶三个人用;
组件抽得烂——到处复制粘贴、样式不一致、改一个地方炸十个地方,三个人顶一个人用。
但很多开发者对"自定义组件"的理解,停留在"把一段代码抽出来,传几个参数"的层面。结果就是:组件越写越臃肿,props 越传越多,逻辑越来越乱,最后还不如不抽。
一个高质量的自定义组件,应该是什么样的?
- 接口清晰:看 props 就知道怎么用,不用读源码
- 职责单一:一个组件只做一件事,做好一件事
- 易于扩展:新增需求不用大改,加个 props 就行
- 性能优良:不会因为用了组件反而更卡
- 风格统一:和项目整体视觉、交互保持一致
本文我们就以「民族图鉴」项目中的几个典型组件为例,从设计思路到实现细节,从 Props 设计到事件定义,从插槽使用到样式定制,带你完整走一遍"从零打造一个高质量组件"的全过程。
🎯 学习目标
完成本文后,你将能够:
- ✅ 掌握自定义组件的设计原则与拆分方法论
- ✅ 学会 Props 的设计技巧:类型、默认值、校验、必填
- ✅ 掌握自定义事件的定义与传参规范
- ✅ 理解插槽(Slot/Builder)的使用场景与最佳实践
- ✅ 学会组件的样式定制与主题适配
- ✅ 深入组件的生命周期与状态管理策略
- ✅ 掌握组件性能优化的关键技巧
- ✅ 避开组件封装的常见坑,写出优雅、可复用的组件
💡 需求分析
什么时候该抽组件?
这是第一个要想清楚的问题——不是所有代码都值得抽成组件。
该抽组件的信号:
- 重复出现:同样的 UI 结构出现了 3 次以上
- 职责独立:有明确的功能边界,可以单独理解和测试
- 复杂度高:代码太长(超过 200 行),影响可读性
- 需要复用:多个页面/模块都会用到
不该抽组件的信号:
- 只用一次:只在一个地方出现,抽了反而增加理解成本
- 和父组件强耦合:传一大堆 props,逻辑都在父组件
- 只是代码长:逻辑紧密耦合,拆分反而割裂
- 过早抽象:需求还不稳定,抽了以后天天改
💡 组件拆分三问:
- 它有独立的职责吗?
- 它会在多个地方复用吗?
- 它的接口清晰吗?(调用方需要知道多少内部细节?)
三个问题有两个以上的答案是"是",再考虑抽组件。
组件设计的原则
1. 单一职责原则(SRP)
一个组件只做一件事。如果一个组件叫 UserCard,它就只负责展示用户卡片,不要在里面放登录逻辑、数据请求、状态管理。
2. 开闭原则(OCP)
对扩展开放,对修改关闭。新增需求应该通过加 props、加插槽来实现,而不是改原有代码。
3. 迪米特法则(Law of Demeter)
组件只和它的直接"朋友"通信。不要在子组件里直接改父组件的状态,不要在组件里直接调用全局 store(除非是全局状态组件)。
4. 可预测性
同样的输入,永远产生同样的输出。组件的行为是可预测的,不会偷偷干一些意料之外的事情。
「民族图鉴」的组件体系
「民族图鉴」项目的组件按层级可以分为三类:
| 层级 | 特点 | 示例 | 复用范围 |
|---|---|---|---|
| 基础组件 | 原子级,不带业务逻辑 | Button、Tag、Avatar、Divider | 全项目 |
| 业务组件 | 带业务数据结构,但通用 | EthnicCard、MusicPlayerBar、EmptyState | 多个页面 |
| 页面组件 | 特定页面的拆分,不复用 | DetailHeader、QuizOptionItem | 单个页面 |
组件的演进路径:
页面内代码 → 抽成 @Builder → 页面级组件 → 业务组件 → 基础组件
不要上来就搞成全局通用组件,先从页面内复用开始,等真的多个页面用了,再往上提。
🛠️ 核心实现
步骤1:Props 设计——组件的"对外接口"
Props 是组件的"输入",也是组件的"接口说明书"。Props 设计得好,组件用起来就顺手;Props 设计得烂,用的人骂娘。
1.1 Props 的基本定义
@Component
export struct EthnicCard {
// 👇 定义 Props
@Prop ethnic: EthnicBrief;
@Prop cardType: CardType = CardType.Grid;
@Prop showDivider: boolean = true;
build() {
// ...
}
}
Props 的命名规范:
- 小驼峰(camelCase):
showDivider、imageUrl - 布尔值用 is/has/can/should 开头:
isVisible、hasBorder - 语义化:不要用
data、info这种太泛的名字
1.2 Props 的类型
ArkTS 是强类型的,Props 一定要明确类型。
基本类型:
@Prop title: string = '';
@Prop count: number = 0;
@Prop isEnabled: boolean = true;
枚举类型:
export enum CardType {
Grid = 'grid',
List = 'list',
}
@Prop cardType: CardType = CardType.Grid;
对象类型:
interface UserInfo {
id: string;
name: string;
avatar: string;
}
@Prop user: UserInfo; // 注意:对象是引用传递
函数类型(回调):
@Prop onClick?: (id: string) => void;
@Prop onReady?: () => void;
1.3 默认值与必填
有默认值的 Props:
// 有默认值,调用方可以不传
@Prop size: number = 24;
@Prop type: ButtonType = ButtonType.Default;
必填 Props:
// 没有默认值,调用方必须传
@Prop title: string;
@Prop ethnic: EthnicBrief;
💡 设计原则:
- 尽量给默认值,降低使用成本
- 核心数据(没有就显示不了的)才设为必填
- 布尔值默认值要符合"最少意外"原则——比如
disabled默认false
1.4 Props 校验
虽然 ArkTS 没有像 Vue 那样内置的 validator,但我们可以在 aboutToAppear 里做校验:
aboutToAppear(): void {
if (!this.ethnic) {
console.error('EthnicCard: ethnic is required');
return;
}
if (this.maxLines < 1) {
console.warn('EthnicCard: maxLines should be >= 1, got ' + this.maxLines);
this.maxLines = 1;
}
}
1.5 Props 多少算多?
一个组件的 Props 多少个合适?
| Props 数量 | 评估 | 建议 |
|---|---|---|
| 0-5 个 | 健康 | 没问题 |
| 5-10 个 | 注意 | 看看能不能合并 |
| 10-15 个 | 警告 | 考虑拆分组件 |
| 15+ 个 | 危险 | 大概率职责不单一,该拆了 |
合并 Props 的技巧:
- 把相关的 props 打包成对象:
{ width, height, borderRadius }→sizeConfig - 用枚举代替多个布尔值:
isPrimary+isDanger+isSuccess→type: ButtonType
步骤2:事件设计——组件的"输出"
组件不光有输入(Props),还要有输出(事件/回调)。
2.1 自定义事件的定义
@Component
export struct EthnicCard {
@Prop ethnic: EthnicBrief;
@Prop onClick?: (ethnic: EthnicBrief) => void;
@Prop onFavoriteChange?: (id: string, isFav: boolean) => void;
build() {
Column() {
// ...
Image(this.isFavorite ? 'heart_filled' : 'heart_outline')
.onClick((event: ClickEvent) => {
event.stopPropagation(); // 阻止冒泡,避免触发卡片的点击
this.handleFavorite();
})
}
.onClick(() => {
if (this.onClick) {
this.onClick(this.ethnic);
}
})
}
private handleFavorite(): void {
this.isFavorite = !this.isFavorite;
if (this.onFavoriteChange) {
this.onFavoriteChange(this.ethnic.id, this.isFavorite);
}
}
}
事件命名规范:
- 动词开头:
onClick、onChange、onSubmit - 语义化:
onFavoriteChange比onHeartClick好——描述"发生了什么",而不是"点了什么" - 过去式表示已发生:
onChanged、onSubmitted(可选,看项目规范)
2.2 事件传参
事件回调的参数设计原则:
- 第一个参数是事件主体:比如点击卡片,第一个参数就是卡片对应的数据
- 第二个参数是事件对象(如果需要):原生事件对象
- 参数不要太多:超过 2 个就考虑打包成对象
// ✅ 好:参数明确
@Prop onClick?: (ethnic: EthnicBrief) => void;
// ✅ 好:多个参数打包成对象
interface CardActionParams {
id: string;
type: ActionType;
position: number;
}
@Prop onAction?: (params: CardActionParams) => void;
// ❌ 不好:参数太多,顺序记不住
@Prop onChange?: (id: string, name: string, type: string, index: number) => void;
2.3 事件冒泡与阻止
嵌套组件的点击事件,会冒泡到父组件。有时候这是想要的,有时候不是。
Column() {
Image('heart')
.onClick((event: ClickEvent) => {
event.stopPropagation(); // 👇 阻止冒泡,点爱心不会触发卡片点击
this.handleFavorite();
})
}
.onClick(() => {
// 整个卡片的点击事件
})
什么时候该阻止冒泡?
- 子组件的操作和父组件的操作是独立的(比如卡片里的爱心按钮)
- 子组件的操作会触发完全不同的逻辑
什么时候不该阻止?
- 子组件只是父组件的一部分,点击子组件就是点击父组件
步骤3:插槽(Slot/Builder)——组件的"扩展点"
Props 是传数据,那如果要传"一段 UI"怎么办?这时候就需要插槽。
ArkUI 中实现"插槽"有几种方式:
3.1 @BuilderParam(推荐)
@BuilderParam 是 ArkUI 中最接近"插槽"概念的东西。
// 组件定义
@Component
export struct CardContainer {
@Prop title: string = '';
@BuilderParam header?: () => void; // 头部插槽
@BuilderParam content?: () => void; // 内容插槽
@BuilderParam footer?: () => void; // 底部插槽
build() {
Column() {
// 头部
if (this.header) {
this.header();
}
// 标题
if (this.title) {
Text(this.title)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.margin({ bottom: 12 });
}
// 内容
if (this.content) {
this.content();
}
// 底部
if (this.footer) {
this.footer();
}
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.card_background'))
.borderRadius(12);
}
}
使用:
// 调用方
CardContainer({
title: '民族信息',
header: () => {
Row() {
Image('badge')
.width(20)
.height(20)
Text('精选')
.fontSize(12)
.fontColor($r('app.color.primary'))
}
},
content: () => {
Column({ space: 8 }) {
Text('人口:约 13 亿')
Text('分布:全国各地')
}
},
footer: () => {
Button('查看详情')
.width('100%')
.onClick(() => { /* ... */ })
}
})
3.2 默认插槽的简化写法
如果组件只有一个主要插槽,可以用默认的方式:
@Component
export struct CardWrapper {
// 用 @BuilderParam 作为主内容
@BuilderParam content: () => void;
build() {
Column() {
this.content();
}
.padding(16)
.backgroundColor(Color.White)
.borderRadius(12);
}
}
使用:
CardWrapper({
content: () => {
Text('Hello')
}
})
3.3 插槽的设计原则
什么时候用插槽,什么时候用 Props?
| 场景 | 推荐方式 |
|---|---|
| 传数据(字符串、数字、对象) | Props |
| 传简单的样式变体(大小、类型) | Props + 枚举 |
| 传一段自定义 UI | 插槽(@BuilderParam) |
| 传复杂的交互逻辑 | 插槽 + 回调 |
插槽的命名:
header/footer/content:布局位置prefix/suffix:前后缀left/right:左右item/cell:列表项自定义
插槽不要太多:
- 1-2 个插槽:健康
- 3-4 个插槽:注意
- 5+ 个插槽:这个组件可能太"空"了,不如不封装
💡 插槽的本质:
插槽是组件给调用方留的"扩展点"。
组件控制整体结构和通用逻辑,调用方通过插槽填入具体内容。
好的插槽设计,是"骨架+血肉"的关系——组件提供骨架,插槽填入血肉。
步骤4:完整案例——民族卡片组件
让我们用一个完整的案例,把上面讲的知识串起来。
我们要做一个 EthnicCard 组件,支持两种模式:
- 网格模式:圆形头像 + 名字,用于首页的网格列表
- 列表模式:左侧头像 + 中间信息 + 右侧箭头,用于列表页
还要支持:
- 点击回调
- 收藏按钮(可选显示)
- 自定义底部内容(插槽)
4.1 第一步:定义接口
先想清楚"这个组件接收什么、输出什么",然后定义类型。
// models/EthnicModels.ets
export interface EthnicBrief {
id: string;
name: string;
nameEn: string;
imageUrl: string;
population: string;
region: string;
primaryColor: string;
}
export enum EthnicCardType {
Grid = 'grid',
List = 'list',
}
4.2 第二步:定义 Props 和事件
// components/EthnicCard.ets
import { EthnicBrief, EthnicCardType } from '../models/EthnicModels';
@Component
export struct EthnicCard {
// 数据
@Prop ethnic: EthnicBrief;
// 样式
@Prop cardType: EthnicCardType = EthnicCardType.Grid;
@Prop showFavorite: boolean = false;
@Prop isFavorite: boolean = false;
@Prop cornerRadius: number = 12;
// 事件
@Prop onClick?: (ethnic: EthnicBrief) => void;
@Prop onFavoriteChange?: (id: string, isFav: boolean) => void;
// 插槽
@BuilderParam extraContent?: () => void;
// 内部状态
@State isPressed: boolean = false;
build() {
if (this.cardType === EthnicCardType.Grid) {
this.buildGridCard();
} else {
this.buildListCard();
}
}
}
4.3 第三步:实现网格模式
@Builder
buildGridCard() {
Column({ space: 8 }) {
Stack({ alignContent: Alignment.TopEnd }) {
Image(this.ethnic.imageUrl)
.width(60)
.height(60)
.borderRadius(30)
.objectFit(ImageFit.Cover)
if (this.showFavorite) {
Image(this.isFavorite ? 'heart_filled' : 'heart_outline')
.width(18)
.height(18)
.fillColor(this.isFavorite ? '#FF4D4F' : '#FFFFFF')
.backgroundColor(this.isFavorite ? '#FFF' : 'rgba(0,0,0,0.3)')
.borderRadius(9)
.padding(3)
.margin({ top: 4, right: 4 })
.onClick((event: ClickEvent) => {
event.stopPropagation();
this.handleFavorite();
})
}
}
Text(this.ethnic.name)
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.fontWeight(FontWeight.Medium)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.ethnic.population)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
.maxLines(1)
if (this.extraContent) {
this.extraContent();
}
}
.width('100%')
.padding(12)
.borderRadius(this.cornerRadius)
.backgroundColor($r('app.color.card_background'))
.scale({ x: this.isPressed ? 0.95 : 1, y: this.isPressed ? 0.95 : 1 })
.opacity(this.isPressed ? 0.8 : 1)
.animation({ duration: 150, curve: Curve.EaseInOut })
.onTouch((event: TouchEvent) => {
if (event.type === TouchType.Down) {
this.isPressed = true;
} else if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
this.isPressed = false;
if (event.type === TouchType.Up && this.onClick) {
this.onClick(this.ethnic);
}
}
})
}
4.4 第四步:实现列表模式
@Builder
buildListCard() {
Row({ space: 12 }) {
Image(this.ethnic.imageUrl)
.width(56)
.height(56)
.borderRadius(8)
.objectFit(ImageFit.Cover)
Column({ space: 4 }) {
Text(this.ethnic.name)
.fontSize(16)
.fontColor($r('app.color.text_primary'))
.fontWeight(FontWeight.Medium)
.maxLines(1)
Text(`${this.ethnic.region} · ${this.ethnic.population}`)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.maxLines(1)
if (this.extraContent) {
this.extraContent();
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
if (this.showFavorite) {
Image(this.isFavorite ? 'heart_filled' : 'heart_outline')
.width(24)
.height(24)
.fillColor(this.isFavorite ? '#FF4D4F' : '#CCCCCC')
.onClick((event: ClickEvent) => {
event.stopPropagation();
this.handleFavorite();
})
}
Image('arrow_right')
.width(16)
.height(16)
.fillColor($r('app.color.text_tertiary'))
}
.width('100%')
.padding(16)
.borderRadius(this.cornerRadius)
.backgroundColor($r('app.color.card_background'))
.scale({ x: this.isPressed ? 0.98 : 1, y: this.isPressed ? 0.98 : 1 })
.opacity(this.isPressed ? 0.8 : 1)
.animation({ duration: 150, curve: Curve.EaseInOut })
.onTouch((event: TouchEvent) => {
if (event.type === TouchType.Down) {
this.isPressed = true;
} else if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
this.isPressed = false;
if (event.type === TouchType.Up && this.onClick) {
this.onClick(this.ethnic);
}
}
})
}
4.5 第五步:内部方法
private handleFavorite(): void {
const newState = !this.isFavorite;
if (this.onFavoriteChange) {
this.onFavoriteChange(this.ethnic.id, newState);
}
}
4.6 使用示例
// 网格模式
EthnicCard({
ethnic: item,
cardType: EthnicCardType.Grid,
showFavorite: true,
isFavorite: this.isFavorite(item.id),
onClick: (ethnic) => {
this.goToDetail(ethnic.id);
},
onFavoriteChange: (id, isFav) => {
this.toggleFavorite(id);
}
})
// 列表模式 + 自定义内容
EthnicCard({
ethnic: item,
cardType: EthnicCardType.List,
onClick: (ethnic) => {
this.goToDetail(ethnic.id);
},
extraContent: () => {
Row({ space: 8 }) {
Tag({ text: '服饰', type: TagType.Primary })
Tag({ text: '美食', type: TagType.Success })
}
}
})
步骤5:组件的样式定制
组件怎么支持"换肤"?怎么让调用方自定义样式?
5.1 样式 Props
最简单的方式:通过 Props 传样式。
@Prop backgroundColor?: string;
@Prop textColor?: string;
@Prop borderRadius: number = 12;
但这样有个问题——样式 props 太多了,每个样式都加一个 prop,props 会爆炸。
5.2 样式类型(推荐)
把相关的样式打包成一个对象:
interface CardStyle {
backgroundColor?: string;
borderRadius?: number;
padding?: Padding;
borderColor?: string;
borderWidth?: number;
}
@Prop style?: CardStyle;
使用:
Column() {
// ...
}
.backgroundColor(this.style?.backgroundColor ?? $r('app.color.card_background'))
.borderRadius(this.style?.borderRadius ?? 12)
5.3 主题适配
用系统资源 $r() 而不是写死颜色值,天然支持深色模式:
// ✅ 好:用资源,自动适配深色模式
.backgroundColor($r('app.color.card_background'))
.fontColor($r('app.color.text_primary'))
// ❌ 不好:写死,深色模式下可能看不见
.backgroundColor('#FFFFFF')
.fontColor('#333333')
5.4 CSS 变量
对于需要动态切换的主题色,可以用 CSS 变量:
// 定义在全局
AppStorage.setOrCreate('--primary-color', '#409EFF');
// 组件中使用
const primaryColor = AppStorage.get('--primary-color') || '#409EFF';
5.5 样式继承的边界
一个原则:组件只控制"自己的骨架样式",内容样式交给调用方。
比如卡片组件:
- ✅ 组件控制:背景色、圆角、内边距、阴影
- ❌ 组件不控制:里面文字的字号、颜色(除非是标题等固定部分)
这样组件才有足够的灵活性,适应不同的使用场景。
步骤6:组件的生命周期
了解组件的生命周期,才能在正确的时机做正确的事。
| 生命周期 | 调用时机 | 典型用途 |
|---|---|---|
aboutToAppear |
组件即将出现时 | 初始化数据、注册监听、开始加载 |
aboutToDisappear |
组件即将消失时 | 取消请求、移除监听、释放资源 |
onPageShow |
页面显示时(仅页面) | 刷新数据、恢复播放 |
onPageHide |
页面隐藏时(仅页面) | 暂停播放、保存状态 |
onActive |
组件激活时 | 恢复动画、重新开始轮播 |
onInactive |
组件失活时 | 暂停动画、暂停轮播 |
aboutToAppear vs onPageShow:
aboutToAppear:组件创建时调用一次onPageShow:每次页面显示都调用(比如从后台切回来)
常见用法:
aboutToAppear(): void {
// 初始化数据
this.loadData();
// 注册监听
EventHub.on('themeChange', this.handleThemeChange);
}
aboutToDisappear(): void {
// 移除监听(重要!防止内存泄漏)
EventHub.off('themeChange', this.handleThemeChange);
// 取消请求
this.requestController?.abort();
// 清理定时器
clearInterval(this.timer);
}
onPageShow(): void {
// 页面回来时刷新
this.refreshIfNeeded();
}
⚠️ 关于内存泄漏:
在 aboutToAppear 里注册的监听,一定要在 aboutToDisappear 里移除。
在组件里启的定时器,一定要在销毁时清掉。
不然组件销毁了,回调还在引用着组件,就会内存泄漏。
步骤7:组件性能优化
组件用多了,性能会不会变差?怎么保证组件的性能?
7.1 避免不必要的重渲染
问题:父组件状态变了,所有子组件都重新 build?
ArkUI 的优化:
ArkUI 是细粒度更新的,不是整个组件树重新渲染。只有真正依赖了变化状态的部分才会更新。
你可以做的优化:
-
合理拆分组件:把不相关的状态拆到子组件里,父组件状态变化不会影响子组件
-
用 @Observed / @ObjectLink:嵌套对象的精细化观测
-
避免在 build 里创建新对象:
// ❌ 不好:每次 build 都创建新数组
build() {
Column() {
ForEach([1, 2, 3], (item) => {
Text(item.toString())
})
}
}
// ✅ 好:数组定义在组件外或者 @State 里
private items: number[] = [1, 2, 3];
build() {
Column() {
ForEach(this.items, (item) => {
Text(item.toString())
})
}
}
7.2 列表组件的性能
列表是性能重灾区。列表里的卡片组件,每一张都可能被滚动时复用。
优化列表项的技巧:
- 组件层级越少越好:能一层解决的不要套三层
- 布局尽量简单:能用 Row/Column 就不用 Stack
- 图片设置明确的宽高:避免加载后重新布局
- 用 LazyForEach:不要一次性渲染所有项
7.3 纯展示组件用 @Builder
如果只是为了拆分代码,没有独立状态,用 @Builder 比抽组件更轻量:
// 同一个组件内,用 @Builder 拆分
@Builder
Header() {
Row() {
// ...
}
}
什么时候用 @Builder,什么时候抽组件?
| 维度 | @Builder | 自定义组件 |
|---|---|---|
| 复用范围 | 同一个组件内 | 跨组件/跨页面 |
| 独立状态 | 没有(用父组件的) | 有自己的状态 |
| 生命周期 | 没有 | 有 |
| 性能开销 | 极低 | 有一些 |
| 复杂度 | 简单 | 可以复杂 |
💡 简单口诀:
只是代码太长、逻辑紧 → 用 @Builder
有独立职责、要复用 → 抽组件
⚠️ 常见问题与解决方案
问题1:Props 太多,组件越来越臃肿
现象:
组件刚抽出来的时候只有 5 个 props,需求迭代加加加,加到 15 个、20 个,组件变成了"万能组件",什么都能干,但什么都干不好。
分析:
这通常是因为组件的职责不单一。一个组件承担了太多功能,什么场景都想支持。
解决方案:
方案1:拆分组件
把一个"万能组件"拆成多个"专用组件"。
// ❌ 不好:一个 Card 组件支持 N 种模式
// props: type, size, variant, showHeader, showFooter, bordered, shadowed...
// ✅ 好:拆成多个组件
PrimaryCard({ title, content }) // 主卡片
SimpleCard({ content }) // 简单卡片
ImageCard({ image, title }) // 图文卡片
ProfileCard({ user, stats }) // 个人资料卡片
方案2:组合大于继承
不要试图用一个组件覆盖所有场景。而是提供一些基础组件,让调用方自己组合。
// 基础组件
Card({ content: ... }) // 只有容器样式
CardHeader({ title: ... }) // 卡片头部
CardFooter({ actions: ... }) // 卡片底部
// 调用方自己组合
Card({
content: () => {
Column() {
CardHeader({ title: '标题' })
Divider()
// 自定义内容
CardFooter({ actions: [...] })
}
}
})
方案3:预设配置 + 自定义
对于常用的组合,提供"预设",同时保留自定义能力。
// 预设类型
type CardPreset = 'default' | 'primary' | 'outlined' | 'elevated';
@Prop preset: CardPreset = 'default';
// 根据预设应用不同的样式
private get cardStyle(): CardStyle {
switch (this.preset) {
case 'primary':
return { backgroundColor: '#409EFF', textColor: '#FFF' };
case 'outlined':
return { borderWidth: 1, borderColor: '#DCDFE6' };
// ...
}
}
问题2:父子组件状态不同步
现象:
父组件传了一个 prop 给子组件,子组件内部改了,但父组件没变。或者反过来,父组件改了,子组件没更新。
分析:
ArkUI 中 @Prop 是单向数据流:
- 父组件 → 子组件:父组件的 prop 变了,子组件自动更新
- 子组件 → 父组件:子组件不能直接改 prop,要通过回调通知父组件改
常见错误:
// ❌ 错误:子组件直接修改 @Prop
@Prop count: number = 0;
increment() {
this.count++; // 不推荐!可能会导致父组件和子组件状态不一致
}
// ✅ 正确:通过回调通知父组件
@Prop count: number = 0;
@Prop onCountChange?: (count: number) => void;
increment() {
if (this.onCountChange) {
this.onCountChange(this.count + 1);
}
}
什么时候子组件可以有自己的状态?
- 这个状态是组件内部的,和父组件无关(比如 isPressed)
- 这个状态是 prop 的"本地副本",可以和父组件不同步(比如输入框的临时值)
什么时候不该有自己的状态?
- 数据应该以父组件为准(比如收藏状态)
- 多个地方需要同步(比如全局设置)
💡 状态归属原则:
谁拥有数据,谁管理状态。
数据是父组件的,就父组件管,子组件只展示和发通知。
数据是组件内部的,就组件自己管。
问题3:组件太"深",传参要传好多层
现象:
A → B → C → D,D 需要一个数据,要从 A 一层一层传下去,中间的 B、C 都得加 prop。
A(数据在这里)
↓ prop
B(不关心,只是往下传)
↓ prop
C(不关心,只是往下传)
↓ prop
D(真正用到的地方)
解决方案:
方案1:Provider / Inject(跨代传值)
// A 组件提供数据
@Provide('userInfo') userInfo: UserInfo = user;
// D 组件直接取
@Consume('userInfo') userInfo: UserInfo;
适用于"祖孙"级别的传递,跳过中间层。
方案2:全局状态(AppStorage / Store)
如果是全局共享的数据,直接放 AppStorage 或者状态管理库:
// 任何地方都能读
@StorageLink('themeMode') themeMode: string = 'light';
方案3:重新考虑组件设计
如果发现数据要传很多层,可能是组件划分有问题。是不是应该把相关的逻辑放到一起?
💡 传参层级的判断:
- 1-2 层:正常,直接传
- 3 层:可以考虑用 Provide/Inject
- 4+ 层:要么放全局,要么重新设计组件结构
问题4:组件复用率低,大家都在复制粘贴
现象:
抽了一个组件,但是别人用的时候觉得"不好用",还是自己写一份。结果组件库形同虚设,代码里到处都是复制粘贴的版本。
分析:
组件复用率低,通常不是因为"大家不爱用组件",而是因为"组件不好用"。
常见原因:
-
接口设计反直觉
- 看名字不知道怎么用
- 参数顺序记不住
- 行为不符合预期
-
灵活性不够
- 这个需求差一点点,组件不支持
- 想改个样式改不了
- 想加个功能加不了
-
文档缺失
- 不知道有这个组件
- 不知道怎么用
- 不知道有哪些 props
改进方法:
-
从使用方的角度设计接口
- 写组件前,先写"使用示例"
- 如果用起来很别扭,说明组件设计有问题
- 好的组件,看一眼就知道怎么用
-
预留扩展点
- 加插槽,而不是加一堆布尔值
- 提供默认配置,但允许覆盖
- 样式留口子,不要写死
-
写好文档和示例
- 每个组件都要有使用示例
- 列出所有 props、事件、插槽
- 常见用法都给个例子
💡 组件的"可用性"比"功能多少"更重要。
一个功能不多但很好用的组件,比一个功能齐全但难用的组件,复用率高得多。
问题5:组件测试难,不知道怎么测
现象:
组件写好了,但不知道怎么测试。UI 组件不像工具函数,输入输出那么明确。
分析:
组件测试确实比纯逻辑难,但不是不能测。
组件测试的思路:
1. 逻辑单独抽出来测试
把组件中的业务逻辑抽成工具函数或者 service,单独测:
// 逻辑抽成纯函数,好测
export function calculateProgress(current: number, total: number): number {
if (total <= 0) return 0;
return Math.min(100, Math.max(0, (current / total) * 100));
}
// 组件里只调用
@Builder
build() {
const progress = calculateProgress(this.current, this.total);
// ...
}
2. 测组件的行为,不测实现
关注"组件做了什么",而不是"组件怎么做到的":
- ✅ 给了 props,渲染的内容对不对
- ✅ 点击按钮,回调有没有触发
- ✅ 状态变化,UI 有没有更新
- ❌ 内部用了几个变量
- ❌ 方法调用顺序
3. 用组件测试框架
ArkUI 有自己的测试框架,可以模拟用户操作、断言 UI 状态。
📝 本章小结
核心知识点
本文从设计原则到具体实现,系统讲解了自定义组件的封装方法:
1. 组件设计原则
- 单一职责:一个组件只做一件事
- 开闭原则:对扩展开放,对修改关闭
- 迪米特法则:只和直接朋友通信
- 可预测性:同样的输入,同样的输出
2. Props 设计
- 明确类型,合理默认值
- 命名语义化,布尔值用 is/has 开头
- props 数量控制在 10 个以内
- 太多就合并、拆分
3. 事件设计
- 动词开头:onClick、onChange
- 描述"发生了什么",不是"点了什么"
- 参数简洁,超过 2 个打包成对象
- 合理控制事件冒泡
4. 插槽(@BuilderParam)
- 传 UI 用插槽,传数据用 Props
- 插槽不要太多,3 个以上要注意
- 好的插槽设计是"骨架+血肉"
5. 样式定制
- 用 $r() 资源,天然支持深色模式
- 样式 Props 打包成对象,不要散着传
- 组件控制骨架样式,内容样式交给调用方
6. 生命周期
- aboutToAppear:初始化、注册监听
- aboutToDisappear:清理资源、移除监听(防止泄漏)
- onPageShow/onPageHide:页面级别的显示隐藏
7. 性能优化
- 合理拆分组件,避免不必要的重渲染
- 列表项轻量化,层级越少越好
- 纯展示用 @Builder,不要过度封装
- 图片设置明确宽高,避免重新布局
最佳实践总结
✅ 先想怎么用,再想怎么实现
写组件前,先写使用示例。
如果用起来很别扭,说明组件设计有问题。
从调用方的角度倒推设计,比从实现角度正推,质量高得多。
✅ Props 宁少勿多,保持克制
一开始 props 少一点,不够再加。
加 props 容易,删 props 难(要考虑兼容)。
一个组件超过 10 个 props,就该想想是不是该拆了。
✅ 单向数据流,数据归谁谁管
父组件的数据,父组件管。
子组件不要直接改 prop,通过回调通知父组件。
状态归属清晰,才不会乱。
✅ 组合大于继承,预设覆盖常见场景
不要试图做一个"万能组件"。
提供基础组件 + 预设配置 + 组合能力,比一个巨型组件灵活得多。
✅ 好组件是"演化"出来的,不是"设计"出来的
不要上来就做通用组件。
先在一个地方用,第二个地方用的时候再抽。
用的地方多了,自然知道怎么设计才合理。
✅ 文档和示例,和代码一样重要
组件再好,别人不知道怎么用,等于没有。
每个组件配一个示例文件,比写十行注释管用。
好的组件,看示例就会用。
下一步预告
在下一篇文章中,我们将:
- 👆 学习常见手势的处理:点击、长按、滑动、捏合、旋转
- 🎯 理解手势冲突的产生原因与解决方法
- 📱 掌握手势驱动的交互动效实现
- 🎨 实现「民族图鉴」中的可拖拽卡片、下拉刷新等高级交互
- 🚀 深入手势识别的原理与优先级控制
🔗 相关链接
- 项目源码: GitCode 仓库
- 自定义组件: 官方文档
- @Prop 装饰器: 官方文档
- @BuilderParam 装饰器: 官方文档
- 组件生命周期: 官方文档
💡 提示:组件封装是一门"平衡的艺术"——太简单了不够用,太复杂了又难用。最好的学习方法,就是多看优秀的组件库(比如 Element Plus、Ant Design),看它们的组件是怎么设计的、props 是怎么命名的、插槽是怎么留的。看得多了,再自己动手写,慢慢就有感觉了。
更多推荐

所有评论(0)