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。复用是手段,可维护性才是目的。
如果这篇对你有帮助,点个赞再走呗 👍 欢迎收藏备用,评论区告诉我你遇到过哪些坑~