鸿蒙从零到一 ArkUI 组件化实战:构建可复用、可组合的自定义组件
当页面只有少量 Text、Image 和 Button 时,把所有代码写在一个 build() 中似乎很直接。但随着业务增加,页面会很快出现三个问题:结构难读、样式重复、状态修改互相影响。
ArkUI 的自定义组件不只是把一段 UI 搬到另一个文件。真正有效的组件化,需要同时回答以下问题:
- 组件负责展示什么,边界在哪里?
- 哪些数据由父组件传入,哪些状态由组件自己维护?
- 用户操作如何通知父组件?
- 如何让组件既统一视觉,又允许局部定制?
本文从一个商品信息卡片出发,逐步构建具备参数、事件、插槽和明确状态边界的自定义组件。
一、从最小自定义组件开始
在 ArkUI 中,使用 @Component 声明自定义组件,使用 @Entry 指定页面入口组件。每个组件都通过 build() 描述自己的 UI 结构。
ts
@Entry
@Component
struct ProductPage {
build() {
Column() {
ProductTitle()
}
.width('100%')
.padding(16)
}
}
@Component
struct ProductTitle {
build() {
Text('HarmonyOS 智能手表')
.fontSize(20)
.fontWeight(FontWeight.Bold)
}
}
这已经完成了结构拆分,但 ProductTitle 的内容是固定的,无法复用。组件化的下一步,是把变化的数据变成参数。
二、使用 @Prop 接收只读参数
商品名称、价格、图片等内容应由页面提供。子组件只负责渲染,不应直接修改这些业务数据。此类单向数据传递适合使用 @Prop。
ts
@Component
struct PriceText {
@Prop price: number
@Prop currency: string = '¥'
build() {
Row({ space: 4 }) {
Text(this.currency)
.fontSize(14)
.fontColor('#D94841')
Text(this.price.toFixed(2))
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#D94841')
}
.alignItems(VerticalAlign.Bottom)
}
}
父组件在使用时传入参数:
ts
PriceText({ price: 1299, currency: '¥' })
@Prop 会接收父组件数据的变化并触发界面刷新,但子组件不应把它当作双向数据通道。如果子组件需要修改父组件中的值,应使用事件回调,或者在确实需要双向绑定时使用 @Link。
三、用数据模型减少零散参数
当组件参数越来越多时,逐个传递字段会让调用处变得冗长。可以将相关字段收敛到模型中。
ts
class ProductInfo {
id: string = ''
name: string = ''
image: ResourceStr = ''
price: number = 0
description: string = ''
tags: string[] = []
}
组件只接收一个业务模型:
ts
@Component
struct ProductCard {
@Prop product: ProductInfo
build() {
Column({ space: 10 }) {
Image(this.product.image)
.width('100%')
.height(180)
.objectFit(ImageFit.Cover)
.borderRadius(6)
Text(this.product.name)
.fontSize(18)
.fontWeight(FontWeight.Medium)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.product.description)
.fontSize(14)
.fontColor('#666666')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
PriceText({ price: this.product.price })
}
.width('100%')
.padding(12)
.alignItems(HorizontalAlign.Start)
.backgroundColor(Color.White)
.borderRadius(8)
}
}
模型能减少参数数量,但不要把页面所有数据塞进一个巨型对象。只向组件暴露它真正需要的信息,组件边界才会稳定。
四、通过回调把交互交给父组件
商品卡片可以展示"加入收藏"按钮,但收藏行为可能涉及登录校验、网络请求和页面提示。这些业务流程不应该写死在展示组件中。
组件可以声明函数参数,将用户操作通知父组件:
ts
@Component
struct FavoriteButton {
@Prop selected: boolean
onChange: (selected: boolean) => void = () => {}
build() {
Button(this.selected ? '已收藏' : '收藏')
.type(ButtonType.Normal)
.fontSize(14)
.fontColor(this.selected ? '#FFFFFF' : '#333333')
.backgroundColor(this.selected ? '#C43D35' : '#F1F2F3')
.borderRadius(6)
.onClick(() => {
this.onChange(!this.selected)
})
}
}
父组件持有真实状态,并处理业务逻辑:
ts
@Entry
@Component
struct ProductDetailPage {
@State isFavorite: boolean = false
build() {
Column() {
FavoriteButton({
selected: this.isFavorite,
onChange: (selected: boolean) => {
this.isFavorite = selected
// 在这里发起持久化或网络请求
}
})
}
}
}
这种"数据向下、事件向上"的结构容易追踪:父组件是状态源,子组件负责展示和发出意图。
五、使用 @Builder 提供可组合内容
有些卡片结构基本一致,但右上角区域可能放收藏按钮、库存标签或菜单。为每种场景复制一个组件会产生大量重复代码。
可以通过 @BuilderParam 接收 UI 构建函数,形成类似插槽的能力:
ts
@Component
struct SectionHeader {
@Prop title: string
@BuilderParam action: () => void = this.defaultAction
@Builder
defaultAction() {}
build() {
Row() {
Text(this.title)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.layoutWeight(1)
this.action()
}
.width('100%')
.height(44)
}
}
调用方可以注入不同内容:
ts
@Builder
function MoreAction() {
Text('更多')
.fontSize(14)
.fontColor('#2167D5')
}
SectionHeader({
title: '推荐商品',
action: MoreAction
})
@BuilderParam 适合局部 UI 定制,普通函数回调适合传递行为。两者职责不同,不应为了调用方便而混用。
六、组织一个完整的商品卡片
下面把参数、事件和插槽组合起来。商品数据和收藏状态来自父组件,组件本身不承担网络请求,也不保存业务真相。
ts
@Component
struct ReusableProductCard {
@Prop product: ProductInfo
@Prop favorite: boolean
onFavoriteChange: (value: boolean) => void = () => {}
onOpen: (id: string) => void = () => {}
@BuilderParam extra: () => void = this.emptyExtra
@Builder
emptyExtra() {}
build() {
Column({ space: 12 }) {
Stack({ alignContent: Alignment.TopEnd }) {
Image(this.product.image)
.width('100%')
.height(180)
.objectFit(ImageFit.Cover)
.borderRadius(6)
this.extra()
}
Row() {
Column({ space: 6 }) {
Text(this.product.name)
.fontSize(18)
.fontWeight(FontWeight.Medium)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
PriceText({ price: this.product.price })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
FavoriteButton({
selected: this.favorite,
onChange: (value: boolean) => this.onFavoriteChange(value)
})
}
}
.width('100%')
.padding(12)
.backgroundColor(Color.White)
.borderRadius(8)
.onClick(() => this.onOpen(this.product.id))
}
}
父组件负责组合与业务处理:
ts
@Entry
@Component
struct ProductListPage {
@State favoriteIds: string[] = []
private product: ProductInfo = {
id: 'watch-001',
name: 'HarmonyOS 智能手表',
image: $r('app.media.product_watch'),
price: 1299,
description: '支持多设备协同与健康监测',
tags: ['新品']
}
isFavorite(id: string): boolean {
return this.favoriteIds.includes(id)
}
build() {
Column() {
ReusableProductCard({
product: this.product,
favorite: this.isFavorite(this.product.id),
onFavoriteChange: (value: boolean) => {
this.favoriteIds = value
? [...this.favoriteIds, this.product.id]
: this.favoriteIds.filter(id => id !== this.product.id)
},
onOpen: (id: string) => {
console.info(`open product: ${id}`)
},
extra: () => {
Text('新品')
.fontSize(12)
.fontColor(Color.White)
.backgroundColor('#D94841')
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.margin(8)
.borderRadius(4)
}
})
}
.width('100%')
.padding(16)
.backgroundColor('#F5F6F7')
}
}
在真实项目中,列表项的收藏状态更适合放入页面级状态模型,并保证数据项具有稳定的唯一标识。示例使用数组是为了突出组件间的数据流。
七、合理划分组件状态
判断状态放在哪里,可以遵循一个简单原则:谁需要决定它,谁就持有它。
| 状态类型 | 建议归属 | 示例 |
|---|---|---|
| 仅影响组件内部的临时视觉 | 子组件 | 按下态、展开动画进度 |
| 父子组件共同关心的业务状态 | 父组件或状态模型 | 收藏、选中、购买数量 |
| 多页面共享数据 | 应用级状态或数据层 | 用户信息、购物车 |
| 可由其他数据计算得到 | 不单独存储 | 是否为空、总价、按钮可用性 |
不要同时在父组件和子组件中各保存一份相同业务状态。双份状态容易在异步更新、页面返回和列表复用时失去同步。
八、组件目录如何组织
小型项目可以按组件类型组织,大型业务更适合按功能域聚合:
text
entry/src/main/ets/
├── components/
│ ├── common/
│ │ ├── PriceText.ets
│ │ └── SectionHeader.ets
│ └── product/
│ ├── FavoriteButton.ets
│ └── ProductCard.ets
├── models/
│ └── ProductInfo.ets
└── pages/
└── ProductListPage.ets
通用组件应尽量不依赖具体页面和网络层。业务组件可以理解商品、订单等领域模型,但仍应通过参数和事件与页面协作。
九、常见误区
1. 拆得过细
不是每个 Text 都需要独立组件。只有当一段 UI 具有清晰语义、需要复用、内部逻辑复杂或需要独立测试时,拆分才有价值。
2. 子组件直接处理完整业务流程
展示组件内部直接调用登录、网络和路由,会让它难以复用和测试。更稳妥的做法是发出事件,由页面或状态模型协调业务。
3. 参数过多且互相制约
大量布尔参数通常意味着组件承担了太多变体。例如 showPrice、showTag、compactMode、darkMode 同时出现时,应考虑拆成不同组件,或使用明确的配置模型和插槽。
4. 过度使用双向绑定
双向绑定虽然简洁,但会让状态修改来源变多。优先使用单向参数和事件回调,只有在输入控件等明确的双向场景中再使用 @Link。
5. 忽略列表中的稳定标识
可复用组件进入 List 或 LazyForEach 后,应基于稳定 ID 管理状态。不要依赖数组下标表示业务身份,否则排序和增删后可能出现状态错位。
十、组件设计检查清单
完成一个自定义组件后,可以检查这些问题:
- 组件名称是否表达了明确职责?
- 输入参数是否都是渲染所必需的?
- 业务状态是否只有一个可信来源?
- 用户操作是否通过清晰的事件暴露?
- 可变区域是否适合使用
@BuilderParam? - 是否依赖了不必要的页面、路由或网络实现?
- 在长文本、空数据和不同屏幕宽度下是否仍能正常布局?
总结
ArkUI 组件化的核心不是文件数量,而是稳定的职责边界和可预测的数据流。使用 @Prop 传入只读数据,通过函数回调上报行为,借助 @BuilderParam 提供局部组合能力,再把共享业务状态放在父组件或状态模型中,就能构建更容易复用、测试和维护的界面。
当组件对外只暴露必要的数据、事件和可组合区域时,页面会从复杂的 UI 堆叠转变为清晰的业务表达。这也是自定义组件从"代码拆分"走向"工程能力"的关键。