ArkTS 自定义组件完全指南:@Builder、@Extend、@Styles 实战解析

ArkTS 自定义组件完全指南:@Builder、@Extend、@Styles 实战解析

鸿蒙生态这几年发展迅猛,ArkTS 作为官方主推的声明式 UI 语言,已经从 "能用" 进化到 "好用"。但很多从 Vue/React 转过来的开发者,对 ArkTS 的组件复用机制还是一头雾水 ------ @Builder@Extend@Styles 到底该用哪个?它们之间有什么区别?哪些场景用哪个最合适?

这篇文章不讲概念堆砌,直接从真实项目场景出发,把这三个装饰器掰开揉碎了讲清楚。

先看结论:一张图选对装饰器

装饰器 适用范围 是否支持传参 典型场景
@Builder 全局 / 组件内 支持 完整 UI 区块(卡片、列表项、弹窗)
@Extend 全局 支持 对系统组件追加通用样式/逻辑
@Styles 全局 / 组件内 不支持 纯样式复用(类似 CSS class)

@Builder:构建可复用的 UI 区块

@Builder 是 ArkTS 中最灵活的复用方式,本质上是一个 UI 构建函数。它可以包含任意复杂的 UI 结构、事件逻辑,甚至内部状态。

基础用法

typescript 复制代码
@Component
struct UserCard {
  @Builder buildUserInfo(name: string, avatar: string, level: number) {
    Row({ space: 12 }) {
      Image(avatar)
        .width(48)
        .height(48)
        .borderRadius(24)
        .clip(true)
      Column({ space: 4 }) {
        Text(name)
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
        Text(`Lv.${level}`)
          .fontSize(12)
          .fontColor('#999')
      }
      .alignItems(HorizontalAlign.Start)
    }
    .padding(12)
    .backgroundColor('#fff')
    .borderRadius(12)
    .shadow({ radius: 8, color: 'rgba(0,0,0,0.08)' })
  }

  build() {
    Column({ space: 16 }) {
      this.buildUserInfo('张三', 'https://example.com/avatar1.png', 5)
      this.buildUserInfo('李四', 'https://example.com/avatar2.png', 3)
    }
  }
}

全局 @Builder:跨组件复用

当一个 UI 区块需要在多个组件中使用时,提取为全局 @Builder

typescript 复制代码
// 全局 Builder ------ 不需要 @Component 包裹,直接写在文件顶层
@Builder function EmptyState(
  icon: ResourceStr,
  title: string,
  subtitle: string = '',
  actionLabel: string = '',
  onAction?: () => void
) {
  Column({ space: 12 }) {
    Image(icon)
      .width(80)
      .height(80)
      .fillColor('#ccc')
    Text(title)
      .fontSize(16)
      .fontColor('#333')
    if (subtitle) {
      Text(subtitle)
        .fontSize(13)
        .fontColor('#999')
        .textAlign(TextAlign.Center)
        .maxLines(2)
    }
    if (actionLabel) {
      Button(actionLabel)
        .fontSize(14)
        .padding({ left: 24, right: 24, top: 8, bottom: 8 })
        .onClick(() => {
          if (onAction) onAction()
        })
    }
  }
  .width('100%')
  .padding({ top: 60, bottom: 60 })
}

调用方式 :直接写函数名,不要加 this

typescript 复制代码
// 在任意组件中使用全局 Builder
Column() {
  EmptyState(
    $r('app.media.empty_order'),
    '暂无订单',
    '去逛逛吧,总有你喜欢的好物',
    '去首页逛逛',
    () => router.pushUrl({ url: 'pages/Index' })
  )
}

@Builder 的一个隐蔽陷阱

组件内 @Builder 和全局 @Builder 的调用方式不同,混用会导致编译错误:

typescript 复制代码
@Component
struct MyPage {
  // 组件内 Builder ------ 必须用 this. 调用
  @Builder MyLocalBuilder(text: string) {
    Text(text)
  }

  build() {
    Column() {
      this.MyLocalBuilder('组件内')   // ✅ 正确
      MyLocalBuilder('全局')           // ❌ 编译错误!组件内 Builder 不能省略 this
      GlobalBuilder('全局')            // ✅ 全局 Builder 不需要 this
    }
  }
}

// 全局 Builder
@Builder function GlobalBuilder(text: string) {
  Text(text).fontColor('#f00')
}

这个规则没有直觉性可言,但一旦记住就不容易犯错。我的建议是:组件内的 Builder 优先用 this 显式调用,全局的永远不加 this。

@Extend:给系统组件 "加装功能"

@Extend 的定位很精确 ------ 只能作用于 已有的系统组件(Text、Image、Button 等),用来追加样式和简单逻辑。它不支持内部嵌套子组件,适合做 "增强版系统组件"。

场景:统一的按钮样式

项目中往往有大量按钮,重复写 .fontSize().padding().backgroundColor() 效率很低:

typescript 复制代码
// 给 Button 扩展一个 "主要操作" 样式
@Extend(Button) function primaryButton() {
  .fontSize(16)
  .fontWeight(FontWeight.Medium)
  .fontColor('#fff')
  .backgroundColor('#007DFF')
  .borderRadius(8)
  .padding({ left: 24, right: 24, top: 10, bottom: 10 })
  .shadow({ radius: 12, color: 'rgba(0,125,255,0.3)', offsetY: 4 })
}

// 使用
Column({ space: 12 }) {
  Button('提交订单').primaryButton()
  Button('立即支付').primaryButton()
  Button('取消').fontSize(14).fontColor('#666') // 普通按钮不受影响
}

@Extend 支持传参

typescript 复制代码
@Extend(Text) function priceText(originalPrice: number, currentPrice: number) {
  if (originalPrice > currentPrice) {
    .decoration({ type: TextDecorationType.LineThrough })
    .fontColor('#ccc')
    .fontSize(12)
  } else {
    .fontColor('#333')
    .fontSize(14)
  }
}

// 使用
Text(`¥${this.originalPrice}`).priceText(this.originalPrice, this.currentPrice)

@Extend 的限制

关键限制只有一个:不能嵌套子组件。下面的写法会报错:

typescript 复制代码
@Extend(Button) function myButton() {
  // ❌ 不能在 @Extend 内部嵌套其他组件
  Row() {
    Text('图标')
    Text('按钮')
  }
}

// 正确做法 ------ 这种场景应该用 @Builder
@Builder function IconButton(icon: ResourceStr, label: string) {
  Row({ space: 6 }) {
    Image(icon).width(16).height(16)
    Text(label).fontSize(14)
  }
  .padding(8)
  .borderRadius(6)
  .onClick(() => { /* ... */ })
}

@Styles:最纯粹的样式复用

@Styles 和 CSS 的 class 概念最接近。它只能包含 样式属性(不包含事件回调、不包含子组件),适合封装 "样式组合"。

基础用法

typescript 复制代码
// 定义一组可复用的样式
@Styles function cardStyle() {
  .backgroundColor('#fff')
  .borderRadius(12)
  .padding(16)
  .shadow({ radius: 8, color: 'rgba(0,0,0,0.06)', offsetY: 2 })
}

@Styles function sectionTitle() {
  .fontSize(18)
  .fontWeight(FontWeight.Bold)
  .fontColor('#1a1a1a')
  .margin({ bottom: 12 })
}

// 使用 ------ 链式调用
Column() {
  Text('商品详情').sectionTitle()
  Text('这是一段商品描述...')
    .fontSize(14)
    .fontColor('#666')
}
.cardStyle()

组件内 @Styles:访问 this 状态

typescript 复制代码
@Component
struct ThemeCard {
  @State isSelected: boolean = false
  @State themeColor: string = '#007DFF'

  // 组件内 @Styles 可以通过 this 读取状态
  @Styles cardContainer() {
    .backgroundColor(this.isSelected ? '#E6F2FF' : '#fff')
    .borderRadius(12)
    .padding(16)
    .border(this.isSelected ? { width: 2, color: this.themeColor } : { width: 1, color: '#eee' })
  }

  build() {
    Column() {
      Text('主题卡片')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
    }
    .cardContainer()
    .onClick(() => { this.isSelected = !this.isSelected })
  }
}

注意 :全局 @Styles 不支持传参,也读不到 this。组件内 @Styles 能读 this 但也不能传参。这个设计有些不统一,实际开发中需要根据场景灵活选择。

实战:电商商品列表项

把三个装饰器组合起来,做一个真实的电商商品卡片:

typescript 复制代码
// ========== 样式层 ==========

@Styles function cardBase() {
  .backgroundColor('#fff')
  .borderRadius(12)
  .padding(12)
  .shadow({ radius: 6, color: 'rgba(0,0,0,0.05)' })
}

@Extend(Text) function originalPrice() {
  .fontColor('#ccc')
  .fontSize(12)
  .decoration({ type: TextDecorationType.LineThrough })
}

@Extend(Text) function currentPrice() {
  .fontSize(18)
  .fontWeight(FontWeight.Bold)
  .fontColor('#FF4D4F')
}

// ========== UI 构建层 ==========

interface ProductItem {
  id: string
  title: string
  image: string
  price: number
  originalPrice: number
  sales: number
}

@Builder function ProductCard(item: ProductItem, onTap: (id: string) => void) {
  Row({ space: 12 }) {
    Image(item.image)
      .width(100)
      .height(100)
      .borderRadius(8)
      .objectFit(ImageFit.Cover)
      .backgroundColor('#f5f5f5')

    Column({ space: 6 }) {
      Text(item.title)
        .fontSize(14)
        .fontColor('#333')
        .maxLines(2)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
        .layoutWeight(1)

      Row({ space: 8 }) {
        Text(`¥${item.price.toFixed(2)}`).currentPrice()
        Text(`¥${item.originalPrice.toFixed(2)}`).originalPrice()
      }

      Text(`已售 ${formatSales(item.sales)}`)
        .fontSize(11)
        .fontColor('#999')
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
  }
  .cardBase()
  .width('100%')
  .onClick(() => onTap(item.id))
}

function formatSales(n: number): string {
  if (n >= 10000) return `${(n / 10000).toFixed(1)}万`
  if (n >= 1000) return `${(n / 1000).toFixed(1)}千`
  return String(n)
}

// ========== 页面使用 ==========

@Entry
@Component
struct ProductListPage {
  private products: ProductItem[] = [
    { id: '1', title: '北欧简约陶瓷花瓶 客厅桌面摆件', image: '...', price: 29.9, originalPrice: 59.9, sales: 2340 },
    { id: '2', title: '304不锈钢保温杯 500ml大容量', image: '...', price: 45.0, originalPrice: 89.0, sales: 15600 },
    { id: '3', title: '竹纤维浴巾套装 柔软吸水', image: '...', price: 68.0, originalPrice: 128.0, sales: 890 },
  ]

  build() {
    Column() {
      Text('推荐好物').sectionTitle()

      List({ space: 12 }) {
        ForEach(this.products, (item: ProductItem) => {
          ListItem() {
            ProductCard(item, (id: string) => {
              console.info(`点击商品: ${id}`)
            })
          }
        })
      }
      .width('100%')
      .layoutWeight(1)
    }
    .padding(16)
    .backgroundColor('#f8f8f8')
  }
}

性能注意事项

实际开发中遇到过几个性能坑,总结一下:

1. @Builder 内部避免重度计算

typescript 复制代码
// ❌ 每次 UI 刷新都会重新计算
@Builder function HeavyList(data: string[]) {
  List() {
    ForEach(data.filter(d => d.startsWith('A')), (item: string) => {
      ListItem() { Text(item) }
    })
  }
}

// ✅ 在 build 之前预处理
aboutToAppear() {
  this.filteredData = this.rawData.filter(d => d.startsWith('A'))
}

2. @Styles 大量使用时注意合并

@Styles 每次调用都会创建样式对象。在 ForEach 渲染上千条列表项时,如果每项都链式调用多个 @Styles,会增加 GC 压力。建议对高频列表项使用 @Reusable 装饰器配合缓存。

3. @Extend 不要滥用全局状态

@Extend 内部如果引用了全局变量或 AppStorage,每次状态变化都会触发所有使用该 Extend 的组件刷新。保持 @Extend 纯粹,不依赖外部可变状态。

选型决策流程

实际开发中遇到 "这段代码该提取成什么" 时,按这个判断:

复制代码
需要复用一段 UI?
├─ 只是样式(颜色、圆角、间距)?
│   └─ 是 → @Styles
├─ 是给系统组件加样式?
│   └─ 是 → @Extend
├─ 包含子组件 + 事件 + 逻辑?
│   └─ 是 → @Builder
└─ 跨多个页面/组件使用?
    └─ 是 → 提取为独立 @Component

最后一个容易忽略的点:如果一个 @Builder 已经复杂到超过 50 行,或者被 5 个以上组件引用,考虑把它提升为独立的 @Component。复用是手段,可维护性才是目的。


如果这篇对你有帮助,点个赞再走呗 👍 欢迎收藏备用,评论区告诉我你遇到过哪些坑~

相关推荐
Utopia^4 小时前
Flutter 框架跨平台鸿蒙开发 - 旅行预算管家
flutter·华为·harmonyos
李李李勃谦4 小时前
Flutter 框架跨平台鸿蒙开发 - 星空识别助手
flutter·华为·harmonyos
李李李勃谦4 小时前
Flutter 框架跨平台鸿蒙开发 - 本地生活服务预约
flutter·华为·生活·harmonyos
我的世界洛天依5 小时前
胡桃讲编程:早期华为手机(比如畅享等)可以升级鸿蒙吗?
华为·harmonyos
2301_822703205 小时前
开源鸿蒙跨平台Flutter开发:幼儿疫苗全生命周期追踪系统:基于 Flutter 的免疫接种档案与状态机设计
算法·flutter·华为·开源·harmonyos·鸿蒙
2301_822703205 小时前
鸿蒙flutter三方库实战——教育与学习平台:Flutter Markdown
学习·算法·flutter·华为·harmonyos·鸿蒙
humors2216 小时前
各厂商工具包网址
java·数据库·python·华为·sdk·苹果·工具包
2301_822703208 小时前
开源鸿蒙跨平台Flutter开发:蛋白质序列特征提取:氨基酸组成与理化性质计算
flutter·华为·开源·harmonyos·鸿蒙
钛态8 小时前
Flutter 三方库 ethereum_addresses 的鸿蒙化适配指南 - 掌控区块链地址资产、精密校验治理实战、鸿蒙级 Web3 专家
flutter·harmonyos·鸿蒙·openharmony·ethereum_addresses