我是兰瓶Coding,一枚刚踏入鸿蒙领域的转型小白,原是移动开发中级,如下是我学习笔记《零基础学鸿蒙》,若对你所有帮助,还请不吝啬的给个大大的赞~

前言

直说吧:在 ArkTS 里,组件写得好不好,直接决定了你的项目后期是“丝滑复用”,还是“屎山返工”。很多同学做页面时能“堆出来”,可一到“自定义组件设计与封装”就含糊其辞:生命周期到底怎么用?父子怎么优雅地传数据、抛事件?样式怎么复用还不污染别人?
  今天就按你给的大纲来一盘硬菜:组件生命周期 → 事件与数据传递 → 复用与样式隔离,结合 ArkUI 的 @Component / @Builder 以及“自定义组件(Custom Component)”最佳实践,从“能跑”到“好维护”,不拐弯抹角。要是文中偶尔带点嘴碎与感叹号,纯属作者本人对“写干净组件”的执念,见谅见谅😎。
(本文示例基于 HarmonyOS NEXT 的 ArkTS/ArkUI 语法体系;概念与 API 以官方指引为准。参考文档我都贴在段尾,方便你对照深挖。(华为开发者官网))

一、从“自定义组件”说起:什么算 Custom Component?

ArkUI 里所有可组合的 UI 单元,本质都是组件。系统内置那一大票(Text/Button/List……)叫系统组件;@Component 装饰的 struct,就是我们说的自定义组件(Custom Component)。它有自己独立的状态、参数、生命周期——这可比“写个函数 return 一棵树”要正式得多。官方把“创建自定义组件、页面与自定义组件的生命周期、成员访问修饰符约束”等都明确成了规范,强烈建议先浏览一遍目录,心里有谱。(华为开发者官网)

一句话版:@Component struct X { build() { … } } 是家门口的招牌;有招牌的,才是“自定义组件”。

二、组件生命周期:不是背 API,而是让“时机”服务于“职责”

常用回调与定位:

  • aboutToAppear():组件即将渲染到树上,做一次性初始化(读取入参、订阅数据源)。
  • aboutToDisappear():组件即将从树上移除,清理副作用(取消订阅、停止计时器)。
  • 页面级还有 onPageShow / onPageHide(窗口可见性变化),自定义组件通常用不到,但你在页面壳里可以接它们分发下去。(华为开发者官网)

一个“星级评分”组件示例(带生命周期日志):

// Rating.ets —— 自定义评分组件
@Component
export default struct Rating {
  // 可输入参数(父传子)
  stars: number = 5
  value: number = 0
  // 向父组件“抛事件”的回调(V1 常用姿势)
  onChange?: (v: number) => void

  private timers: number[] = []

  aboutToAppear() {
    // 一次性初始化:比如读取本地配置、开一个弱动画计时器
    console.log('[Rating] appear with', this.stars, this.value)
  }

  aboutToDisappear() {
    // 清理副作用
    this.timers.forEach(t => clearInterval(t))
    console.log('[Rating] disappear')
  }

  build() {
    Row({ space: 6 }) {
      ForEach(new Array(this.stars).fill(0), (_, i: number) => {
        // 简化:用文本符号代替图标
        Text(i < this.value ? '★' : '☆')
          .fontSize(22)
          .onClick(() => this.set(i + 1))
      }, (_: number, idx: number) => idx + '')
    }
  }

  private set(v: number) {
    if (v === this.value) v = v - 1 // 再点一次减一星的小交互
    this.value = Math.max(0, Math.min(this.stars, v))
    this.onChange?.(this.value)
  }
}

诀窍:初始化在 aboutToAppear,清理在 aboutToDisappear;不要把“副作用”丢在 build() 或频繁触发的响应里,性能与可预期性都会走下坡路。关于“自定义组件生命周期”与“页面/组件差异”,官方指引有清晰章节。(华为开发者官网)

三、事件与数据传递:一图胜千言的“数据流谱”

父 → 子的数据输入样式/行为参数,子 → 父的事件回传状态同步,再加上“跨层级状态共享”,就构成了一条顺畅的数据管道。

3.1 父 → 子:入参与单/双向绑定

  • 单向:把值作为参数传给子组件(写死或响应式计算皆可)。
  • 双向(可选):用 @Link(V1)或 @Param + @Event(V2)建立同步关系;不需要双向时别滥用
  • 自定义构建函数 @Builder:把一段“插槽内容”安全地传给组件,灵活又不破坏封装。官方在“@Builder@BuilderParam@LocalBuilder”处展示了完整语法。(华为开发者官网)
// 父组件使用 Rating:单向入参 + 事件回传
@Component
struct RatingDemo {
  @State score: number = 3

  build() {
    Column({ space: 12 }) {
      Rating({ stars: 5, value: this.score, onChange: (v) => this.score = v })
      Text(`Current: ${this.score}`)
    }.padding(16)
  }
}

3.2 子 → 父:事件回传两种常见写法

写法 A(通用、版本无关):把回调当作普通函数属性传入,子里 this.onChange?.(v) 回调即可——上面已经用过。
写法 B(V2 装饰器风格):@Event 声明输出事件,调用时像发射信号一样清晰;适合规范化组件库。(事件装饰器归于 V2 专属,文档目录有标注,团队要注意版本一致性)(华为开发者官网)

// V2 风格(伪示意):@Event 装饰器声明输出
@Component
export default struct Toggle {
  // @Param 输入(V2 语义)
  checked: boolean = false
  // @Event 输出
  onToggle: (v: boolean) => void = () => {}

  build() {
    Row() {
      Text(this.checked ? 'ON' : 'OFF')
        .onClick(() => {
          const nv = !this.checked
          this.checked = nv
          this.onToggle(nv) // 发射事件
        })
    }
  }
}

小提示:V1 与 V2 混用是允许的,但要有团队约定。官方文档明确了 V1/V2 的装饰器差异与迁移指南,别“你写 V2 我写 V1”各自开花。(华为开发者官网)

3.3 @Builder:给组件塞一块“插槽”,灵活不耦合

当你需要让父组件定制“局部渲染”时,就该用 @Builder 自定义构建函数或 @BuilderParam 参数。常见场景:自定义头部、尾部、空态、列表项渲染。(华为开发者官网)

// ListPanel.ets —— 支持自定义 header/empty 渲染
@Component
export default struct ListPanel<T> {
  items: T[] = []

  @BuilderParam header?: () => void
  @BuilderParam empty?: () => void
  @BuilderParam item?: (it: T, idx: number) => void

  build() {
    Column({ space: 8 }) {
      // 头部插槽
      if (this.header) this.header!()

      if (this.items.length === 0) {
        // 空态插槽
        if (this.empty) this.empty!()
        else Text('No data')
        return
      }

      // 项渲染插槽
      ForEach(this.items, (it: T, i: number) => {
        if (this.item) this.item!(it, i)
        else Text(JSON.stringify(it))
      }, (_: T, i: number) => i + '')
    }
  }
}
// 使用处
@Component
struct PanelDemo {
  data: string[] = ['ArkTS', 'ArkUI', 'OpenHarmony']

  @Builder headerView() {
    Text('💡 Tech Stack').fontSize(18).fontWeight(FontWeight.Bolder).margin({ bottom: 6 })
  }
  @Builder emptyView() {
    Text('空空如也,来一条?')
  }
  @Builder itemView(s: string, i: number) {
    Row() { Text(`${i + 1}. ${s}`) }.padding(8).backgroundColor('#161B22').borderRadius(12)
  }

  build() {
    ListPanel<string>({ items: this.data, header: this.headerView, empty: this.emptyView, item: this.itemView })
      .padding(16)
  }
}

@Builder 带来的“插槽式可定制”,能极大降低“复制粘贴组件 + 手搓 if/else”的欲望,还能把“样式与结构”隔离出来,既复用,又不丢设计自由度。官方文档对 @Builder@BuilderParam@LocalBuilder 的用法与注意事项写得很细,建议对照阅读。(华为开发者官网)

四、复用与样式隔离:组件库不是“堆文件”,而是“定边界”

4.1 封装层级的“三明治法”

  • 原子组件:最小 UI 单元,职责单一(如 IconTextBadge)。
  • 复合组件:由多个原子组件组合,形成可配置行为(如 CardListPanel)。
  • 业务组件:耦合具体领域语义(如 UserCellOrderCard),不要反向渗透到“通用库”。

建议把通用组件沉到 HAR 包(例如 ui-kit),业务组件放在各自 HAP 内部。这套结构在官方指南与社区实践中都被反复证明是“长久可养”的。(华为开发者官网)

4.2 样式隔离:不靠“选择器黑魔法”,靠边界与复用工具

ArkUI 不是 CSS 世界,但样式仍可复用且天然“局部作用”

  • 组件内部的修饰只影响其子树,不会“外溢”到同级或祖先;
  • 使用 @Builder 把可变样式/结构当作插槽传入,组件不做决策,仅承载位点
  • 需要跨组件复用一组风格时,用 @Styles 或“样式工厂函数”封装为可重用片段(不同版本命名略有差异,参考官方“可复用样式与扩展”章节)。(华为开发者官网)
// 可复用样式(函数化更易控)
function CardStyle() {
  return {
    padding: 12,
    backgroundColor: '#161B22',
    borderRadius: 16
  }
}

@Component
export default struct InfoCard {
  title: string = ''
  @BuilderParam extra?: () => void

  build() {
    Column() {
      Text(this.title).fontSize(18).fontWeight(FontWeight.Bolder).margin({ bottom: 8 })
      if (this.extra) this.extra!()
    }
    .apply(CardStyle()) // 把样式收口为一处,便于统一替换
  }
}

风格收口(一个函数/装饰器/样式片段定义处)+ 结构插槽@Builder)= 风格自由 + 作用域明确。这比到处 copy 一堆 .padding(12) 靠谱多了。

4.3 图标/主题/尺寸:把“可变项”抽成 Token

把颜色、字号、圆角、阴影抽成 Theme Token(常量/配置函数),组件只消费 Token,不要“硬编码魔法数”。
一处改 Token,全局统一 —— 这就是“样式隔离”的终极秘籍。

// theme.ts
export const Theme = {
  color: {
    bg: '#0C1117',
    card: '#161B22',
    text: '#E6EDF3',
    sub: '#9DA3AE',
    primary: '#238636',
  },
  radius: { md: 12, lg: 16 },
  space: { sm: 8, md: 12, lg: 16 },
}

五、把三个点串成“一件事”:一个“可插槽卡片列表”的完整落地

目标:做一个“可复用卡片列表”组件,具备:

  1. 生命周期:挂载拉数据,卸载清理;
  2. 事件与数据传递:父传查询参数,子抛“选择项/重试”事件;
  3. 样式隔离:列表卡片风格可收口、可替换;
  4. 可插槽:自定义头部、项、空态、尾部。
// CardList.ets —— 复用组件
@Component
export default struct CardList<T> {
  // 入参:查询关键词、尺寸、异步拉取函数
  keyword: string = ''
  fetcher: (kw: string) => Promise<T[]> = async () => []
  // 事件:项被选中、重试
  onSelect?: (it: T, idx: number) => void
  onRetry?: () => void

  // 插槽:头/尾/空/项
  @BuilderParam header?: () => void
  @BuilderParam footer?: () => void
  @BuilderParam empty?: () => void
  @BuilderParam item?: (it: T, i: number) => void

  // 组件内部状态
  @State list: T[] = []
  @State loading: boolean = false
  @State err: string = ''

  aboutToAppear() {
    this.load(this.keyword)
  }

  aboutToDisappear() {
    // 如果有轮询或订阅,这里统一清理
  }

  async load(kw: string) {
    this.loading = true; this.err = ''
    try {
      this.list = await this.fetcher(kw)
    } catch (e) {
      this.err = 'Load failed'
    } finally {
      this.loading = false
    }
  }

  build() {
    Column({ space: 10 }) {
      if (this.header) this.header!()

      if (this.loading) {
        Text('Loading...').fontColor('#9DA3AE')
      } else if (this.err) {
        Row({ space: 8 }) {
          Text(this.err).fontColor('#C9510C')
          Button('Retry').onClick(() => { this.onRetry?.(); this.load(this.keyword) })
        }
      } else if (this.list.length === 0) {
        if (this.empty) this.empty!(); else Text('No data')
      } else {
        ForEach(this.list, (it: T, i: number) => {
          Column() {
            if (this.item) this.item!(it, i)
            else Text(JSON.stringify(it))
          }
          .padding(12).backgroundColor('#161B22').borderRadius(16)
          .onClick(() => this.onSelect?.(it, i))
        }, (_: T, i: number) => i + '')
      }

      if (this.footer) this.footer!()
    }
  }
}
// 使用处:把数据/事件/样式/插槽都从外面喂给它
@Component
struct CardListDemo {
  @State kw: string = ''
  @State toast: string = ''

  private async fetchUsers(kw: string) {
    // 这里可换成真实 http:ohos.net.http
    const raw = ['Ace', 'Bob', 'Cindy', 'Duke', 'Elly'].filter(x => x.toLowerCase().includes(kw.toLowerCase()))
    return raw.map((name, idx) => ({ id: idx + 1, name }))
  }

  @Builder headerView() {
    Row({ space: 8 }) {
      Text('👤 Users').fontSize(18).fontWeight(FontWeight.Bolder)
      Blank()
      TextInput({ placeholder: 'Search...' }).onChange(t => this.kw = t)
      Button('Go').onClick(()=> this.kw = this.kw) // 简易触发
    }
  }

  @Builder itemView(u: { id:number; name:string }, i: number) {
    Row({ space: 8 }) {
      Text(`${u.id}.`).fontColor('#9DA3AE')
      Text(u.name).fontWeight(FontWeight.Medium)
      Blank()
      Button('Invite').onClick(() => this.toast = `Invited ${u.name}`)
    }
  }

  @Builder emptyView() {
    Text('没有匹配的用户,换个关键词?').fontColor('#9DA3AE')
  }

  build() {
    Column({ space: 12 }) {
      if (this.toast) Text(this.toast).fontColor('#238636')

      CardList<{id:number;name:string}>({
        keyword: this.kw,
        fetcher: this.fetchUsers.bind(this),
        onSelect: (u) => this.toast = `Selected ${u.name}`,
        onRetry: () => this.toast = 'Retrying...',
        header: this.headerView,
        empty: this.emptyView,
        item: this.itemView
      })
    }.padding(16).backgroundColor('#0C1117').height('100%')
  }
}

你能看到:

  • 生命周期统一管“数据加载/清理”;
  • 数据和事件都从外面输入,组件保持可测试性;
  • 样式局部作用,想换风格只需改 itemView 或收口的样式函数;
  • @Builder 插槽把“结构可变”这件事交给使用者,组件不做产品决策——这就是“复用与隔离”的平衡点。

六、工程化补强:让“会用”升级成“好用很久”

  1. 命名规范XxxCardXxxListXxxPicker,一眼看出职责;
  2. 只导出必要的:给每个组件单独 index.ets 出口,避免外部 import 内部私有路径;
  3. 示例/Story:给组件写“小样例页”,上线前至少走一遍“生命周期→事件→样式替换”的回归;
  4. 版本与分层:通用组件进 HAR(如 ui-kit),业务组件呆在 HAP 内部,禁止倒灌
  5. 文档一页纸:每个组件说明“入参、事件、插槽、默认样式点”,新同学 5 分钟上手;
  6. 不要把副作用放在 build():这是我给每个新人第一天就会强调三遍的红线。

七、最后的小抄(Checklist)

  • 组件职责一句话能讲清楚
  • 初始化/清理写在 aboutToAppear/aboutToDisappear
  • 父传子用“参数 + 插槽”,子传父用“回调/事件”
  • 需要定制结构?@Builder
  • 样式可复用?抽 Token 或样式函数
  • 通用进 HAR,业务留本 HAP
  • API 收口(index.ets 导出),外部别碰私有文件
  • 给组件留一个 Demo 页面,回归不再靠祈祷

参考与延伸

  • 官方“创建自定义组件”“生命周期、状态管理、Builder 等装饰器”与目录总览(含 V1/V2 差异、@BuilderParam@LocalBuilder@Styles 等专题)。看目录能迅速对齐团队共识。(华为开发者官网)
  • OpenHarmony 文档示例:“自定义组件的创建、@Component 说明”等快速入门条目。(GitCode)
  • 社区文章与实践:@Builder 的使用心得与注意事项(对插槽定制很有帮助,适合结合官方文档一起看)。(华为云社区)

收个尾:组件是“产品乐高”,不是“页面碎石”

当你把生命周期用来管理副作用,把数据与事件管成一条清清楚楚的单向/双向流,再用 @Builder可变结构留给使用者,顺手把样式收口,恭喜你:自定义组件不再是“写出来能用”,而是“写出来就好用、长期都好用”

(未完待续)

Logo

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

更多推荐