我是兰瓶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() 里现算。
  • Key 稳定性(ForEach 的灵魂):

    • 用业务 ID 做 key:(it) => it.id
    • key 变了 = UI 节点被当作新建,滚动位置/动画/状态全丢。
  • 副作用解耦

    • 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 留接口?

(未完待续)

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐