自定义组件还写不“香”?为什么不把生命周期、传参与样式隔离一口吃掉!
我是兰瓶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 单元,职责单一(如
IconText、Badge)。 - 复合组件:由多个原子组件组合,形成可配置行为(如
Card、ListPanel)。 - 业务组件:耦合具体领域语义(如
UserCell、OrderCard),不要反向渗透到“通用库”。
建议把通用组件沉到 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 },
}
五、把三个点串成“一件事”:一个“可插槽卡片列表”的完整落地
目标:做一个“可复用卡片列表”组件,具备:
- 生命周期:挂载拉数据,卸载清理;
- 事件与数据传递:父传查询参数,子抛“选择项/重试”事件;
- 样式隔离:列表卡片风格可收口、可替换;
- 可插槽:自定义头部、项、空态、尾部。
// 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插槽把“结构可变”这件事交给使用者,组件不做产品决策——这就是“复用与隔离”的平衡点。
六、工程化补强:让“会用”升级成“好用很久”
- 命名规范:
XxxCard、XxxList、XxxPicker,一眼看出职责; - 只导出必要的:给每个组件单独
index.ets出口,避免外部 import 内部私有路径; - 示例/Story:给组件写“小样例页”,上线前至少走一遍“生命周期→事件→样式替换”的回归;
- 版本与分层:通用组件进 HAR(如
ui-kit),业务组件呆在 HAP 内部,禁止倒灌; - 文档一页纸:每个组件说明“入参、事件、插槽、默认样式点”,新同学 5 分钟上手;
- 不要把副作用放在
build()里:这是我给每个新人第一天就会强调三遍的红线。
七、最后的小抄(Checklist)
- 组件职责一句话能讲清楚
- 初始化/清理写在
aboutToAppear/aboutToDisappear - 父传子用“参数 + 插槽”,子传父用“回调/事件”
- 需要定制结构?
@Builder上 - 样式可复用?抽 Token 或样式函数
- 通用进 HAR,业务留本 HAP
- API 收口(
index.ets导出),外部别碰私有文件 - 给组件留一个 Demo 页面,回归不再靠祈祷
参考与延伸
- 官方“创建自定义组件”“生命周期、状态管理、Builder 等装饰器”与目录总览(含 V1/V2 差异、
@BuilderParam、@LocalBuilder、@Styles等专题)。看目录能迅速对齐团队共识。(华为开发者官网) - OpenHarmony 文档示例:“自定义组件的创建、
@Component说明”等快速入门条目。(GitCode) - 社区文章与实践:
@Builder的使用心得与注意事项(对插槽定制很有帮助,适合结合官方文档一起看)。(华为云社区)
收个尾:组件是“产品乐高”,不是“页面碎石”
当你把生命周期用来管理副作用,把数据与事件管成一条清清楚楚的单向/双向流,再用 @Builder 把可变结构留给使用者,顺手把样式收口,恭喜你:自定义组件不再是“写出来能用”,而是“写出来就好用、长期都好用”。
…
(未完待续)
更多推荐





所有评论(0)