“一个组件干万种活,真不累吗?”——ArkUI 中多态组件的设计模式全攻略
本文介绍鸿蒙ArkUI中实现多态组件的关键方法与设计思路。作者提出"组合优于继承"的理念,通过组合、接口和动态渲染三种方式构建灵活可扩展的组件。文章详细讲解了如何利用@BuilderParam、条件渲染和UI复用技术实现组件的多态性,包括样式变体、插槽内容和行为策略等场景。实战部分展示了一个支持多种样式和插槽的可复用Button组件实现,强调通过接口设计将视觉Token和行为策
我是兰瓶Coding,一枚刚踏入鸿蒙领域的转型小白,原是移动开发中级,如下是我学习笔记《零基础学鸿蒙》,若对你所有帮助,还请不吝啬的给个大大的赞~
前言
先把话挑明了:在 ArkUI 里做“多态组件”(一个组件适配多种外观、行为、甚至结构)并不是耍酷,而是工程化的救命绳。页面越做越复杂、需求一日三变,你要么靠分叉代码“人海战术”顶住,要么在设计层把“组合、接口、动态渲染”三板斧练熟。今天我就按你的大纲,把思路和代码都摊开:组合与继承 → 通用组件接口 → 动态渲染,重点讲 @BuilderParam、条件渲染、UI 复用 怎么玩得漂亮、不翻车。😎
目标读者:已经会写 ArkTS/ArkUI 基本组件,想把组件库做“可塑、可扩、可维护”的你。
设计口号:用组合替代继承,用接口收敛差异,用动态渲染托底变体。
目录(你可以当作学习路线)
- 01|为什么多态组件离不开“组合优先”的世界观
- 02|从“继承”迁到“组合”:三种常见多态需求的落地写法
- 03|通用组件接口:把差异“封箱”,把扩展“留口”
- 04|动态渲染三板斧:条件、映射表、策略注入(@BuilderParam)
- 05|实战:一个“可多态”的 Button & 一个“无头”列表
- 06|性能与可维护性:重建边界、Key 稳定性、解耦副作用
- 07|落坑清单:10 个你会遇到的坑与对策
- 08|心法总结:多态不是变戏法,是按接口造零件
01|为什么多态组件离不开“组合优先”的世界观
先说个残酷现实:ArkUI 的组件不是 React 那种任意 class/函数可以随手“继承”、覆写的生态。ArkTS 里你用 @Component struct 定义组件,生命周期、状态装饰器、UI DSL 都是强语义的。想靠“继承”拼装 UI,多半会越走越窄。
-
继承的常见症状
1)基类塞了一堆“公共逻辑”,子类只改一点样式;
2)变体越多,基类越臃肿,子类越难改;
3)跨层级调样式/行为,耦合越来越紧。 -
组合的天然优势
1)把变体做成可替换的片段(Builder);
2)把差异抽象为接口/策略(回调 + 配置 + slot);
3)把 UI 结构与状态更新解耦,利于独立测试与复用。
一句话:ArkUI 的“多态”,更像“头部 + 插槽 + 策略注入”,而不是 OO 的“父类 + 子类”。
02|从“继承”迁到“组合”:三种常见多态需求的落地写法
场景 A:同一交互,不同视觉(Variant)
- 例:同一个 Button,有
primary / ghost / link三种风格。 - 解法:通用骨架 + 主题 Token + 条件渲染。把差异收敛到“样式 Token 表”,用条件或映射表决定最终 UI。
场景 B:同一结构,不同局部内容(Slot)
- 例:卡片头尾插 icon / 额外信息。
- 解法:@BuilderParam 插槽(render props),让调用方把局部 UI 传进来。
场景 C:同一能力,不同实现(Strategy)
- 例:列表项可能是“可点击 / 可选择 / 可跳转”。
- 解法:把行为抽成策略对象/回调,由外部注入,UI 只关心“触发点”。
03|通用组件接口:把差异“封箱”,把扩展“留口”
多态组件的接口设计,核心是两类能力:
1)确定性配置(枚举/布尔/数字):variant / size / disabled / loading
2)开放式插槽(@BuilderParam):leading / trailing / content / extra
命名建议:视觉相关走
variant/size/shape,行为相关走onClick/onSelect,插槽都以名词形式出现并用@BuilderParam标注。
一个“像模像样”的接口雏形👇
// tokens/ThemeTokens.ets —— 视觉 Token,便于主题化
export type BtnVariant = 'primary' | 'ghost' | 'link'
export type BtnSize = 'sm' | 'md' | 'lg'
export interface ButtonTokens {
padding: { h: number; v: number }
radius: number
fontSize: number
bgColor: string
fgColor: string
borderColor?: string
}
export function pickTokens(variant: BtnVariant, size: BtnSize): ButtonTokens {
const baseBySize: Record<BtnSize, Partial<ButtonTokens>> = {
sm: { padding: { h: 10, v: 6 }, fontSize: 14, radius: 12 },
md: { padding: { h: 14, v: 8 }, fontSize: 16, radius: 14 },
lg: { padding: { h: 18, v: 12 }, fontSize: 18, radius: 16 },
}
const byVar: Record<BtnVariant, Partial<ButtonTokens>> = {
primary: { bgColor: '#165DFF', fgColor: '#FFFFFF' },
ghost: { bgColor: '#FFFFFF', fgColor: '#165DFF', borderColor: '#165DFF' },
link: { bgColor: 'transparent', fgColor: '#165DFF' },
}
const b = baseBySize[size]
const v = byVar[variant]
return {
padding: { h: (b.padding?.h ?? 12), v: (b.padding?.v ?? 8) },
radius: b.radius ?? 12,
fontSize: b.fontSize ?? 16,
bgColor: v.bgColor ?? '#FFFFFF',
fgColor: v.fgColor ?? '#000000',
borderColor: v.borderColor,
}
}
04|动态渲染三板斧:条件、映射表、策略注入
4.1 条件渲染(if / else)
最直觉,适合分支很少的情况;优点是可读性高,缺点是“分支多就嫌重”。
4.2 映射表(Record → Builder)
把变体与渲染函数对应起来,新增变体只加一行。这是 ArkUI 里组织多态最优雅的方式之一。
4.3 策略注入(@BuilderParam)
开放插槽,把“差异型 UI/行为”交给外部调用方提供;组件内部只负责组合与布局。这就是 ArkUI 版的“render props”。
05|实战 1:一个“可多态”的 Button(变体 + 插槽 + 策略)
需求:一个 Button 同时支持
variant/size视觉变体,支持前后插槽(icon/徽标),支持“链接态”与“普通态”,还能加 loading、disabled。
// components/PolyButton.ets
import { BtnVariant, BtnSize, pickTokens } from '../tokens/ThemeTokens'
@Component
export struct PolyButton {
@Prop variant: BtnVariant = 'primary'
@Prop size: BtnSize = 'md'
@Prop disabled: boolean = false
@Prop loading: boolean = false
@Prop fullWidth: boolean = false
// 行为策略:留给外部决定
onClick?: () => void
// 插槽(可选)
@BuilderParam leading?: () => void
@BuilderParam trailing?: () => void
@BuilderParam content?: () => void // 若不提供,默认用 label
@Prop label?: string // 简化用法:不传 content 就显示 label
private renderInner() {
const t = pickTokens(this.variant, this.size)
Row() {
if (this.leading) { this.leading!() }
if (this.loading) {
// 简化的 loading 视图
Text('…').fontSize(t.fontSize).opacity(0.7).margin({ right: 6 })
}
if (this.content) {
this.content!()
} else {
Text(this.label ?? 'Button')
.fontSize(t.fontSize)
.fontWeight(FontWeight.Medium)
.fontColor(t.fgColor)
}
if (this.trailing) { this.trailing!() }
}
.justifyContent(FlexAlign.Center)
.alignItems(VerticalAlign.Center)
.padding({ left: t.padding.h, right: t.padding.h, top: t.padding.v, bottom: t.padding.v })
.borderRadius(t.radius)
.backgroundColor(t.bgColor)
.border({ color: t.borderColor ?? 'transparent', width: t.borderColor ? 1 : 0 })
.width(this.fullWidth ? '100%' : undefined)
.opacity(this.disabled ? 0.5 : 1)
}
build() {
// “链接态”可以直接渲染成文本风格,点击行为保留
if (this.variant === 'link') {
const t = pickTokens('link', this.size)
Text(this.label ?? '')
.fontSize(t.fontSize)
.fontColor(t.fgColor)
.decoration({ type: TextDecorationType.None })
.onClick(() => {
if (!this.disabled && !this.loading) this.onClick?.()
})
} else {
// 普通按钮骨架
this.renderInner()
.onClick(() => {
if (!this.disabled && !this.loading) this.onClick?.()
})
.hoverEffect(HoverEffect.ScaleSmall)
}
}
}
使用示例
// pages/DemoButtons.ets
import { PolyButton } from '../components/PolyButton'
@Entry
@Component
struct DemoButtons {
@State clicked: number = 0
build() {
Column() {
Text(`clicked: ${this.clicked}`).margin({ bottom: 10 })
// 基础用法
PolyButton({ label: 'Primary', onClick: () => this.clicked++ })
// Ghost + leading 插槽
PolyButton({
variant: 'ghost',
label: 'With Icon',
onClick: () => this.clicked++,
leading: () => { Text('★').margin({ right: 6 }) }
}).margin({ top: 8 })
// Link 变体
PolyButton({
variant: 'link',
label: 'Open Link',
onClick: () => this.clicked++
}).margin({ top: 8 })
// 自定义 content(完全覆盖 label)
PolyButton({
variant: 'primary',
onClick: () => this.clicked++,
content: () => {
Row() {
Text('Buy').margin({ right: 6 })
Text('$9.9').opacity(0.8)
}
}
}).margin({ top: 8 })
}.padding(16)
}
}
为什么这算“多态”?
- 同一组件承担不同视觉与结构:link 变体是文本,primary/ghost 是卡片外观;
- 插槽(@BuilderParam) 把“不同局部 UI”交给调用方;
- 策略(onClick) 把行为交给外部,内部不关心跳转/弹窗/埋点的细节。
05|实战 2:“无头”列表(Headless List)——把渲染权交给调用方
需求:做一个通用
ListBox,它不决定长什么样,只负责滚动、空态、分隔等“框架”,把“怎么渲染每个项”的权力交给外部。
// components/HeadlessList.ets
export interface KeySelector<T = any> { (item: T, index: number): string }
export interface ItemClick<T = any> { (item: T, index: number): void }
@Component
export struct HeadlessList {
@Prop items: any[] = []
@Prop keyOf: KeySelector = (it, i) => (it?.id ?? `i_${i}`)
@Prop onItemClick?: ItemClick
// 可选插槽
@BuilderParam renderItem!: (item?: any, index?: number) => void
@BuilderParam renderEmpty?: () => void
@BuilderParam renderSeparator?: () => void
@Prop padding: number = 8
build() {
if (!this.items || this.items.length === 0) {
if (this.renderEmpty) {
this.renderEmpty!()
} else {
// 默认空态
Column() {
Text('暂无数据').opacity(0.6)
}.padding(this.padding)
}
return
}
List() {
ForEach(this.items, (it: any, idx: number) => {
ListItem() {
Column() {
// 由外部决定单项如何渲染
this.renderItem!(it, idx)
// 分隔
if (this.renderSeparator && idx < this.items.length - 1) {
this.renderSeparator!()
}
}.onClick(() => this.onItemClick?.(it, idx))
}
}, (it: any, i: number) => this.keyOf(it, i))
}.padding(this.padding)
}
}
使用示例:两种完全不同的“多态外观”
// pages/DemoHeadlessList.ets
import { HeadlessList } from '../components/HeadlessList'
@Entry
@Component
struct DemoHeadlessList {
@State books: any[] = [
{ id: 'b1', title: 'ArkUI 进阶', author: 'Neo' },
{ id: 'b2', title: '分布式开发手记', author: 'Amy' },
]
@State chips: any[] = [
{ id: 'c1', label: 'ArkTS' },
{ id: 'c2', label: 'UI DSL' },
{ id: 'c3', label: 'BuilderParam' },
]
build() {
Column() {
Text('列表:图文卡片风').fontSize(18).margin({ bottom: 8 })
HeadlessList({
items: this.books,
renderItem: (it?: any) => {
Row() {
Column() {
Text(it.title).fontSize(16).fontWeight(FontWeight.Medium)
Text(it.author).opacity(0.6)
}
.layoutPriority(1)
Text('›').opacity(0.3)
}.padding(12).backgroundColor('#fff').borderRadius(12)
},
renderSeparator: () => {
Blank(6)
},
onItemClick: (it?: any) => {
console.info('click book: ' + it.title)
}
})
Blank(16)
Text('列表:标签云风').fontSize(18).margin({ bottom: 8 })
HeadlessList({
items: this.chips,
keyOf: (it: any) => it.id,
renderItem: (it?: any) => {
// 让“同一个列表框架”渲染出“芯片风”
Row() {
Text(it.label).fontColor('#165DFF')
}
.padding(8)
.border({ color: '#165DFF', width: 1 })
.borderRadius(16)
},
renderSeparator: () => Blank(8)
})
}.padding(16).backgroundColor('#F6F7FB')
}
}
这就是 ArkUI 的“无头组件”套路:框架内聚、渲染外放、行为插拔。新增外观?写新的 renderItem 即可;框架层几乎不用动。
06|性能与可维护性:重建边界、Key 稳定性、解耦副作用
-
重建边界:
- 影响 UI 的字段用
@State/@Prop/@Link; - 把大型子树拆成子组件或
@Builder,降低一次性重建范围; - 计算量大的数据外提到方法或
@Watch,别在build()里现算。
- 影响 UI 的字段用
-
Key 稳定性(ForEach 的灵魂):
- 用业务 ID 做 key:
(it) => it.id; - key 变了 = UI 节点被当作新建,滚动位置/动画/状态全丢。
- 用业务 ID 做 key:
-
副作用解耦:
build()只描述 UI;- 数据拉取、节流/防抖放在
aboutToAppear/@Watch; - 别在
build()里console或网络请求,防止重建→副作用连锁。
-
条件渲染的取舍:
- 简单分支用
if/else; - 分支多且“新增变体频繁”的,改用映射表 + 默认分支,可维护性更强。
- 简单分支用
07|落坑清单:10 个你会遇到的坑与对策(都是真坑)
1)@BuilderParam 未判空 → 直接调用崩:先判 if (this.slot) this.slot!()
2)ForEach key 用下标 → 复用失败:一律用稳定业务 ID
3)把 loading/disabled 当样式写 → 行为漏洞:在 onClick 里先判断,UI 也要降不透明度
4)变体分支写在多个组件里 → 维护散:集中在“主题 Token + 映射表”
5)把异步放 build() → 抖动 + 冗余请求:丢到生命周期或 @Watch
6)把大 Slot 当“万能垃圾桶” → 难以测试:Slot 专注“局部 UI”,行为走回调
7)用 Link 双向绑定传一切 → 数据流混乱:表单局部用 Link,其他尽量单向
8)装饰器忘了 → UI 不刷新:字段若驱动 UI,务必 @State/@Prop/@Link
9)无意间改变引用 → 误触重建:大对象拆分成原子状态/不可变更新
10)把列表内业务写死在框架 → 无法多态:做成无头组件,渲染用 @BuilderParam
08|心法总结:多态不是变戏法,是按接口造零件
- 组合胜于继承:骨架 + Token + Slot,三件套吃遍大多数需求;
- 接口收敛差异:视觉走枚举 + Token,行为走回调/策略,结构走 Slot;
- 动态渲染优先级:
1)if/else(分支少);
2)映射表(变体多/可扩展);
3)策略注入(把差异交给调用方)。 - @BuilderParam 是 ArkUI 的“王牌”:把“可变部分”变成函数参数,既不牺牲类型安全,又让复用变轻。
附:三段可抄走的“多态骨架”
A. 映射表版变体渲染
@Builder
function PrimaryView(label: string) {
Text(label).fontColor('#fff').backgroundColor('#165DFF').padding(10).borderRadius(12)
}
@Builder
function GhostView(label: string) {
Text(label).fontColor('#165DFF').border({ color: '#165DFF', width: 1 }).padding(10).borderRadius(12)
}
const BTN_MAP: Record<'primary'|'ghost', (label: string) => void> = {
primary: (label: string) => PrimaryView(label),
ghost: (label: string) => GhostView(label),
}
@Component
struct MapButton {
@Prop variant: 'primary'|'ghost' = 'primary'
@Prop label: string = 'OK'
build() {
BTN_MAP[this.variant](this.label)
}
}
B. 策略注入(行为外放)
@Component
struct ActionCard {
onPrimary?: () => void
onSecondary?: () => void
@Prop title: string = 'Title'
@Prop desc: string = 'Desc'
build() {
Column() {
Text(this.title).fontSize(18).fontWeight(FontWeight.Medium)
Text(this.desc).opacity(0.7).margin({ bottom: 8 })
Row() {
PolyButton({ label: 'OK', onClick: () => this.onPrimary?.() })
Blank(8)
PolyButton({ variant: 'ghost', label: 'Cancel', onClick: () => this.onSecondary?.() })
}
}.padding(12).backgroundColor('#fff').borderRadius(12)
}
}
C. 无头表单行(Slot 承载差异)
@Component
struct FormRow {
@Prop label: string = ''
@BuilderParam control!: () => void // 输入控件 Slot
@BuilderParam helpText?: () => void
build() {
Column() {
Text(this.label).opacity(0.8).margin({ bottom: 6 })
this.control!()
if (this.helpText) {
this.helpText!().opacity(0.6).margin({ top: 4 })
}
}
.padding(10).backgroundColor('#fff').borderRadius(10)
}
}
使用:
FormRow({
label: '用户名',
control: () => {
TextInput({ text: '', placeholder: '请输入' })
},
helpText: () => Text('支持字母数字,下划线')
})
最后一句“下战书”
如果你正打算搭一套可复用的 ArkUI 组件库,就从今天起给每个复杂组件想清楚三件事:我能把它拆成“骨架 + Token + Slot”吗?我能用“映射表”而不是 if/else 把变体托管起来吗?哪些地方必须给 @BuilderParam 留接口?
…
(未完待续)
更多推荐





所有评论(0)