[鸿蒙从零到一] ArkUI 组件化实战:构建可复用、可组合的自定义组件

鸿蒙从零到一 ArkUI 组件化实战:构建可复用、可组合的自定义组件

当页面只有少量 TextImageButton 时,把所有代码写在一个 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. 参数过多且互相制约

大量布尔参数通常意味着组件承担了太多变体。例如 showPriceshowTagcompactModedarkMode 同时出现时,应考虑拆成不同组件,或使用明确的配置模型和插槽。

4. 过度使用双向绑定

双向绑定虽然简洁,但会让状态修改来源变多。优先使用单向参数和事件回调,只有在输入控件等明确的双向场景中再使用 @Link

5. 忽略列表中的稳定标识

可复用组件进入 ListLazyForEach 后,应基于稳定 ID 管理状态。不要依赖数组下标表示业务身份,否则排序和增删后可能出现状态错位。

十、组件设计检查清单

完成一个自定义组件后,可以检查这些问题:

  • 组件名称是否表达了明确职责?
  • 输入参数是否都是渲染所必需的?
  • 业务状态是否只有一个可信来源?
  • 用户操作是否通过清晰的事件暴露?
  • 可变区域是否适合使用 @BuilderParam
  • 是否依赖了不必要的页面、路由或网络实现?
  • 在长文本、空数据和不同屏幕宽度下是否仍能正常布局?

总结

ArkUI 组件化的核心不是文件数量,而是稳定的职责边界和可预测的数据流。使用 @Prop 传入只读数据,通过函数回调上报行为,借助 @BuilderParam 提供局部组合能力,再把共享业务状态放在父组件或状态模型中,就能构建更容易复用、测试和维护的界面。

当组件对外只暴露必要的数据、事件和可组合区域时,页面会从复杂的 UI 堆叠转变为清晰的业务表达。这也是自定义组件从"代码拆分"走向"工程能力"的关键。

相关推荐
一只公羊7 小时前
iPhone摄像头 在开发/调试过程中强行停止 App,导致 `AVCaptureSession` 没有被正常释放
前端
qizayaoshuap7 小时前
# [特殊字符] 密码生成器 — 鸿蒙ArkTS安全算法与密码强度评估系统
java·算法·安全·华为·harmonyos
浮江雾7 小时前
Flutter第十节-----Flutter布局与组件全解析
android·开发语言·前端·学习·flutter·入门
●VON7 小时前
鸿蒙 PC Markdown 编辑器自动化测试:Playwright、ohosTest 与构建门禁
华为·编辑器·harmonyos·鸿蒙
xcLeigh8 小时前
Doubao-Seed-Evolving大模型接入教程|搭建全品类提示词+AI工具导航网页
前端·人工智能·python·ai·html·ai开发·豆包
酣大智8 小时前
华为交换机 端口隔离完整配置(S5700/S5300 通用)
华为·端口隔离
世人万千丶9 小时前
鸿蒙Flutter Flex嵌套布局技巧
学习·flutter·华为·harmonyos·鸿蒙·鸿蒙系统
巴勒个啦9 小时前
Vue 3.6 Vapor Mode 实战:我把一个 Vue3 项目的渲染性能提升了 4 倍
前端·angular.js